Merge branch 'yongpxu-dev' into yongpxu-dev4
This commit is contained in:
3
Cunkebao/.gitignore
vendored
3
Cunkebao/.gitignore
vendored
@@ -3,4 +3,5 @@ dist/
|
||||
build/
|
||||
yarn.lock
|
||||
.env
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
dist/*
|
||||
|
||||
36
Cunkebao/dist/.vite/manifest.json
vendored
36
Cunkebao/dist/.vite/manifest.json
vendored
@@ -1,50 +1,50 @@
|
||||
{
|
||||
"_charts-aNYyX7D2.js": {
|
||||
"file": "assets/charts-aNYyX7D2.js",
|
||||
"_charts-B449e2xS.js": {
|
||||
"file": "assets/charts-B449e2xS.js",
|
||||
"name": "charts",
|
||||
"imports": [
|
||||
"_ui-DZwp85UP.js",
|
||||
"_vendor-Bq99rrm8.js"
|
||||
"_ui-DDu9FCjt.js",
|
||||
"_vendor-0WYR1k4q.js"
|
||||
]
|
||||
},
|
||||
"_ui-D0C0OGrH.css": {
|
||||
"file": "assets/ui-D0C0OGrH.css",
|
||||
"src": "_ui-D0C0OGrH.css"
|
||||
},
|
||||
"_ui-DZwp85UP.js": {
|
||||
"file": "assets/ui-DZwp85UP.js",
|
||||
"_ui-DDu9FCjt.js": {
|
||||
"file": "assets/ui-DDu9FCjt.js",
|
||||
"name": "ui",
|
||||
"imports": [
|
||||
"_vendor-Bq99rrm8.js"
|
||||
"_vendor-0WYR1k4q.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/ui-D0C0OGrH.css"
|
||||
]
|
||||
},
|
||||
"_utils-Ft3ushmX.js": {
|
||||
"file": "assets/utils-Ft3ushmX.js",
|
||||
"_utils-DC06x9DY.js": {
|
||||
"file": "assets/utils-DC06x9DY.js",
|
||||
"name": "utils",
|
||||
"imports": [
|
||||
"_vendor-Bq99rrm8.js"
|
||||
"_vendor-0WYR1k4q.js"
|
||||
]
|
||||
},
|
||||
"_vendor-Bq99rrm8.js": {
|
||||
"file": "assets/vendor-Bq99rrm8.js",
|
||||
"_vendor-0WYR1k4q.js": {
|
||||
"file": "assets/vendor-0WYR1k4q.js",
|
||||
"name": "vendor"
|
||||
},
|
||||
"index.html": {
|
||||
"file": "assets/index-CCIZs36L.js",
|
||||
"file": "assets/index-DzNmnMYg.js",
|
||||
"name": "index",
|
||||
"src": "index.html",
|
||||
"isEntry": true,
|
||||
"imports": [
|
||||
"_vendor-Bq99rrm8.js",
|
||||
"_ui-DZwp85UP.js",
|
||||
"_utils-Ft3ushmX.js",
|
||||
"_charts-aNYyX7D2.js"
|
||||
"_vendor-0WYR1k4q.js",
|
||||
"_ui-DDu9FCjt.js",
|
||||
"_utils-DC06x9DY.js",
|
||||
"_charts-B449e2xS.js"
|
||||
],
|
||||
"css": [
|
||||
"assets/index-DRrzDMi4.css"
|
||||
"assets/index-QrS4Cvyc.css"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
Cunkebao/dist/index.html
vendored
12
Cunkebao/dist/index.html
vendored
@@ -11,13 +11,13 @@
|
||||
</style>
|
||||
<!-- 引入 uni-app web-view SDK(必须) -->
|
||||
<script type="text/javascript" src="/websdk.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CCIZs36L.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-Bq99rrm8.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-DZwp85UP.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-Ft3ushmX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-aNYyX7D2.js">
|
||||
<script type="module" crossorigin src="/assets/index-DzNmnMYg.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-0WYR1k4q.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-DDu9FCjt.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-DC06x9DY.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-B449e2xS.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/ui-D0C0OGrH.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DRrzDMi4.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-QrS4Cvyc.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
||||
import { Form, Input, Button, Toast, Checkbox } from "antd-mobile";
|
||||
import {
|
||||
EyeInvisibleOutline,
|
||||
@@ -7,7 +6,6 @@ import {
|
||||
UserOutline,
|
||||
} from "antd-mobile-icons";
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
|
||||
import { loginWithPassword, loginWithCode, sendVerificationCode } from "./api";
|
||||
import style from "./login.module.scss";
|
||||
@@ -19,7 +17,6 @@ const Login: React.FC = () => {
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [agreeToTerms, setAgreeToTerms] = useState(false);
|
||||
const { setUserInfo } = useCkChatStore.getState();
|
||||
const { login, login2 } = useUserStore();
|
||||
|
||||
// 倒计时效果
|
||||
@@ -75,13 +72,9 @@ const Login: React.FC = () => {
|
||||
: loginWithCode(loginParams);
|
||||
|
||||
response.then(res => {
|
||||
const { member, kefuData, deviceTotal } = res;
|
||||
const { member, deviceTotal } = res;
|
||||
// 清空WebSocket连接状态
|
||||
useWebSocketStore.getState().clearConnectionState();
|
||||
login(res.token, member, deviceTotal);
|
||||
const { self, token } = kefuData;
|
||||
login2(token.access_token);
|
||||
setUserInfo(self);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -291,7 +291,10 @@ const Devices: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域:头像和详细信息 */}
|
||||
<div className={styles.mainContent}>
|
||||
<div
|
||||
className={styles.mainContent}
|
||||
onClick={() => goDetail(device.id!)}
|
||||
>
|
||||
{/* 头像 */}
|
||||
<div className={styles.avatar}>
|
||||
{device.avatar ? (
|
||||
@@ -339,12 +342,6 @@ const Devices: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 箭头图标 */}
|
||||
<RightOutlined
|
||||
className={styles.arrowIcon}
|
||||
onClick={() => goDetail(device.id!)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -84,16 +84,6 @@ const Mine: React.FC = () => {
|
||||
bgColor: "#fff7e6",
|
||||
iconColor: "#fa8c16",
|
||||
},
|
||||
{
|
||||
id: "ckb",
|
||||
title: "触客宝",
|
||||
description: "触客宝",
|
||||
icon: <PhoneOutlined />,
|
||||
count: 0,
|
||||
path: "/ckbox/weChat",
|
||||
bgColor: "#fff7e6",
|
||||
iconColor: "#fa8c16",
|
||||
},
|
||||
];
|
||||
|
||||
// 加载统计数据
|
||||
|
||||
@@ -2,7 +2,7 @@ import request from "@/api/request";
|
||||
import type { UserTagsResponse } from "./data";
|
||||
|
||||
export function getTrafficPoolDetail(wechatId: string) {
|
||||
return request("/v1/wechats/getWechatInfo", { wechatId }, "GET");
|
||||
return request("/v1/traffic/pool/getUserInfo", { wechatId }, "GET");
|
||||
}
|
||||
|
||||
// 获取用户旅程记录
|
||||
|
||||
@@ -1,39 +1,64 @@
|
||||
// 设备信息类型
|
||||
export interface DeviceInfo {
|
||||
id: number;
|
||||
memo: string;
|
||||
imei: string;
|
||||
brand: string;
|
||||
alive: number;
|
||||
address: string;
|
||||
}
|
||||
|
||||
// 来源信息类型
|
||||
export interface SourceInfo {
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
gender: number;
|
||||
phone: string;
|
||||
wechatId: string;
|
||||
alias: string;
|
||||
createTime: string;
|
||||
friendId: number;
|
||||
wechatAccountId: number;
|
||||
lastMsgTime: string;
|
||||
device: DeviceInfo;
|
||||
}
|
||||
|
||||
// 统计总计类型
|
||||
export interface TotalStats {
|
||||
msg: number;
|
||||
money: number;
|
||||
isFriend: boolean;
|
||||
percentage: string;
|
||||
}
|
||||
|
||||
// RMM评分类型
|
||||
export interface RmmScore {
|
||||
r: number;
|
||||
f: number;
|
||||
m: number;
|
||||
}
|
||||
|
||||
// 用户详情类型
|
||||
export interface TrafficPoolUserDetail {
|
||||
id: number;
|
||||
identifier: string;
|
||||
wechatId: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
wechatId: string;
|
||||
status: number | string;
|
||||
addTime: string;
|
||||
lastInteraction: string;
|
||||
deviceName?: string;
|
||||
wechatAccountName?: string;
|
||||
customerServiceName?: string;
|
||||
poolNames?: string[];
|
||||
rfmScore?: {
|
||||
recency: number;
|
||||
frequency: number;
|
||||
monetary: number;
|
||||
segment?: string;
|
||||
};
|
||||
totalSpent?: number;
|
||||
interactionCount?: number;
|
||||
conversionRate?: number;
|
||||
tags?: string[];
|
||||
packages?: string[];
|
||||
interactions?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
value?: number;
|
||||
}>;
|
||||
gender: number;
|
||||
phone: string;
|
||||
alias: string;
|
||||
lastMsgTime: string;
|
||||
source: SourceInfo[];
|
||||
packages: any[];
|
||||
total: TotalStats;
|
||||
rmm: RmmScore;
|
||||
}
|
||||
|
||||
// 扩展的用户详情类型
|
||||
export interface ExtendedUserDetail extends TrafficPoolUserDetail {
|
||||
userInfo: {
|
||||
// 保留原有的扩展字段用于向后兼容
|
||||
userInfo?: {
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
wechatId: string;
|
||||
@@ -44,23 +69,23 @@ export interface ExtendedUserDetail extends TrafficPoolUserDetail {
|
||||
unknowFriend: number;
|
||||
};
|
||||
};
|
||||
rfmScore: {
|
||||
rfmScore?: {
|
||||
recency: number;
|
||||
frequency: number;
|
||||
monetary: number;
|
||||
totalScore: number;
|
||||
};
|
||||
trafficPools: {
|
||||
trafficPools?: {
|
||||
currentPool: string;
|
||||
availablePools: string[];
|
||||
};
|
||||
userTags: Array<{
|
||||
userTags?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
type: string;
|
||||
}>;
|
||||
valueTags: Array<{
|
||||
valueTags?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
.container {
|
||||
padding: 0;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
// 头部样式
|
||||
.header {
|
||||
display: flex;
|
||||
@@ -123,7 +117,7 @@
|
||||
|
||||
// 内容区域
|
||||
.content {
|
||||
padding: 16px;
|
||||
padding: 10px 10px 10px 16px;
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Card, Button, Avatar, Tag, List, SpinLoading } from "antd-mobile";
|
||||
import {
|
||||
UserOutlined,
|
||||
@@ -22,9 +22,23 @@ import type {
|
||||
} from "./data";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
// RMM评分辅助函数
|
||||
const getRmmValueLevel = (totalScore: number): string => {
|
||||
if (totalScore >= 12) return "高价值客户";
|
||||
if (totalScore >= 8) return "中等价值客户";
|
||||
if (totalScore >= 4) return "低价值客户";
|
||||
return "潜在客户";
|
||||
};
|
||||
|
||||
const getRmmColor = (totalScore: number): string => {
|
||||
if (totalScore >= 12) return "danger";
|
||||
if (totalScore >= 8) return "warning";
|
||||
if (totalScore >= 4) return "primary";
|
||||
return "default";
|
||||
};
|
||||
|
||||
const TrafficPoolDetail: React.FC = () => {
|
||||
const { wxid, userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<ExtendedUserDetail | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("basic");
|
||||
@@ -46,43 +60,89 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
setLoading(true);
|
||||
getTrafficPoolDetail(wxid as string)
|
||||
.then(res => {
|
||||
// 将API数据转换为扩展的用户详情数据
|
||||
// 直接使用API返回的数据结构
|
||||
const extendedUser: ExtendedUserDetail = {
|
||||
...res,
|
||||
// 添加userInfo属性
|
||||
userInfo: res.userInfo,
|
||||
// 模拟RFM评分数据
|
||||
// 根据新数据结构构建userInfo
|
||||
userInfo: {
|
||||
nickname: res.nickname,
|
||||
avatar: res.avatar,
|
||||
wechatId: res.wechatId,
|
||||
friendShip: {
|
||||
totalFriend: res.source?.length || 0,
|
||||
maleFriend: res.source?.filter(s => s.gender === 1).length || 0,
|
||||
femaleFriend: res.source?.filter(s => s.gender === 2).length || 0,
|
||||
unknowFriend: res.source?.filter(s => s.gender === 0).length || 0,
|
||||
},
|
||||
},
|
||||
// 使用API返回的RMM数据
|
||||
rfmScore: {
|
||||
recency: 5,
|
||||
frequency: 5,
|
||||
monetary: 5,
|
||||
totalScore: 15,
|
||||
recency: res.rmm.r,
|
||||
frequency: res.rmm.f,
|
||||
monetary: res.rmm.m,
|
||||
totalScore: res.rmm.r + res.rmm.f + res.rmm.m,
|
||||
},
|
||||
// 模拟流量池数据
|
||||
// 根据数据推断流量池信息
|
||||
trafficPools: {
|
||||
currentPool: "新用户池",
|
||||
availablePools: ["高价值客户池", "活跃用户池"],
|
||||
currentPool: res.total.isFriend ? "已添加好友池" : "待添加池",
|
||||
availablePools: ["高价值客户池", "活跃用户池", "新用户池"],
|
||||
},
|
||||
// 模拟用户标签数据
|
||||
// 基于数据生成用户标签
|
||||
userTags: [
|
||||
{ id: "1", name: "近期活跃", color: "success", type: "user" },
|
||||
{ id: "2", name: "高频互动", color: "primary", type: "user" },
|
||||
{ id: "3", name: "高消费", color: "warning", type: "user" },
|
||||
{ id: "4", name: "老客户", color: "danger", type: "user" },
|
||||
...(res.total.isFriend
|
||||
? [
|
||||
{
|
||||
id: "friend",
|
||||
name: "已添加好友",
|
||||
color: "success",
|
||||
type: "status",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(res.total.money > 0
|
||||
? [
|
||||
{
|
||||
id: "paid",
|
||||
name: "付费用户",
|
||||
color: "warning",
|
||||
type: "value",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(res.total.msg > 10
|
||||
? [
|
||||
{
|
||||
id: "active",
|
||||
name: "高频互动",
|
||||
color: "primary",
|
||||
type: "behavior",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(res.source?.length > 1
|
||||
? [
|
||||
{
|
||||
id: "multi",
|
||||
name: "多设备用户",
|
||||
color: "danger",
|
||||
type: "device",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
// 模拟价值标签数据
|
||||
// 基于RMM评分生成价值标签
|
||||
valueTags: [
|
||||
{
|
||||
id: "1",
|
||||
name: "重要保持客户",
|
||||
color: "primary",
|
||||
id: "rmm",
|
||||
name: getRmmValueLevel(res.rmm.r + res.rmm.f + res.rmm.m),
|
||||
color: getRmmColor(res.rmm.r + res.rmm.f + res.rmm.m),
|
||||
icon: "crown",
|
||||
rfmScore: 14,
|
||||
valueLevel: "高价值",
|
||||
rfmScore: res.rmm.r + res.rmm.f + res.rmm.m,
|
||||
valueLevel: getRmmValueLevel(res.rmm.r + res.rmm.f + res.rmm.m),
|
||||
},
|
||||
],
|
||||
};
|
||||
console.log(extendedUser);
|
||||
console.log("用户详情数据:", extendedUser);
|
||||
|
||||
setUser(extendedUser);
|
||||
})
|
||||
@@ -258,7 +318,7 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
<Card className={styles.userCard}>
|
||||
<div className={styles.userInfo}>
|
||||
<Avatar
|
||||
src={user.userInfo.avatar}
|
||||
src={user.avatar}
|
||||
className={styles.avatar}
|
||||
fallback={
|
||||
<div className={styles.avatarFallback}>
|
||||
@@ -267,20 +327,29 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
<div className={styles.userDetails}>
|
||||
<div className={styles.nickname}>{user.userInfo.nickname}</div>
|
||||
<div className={styles.wechatId}>{user.userInfo.wechatId}</div>
|
||||
<div className={styles.nickname}>{user.nickname}</div>
|
||||
<div className={styles.wechatId}>{user.wechatId}</div>
|
||||
<div className={styles.tags}>
|
||||
<Tag
|
||||
color="warning"
|
||||
fill="outline"
|
||||
className={styles.userTag}
|
||||
>
|
||||
<CrownOutlined />
|
||||
重要价值客户
|
||||
</Tag>
|
||||
<Tag color="danger" fill="outline" className={styles.userTag}>
|
||||
优先添加
|
||||
</Tag>
|
||||
{user.valueTags?.map(tag => (
|
||||
<Tag
|
||||
key={tag.id}
|
||||
color={tag.color}
|
||||
fill="outline"
|
||||
className={styles.userTag}
|
||||
>
|
||||
<CrownOutlined />
|
||||
{tag.name}
|
||||
</Tag>
|
||||
))}
|
||||
{user.total.isFriend && (
|
||||
<Tag
|
||||
color="success"
|
||||
fill="outline"
|
||||
className={styles.userTag}
|
||||
>
|
||||
已添加好友
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,11 +392,29 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
{/* 关联信息 */}
|
||||
<Card title="关联信息" className={styles.infoCard}>
|
||||
<List>
|
||||
<List.Item extra="设备4">设备</List.Item>
|
||||
<List.Item extra="微信4-1">微信号</List.Item>
|
||||
<List.Item extra="客服1">客服</List.Item>
|
||||
<List.Item extra="2025/07/21">添加时间</List.Item>
|
||||
<List.Item extra="2025/07/25">最近互动</List.Item>
|
||||
<List.Item
|
||||
extra={
|
||||
user.source?.length
|
||||
? `${user.source.length}个设备`
|
||||
: "无设备"
|
||||
}
|
||||
>
|
||||
设备
|
||||
</List.Item>
|
||||
<List.Item extra={user.wechatId || "--"}>微信号</List.Item>
|
||||
<List.Item extra={user.alias || "--"}>别名</List.Item>
|
||||
<List.Item
|
||||
extra={
|
||||
user.source?.[0]?.createTime
|
||||
? formatDateTime(user.source[0].createTime)
|
||||
: "--"
|
||||
}
|
||||
>
|
||||
添加时间
|
||||
</List.Item>
|
||||
<List.Item extra={user.lastMsgTime || "--"}>
|
||||
最近互动
|
||||
</List.Item>
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
@@ -405,7 +492,7 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
className={styles.statValue}
|
||||
style={{ color: "#52c41a" }}
|
||||
>
|
||||
¥9561
|
||||
¥{user.total.money || 0}
|
||||
</div>
|
||||
<div className={styles.statLabel}>总消费</div>
|
||||
</div>
|
||||
@@ -414,7 +501,7 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
className={styles.statValue}
|
||||
style={{ color: "#1677ff" }}
|
||||
>
|
||||
6
|
||||
{user.total.msg || 0}
|
||||
</div>
|
||||
<div className={styles.statLabel}>互动次数</div>
|
||||
</div>
|
||||
@@ -423,13 +510,18 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
className={styles.statValue}
|
||||
style={{ color: "#722ed1" }}
|
||||
>
|
||||
3%
|
||||
{user.total.percentage || "0"}%
|
||||
</div>
|
||||
<div className={styles.statLabel}>转化率</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div className={styles.statValue} style={{ color: "#999" }}>
|
||||
未添加
|
||||
<div
|
||||
className={styles.statValue}
|
||||
style={{
|
||||
color: user.total.isFriend ? "#52c41a" : "#999",
|
||||
}}
|
||||
>
|
||||
{user.total.isFriend ? "已添加" : "未添加"}
|
||||
</div>
|
||||
<div className={styles.statLabel}>添加状态</div>
|
||||
</div>
|
||||
@@ -444,7 +536,7 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
className={styles.statValue}
|
||||
style={{ color: "#1677ff" }}
|
||||
>
|
||||
{user.userInfo.friendShip.totalFriend}
|
||||
{user.userInfo?.friendShip.totalFriend || 0}
|
||||
</div>
|
||||
<div className={styles.statLabel}>总好友</div>
|
||||
</div>
|
||||
@@ -453,7 +545,7 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
className={styles.statValue}
|
||||
style={{ color: "#1677ff" }}
|
||||
>
|
||||
{user.userInfo.friendShip.maleFriend}
|
||||
{user.userInfo?.friendShip.maleFriend || 0}
|
||||
</div>
|
||||
<div className={styles.statLabel}>男性好友</div>
|
||||
</div>
|
||||
@@ -462,13 +554,13 @@ const TrafficPoolDetail: React.FC = () => {
|
||||
className={styles.statValue}
|
||||
style={{ color: "#eb2f96" }}
|
||||
>
|
||||
{user.userInfo.friendShip.femaleFriend}
|
||||
{user.userInfo?.friendShip.femaleFriend || 0}
|
||||
</div>
|
||||
<div className={styles.statLabel}>女性好友</div>
|
||||
</div>
|
||||
<div className={styles.statItem}>
|
||||
<div className={styles.statValue} style={{ color: "#999" }}>
|
||||
{user.userInfo.friendShip.unknowFriend}
|
||||
{user.userInfo?.friendShip.unknowFriend || 0}
|
||||
</div>
|
||||
<div className={styles.statLabel}>未知性别</div>
|
||||
</div>
|
||||
|
||||
@@ -209,7 +209,7 @@ const TrafficPoolList: React.FC = () => {
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/mine/traffic-pool/detail/${item.sourceId}/${item.id}`,
|
||||
`/mine/traffic-pool/detail/${item.wechatId}/${item.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface WechatAccountSummary {
|
||||
};
|
||||
restrictions: {
|
||||
id: number;
|
||||
level: string;
|
||||
level: number;
|
||||
reason: string;
|
||||
date: string;
|
||||
}[];
|
||||
|
||||
@@ -738,3 +738,74 @@
|
||||
min-width: 70px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.risk-content {
|
||||
padding: 16px 0;
|
||||
height: 500px;
|
||||
overflow-y: auto;
|
||||
|
||||
.restrictions-list {
|
||||
.restriction-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.restriction-info {
|
||||
flex: 1;
|
||||
|
||||
.restriction-reason {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.restriction-date {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.restriction-level {
|
||||
.level-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.level-1 {
|
||||
background: #e8f5e8;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.level-2 {
|
||||
background: #fff7e6;
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
&.level-3 {
|
||||
background: #fff2f0;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
padding: 40px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,6 @@ import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
UserOutlined,
|
||||
ClockCircleOutlined,
|
||||
MessageOutlined,
|
||||
StarOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import style from "./detail.module.scss";
|
||||
@@ -39,7 +35,6 @@ const WechatAccountDetail: React.FC = () => {
|
||||
const [showTransferConfirm, setShowTransferConfirm] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingInfo, setLoadingInfo] = useState(true);
|
||||
|
||||
// 好友列表相关状态
|
||||
@@ -91,7 +86,7 @@ const WechatAccountDetail: React.FC = () => {
|
||||
const response = await getWechatFriends({
|
||||
wechatAccount: id,
|
||||
page: page,
|
||||
limit: 20,
|
||||
limit: 5,
|
||||
keyword: keyword,
|
||||
});
|
||||
|
||||
@@ -185,35 +180,6 @@ const WechatAccountDetail: React.FC = () => {
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
};
|
||||
|
||||
const calculateAccountAge = (registerTime: string) => {
|
||||
const registerDate = new Date(registerTime);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now.getTime() - registerDate.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
const years = Math.floor(diffDays / 365);
|
||||
const months = Math.floor((diffDays % 365) / 30);
|
||||
return { years, months };
|
||||
};
|
||||
|
||||
const formatAccountAge = (age: { years: number; months: number }) => {
|
||||
if (age.years > 0) {
|
||||
return `${age.years}年${age.months}个月`;
|
||||
}
|
||||
return `${age.months}个月`;
|
||||
};
|
||||
|
||||
const getWeightColor = (weight: number) => {
|
||||
if (weight >= 80) return "text-green-600";
|
||||
if (weight >= 60) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
const getWeightDescription = (weight: number) => {
|
||||
if (weight >= 80) return "账号质量优秀,可以正常使用";
|
||||
if (weight >= 60) return "账号质量良好,需要注意使用频率";
|
||||
return "账号质量较差,建议谨慎使用";
|
||||
};
|
||||
|
||||
const handleTransferFriends = () => {
|
||||
setShowTransferConfirm(true);
|
||||
};
|
||||
@@ -227,11 +193,11 @@ const WechatAccountDetail: React.FC = () => {
|
||||
navigate("/scenarios");
|
||||
};
|
||||
|
||||
const getRestrictionLevelColor = (level: string) => {
|
||||
const getRestrictionLevelColor = (level: number) => {
|
||||
switch (level) {
|
||||
case "high":
|
||||
case 3:
|
||||
return "text-red-600";
|
||||
case "medium":
|
||||
case 2:
|
||||
return "text-yellow-600";
|
||||
default:
|
||||
return "text-gray-600";
|
||||
@@ -257,6 +223,10 @@ const WechatAccountDetail: React.FC = () => {
|
||||
setActiveTab(value);
|
||||
};
|
||||
|
||||
const handleFriendClick = (friend: Friend) => {
|
||||
navigate(`/mine/traffic-pool/detail/${friend.wechatId}/${friend.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout header={<NavCommon title="微信号详情" />} loading={loadingInfo}>
|
||||
<div className={style["wechat-account-detail-page"]}>
|
||||
@@ -415,7 +385,11 @@ const WechatAccountDetail: React.FC = () => {
|
||||
) : (
|
||||
<>
|
||||
{friends.map(friend => (
|
||||
<div key={friend.id} className={style["friend-item"]}>
|
||||
<div
|
||||
key={friend.id}
|
||||
className={style["friend-item"]}
|
||||
onClick={() => handleFriendClick(friend)}
|
||||
>
|
||||
<Avatar
|
||||
src={friend.avatar}
|
||||
className={style["friend-avatar"]}
|
||||
@@ -460,12 +434,51 @@ const WechatAccountDetail: React.FC = () => {
|
||||
total={Math.ceil(friendsTotal / 20)}
|
||||
current={friendsPage}
|
||||
onChange={handlePageChange}
|
||||
showText={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
|
||||
<Tabs.Tab title="风险评估" key="risk">
|
||||
<div className={style["risk-content"]}>
|
||||
{accountSummary?.restrictions &&
|
||||
accountSummary.restrictions.length > 0 ? (
|
||||
<div className={style["restrictions-list"]}>
|
||||
{accountSummary.restrictions.map(restriction => (
|
||||
<div
|
||||
key={restriction.id}
|
||||
className={style["restriction-item"]}
|
||||
>
|
||||
<div className={style["restriction-info"]}>
|
||||
<div className={style["restriction-reason"]}>
|
||||
{restriction.reason}
|
||||
</div>
|
||||
<div className={style["restriction-date"]}>
|
||||
{restriction.date
|
||||
? formatDateTime(restriction.date)
|
||||
: "暂无时间"}
|
||||
</div>
|
||||
</div>
|
||||
<div className={style["restriction-level"]}>
|
||||
<span
|
||||
className={`${style["level-badge"]} ${style[`level-${restriction.level}`]}`}
|
||||
>
|
||||
{restriction.level === 1
|
||||
? "低风险"
|
||||
: restriction.level === 2
|
||||
? "中风险"
|
||||
: "高风险"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={style["empty"]}>暂无风险记录</div>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -506,9 +519,9 @@ const WechatAccountDetail: React.FC = () => {
|
||||
<span
|
||||
className={`${style["restriction-detail-level"]} ${getRestrictionLevelColor(restriction.level)}`}
|
||||
>
|
||||
{restriction.level === "high"
|
||||
{restriction.level === 3
|
||||
? "高风险"
|
||||
: restriction.level === "medium"
|
||||
: restriction.level === 2
|
||||
? "中风险"
|
||||
: "低风险"}
|
||||
</span>
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
import request from "@/api/request2";
|
||||
import {
|
||||
MessageData,
|
||||
ChatHistoryResponse,
|
||||
MessageType,
|
||||
OnlineStatus,
|
||||
MessageStatus,
|
||||
FileUploadResponse,
|
||||
EmojiData,
|
||||
QuickReply,
|
||||
ChatSettings,
|
||||
} from "./data";
|
||||
|
||||
//读取聊天信息
|
||||
//kf.quwanzhi.com:9991/api/WechatFriend/clearUnreadCount
|
||||
|
||||
export function WechatGroup(params) {
|
||||
return request("/api/WechatGroup/list", params, "GET");
|
||||
}
|
||||
|
||||
//获取聊天记录-1 清除未读
|
||||
export function clearUnreadCount(params) {
|
||||
return request("/api/WechatFriend/clearUnreadCount", params, "PUT");
|
||||
}
|
||||
|
||||
//更新配置
|
||||
export function updateConfig(params) {
|
||||
return request("/api/WechatFriend/updateConfig", params, "PUT");
|
||||
}
|
||||
//获取聊天记录-2 获取列表
|
||||
export function getChatMessages(params: {
|
||||
wechatAccountId: number;
|
||||
wechatFriendId?: number;
|
||||
wechatChatroomId?: number;
|
||||
From: number;
|
||||
To: number;
|
||||
Count: number;
|
||||
olderData: boolean;
|
||||
}) {
|
||||
return request("/api/FriendMessage/SearchMessage", params, "GET");
|
||||
}
|
||||
export function getChatroomMessages(params: {
|
||||
wechatAccountId: number;
|
||||
wechatFriendId?: number;
|
||||
wechatChatroomId?: number;
|
||||
From: number;
|
||||
To: number;
|
||||
Count: number;
|
||||
olderData: boolean;
|
||||
}) {
|
||||
return request("/api/ChatroomMessage/SearchMessage", params, "GET");
|
||||
}
|
||||
|
||||
//获取群列表
|
||||
export function getGroupList(params: { prevId: number; count: number }) {
|
||||
return request(
|
||||
"/api/wechatChatroom/listExcludeMembersByPage?",
|
||||
params,
|
||||
"GET",
|
||||
);
|
||||
}
|
||||
|
||||
//获取群成员
|
||||
export function getGroupMembers(params: { id: number }) {
|
||||
return request(
|
||||
"/api/WechatChatroom/listMembersByWechatChatroomId",
|
||||
params,
|
||||
"GET",
|
||||
);
|
||||
}
|
||||
|
||||
//触客宝登陆
|
||||
export function loginWithToken(params: any) {
|
||||
return request(
|
||||
"/token",
|
||||
params,
|
||||
"POST",
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
},
|
||||
1000,
|
||||
);
|
||||
}
|
||||
|
||||
// 获取触客宝用户信息
|
||||
export function getChuKeBaoUserInfo() {
|
||||
return request("/api/account/self", {}, "GET");
|
||||
}
|
||||
|
||||
// 获取联系人列表
|
||||
export const getContactList = (params: { prevId: number; count: number }) => {
|
||||
return request("/api/wechatFriend/list", params, "GET");
|
||||
};
|
||||
|
||||
//获取控制终端列表
|
||||
export const getControlTerminalList = params => {
|
||||
return request("/api/wechataccount", params, "GET");
|
||||
};
|
||||
|
||||
// 获取聊天历史
|
||||
export const getChatHistory = (
|
||||
chatId: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 50,
|
||||
): Promise<ChatHistoryResponse> => {
|
||||
return request(`/v1/chats/${chatId}/messages`, { page, pageSize }, "GET");
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
export const sendMessage = (
|
||||
chatId: string,
|
||||
content: string,
|
||||
type: MessageType = MessageType.TEXT,
|
||||
): Promise<MessageData> => {
|
||||
return request(`/v1/chats/${chatId}/messages`, { content, type }, "POST");
|
||||
};
|
||||
|
||||
// 发送文件消息
|
||||
export const sendFileMessage = (
|
||||
chatId: string,
|
||||
file: File,
|
||||
type: MessageType,
|
||||
): Promise<MessageData> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("type", type);
|
||||
return request(`/v1/chats/${chatId}/messages/file`, formData, "POST");
|
||||
};
|
||||
|
||||
// 标记消息为已读
|
||||
export const markMessageAsRead = (messageId: string): Promise<void> => {
|
||||
return request(`/v1/messages/${messageId}/read`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 标记聊天为已读
|
||||
export const markChatAsRead = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/read`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 添加群组成员
|
||||
export const addGroupMembers = (
|
||||
groupId: string,
|
||||
memberIds: string[],
|
||||
): Promise<void> => {
|
||||
return request(`/v1/groups/${groupId}/members`, { memberIds }, "POST");
|
||||
};
|
||||
|
||||
// 移除群组成员
|
||||
export const removeGroupMembers = (
|
||||
groupId: string,
|
||||
memberIds: string[],
|
||||
): Promise<void> => {
|
||||
return request(`/v1/groups/${groupId}/members`, { memberIds }, "DELETE");
|
||||
};
|
||||
|
||||
// 获取在线状态
|
||||
export const getOnlineStatus = (userId: string): Promise<OnlineStatus> => {
|
||||
return request(`/v1/users/${userId}/status`, {}, "GET");
|
||||
};
|
||||
|
||||
// 获取消息状态
|
||||
export const getMessageStatus = (messageId: string): Promise<MessageStatus> => {
|
||||
return request(`/v1/messages/${messageId}/status`, {}, "GET");
|
||||
};
|
||||
|
||||
// 上传文件
|
||||
export const uploadFile = (file: File): Promise<FileUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return request("/v1/upload", formData, "POST");
|
||||
};
|
||||
|
||||
// 获取表情包列表
|
||||
export const getEmojiList = (): Promise<EmojiData[]> => {
|
||||
return request("/v1/emojis", {}, "GET");
|
||||
};
|
||||
|
||||
// 获取快捷回复列表
|
||||
export const getQuickReplies = (): Promise<QuickReply[]> => {
|
||||
return request("/v1/quick-replies", {}, "GET");
|
||||
};
|
||||
|
||||
// 添加快捷回复
|
||||
export const addQuickReply = (data: {
|
||||
content: string;
|
||||
category: string;
|
||||
}): Promise<QuickReply> => {
|
||||
return request("/v1/quick-replies", data, "POST");
|
||||
};
|
||||
|
||||
// 删除快捷回复
|
||||
export const deleteQuickReply = (id: string): Promise<void> => {
|
||||
return request(`/v1/quick-replies/${id}`, {}, "DELETE");
|
||||
};
|
||||
|
||||
// 获取聊天设置
|
||||
export const getChatSettings = (): Promise<ChatSettings> => {
|
||||
return request("/v1/chat/settings", {}, "GET");
|
||||
};
|
||||
|
||||
// 更新聊天设置
|
||||
export const updateChatSettings = (
|
||||
settings: Partial<ChatSettings>,
|
||||
): Promise<ChatSettings> => {
|
||||
return request("/v1/chat/settings", settings, "PUT");
|
||||
};
|
||||
|
||||
// 删除聊天会话
|
||||
export const deleteChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}`, {}, "DELETE");
|
||||
};
|
||||
|
||||
// 置顶聊天会话
|
||||
export const pinChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/pin`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 取消置顶聊天会话
|
||||
export const unpinChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/unpin`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 静音聊天会话
|
||||
export const muteChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/mute`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 取消静音聊天会话
|
||||
export const unmuteChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/unmute`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 转发消息
|
||||
export const forwardMessage = (
|
||||
messageId: string,
|
||||
targetChatIds: string[],
|
||||
): Promise<void> => {
|
||||
return request("/v1/messages/forward", { messageId, targetChatIds }, "POST");
|
||||
};
|
||||
|
||||
// 撤回消息
|
||||
export const recallMessage = (messageId: string): Promise<void> => {
|
||||
return request(`/v1/messages/${messageId}/recall`, {}, "PUT");
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
// 菜单项接口
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
// 菜单列表数据
|
||||
export const menuList: MenuItem[] = [
|
||||
{
|
||||
id: "dashboard",
|
||||
title: "数据面板",
|
||||
icon: "📊",
|
||||
path: "/ckbox/dashboard",
|
||||
},
|
||||
{
|
||||
id: "wechat",
|
||||
title: "微信管理",
|
||||
icon: "💬",
|
||||
path: "/ckbox/weChat",
|
||||
},
|
||||
];
|
||||
|
||||
// 抽屉菜单配置数据
|
||||
export const drawerMenuData = {
|
||||
header: {
|
||||
logoIcon: "✨",
|
||||
appName: "触客宝",
|
||||
appDesc: "AI智能营销系统",
|
||||
},
|
||||
primaryButton: {
|
||||
title: "AI智能客服",
|
||||
icon: "🔒",
|
||||
},
|
||||
footer: {
|
||||
balanceIcon: "⚡",
|
||||
balanceLabel: "算力余额",
|
||||
balanceValue: "9307.423",
|
||||
},
|
||||
};
|
||||
|
||||
// 导出默认配置
|
||||
export default drawerMenuData;
|
||||
@@ -1,316 +0,0 @@
|
||||
.header {
|
||||
background: #fff;
|
||||
padding: 0 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 64px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
|
||||
.suanli {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
padding: 6px 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e9ecef;
|
||||
|
||||
.suanliIcon {
|
||||
font-size: 16px;
|
||||
color: #ffc107;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messageButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid #e9ecef;
|
||||
background: #fff;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f9fa;
|
||||
border-color: #1890ff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.userSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding: 8px 16px;
|
||||
border-radius: 24px;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid #e9ecef;
|
||||
background: #fff;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f9fa;
|
||||
border-color: #1890ff;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.userNickname {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border: 2px solid #e9ecef;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
// 抽屉样式
|
||||
.drawer {
|
||||
:global(.ant-drawer-header) {
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:global(.ant-drawer-body) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.drawerContent {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.drawerHeader {
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.logoSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.appDesc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
|
||||
.drawerBody {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.buttonIcon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.menuSection {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
font-size: 20px;
|
||||
margin-right: 12px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.drawerFooter {
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
.balanceSection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.balanceIcon {
|
||||
color: #666;
|
||||
.suanliIcon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.balanceText {
|
||||
color: #3d9c0d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.balanceLabel {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.balanceAmount {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #52c41a;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.username {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
width: 280px !important;
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Layout, Drawer, Avatar, Dropdown, Space, Button } from "antd";
|
||||
import {
|
||||
MenuOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { drawerMenuData, menuList } from "./index.data";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
interface NavCommonProps {
|
||||
title?: string;
|
||||
onMenuClick?: () => void;
|
||||
}
|
||||
|
||||
const NavCommon: React.FC<NavCommonProps> = ({
|
||||
title = "触客宝",
|
||||
onMenuClick,
|
||||
}) => {
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { userInfo } = useCkChatStore();
|
||||
|
||||
// 处理菜单图标点击
|
||||
const handleMenuClick = () => {
|
||||
setDrawerVisible(true);
|
||||
onMenuClick?.();
|
||||
};
|
||||
|
||||
// 处理抽屉关闭
|
||||
const handleDrawerClose = () => {
|
||||
setDrawerVisible(false);
|
||||
};
|
||||
|
||||
// 默认抽屉内容
|
||||
const defaultDrawerContent = (
|
||||
<div className={styles.drawerContent}>
|
||||
<div className={styles.drawerHeader}>
|
||||
<div className={styles.logoSection}>
|
||||
<div className={styles.logoIcon}>
|
||||
{drawerMenuData.header.logoIcon}
|
||||
</div>
|
||||
<div className={styles.logoText}>
|
||||
<div className={styles.appName}>
|
||||
{drawerMenuData.header.appName}
|
||||
</div>
|
||||
<div className={styles.appDesc}>
|
||||
{drawerMenuData.header.appDesc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.drawerBody}>
|
||||
<div className={styles.primaryButton}>
|
||||
<div className={styles.buttonIcon}>
|
||||
{drawerMenuData.primaryButton.icon}
|
||||
</div>
|
||||
<span>{drawerMenuData.primaryButton.title}</span>
|
||||
</div>
|
||||
<div className={styles.menuSection}>
|
||||
{menuList.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={styles.menuItem}
|
||||
onClick={() => {
|
||||
if (item.path) {
|
||||
navigate(item.path);
|
||||
setDrawerVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.menuIcon}>{item.icon}</div>
|
||||
<span>{item.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.drawerFooter}>
|
||||
<div className={styles.balanceSection}>
|
||||
<div className={styles.balanceIcon}>
|
||||
<span className={styles.suanliIcon}>
|
||||
{drawerMenuData.footer.balanceIcon}
|
||||
</span>
|
||||
{drawerMenuData.footer.balanceLabel}
|
||||
</div>
|
||||
<div className={styles.balanceText}>
|
||||
{drawerMenuData.footer.balanceValue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined />}
|
||||
onClick={handleMenuClick}
|
||||
className={styles.menuButton}
|
||||
/>
|
||||
<span className={styles.title}>{title}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.headerRight}>
|
||||
<Space className={styles.userInfo}>
|
||||
<span className={styles.suanli}>
|
||||
<span className={styles.suanliIcon}>⚡</span>
|
||||
9307.423
|
||||
</span>
|
||||
<Avatar
|
||||
size={40}
|
||||
icon={<UserOutlined />}
|
||||
src={userInfo?.account?.avatar}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Drawer
|
||||
title="菜单"
|
||||
placement="left"
|
||||
onClose={handleDrawerClose}
|
||||
open={drawerVisible}
|
||||
width={300}
|
||||
className={styles.drawer}
|
||||
>
|
||||
{defaultDrawerContent}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavCommon;
|
||||
@@ -1,193 +0,0 @@
|
||||
.monitoring {
|
||||
padding: 24px;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #262626;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.statCard {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progressRow {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.progressCard,
|
||||
.metricsCard {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
height: 280px;
|
||||
|
||||
.ant-card-body {
|
||||
height: calc(100% - 57px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.progressItem {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #595959;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.metricItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #595959;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chartsRow {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.chartCard {
|
||||
.ant-card-head {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
color: white;
|
||||
|
||||
.ant-card-head-title {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// 图表容器样式
|
||||
.g2-tooltip {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.tableCard {
|
||||
.ant-card-head {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
|
||||
.ant-card-head-title {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tableCard {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
.ant-table {
|
||||
.ant-table-thead > tr > th {
|
||||
background-color: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr {
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.monitoring {
|
||||
padding: 16px;
|
||||
|
||||
.header {
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.progressRow {
|
||||
.progressCard,
|
||||
.metricsCard {
|
||||
height: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.monitoring {
|
||||
padding: 12px;
|
||||
|
||||
.statsRow {
|
||||
.statCard {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,476 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, Row, Col, Statistic, Progress, Table, Tag } from "antd";
|
||||
import {
|
||||
UserOutlined,
|
||||
MessageOutlined,
|
||||
TeamOutlined,
|
||||
TrophyOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import * as echarts from "echarts";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
interface DashboardProps {
|
||||
// 预留接口属性
|
||||
}
|
||||
|
||||
const Dashboard: React.FC<DashboardProps> = () => {
|
||||
// 模拟数据
|
||||
const statsData = [
|
||||
{
|
||||
title: "在线设备数",
|
||||
value: 128,
|
||||
prefix: <UserOutlined />,
|
||||
suffix: "台",
|
||||
valueStyle: { color: "#3f8600" },
|
||||
},
|
||||
{
|
||||
title: "今日消息量",
|
||||
value: 2456,
|
||||
prefix: <MessageOutlined />,
|
||||
suffix: "条",
|
||||
valueStyle: { color: "#1890ff" },
|
||||
},
|
||||
{
|
||||
title: "活跃群组",
|
||||
value: 89,
|
||||
prefix: <TeamOutlined />,
|
||||
suffix: "个",
|
||||
valueStyle: { color: "#722ed1" },
|
||||
},
|
||||
{
|
||||
title: "成功率",
|
||||
value: 98.5,
|
||||
prefix: <TrophyOutlined />,
|
||||
suffix: "%",
|
||||
valueStyle: { color: "#f5222d" },
|
||||
},
|
||||
];
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
title: "设备名称",
|
||||
dataIndex: "deviceName",
|
||||
key: "deviceName",
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
render: (status: string) => (
|
||||
<Tag color={status === "在线" ? "green" : "red"}>{status}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "消息数",
|
||||
dataIndex: "messageCount",
|
||||
key: "messageCount",
|
||||
},
|
||||
{
|
||||
title: "最后活跃时间",
|
||||
dataIndex: "lastActive",
|
||||
key: "lastActive",
|
||||
},
|
||||
];
|
||||
|
||||
const tableData = [
|
||||
{
|
||||
key: "1",
|
||||
deviceName: "设备001",
|
||||
status: "在线",
|
||||
messageCount: 245,
|
||||
lastActive: "2024-01-15 14:30:25",
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
deviceName: "设备002",
|
||||
status: "离线",
|
||||
messageCount: 156,
|
||||
lastActive: "2024-01-15 12:15:10",
|
||||
},
|
||||
{
|
||||
key: "3",
|
||||
deviceName: "设备003",
|
||||
status: "在线",
|
||||
messageCount: 389,
|
||||
lastActive: "2024-01-15 14:28:45",
|
||||
},
|
||||
];
|
||||
|
||||
// 图表数据
|
||||
const lineData = [
|
||||
{ time: "00:00", value: 120 },
|
||||
{ time: "02:00", value: 132 },
|
||||
{ time: "04:00", value: 101 },
|
||||
{ time: "06:00", value: 134 },
|
||||
{ time: "08:00", value: 190 },
|
||||
{ time: "10:00", value: 230 },
|
||||
{ time: "12:00", value: 210 },
|
||||
{ time: "14:00", value: 220 },
|
||||
{ time: "16:00", value: 165 },
|
||||
{ time: "18:00", value: 127 },
|
||||
{ time: "20:00", value: 82 },
|
||||
{ time: "22:00", value: 91 },
|
||||
];
|
||||
|
||||
const columnData = [
|
||||
{ type: "消息发送", value: 27 },
|
||||
{ type: "消息接收", value: 25 },
|
||||
{ type: "群组管理", value: 18 },
|
||||
{ type: "设备监控", value: 15 },
|
||||
{ type: "数据同步", value: 10 },
|
||||
{ type: "其他", value: 5 },
|
||||
];
|
||||
|
||||
const pieData = [
|
||||
{ type: "在线设备", value: 128 },
|
||||
{ type: "离线设备", value: 32 },
|
||||
{ type: "维护中", value: 8 },
|
||||
];
|
||||
|
||||
const areaData = [
|
||||
{ time: "1月", value: 3000 },
|
||||
{ time: "2月", value: 4000 },
|
||||
{ time: "3月", value: 3500 },
|
||||
{ time: "4月", value: 5000 },
|
||||
{ time: "5月", value: 4900 },
|
||||
{ time: "6月", value: 6000 },
|
||||
];
|
||||
|
||||
// ECharts配置
|
||||
const lineOption = {
|
||||
title: {
|
||||
text: "24小时消息趋势",
|
||||
left: "center",
|
||||
textStyle: {
|
||||
color: "#333",
|
||||
fontSize: 16,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: "rgba(0,0,0,0.8)",
|
||||
textStyle: {
|
||||
color: "#fff",
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: "3%",
|
||||
right: "4%",
|
||||
bottom: "3%",
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: lineData.map(item => item.time),
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: "#ccc",
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: "#ccc",
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: lineData.map(item => item.value),
|
||||
type: "line",
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: "#1890ff",
|
||||
width: 3,
|
||||
},
|
||||
itemStyle: {
|
||||
color: "#1890ff",
|
||||
},
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: "rgba(24, 144, 255, 0.3)" },
|
||||
{ offset: 1, color: "rgba(24, 144, 255, 0.1)" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const columnOption = {
|
||||
title: {
|
||||
text: "功能使用分布",
|
||||
left: "center",
|
||||
textStyle: {
|
||||
color: "#333",
|
||||
fontSize: 16,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: "rgba(0,0,0,0.8)",
|
||||
textStyle: {
|
||||
color: "#fff",
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: "3%",
|
||||
right: "4%",
|
||||
bottom: "3%",
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: columnData.map(item => item.type),
|
||||
axisLabel: {
|
||||
rotate: 45,
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: "#ccc",
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: "#ccc",
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: columnData.map(item => item.value),
|
||||
type: "bar",
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: "#52c41a" },
|
||||
{ offset: 1, color: "#389e0d" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const pieOption = {
|
||||
title: {
|
||||
text: "设备状态分布",
|
||||
left: "center",
|
||||
textStyle: {
|
||||
color: "#333",
|
||||
fontSize: 16,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
backgroundColor: "rgba(0,0,0,0.8)",
|
||||
textStyle: {
|
||||
color: "#fff",
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
orient: "vertical",
|
||||
left: "left",
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "设备状态",
|
||||
type: "pie",
|
||||
radius: "50%",
|
||||
data: pieData.map(item => ({ name: item.type, value: item.value })),
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: 5,
|
||||
borderColor: "#fff",
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const areaOption = {
|
||||
title: {
|
||||
text: "月度数据趋势",
|
||||
left: "center",
|
||||
textStyle: {
|
||||
color: "#333",
|
||||
fontSize: 16,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: "rgba(0,0,0,0.8)",
|
||||
textStyle: {
|
||||
color: "#fff",
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: "3%",
|
||||
right: "4%",
|
||||
bottom: "3%",
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: areaData.map(item => item.time),
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: "#ccc",
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: "#ccc",
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: areaData.map(item => item.value),
|
||||
type: "line",
|
||||
smooth: true,
|
||||
lineStyle: {
|
||||
color: "#722ed1",
|
||||
width: 3,
|
||||
},
|
||||
itemStyle: {
|
||||
color: "#722ed1",
|
||||
},
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: "rgba(114, 46, 209, 0.6)" },
|
||||
{ offset: 1, color: "rgba(114, 46, 209, 0.1)" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.monitoring}>
|
||||
<div className={styles.header}>
|
||||
<h2>数据监控看板</h2>
|
||||
<p>实时监控系统运行状态和数据指标</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={[16, 16]} className={styles.statsRow}>
|
||||
{statsData.map((stat, index) => (
|
||||
<Col xs={24} sm={12} md={6} key={index}>
|
||||
<Card className={styles.statCard}>
|
||||
<Statistic
|
||||
title={stat.title}
|
||||
value={stat.value}
|
||||
prefix={stat.prefix}
|
||||
suffix={stat.suffix}
|
||||
valueStyle={stat.valueStyle}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 进度指标 */}
|
||||
<Row gutter={[16, 16]} className={styles.progressRow}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="系统负载" className={styles.progressCard}>
|
||||
<div className={styles.progressItem}>
|
||||
<span>CPU使用率</span>
|
||||
<Progress percent={65} status="active" />
|
||||
</div>
|
||||
<div className={styles.progressItem}>
|
||||
<span>内存使用率</span>
|
||||
<Progress percent={45} />
|
||||
</div>
|
||||
<div className={styles.progressItem}>
|
||||
<span>磁盘使用率</span>
|
||||
<Progress percent={30} />
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="实时指标" className={styles.metricsCard}>
|
||||
<div className={styles.metricItem}>
|
||||
<span>消息处理速度</span>
|
||||
<div className={styles.metricValue}>
|
||||
<span>1,245</span>
|
||||
<ArrowUpOutlined style={{ color: "#3f8600" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.metricItem}>
|
||||
<span>错误率</span>
|
||||
<div className={styles.metricValue}>
|
||||
<span>0.2%</span>
|
||||
<ArrowDownOutlined style={{ color: "#3f8600" }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.metricItem}>
|
||||
<span>响应时间</span>
|
||||
<div className={styles.metricValue}>
|
||||
<span>125ms</span>
|
||||
<ArrowDownOutlined style={{ color: "#3f8600" }} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 图表区域 */}
|
||||
<Row gutter={[16, 16]} className={styles.chartsRow}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card className={styles.chartCard}>
|
||||
<ReactECharts option={lineOption} style={{ height: "350px" }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card className={styles.chartCard}>
|
||||
<ReactECharts option={columnOption} style={{ height: "350px" }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]} className={styles.chartsRow}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card className={styles.chartCard}>
|
||||
<ReactECharts option={pieOption} style={{ height: "350px" }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card className={styles.chartCard}>
|
||||
<ReactECharts option={areaOption} style={{ height: "350px" }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 设备状态表格 */}
|
||||
<Row className={styles.tableRow}>
|
||||
<Col span={24}>
|
||||
<Card title="设备状态" className={styles.tableCard}>
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
dataSource={tableData}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -1,323 +0,0 @@
|
||||
// 消息列表数据接口 - 支持weChatGroup和contracts两种数据类型
|
||||
export interface MessageListData {
|
||||
serverId: number | string; // 服务器ID作为主键
|
||||
id?: number; // 接口数据的原始ID字段
|
||||
|
||||
// 数据类型标识
|
||||
dataType: "weChatGroup" | "contracts"; // 数据类型:微信群组或联系人
|
||||
|
||||
// 通用字段(两种类型都有的字段)
|
||||
wechatAccountId: number; // 微信账号ID
|
||||
tenantId: number; // 租户ID
|
||||
accountId: number; // 账号ID
|
||||
nickname: string; // 昵称
|
||||
avatar?: string; // 头像
|
||||
groupId: number; // 分组ID
|
||||
config?: {
|
||||
chat: boolean;
|
||||
}; // 配置信息
|
||||
labels?: string[]; // 标签列表
|
||||
unreadCount: number; // 未读消息数
|
||||
|
||||
// 联系人特有字段(当dataType为'contracts'时使用)
|
||||
wechatId?: string; // 微信ID
|
||||
alias?: string; // 别名
|
||||
conRemark?: string; // 备注
|
||||
quanPin?: string; // 全拼
|
||||
gender?: number; // 性别
|
||||
region?: string; // 地区
|
||||
addFrom?: number; // 添加来源
|
||||
phone?: string; // 电话
|
||||
signature?: string; // 签名
|
||||
extendFields?: any; // 扩展字段
|
||||
city?: string; // 城市
|
||||
lastUpdateTime?: string; // 最后更新时间
|
||||
isPassed?: boolean; // 是否通过
|
||||
thirdParty?: any; // 第三方
|
||||
additionalPicture?: string; // 附加图片
|
||||
desc?: string; // 描述
|
||||
lastMessageTime?: number; // 最后消息时间
|
||||
duplicate?: boolean; // 是否重复
|
||||
|
||||
// 微信群组特有字段(当dataType为'weChatGroup'时使用)
|
||||
chatroomId?: string; // 群聊ID
|
||||
chatroomOwner?: string; // 群主
|
||||
chatroomAvatar?: string; // 群头像
|
||||
notice?: string; // 群公告
|
||||
selfDisplyName?: string; // 自己在群里的显示名称
|
||||
|
||||
[key: string]: any; // 兼容其他字段
|
||||
}
|
||||
|
||||
//联系人标签分组
|
||||
export interface ContactGroupByLabel {
|
||||
id: number;
|
||||
accountId?: number;
|
||||
groupName: string;
|
||||
tenantId?: number;
|
||||
count: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
//终端用户数据接口
|
||||
export interface KfUserListData {
|
||||
id: number;
|
||||
tenantId: number;
|
||||
wechatId: string;
|
||||
nickname: string;
|
||||
alias: string;
|
||||
avatar: string;
|
||||
gender: number;
|
||||
region: string;
|
||||
signature: string;
|
||||
bindQQ: string;
|
||||
bindEmail: string;
|
||||
bindMobile: string;
|
||||
createTime: string;
|
||||
currentDeviceId: number;
|
||||
isDeleted: boolean;
|
||||
deleteTime: string;
|
||||
groupId: number;
|
||||
memo: string;
|
||||
wechatVersion: string;
|
||||
labels: string[];
|
||||
lastUpdateTime: string;
|
||||
isOnline?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 账户信息接口
|
||||
export interface CkAccount {
|
||||
id: number;
|
||||
realName: string;
|
||||
nickname: string | null;
|
||||
memo: string | null;
|
||||
avatar: string;
|
||||
userName: string;
|
||||
secret: string;
|
||||
accountType: number;
|
||||
departmentId: number;
|
||||
useGoogleSecretKey: boolean;
|
||||
hasVerifyGoogleSecret: boolean;
|
||||
}
|
||||
|
||||
//群聊数据接口
|
||||
export interface weChatGroup {
|
||||
id?: number;
|
||||
wechatAccountId: number;
|
||||
tenantId: number;
|
||||
accountId: number;
|
||||
chatroomId: string;
|
||||
chatroomOwner: string;
|
||||
conRemark: string;
|
||||
nickname: string;
|
||||
chatroomAvatar: string;
|
||||
groupId: number;
|
||||
config?: {
|
||||
chat: boolean;
|
||||
};
|
||||
labels?: string[];
|
||||
unreadCount: number;
|
||||
notice: string;
|
||||
selfDisplyName: string;
|
||||
wechatChatroomId: number;
|
||||
serverId?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 联系人数据接口
|
||||
export interface ContractData {
|
||||
id?: number;
|
||||
serverId?: number;
|
||||
wechatAccountId: number;
|
||||
wechatId: string;
|
||||
alias: string;
|
||||
conRemark: string;
|
||||
nickname: string;
|
||||
quanPin: string;
|
||||
avatar?: string;
|
||||
gender: number;
|
||||
region: string;
|
||||
addFrom: number;
|
||||
phone: string;
|
||||
labels: string[];
|
||||
signature: string;
|
||||
accountId: number;
|
||||
extendFields: null;
|
||||
city?: string;
|
||||
lastUpdateTime: string;
|
||||
isPassed: boolean;
|
||||
tenantId: number;
|
||||
groupId: number;
|
||||
thirdParty: null;
|
||||
additionalPicture: string;
|
||||
desc: string;
|
||||
config?: {
|
||||
chat: boolean;
|
||||
};
|
||||
lastMessageTime: number;
|
||||
unreadCount: number;
|
||||
duplicate: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
//聊天记录接口
|
||||
export interface ChatRecord {
|
||||
id: number;
|
||||
wechatFriendId: number;
|
||||
wechatAccountId: number;
|
||||
tenantId: number;
|
||||
accountId: number;
|
||||
synergyAccountId: number;
|
||||
content: string;
|
||||
msgType: number;
|
||||
msgSubType: number;
|
||||
msgSvrId: string;
|
||||
isSend: boolean;
|
||||
createTime: string;
|
||||
isDeleted: boolean;
|
||||
deleteTime: string;
|
||||
sendStatus: number;
|
||||
wechatTime: number;
|
||||
origin: number;
|
||||
msgId: number;
|
||||
recalled: boolean;
|
||||
sender?: {
|
||||
chatroomNickname: string;
|
||||
isAdmin: boolean;
|
||||
isDeleted: boolean;
|
||||
nickname: string;
|
||||
ownerWechatId: string;
|
||||
wechatId: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信好友基本信息接口
|
||||
* 包含主要字段和兼容性字段
|
||||
*/
|
||||
export interface WechatFriend {
|
||||
// 主要字段
|
||||
id: number; // 好友ID
|
||||
wechatAccountId: number; // 微信账号ID
|
||||
wechatId: string; // 微信ID
|
||||
nickname: string; // 昵称
|
||||
conRemark: string; // 备注名
|
||||
avatar: string; // 头像URL
|
||||
gender: number; // 性别:1-男,2-女,0-未知
|
||||
region: string; // 地区
|
||||
phone: string; // 电话
|
||||
labels: string[]; // 标签列表
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 消息类型枚举
|
||||
export enum MessageType {
|
||||
TEXT = "text",
|
||||
IMAGE = "image",
|
||||
VOICE = "voice",
|
||||
VIDEO = "video",
|
||||
FILE = "file",
|
||||
LOCATION = "location",
|
||||
}
|
||||
|
||||
// 消息数据接口
|
||||
export interface MessageData {
|
||||
id: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
type: MessageType;
|
||||
timestamp: string;
|
||||
isRead: boolean;
|
||||
replyTo?: string;
|
||||
forwardFrom?: string;
|
||||
}
|
||||
|
||||
// 聊天会话类型
|
||||
export type ChatType = "private" | "group";
|
||||
|
||||
// 聊天会话接口
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
type: ChatType;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
lastMessage: string;
|
||||
lastTime: string;
|
||||
unreadCount: number;
|
||||
online: boolean;
|
||||
members?: string[];
|
||||
pinned?: boolean;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
// 聊天历史响应接口
|
||||
export interface ChatHistoryResponse {
|
||||
messages: MessageData[];
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 发送消息请求接口
|
||||
export interface SendMessageRequest {
|
||||
chatId: string;
|
||||
content: string;
|
||||
type: MessageType;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
// 搜索联系人请求接口
|
||||
export interface SearchContactRequest {
|
||||
keyword: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// 在线状态接口
|
||||
export interface OnlineStatus {
|
||||
userId: string;
|
||||
online: boolean;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
// 消息状态接口
|
||||
export interface MessageStatus {
|
||||
messageId: string;
|
||||
status: "sending" | "sent" | "delivered" | "read" | "failed";
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// 文件上传响应接口
|
||||
export interface FileUploadResponse {
|
||||
url: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// 表情包接口
|
||||
export interface EmojiData {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
// 快捷回复接口
|
||||
export interface QuickReply {
|
||||
id: string;
|
||||
content: string;
|
||||
category: string;
|
||||
useCount: number;
|
||||
}
|
||||
|
||||
// 聊天设置接口
|
||||
export interface ChatSettings {
|
||||
autoReply: boolean;
|
||||
autoReplyMessage: string;
|
||||
notification: boolean;
|
||||
sound: boolean;
|
||||
theme: "light" | "dark";
|
||||
fontSize: "small" | "medium" | "large";
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
.ckboxLayout {
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
height: 64px;
|
||||
line-height: 64px;
|
||||
padding: 0 24px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.verticalSider {
|
||||
background: #2e2e2e;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sider {
|
||||
background: #fff;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.searchBar {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
|
||||
:global(.ant-input) {
|
||||
border-radius: 20px;
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
|
||||
&:focus {
|
||||
background: #fff;
|
||||
border: 1px solid #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global(.ant-tabs-content) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.ant-tabs-tabpane) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.ant-tabs-nav) {
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
:global(.ant-tabs-tab) {
|
||||
padding: 12px 16px;
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.ant-tabs-tab-active {
|
||||
.ant-tabs-tab-btn {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-tabs-ink-bar) {
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #8c8c8c;
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
|
||||
.chatContainer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.chatToolbar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.welcomeScreen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
|
||||
.welcomeContent {
|
||||
text-align: center;
|
||||
color: #8c8c8c;
|
||||
|
||||
h2 {
|
||||
margin: 24px 0 12px 0;
|
||||
color: #262626;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.ckboxLayout {
|
||||
.sidebar {
|
||||
.searchBar {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
:global(.ant-tabs-nav) {
|
||||
padding: 0 12px;
|
||||
|
||||
:global(.ant-tabs-tab) {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
.chatContainer {
|
||||
.chatToolbar {
|
||||
padding: 6px 12px;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.welcomeScreen {
|
||||
.welcomeContent {
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from "react";
|
||||
import Layout from "@/components/Layout/Layout";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import NavCommon from "./components/NavCommon";
|
||||
import styles from "./index.module.scss";
|
||||
const CkboxPage: React.FC = () => {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<NavCommon title="AI自动聊天,懂业务,会引导,客户不停地聊不停" />
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CkboxPage;
|
||||
@@ -1,307 +0,0 @@
|
||||
import {
|
||||
asyncKfUserList,
|
||||
asyncContractList,
|
||||
asyncChatSessions,
|
||||
asyncWeChatGroup,
|
||||
asyncCountLables,
|
||||
useCkChatStore,
|
||||
} from "@/store/module/ckchat/ckchat";
|
||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
|
||||
import {
|
||||
loginWithToken,
|
||||
getControlTerminalList,
|
||||
getContactList,
|
||||
getGroupList,
|
||||
} from "./api";
|
||||
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
|
||||
import {
|
||||
KfUserListData,
|
||||
ContractData,
|
||||
weChatGroup,
|
||||
} from "@/pages/pc/ckbox/data";
|
||||
|
||||
import { WechatGroup } from "./api";
|
||||
const { login2 } = useUserStore.getState();
|
||||
//获取触客宝基础信息
|
||||
export const chatInitAPIdata = async () => {
|
||||
try {
|
||||
//获取联系人列表
|
||||
const contractList = await getAllContactList();
|
||||
|
||||
//获取联系人列表
|
||||
asyncContractList(contractList);
|
||||
|
||||
//获取群列表
|
||||
const groupList = await getAllGroupList();
|
||||
|
||||
await asyncWeChatGroup(groupList);
|
||||
|
||||
// 提取不重复的wechatAccountId组
|
||||
const uniqueWechatAccountIds: number[] = getUniqueWechatAccountIds(
|
||||
contractList,
|
||||
groupList,
|
||||
);
|
||||
|
||||
//获取控制终端列表
|
||||
const kfUserList: KfUserListData[] =
|
||||
await getControlTerminalListByWechatAccountIds(uniqueWechatAccountIds);
|
||||
|
||||
//获取用户列表
|
||||
await asyncKfUserList(kfUserList);
|
||||
|
||||
//获取标签列表
|
||||
const countLables = await getCountLables();
|
||||
await asyncCountLables(countLables);
|
||||
|
||||
//获取消息会话列表并按lastUpdateTime排序
|
||||
const filterUserSessions = contractList?.filter(
|
||||
v => v?.config && v.config?.chat,
|
||||
);
|
||||
const filterGroupSessions = groupList?.filter(
|
||||
v => v?.config && v.config?.chat,
|
||||
);
|
||||
//排序功能
|
||||
const sortedSessions = [...filterUserSessions, ...filterGroupSessions].sort(
|
||||
(a, b) => {
|
||||
// 如果lastUpdateTime不存在,则将其排在最后
|
||||
if (!a.lastUpdateTime) return 1;
|
||||
if (!b.lastUpdateTime) return -1;
|
||||
|
||||
// 首先按时间降序排列(最新的在前面)
|
||||
const timeCompare =
|
||||
new Date(b.lastUpdateTime).getTime() -
|
||||
new Date(a.lastUpdateTime).getTime();
|
||||
|
||||
// 如果时间相同,则按未读消息数量降序排列
|
||||
if (timeCompare === 0) {
|
||||
// 如果unreadCount不存在,则将其排在后面
|
||||
const aUnread = a.unreadCount || 0;
|
||||
const bUnread = b.unreadCount || 0;
|
||||
return bUnread - aUnread; // 未读消息多的排在前面
|
||||
}
|
||||
|
||||
return timeCompare;
|
||||
},
|
||||
);
|
||||
//会话数据同步
|
||||
asyncChatSessions(sortedSessions);
|
||||
|
||||
return {
|
||||
contractList,
|
||||
groupList,
|
||||
kfUserList,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("获取联系人列表失败:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
//发起soket连接
|
||||
export const initSocket = () => {
|
||||
// 检查WebSocket是否已经连接
|
||||
const { status } = useWebSocketStore.getState();
|
||||
|
||||
// 如果已经连接或正在连接,则不重复连接
|
||||
if (["connected", "connecting"].includes(status)) {
|
||||
console.log("WebSocket已连接或正在连接,跳过重复连接", { status });
|
||||
return;
|
||||
}
|
||||
|
||||
// 从store获取token和accountId
|
||||
const { token2 } = useUserStore.getState();
|
||||
const { getAccountId } = useCkChatStore.getState();
|
||||
const Token = token2;
|
||||
const accountId = getAccountId();
|
||||
// 使用WebSocket store初始化连接
|
||||
const { connect } = useWebSocketStore.getState();
|
||||
|
||||
// 连接WebSocket
|
||||
connect({
|
||||
accessToken: Token,
|
||||
accountId: Number(accountId),
|
||||
client: "kefu-client",
|
||||
cmdType: "CmdSignIn",
|
||||
seq: +new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getCountLables = async () => {
|
||||
const LablesRes = await Promise.all(
|
||||
[1, 2].map(item =>
|
||||
WechatGroup({
|
||||
groupType: item,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const [friend, group] = LablesRes;
|
||||
const countLables = [
|
||||
...[
|
||||
{
|
||||
id: 0,
|
||||
groupName: "默认群分组",
|
||||
groupType: 2,
|
||||
},
|
||||
],
|
||||
...group,
|
||||
...friend,
|
||||
...[
|
||||
{
|
||||
id: 0,
|
||||
groupName: "未分组",
|
||||
groupType: 1,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
return countLables;
|
||||
};
|
||||
/**
|
||||
* 根据标签组织联系人
|
||||
* @param contractList 联系人列表
|
||||
* @param countLables 标签列表
|
||||
* @returns 按标签分组的联系人
|
||||
*/
|
||||
|
||||
//获取控制终端列表
|
||||
export const getControlTerminalListByWechatAccountIds = (
|
||||
WechatAccountIds: number[],
|
||||
) => {
|
||||
return Promise.all(
|
||||
WechatAccountIds.map(id => getControlTerminalList({ id: id })),
|
||||
);
|
||||
};
|
||||
// 递归获取所有联系人列表
|
||||
export const getAllContactList = async () => {
|
||||
try {
|
||||
let allContacts = [];
|
||||
let prevId = 0;
|
||||
const count = 1000;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const contractList = await getContactList({
|
||||
prevId,
|
||||
count,
|
||||
});
|
||||
|
||||
if (
|
||||
!contractList ||
|
||||
!Array.isArray(contractList) ||
|
||||
contractList.length === 0
|
||||
) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
allContacts = [...allContacts, ...contractList];
|
||||
|
||||
// 如果返回的数据少于请求的数量,说明已经没有更多数据了
|
||||
if (contractList.length < count) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
// 获取最后一条数据的id作为下一次请求的prevId
|
||||
const lastContact = contractList[contractList.length - 1];
|
||||
prevId = lastContact.id;
|
||||
}
|
||||
}
|
||||
return allContacts;
|
||||
} catch (error) {
|
||||
console.error("获取所有联系人列表失败:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 提取不重复的wechatAccountId组
|
||||
export const getUniqueWechatAccountIds = (
|
||||
contacts: ContractData[],
|
||||
groupList: weChatGroup[],
|
||||
) => {
|
||||
if (!contacts || !Array.isArray(contacts) || contacts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 使用Set来存储不重复的wechatAccountId
|
||||
const uniqueAccountIdsSet = new Set<number>();
|
||||
|
||||
// 遍历联系人列表,将每个wechatAccountId添加到Set中
|
||||
contacts.forEach(contact => {
|
||||
if (contact && contact.wechatAccountId) {
|
||||
uniqueAccountIdsSet.add(contact.wechatAccountId);
|
||||
}
|
||||
});
|
||||
|
||||
// 遍历联系人列表,将每个wechatAccountId添加到Set中
|
||||
groupList.forEach(group => {
|
||||
if (group && group.wechatAccountId) {
|
||||
uniqueAccountIdsSet.add(group.wechatAccountId);
|
||||
}
|
||||
});
|
||||
|
||||
// 将Set转换为数组并返回
|
||||
return Array.from(uniqueAccountIdsSet);
|
||||
};
|
||||
// 递归获取所有群列表
|
||||
export const getAllGroupList = async () => {
|
||||
try {
|
||||
let allContacts = [];
|
||||
let prevId = 0;
|
||||
const count = 1000;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const contractList = await getGroupList({
|
||||
prevId,
|
||||
count,
|
||||
});
|
||||
|
||||
if (
|
||||
!contractList ||
|
||||
!Array.isArray(contractList) ||
|
||||
contractList.length === 0
|
||||
) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
allContacts = [...allContacts, ...contractList];
|
||||
|
||||
// 如果返回的数据少于请求的数量,说明已经没有更多数据了
|
||||
if (contractList.length < count) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
// 获取最后一条数据的id作为下一次请求的prevId
|
||||
const lastContact = contractList[contractList.length - 1];
|
||||
prevId = lastContact.id;
|
||||
}
|
||||
}
|
||||
|
||||
return allContacts;
|
||||
} catch (error) {
|
||||
console.error("获取所有群列表失败:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
//获取token
|
||||
const getToken = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const params = {
|
||||
grant_type: "password",
|
||||
password: "kr123456",
|
||||
username: "kr_xf3",
|
||||
// username: "karuo",
|
||||
// password: "zhiqun1984",
|
||||
};
|
||||
loginWithToken(params)
|
||||
.then(res => {
|
||||
login2(res.access_token);
|
||||
resolve(res.access_token);
|
||||
})
|
||||
.catch(err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,287 +0,0 @@
|
||||
import request from "@/api/request2";
|
||||
import {
|
||||
MessageData,
|
||||
ChatHistoryResponse,
|
||||
MessageType,
|
||||
OnlineStatus,
|
||||
MessageStatus,
|
||||
FileUploadResponse,
|
||||
EmojiData,
|
||||
QuickReply,
|
||||
ChatSettings,
|
||||
} from "./data";
|
||||
|
||||
//读取聊天信息
|
||||
//kf.quwanzhi.com:9991/api/WechatFriend/clearUnreadCount
|
||||
function jsonToQueryString(json) {
|
||||
const params = new URLSearchParams();
|
||||
for (const key in json) {
|
||||
if (Object.prototype.hasOwnProperty.call(json, key)) {
|
||||
params.append(key, json[key]);
|
||||
}
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
//转移客户
|
||||
export function WechatFriendAllot(params: {
|
||||
wechatFriendId?: number;
|
||||
wechatChatroomId?: number;
|
||||
toAccountId: number;
|
||||
notifyReceiver: boolean;
|
||||
comment: string;
|
||||
}) {
|
||||
return request(
|
||||
"/api/wechatFriend/allot?" + jsonToQueryString(params),
|
||||
undefined,
|
||||
"PUT",
|
||||
);
|
||||
}
|
||||
|
||||
//获取可转移客服列表
|
||||
export function getTransferableAgentList() {
|
||||
return request("/api/account/myDepartmentAccountsForTransfer", {}, "GET");
|
||||
}
|
||||
|
||||
// 微信好友列表
|
||||
export function WechatFriendRebackAllot(params: {
|
||||
wechatFriendId?: number;
|
||||
wechatChatroomId?: number;
|
||||
}) {
|
||||
return request(
|
||||
"/api/wechatFriend/rebackAllot?" + jsonToQueryString(params),
|
||||
undefined,
|
||||
"PUT",
|
||||
);
|
||||
}
|
||||
|
||||
// 微信群列表
|
||||
export function WechatGroup(params) {
|
||||
return request("/api/WechatGroup/list", params, "GET");
|
||||
}
|
||||
|
||||
//获取聊天记录-1 清除未读
|
||||
export function clearUnreadCount(params) {
|
||||
return request("/api/WechatFriend/clearUnreadCount", params, "PUT");
|
||||
}
|
||||
|
||||
//更新配置
|
||||
export function updateConfig(params) {
|
||||
return request("/api/WechatFriend/updateConfig", params, "PUT");
|
||||
}
|
||||
//获取聊天记录-2 获取列表
|
||||
export function getChatMessages(params: {
|
||||
wechatAccountId: number;
|
||||
wechatFriendId?: number;
|
||||
wechatChatroomId?: number;
|
||||
From: number;
|
||||
To: number;
|
||||
Count: number;
|
||||
olderData: boolean;
|
||||
}) {
|
||||
return request("/api/FriendMessage/SearchMessage", params, "GET");
|
||||
}
|
||||
export function getChatroomMessages(params: {
|
||||
wechatAccountId: number;
|
||||
wechatFriendId?: number;
|
||||
wechatChatroomId?: number;
|
||||
From: number;
|
||||
To: number;
|
||||
Count: number;
|
||||
olderData: boolean;
|
||||
}) {
|
||||
return request("/api/ChatroomMessage/SearchMessage", params, "GET");
|
||||
}
|
||||
|
||||
//获取群列表
|
||||
export function getGroupList(params: { prevId: number; count: number }) {
|
||||
return request(
|
||||
"/api/wechatChatroom/listExcludeMembersByPage?",
|
||||
params,
|
||||
"GET",
|
||||
);
|
||||
}
|
||||
|
||||
//获取群成员
|
||||
export function getGroupMembers(params: { id: number }) {
|
||||
return request(
|
||||
"/api/WechatChatroom/listMembersByWechatChatroomId",
|
||||
params,
|
||||
"GET",
|
||||
);
|
||||
}
|
||||
|
||||
//触客宝登陆
|
||||
export function loginWithToken(params: any) {
|
||||
return request(
|
||||
"/token",
|
||||
params,
|
||||
"POST",
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
},
|
||||
1000,
|
||||
);
|
||||
}
|
||||
|
||||
// 获取触客宝用户信息
|
||||
export function getChuKeBaoUserInfo() {
|
||||
return request("/api/account/self", {}, "GET");
|
||||
}
|
||||
|
||||
// 获取联系人列表
|
||||
export const getContactList = (params: { prevId: number; count: number }) => {
|
||||
return request("/api/wechatFriend/list", params, "GET");
|
||||
};
|
||||
|
||||
//获取控制终端列表
|
||||
export const getControlTerminalList = params => {
|
||||
return request("/api/wechataccount", params, "GET");
|
||||
};
|
||||
|
||||
// 获取聊天历史
|
||||
export const getChatHistory = (
|
||||
chatId: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 50,
|
||||
): Promise<ChatHistoryResponse> => {
|
||||
return request(`/v1/chats/${chatId}/messages`, { page, pageSize }, "GET");
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
export const sendMessage = (
|
||||
chatId: string,
|
||||
content: string,
|
||||
type: MessageType = MessageType.TEXT,
|
||||
): Promise<MessageData> => {
|
||||
return request(`/v1/chats/${chatId}/messages`, { content, type }, "POST");
|
||||
};
|
||||
|
||||
// 发送文件消息
|
||||
export const sendFileMessage = (
|
||||
chatId: string,
|
||||
file: File,
|
||||
type: MessageType,
|
||||
): Promise<MessageData> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("type", type);
|
||||
return request(`/v1/chats/${chatId}/messages/file`, formData, "POST");
|
||||
};
|
||||
|
||||
// 标记消息为已读
|
||||
export const markMessageAsRead = (messageId: string): Promise<void> => {
|
||||
return request(`/v1/messages/${messageId}/read`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 标记聊天为已读
|
||||
export const markChatAsRead = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/read`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 添加群组成员
|
||||
export const addGroupMembers = (
|
||||
groupId: string,
|
||||
memberIds: string[],
|
||||
): Promise<void> => {
|
||||
return request(`/v1/groups/${groupId}/members`, { memberIds }, "POST");
|
||||
};
|
||||
|
||||
// 移除群组成员
|
||||
export const removeGroupMembers = (
|
||||
groupId: string,
|
||||
memberIds: string[],
|
||||
): Promise<void> => {
|
||||
return request(`/v1/groups/${groupId}/members`, { memberIds }, "DELETE");
|
||||
};
|
||||
|
||||
// 获取在线状态
|
||||
export const getOnlineStatus = (userId: string): Promise<OnlineStatus> => {
|
||||
return request(`/v1/users/${userId}/status`, {}, "GET");
|
||||
};
|
||||
|
||||
// 获取消息状态
|
||||
export const getMessageStatus = (messageId: string): Promise<MessageStatus> => {
|
||||
return request(`/v1/messages/${messageId}/status`, {}, "GET");
|
||||
};
|
||||
|
||||
// 上传文件
|
||||
export const uploadFile = (file: File): Promise<FileUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return request("/v1/upload", formData, "POST");
|
||||
};
|
||||
|
||||
// 获取表情包列表
|
||||
export const getEmojiList = (): Promise<EmojiData[]> => {
|
||||
return request("/v1/emojis", {}, "GET");
|
||||
};
|
||||
|
||||
// 获取快捷回复列表
|
||||
export const getQuickReplies = (): Promise<QuickReply[]> => {
|
||||
return request("/v1/quick-replies", {}, "GET");
|
||||
};
|
||||
|
||||
// 添加快捷回复
|
||||
export const addQuickReply = (data: {
|
||||
content: string;
|
||||
category: string;
|
||||
}): Promise<QuickReply> => {
|
||||
return request("/v1/quick-replies", data, "POST");
|
||||
};
|
||||
|
||||
// 删除快捷回复
|
||||
export const deleteQuickReply = (id: string): Promise<void> => {
|
||||
return request(`/v1/quick-replies/${id}`, {}, "DELETE");
|
||||
};
|
||||
|
||||
// 获取聊天设置
|
||||
export const getChatSettings = (): Promise<ChatSettings> => {
|
||||
return request("/v1/chat/settings", {}, "GET");
|
||||
};
|
||||
|
||||
// 更新聊天设置
|
||||
export const updateChatSettings = (
|
||||
settings: Partial<ChatSettings>,
|
||||
): Promise<ChatSettings> => {
|
||||
return request("/v1/chat/settings", settings, "PUT");
|
||||
};
|
||||
|
||||
// 删除聊天会话
|
||||
export const deleteChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}`, {}, "DELETE");
|
||||
};
|
||||
|
||||
// 置顶聊天会话
|
||||
export const pinChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/pin`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 取消置顶聊天会话
|
||||
export const unpinChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/unpin`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 静音聊天会话
|
||||
export const muteChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/mute`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 取消静音聊天会话
|
||||
export const unmuteChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/unmute`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 转发消息
|
||||
export const forwardMessage = (
|
||||
messageId: string,
|
||||
targetChatIds: string[],
|
||||
): Promise<void> => {
|
||||
return request("/v1/messages/forward", { messageId, targetChatIds }, "POST");
|
||||
};
|
||||
|
||||
// 撤回消息
|
||||
export const recallMessage = (messageId: string): Promise<void> => {
|
||||
return request(`/v1/messages/${messageId}/recall`, {}, "PUT");
|
||||
};
|
||||
@@ -1,404 +0,0 @@
|
||||
.chatWindow {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.chatMain {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chatHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
height: 64px;
|
||||
min-height: 64px;
|
||||
flex-shrink: 0;
|
||||
gap: 16px; // 确保信息区域和按钮区域有足够间距
|
||||
|
||||
.chatHeaderInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0; // 防止flex子元素溢出
|
||||
|
||||
:global(.ant-avatar) {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.chatHeaderDetails {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
|
||||
.chatHeaderName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 30px;
|
||||
|
||||
.chatHeaderOnlineStatus {
|
||||
font-size: 12px;
|
||||
color: #52c41a;
|
||||
font-weight: normal;
|
||||
flex-shrink: 0; // 防止在线状态被压缩
|
||||
}
|
||||
}
|
||||
|
||||
.chatHeaderType {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chatHeaderSubInfo {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
.chatHeaderRemark {
|
||||
color: #1890ff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chatHeaderWechatId {
|
||||
color: #8c8c8c;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.headerButton {
|
||||
color: #8c8c8c;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chatContent {
|
||||
flex: 1;
|
||||
overflow: visible;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.messagesContainer {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧个人资料卡片
|
||||
.profileSider {
|
||||
background: #fff;
|
||||
border-left: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
|
||||
.profileSiderContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.profileHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
flex-shrink: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
color: #8c8c8c;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profileContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
height: 0; // 确保flex子元素能够正确计算高度
|
||||
|
||||
.profileCard {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.ant-card-head) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 0 16px;
|
||||
|
||||
:global(.ant-card-head-title) {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.profileBasic {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
:global(.ant-avatar) {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.profileInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.profileNickname {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.profileStatus {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 12px;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.profilePosition {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.profileRemark {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
|
||||
:global(.ant-input) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:global(.ant-btn) {
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.profileWechatId {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contractInfo {
|
||||
.contractItem {
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:global(.anticon) {
|
||||
color: #8c8c8c;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.contractItemText {
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tagsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
:global(.ant-tag) {
|
||||
margin: 0;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.bioText {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.profileActions {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messageTime {
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 1200px) {
|
||||
.profileSider {
|
||||
width: 260px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chatWindow {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profileSider {
|
||||
width: 100% !important;
|
||||
height: 300px;
|
||||
border-left: none;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.chatHeader {
|
||||
padding: 0 12px;
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
|
||||
.chatHeaderInfo {
|
||||
.chatHeaderDetails {
|
||||
.chatHeaderName {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chatContent {
|
||||
.messagesContainer {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.profileContent {
|
||||
padding: 12px;
|
||||
|
||||
.profileCard {
|
||||
margin-bottom: 12px;
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.profileBasic {
|
||||
gap: 12px;
|
||||
|
||||
.profileInfo {
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contractInfo {
|
||||
.contractItem {
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
// MessageEnter 组件样式 - 微信风格
|
||||
.chatFooter {
|
||||
background: #f7f7f7;
|
||||
border-top: 1px solid #e1e1e1;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.inputToolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.leftTool {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.rightTool {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rightToolItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 3px 6px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: #e6e6e6;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.inputHint {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
text-align: right;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.inputContainer {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.inputToolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rightTool {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rightToolItem {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sendButton {
|
||||
align-self: flex-end;
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Modal, Input, DatePicker, message } from "antd";
|
||||
import { MessageOutlined } from "@ant-design/icons";
|
||||
import dayjs from "dayjs";
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface ChatRecordProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ChatRecord: React.FC<ChatRecordProps> = ({
|
||||
className,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [searchContent, setSearchContent] = useState<string>("");
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const SearchMessage = useWeChatStore(state => state.SearchMessage);
|
||||
|
||||
// 打开弹窗
|
||||
const openModal = () => {
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
// 关闭弹窗并重置状态
|
||||
const closeModal = () => {
|
||||
setVisible(false);
|
||||
setSearchContent("");
|
||||
setDateRange(null);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// 执行查找
|
||||
const handleSearch = async () => {
|
||||
if (!dateRange) {
|
||||
message.warning("请选择时间范围");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const [From, To] = dateRange;
|
||||
const searchData = {
|
||||
From: From.unix() * 1000,
|
||||
To: To.unix() * 1000,
|
||||
keyword: searchContent.trim(),
|
||||
};
|
||||
await SearchMessage(searchData);
|
||||
|
||||
message.success("查找完成");
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error("查找失败:", error);
|
||||
message.error("查找失败,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={className}
|
||||
onClick={openModal}
|
||||
style={{
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<MessageOutlined />
|
||||
聊天记录
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="查找聊天记录"
|
||||
open={visible}
|
||||
onCancel={closeModal}
|
||||
width={450}
|
||||
centered
|
||||
maskClosable={!loading}
|
||||
footer={[
|
||||
<div
|
||||
key="footer"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={closeModal}
|
||||
disabled={loading}
|
||||
style={{ marginRight: "8px" }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" loading={loading} onClick={handleSearch}>
|
||||
查找
|
||||
</Button>
|
||||
</div>,
|
||||
]}
|
||||
>
|
||||
<div style={{ padding: "20px 0" }}>
|
||||
{/* 时间范围选择 */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<div
|
||||
style={{ marginBottom: "8px", fontSize: "14px", color: "#333" }}
|
||||
>
|
||||
时间范围
|
||||
</div>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
disabled={loading}
|
||||
placeholder={["开始日期", "结束日期"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 查找内容输入 */}
|
||||
<div>
|
||||
<div
|
||||
style={{ marginBottom: "8px", fontSize: "14px", color: "#333" }}
|
||||
>
|
||||
查找内容
|
||||
</div>
|
||||
<Input
|
||||
placeholder="请输入要查找的关键词或内容"
|
||||
value={searchContent}
|
||||
onChange={e => setSearchContent(e.target.value)}
|
||||
size="large"
|
||||
maxLength={100}
|
||||
showCount
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatRecord;
|
||||
@@ -1,253 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Modal, Select, Input, message } from "antd";
|
||||
import { ShareAltOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
getTransferableAgentList,
|
||||
WechatFriendAllot,
|
||||
WechatFriendRebackAllot,
|
||||
} from "@/pages/pc/ckbox/weChat/api";
|
||||
import { useCurrentContact } from "@/store/module/weChat/weChat";
|
||||
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
||||
import { contractService, weChatGroupService } from "@/utils/db";
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
interface ToContractProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
interface DepartItem {
|
||||
id: number;
|
||||
userName: string;
|
||||
realName: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
memo: string;
|
||||
departmentId: number;
|
||||
alive: boolean;
|
||||
}
|
||||
|
||||
const ToContract: React.FC<ToContractProps> = ({
|
||||
className,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const currentContact = useCurrentContact();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [selectedTarget, setSelectedTarget] = useState<number | null>(null);
|
||||
const [comment, setComment] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [customerServiceList, setCustomerServiceList] = useState<DepartItem[]>(
|
||||
[],
|
||||
);
|
||||
const deleteChatSession = useCkChatStore(state => state.deleteChatSession);
|
||||
// 打开弹窗
|
||||
const openModal = () => {
|
||||
setVisible(true);
|
||||
getTransferableAgentList().then(data => {
|
||||
setCustomerServiceList(data);
|
||||
});
|
||||
};
|
||||
|
||||
// 关闭弹窗并重置状态
|
||||
const closeModal = () => {
|
||||
setVisible(false);
|
||||
setSelectedTarget(null);
|
||||
setComment("");
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// 确定转给他人
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedTarget) {
|
||||
message.warning("请选择目标客服");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
console.log(currentContact);
|
||||
|
||||
// 调用转接接口
|
||||
if (currentContact) {
|
||||
if ("chatroomId" in currentContact && currentContact.chatroomId) {
|
||||
await WechatFriendAllot({
|
||||
wechatChatroomId: currentContact.id,
|
||||
toAccountId: selectedTarget as number,
|
||||
notifyReceiver: true,
|
||||
comment: comment.trim(),
|
||||
});
|
||||
} else {
|
||||
await WechatFriendAllot({
|
||||
wechatFriendId: currentContact.id,
|
||||
toAccountId: selectedTarget as number,
|
||||
notifyReceiver: true,
|
||||
comment: comment.trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
message.success("转接成功");
|
||||
try {
|
||||
// 删除聊天会话
|
||||
deleteChatSession(currentContact.id);
|
||||
// 删除本地数据库记录
|
||||
if ("chatroomId" in currentContact) {
|
||||
await weChatGroupService.delete(currentContact.id);
|
||||
} else {
|
||||
await contractService.delete(currentContact.id);
|
||||
}
|
||||
} catch (deleteError) {
|
||||
console.error("删除本地数据失败:", deleteError);
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error("转接失败:", error);
|
||||
message.error("转接失败,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 一键转回
|
||||
const handleReturn = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 调用转回接口
|
||||
if (currentContact) {
|
||||
if ("chatroomId" in currentContact && currentContact.chatroomId) {
|
||||
await WechatFriendRebackAllot({
|
||||
wechatChatroomId: currentContact.id,
|
||||
});
|
||||
} else {
|
||||
await WechatFriendRebackAllot({
|
||||
wechatFriendId: currentContact.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
message.success("转回成功");
|
||||
try {
|
||||
// 删除聊天会话
|
||||
deleteChatSession(currentContact.id);
|
||||
// 删除本地数据库记录
|
||||
if ("chatroomId" in currentContact) {
|
||||
await weChatGroupService.delete(currentContact.id);
|
||||
} else {
|
||||
await contractService.delete(currentContact.id);
|
||||
}
|
||||
} catch (deleteError) {
|
||||
console.error("删除本地数据失败:", deleteError);
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error("转回失败:", error);
|
||||
message.error("转回失败,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={className}
|
||||
onClick={openModal}
|
||||
style={{
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<ShareAltOutlined />
|
||||
转给他人
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="转给他人"
|
||||
open={visible}
|
||||
onCancel={closeModal}
|
||||
width={400}
|
||||
centered
|
||||
maskClosable={!loading}
|
||||
footer={[
|
||||
<div
|
||||
key="footer"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Button onClick={handleReturn} disabled={loading}>
|
||||
一键转回
|
||||
</Button>
|
||||
<div>
|
||||
<Button
|
||||
onClick={closeModal}
|
||||
disabled={loading}
|
||||
style={{ marginRight: "8px" }}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedTarget}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
]}
|
||||
>
|
||||
<div style={{ padding: "20px 0" }}>
|
||||
{/* 目标客服选择 */}
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<div
|
||||
style={{ marginBottom: "8px", fontSize: "14px", color: "#333" }}
|
||||
>
|
||||
目标客服
|
||||
</div>
|
||||
<Select
|
||||
placeholder="请选择目标客服"
|
||||
value={selectedTarget}
|
||||
onChange={setSelectedTarget}
|
||||
style={{ width: "100%" }}
|
||||
size="large"
|
||||
disabled={loading}
|
||||
>
|
||||
{customerServiceList.map(item => (
|
||||
<Option key={item.id} value={item.id}>
|
||||
{item.nickname || item.realName} - {item.userName}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 附言输入 */}
|
||||
<div>
|
||||
<div
|
||||
style={{ marginBottom: "8px", fontSize: "14px", color: "#333" }}
|
||||
>
|
||||
附言
|
||||
</div>
|
||||
<TextArea
|
||||
placeholder="请输入附言内容"
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={300}
|
||||
showCount
|
||||
style={{ resize: "none" }}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToContract;
|
||||
@@ -1,291 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Layout, Input, Button, Modal } from "antd";
|
||||
import {
|
||||
SendOutlined,
|
||||
FolderOutlined,
|
||||
PictureOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
import { EmojiPicker } from "@/components/EmojiSeclection";
|
||||
import { EmojiInfo } from "@/components/EmojiSeclection/wechatEmoji";
|
||||
import SimpleFileUpload from "@/components/Upload/SimpleFileUpload";
|
||||
import AudioRecorder from "@/components/Upload/AudioRecorder";
|
||||
import ToContract from "./components/toContract";
|
||||
import ChatRecord from "./components/chatRecord";
|
||||
import styles from "./MessageEnter.module.scss";
|
||||
|
||||
const { Footer } = Layout;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface MessageEnterProps {
|
||||
contract: ContractData | weChatGroup;
|
||||
}
|
||||
|
||||
const { sendCommand } = useWebSocketStore.getState();
|
||||
|
||||
const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [showMaterialModal, setShowMaterialModal] = useState(false);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim()) return;
|
||||
console.log("发送消息", contract);
|
||||
const params = {
|
||||
wechatAccountId: contract.wechatAccountId,
|
||||
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
|
||||
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
||||
msgSubType: 0,
|
||||
msgType: 1,
|
||||
content: inputValue,
|
||||
};
|
||||
sendCommand("CmdSendMessage", params);
|
||||
setInputValue("");
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
// Ctrl+Enter 换行由 TextArea 自动处理,不需要阻止默认行为
|
||||
};
|
||||
|
||||
// 处理表情选择
|
||||
const handleEmojiSelect = (emoji: EmojiInfo) => {
|
||||
setInputValue(prevValue => prevValue + `[${emoji.name}]`);
|
||||
};
|
||||
|
||||
// 根据文件格式判断消息类型
|
||||
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 FileType = {
|
||||
TEXT: 1,
|
||||
IMAGE: 2,
|
||||
VIDEO: 3,
|
||||
AUDIO: 4,
|
||||
FILE: 5,
|
||||
};
|
||||
const handleFileUploaded = (
|
||||
filePath: string | { url: string; durationMs: number },
|
||||
fileType: number,
|
||||
) => {
|
||||
// msgType(1:文本 3:图片 43:视频 47:动图表情包(gif、其他表情包) 49:小程序/其他:图文、文件)
|
||||
let msgType = 1;
|
||||
if ([FileType.TEXT].includes(fileType)) {
|
||||
msgType = getMsgTypeByFileFormat(filePath as string);
|
||||
} else if ([FileType.IMAGE].includes(fileType)) {
|
||||
msgType = 3;
|
||||
} else if ([FileType.AUDIO].includes(fileType)) {
|
||||
msgType = 34;
|
||||
}
|
||||
|
||||
const params = {
|
||||
wechatAccountId: contract.wechatAccountId,
|
||||
wechatChatroomId: contract?.chatroomId ? contract.id : 0,
|
||||
wechatFriendId: contract?.chatroomId ? 0 : contract.id,
|
||||
msgSubType: 0,
|
||||
msgType,
|
||||
content: [FileType.AUDIO].includes(fileType)
|
||||
? JSON.stringify(filePath)
|
||||
: filePath,
|
||||
};
|
||||
sendCommand("CmdSendMessage", params);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 聊天输入 */}
|
||||
<Footer className={styles.chatFooter}>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.inputToolbar}>
|
||||
<div className={styles.leftTool}>
|
||||
<EmojiPicker onEmojiSelect={handleEmojiSelect} />
|
||||
<SimpleFileUpload
|
||||
onFileUploaded={filePath =>
|
||||
handleFileUploaded(filePath, FileType.FILE)
|
||||
}
|
||||
maxSize={1}
|
||||
type={4}
|
||||
slot={
|
||||
<Button
|
||||
className={styles.toolbarButton}
|
||||
type="text"
|
||||
icon={<FolderOutlined />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SimpleFileUpload
|
||||
onFileUploaded={filePath =>
|
||||
handleFileUploaded(filePath, FileType.IMAGE)
|
||||
}
|
||||
maxSize={1}
|
||||
type={1}
|
||||
slot={
|
||||
<Button
|
||||
className={styles.toolbarButton}
|
||||
type="text"
|
||||
icon={<PictureOutlined />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<AudioRecorder
|
||||
onAudioUploaded={audioData =>
|
||||
handleFileUploaded(audioData, FileType.AUDIO)
|
||||
}
|
||||
className={styles.toolbarButton}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.rightTool}>
|
||||
<ToContract className={styles.rightToolItem} />
|
||||
<ChatRecord className={styles.rightToolItem} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.inputArea}>
|
||||
<div className={styles.inputWrapper}>
|
||||
<TextArea
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder="输入消息..."
|
||||
className={styles.messageInput}
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
/>
|
||||
<div className={styles.sendButtonArea}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim()}
|
||||
className={styles.sendButton}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.inputHint}>按下Ctrl+Enter换行,Enter发送</div>
|
||||
</div>
|
||||
</Footer>
|
||||
|
||||
{/* 素材选择模态框 */}
|
||||
<Modal
|
||||
title="选择素材"
|
||||
open={showMaterialModal}
|
||||
onCancel={() => setShowMaterialModal(false)}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => setShowMaterialModal(false)}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="confirm"
|
||||
type="primary"
|
||||
onClick={() => setShowMaterialModal(false)}
|
||||
>
|
||||
确定
|
||||
</Button>,
|
||||
]}
|
||||
width={800}
|
||||
>
|
||||
<div style={{ display: "flex", height: "400px" }}>
|
||||
{/* 左侧素材分类 */}
|
||||
<div
|
||||
style={{
|
||||
width: "200px",
|
||||
background: "#f5f5f5",
|
||||
borderRight: "1px solid #e8e8e8",
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "16px", borderBottom: "1px solid #e8e8e8" }}>
|
||||
<h4 style={{ margin: 0, color: "#262626" }}>公共素材</h4>
|
||||
</div>
|
||||
<div style={{ padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
cursor: "pointer",
|
||||
background: "#e6f7ff",
|
||||
borderLeft: "3px solid #1890ff",
|
||||
color: "#1890ff",
|
||||
}}
|
||||
>
|
||||
暗黑4
|
||||
</div>
|
||||
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
|
||||
针对老客户的...
|
||||
</div>
|
||||
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
|
||||
D2辅助
|
||||
</div>
|
||||
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
|
||||
ROS反馈演示...
|
||||
</div>
|
||||
<div style={{ padding: "8px 16px", cursor: "pointer" }}>
|
||||
一键宏产品素...
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: "16px", borderTop: "1px solid #e8e8e8" }}>
|
||||
<h4 style={{ margin: 0, color: "#262626" }}>部门素材</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧内容区域 */}
|
||||
<div style={{ flex: 1, padding: "16px" }}>
|
||||
<div style={{ marginBottom: "16px" }}>
|
||||
<Input.Search placeholder="昵称" style={{ width: "100%" }} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "300px",
|
||||
color: "#8c8c8c",
|
||||
}}
|
||||
>
|
||||
请选择左侧素材分类
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageEnter;
|
||||
@@ -1,584 +0,0 @@
|
||||
// 消息容器
|
||||
.messagesContainer {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: #f5f5f5;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #d9d9d9;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #bfbfbf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 时间分组
|
||||
.messageTime {
|
||||
text-align: center;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
margin: 8px 0;
|
||||
position: relative;
|
||||
font-weight: normal;
|
||||
a {
|
||||
color: #1890ff;
|
||||
margin: 0px 5px;
|
||||
}
|
||||
}
|
||||
.loadMore {
|
||||
text-align: center;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// 消息项
|
||||
.messageItem {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&.ownMessage {
|
||||
justify-content: flex-end;
|
||||
|
||||
.messageContent {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.messageBubble {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&.otherMessage {
|
||||
justify-content: flex-start;
|
||||
|
||||
.messageBubble {
|
||||
background: white;
|
||||
color: #262626;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 消息内容容器
|
||||
.messageContent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// 头像
|
||||
.messageAvatar {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
// 消息气泡
|
||||
.messageBubble {
|
||||
padding: 8px 12px;
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// 发送者名称
|
||||
.messageSender {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 普通文本消息
|
||||
.messageText {
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// 表情包消息
|
||||
.emojiMessage {
|
||||
img {
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 视频消息
|
||||
.videoMessage {
|
||||
.videoContainer {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
|
||||
.playButton {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.videoPreview {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.videoThumbnail {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.videoPlayIcon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loadingSpinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.playButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
|
||||
svg {
|
||||
margin-left: 2px; // 视觉居中调整
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 图片消息
|
||||
.imageMessage {
|
||||
img {
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 小程序消息基础样式
|
||||
.miniProgramMessage {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
|
||||
// 通用小程序卡片基础样式
|
||||
.miniProgramCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e1e8ed;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 280px;
|
||||
min-height: 64px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 通用小程序元素样式
|
||||
.miniProgramThumb {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
background: linear-gradient(135deg, #f0f2f5 0%, #e6f7ff 100%);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
transition: transform 0.2s ease;
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.miniProgramInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.miniProgramTitle {
|
||||
padding-left: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.miniProgramApp {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 类型1小程序样式(默认横向布局)
|
||||
.miniProgramType1 {
|
||||
// 继承基础样式,无需额外定义
|
||||
}
|
||||
|
||||
// 类型2小程序样式(垂直图片布局)
|
||||
.miniProgramType2 {
|
||||
.miniProgramCardType2 {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
min-height: 220px;
|
||||
max-width: 280px;
|
||||
|
||||
.miniProgramAppTop {
|
||||
padding: 12px 16px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::before {
|
||||
content: "📱";
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.miniProgramImageArea {
|
||||
width: calc(100% - 32px);
|
||||
height: 0;
|
||||
padding-bottom: 75%; // 4:3 宽高比
|
||||
margin: 0px 16px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #f8f9fa;
|
||||
|
||||
.miniProgramImage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
border: 0.5px solid #e1e8ed;
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.miniProgramContent {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.miniProgramTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.miniProgramIdentifier {
|
||||
font-size: 11px;
|
||||
color: #6c757d;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
gap: 3px;
|
||||
|
||||
&::before {
|
||||
content: "🏷️";
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 链接类型消息样式
|
||||
.linkMessage {
|
||||
.linkCard {
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.linkDescription {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
margin: 4px 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
// 文章类型消息样式
|
||||
.articleMessage {
|
||||
.articleCard {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 16px;
|
||||
min-height: auto;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.articleTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 12px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.articleContent {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.articleTextArea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.articleDescription {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.articleImageArea {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.articleImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// 文件消息样式
|
||||
.fileMessage {
|
||||
.fileCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
max-width: 250px;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fileAction {
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.messageBubble {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.videoMessage .videoPreview {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
// 小程序消息移动端适配
|
||||
.miniProgramMessage {
|
||||
.miniProgramCard {
|
||||
max-width: 260px;
|
||||
padding: 10px 14px;
|
||||
min-height: 56px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.miniProgramThumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.miniProgramTitle {
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.miniProgramApp {
|
||||
font-size: 11px;
|
||||
padding: 1px 4px;
|
||||
|
||||
&::before {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// 消息气泡样式
|
||||
.messageBubble {
|
||||
word-wrap: break-word;
|
||||
border-radius: 8px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
// 音频错误提示样式
|
||||
.audioError {
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 8px;
|
||||
background: #fff2f0;
|
||||
border: 1px solid #ffccc7;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 语音消息容器
|
||||
.audioMessage {
|
||||
min-width: 200px;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 音频控制容器
|
||||
.audioContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
// 播放图标
|
||||
.audioIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
// 音频内容区域
|
||||
.audioContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 波形动画容器
|
||||
.audioWaveform {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 30px; // 固定高度防止抖动
|
||||
}
|
||||
|
||||
// 波形条
|
||||
.waveBar {
|
||||
width: 3px;
|
||||
background-color: #d9d9d9;
|
||||
border-radius: 1.5px;
|
||||
transition: all 0.3s ease;
|
||||
transform-origin: center; // 设置变换原点为中心
|
||||
|
||||
&.playing {
|
||||
animation: waveAnimation 1.5s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// 音频时长显示
|
||||
.audioDuration {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 音频文本显示
|
||||
.audioText {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
// 进度条容器
|
||||
.audioProgress {
|
||||
margin-top: 8px;
|
||||
height: 2px;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 进度条
|
||||
.audioProgressBar {
|
||||
height: 100%;
|
||||
background-color: #1890ff;
|
||||
border-radius: 1px;
|
||||
transition: width 0.1s ease;
|
||||
}
|
||||
|
||||
// 波形动画
|
||||
@keyframes waveAnimation {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.5);
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1.2);
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { PauseCircleFilled, SoundOutlined } from "@ant-design/icons";
|
||||
import styles from "./AudioMessage.module.scss";
|
||||
|
||||
interface AudioMessageProps {
|
||||
audioUrl: string;
|
||||
msgId: string;
|
||||
}
|
||||
|
||||
interface AudioData {
|
||||
durationMs?: number;
|
||||
url: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// 解析音频URL,支持两种格式:纯URL字符串和JSON字符串
|
||||
const parseAudioUrl = (audioUrl: string): AudioData => {
|
||||
try {
|
||||
// 尝试解析为JSON
|
||||
const parsed = JSON.parse(audioUrl);
|
||||
if (parsed.url) {
|
||||
return {
|
||||
durationMs: parsed.durationMs,
|
||||
url: parsed.url,
|
||||
text: parsed.text,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果解析失败,说明是纯URL字符串
|
||||
}
|
||||
|
||||
// 返回纯URL格式
|
||||
return {
|
||||
url: audioUrl,
|
||||
};
|
||||
};
|
||||
|
||||
// 测试音频URL是否可访问(避免CORS问题)
|
||||
const testAudioUrl = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
// 对于阿里云OSS等外部资源,直接返回true,让Audio对象自己处理
|
||||
// 避免fetch HEAD请求触发CORS问题
|
||||
if (url.includes(".aliyuncs.com") || url.includes("oss-")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { method: "HEAD" });
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
// 如果fetch失败(可能是CORS问题),返回true让Audio对象尝试加载
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const AudioMessage: React.FC<AudioMessageProps> = ({ audioUrl, msgId }) => {
|
||||
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null);
|
||||
const [audioProgress, setAudioProgress] = useState<Record<string, number>>(
|
||||
{},
|
||||
);
|
||||
const [audioError, setAudioError] = useState<string | null>(null);
|
||||
const audioRefs = useRef<Record<string, HTMLAudioElement>>({});
|
||||
|
||||
// 解析音频数据
|
||||
const audioData = parseAudioUrl(audioUrl);
|
||||
const actualAudioUrl = audioData.url;
|
||||
const audioDuration = audioData.durationMs;
|
||||
const audioText = audioData.text;
|
||||
|
||||
const audioId = `audio_${msgId}_${Date.now()}`;
|
||||
const isPlaying = playingAudioId === audioId;
|
||||
const progress = audioProgress[audioId] || 0;
|
||||
|
||||
// 格式化时长显示
|
||||
const formatDuration = (ms?: number): string => {
|
||||
if (!ms) return "语音";
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// 播放/暂停音频
|
||||
const handleAudioToggle = async () => {
|
||||
const audio = audioRefs.current[audioId];
|
||||
if (!audio) {
|
||||
// 先测试URL是否可访问
|
||||
const isUrlAccessible = await testAudioUrl(actualAudioUrl);
|
||||
if (!isUrlAccessible) {
|
||||
setAudioError("音频文件无法访问,请检查网络连接");
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的错误状态
|
||||
setAudioError(null);
|
||||
|
||||
const newAudio = new Audio();
|
||||
|
||||
// 对于阿里云OSS等外部资源,不设置crossOrigin避免CORS问题
|
||||
// 只有在需要访问音频数据时才设置crossOrigin
|
||||
if (
|
||||
!actualAudioUrl.includes(".aliyuncs.com") &&
|
||||
!actualAudioUrl.includes("oss-")
|
||||
) {
|
||||
newAudio.crossOrigin = "anonymous";
|
||||
}
|
||||
newAudio.preload = "metadata";
|
||||
|
||||
audioRefs.current[audioId] = newAudio;
|
||||
|
||||
newAudio.addEventListener("timeupdate", () => {
|
||||
const currentProgress =
|
||||
(newAudio.currentTime / newAudio.duration) * 100;
|
||||
setAudioProgress(prev => ({
|
||||
...prev,
|
||||
[audioId]: currentProgress,
|
||||
}));
|
||||
});
|
||||
|
||||
newAudio.addEventListener("ended", () => {
|
||||
setPlayingAudioId(null);
|
||||
setAudioProgress(prev => ({ ...prev, [audioId]: 0 }));
|
||||
});
|
||||
|
||||
newAudio.addEventListener("error", e => {
|
||||
setPlayingAudioId(null);
|
||||
setAudioError("音频播放失败,请稍后重试");
|
||||
});
|
||||
|
||||
// 设置音频源并尝试播放
|
||||
newAudio.src = actualAudioUrl;
|
||||
|
||||
try {
|
||||
await newAudio.play();
|
||||
setPlayingAudioId(audioId);
|
||||
} catch (error) {
|
||||
setPlayingAudioId(null);
|
||||
setAudioError("音频播放失败,请检查音频格式或网络连接");
|
||||
console.error("音频播放错误:", error);
|
||||
}
|
||||
} else {
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setPlayingAudioId(null);
|
||||
} else {
|
||||
// 停止其他正在播放的音频
|
||||
Object.values(audioRefs.current).forEach(a => a.pause());
|
||||
setPlayingAudioId(null);
|
||||
|
||||
try {
|
||||
await audio.play();
|
||||
setPlayingAudioId(audioId);
|
||||
} catch (error) {
|
||||
setPlayingAudioId(null);
|
||||
setAudioError("音频播放失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.messageBubble}>
|
||||
{audioError && (
|
||||
<div
|
||||
className={styles.audioError}
|
||||
onClick={() => {
|
||||
setAudioError(null);
|
||||
handleAudioToggle();
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
title="点击重试"
|
||||
>
|
||||
{audioError} (点击重试)
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.audioMessage}>
|
||||
<div className={styles.audioContainer} onClick={handleAudioToggle}>
|
||||
<div className={styles.audioIcon}>
|
||||
{isPlaying ? (
|
||||
<PauseCircleFilled
|
||||
style={{ fontSize: "20px", color: "#1890ff" }}
|
||||
/>
|
||||
) : (
|
||||
<SoundOutlined style={{ fontSize: "20px", color: "#666" }} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.audioContent}>
|
||||
<div className={styles.audioWaveform}>
|
||||
{/* 音频波形效果 */}
|
||||
{Array.from({ length: 20 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.waveBar} ${isPlaying ? styles.playing : ""}`}
|
||||
style={{
|
||||
height: `${Math.random() * 20 + 10}px`,
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
backgroundColor: progress > i * 5 ? "#1890ff" : "#d9d9d9",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.audioDuration}>
|
||||
{formatDuration(audioDuration)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{progress > 0 && (
|
||||
<div className={styles.audioProgress}>
|
||||
<div
|
||||
className={styles.audioProgressBar}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{audioText && (
|
||||
<div className={styles.audioText} title={audioText}>
|
||||
{audioText.length > 10
|
||||
? `${audioText.substring(0, 10)}...`
|
||||
: audioText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioMessage;
|
||||
@@ -1,315 +0,0 @@
|
||||
// 通用消息文本样式
|
||||
.messageText {
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// 小程序消息基础样式
|
||||
.miniProgramMessage {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
|
||||
// 通用小程序卡片基础样式
|
||||
.miniProgramCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e1e8ed;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 280px;
|
||||
min-height: 64px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 通用小程序元素样式
|
||||
.miniProgramThumb {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
background: linear-gradient(135deg, #f0f2f5 0%, #e6f7ff 100%);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
transition: transform 0.2s ease;
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.miniProgramInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.miniProgramTitle {
|
||||
padding-left: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.miniProgramApp {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
line-height: 1.2;
|
||||
font-weight: 500;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 类型1小程序样式(默认横向布局)
|
||||
.miniProgramType1 {
|
||||
// 继承基础样式,无需额外定义
|
||||
}
|
||||
|
||||
// 类型2小程序样式(垂直图片布局)
|
||||
.miniProgramType2 {
|
||||
.miniProgramCardType2 {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
min-height: 220px;
|
||||
max-width: 280px;
|
||||
|
||||
.miniProgramAppTop {
|
||||
padding: 12px 16px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::before {
|
||||
content: "📱";
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.miniProgramImageArea {
|
||||
width: calc(100% - 32px);
|
||||
height: 0;
|
||||
padding-bottom: 75%; // 4:3 宽高比
|
||||
margin: 0px 16px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: #f8f9fa;
|
||||
|
||||
.miniProgramImage {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
border: 0.5px solid #e1e8ed;
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.miniProgramContent {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.miniProgramTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.miniProgramIdentifier {
|
||||
font-size: 11px;
|
||||
color: #6c757d;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
gap: 3px;
|
||||
|
||||
&::before {
|
||||
content: "🏷️";
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文章类型消息样式
|
||||
.articleMessage {
|
||||
.articleCard {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 16px;
|
||||
min-height: auto;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.articleTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 12px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.articleContent {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.articleTextArea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.articleDescription {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.articleImageArea {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.articleImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
// 文件消息样式
|
||||
.fileMessage {
|
||||
.fileCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
max-width: 250px;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
font-size: 24px;
|
||||
color: #1890ff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fileAction {
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
// 小程序消息移动端适配
|
||||
.miniProgramMessage {
|
||||
.miniProgramCard {
|
||||
max-width: 260px;
|
||||
padding: 10px 14px;
|
||||
min-height: 56px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.miniProgramThumb {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.miniProgramTitle {
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.miniProgramApp {
|
||||
font-size: 11px;
|
||||
padding: 1px 4px;
|
||||
|
||||
&::before {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import React from "react";
|
||||
import { parseWeappMsgStr } from "@/utils/common";
|
||||
import styles from "./SmallProgramMessage.module.scss";
|
||||
|
||||
interface SmallProgramMessageProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const SmallProgramMessage: React.FC<SmallProgramMessageProps> = ({
|
||||
content,
|
||||
}) => {
|
||||
// 统一的错误消息渲染函数
|
||||
const renderErrorMessage = (fallbackText: string) => (
|
||||
<div className={styles.messageText}>{fallbackText}</div>
|
||||
);
|
||||
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[小程序/文章/文件消息 - 无效内容]");
|
||||
}
|
||||
|
||||
try {
|
||||
const trimmedContent = content.trim();
|
||||
|
||||
// 尝试解析JSON格式的消息
|
||||
if (trimmedContent.startsWith("{") && trimmedContent.endsWith("}")) {
|
||||
const messageData = JSON.parse(trimmedContent);
|
||||
|
||||
// 处理文章类型消息
|
||||
if (messageData.type === "link" && messageData.title && messageData.url) {
|
||||
const { title, desc, thumbPath, url } = messageData;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.miniProgramMessage} ${styles.articleMessage}`}
|
||||
>
|
||||
<div
|
||||
className={`${styles.miniProgramCard} ${styles.articleCard}`}
|
||||
onClick={() => window.open(url, "_blank")}
|
||||
>
|
||||
{/* 标题在第一行 */}
|
||||
<div className={styles.articleTitle}>{title}</div>
|
||||
|
||||
{/* 下方:文字在左,图片在右 */}
|
||||
<div className={styles.articleContent}>
|
||||
<div className={styles.articleTextArea}>
|
||||
{desc && (
|
||||
<div className={styles.articleDescription}>{desc}</div>
|
||||
)}
|
||||
</div>
|
||||
{thumbPath && (
|
||||
<div className={styles.articleImageArea}>
|
||||
<img
|
||||
src={thumbPath}
|
||||
alt="文章缩略图"
|
||||
className={styles.articleImage}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.miniProgramApp}>文章</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 处理小程序消息 - 统一使用parseWeappMsgStr解析
|
||||
if (messageData.type === "miniprogram" && messageData.contentXml) {
|
||||
try {
|
||||
const parsedData = parseWeappMsgStr(trimmedContent);
|
||||
|
||||
if (parsedData.appmsg) {
|
||||
const { appmsg } = parsedData;
|
||||
const title = appmsg.title || "小程序消息";
|
||||
const appName =
|
||||
appmsg.sourcedisplayname || appmsg.appname || "小程序";
|
||||
|
||||
// 获取小程序类型
|
||||
const miniProgramType =
|
||||
appmsg.weappinfo && appmsg.weappinfo.type
|
||||
? parseInt(appmsg.weappinfo.type)
|
||||
: 1;
|
||||
|
||||
// 根据type类型渲染不同布局
|
||||
if (miniProgramType === 2) {
|
||||
// 类型2:图片区域布局
|
||||
return (
|
||||
<div
|
||||
className={`${styles.miniProgramMessage} ${styles.miniProgramType2}`}
|
||||
>
|
||||
<div
|
||||
className={`${styles.miniProgramCard} ${styles.miniProgramCardType2}`}
|
||||
>
|
||||
<div className={styles.miniProgramAppTop}>{appName}</div>
|
||||
<div className={styles.miniProgramTitle}>{title}</div>
|
||||
<div className={styles.miniProgramImageArea}>
|
||||
<img
|
||||
src={parsedData.previewImage}
|
||||
alt="小程序图片"
|
||||
className={styles.miniProgramImage}
|
||||
onError={e => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.miniProgramContent}>
|
||||
<div className={styles.miniProgramIdentifier}>小程序</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error("parseWeappMsgStr解析失败:", parseError);
|
||||
return renderErrorMessage("[小程序消息 - 解析失败]");
|
||||
}
|
||||
}
|
||||
|
||||
// 验证传统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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 增强的文件消息处理
|
||||
const isFileUrl =
|
||||
content.startsWith("http") ||
|
||||
content.startsWith("https") ||
|
||||
content.startsWith("file://") ||
|
||||
/\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i.test(content);
|
||||
|
||||
if (isFileUrl) {
|
||||
// 尝试从URL中提取文件名
|
||||
const fileName = content.split("/").pop()?.split("?")[0] || "文件";
|
||||
const fileExtension = fileName.split(".").pop()?.toLowerCase();
|
||||
|
||||
// 根据文件类型选择图标
|
||||
let fileIcon = "📄";
|
||||
if (fileExtension) {
|
||||
const iconMap: { [key: string]: string } = {
|
||||
pdf: "📕",
|
||||
doc: "📘",
|
||||
docx: "📘",
|
||||
xls: "📗",
|
||||
xlsx: "📗",
|
||||
ppt: "📙",
|
||||
pptx: "📙",
|
||||
txt: "📝",
|
||||
zip: "🗜️",
|
||||
rar: "🗜️",
|
||||
"7z": "🗜️",
|
||||
jpg: "🖼️",
|
||||
jpeg: "🖼️",
|
||||
png: "🖼️",
|
||||
gif: "🖼️",
|
||||
mp4: "🎬",
|
||||
avi: "🎬",
|
||||
mov: "🎬",
|
||||
mp3: "🎵",
|
||||
wav: "🎵",
|
||||
flac: "🎵",
|
||||
};
|
||||
fileIcon = iconMap[fileExtension] || "📄";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.fileMessage}>
|
||||
<div className={styles.fileCard}>
|
||||
<div className={styles.fileIcon}>{fileIcon}</div>
|
||||
<div className={styles.fileInfo}>
|
||||
<div className={styles.fileName}>
|
||||
{fileName.length > 20
|
||||
? fileName.substring(0, 20) + "..."
|
||||
: fileName}
|
||||
</div>
|
||||
<div
|
||||
className={styles.fileAction}
|
||||
onClick={() => {
|
||||
try {
|
||||
window.open(content, "_blank");
|
||||
} catch (e) {
|
||||
console.error("文件打开失败:", e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
点击查看
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderErrorMessage("[小程序/文件消息]");
|
||||
} catch (e) {
|
||||
console.warn("小程序/文件消息解析失败:", e);
|
||||
return renderErrorMessage("[小程序/文件消息 - 解析失败]");
|
||||
}
|
||||
};
|
||||
|
||||
export default SmallProgramMessage;
|
||||
@@ -1,153 +0,0 @@
|
||||
// 通用消息文本样式
|
||||
.messageText {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
padding: 8px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
// 消息气泡样式
|
||||
.messageBubble {
|
||||
display: inline-block;
|
||||
max-width: 70%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
word-wrap: break-word;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// 视频消息样式
|
||||
.videoMessage {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
background: #000;
|
||||
|
||||
.videoContainer {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
||||
video {
|
||||
display: block;
|
||||
max-width: 300px;
|
||||
max-height: 400px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.videoThumbnail {
|
||||
display: block;
|
||||
max-width: 300px;
|
||||
max-height: 400px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.videoPlayIcon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
|
||||
.loadingSpinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 4px solid #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.playButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
|
||||
svg {
|
||||
margin-left: 2px; // 视觉居中调整
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.messageBubble {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.videoMessage .videoThumbnail,
|
||||
.videoMessage .videoContainer video {
|
||||
max-width: 200px;
|
||||
max-height: 250px;
|
||||
}
|
||||
|
||||
.videoMessage .videoPlayIcon {
|
||||
.loadingSpinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-width: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.videoMessage .downloadButton {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
|
||||
svg {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import React from "react";
|
||||
import { DownloadOutlined, PlayCircleFilled } from "@ant-design/icons";
|
||||
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
import styles from "./VideoMessage.module.scss";
|
||||
|
||||
interface VideoMessageProps {
|
||||
content: string;
|
||||
msg: ChatRecord;
|
||||
contract: ContractData | weChatGroup;
|
||||
}
|
||||
|
||||
const VideoMessage: React.FC<VideoMessageProps> = ({
|
||||
content,
|
||||
msg,
|
||||
contract,
|
||||
}) => {
|
||||
// 检测是否为直接视频链接的函数
|
||||
const isDirectVideoLink = (content: string): boolean => {
|
||||
const trimmedContent = content.trim();
|
||||
return (
|
||||
trimmedContent.startsWith("http") &&
|
||||
(trimmedContent.includes(".mp4") ||
|
||||
trimmedContent.includes(".mov") ||
|
||||
trimmedContent.includes(".avi") ||
|
||||
trimmedContent.includes("video"))
|
||||
);
|
||||
};
|
||||
|
||||
// 处理视频播放请求,发送socket请求获取真实视频地址
|
||||
const handleVideoPlayRequest = (tencentUrl: string, messageId: number) => {
|
||||
console.log("发送视频下载请求:", { messageId, tencentUrl });
|
||||
|
||||
// 先设置加载状态
|
||||
useWeChatStore.getState().setVideoLoading(messageId, true);
|
||||
|
||||
// 构建socket请求数据
|
||||
useWebSocketStore.getState().sendCommand("CmdDownloadVideo", {
|
||||
chatroomMessageId: contract.chatroomId ? messageId : 0,
|
||||
friendMessageId: contract.chatroomId ? 0 : messageId,
|
||||
seq: `${+new Date()}`, // 使用时间戳作为请求序列号
|
||||
tencentUrl: tencentUrl,
|
||||
wechatAccountId: contract.wechatAccountId,
|
||||
});
|
||||
};
|
||||
|
||||
// 渲染错误消息
|
||||
const renderErrorMessage = (message: string) => (
|
||||
<div className={styles.messageText}>{message}</div>
|
||||
);
|
||||
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[视频消息 - 无效内容]");
|
||||
}
|
||||
|
||||
// 如果content是直接的视频链接(已预览过或下载好的视频)
|
||||
if (isDirectVideoLink(content)) {
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer}>
|
||||
<video
|
||||
controls
|
||||
src={content}
|
||||
style={{ maxWidth: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
<a
|
||||
href={content}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试解析JSON格式的视频数据
|
||||
if (content.startsWith("{") && content.endsWith("}")) {
|
||||
const videoData = JSON.parse(content);
|
||||
|
||||
// 验证必要的视频数据字段
|
||||
if (
|
||||
videoData &&
|
||||
typeof videoData === "object" &&
|
||||
videoData.previewImage &&
|
||||
videoData.tencentUrl
|
||||
) {
|
||||
const previewImageUrl = String(videoData.previewImage).replace(
|
||||
/[`"']/g,
|
||||
"",
|
||||
);
|
||||
|
||||
// 创建点击处理函数
|
||||
const handlePlayClick = (e: React.MouseEvent, msg: ChatRecord) => {
|
||||
e.stopPropagation();
|
||||
// 如果没有视频URL且不在加载中,则发起下载请求
|
||||
if (!videoData.videoUrl && !videoData.isLoading) {
|
||||
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 如果已有视频URL,显示视频播放器
|
||||
if (videoData.videoUrl) {
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer}>
|
||||
<video
|
||||
controls
|
||||
src={videoData.videoUrl}
|
||||
style={{ maxWidth: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
<a
|
||||
href={videoData.videoUrl}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示预览图,根据加载状态显示不同的图标
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.videoMessage}>
|
||||
<div
|
||||
className={styles.videoContainer}
|
||||
onClick={e => handlePlayClick(e, msg)}
|
||||
>
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt="视频预览"
|
||||
className={styles.videoThumbnail}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
borderRadius: "8px",
|
||||
opacity: videoData.isLoading ? "0.7" : "1",
|
||||
}}
|
||||
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.videoPlayIcon}>
|
||||
{videoData.isLoading ? (
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
) : (
|
||||
<PlayCircleFilled
|
||||
style={{ fontSize: "48px", color: "#fff" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return renderErrorMessage("[视频消息]");
|
||||
} catch (e) {
|
||||
console.warn("视频消息解析失败:", e);
|
||||
return renderErrorMessage("[视频消息 - 解析失败]");
|
||||
}
|
||||
};
|
||||
|
||||
export default VideoMessage;
|
||||
@@ -1,588 +0,0 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Avatar, Divider } from "antd";
|
||||
import { UserOutlined, LoadingOutlined } from "@ant-design/icons";
|
||||
import AudioMessage from "./components/AudioMessage/AudioMessage";
|
||||
import SmallProgramMessage from "./components/SmallProgramMessage";
|
||||
import VideoMessage from "./components/VideoMessage";
|
||||
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import { formatWechatTime } from "@/utils/common";
|
||||
import { getEmojiPath } from "@/components/EmojiSeclection/wechatEmoji";
|
||||
import styles from "./MessageRecord.module.scss";
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
|
||||
interface MessageRecordProps {
|
||||
contract: ContractData | weChatGroup;
|
||||
}
|
||||
const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentMessages = useWeChatStore(state => state.currentMessages);
|
||||
const loadChatMessages = useWeChatStore(state => state.loadChatMessages);
|
||||
const messagesLoading = useWeChatStore(state => state.messagesLoading);
|
||||
const isLoadingData = useWeChatStore(state => state.isLoadingData);
|
||||
const currentGroupMembers = useWeChatStore(
|
||||
state => state.currentGroupMembers,
|
||||
);
|
||||
const prevMessagesRef = useRef(currentMessages);
|
||||
|
||||
// 判断是否为表情包URL的工具函数
|
||||
const isEmojiUrl = (content: string): boolean => {
|
||||
return (
|
||||
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") ||
|
||||
/\.(gif|webp|png|jpg|jpeg)$/i.test(content) ||
|
||||
content.includes("emoji") ||
|
||||
content.includes("sticker") ||
|
||||
content.includes("expression")
|
||||
);
|
||||
};
|
||||
|
||||
// 解析表情包文字格式[表情名称]并替换为img标签
|
||||
const parseEmojiText = (text: string): React.ReactNode[] => {
|
||||
const emojiRegex = /\[([^\]]+)\]/g;
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = emojiRegex.exec(text)) !== null) {
|
||||
// 添加表情前的文字
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// 获取表情名称并查找对应路径
|
||||
const emojiName = match[1];
|
||||
const emojiPath = getEmojiPath(emojiName as any);
|
||||
|
||||
if (emojiPath) {
|
||||
// 如果找到表情,添加img标签
|
||||
parts.push(
|
||||
<img
|
||||
key={`emoji-${match.index}`}
|
||||
src={emojiPath}
|
||||
alt={emojiName}
|
||||
className={styles.emojiImage}
|
||||
style={{
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
margin: "0 2px",
|
||||
display: "inline",
|
||||
lineHeight: "20px",
|
||||
float: "left",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
// 如果没找到表情,保持原文字
|
||||
parts.push(match[0]);
|
||||
}
|
||||
|
||||
lastIndex = emojiRegex.lastIndex;
|
||||
}
|
||||
|
||||
// 添加剩余的文字
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const prevMessages = prevMessagesRef.current;
|
||||
|
||||
const hasVideoStateChange = currentMessages.some((msg, index) => {
|
||||
// 首先检查消息对象本身是否为null或undefined
|
||||
if (!msg || !msg.content) return false;
|
||||
|
||||
const prevMsg = prevMessages[index];
|
||||
if (!prevMsg || !prevMsg.content || prevMsg.id !== msg.id) return false;
|
||||
|
||||
try {
|
||||
const currentContent =
|
||||
typeof msg.content === "string"
|
||||
? JSON.parse(msg.content)
|
||||
: msg.content;
|
||||
const prevContent =
|
||||
typeof prevMsg.content === "string"
|
||||
? JSON.parse(prevMsg.content)
|
||||
: prevMsg.content;
|
||||
|
||||
// 检查视频状态是否发生变化(开始加载、完成加载、获得URL)
|
||||
const currentHasVideo =
|
||||
currentContent.previewImage && currentContent.tencentUrl;
|
||||
const prevHasVideo = prevContent.previewImage && prevContent.tencentUrl;
|
||||
|
||||
if (currentHasVideo && prevHasVideo) {
|
||||
// 检查加载状态变化或视频URL变化
|
||||
return (
|
||||
currentContent.isLoading !== prevContent.isLoading ||
|
||||
currentContent.videoUrl !== prevContent.videoUrl
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 只有在没有视频状态变化时才自动滚动到底部
|
||||
if (!hasVideoStateChange && isLoadingData) {
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// 更新上一次的消息状态
|
||||
prevMessagesRef.current = currentMessages;
|
||||
}, [currentMessages, isLoadingData]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
// 解析消息内容,根据msgType判断消息类型并返回对应的渲染内容
|
||||
const parseMessageContent = (
|
||||
content: string | null | undefined,
|
||||
msg: ChatRecord,
|
||||
msgType?: number,
|
||||
) => {
|
||||
// 处理null或undefined的内容
|
||||
if (content === null || content === undefined) {
|
||||
return <div className={styles.messageText}>消息内容不可用</div>;
|
||||
}
|
||||
|
||||
// 统一的错误消息渲染函数
|
||||
const renderErrorMessage = (fallbackText: string) => (
|
||||
<div className={styles.messageText}>{fallbackText}</div>
|
||||
);
|
||||
|
||||
// 根据msgType进行消息类型判断
|
||||
switch (msgType) {
|
||||
case 1: // 文本消息
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.messageText}>{parseEmojiText(content)}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3: // 图片消息
|
||||
// 验证是否为有效的图片URL
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[图片消息 - 无效链接]");
|
||||
}
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.imageMessage}>
|
||||
<img
|
||||
src={content}
|
||||
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 43: // 视频消息
|
||||
return (
|
||||
<VideoMessage content={content || ""} msg={msg} contract={contract} />
|
||||
);
|
||||
|
||||
case 47: // 动图表情包(gif、其他表情包)
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[表情包 - 无效链接]");
|
||||
}
|
||||
|
||||
// 使用工具函数判断表情包URL
|
||||
if (isEmojiUrl(content)) {
|
||||
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("[表情包]");
|
||||
|
||||
case 34: // 语音消息
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[语音消息 - 无效内容]");
|
||||
}
|
||||
|
||||
// content直接是音频URL字符串
|
||||
return <AudioMessage audioUrl={content} msgId={String(msg.id)} />;
|
||||
|
||||
case 49: // 小程序/文章/其他:图文、文件
|
||||
return <SmallProgramMessage content={content || ""} />;
|
||||
|
||||
default: {
|
||||
// 兼容旧版本和未知消息类型的处理逻辑
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage(
|
||||
`[未知消息类型${msgType ? ` - ${msgType}` : ""}]`,
|
||||
);
|
||||
}
|
||||
|
||||
// 智能识别消息类型(兼容旧版本数据)
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取群成员头像
|
||||
const groupMemberAvatar = (msg: ChatRecord) => {
|
||||
const groupMembers = currentGroupMembers.find(
|
||||
v => v?.wechatId == msg?.sender?.wechatId,
|
||||
);
|
||||
return groupMembers?.avatar;
|
||||
};
|
||||
|
||||
// 清理微信ID前缀
|
||||
const clearWechatidInContent = (sender: any, content: string) => {
|
||||
try {
|
||||
return content.replace(new RegExp(`${sender?.wechatId}:\n`, "g"), "");
|
||||
} catch (err) {
|
||||
return "-";
|
||||
}
|
||||
};
|
||||
|
||||
// 用于分组消息并添加时间戳的辅助函数
|
||||
const groupMessagesByTime = (messages: ChatRecord[]) => {
|
||||
return messages
|
||||
.filter(msg => msg !== null && msg !== undefined) // 过滤掉null和undefined的消息
|
||||
.map(msg => ({
|
||||
time: formatWechatTime(msg?.wechatTime),
|
||||
messages: [msg],
|
||||
}));
|
||||
};
|
||||
|
||||
// 渲染单条消息
|
||||
const renderMessage = (msg: ChatRecord) => {
|
||||
// 添加null检查,防止访问null对象的属性
|
||||
if (!msg) return null;
|
||||
|
||||
const isOwn = msg?.isSend;
|
||||
const isGroup = !!contract.chatroomId;
|
||||
return (
|
||||
<div
|
||||
key={msg.id || `msg-${Date.now()}`}
|
||||
className={`${styles.messageItem} ${
|
||||
isOwn ? styles.ownMessage : styles.otherMessage
|
||||
}`}
|
||||
>
|
||||
<div className={styles.messageContent}>
|
||||
{/* 如果不是群聊 */}
|
||||
{!isGroup && !isOwn && (
|
||||
<>
|
||||
<Avatar
|
||||
size={32}
|
||||
src={contract.avatar}
|
||||
icon={<UserOutlined />}
|
||||
className={styles.messageAvatar}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{!isOwn && (
|
||||
<div className={styles.messageSender}>
|
||||
{contract.nickname}
|
||||
</div>
|
||||
)}
|
||||
<>{parseMessageContent(msg?.content, msg, msg?.msgType)}</>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* 如果是群聊 */}
|
||||
{isGroup && !isOwn && (
|
||||
<>
|
||||
<Avatar
|
||||
size={32}
|
||||
src={groupMemberAvatar(msg)}
|
||||
icon={<UserOutlined />}
|
||||
className={styles.messageAvatar}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{!isOwn && (
|
||||
<div className={styles.messageSender}>
|
||||
{msg?.sender?.nickname}
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
{parseMessageContent(
|
||||
clearWechatidInContent(msg?.sender, msg?.content),
|
||||
msg,
|
||||
msg?.msgType,
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isOwn && <>{parseMessageContent(msg?.content, msg, msg?.msgType)}</>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const loadMoreMessages = () => {
|
||||
// 兼容性处理:检查消息数组和时间戳
|
||||
if (!currentMessages || currentMessages.length === 0) {
|
||||
console.warn("No messages available for loading more");
|
||||
return;
|
||||
}
|
||||
|
||||
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() - 20000;
|
||||
} catch (error) {
|
||||
console.error("Error parsing createTime:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
loadChatMessages(false, timestamp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.messagesContainer}>
|
||||
<div className={styles.loadMore} onClick={() => loadMoreMessages()}>
|
||||
点击加载更早的信息 {messagesLoading ? <LoadingOutlined /> : ""}
|
||||
</div>
|
||||
{groupMessagesByTime(currentMessages).map((group, groupIndex) => (
|
||||
<React.Fragment key={`group-${groupIndex}`}>
|
||||
{group.messages
|
||||
.filter(v => [10000].includes(v.msgType))
|
||||
.map(msg => (
|
||||
<div
|
||||
key={`divider-${msg.id}`}
|
||||
className={styles.messageTime}
|
||||
dangerouslySetInnerHTML={{ __html: msg.content }}
|
||||
></div>
|
||||
))}
|
||||
<div className={styles.messageTime}>{group.time}</div>
|
||||
{group.messages
|
||||
.filter(v => ![10000].includes(v.msgType))
|
||||
.map(renderMessage)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageRecord;
|
||||
@@ -1,243 +0,0 @@
|
||||
.profileSider {
|
||||
background: #fff;
|
||||
border-left: 1px solid #e8e8e8;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
.profileContainer {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profileHeader {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.closeButton {
|
||||
color: #8c8c8c;
|
||||
|
||||
&:hover {
|
||||
color: #262626;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profileBasic {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.profileInfo {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
.profileNickname {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profileRemark {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.remarkText {
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profileStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: #52c41a;
|
||||
font-size: 14px;
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #52c41a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profileCard {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
|
||||
|
||||
:global(.ant-card-head) {
|
||||
padding: 0 16px;
|
||||
min-height: 40px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
:global(.ant-card-head-title) {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
color: #8c8c8c;
|
||||
margin-right: 8px;
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
color: #262626;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
|
||||
// 备注编辑区域样式
|
||||
:global(.ant-input) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:global(.ant-btn) {
|
||||
font-size: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tagsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
:global(.ant-tag) {
|
||||
margin: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.bioText {
|
||||
margin: 0;
|
||||
color: #595959;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.groupManagement {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.groupMemberList {
|
||||
.groupMember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: 8px;
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profileActions {
|
||||
margin-top: auto;
|
||||
padding-top: 16px;
|
||||
|
||||
:global(.ant-btn) {
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.profileSider {
|
||||
width: 280px !important;
|
||||
|
||||
.profileContainer {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.profileBasic {
|
||||
.profileInfo {
|
||||
.profileNickname {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profileCard {
|
||||
:global(.ant-card-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
margin-bottom: 10px;
|
||||
|
||||
.infoLabel {
|
||||
width: 50px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,669 +0,0 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Layout, Button, Avatar, Space, Dropdown, Menu, Tooltip } from "antd";
|
||||
import {
|
||||
PhoneOutlined,
|
||||
VideoCameraOutlined,
|
||||
MoreOutlined,
|
||||
UserOutlined,
|
||||
DownloadOutlined,
|
||||
FileOutlined,
|
||||
FilePdfOutlined,
|
||||
FileWordOutlined,
|
||||
FileExcelOutlined,
|
||||
FilePptOutlined,
|
||||
PlayCircleFilled,
|
||||
TeamOutlined,
|
||||
FolderOutlined,
|
||||
EnvironmentOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import styles from "./ChatWindow.module.scss";
|
||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
import { formatWechatTime } from "@/utils/common";
|
||||
import ProfileCard from "./components/ProfileCard";
|
||||
import MessageEnter from "./components/MessageEnter";
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
interface ChatWindowProps {
|
||||
contract: ContractData | weChatGroup;
|
||||
showProfile?: boolean;
|
||||
onToggleProfile?: () => void;
|
||||
}
|
||||
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
contract,
|
||||
showProfile = true,
|
||||
onToggleProfile,
|
||||
}) => {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const currentMessages = useWeChatStore(state => state.currentMessages);
|
||||
const prevMessagesRef = useRef(currentMessages);
|
||||
|
||||
useEffect(() => {
|
||||
const prevMessages = prevMessagesRef.current;
|
||||
|
||||
const hasVideoStateChange = currentMessages.some((msg, index) => {
|
||||
// 首先检查消息对象本身是否为null或undefined
|
||||
if (!msg || !msg.content) return false;
|
||||
|
||||
const prevMsg = prevMessages[index];
|
||||
if (!prevMsg || !prevMsg.content || prevMsg.id !== msg.id) return false;
|
||||
|
||||
try {
|
||||
const currentContent =
|
||||
typeof msg.content === "string"
|
||||
? JSON.parse(msg.content)
|
||||
: msg.content;
|
||||
const prevContent =
|
||||
typeof prevMsg.content === "string"
|
||||
? JSON.parse(prevMsg.content)
|
||||
: prevMsg.content;
|
||||
|
||||
// 检查视频状态是否发生变化(开始加载、完成加载、获得URL)
|
||||
const currentHasVideo =
|
||||
currentContent.previewImage && currentContent.tencentUrl;
|
||||
const prevHasVideo = prevContent.previewImage && prevContent.tencentUrl;
|
||||
|
||||
if (currentHasVideo && prevHasVideo) {
|
||||
// 检查加载状态变化或视频URL变化
|
||||
return (
|
||||
currentContent.isLoading !== prevContent.isLoading ||
|
||||
currentContent.videoUrl !== prevContent.videoUrl
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// 只有在没有视频状态变化时才自动滚动到底部
|
||||
if (!hasVideoStateChange) {
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
// 更新上一次的消息状态
|
||||
prevMessagesRef.current = currentMessages;
|
||||
}, [currentMessages]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
// 处理视频播放请求,发送socket请求获取真实视频地址
|
||||
const handleVideoPlayRequest = (tencentUrl: string, messageId: number) => {
|
||||
console.log("发送视频下载请求:", { messageId, tencentUrl });
|
||||
|
||||
// 先设置加载状态
|
||||
useWeChatStore.getState().setVideoLoading(messageId, true);
|
||||
|
||||
// 构建socket请求数据
|
||||
useWebSocketStore.getState().sendCommand("CmdDownloadVideo", {
|
||||
chatroomMessageId: contract.chatroomId ? messageId : 0,
|
||||
friendMessageId: contract.chatroomId ? 0 : messageId,
|
||||
seq: `${+new Date()}`, // 使用时间戳作为请求序列号
|
||||
tencentUrl: tencentUrl,
|
||||
wechatAccountId: contract.wechatAccountId,
|
||||
});
|
||||
};
|
||||
|
||||
// 解析消息内容,判断消息类型并返回对应的渲染内容
|
||||
const parseMessageContent = (
|
||||
content: string | null | undefined,
|
||||
msg: ChatRecord,
|
||||
) => {
|
||||
// 处理null或undefined的内容
|
||||
if (content === null || content === undefined) {
|
||||
return <div className={styles.messageText}>消息内容不可用</div>;
|
||||
}
|
||||
// 检查是否为表情包
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") &&
|
||||
content.includes("#")
|
||||
) {
|
||||
return (
|
||||
<div className={styles.emojiMessage}>
|
||||
<img
|
||||
src={content}
|
||||
alt="表情包"
|
||||
style={{ maxWidth: "120px", maxHeight: "120px" }}
|
||||
onClick={() => window.open(content, "_blank")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否为带预览图的视频消息
|
||||
try {
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
content.trim().startsWith("{") &&
|
||||
content.trim().endsWith("}")
|
||||
) {
|
||||
const videoData = JSON.parse(content);
|
||||
// 处理视频消息格式 {"previewImage":"https://...", "tencentUrl":"...", "videoUrl":"...", "isLoading":true}
|
||||
if (videoData.previewImage && videoData.tencentUrl) {
|
||||
// 提取预览图URL,去掉可能的引号
|
||||
const previewImageUrl = videoData.previewImage.replace(/[`"']/g, "");
|
||||
|
||||
// 创建点击处理函数
|
||||
const handlePlayClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// 如果没有视频URL且不在加载中,则发起下载请求
|
||||
if (!videoData.videoUrl && !videoData.isLoading) {
|
||||
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 如果已有视频URL,显示视频播放器
|
||||
if (videoData.videoUrl) {
|
||||
return (
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer}>
|
||||
<video
|
||||
controls
|
||||
src={videoData.videoUrl}
|
||||
style={{ maxWidth: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
<a
|
||||
href={videoData.videoUrl}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示预览图,根据加载状态显示不同的图标
|
||||
return (
|
||||
<div className={styles.videoMessage}>
|
||||
<div className={styles.videoContainer} onClick={handlePlayClick}>
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt="视频预览"
|
||||
className={styles.videoThumbnail}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
borderRadius: "8px",
|
||||
opacity: videoData.isLoading ? "0.7" : "1",
|
||||
}}
|
||||
/>
|
||||
<div className={styles.videoPlayIcon}>
|
||||
{videoData.isLoading ? (
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
) : (
|
||||
<PlayCircleFilled
|
||||
style={{ fontSize: "48px", color: "#fff" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 保留原有的视频处理逻辑
|
||||
else if (
|
||||
videoData.type === "video" &&
|
||||
videoData.url &&
|
||||
videoData.thumb
|
||||
) {
|
||||
return (
|
||||
<div className={styles.videoMessage}>
|
||||
<div
|
||||
className={styles.videoContainer}
|
||||
onClick={() => window.open(videoData.url, "_blank")}
|
||||
>
|
||||
<img
|
||||
src={videoData.thumb}
|
||||
alt="视频预览"
|
||||
className={styles.videoThumbnail}
|
||||
/>
|
||||
<div className={styles.videoPlayIcon}>
|
||||
<VideoCameraOutlined
|
||||
style={{ fontSize: "32px", color: "#fff" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={videoData.url}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析JSON失败,不是视频消息
|
||||
console.log("解析视频消息失败:", e);
|
||||
}
|
||||
|
||||
// 检查是否为图片链接
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
(content.match(/\.(jpg|jpeg|png|gif)$/i) ||
|
||||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
|
||||
content.includes(".jpg")))
|
||||
) {
|
||||
return (
|
||||
<div className={styles.imageMessage}>
|
||||
<img
|
||||
src={content}
|
||||
alt="图片消息"
|
||||
onClick={() => window.open(content, "_blank")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否为视频链接
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
(content.match(/\.(mp4|avi|mov|wmv|flv)$/i) ||
|
||||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
|
||||
content.includes(".mp4")))
|
||||
) {
|
||||
return (
|
||||
<div className={styles.videoMessage}>
|
||||
<video
|
||||
controls
|
||||
src={content}
|
||||
style={{ maxWidth: "100%", borderRadius: "8px" }}
|
||||
/>
|
||||
<a
|
||||
href={content}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否为音频链接
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
(content.match(/\.(mp3|wav|ogg|m4a)$/i) ||
|
||||
(content.includes("oss-cn-shenzhen.aliyuncs.com") &&
|
||||
content.includes(".mp3")))
|
||||
) {
|
||||
return (
|
||||
<div className={styles.audioMessage}>
|
||||
<audio controls src={content} style={{ maxWidth: "100%" }} />
|
||||
<a
|
||||
href={content}
|
||||
download
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否为Office文件链接
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
content.match(/\.(doc|docx|xls|xlsx|ppt|pptx|pdf)$/i)
|
||||
) {
|
||||
const fileName = content.split("/").pop() || "文件";
|
||||
const fileExt = fileName.split(".").pop()?.toLowerCase();
|
||||
|
||||
// 根据文件类型选择不同的图标
|
||||
let fileIcon = (
|
||||
<FileOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#1890ff" }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (fileExt === "pdf") {
|
||||
fileIcon = (
|
||||
<FilePdfOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#ff4d4f" }}
|
||||
/>
|
||||
);
|
||||
} else if (fileExt === "doc" || fileExt === "docx") {
|
||||
fileIcon = (
|
||||
<FileWordOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#2f54eb" }}
|
||||
/>
|
||||
);
|
||||
} else if (fileExt === "xls" || fileExt === "xlsx") {
|
||||
fileIcon = (
|
||||
<FileExcelOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#52c41a" }}
|
||||
/>
|
||||
);
|
||||
} else if (fileExt === "ppt" || fileExt === "pptx") {
|
||||
fileIcon = (
|
||||
<FilePptOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#fa8c16" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.fileMessage}>
|
||||
{fileIcon}
|
||||
<div className={styles.fileInfo}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{fileName}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={content}
|
||||
download={fileExt !== "pdf" ? fileName : undefined}
|
||||
target={fileExt === "pdf" ? "_blank" : undefined}
|
||||
className={styles.downloadButton}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ display: "flex" }}
|
||||
rel="noreferrer"
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否为文件消息(JSON格式)
|
||||
try {
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
content.trim().startsWith("{") &&
|
||||
content.trim().endsWith("}")
|
||||
) {
|
||||
const fileData = JSON.parse(content);
|
||||
if (fileData.type === "file" && fileData.title) {
|
||||
// 检查是否为Office文件
|
||||
const fileExt = fileData.title.split(".").pop()?.toLowerCase();
|
||||
let fileIcon = (
|
||||
<FolderOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#1890ff" }}
|
||||
/>
|
||||
);
|
||||
|
||||
if (fileExt === "pdf") {
|
||||
fileIcon = (
|
||||
<FilePdfOutlined
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
marginRight: "8px",
|
||||
color: "#ff4d4f",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (fileExt === "doc" || fileExt === "docx") {
|
||||
fileIcon = (
|
||||
<FileWordOutlined
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
marginRight: "8px",
|
||||
color: "#2f54eb",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (fileExt === "xls" || fileExt === "xlsx") {
|
||||
fileIcon = (
|
||||
<FileExcelOutlined
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
marginRight: "8px",
|
||||
color: "#52c41a",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (fileExt === "ppt" || fileExt === "pptx") {
|
||||
fileIcon = (
|
||||
<FilePptOutlined
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
marginRight: "8px",
|
||||
color: "#fa8c16",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.fileMessage}>
|
||||
{fileIcon}
|
||||
<div className={styles.fileInfo}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{fileData.title}
|
||||
</div>
|
||||
{fileData.totalLen && (
|
||||
<div style={{ fontSize: "12px", color: "#8c8c8c" }}>
|
||||
{Math.round(fileData.totalLen / 1024)} KB
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={fileData.url || "#"}
|
||||
download={fileExt !== "pdf" ? fileData.title : undefined}
|
||||
target={fileExt === "pdf" ? "_blank" : undefined}
|
||||
className={styles.downloadButton}
|
||||
style={{ display: "flex" }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (!fileData.url) {
|
||||
console.log("文件URL不存在");
|
||||
}
|
||||
}}
|
||||
rel="noreferrer"
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: "18px" }} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析JSON失败,不是文件消息
|
||||
}
|
||||
|
||||
// 检查是否为位置信息
|
||||
if (
|
||||
typeof content === "string" &&
|
||||
(content.includes("<location") || content.includes("<msg><location"))
|
||||
) {
|
||||
// 提取位置信息
|
||||
const labelMatch = content.match(/label="([^"]*)"/i);
|
||||
const poiNameMatch = content.match(/poiname="([^"]*)"/i);
|
||||
const xMatch = content.match(/x="([^"]*)"/i);
|
||||
const yMatch = content.match(/y="([^"]*)"/i);
|
||||
|
||||
const label = labelMatch
|
||||
? labelMatch[1]
|
||||
: poiNameMatch
|
||||
? poiNameMatch[1]
|
||||
: "位置信息";
|
||||
const coordinates = xMatch && yMatch ? `${yMatch[1]}, ${xMatch[1]}` : "";
|
||||
|
||||
return (
|
||||
<div className={styles.locationMessage}>
|
||||
<EnvironmentOutlined
|
||||
style={{ fontSize: "24px", marginRight: "8px", color: "#ff4d4f" }}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontWeight: "bold" }}>{label}</div>
|
||||
{coordinates && (
|
||||
<div style={{ fontSize: "12px", color: "#8c8c8c" }}>
|
||||
{coordinates}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 默认为文本消息
|
||||
return <div className={styles.messageText}>{content}</div>;
|
||||
};
|
||||
|
||||
// 用于分组消息并添加时间戳的辅助函数
|
||||
const groupMessagesByTime = (messages: ChatRecord[]) => {
|
||||
return messages
|
||||
.filter(msg => msg !== null && msg !== undefined) // 过滤掉null和undefined的消息
|
||||
.map(msg => ({
|
||||
time: formatWechatTime(msg?.wechatTime),
|
||||
messages: [msg],
|
||||
}));
|
||||
};
|
||||
|
||||
const renderMessage = (msg: ChatRecord) => {
|
||||
// 添加null检查,防止访问null对象的属性
|
||||
if (!msg) return null;
|
||||
|
||||
const isOwn = msg?.isSend;
|
||||
return (
|
||||
<div
|
||||
key={msg.id || `msg-${Date.now()}`}
|
||||
className={`${styles.messageItem} ${
|
||||
isOwn ? styles.ownMessage : styles.otherMessage
|
||||
}`}
|
||||
>
|
||||
<div className={styles.messageContent}>
|
||||
{!isOwn && (
|
||||
<Avatar
|
||||
size={32}
|
||||
src={contract.avatar}
|
||||
icon={<UserOutlined />}
|
||||
className={styles.messageAvatar}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.messageBubble}>
|
||||
{!isOwn && (
|
||||
<div className={styles.messageSender}>{msg?.senderName}</div>
|
||||
)}
|
||||
{parseMessageContent(msg?.content, msg)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const chatMenu = (
|
||||
<Menu>
|
||||
<Menu.Item key="profile" icon={<UserOutlined />}>
|
||||
查看资料
|
||||
</Menu.Item>
|
||||
<Menu.Item key="call" icon={<PhoneOutlined />}>
|
||||
语音通话
|
||||
</Menu.Item>
|
||||
<Menu.Item key="video" icon={<VideoCameraOutlined />}>
|
||||
视频通话
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="pin">置顶聊天</Menu.Item>
|
||||
<Menu.Item key="mute">消息免打扰</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="clear" danger>
|
||||
清空聊天记录
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout className={styles.chatWindow}>
|
||||
{/* 聊天主体区域 */}
|
||||
<Layout className={styles.chatMain}>
|
||||
{/* 聊天头部 */}
|
||||
<Header className={styles.chatHeader}>
|
||||
<div className={styles.chatHeaderInfo}>
|
||||
<Avatar
|
||||
size={40}
|
||||
src={contract.avatar || contract.chatroomAvatar}
|
||||
icon={
|
||||
contract.type === "group" ? <TeamOutlined /> : <UserOutlined />
|
||||
}
|
||||
/>
|
||||
<div className={styles.chatHeaderDetails}>
|
||||
<div className={styles.chatHeaderName}>
|
||||
{contract.nickname || contract.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Space>
|
||||
<Tooltip title="语音通话">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PhoneOutlined />}
|
||||
className={styles.headerButton}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="视频通话">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<VideoCameraOutlined />}
|
||||
className={styles.headerButton}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dropdown overlay={chatMenu} trigger={["click"]}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MoreOutlined />}
|
||||
className={styles.headerButton}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
{/* 聊天内容 */}
|
||||
<Content className={styles.chatContent}>
|
||||
<div className={styles.messagesContainer}>
|
||||
{groupMessagesByTime(currentMessages).map((group, groupIndex) => (
|
||||
<React.Fragment key={`group-${groupIndex}`}>
|
||||
<div className={styles.messageTime}>{group.time}</div>
|
||||
{group.messages.map(renderMessage)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
{/* 消息输入组件 */}
|
||||
<MessageEnter contract={contract} />
|
||||
</Layout>
|
||||
|
||||
{/* 右侧个人资料卡片 */}
|
||||
<ProfileCard
|
||||
contract={contract}
|
||||
showProfile={showProfile}
|
||||
onToggleProfile={onToggleProfile}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatWindow;
|
||||
@@ -1,108 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Layout, Button, Avatar, Space, Dropdown, Menu, Tooltip } from "antd";
|
||||
import {
|
||||
PhoneOutlined,
|
||||
VideoCameraOutlined,
|
||||
MoreOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined,
|
||||
InfoCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import styles from "./ChatWindow.module.scss";
|
||||
|
||||
import ProfileCard from "./components/ProfileCard";
|
||||
import MessageEnter from "./components/MessageEnter";
|
||||
import MessageRecord from "./components/MessageRecord";
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
interface ChatWindowProps {
|
||||
contract: ContractData | weChatGroup;
|
||||
}
|
||||
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({ contract }) => {
|
||||
const [showProfile, setShowProfile] = useState(true);
|
||||
const onToggleProfile = () => {
|
||||
setShowProfile(!showProfile);
|
||||
};
|
||||
const chatMenu = (
|
||||
<Menu>
|
||||
<Menu.Item key="profile" icon={<UserOutlined />}>
|
||||
查看资料
|
||||
</Menu.Item>
|
||||
<Menu.Item key="call" icon={<PhoneOutlined />}>
|
||||
语音通话
|
||||
</Menu.Item>
|
||||
<Menu.Item key="video" icon={<VideoCameraOutlined />}>
|
||||
视频通话
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="pin">置顶聊天</Menu.Item>
|
||||
<Menu.Item key="mute">消息免打扰</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item key="clear" danger>
|
||||
清空聊天记录
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout className={styles.chatWindow}>
|
||||
{/* 聊天主体区域 */}
|
||||
<Layout className={styles.chatMain}>
|
||||
{/* 聊天头部 */}
|
||||
<Header className={styles.chatHeader}>
|
||||
<div className={styles.chatHeaderInfo}>
|
||||
<Avatar
|
||||
size={40}
|
||||
src={contract.avatar || contract.chatroomAvatar}
|
||||
icon={
|
||||
contract.type === "group" ? <TeamOutlined /> : <UserOutlined />
|
||||
}
|
||||
/>
|
||||
<div className={styles.chatHeaderDetails}>
|
||||
<div className={styles.chatHeaderName}>
|
||||
{contract.nickname || contract.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Space>
|
||||
<Tooltip title="个人资料">
|
||||
<Button
|
||||
onClick={onToggleProfile}
|
||||
type="text"
|
||||
icon={<InfoCircleOutlined />}
|
||||
className={styles.headerButton}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dropdown overlay={chatMenu} trigger={["click"]}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MoreOutlined />}
|
||||
className={styles.headerButton}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Header>
|
||||
|
||||
{/* 聊天内容 */}
|
||||
<Content className={styles.chatContent}>
|
||||
<MessageRecord contract={contract} />
|
||||
</Content>
|
||||
|
||||
{/* 消息输入组件 */}
|
||||
<MessageEnter contract={contract} />
|
||||
</Layout>
|
||||
|
||||
{/* 右侧个人资料卡片 */}
|
||||
<ProfileCard
|
||||
contract={contract}
|
||||
showProfile={showProfile}
|
||||
onToggleProfile={onToggleProfile}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatWindow;
|
||||
@@ -1,258 +0,0 @@
|
||||
.header {
|
||||
background: #fff;
|
||||
padding: 0 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 64px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.suanli {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
.suanliIcon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border: 2px solid #f0f0f0;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
// 抽屉样式
|
||||
.drawer {
|
||||
:global(.ant-drawer-header) {
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:global(.ant-drawer-body) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.drawerContent {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.drawerHeader {
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.logoSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logoText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.appDesc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
|
||||
.drawerBody {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.buttonIcon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.menuSection {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
font-size: 20px;
|
||||
margin-right: 12px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.drawerFooter {
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
.balanceSection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.balanceIcon {
|
||||
color: #666;
|
||||
.suanliIcon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.balanceText {
|
||||
color: #3d9c0d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.balanceLabel {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.balanceAmount {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #52c41a;
|
||||
margin: 2px 0 0 0;
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.username {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
width: 280px !important;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Layout, Drawer, Avatar, Dropdown, Space, Button } from "antd";
|
||||
import {
|
||||
MenuOutlined,
|
||||
UserOutlined,
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
interface NavCommonProps {
|
||||
title?: string;
|
||||
onMenuClick?: () => void;
|
||||
drawerContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
const NavCommon: React.FC<NavCommonProps> = ({
|
||||
title = "触客宝",
|
||||
onMenuClick,
|
||||
drawerContent,
|
||||
}) => {
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
|
||||
const { userInfo } = useCkChatStore();
|
||||
|
||||
// 处理菜单图标点击
|
||||
const handleMenuClick = () => {
|
||||
setDrawerVisible(true);
|
||||
onMenuClick?.();
|
||||
};
|
||||
|
||||
// 处理抽屉关闭
|
||||
const handleDrawerClose = () => {
|
||||
setDrawerVisible(false);
|
||||
};
|
||||
|
||||
// 默认抽屉内容
|
||||
const defaultDrawerContent = (
|
||||
<div className={styles.drawerContent}>
|
||||
<div className={styles.drawerHeader}>
|
||||
<div className={styles.logoSection}>
|
||||
<div className={styles.logoIcon}>✨</div>
|
||||
<div className={styles.logoText}>
|
||||
<div className={styles.appName}>触客宝</div>
|
||||
<div className={styles.appDesc}>AI智能营销系统</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.drawerBody}>
|
||||
<div className={styles.primaryButton}>
|
||||
<div className={styles.buttonIcon}>🔒</div>
|
||||
<span>AI智能客服</span>
|
||||
</div>
|
||||
<div className={styles.menuSection}>
|
||||
<div className={styles.menuItem}>
|
||||
<div className={styles.menuIcon}>📊</div>
|
||||
<span>功能中心</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.drawerFooter}>
|
||||
<div className={styles.balanceSection}>
|
||||
<div className={styles.balanceIcon}>
|
||||
<span className={styles.suanliIcon}>⚡</span>算力余额
|
||||
</div>
|
||||
<div className={styles.balanceText}>9307.423</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuOutlined />}
|
||||
onClick={handleMenuClick}
|
||||
className={styles.menuButton}
|
||||
/>
|
||||
<span className={styles.title}>{title}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.headerRight}>
|
||||
<Space className={styles.userInfo}>
|
||||
<span className={styles.suanli}>
|
||||
<span className={styles.suanliIcon}>⚡</span>
|
||||
9307.423
|
||||
</span>
|
||||
<Avatar
|
||||
size={40}
|
||||
icon={<UserOutlined />}
|
||||
src={userInfo?.account?.avatar}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Drawer
|
||||
title="菜单"
|
||||
placement="left"
|
||||
onClose={handleDrawerClose}
|
||||
open={drawerVisible}
|
||||
width={300}
|
||||
className={styles.drawer}
|
||||
>
|
||||
{drawerContent || defaultDrawerContent}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavCommon;
|
||||
@@ -1,156 +0,0 @@
|
||||
.messageList {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
|
||||
.messageItem {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #e6f7ff;
|
||||
border-right: 3px solid #1890ff;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.messageInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
.messageDetails {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.messageHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.messageName {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messageTime {
|
||||
font-size: 11px;
|
||||
color: #bfbfbf;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.messageContent {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.lastMessage {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
padding-right: 5px;
|
||||
height: 18px; // 添加固定高度
|
||||
line-height: 18px; // 设置行高与高度一致
|
||||
|
||||
&::before {
|
||||
content: attr(data-count);
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
top: 0;
|
||||
background-color: #ff4d4f;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
padding: 0 6px;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&[data-count]:not([data-count=""]):not([data-count="0"]) {
|
||||
&::before {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.onlineIndicator {
|
||||
font-size: 10px;
|
||||
color: #52c41a;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lastDayMessage {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #f9f9f9;
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
text-align: center;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.messageList {
|
||||
.messageItem {
|
||||
padding: 10px 12px;
|
||||
|
||||
.messageInfo {
|
||||
gap: 10px;
|
||||
|
||||
.messageDetails {
|
||||
.messageHeader {
|
||||
.messageName {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.messageTime {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.messageContent {
|
||||
.lastMessage {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.onlineIndicator {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import request from "@/api/request";
|
||||
|
||||
// 获取联系人列表
|
||||
export const getContactList = (params: { prevId: string; count: number }) => {
|
||||
return request("/api/wechatFriend/list", params, "GET");
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
// 联系人数据接口
|
||||
export interface ContractData {
|
||||
id?: number;
|
||||
wechatAccountId: number;
|
||||
wechatId: string;
|
||||
alias: string;
|
||||
conRemark: string;
|
||||
nickname: string;
|
||||
quanPin: string;
|
||||
avatar?: string;
|
||||
gender: number;
|
||||
region: string;
|
||||
addFrom: number;
|
||||
phone: string;
|
||||
labels: string[];
|
||||
signature: string;
|
||||
accountId: number;
|
||||
extendFields: null;
|
||||
city?: string;
|
||||
lastUpdateTime: string;
|
||||
isPassed: boolean;
|
||||
tenantId: number;
|
||||
groupId: number;
|
||||
thirdParty: null;
|
||||
additionalPicture: string;
|
||||
desc: string;
|
||||
config: null;
|
||||
lastMessageTime: number;
|
||||
unreadCount: number;
|
||||
duplicate: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
//聊天会话类型
|
||||
export type ChatType = "private" | "group";
|
||||
// 聊天会话接口
|
||||
export interface ChatSession {
|
||||
id: number;
|
||||
type: ChatType;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
lastMessage: string;
|
||||
lastTime: string;
|
||||
unreadCount: number;
|
||||
online: boolean;
|
||||
members?: string[];
|
||||
pinned?: boolean;
|
||||
muted?: boolean;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { List, Avatar, Badge } from "antd";
|
||||
import { UserOutlined, TeamOutlined } from "@ant-design/icons";
|
||||
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
||||
|
||||
import styles from "./MessageList.module.scss";
|
||||
import { formatWechatTime } from "@/utils/common";
|
||||
interface MessageListProps {}
|
||||
|
||||
const MessageList: React.FC<MessageListProps> = () => {
|
||||
const { setCurrentContact, currentContract } = useWeChatStore();
|
||||
const getChatSessions = useCkChatStore(state => state.chatSessions);
|
||||
const kfSelected = useCkChatStore(state => state.kfSelected);
|
||||
const onContactClick = (session: ContractData | weChatGroup) => {
|
||||
setCurrentContact(session, true);
|
||||
};
|
||||
const [chatSessions, setChatSessions] = useState<
|
||||
(ContractData | weChatGroup)[]
|
||||
>([]);
|
||||
const searchKeyword = useCkChatStore(state => state.searchKeyword);
|
||||
useEffect(() => {
|
||||
let filteredSessions = getChatSessions;
|
||||
|
||||
// 根据客服筛选
|
||||
if (kfSelected !== 0) {
|
||||
filteredSessions = filteredSessions.filter(
|
||||
v => v.wechatAccountId === kfSelected,
|
||||
);
|
||||
}
|
||||
|
||||
// 根据搜索关键词进行模糊匹配
|
||||
if (searchKeyword.trim()) {
|
||||
const keyword = searchKeyword.toLowerCase();
|
||||
filteredSessions = filteredSessions.filter(v => {
|
||||
const nickname = (v.nickname || "").toLowerCase();
|
||||
const conRemark = (v.conRemark || "").toLowerCase();
|
||||
return nickname.includes(keyword) || conRemark.includes(keyword);
|
||||
});
|
||||
}
|
||||
|
||||
setChatSessions(filteredSessions);
|
||||
}, [getChatSessions, kfSelected, searchKeyword]);
|
||||
return (
|
||||
<div className={styles.messageList}>
|
||||
<List
|
||||
dataSource={chatSessions as (ContractData | weChatGroup)[]}
|
||||
renderItem={session => (
|
||||
<List.Item
|
||||
key={session.id}
|
||||
className={`${styles.messageItem} ${
|
||||
currentContract?.id === session.id ? styles.active : ""
|
||||
}`}
|
||||
onClick={() => onContactClick(session)}
|
||||
>
|
||||
<div className={styles.messageInfo}>
|
||||
<Badge count={session.unreadCount} size="small">
|
||||
<Avatar
|
||||
size={48}
|
||||
src={session.avatar || session.chatroomAvatar}
|
||||
icon={
|
||||
session?.type === "group" ? (
|
||||
<TeamOutlined />
|
||||
) : (
|
||||
<UserOutlined />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Badge>
|
||||
<div className={styles.messageDetails}>
|
||||
<div className={styles.messageHeader}>
|
||||
<div className={styles.messageName}>{session.nickname}</div>
|
||||
<div className={styles.messageTime}>
|
||||
{formatWechatTime(session?.lastUpdateTime)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.messageContent}>
|
||||
<div
|
||||
className={styles.lastMessage}
|
||||
data-count={
|
||||
session.unreadCount > 0 ? session.unreadCount : ""
|
||||
}
|
||||
>
|
||||
{session?.lastMessage}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageList;
|
||||
@@ -1,111 +0,0 @@
|
||||
.sidebarMenu {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
|
||||
.headerContainer {
|
||||
padding: 16px 16px 0px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.searchBar {
|
||||
margin-bottom: 16px;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tabsContainer {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.tabItem {
|
||||
padding: 8px 0;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #1890ff;
|
||||
border-bottom: 2px solid #1890ff;
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 骨架屏样式
|
||||
.skeletonContainer {
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.searchBarSkeleton {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tabsContainerSkeleton {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contactListSkeleton {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.contactItemSkeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
|
||||
.contactInfoSkeleton {
|
||||
margin-left: 12px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.contentContainer {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
display: none; /* 默认隐藏底部,如果需要显示可以移除此行 */
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
.contractListSimple {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
|
||||
.header {
|
||||
padding: 10px 15px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.groupCollapse {
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
padding: 10px 15px !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contactCount {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.groupPanel {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.loadMoreContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.noMoreText {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.noResults {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.ant-list-item) {
|
||||
padding: 10px 15px;
|
||||
border-bottom: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contractItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 15px;
|
||||
|
||||
&.selected {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.avatarContainer {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
background-color: #1890ff;
|
||||
}
|
||||
|
||||
.contractInfo {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { List, Avatar, Collapse, Button } from "antd";
|
||||
import type { CollapseProps } from "antd";
|
||||
import styles from "./WechatFriends.module.scss";
|
||||
import {
|
||||
useCkChatStore,
|
||||
searchContactsAndGroups,
|
||||
} from "@/store/module/ckchat/ckchat";
|
||||
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import { addChatSession } from "@/store/module/ckchat/ckchat";
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
|
||||
interface WechatFriendsProps {
|
||||
selectedContactId?: ContractData | weChatGroup;
|
||||
}
|
||||
const ContactListSimple: React.FC<WechatFriendsProps> = ({
|
||||
selectedContactId,
|
||||
}) => {
|
||||
const [newContractList, setNewContractList] = useState<any[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
(ContractData | weChatGroup)[]
|
||||
>([]);
|
||||
const getNewContractListFn = useCkChatStore(
|
||||
state => state.getNewContractList,
|
||||
);
|
||||
const kfSelected = useCkChatStore(state => state.kfSelected);
|
||||
const countLables = useCkChatStore(state => state.countLables);
|
||||
const searchKeyword = useCkChatStore(state => state.searchKeyword);
|
||||
|
||||
// 使用useEffect来处理异步的getNewContractList调用
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (searchKeyword.trim()) {
|
||||
// 有搜索关键词时,获取搜索结果
|
||||
const searchResult = await searchContactsAndGroups();
|
||||
setSearchResults(searchResult || []);
|
||||
setNewContractList([]);
|
||||
} else {
|
||||
// 无搜索关键词时,获取分组列表
|
||||
const result = await getNewContractListFn();
|
||||
setNewContractList(result || []);
|
||||
setSearchResults([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取联系人数据失败:", error);
|
||||
setNewContractList([]);
|
||||
setSearchResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [getNewContractListFn, kfSelected, countLables, searchKeyword]);
|
||||
|
||||
const [activeKey, setActiveKey] = useState<string[]>([]); // 默认展开第一个分组
|
||||
|
||||
// 分页加载相关状态
|
||||
const [visibleContacts, setVisibleContacts] = useState<{
|
||||
[key: string]: ContractData[];
|
||||
}>({});
|
||||
const [loading, setLoading] = useState<{ [key: string]: boolean }>({});
|
||||
const [hasMore, setHasMore] = useState<{ [key: string]: boolean }>({});
|
||||
const [page, setPage] = useState<{ [key: string]: number }>({});
|
||||
const { setCurrentContact } = useWeChatStore();
|
||||
const onContactClick = (contact: ContractData | weChatGroup) => {
|
||||
addChatSession(contact);
|
||||
setCurrentContact(contact);
|
||||
};
|
||||
|
||||
// 渲染联系人项
|
||||
const renderContactItem = (contact: ContractData | weChatGroup) => {
|
||||
// 判断是否为群组
|
||||
const isGroup = "chatroomId" in contact;
|
||||
const avatar = contact.avatar || contact.chatroomAvatar;
|
||||
const name = contact.conRemark || contact.nickname;
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={contact.id}
|
||||
onClick={() => onContactClick(contact)}
|
||||
className={`${styles.contractItem} ${contact.id === selectedContactId?.id ? styles.selected : ""}`}
|
||||
>
|
||||
<div className={styles.avatarContainer}>
|
||||
<Avatar
|
||||
src={avatar}
|
||||
icon={!avatar && <span>{contact.nickname.charAt(0)}</span>}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.contractInfo}>
|
||||
<div className={styles.name}>{name}</div>
|
||||
{isGroup && <div className={styles.groupInfo}>群聊</div>}
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
// 初始化分页数据
|
||||
useEffect(() => {
|
||||
if (newContractList && newContractList.length > 0) {
|
||||
const initialVisibleContacts: { [key: string]: ContractData[] } = {};
|
||||
const initialLoading: { [key: string]: boolean } = {};
|
||||
const initialHasMore: { [key: string]: boolean } = {};
|
||||
const initialPage: { [key: string]: number } = {};
|
||||
|
||||
newContractList.forEach((group, index) => {
|
||||
const groupKey = index.toString();
|
||||
// 每个分组初始加载20条数据
|
||||
const pageSize = 20;
|
||||
initialVisibleContacts[groupKey] = group.contacts.slice(0, pageSize);
|
||||
initialLoading[groupKey] = false;
|
||||
initialHasMore[groupKey] = group.contacts.length > pageSize;
|
||||
initialPage[groupKey] = 1;
|
||||
});
|
||||
|
||||
setVisibleContacts(initialVisibleContacts);
|
||||
setLoading(initialLoading);
|
||||
setHasMore(initialHasMore);
|
||||
setPage(initialPage);
|
||||
}
|
||||
}, [newContractList]);
|
||||
|
||||
// 加载更多联系人
|
||||
const loadMoreContacts = useCallback(
|
||||
(groupKey: string) => {
|
||||
if (loading[groupKey] || !hasMore[groupKey] || !newContractList) return;
|
||||
|
||||
setLoading(prev => ({ ...prev, [groupKey]: true }));
|
||||
|
||||
// 模拟异步加载
|
||||
setTimeout(() => {
|
||||
const groupIndex = parseInt(groupKey);
|
||||
const group = newContractList[groupIndex];
|
||||
if (!group) return;
|
||||
|
||||
const pageSize = 20;
|
||||
const currentPage = page[groupKey] || 1;
|
||||
const nextPage = currentPage + 1;
|
||||
const startIndex = currentPage * pageSize;
|
||||
const endIndex = nextPage * pageSize;
|
||||
const newContacts = group.contacts.slice(startIndex, endIndex);
|
||||
|
||||
setVisibleContacts(prev => ({
|
||||
...prev,
|
||||
[groupKey]: [...(prev[groupKey] || []), ...newContacts],
|
||||
}));
|
||||
|
||||
setPage(prev => ({ ...prev, [groupKey]: nextPage }));
|
||||
setHasMore(prev => ({
|
||||
...prev,
|
||||
[groupKey]: endIndex < group.contacts.length,
|
||||
}));
|
||||
|
||||
setLoading(prev => ({ ...prev, [groupKey]: false }));
|
||||
}, 300);
|
||||
},
|
||||
[loading, hasMore, page, newContractList],
|
||||
);
|
||||
|
||||
// 渲染加载更多按钮
|
||||
const renderLoadMoreButton = (groupKey: string) => {
|
||||
if (!hasMore[groupKey])
|
||||
return <div className={styles.noMoreText}>没有更多了</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.loadMoreContainer}>
|
||||
<Button
|
||||
size="small"
|
||||
loading={loading[groupKey]}
|
||||
onClick={() => loadMoreContacts(groupKey)}
|
||||
>
|
||||
{loading[groupKey] ? "加载中..." : "加载更多"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 构建Collapse的items属性
|
||||
const getCollapseItems = (): CollapseProps["items"] => {
|
||||
if (!newContractList || newContractList.length === 0) return [];
|
||||
|
||||
return newContractList.map((group, index) => {
|
||||
const groupKey = index.toString();
|
||||
const isActive = activeKey.includes(groupKey);
|
||||
|
||||
return {
|
||||
key: groupKey,
|
||||
label: (
|
||||
<div className={styles.groupHeader}>
|
||||
<span>{group.groupName}</span>
|
||||
<span className={styles.contactCount}>{group.contacts.length}</span>
|
||||
</div>
|
||||
),
|
||||
className: styles.groupPanel,
|
||||
children: isActive ? (
|
||||
<>
|
||||
<List
|
||||
className={styles.list}
|
||||
dataSource={visibleContacts[groupKey] || []}
|
||||
renderItem={renderContactItem}
|
||||
/>
|
||||
{renderLoadMoreButton(groupKey)}
|
||||
</>
|
||||
) : null,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.contractListSimple}>
|
||||
{searchKeyword.trim() ? (
|
||||
// 搜索模式:直接显示搜索结果列表
|
||||
<>
|
||||
<div className={styles.header}>搜索结果</div>
|
||||
<List
|
||||
className={styles.list}
|
||||
dataSource={searchResults}
|
||||
renderItem={renderContactItem}
|
||||
/>
|
||||
{searchResults.length === 0 && (
|
||||
<div className={styles.noResults}>未找到匹配的联系人</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// 正常模式:显示分组
|
||||
<Collapse
|
||||
className={styles.groupCollapse}
|
||||
activeKey={activeKey}
|
||||
onChange={keys => setActiveKey(keys as string[])}
|
||||
items={getCollapseItems()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactListSimple;
|
||||
@@ -1,152 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Input, Skeleton } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
ChromeOutlined,
|
||||
MessageOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import WechatFriends from "./WechatFriends";
|
||||
import MessageList from "./MessageList/index";
|
||||
import styles from "./SidebarMenu.module.scss";
|
||||
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
||||
interface SidebarMenuProps {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
|
||||
const searchKeyword = useCkChatStore(state => state.searchKeyword);
|
||||
const setSearchKeyword = useCkChatStore(state => state.setSearchKeyword);
|
||||
const clearSearchKeyword = useCkChatStore(state => state.clearSearchKeyword);
|
||||
|
||||
const [activeTab, setActiveTab] = useState("chats");
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchKeyword(value);
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
clearSearchKeyword();
|
||||
};
|
||||
|
||||
// 过滤逻辑已移至store中,这里直接使用store返回的已过滤数据
|
||||
|
||||
// 渲染骨架屏
|
||||
const renderSkeleton = () => (
|
||||
<div className={styles.skeletonContainer}>
|
||||
<div className={styles.searchBarSkeleton}>
|
||||
<Skeleton.Input active size="small" block />
|
||||
</div>
|
||||
<div className={styles.tabsContainerSkeleton}>
|
||||
<Skeleton.Button
|
||||
active
|
||||
size="small"
|
||||
shape="square"
|
||||
style={{ width: "30%" }}
|
||||
/>
|
||||
<Skeleton.Button
|
||||
active
|
||||
size="small"
|
||||
shape="square"
|
||||
style={{ width: "30%" }}
|
||||
/>
|
||||
<Skeleton.Button
|
||||
active
|
||||
size="small"
|
||||
shape="square"
|
||||
style={{ width: "30%" }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.contactListSkeleton}>
|
||||
{Array(8)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={`contact-skeleton-${index}`}
|
||||
className={styles.contactItemSkeleton}
|
||||
>
|
||||
<Skeleton.Avatar active size="large" shape="circle" />
|
||||
<div className={styles.contactInfoSkeleton}>
|
||||
<Skeleton.Input active size="small" style={{ width: "60%" }} />
|
||||
<Skeleton.Input active size="small" style={{ width: "80%" }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染Header部分,包含搜索框和标签页切换
|
||||
const renderHeader = () => (
|
||||
<div className={styles.headerContainer}>
|
||||
{/* 搜索栏 */}
|
||||
<div className={styles.searchBar}>
|
||||
<Input
|
||||
placeholder="搜索客户..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchKeyword}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
onClear={handleClearSearch}
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 标签页切换 */}
|
||||
<div className={styles.tabsContainer}>
|
||||
<div
|
||||
className={`${styles.tabItem} ${activeTab === "chats" ? styles.active : ""}`}
|
||||
onClick={() => setActiveTab("chats")}
|
||||
>
|
||||
<MessageOutlined />
|
||||
<span>聊天</span>
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.tabItem} ${activeTab === "contracts" ? styles.active : ""}`}
|
||||
onClick={() => setActiveTab("contracts")}
|
||||
>
|
||||
<UserOutlined />
|
||||
<span>联系人</span>
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.tabItem} ${activeTab === "groups" ? styles.active : ""}`}
|
||||
onClick={() => setActiveTab("groups")}
|
||||
>
|
||||
<ChromeOutlined />
|
||||
<span>朋友圈</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 渲染内容部分
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case "chats":
|
||||
return <MessageList />;
|
||||
case "contracts":
|
||||
return <WechatFriends />;
|
||||
case "groups":
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<ChromeOutlined style={{ fontSize: 48, color: "#ccc" }} />
|
||||
<p>暂无群组</p>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return renderSkeleton();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.sidebarMenu}>
|
||||
{renderHeader()}
|
||||
<div className={styles.contentContainer}>{renderContent()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarMenu;
|
||||
@@ -1,185 +0,0 @@
|
||||
// 头部骨架样式
|
||||
.headerSkeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.headerCenter {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 左侧边栏骨架样式
|
||||
.siderContent {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.contactList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.contactItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
}
|
||||
|
||||
.contactInfo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.contactTime {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
// 聊天区域骨架样式
|
||||
.chatContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.chatHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.chatHeaderLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chatHeaderRight {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.messageArea {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.loadingTip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loadingText {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.inputToolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.inputField {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
// 右侧面板骨架样式
|
||||
.rightPanel {
|
||||
background: #fff;
|
||||
border-left: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.profileSection {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.profileHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.profileDetails {
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.detailItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tagSection {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import React from "react";
|
||||
import { Skeleton, Layout, Spin } from "antd";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import styles from "./index.module.scss";
|
||||
import pageStyles from "../../index.module.scss";
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
interface PageSkeletonProps {
|
||||
loading: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面骨架屏组件
|
||||
* 在数据加载完成前显示骨架屏
|
||||
*/
|
||||
const PageSkeleton: React.FC<PageSkeletonProps> = ({ loading, children }) => {
|
||||
if (!loading) return <>{children}</>;
|
||||
|
||||
const antIcon = <LoadingOutlined style={{ fontSize: 16 }} spin />;
|
||||
|
||||
return (
|
||||
<Layout className={pageStyles.ckboxLayout}>
|
||||
{/* 顶部标题栏骨架 */}
|
||||
<Header className={pageStyles.header}>
|
||||
<div className={styles.headerSkeleton}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Skeleton.Avatar active size="small" shape="circle" />
|
||||
<Skeleton.Input active size="small" style={{ width: "80px" }} />
|
||||
</div>
|
||||
<div className={styles.headerCenter}>
|
||||
<Skeleton.Input active size="small" style={{ width: "200px" }} />
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
<Skeleton.Button active size="small" style={{ width: "60px" }} />
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Layout>
|
||||
{/* 左侧联系人列表骨架 */}
|
||||
<Sider width={280} className={pageStyles.sider}>
|
||||
<div className={styles.siderContent}>
|
||||
{/* 搜索框 */}
|
||||
<div className={styles.searchBox}>
|
||||
<Skeleton.Input active size="large" block />
|
||||
</div>
|
||||
|
||||
{/* 标签栏 */}
|
||||
<div className={styles.tabBar}>
|
||||
<Skeleton.Button active size="small" style={{ width: "60px" }} />
|
||||
<Skeleton.Button active size="small" style={{ width: "60px" }} />
|
||||
<Skeleton.Button active size="small" style={{ width: "60px" }} />
|
||||
</div>
|
||||
|
||||
{/* 联系人列表 */}
|
||||
<div className={styles.contactList}>
|
||||
{Array(10)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<div key={`contact-${index}`} className={styles.contactItem}>
|
||||
<Skeleton.Avatar active size="large" shape="circle" />
|
||||
<div className={styles.contactInfo}>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{ width: "80px" }}
|
||||
/>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{ width: "120px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.contactTime}>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{ width: "40px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
{/* 主聊天区域骨架 */}
|
||||
<Content className={styles.chatContent}>
|
||||
{/* 聊天头部 */}
|
||||
<div className={styles.chatHeader}>
|
||||
<div className={styles.chatHeaderLeft}>
|
||||
<Skeleton.Avatar active size="large" shape="circle" />
|
||||
<Skeleton.Input active size="small" style={{ width: "100px" }} />
|
||||
</div>
|
||||
<div className={styles.chatHeaderRight}>
|
||||
<Skeleton.Button active size="small" shape="circle" />
|
||||
<Skeleton.Button active size="small" shape="circle" />
|
||||
<Skeleton.Button active size="small" shape="circle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息区域 */}
|
||||
<div className={styles.messageArea}>
|
||||
<div className={styles.loadingTip}>
|
||||
<Spin indicator={antIcon} />
|
||||
<span className={styles.loadingText}>
|
||||
加载速度与好友数量有关,请耐心等待...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className={styles.inputArea}>
|
||||
<div className={styles.inputToolbar}>
|
||||
<Skeleton.Button active size="small" shape="circle" />
|
||||
<Skeleton.Button active size="small" shape="circle" />
|
||||
<Skeleton.Button active size="small" shape="circle" />
|
||||
<Skeleton.Button active size="small" shape="circle" />
|
||||
<Skeleton.Button active size="small" shape="circle" />
|
||||
<Skeleton.Button active size="small" shape="circle" />
|
||||
</div>
|
||||
<div className={styles.inputField}>
|
||||
<Skeleton.Input active size="large" block />
|
||||
<Skeleton.Button active size="large" style={{ width: "60px" }} />
|
||||
</div>
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
{/* 右侧个人信息面板骨架 */}
|
||||
<Sider width={300} className={styles.rightPanel}>
|
||||
<div className={styles.profileSection}>
|
||||
<div className={styles.profileHeader}>
|
||||
<Skeleton.Avatar active size={80} shape="circle" />
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{ width: "100px", marginTop: "12px" }}
|
||||
/>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{ width: "60px", marginTop: "4px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.profileDetails}>
|
||||
<div className={styles.detailItem}>
|
||||
<Skeleton.Input active size="small" style={{ width: "60px" }} />
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{ width: "120px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<Skeleton.Input active size="small" style={{ width: "40px" }} />
|
||||
<Skeleton.Input active size="small" style={{ width: "80px" }} />
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<Skeleton.Input active size="small" style={{ width: "40px" }} />
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{ width: "100px" }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.detailItem}>
|
||||
<Skeleton.Input active size="small" style={{ width: "40px" }} />
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{ width: "140px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tagSection}>
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
style={{ width: "40px", marginBottom: "12px" }}
|
||||
/>
|
||||
<div className={styles.tags}>
|
||||
<Skeleton.Button
|
||||
active
|
||||
size="small"
|
||||
style={{ width: "50px", marginRight: "8px" }}
|
||||
/>
|
||||
<Skeleton.Button
|
||||
active
|
||||
size="small"
|
||||
style={{ width: "60px", marginRight: "8px" }}
|
||||
/>
|
||||
<Skeleton.Button
|
||||
active
|
||||
size="small"
|
||||
style={{ width: "70px" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Sider>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageSkeleton;
|
||||
@@ -1,83 +0,0 @@
|
||||
.verticalUserList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: #ffffff;
|
||||
|
||||
.userListHeader {
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
.allFriends {
|
||||
font-size: 12px;
|
||||
color: #333333;
|
||||
}
|
||||
}
|
||||
|
||||
.userList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 0;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #555;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.userItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background: #ffffff;
|
||||
|
||||
&.active {
|
||||
.userAvatar {
|
||||
border: 4px solid #1890ff;
|
||||
|
||||
.active & {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messageBadge {
|
||||
:global(.ant-badge-count) {
|
||||
background-color: #ff4d4f;
|
||||
box-shadow: none;
|
||||
font-size: 10px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.onlineIndicator {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #2e2e2e;
|
||||
|
||||
&.online {
|
||||
background-color: #52c41a; // 绿色表示在线
|
||||
}
|
||||
|
||||
&.offline {
|
||||
background-color: #8c8c8c; // 灰色表示离线
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import React from "react";
|
||||
import { Avatar, Badge, Tooltip } from "antd";
|
||||
import styles from "./VerticalUserList.module.scss";
|
||||
import { useCkChatStore, asyncKfSelected } from "@/store/module/ckchat/ckchat";
|
||||
|
||||
import { TeamOutlined } from "@ant-design/icons";
|
||||
const VerticalUserList: React.FC = () => {
|
||||
// 格式化消息数量显示
|
||||
const formatMessageCount = (count: number) => {
|
||||
if (count > 99) return "99+";
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
const handleUserSelect = (userId: number) => {
|
||||
asyncKfSelected(userId);
|
||||
};
|
||||
const kfUserList = useCkChatStore(state => state.kfUserList);
|
||||
const kfSelected = useCkChatStore(state => state.kfSelected);
|
||||
|
||||
return (
|
||||
<div className={styles.verticalUserList}>
|
||||
<div
|
||||
className={styles.userListHeader}
|
||||
onClick={() => handleUserSelect(0)}
|
||||
>
|
||||
<TeamOutlined style={{ fontSize: "26px" }} />
|
||||
<div className={styles.allFriends}>全部好友</div>
|
||||
</div>
|
||||
<div className={styles.userList}>
|
||||
{kfUserList.map(user => (
|
||||
<Tooltip key={user.id} title={user.name} placement="right">
|
||||
<div
|
||||
className={`${styles.userItem} ${kfSelected === user.id ? styles.active : ""}`}
|
||||
onClick={() => handleUserSelect(user.id)}
|
||||
>
|
||||
<Badge
|
||||
count={
|
||||
user.messageCount ? formatMessageCount(user.messageCount) : 0
|
||||
}
|
||||
overflowCount={99}
|
||||
className={styles.messageBadge}
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
size={50}
|
||||
className={styles.userAvatar}
|
||||
style={{
|
||||
backgroundColor: !user.avatar ? "#1890ff" : undefined,
|
||||
}}
|
||||
>
|
||||
{!user.avatar && user.name.charAt(0)}
|
||||
</Avatar>
|
||||
</Badge>
|
||||
<div
|
||||
className={`${styles.onlineIndicator} ${user.isOnline ? styles.online : styles.offline}`}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerticalUserList;
|
||||
@@ -1,323 +0,0 @@
|
||||
// 消息列表数据接口 - 支持weChatGroup和contracts两种数据类型
|
||||
export interface MessageListData {
|
||||
serverId: number | string; // 服务器ID作为主键
|
||||
id?: number; // 接口数据的原始ID字段
|
||||
|
||||
// 数据类型标识
|
||||
dataType: "weChatGroup" | "contracts"; // 数据类型:微信群组或联系人
|
||||
|
||||
// 通用字段(两种类型都有的字段)
|
||||
wechatAccountId: number; // 微信账号ID
|
||||
tenantId: number; // 租户ID
|
||||
accountId: number; // 账号ID
|
||||
nickname: string; // 昵称
|
||||
avatar?: string; // 头像
|
||||
groupId: number; // 分组ID
|
||||
config?: {
|
||||
chat: boolean;
|
||||
}; // 配置信息
|
||||
labels?: string[]; // 标签列表
|
||||
unreadCount: number; // 未读消息数
|
||||
|
||||
// 联系人特有字段(当dataType为'contracts'时使用)
|
||||
wechatId?: string; // 微信ID
|
||||
alias?: string; // 别名
|
||||
conRemark?: string; // 备注
|
||||
quanPin?: string; // 全拼
|
||||
gender?: number; // 性别
|
||||
region?: string; // 地区
|
||||
addFrom?: number; // 添加来源
|
||||
phone?: string; // 电话
|
||||
signature?: string; // 签名
|
||||
extendFields?: any; // 扩展字段
|
||||
city?: string; // 城市
|
||||
lastUpdateTime?: string; // 最后更新时间
|
||||
isPassed?: boolean; // 是否通过
|
||||
thirdParty?: any; // 第三方
|
||||
additionalPicture?: string; // 附加图片
|
||||
desc?: string; // 描述
|
||||
lastMessageTime?: number; // 最后消息时间
|
||||
duplicate?: boolean; // 是否重复
|
||||
|
||||
// 微信群组特有字段(当dataType为'weChatGroup'时使用)
|
||||
chatroomId?: string; // 群聊ID
|
||||
chatroomOwner?: string; // 群主
|
||||
chatroomAvatar?: string; // 群头像
|
||||
notice?: string; // 群公告
|
||||
selfDisplyName?: string; // 自己在群里的显示名称
|
||||
|
||||
[key: string]: any; // 兼容其他字段
|
||||
}
|
||||
|
||||
//联系人标签分组
|
||||
export interface ContactGroupByLabel {
|
||||
id: number;
|
||||
accountId?: number;
|
||||
groupName: string;
|
||||
tenantId?: number;
|
||||
count: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
//终端用户数据接口
|
||||
export interface KfUserListData {
|
||||
id: number;
|
||||
tenantId: number;
|
||||
wechatId: string;
|
||||
nickname: string;
|
||||
alias: string;
|
||||
avatar: string;
|
||||
gender: number;
|
||||
region: string;
|
||||
signature: string;
|
||||
bindQQ: string;
|
||||
bindEmail: string;
|
||||
bindMobile: string;
|
||||
createTime: string;
|
||||
currentDeviceId: number;
|
||||
isDeleted: boolean;
|
||||
deleteTime: string;
|
||||
groupId: number;
|
||||
memo: string;
|
||||
wechatVersion: string;
|
||||
labels: string[];
|
||||
lastUpdateTime: string;
|
||||
isOnline?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 账户信息接口
|
||||
export interface CkAccount {
|
||||
id: number;
|
||||
realName: string;
|
||||
nickname: string | null;
|
||||
memo: string | null;
|
||||
avatar: string;
|
||||
userName: string;
|
||||
secret: string;
|
||||
accountType: number;
|
||||
departmentId: number;
|
||||
useGoogleSecretKey: boolean;
|
||||
hasVerifyGoogleSecret: boolean;
|
||||
}
|
||||
|
||||
//群聊数据接口
|
||||
export interface weChatGroup {
|
||||
id?: number;
|
||||
wechatAccountId: number;
|
||||
tenantId: number;
|
||||
accountId: number;
|
||||
chatroomId: string;
|
||||
chatroomOwner: string;
|
||||
conRemark: string;
|
||||
nickname: string;
|
||||
chatroomAvatar: string;
|
||||
groupId: number;
|
||||
config?: {
|
||||
chat: boolean;
|
||||
};
|
||||
labels?: string[];
|
||||
unreadCount: number;
|
||||
notice: string;
|
||||
selfDisplyName: string;
|
||||
wechatChatroomId: number;
|
||||
serverId?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 联系人数据接口
|
||||
export interface ContractData {
|
||||
id?: number;
|
||||
serverId?: number;
|
||||
wechatAccountId: number;
|
||||
wechatId: string;
|
||||
alias: string;
|
||||
conRemark: string;
|
||||
nickname: string;
|
||||
quanPin: string;
|
||||
avatar?: string;
|
||||
gender: number;
|
||||
region: string;
|
||||
addFrom: number;
|
||||
phone: string;
|
||||
labels: string[];
|
||||
signature: string;
|
||||
accountId: number;
|
||||
extendFields: null;
|
||||
city?: string;
|
||||
lastUpdateTime: string;
|
||||
isPassed: boolean;
|
||||
tenantId: number;
|
||||
groupId: number;
|
||||
thirdParty: null;
|
||||
additionalPicture: string;
|
||||
desc: string;
|
||||
config?: {
|
||||
chat: boolean;
|
||||
};
|
||||
lastMessageTime: number;
|
||||
unreadCount: number;
|
||||
duplicate: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
//聊天记录接口
|
||||
export interface ChatRecord {
|
||||
id: number;
|
||||
wechatFriendId: number;
|
||||
wechatAccountId: number;
|
||||
tenantId: number;
|
||||
accountId: number;
|
||||
synergyAccountId: number;
|
||||
content: string;
|
||||
msgType: number;
|
||||
msgSubType: number;
|
||||
msgSvrId: string;
|
||||
isSend: boolean;
|
||||
createTime: string;
|
||||
isDeleted: boolean;
|
||||
deleteTime: string;
|
||||
sendStatus: number;
|
||||
wechatTime: number;
|
||||
origin: number;
|
||||
msgId: number;
|
||||
recalled: boolean;
|
||||
sender?: {
|
||||
chatroomNickname: string;
|
||||
isAdmin: boolean;
|
||||
isDeleted: boolean;
|
||||
nickname: string;
|
||||
ownerWechatId: string;
|
||||
wechatId: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信好友基本信息接口
|
||||
* 包含主要字段和兼容性字段
|
||||
*/
|
||||
export interface WechatFriend {
|
||||
// 主要字段
|
||||
id: number; // 好友ID
|
||||
wechatAccountId: number; // 微信账号ID
|
||||
wechatId: string; // 微信ID
|
||||
nickname: string; // 昵称
|
||||
conRemark: string; // 备注名
|
||||
avatar: string; // 头像URL
|
||||
gender: number; // 性别:1-男,2-女,0-未知
|
||||
region: string; // 地区
|
||||
phone: string; // 电话
|
||||
labels: string[]; // 标签列表
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 消息类型枚举
|
||||
export enum MessageType {
|
||||
TEXT = "text",
|
||||
IMAGE = "image",
|
||||
VOICE = "voice",
|
||||
VIDEO = "video",
|
||||
FILE = "file",
|
||||
LOCATION = "location",
|
||||
}
|
||||
|
||||
// 消息数据接口
|
||||
export interface MessageData {
|
||||
id: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
type: MessageType;
|
||||
timestamp: string;
|
||||
isRead: boolean;
|
||||
replyTo?: string;
|
||||
forwardFrom?: string;
|
||||
}
|
||||
|
||||
// 聊天会话类型
|
||||
export type ChatType = "private" | "group";
|
||||
|
||||
// 聊天会话接口
|
||||
export interface ChatSession {
|
||||
id: string;
|
||||
type: ChatType;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
lastMessage: string;
|
||||
lastTime: string;
|
||||
unreadCount: number;
|
||||
online: boolean;
|
||||
members?: string[];
|
||||
pinned?: boolean;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
// 聊天历史响应接口
|
||||
export interface ChatHistoryResponse {
|
||||
messages: MessageData[];
|
||||
hasMore: boolean;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 发送消息请求接口
|
||||
export interface SendMessageRequest {
|
||||
chatId: string;
|
||||
content: string;
|
||||
type: MessageType;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
// 搜索联系人请求接口
|
||||
export interface SearchContactRequest {
|
||||
keyword: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// 在线状态接口
|
||||
export interface OnlineStatus {
|
||||
userId: string;
|
||||
online: boolean;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
// 消息状态接口
|
||||
export interface MessageStatus {
|
||||
messageId: string;
|
||||
status: "sending" | "sent" | "delivered" | "read" | "failed";
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// 文件上传响应接口
|
||||
export interface FileUploadResponse {
|
||||
url: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// 表情包接口
|
||||
export interface EmojiData {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
// 快捷回复接口
|
||||
export interface QuickReply {
|
||||
id: string;
|
||||
content: string;
|
||||
category: string;
|
||||
useCount: number;
|
||||
}
|
||||
|
||||
// 聊天设置接口
|
||||
export interface ChatSettings {
|
||||
autoReply: boolean;
|
||||
autoReplyMessage: string;
|
||||
notification: boolean;
|
||||
sound: boolean;
|
||||
theme: "light" | "dark";
|
||||
fontSize: "small" | "medium" | "large";
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
.ckboxLayout {
|
||||
height: calc(100vh - 64px);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
height: 64px;
|
||||
line-height: 64px;
|
||||
padding: 0 24px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.verticalSider {
|
||||
background: #2e2e2e;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sider {
|
||||
background: #fff;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.searchBar {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
|
||||
:global(.ant-input) {
|
||||
border-radius: 20px;
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
|
||||
&:focus {
|
||||
background: #fff;
|
||||
border: 1px solid #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global(.ant-tabs-content) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.ant-tabs-tabpane) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.ant-tabs-nav) {
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
:global(.ant-tabs-tab) {
|
||||
padding: 12px 16px;
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.ant-tabs-tab-active {
|
||||
.ant-tabs-tab-btn {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-tabs-ink-bar) {
|
||||
background: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
color: #8c8c8c;
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
|
||||
.chatContainer {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.chatToolbar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.welcomeScreen {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
|
||||
.welcomeContent {
|
||||
text-align: center;
|
||||
color: #8c8c8c;
|
||||
|
||||
h2 {
|
||||
margin: 24px 0 12px 0;
|
||||
color: #262626;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.ckboxLayout {
|
||||
.sidebar {
|
||||
.searchBar {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
:global(.ant-tabs-nav) {
|
||||
padding: 0 12px;
|
||||
|
||||
:global(.ant-tabs-tab) {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
.chatContainer {
|
||||
.chatToolbar {
|
||||
padding: 6px 12px;
|
||||
min-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.welcomeScreen {
|
||||
.welcomeContent {
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Layout } from "antd";
|
||||
import { MessageOutlined } from "@ant-design/icons";
|
||||
import ChatWindow from "./components/ChatWindow/index";
|
||||
import SidebarMenu from "./components/SidebarMenu/index";
|
||||
import VerticalUserList from "./components/VerticalUserList";
|
||||
import PageSkeleton from "./components/Skeleton";
|
||||
import styles from "./index.module.scss";
|
||||
import { addChatSession } from "@/store/module/ckchat/ckchat";
|
||||
const { Content, Sider } = Layout;
|
||||
import { chatInitAPIdata, initSocket } from "./main";
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
|
||||
import { KfUserListData } from "@/pages/pc/ckbox/data";
|
||||
|
||||
const CkboxPage: React.FC = () => {
|
||||
// 不要在组件初始化时获取sendCommand,而是在需要时动态获取
|
||||
const [loading, setLoading] = useState(false);
|
||||
const currentContract = useWeChatStore(state => state.currentContract);
|
||||
useEffect(() => {
|
||||
// 方法一:使用 Promise 链式调用处理异步函数
|
||||
setLoading(true);
|
||||
chatInitAPIdata()
|
||||
.then(response => {
|
||||
const data = response as {
|
||||
contractList: any[];
|
||||
groupList: any[];
|
||||
kfUserList: KfUserListData[];
|
||||
newContractList: { groupName: string; contacts: any[] }[];
|
||||
};
|
||||
const { contractList } = data;
|
||||
|
||||
//找出已经在聊天的
|
||||
const isChatList = contractList.filter(
|
||||
v => (v?.config && v.config?.chat) || false,
|
||||
);
|
||||
isChatList.forEach(v => {
|
||||
addChatSession(v);
|
||||
});
|
||||
|
||||
// 数据加载完成后初始化WebSocket连接
|
||||
initSocket();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("获取联系人列表失败:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageSkeleton loading={loading}>
|
||||
<Layout className={styles.ckboxLayout}>
|
||||
<Layout>
|
||||
{/* 垂直侧边栏 */}
|
||||
|
||||
<Sider width={80} className={styles.verticalSider}>
|
||||
<VerticalUserList />
|
||||
</Sider>
|
||||
|
||||
{/* 左侧联系人边栏 */}
|
||||
<Sider width={280} className={styles.sider}>
|
||||
<SidebarMenu loading={loading} />
|
||||
</Sider>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<Content className={styles.mainContent}>
|
||||
{currentContract ? (
|
||||
<div className={styles.chatContainer}>
|
||||
<ChatWindow contract={currentContract} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.welcomeScreen}>
|
||||
<div className={styles.welcomeContent}>
|
||||
<MessageOutlined style={{ fontSize: 64, color: "#1890ff" }} />
|
||||
<h2>欢迎使用触客宝</h2>
|
||||
<p>选择一个联系人开始聊天</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</PageSkeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export default CkboxPage;
|
||||
@@ -1,307 +0,0 @@
|
||||
import {
|
||||
asyncKfUserList,
|
||||
asyncContractList,
|
||||
asyncChatSessions,
|
||||
asyncWeChatGroup,
|
||||
asyncCountLables,
|
||||
useCkChatStore,
|
||||
} from "@/store/module/ckchat/ckchat";
|
||||
import { useWebSocketStore } from "@/store/module/websocket/websocket";
|
||||
|
||||
import {
|
||||
loginWithToken,
|
||||
getControlTerminalList,
|
||||
getContactList,
|
||||
getGroupList,
|
||||
} from "./api";
|
||||
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
|
||||
import {
|
||||
KfUserListData,
|
||||
ContractData,
|
||||
weChatGroup,
|
||||
} from "@/pages/pc/ckbox/data";
|
||||
|
||||
import { WechatGroup } from "./api";
|
||||
const { login2 } = useUserStore.getState();
|
||||
//获取触客宝基础信息
|
||||
export const chatInitAPIdata = async () => {
|
||||
try {
|
||||
//获取联系人列表
|
||||
const contractList = await getAllContactList();
|
||||
|
||||
//获取联系人列表
|
||||
asyncContractList(contractList);
|
||||
|
||||
//获取群列表
|
||||
const groupList = await getAllGroupList();
|
||||
|
||||
await asyncWeChatGroup(groupList);
|
||||
|
||||
// 提取不重复的wechatAccountId组
|
||||
const uniqueWechatAccountIds: number[] = getUniqueWechatAccountIds(
|
||||
contractList,
|
||||
groupList,
|
||||
);
|
||||
|
||||
//获取控制终端列表
|
||||
const kfUserList: KfUserListData[] =
|
||||
await getControlTerminalListByWechatAccountIds(uniqueWechatAccountIds);
|
||||
|
||||
//获取用户列表
|
||||
await asyncKfUserList(kfUserList);
|
||||
|
||||
//获取标签列表
|
||||
const countLables = await getCountLables();
|
||||
await asyncCountLables(countLables);
|
||||
|
||||
//获取消息会话列表并按lastUpdateTime排序
|
||||
const filterUserSessions = contractList?.filter(
|
||||
v => v?.config && v.config?.chat,
|
||||
);
|
||||
const filterGroupSessions = groupList?.filter(
|
||||
v => v?.config && v.config?.chat,
|
||||
);
|
||||
//排序功能
|
||||
const sortedSessions = [...filterUserSessions, ...filterGroupSessions].sort(
|
||||
(a, b) => {
|
||||
// 如果lastUpdateTime不存在,则将其排在最后
|
||||
if (!a.lastUpdateTime) return 1;
|
||||
if (!b.lastUpdateTime) return -1;
|
||||
|
||||
// 首先按时间降序排列(最新的在前面)
|
||||
const timeCompare =
|
||||
new Date(b.lastUpdateTime).getTime() -
|
||||
new Date(a.lastUpdateTime).getTime();
|
||||
|
||||
// 如果时间相同,则按未读消息数量降序排列
|
||||
if (timeCompare === 0) {
|
||||
// 如果unreadCount不存在,则将其排在后面
|
||||
const aUnread = a.unreadCount || 0;
|
||||
const bUnread = b.unreadCount || 0;
|
||||
return bUnread - aUnread; // 未读消息多的排在前面
|
||||
}
|
||||
|
||||
return timeCompare;
|
||||
},
|
||||
);
|
||||
//会话数据同步
|
||||
asyncChatSessions(sortedSessions);
|
||||
|
||||
return {
|
||||
contractList,
|
||||
groupList,
|
||||
kfUserList,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("获取联系人列表失败:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
//发起soket连接
|
||||
export const initSocket = () => {
|
||||
// 检查WebSocket是否已经连接
|
||||
const { status } = useWebSocketStore.getState();
|
||||
|
||||
// 如果已经连接或正在连接,则不重复连接
|
||||
if (["connected", "connecting"].includes(status)) {
|
||||
console.log("WebSocket已连接或正在连接,跳过重复连接", { status });
|
||||
return;
|
||||
}
|
||||
|
||||
// 从store获取token和accountId
|
||||
const { token2 } = useUserStore.getState();
|
||||
const { getAccountId } = useCkChatStore.getState();
|
||||
const Token = token2;
|
||||
const accountId = getAccountId();
|
||||
// 使用WebSocket store初始化连接
|
||||
const { connect } = useWebSocketStore.getState();
|
||||
|
||||
// 连接WebSocket
|
||||
connect({
|
||||
accessToken: Token,
|
||||
accountId: Number(accountId),
|
||||
client: "kefu-client",
|
||||
cmdType: "CmdSignIn",
|
||||
seq: +new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getCountLables = async () => {
|
||||
const LablesRes = await Promise.all(
|
||||
[1, 2].map(item =>
|
||||
WechatGroup({
|
||||
groupType: item,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const [friend, group] = LablesRes;
|
||||
const countLables = [
|
||||
...[
|
||||
{
|
||||
id: 0,
|
||||
groupName: "默认群分组",
|
||||
groupType: 2,
|
||||
},
|
||||
],
|
||||
...group,
|
||||
...friend,
|
||||
...[
|
||||
{
|
||||
id: 0,
|
||||
groupName: "未分组",
|
||||
groupType: 1,
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
return countLables;
|
||||
};
|
||||
/**
|
||||
* 根据标签组织联系人
|
||||
* @param contractList 联系人列表
|
||||
* @param countLables 标签列表
|
||||
* @returns 按标签分组的联系人
|
||||
*/
|
||||
|
||||
//获取控制终端列表
|
||||
export const getControlTerminalListByWechatAccountIds = (
|
||||
WechatAccountIds: number[],
|
||||
) => {
|
||||
return Promise.all(
|
||||
WechatAccountIds.map(id => getControlTerminalList({ id: id })),
|
||||
);
|
||||
};
|
||||
// 递归获取所有联系人列表
|
||||
export const getAllContactList = async () => {
|
||||
try {
|
||||
let allContacts = [];
|
||||
let prevId = 0;
|
||||
const count = 1000;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const contractList = await getContactList({
|
||||
prevId,
|
||||
count,
|
||||
});
|
||||
|
||||
if (
|
||||
!contractList ||
|
||||
!Array.isArray(contractList) ||
|
||||
contractList.length === 0
|
||||
) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
allContacts = [...allContacts, ...contractList];
|
||||
|
||||
// 如果返回的数据少于请求的数量,说明已经没有更多数据了
|
||||
if (contractList.length < count) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
// 获取最后一条数据的id作为下一次请求的prevId
|
||||
const lastContact = contractList[contractList.length - 1];
|
||||
prevId = lastContact.id;
|
||||
}
|
||||
}
|
||||
return allContacts;
|
||||
} catch (error) {
|
||||
console.error("获取所有联系人列表失败:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 提取不重复的wechatAccountId组
|
||||
export const getUniqueWechatAccountIds = (
|
||||
contacts: ContractData[],
|
||||
groupList: weChatGroup[],
|
||||
) => {
|
||||
if (!contacts || !Array.isArray(contacts) || contacts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 使用Set来存储不重复的wechatAccountId
|
||||
const uniqueAccountIdsSet = new Set<number>();
|
||||
|
||||
// 遍历联系人列表,将每个wechatAccountId添加到Set中
|
||||
contacts.forEach(contact => {
|
||||
if (contact && contact.wechatAccountId) {
|
||||
uniqueAccountIdsSet.add(contact.wechatAccountId);
|
||||
}
|
||||
});
|
||||
|
||||
// 遍历联系人列表,将每个wechatAccountId添加到Set中
|
||||
groupList.forEach(group => {
|
||||
if (group && group.wechatAccountId) {
|
||||
uniqueAccountIdsSet.add(group.wechatAccountId);
|
||||
}
|
||||
});
|
||||
|
||||
// 将Set转换为数组并返回
|
||||
return Array.from(uniqueAccountIdsSet);
|
||||
};
|
||||
// 递归获取所有群列表
|
||||
export const getAllGroupList = async () => {
|
||||
try {
|
||||
let allContacts = [];
|
||||
let prevId = 0;
|
||||
const count = 1000;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const contractList = await getGroupList({
|
||||
prevId,
|
||||
count,
|
||||
});
|
||||
|
||||
if (
|
||||
!contractList ||
|
||||
!Array.isArray(contractList) ||
|
||||
contractList.length === 0
|
||||
) {
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
allContacts = [...allContacts, ...contractList];
|
||||
|
||||
// 如果返回的数据少于请求的数量,说明已经没有更多数据了
|
||||
if (contractList.length < count) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
// 获取最后一条数据的id作为下一次请求的prevId
|
||||
const lastContact = contractList[contractList.length - 1];
|
||||
prevId = lastContact.id;
|
||||
}
|
||||
}
|
||||
|
||||
return allContacts;
|
||||
} catch (error) {
|
||||
console.error("获取所有群列表失败:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
//获取token
|
||||
const getToken = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const params = {
|
||||
grant_type: "password",
|
||||
password: "kr123456",
|
||||
username: "kr_xf3",
|
||||
// username: "karuo",
|
||||
// password: "zhiqun1984",
|
||||
};
|
||||
loginWithToken(params)
|
||||
.then(res => {
|
||||
login2(res.access_token);
|
||||
resolve(res.access_token);
|
||||
})
|
||||
.catch(err => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import Login from "@/pages/login/login";
|
||||
import Login from "@/pages/login/Login";
|
||||
import Guide from "@/pages/guide";
|
||||
|
||||
const authRoutes = [
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import CkboxPage from "@/pages/pc/ckbox";
|
||||
import WeChatPage from "@/pages/pc/ckbox/weChat";
|
||||
import Dashboard from "@/pages/pc/ckbox/dashboard";
|
||||
|
||||
const ckboxRoutes = [
|
||||
{
|
||||
path: "/ckbox",
|
||||
element: <CkboxPage />,
|
||||
auth: true,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
element: <Dashboard />,
|
||||
},
|
||||
{
|
||||
path: "dashboard",
|
||||
element: <Dashboard />,
|
||||
},
|
||||
{
|
||||
path: "weChat",
|
||||
element: <WeChatPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default ckboxRoutes;
|
||||
@@ -12,7 +12,6 @@ import SecuritySetting from "@/pages/mobile/mine/setting/SecuritySetting";
|
||||
import About from "@/pages/mobile/mine/setting/About";
|
||||
import Privacy from "@/pages/mobile/mine/setting/Privacy";
|
||||
import UserSetting from "@/pages/mobile/mine/setting/UserSetting";
|
||||
import Ckbox from "@/pages/pc/ckbox/index";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -25,11 +24,6 @@ const routes = [
|
||||
element: <Devices />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/mine/ckbox",
|
||||
element: <Ckbox />,
|
||||
auth: true,
|
||||
},
|
||||
{
|
||||
path: "/mine/devices/:id",
|
||||
element: <DeviceDetail />,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
//构建联系人列表标签
|
||||
import { weChatGroupService, contractService } from "@/utils/db";
|
||||
import { request } from "@/api/request2";
|
||||
import { ContactGroupByLabel } from "@/pages/pc/ckbox/data";
|
||||
|
||||
export function WechatGroup(params) {
|
||||
return request("/api/WechatGroup/list", params, "GET");
|
||||
}
|
||||
|
||||
export const createContractList = async (
|
||||
kfSelected: number,
|
||||
countLables: ContactGroupByLabel[],
|
||||
) => {
|
||||
// 根据 groupType 决定查询不同的服务
|
||||
const dataByLabels = [];
|
||||
for (const label of countLables) {
|
||||
let data;
|
||||
if (label.groupType === 1) {
|
||||
// groupType: 1, 查询 contractService
|
||||
data = await contractService.findWhere("groupId", label.id);
|
||||
// 过滤出 kfSelected 对应的联系人
|
||||
if (kfSelected && kfSelected != 0) {
|
||||
data = data.filter(contact => contact.wechatAccountId === kfSelected);
|
||||
}
|
||||
// console.log(`标签 ${label.groupName} 对应的联系人数据:`, data);
|
||||
} else if (label.groupType === 2) {
|
||||
// groupType: 2, 查询 weChatGroupService
|
||||
data = await weChatGroupService.findWhere("groupId", label.id);
|
||||
if (kfSelected && kfSelected != 0) {
|
||||
data = data.filter(contact => contact.wechatAccountId === kfSelected);
|
||||
}
|
||||
} else {
|
||||
console.warn(`未知的 groupType: ${label.groupType}`);
|
||||
data = [];
|
||||
}
|
||||
dataByLabels.push({
|
||||
...label,
|
||||
contacts: data,
|
||||
});
|
||||
}
|
||||
|
||||
return dataByLabels;
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import {
|
||||
ContractData,
|
||||
KfUserListData,
|
||||
CkAccount,
|
||||
ContactGroupByLabel,
|
||||
weChatGroup,
|
||||
} from "@/pages/pc/ckbox/data";
|
||||
|
||||
// 权限片段接口
|
||||
export interface PrivilegeFrag {
|
||||
// 根据实际数据结构补充
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 租户信息接口
|
||||
export interface CkTenant {
|
||||
id: number;
|
||||
name: string;
|
||||
guid: string;
|
||||
thirdParty: string | null;
|
||||
tenantType: number;
|
||||
deployName: string;
|
||||
}
|
||||
|
||||
// 触客宝用户信息接口
|
||||
export interface CkUserInfo {
|
||||
account: CkAccount;
|
||||
privilegeFrags: PrivilegeFrag[];
|
||||
tenant: CkTenant;
|
||||
}
|
||||
|
||||
// 状态接口
|
||||
export interface CkChatState {
|
||||
userInfo: CkUserInfo | null;
|
||||
isLoggedIn: boolean;
|
||||
searchKeyword: string;
|
||||
contractList: ContractData[];
|
||||
chatSessions: any[];
|
||||
kfUserList: KfUserListData[];
|
||||
kfSelected: number;
|
||||
getKfSelectedUser: () => KfUserListData | undefined;
|
||||
countLables: ContactGroupByLabel[];
|
||||
newContractList: ContactGroupByLabel[];
|
||||
getContractList: () => ContractData[];
|
||||
getSomeContractList: (kfSelected: number) => ContractData[];
|
||||
getNewContractList: () => ContactGroupByLabel[];
|
||||
setSearchKeyword: (keyword: string) => void;
|
||||
clearSearchKeyword: () => void;
|
||||
asyncKfSelected: (data: number) => void;
|
||||
asyncWeChatGroup: (data: weChatGroup[]) => void;
|
||||
asyncCountLables: (data: ContactGroupByLabel[]) => void;
|
||||
getkfUserList: () => KfUserListData[];
|
||||
asyncKfUserList: (data: KfUserListData[]) => void;
|
||||
getKfUserInfo: (wechatAccountId: number) => KfUserListData | undefined;
|
||||
asyncContractList: (data: ContractData[]) => void;
|
||||
getChatSessions: () => any[];
|
||||
asyncChatSessions: (data: any[]) => void;
|
||||
updateChatSession: (session: ContractData | weChatGroup) => void;
|
||||
deleteCtrlUser: (userId: number) => void;
|
||||
updateCtrlUser: (user: KfUserListData) => void;
|
||||
clearkfUserList: () => void;
|
||||
addChatSession: (session: any) => void;
|
||||
deleteChatSession: (sessionId: number) => void;
|
||||
setUserInfo: (userInfo: CkUserInfo) => void;
|
||||
clearUserInfo: () => void;
|
||||
updateAccount: (account: Partial<CkAccount>) => void;
|
||||
updateTenant: (tenant: Partial<CkTenant>) => void;
|
||||
getAccountId: () => number | null;
|
||||
getTenantId: () => number | null;
|
||||
getAccountName: () => string | null;
|
||||
getTenantName: () => string | null;
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
import { createPersistStore } from "@/store/createPersistStore";
|
||||
import { CkChatState, CkUserInfo, CkTenant } from "./ckchat.data";
|
||||
import {
|
||||
ContractData,
|
||||
weChatGroup,
|
||||
CkAccount,
|
||||
KfUserListData,
|
||||
ContactGroupByLabel,
|
||||
} from "@/pages/pc/ckbox/data";
|
||||
import { weChatGroupService, contractService } from "@/utils/db";
|
||||
import { createContractList } from "@/store/module/ckchat/api";
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
// 从weChat store获取clearCurrentContact方法
|
||||
const getClearCurrentContact = () =>
|
||||
useWeChatStore.getState().clearCurrentContact;
|
||||
export const useCkChatStore = createPersistStore<CkChatState>(
|
||||
set => ({
|
||||
userInfo: null,
|
||||
isLoggedIn: false,
|
||||
contractList: [], //联系人列表
|
||||
chatSessions: [], //聊天会话
|
||||
kfUserList: [], //客服列表
|
||||
countLables: [], //标签列表
|
||||
newContractList: [], //联系人分组
|
||||
kfSelected: 0, //选中的客服
|
||||
searchKeyword: "", //搜索关键词
|
||||
//客服列表
|
||||
asyncKfUserList: async data => {
|
||||
set({ kfUserList: data });
|
||||
// await kfUserService.createManyWithServerId(data);
|
||||
},
|
||||
// 获取客服列表
|
||||
getkfUserList: async () => {
|
||||
const state = useCkChatStore.getState();
|
||||
return state.kfUserList;
|
||||
// return await kfUserService.findAll();
|
||||
},
|
||||
// 异步设置标签列表
|
||||
asyncCountLables: async (data: ContactGroupByLabel[]) => {
|
||||
set({ countLables: data });
|
||||
// 清除getNewContractList缓存
|
||||
const state = useCkChatStore.getState();
|
||||
if (
|
||||
state.getNewContractList &&
|
||||
typeof state.getNewContractList === "function"
|
||||
) {
|
||||
// 触发缓存重新计算
|
||||
await state.getNewContractList();
|
||||
}
|
||||
},
|
||||
// 设置搜索关键词
|
||||
setSearchKeyword: (keyword: string) => {
|
||||
set({ searchKeyword: keyword });
|
||||
},
|
||||
// 清除搜索关键词
|
||||
clearSearchKeyword: () => {
|
||||
set({ searchKeyword: "" });
|
||||
},
|
||||
asyncKfSelected: async (data: number) => {
|
||||
set({ kfSelected: data });
|
||||
// 清除getChatSessions、getContractList和getNewContractList缓存
|
||||
const state = useCkChatStore.getState();
|
||||
if (
|
||||
state.getChatSessions &&
|
||||
typeof state.getChatSessions === "function"
|
||||
) {
|
||||
// 触发缓存重新计算
|
||||
state.getChatSessions();
|
||||
}
|
||||
if (
|
||||
state.getContractList &&
|
||||
typeof state.getContractList === "function"
|
||||
) {
|
||||
// 触发缓存重新计算
|
||||
state.getContractList();
|
||||
}
|
||||
if (
|
||||
state.getNewContractList &&
|
||||
typeof state.getNewContractList === "function"
|
||||
) {
|
||||
// 触发缓存重新计算
|
||||
await state.getNewContractList();
|
||||
}
|
||||
},
|
||||
|
||||
// 获取联系人分组列表 - 使用缓存避免无限循环
|
||||
getNewContractList: (() => {
|
||||
let cachedResult: any = null;
|
||||
let lastKfSelected: number | null = null;
|
||||
let lastCountLablesLength: number = 0;
|
||||
let lastSearchKeyword: string = "";
|
||||
|
||||
return async () => {
|
||||
const state = useCkChatStore.getState();
|
||||
|
||||
// 检查是否需要重新计算缓存
|
||||
const shouldRecalculate =
|
||||
cachedResult === null ||
|
||||
lastKfSelected !== state.kfSelected ||
|
||||
lastCountLablesLength !== (state.countLables?.length || 0) ||
|
||||
lastSearchKeyword !== state.searchKeyword;
|
||||
|
||||
if (shouldRecalculate) {
|
||||
// 使用createContractList构建联系人分组数据
|
||||
let contractList = await createContractList(
|
||||
state.kfSelected,
|
||||
state.countLables,
|
||||
);
|
||||
|
||||
// 根据搜索关键词筛选联系人分组
|
||||
if (state.searchKeyword.trim()) {
|
||||
const keyword = state.searchKeyword.toLowerCase();
|
||||
contractList = contractList
|
||||
.map(group => ({
|
||||
...group,
|
||||
contracts:
|
||||
group.contracts?.filter(item => {
|
||||
const nickname = (item.nickname || "").toLowerCase();
|
||||
const conRemark = (item.conRemark || "").toLowerCase();
|
||||
return (
|
||||
nickname.includes(keyword) || conRemark.includes(keyword)
|
||||
);
|
||||
}) || [],
|
||||
}))
|
||||
.filter(group => group.contracts.length > 0);
|
||||
}
|
||||
|
||||
cachedResult = contractList;
|
||||
lastKfSelected = state.kfSelected;
|
||||
lastCountLablesLength = state.countLables?.length || 0;
|
||||
lastSearchKeyword = state.searchKeyword;
|
||||
}
|
||||
|
||||
return cachedResult;
|
||||
};
|
||||
})(),
|
||||
// 搜索好友和群组的新方法 - 从本地数据库查询并返回扁平化的搜索结果
|
||||
searchContactsAndGroups: (() => {
|
||||
let cachedResult: (ContractData | weChatGroup)[] = [];
|
||||
let lastKfSelected: number | null = null;
|
||||
let lastSearchKeyword: string = "";
|
||||
|
||||
return async () => {
|
||||
const state = useCkChatStore.getState();
|
||||
|
||||
// 检查是否需要重新计算缓存
|
||||
const shouldRecalculate =
|
||||
lastKfSelected !== state.kfSelected ||
|
||||
lastSearchKeyword !== state.searchKeyword;
|
||||
|
||||
if (shouldRecalculate) {
|
||||
if (state.searchKeyword.trim()) {
|
||||
const keyword = state.searchKeyword.toLowerCase();
|
||||
|
||||
// 从本地数据库查询联系人数据
|
||||
let allContacts: any[] = await contractService.findAll();
|
||||
|
||||
// 从本地数据库查询群组数据
|
||||
let allGroups: any[] = await weChatGroupService.findAll();
|
||||
|
||||
// 根据选中的客服筛选联系人
|
||||
if (state.kfSelected !== 0) {
|
||||
allContacts = allContacts.filter(
|
||||
item => item.wechatAccountId === state.kfSelected,
|
||||
);
|
||||
}
|
||||
|
||||
// 根据选中的客服筛选群组
|
||||
if (state.kfSelected !== 0) {
|
||||
allGroups = allGroups.filter(
|
||||
item => item.wechatAccountId === state.kfSelected,
|
||||
);
|
||||
}
|
||||
|
||||
// 搜索匹配的联系人
|
||||
const matchedContacts = allContacts.filter(item => {
|
||||
const nickname = (item.nickname || "").toLowerCase();
|
||||
const conRemark = (item.conRemark || "").toLowerCase();
|
||||
return nickname.includes(keyword) || conRemark.includes(keyword);
|
||||
});
|
||||
|
||||
// 搜索匹配的群组
|
||||
const matchedGroups = allGroups.filter(item => {
|
||||
const nickname = (item.nickname || "").toLowerCase();
|
||||
const conRemark = (item.conRemark || "").toLowerCase();
|
||||
return nickname.includes(keyword) || conRemark.includes(keyword);
|
||||
});
|
||||
|
||||
// 合并搜索结果
|
||||
cachedResult = [...matchedContacts, ...matchedGroups];
|
||||
} else {
|
||||
cachedResult = [];
|
||||
}
|
||||
|
||||
lastKfSelected = state.kfSelected;
|
||||
lastSearchKeyword = state.searchKeyword;
|
||||
}
|
||||
|
||||
return cachedResult;
|
||||
};
|
||||
})(),
|
||||
// 异步设置联系人分组列表
|
||||
asyncNewContractList: async (data: any[]) => {
|
||||
set({ newContractList: data });
|
||||
// 清除getNewContractList缓存
|
||||
const state = useCkChatStore.getState();
|
||||
if (
|
||||
state.getNewContractList &&
|
||||
typeof state.getNewContractList === "function"
|
||||
) {
|
||||
// 触发缓存重新计算
|
||||
await state.getNewContractList();
|
||||
}
|
||||
},
|
||||
// 异步设置会话列表
|
||||
asyncChatSessions: data => {
|
||||
set({ chatSessions: data });
|
||||
// 清除getChatSessions缓存
|
||||
const state = useCkChatStore.getState();
|
||||
if (
|
||||
state.getChatSessions &&
|
||||
typeof state.getChatSessions === "function"
|
||||
) {
|
||||
// 触发缓存重新计算
|
||||
state.getChatSessions();
|
||||
}
|
||||
},
|
||||
// 异步设置联系人列表
|
||||
asyncContractList: async (data: ContractData[]) => {
|
||||
set({ contractList: data });
|
||||
await contractService.createManyWithServerId(data);
|
||||
// 清除getContractList缓存
|
||||
const state = useCkChatStore.getState();
|
||||
if (
|
||||
state.getContractList &&
|
||||
typeof state.getContractList === "function"
|
||||
) {
|
||||
// 触发缓存重新计算
|
||||
state.getContractList();
|
||||
}
|
||||
},
|
||||
//获取特定联系人
|
||||
getSomeContractList: (kfSelected: number) => {
|
||||
const state = useCkChatStore.getState();
|
||||
return state.contractList.filter(
|
||||
item => item.wechatAccountId === kfSelected,
|
||||
);
|
||||
},
|
||||
// 获取联系人列表 - 使用缓存避免无限循环
|
||||
getContractList: (() => {
|
||||
let cachedResult: any = null;
|
||||
let lastKfSelected: number | null = null;
|
||||
let lastContractListLength: number = 0;
|
||||
let lastSearchKeyword: string = "";
|
||||
|
||||
return () => {
|
||||
const state = useCkChatStore.getState();
|
||||
|
||||
// 检查是否需要重新计算缓存
|
||||
const shouldRecalculate =
|
||||
cachedResult === null ||
|
||||
lastKfSelected !== state.kfSelected ||
|
||||
lastContractListLength !== state.contractList.length ||
|
||||
lastSearchKeyword !== state.searchKeyword;
|
||||
|
||||
if (shouldRecalculate) {
|
||||
let filteredContracts = state.contractList;
|
||||
|
||||
// 根据客服筛选
|
||||
if (state.kfSelected !== 0) {
|
||||
filteredContracts = filteredContracts.filter(
|
||||
item => item.wechatAccountId === state.kfSelected,
|
||||
);
|
||||
}
|
||||
|
||||
// 根据搜索关键词筛选
|
||||
if (state.searchKeyword.trim()) {
|
||||
const keyword = state.searchKeyword.toLowerCase();
|
||||
filteredContracts = filteredContracts.filter(item => {
|
||||
const nickname = (item.nickname || "").toLowerCase();
|
||||
const conRemark = (item.conRemark || "").toLowerCase();
|
||||
return nickname.includes(keyword) || conRemark.includes(keyword);
|
||||
});
|
||||
}
|
||||
|
||||
cachedResult = filteredContracts;
|
||||
lastKfSelected = state.kfSelected;
|
||||
lastContractListLength = state.contractList.length;
|
||||
lastSearchKeyword = state.searchKeyword;
|
||||
}
|
||||
|
||||
return cachedResult;
|
||||
};
|
||||
})(),
|
||||
//异步设置联系人分组
|
||||
asyncWeChatGroup: async (data: weChatGroup[]) => {
|
||||
await weChatGroupService.createManyWithServerId(data);
|
||||
},
|
||||
//获取选中的客服信息
|
||||
getKfSelectedUser: () => {
|
||||
const state = useCkChatStore.getState();
|
||||
return state.kfUserList.find(item => item.id === state.kfSelected);
|
||||
},
|
||||
getKfUserInfo: (wechatAccountId: number) => {
|
||||
const state = useCkChatStore.getState();
|
||||
return state.kfUserList.find(item => item.id === wechatAccountId);
|
||||
},
|
||||
|
||||
// 删除控制终端用户
|
||||
deleteCtrlUser: (userId: number) => {
|
||||
set(state => ({
|
||||
kfUserList: state.kfUserList.filter(item => item.id !== userId),
|
||||
}));
|
||||
},
|
||||
// 更新控制终端用户
|
||||
updateCtrlUser: (user: KfUserListData) => {
|
||||
set(state => ({
|
||||
kfUserList: state.kfUserList.map(item =>
|
||||
item.id === user.id ? user : item,
|
||||
),
|
||||
}));
|
||||
},
|
||||
// 清空控制终端用户列表
|
||||
clearkfUserList: () => {
|
||||
set({ kfUserList: [] });
|
||||
},
|
||||
// 获取聊天会话 - 使用缓存避免无限循环
|
||||
getChatSessions: (() => {
|
||||
let cachedResult: any = null;
|
||||
let lastKfSelected: number | null = null;
|
||||
let lastChatSessionsLength: number = 0;
|
||||
let lastSearchKeyword: string = "";
|
||||
|
||||
return () => {
|
||||
const state = useCkChatStore.getState();
|
||||
|
||||
// 检查是否需要重新计算缓存
|
||||
const shouldRecalculate =
|
||||
cachedResult === null ||
|
||||
lastKfSelected !== state.kfSelected ||
|
||||
lastChatSessionsLength !== state.chatSessions.length ||
|
||||
lastSearchKeyword !== state.searchKeyword;
|
||||
|
||||
if (shouldRecalculate) {
|
||||
let filteredSessions = state.chatSessions;
|
||||
|
||||
// 根据客服筛选
|
||||
if (state.kfSelected !== 0) {
|
||||
filteredSessions = filteredSessions.filter(
|
||||
item => item.wechatAccountId === state.kfSelected,
|
||||
);
|
||||
}
|
||||
|
||||
// 根据搜索关键词筛选
|
||||
if (state.searchKeyword.trim()) {
|
||||
const keyword = state.searchKeyword.toLowerCase();
|
||||
filteredSessions = filteredSessions.filter(item => {
|
||||
const nickname = (item.nickname || "").toLowerCase();
|
||||
const conRemark = (item.conRemark || "").toLowerCase();
|
||||
return nickname.includes(keyword) || conRemark.includes(keyword);
|
||||
});
|
||||
}
|
||||
|
||||
cachedResult = filteredSessions;
|
||||
lastKfSelected = state.kfSelected;
|
||||
lastChatSessionsLength = state.chatSessions.length;
|
||||
lastSearchKeyword = state.searchKeyword;
|
||||
}
|
||||
|
||||
return cachedResult;
|
||||
};
|
||||
})(),
|
||||
// 添加聊天会话
|
||||
addChatSession: (session: ContractData | weChatGroup) => {
|
||||
set(state => {
|
||||
// 检查是否已存在相同id的会话
|
||||
const exists = state.chatSessions.some(item => item.id === session.id);
|
||||
// 如果已存在则不添加,否则添加到列表中
|
||||
return {
|
||||
chatSessions: exists
|
||||
? state.chatSessions
|
||||
: [...state.chatSessions, session as ContractData | weChatGroup],
|
||||
};
|
||||
});
|
||||
},
|
||||
// 更新聊天会话
|
||||
updateChatSession: (session: ContractData | weChatGroup) => {
|
||||
set(state => ({
|
||||
chatSessions: state.chatSessions.map(item =>
|
||||
item.id === session.id ? { ...item, ...session } : item,
|
||||
),
|
||||
}));
|
||||
},
|
||||
// 删除聊天会话
|
||||
deleteChatSession: (sessionId: number) => {
|
||||
set(state => ({
|
||||
chatSessions: state.chatSessions.filter(item => item.id !== sessionId),
|
||||
}));
|
||||
//当前选中的客户清空
|
||||
getClearCurrentContact();
|
||||
},
|
||||
// 设置用户信息
|
||||
setUserInfo: (userInfo: CkUserInfo) => {
|
||||
set({ userInfo, isLoggedIn: true });
|
||||
},
|
||||
|
||||
// 清除用户信息
|
||||
clearUserInfo: () => {
|
||||
set({ userInfo: null, isLoggedIn: false });
|
||||
},
|
||||
|
||||
// 更新账户信息
|
||||
updateAccount: (account: Partial<CkAccount>) => {
|
||||
set(state => ({
|
||||
userInfo: state.userInfo
|
||||
? {
|
||||
...state.userInfo,
|
||||
account: { ...state.userInfo.account, ...account },
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
},
|
||||
|
||||
// 更新租户信息
|
||||
updateTenant: (tenant: Partial<CkTenant>) => {
|
||||
set(state => ({
|
||||
userInfo: state.userInfo
|
||||
? {
|
||||
...state.userInfo,
|
||||
tenant: { ...state.userInfo.tenant, ...tenant },
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
},
|
||||
|
||||
// 获取账户ID
|
||||
getAccountId: () => {
|
||||
const state = useCkChatStore.getState();
|
||||
return Number(state.userInfo?.account?.id) || null;
|
||||
},
|
||||
|
||||
// 获取租户ID
|
||||
getTenantId: () => {
|
||||
const state = useCkChatStore.getState();
|
||||
return state.userInfo?.tenant?.id || null;
|
||||
},
|
||||
|
||||
// 获取账户名称
|
||||
getAccountName: () => {
|
||||
const state = useCkChatStore.getState();
|
||||
return (
|
||||
state.userInfo?.account?.realName ||
|
||||
state.userInfo?.account?.userName ||
|
||||
null
|
||||
);
|
||||
},
|
||||
|
||||
// 获取租户名称
|
||||
getTenantName: () => {
|
||||
const state = useCkChatStore.getState();
|
||||
return state.userInfo?.tenant?.name || null;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "ckchat-store",
|
||||
partialize: state => ({
|
||||
userInfo: state.userInfo,
|
||||
isLoggedIn: state.isLoggedIn,
|
||||
kfUserList: state.kfUserList,
|
||||
}),
|
||||
onRehydrateStorage: () => state => {
|
||||
// console.log("CkChat store hydrated:", state);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// 导出便捷的获取方法
|
||||
export const getCkAccountId = () => useCkChatStore.getState().getAccountId();
|
||||
export const getCkTenantId = () => useCkChatStore.getState().getTenantId();
|
||||
export const getCkAccountName = () =>
|
||||
useCkChatStore.getState().getAccountName();
|
||||
export const getCkTenantName = () => useCkChatStore.getState().getTenantName();
|
||||
export const getChatSessions = () =>
|
||||
useCkChatStore.getState().getChatSessions();
|
||||
export const addChatSession = (session: ContractData | weChatGroup) =>
|
||||
useCkChatStore.getState().addChatSession(session);
|
||||
export const updateChatSession = (session: ContractData | weChatGroup) =>
|
||||
useCkChatStore.getState().updateChatSession(session);
|
||||
export const deleteChatSession = (sessionId: string) =>
|
||||
useCkChatStore.getState().deleteChatSession(sessionId);
|
||||
export const getkfUserList = () => useCkChatStore.getState().kfUserList;
|
||||
export const addCtrlUser = (user: KfUserListData) =>
|
||||
useCkChatStore.getState().addCtrlUser(user);
|
||||
export const deleteCtrlUser = (userId: number) =>
|
||||
useCkChatStore.getState().deleteCtrlUser(userId);
|
||||
export const updateCtrlUser = (user: KfUserListData) =>
|
||||
useCkChatStore.getState().updateCtrlUser(user);
|
||||
export const asyncKfUserList = (data: KfUserListData[]) =>
|
||||
useCkChatStore.getState().asyncKfUserList(data);
|
||||
export const asyncContractList = (data: ContractData[]) =>
|
||||
useCkChatStore.getState().asyncContractList(data);
|
||||
export const asyncChatSessions = (data: ContractData[]) =>
|
||||
useCkChatStore.getState().asyncChatSessions(data);
|
||||
export const asyncKfSelected = (data: number) =>
|
||||
useCkChatStore.getState().asyncKfSelected(data);
|
||||
export const asyncWeChatGroup = (data: weChatGroup[]) =>
|
||||
useCkChatStore.getState().asyncWeChatGroup(data);
|
||||
export const getKfSelectedUser = () =>
|
||||
useCkChatStore.getState().getKfSelectedUser();
|
||||
export const getKfUserInfo = (wechatAccountId: number) =>
|
||||
useCkChatStore.getState().getKfUserInfo(wechatAccountId);
|
||||
export const getContractList = () =>
|
||||
useCkChatStore.getState().getContractList();
|
||||
export const getNewContractList = () =>
|
||||
useCkChatStore.getState().getNewContractList();
|
||||
export const asyncCountLables = (data: ContactGroupByLabel[]) =>
|
||||
useCkChatStore.getState().asyncCountLables(data);
|
||||
export const asyncNewContractList = (data: any[]) =>
|
||||
useCkChatStore.getState().asyncNewContractList(data);
|
||||
export const getCountLables = () => useCkChatStore.getState().countLables;
|
||||
export const setSearchKeyword = (keyword: string) =>
|
||||
useCkChatStore.getState().setSearchKeyword(keyword);
|
||||
export const clearSearchKeyword = () =>
|
||||
useCkChatStore.getState().clearSearchKeyword();
|
||||
export const searchContactsAndGroups = () =>
|
||||
useCkChatStore.getState().searchContactsAndGroups();
|
||||
useCkChatStore.getState().getKfSelectedUser();
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
// 微信聊天相关的类型定义
|
||||
export interface WeChatState {
|
||||
// 当前选中的联系人/群组
|
||||
currentContract: ContractData | weChatGroup | null;
|
||||
|
||||
// 当前聊天用户的消息列表(只存储当前聊天用户的消息)
|
||||
currentMessages: ChatRecord[];
|
||||
// 清空当前联系人
|
||||
clearCurrentContact: () => void;
|
||||
// 消息加载状态
|
||||
messagesLoading: boolean;
|
||||
isLoadingData: boolean;
|
||||
currentGroupMembers: any[];
|
||||
|
||||
// Actions
|
||||
setCurrentContact: (
|
||||
contract: ContractData | weChatGroup,
|
||||
isExist?: boolean,
|
||||
) => void;
|
||||
loadChatMessages: (Init: boolean, To?: number) => Promise<void>;
|
||||
SearchMessage: (params: {
|
||||
From: number;
|
||||
To: number;
|
||||
keyword: string;
|
||||
Count?: number;
|
||||
}) => Promise<void>;
|
||||
// 视频消息处理方法
|
||||
setVideoLoading: (messageId: number, isLoading: boolean) => void;
|
||||
setVideoUrl: (messageId: number, videoUrl: string) => void;
|
||||
addMessage: (message: ChatRecord) => void;
|
||||
receivedMsg: (message: ChatRecord) => void;
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import {
|
||||
getChatMessages,
|
||||
getChatroomMessages,
|
||||
getGroupMembers,
|
||||
} from "@/pages/pc/ckbox/api";
|
||||
import { WeChatState } from "./weChat.data";
|
||||
import { clearUnreadCount, updateConfig } from "@/pages/pc/ckbox/api";
|
||||
import { ContractData, weChatGroup } from "@/pages/pc/ckbox/data";
|
||||
import { weChatGroupService, contractService } from "@/utils/db";
|
||||
import {
|
||||
addChatSession,
|
||||
updateChatSession,
|
||||
useCkChatStore,
|
||||
} from "@/store/module/ckchat/ckchat";
|
||||
|
||||
export const useWeChatStore = create<WeChatState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// 初始状态
|
||||
currentContract: null,
|
||||
currentMessages: [],
|
||||
messagesLoading: false,
|
||||
isLoadingData: false,
|
||||
currentGroupMembers: [],
|
||||
//清空当前联系人
|
||||
clearCurrentContact: () => {
|
||||
set({ currentContract: null, currentMessages: [] });
|
||||
},
|
||||
// Actions
|
||||
setCurrentContact: (
|
||||
contract: ContractData | weChatGroup,
|
||||
isExist?: boolean,
|
||||
) => {
|
||||
const state = useWeChatStore.getState();
|
||||
// 切换联系人时清空当前消息,等待重新加载
|
||||
set({ currentMessages: [] });
|
||||
clearUnreadCount([contract.id]).then(() => {
|
||||
if (isExist) {
|
||||
updateChatSession({ ...contract, unreadCount: 0 });
|
||||
} else {
|
||||
addChatSession(contract);
|
||||
}
|
||||
set({ currentContract: contract });
|
||||
updateConfig({
|
||||
id: contract.id,
|
||||
config: { chat: true },
|
||||
});
|
||||
state.loadChatMessages(true, 4704624000000);
|
||||
});
|
||||
},
|
||||
loadChatMessages: async (Init: boolean, To?: number) => {
|
||||
const state = useWeChatStore.getState();
|
||||
const contact = state.currentContract;
|
||||
set({ messagesLoading: true });
|
||||
set({ isLoadingData: Init });
|
||||
try {
|
||||
const params: any = {
|
||||
wechatAccountId: contact.wechatAccountId,
|
||||
From: 1,
|
||||
To: To || +new Date(),
|
||||
Count: 5,
|
||||
olderData: true,
|
||||
};
|
||||
|
||||
if ("chatroomId" in contact && contact.chatroomId) {
|
||||
params.wechatChatroomId = contact.id;
|
||||
const messages = await getChatroomMessages(params);
|
||||
const currentGroupMembers = await getGroupMembers({
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error("获取聊天消息失败:", error);
|
||||
} finally {
|
||||
set({ messagesLoading: false });
|
||||
}
|
||||
},
|
||||
SearchMessage: async ({
|
||||
From = 1,
|
||||
To = 4704624000000,
|
||||
keyword = "",
|
||||
Count = 20,
|
||||
}: {
|
||||
From: number;
|
||||
To: number;
|
||||
keyword: string;
|
||||
Count?: number;
|
||||
}) => {
|
||||
const state = useWeChatStore.getState();
|
||||
const contact = state.currentContract;
|
||||
set({ messagesLoading: true });
|
||||
|
||||
try {
|
||||
const params: any = {
|
||||
wechatAccountId: contact.wechatAccountId,
|
||||
From,
|
||||
To,
|
||||
keyword,
|
||||
Count,
|
||||
olderData: true,
|
||||
};
|
||||
|
||||
if ("chatroomId" in contact && contact.chatroomId) {
|
||||
params.wechatChatroomId = contact.id;
|
||||
const messages = await getChatroomMessages(params);
|
||||
const currentGroupMembers = await getGroupMembers({
|
||||
id: contact.id,
|
||||
});
|
||||
set({ currentMessages: messages || [], currentGroupMembers });
|
||||
} else {
|
||||
params.wechatFriendId = contact.id;
|
||||
const messages = await getChatMessages(params);
|
||||
set({ currentMessages: messages || [] });
|
||||
}
|
||||
set({ messagesLoading: false });
|
||||
} catch (error) {
|
||||
console.error("获取聊天消息失败:", error);
|
||||
} finally {
|
||||
set({ messagesLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setMessageLoading: loading => {
|
||||
set({ messagesLoading: Boolean(loading) });
|
||||
},
|
||||
|
||||
addMessage: message => {
|
||||
set(state => ({
|
||||
currentMessages: [...state.currentMessages, message],
|
||||
}));
|
||||
},
|
||||
|
||||
receivedMsg: async message => {
|
||||
const currentContract = useWeChatStore.getState().currentContract;
|
||||
//判断群还是好友
|
||||
const getMessageId =
|
||||
message?.wechatChatroomId || message.wechatFriendId;
|
||||
const isWechatGroup = message?.wechatChatroomId;
|
||||
//当前选中聊天的群或好友
|
||||
if (currentContract && currentContract.id == getMessageId) {
|
||||
set(state => ({
|
||||
currentMessages: [...state.currentMessages, message],
|
||||
}));
|
||||
} else {
|
||||
//更新消息列表unread数值,根据接收的++1 这样
|
||||
const chatSessions = useCkChatStore.getState().chatSessions;
|
||||
const session = chatSessions.find(item => item.id == getMessageId);
|
||||
if (session) {
|
||||
session.unreadCount = Number(session.unreadCount) + 1;
|
||||
updateChatSession(session);
|
||||
} else {
|
||||
if (isWechatGroup) {
|
||||
const [group] = await weChatGroupService.findByIds(getMessageId);
|
||||
if (group) {
|
||||
addChatSession({
|
||||
...group,
|
||||
unreadCount: 1,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const [user] = await contractService.findByIds(getMessageId);
|
||||
addChatSession({
|
||||
...user,
|
||||
unreadCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updateMessage: (messageId, updates) => {
|
||||
set(state => ({
|
||||
currentMessages: state.currentMessages.map(msg =>
|
||||
msg.id === messageId ? { ...msg, ...updates } : msg,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
// 便捷选择器
|
||||
getCurrentContact: () => get().currentContract,
|
||||
getCurrentMessages: () => get().currentMessages,
|
||||
getMessagesLoading: () => get().messagesLoading,
|
||||
|
||||
// 视频消息处理方法
|
||||
setVideoLoading: (messageId: number, isLoading: boolean) => {
|
||||
set(state => ({
|
||||
currentMessages: state.currentMessages.map(msg => {
|
||||
if (msg.id === messageId) {
|
||||
try {
|
||||
const content = JSON.parse(msg.content);
|
||||
// 更新加载状态
|
||||
const updatedContent = { ...content, isLoading };
|
||||
return {
|
||||
...msg,
|
||||
content: JSON.stringify(updatedContent),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("更新视频加载状态失败:", e);
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}),
|
||||
}));
|
||||
},
|
||||
|
||||
setVideoUrl: (messageId: number, videoUrl: string) => {
|
||||
set(state => ({
|
||||
currentMessages: state.currentMessages.map(msg => {
|
||||
if (msg.id === messageId) {
|
||||
try {
|
||||
const content = JSON.parse(msg.content);
|
||||
// 检查视频是否已经下载完毕,避免重复更新
|
||||
if (content.videoUrl && content.videoUrl === videoUrl) {
|
||||
console.log("视频已下载,跳过重复更新:", messageId);
|
||||
return msg;
|
||||
}
|
||||
|
||||
// 设置视频URL并清除加载状态
|
||||
const updatedContent = {
|
||||
...content,
|
||||
videoUrl,
|
||||
isLoading: false,
|
||||
};
|
||||
return {
|
||||
...msg,
|
||||
content: JSON.stringify(updatedContent),
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("更新视频URL失败:", e);
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}),
|
||||
}));
|
||||
},
|
||||
clearAllData: () => {
|
||||
set({
|
||||
currentContract: null,
|
||||
currentMessages: [],
|
||||
messagesLoading: false,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "wechat-storage",
|
||||
partialize: state => ({
|
||||
// currentContract 不做持久化,登录和页面刷新时直接清空
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// 导出便捷的选择器函数
|
||||
export const useCurrentContact = () =>
|
||||
useWeChatStore(state => state.currentContract);
|
||||
export const useCurrentMessages = () =>
|
||||
useWeChatStore(state => state.currentMessages);
|
||||
export const useMessagesLoading = () =>
|
||||
useWeChatStore(state => state.messagesLoading);
|
||||
@@ -1,7 +0,0 @@
|
||||
import { ChatRecord } from "@/pages/pc/ckbox/data";
|
||||
export interface Messages {
|
||||
friendMessage?: ChatRecord | null;
|
||||
chatroomMessage?: ChatRecord | null;
|
||||
seq: number;
|
||||
cmdType: string;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
//消息管理器
|
||||
import { deepCopy } from "@/utils/common";
|
||||
import { WebSocketMessage } from "./websocket";
|
||||
import { getkfUserList, asyncKfUserList } from "@/store/module/ckchat/ckchat";
|
||||
import { Messages } from "./msg.data";
|
||||
|
||||
import { useWeChatStore } from "@/store/module/weChat/weChat";
|
||||
// 消息处理器类型定义
|
||||
type MessageHandler = (message: WebSocketMessage) => void;
|
||||
const setVideoUrl = useWeChatStore.getState().setVideoUrl;
|
||||
const addMessage = useWeChatStore.getState().addMessage;
|
||||
const receivedMsg = useWeChatStore.getState().receivedMsg;
|
||||
|
||||
// 消息处理器映射
|
||||
const messageHandlers: Record<string, MessageHandler> = {
|
||||
// 微信账号存活状态响应
|
||||
CmdRequestWechatAccountsAliveStatusResp: message => {
|
||||
// console.log("微信账号存活状态响应", message);
|
||||
// 获取客服列表
|
||||
const kfUserList = deepCopy(getkfUserList());
|
||||
const wechatAccountsAliveStatus = message.wechatAccountsAliveStatus || {};
|
||||
// 遍历客服列表,更新存活状态
|
||||
kfUserList.forEach(kfUser => {
|
||||
kfUser.isOnline = wechatAccountsAliveStatus[kfUser.id];
|
||||
});
|
||||
asyncKfUserList(kfUserList);
|
||||
},
|
||||
// 发送消息响应
|
||||
CmdSendMessageResp: message => {
|
||||
console.log("发送消息响应", message);
|
||||
addMessage(message.friendMessage || message.chatroomMessage);
|
||||
// 在这里添加具体的处理逻辑
|
||||
},
|
||||
CmdSendMessageResult: message => {
|
||||
console.log("发送消息结果", message);
|
||||
// 在这里添加具体的处理逻辑
|
||||
},
|
||||
// 接收消息响应
|
||||
CmdReceiveMessageResp: message => {
|
||||
console.log("接收消息响应", message);
|
||||
addMessage(message.friendMessage || message.chatroomMessage);
|
||||
// 在这里添加具体的处理逻辑
|
||||
},
|
||||
//收到消息
|
||||
CmdNewMessage: (message: Messages) => {
|
||||
// 在这里添加具体的处理逻辑
|
||||
receivedMsg(message.friendMessage || message.chatroomMessage);
|
||||
},
|
||||
CmdFriendInfoChanged: message => {
|
||||
// console.log("好友信息变更", message);
|
||||
// 在这里添加具体的处理逻辑
|
||||
},
|
||||
|
||||
// 登录响应
|
||||
CmdSignInResp: message => {
|
||||
console.log("登录响应", message);
|
||||
// 在这里添加具体的处理逻辑
|
||||
},
|
||||
|
||||
// 通知消息
|
||||
CmdNotify: message => {
|
||||
console.log("通知消息", message);
|
||||
// 在这里添加具体的处理逻辑
|
||||
if (message.notify == "Kicked out") {
|
||||
// 被踢出时直接跳转到登录页面
|
||||
window.location.href = "/login";
|
||||
}
|
||||
},
|
||||
|
||||
CmdDownloadVideoResult: message => {
|
||||
// 在这里添加具体的处理逻辑
|
||||
setVideoUrl(message.friendMessageId, message.url);
|
||||
},
|
||||
|
||||
// 可以继续添加更多处理器...
|
||||
};
|
||||
|
||||
// 默认处理器
|
||||
const defaultHandler: MessageHandler = message => {
|
||||
console.log("未知消息类型", message.cmdType, message);
|
||||
};
|
||||
|
||||
// 注册新的消息处理器
|
||||
export const registerMessageHandler = (
|
||||
cmdType: string,
|
||||
handler: MessageHandler,
|
||||
) => {
|
||||
messageHandlers[cmdType] = handler;
|
||||
};
|
||||
|
||||
// 移除消息处理器
|
||||
export const unregisterMessageHandler = (cmdType: string) => {
|
||||
delete messageHandlers[cmdType];
|
||||
};
|
||||
|
||||
// 获取所有已注册的消息类型
|
||||
export const getRegisteredMessageTypes = (): string[] => {
|
||||
return Object.keys(messageHandlers);
|
||||
};
|
||||
|
||||
// 消息管理核心函数
|
||||
export const msgManageCore = (message: WebSocketMessage) => {
|
||||
const cmdType = message.cmdType;
|
||||
if (!cmdType) {
|
||||
console.warn("消息缺少cmdType字段", message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取对应的处理器,如果没有则使用默认处理器
|
||||
const handler = messageHandlers[cmdType] || defaultHandler;
|
||||
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error) {
|
||||
console.error(`处理消息类型 ${cmdType} 时发生错误:`, error);
|
||||
}
|
||||
};
|
||||
@@ -1,586 +0,0 @@
|
||||
import { createPersistStore } from "@/store/createPersistStore";
|
||||
import { Toast } from "antd-mobile";
|
||||
import { useUserStore } from "../user";
|
||||
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
||||
const { getAccountId } = useCkChatStore.getState();
|
||||
import { msgManageCore } from "./msgManage";
|
||||
// WebSocket消息类型
|
||||
export interface WebSocketMessage {
|
||||
cmdType?: string;
|
||||
seq?: number;
|
||||
wechatAccountIds?: string[];
|
||||
content?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// WebSocket连接状态
|
||||
export enum WebSocketStatus {
|
||||
DISCONNECTED = "disconnected",
|
||||
CONNECTING = "connecting",
|
||||
CONNECTED = "connected",
|
||||
RECONNECTING = "reconnecting",
|
||||
ERROR = "error",
|
||||
}
|
||||
|
||||
// WebSocket配置
|
||||
interface WebSocketConfig {
|
||||
url: string;
|
||||
client: string;
|
||||
accountId: number;
|
||||
accessToken: string;
|
||||
autoReconnect: boolean;
|
||||
cmdType: string;
|
||||
seq: number;
|
||||
reconnectInterval: number;
|
||||
maxReconnectAttempts: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface WebSocketState {
|
||||
// 连接状态
|
||||
status: WebSocketStatus;
|
||||
ws: WebSocket | null;
|
||||
|
||||
// 配置信息
|
||||
config: WebSocketConfig | null;
|
||||
|
||||
// 消息相关
|
||||
messages: WebSocketMessage[];
|
||||
unreadCount: number;
|
||||
|
||||
// 重连相关
|
||||
reconnectAttempts: number;
|
||||
reconnectTimer: NodeJS.Timeout | null;
|
||||
aliveStatusTimer: NodeJS.Timeout | null; // 客服用户状态查询定时器
|
||||
|
||||
// 方法
|
||||
connect: (config: Partial<WebSocketConfig>) => void;
|
||||
disconnect: () => void;
|
||||
sendMessage: (message: Omit<WebSocketMessage, "id" | "timestamp">) => void;
|
||||
sendCommand: (cmdType: string, data?: any) => void;
|
||||
clearMessages: () => void;
|
||||
markAsRead: () => void;
|
||||
reconnect: () => void;
|
||||
clearConnectionState: () => void; // 清空连接状态
|
||||
|
||||
// 内部方法
|
||||
_handleOpen: () => void;
|
||||
_handleMessage: (event: MessageEvent) => void;
|
||||
_handleClose: (event: CloseEvent) => void;
|
||||
_handleError: (event: Event) => void;
|
||||
_startReconnectTimer: () => void;
|
||||
_stopReconnectTimer: () => void;
|
||||
_startAliveStatusTimer: () => void; // 启动客服状态查询定时器
|
||||
_stopAliveStatusTimer: () => void; // 停止客服状态查询定时器
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG: WebSocketConfig = {
|
||||
url: "wss://kf.quwanzhi.com:9993",
|
||||
client: "kefu-client",
|
||||
accountId: 0,
|
||||
accessToken: "",
|
||||
autoReconnect: true,
|
||||
cmdType: "", // 添加默认的命令类型
|
||||
seq: +new Date(), // 添加默认的序列号
|
||||
reconnectInterval: 3000,
|
||||
maxReconnectAttempts: 5,
|
||||
};
|
||||
|
||||
export const useWebSocketStore = createPersistStore<WebSocketState>(
|
||||
(set, get) => ({
|
||||
status: WebSocketStatus.DISCONNECTED,
|
||||
ws: null,
|
||||
config: null,
|
||||
messages: [],
|
||||
unreadCount: 0,
|
||||
reconnectAttempts: 0,
|
||||
reconnectTimer: null,
|
||||
aliveStatusTimer: null,
|
||||
|
||||
// 连接WebSocket
|
||||
connect: (config: Partial<WebSocketConfig>) => {
|
||||
const currentState = get();
|
||||
|
||||
// 检查当前连接状态,避免重复连接
|
||||
if (
|
||||
currentState.status === WebSocketStatus.CONNECTED ||
|
||||
currentState.status === WebSocketStatus.CONNECTING
|
||||
) {
|
||||
// console.log("WebSocket已连接或正在连接,跳过重复连接", {
|
||||
// currentStatus: currentState.status,
|
||||
// hasWebSocket: !!currentState.ws,
|
||||
// });
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已经有WebSocket实例,先断开
|
||||
if (currentState.ws) {
|
||||
// console.log("断开现有WebSocket连接");
|
||||
currentState.disconnect();
|
||||
}
|
||||
|
||||
// 合并配置
|
||||
const fullConfig: WebSocketConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
...config,
|
||||
};
|
||||
|
||||
// 获取用户信息
|
||||
const { token2 } = useUserStore.getState();
|
||||
|
||||
if (!token2) {
|
||||
Toast.show({ content: "未找到有效的访问令牌", position: "top" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建WebSocket URL
|
||||
const params = new URLSearchParams({
|
||||
client: fullConfig.client.toString(),
|
||||
accountId: getAccountId().toString(),
|
||||
accessToken: token2,
|
||||
t: Date.now().toString(),
|
||||
});
|
||||
|
||||
const wsUrl = fullConfig.url + "?" + params;
|
||||
|
||||
// 检查URL是否为localhost,如果是则不连接
|
||||
if (wsUrl.includes("localhost") || wsUrl.includes("127.0.0.1")) {
|
||||
// console.error("WebSocket连接被拦截:不允许连接到本地地址", wsUrl);
|
||||
Toast.show({
|
||||
content: "WebSocket连接被拦截:不允许连接到本地地址",
|
||||
position: "top",
|
||||
});
|
||||
set({ status: WebSocketStatus.ERROR });
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
status: WebSocketStatus.CONNECTING,
|
||||
config: fullConfig,
|
||||
});
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
// 绑定事件处理器
|
||||
ws.onopen = () => get()._handleOpen();
|
||||
ws.onmessage = event => get()._handleMessage(event);
|
||||
ws.onclose = event => get()._handleClose(event);
|
||||
ws.onerror = event => get()._handleError(event);
|
||||
|
||||
set({ ws });
|
||||
|
||||
// console.log("WebSocket连接创建成功", wsUrl);
|
||||
} catch (error) {
|
||||
// console.error("WebSocket连接失败:", error);
|
||||
set({ status: WebSocketStatus.ERROR });
|
||||
Toast.show({ content: "WebSocket连接失败", position: "top" });
|
||||
}
|
||||
},
|
||||
|
||||
// 断开连接
|
||||
disconnect: () => {
|
||||
const currentState = get();
|
||||
|
||||
if (currentState.ws) {
|
||||
currentState.ws.close();
|
||||
}
|
||||
|
||||
currentState._stopReconnectTimer();
|
||||
currentState._stopAliveStatusTimer();
|
||||
|
||||
set({
|
||||
status: WebSocketStatus.DISCONNECTED,
|
||||
ws: null,
|
||||
reconnectAttempts: 0,
|
||||
});
|
||||
|
||||
// console.log("WebSocket连接已断开");
|
||||
},
|
||||
|
||||
// 发送消息
|
||||
sendMessage: message => {
|
||||
const currentState = get();
|
||||
|
||||
if (
|
||||
currentState.status !== WebSocketStatus.CONNECTED ||
|
||||
!currentState.ws
|
||||
) {
|
||||
Toast.show({ content: "WebSocket未连接", position: "top" });
|
||||
return;
|
||||
}
|
||||
|
||||
const fullMessage: WebSocketMessage = {
|
||||
...message,
|
||||
};
|
||||
|
||||
try {
|
||||
currentState.ws.send(JSON.stringify(fullMessage));
|
||||
// console.log("消息发送成功:", fullMessage);
|
||||
} catch (error) {
|
||||
// console.error("消息发送失败:", error);
|
||||
Toast.show({ content: "消息发送失败", position: "top" });
|
||||
}
|
||||
},
|
||||
|
||||
// 发送命令
|
||||
sendCommand: (cmdType: string, data?: any) => {
|
||||
const currentState = get();
|
||||
|
||||
if (
|
||||
currentState.status !== WebSocketStatus.CONNECTED ||
|
||||
!currentState.ws
|
||||
) {
|
||||
Toast.show({
|
||||
content: "WebSocket未连接,正在重新连接...",
|
||||
position: "top",
|
||||
});
|
||||
|
||||
// 重置连接状态并发起重新连接
|
||||
set({ status: WebSocketStatus.DISCONNECTED });
|
||||
if (currentState.config) {
|
||||
currentState.connect(currentState.config);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const command = {
|
||||
cmdType,
|
||||
...data,
|
||||
seq: +new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
currentState.ws.send(JSON.stringify(command));
|
||||
// console.log("命令发送成功:", command);
|
||||
} catch (error) {
|
||||
// console.error("命令发送失败:", error);
|
||||
Toast.show({ content: "命令发送失败", position: "top" });
|
||||
|
||||
// 发送失败时也尝试重新连接
|
||||
set({ status: WebSocketStatus.DISCONNECTED });
|
||||
if (currentState.config) {
|
||||
currentState.connect(currentState.config);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 清除消息
|
||||
clearMessages: () => {
|
||||
set({ messages: [], unreadCount: 0 });
|
||||
},
|
||||
|
||||
// 标记为已读
|
||||
markAsRead: () => {
|
||||
set({ unreadCount: 0 });
|
||||
},
|
||||
|
||||
// 重连
|
||||
reconnect: () => {
|
||||
const currentState = get();
|
||||
|
||||
if (currentState.config) {
|
||||
// 检查是否允许重连
|
||||
if (!currentState.config.autoReconnect) {
|
||||
// console.log("自动重连已禁用,不再尝试重连");
|
||||
return;
|
||||
}
|
||||
currentState.connect(currentState.config);
|
||||
}
|
||||
},
|
||||
|
||||
// 清空连接状态(用于退出登录时)
|
||||
clearConnectionState: () => {
|
||||
const currentState = get();
|
||||
|
||||
// 断开现有连接
|
||||
if (currentState.ws) {
|
||||
currentState.ws.close();
|
||||
}
|
||||
|
||||
// 停止所有定时器
|
||||
currentState._stopReconnectTimer();
|
||||
currentState._stopAliveStatusTimer();
|
||||
|
||||
// 重置所有状态
|
||||
set({
|
||||
status: WebSocketStatus.DISCONNECTED,
|
||||
ws: null,
|
||||
config: null,
|
||||
messages: [],
|
||||
unreadCount: 0,
|
||||
reconnectAttempts: 0,
|
||||
reconnectTimer: null,
|
||||
aliveStatusTimer: null,
|
||||
});
|
||||
|
||||
// console.log("WebSocket连接状态已清空");
|
||||
},
|
||||
|
||||
// 内部方法:处理连接打开
|
||||
_handleOpen: () => {
|
||||
const currentState = get();
|
||||
|
||||
set({
|
||||
status: WebSocketStatus.CONNECTED,
|
||||
reconnectAttempts: 0,
|
||||
});
|
||||
|
||||
// console.log("WebSocket连接成功");
|
||||
const { token2 } = useUserStore.getState();
|
||||
// 发送登录命令
|
||||
if (currentState.config) {
|
||||
currentState.sendCommand("CmdSignIn", {
|
||||
accessToken: token2,
|
||||
accountId: Number(getAccountId()),
|
||||
client: currentState.config?.client || "kefu-client",
|
||||
seq: +new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
Toast.show({ content: "WebSocket连接成功", position: "top" });
|
||||
|
||||
// 启动客服状态查询定时器
|
||||
currentState._startAliveStatusTimer();
|
||||
},
|
||||
|
||||
// 内部方法:处理消息接收
|
||||
_handleMessage: (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
// console.log("收到WebSocket消息:", data);
|
||||
|
||||
// 处理特定的通知消息
|
||||
if (data.cmdType === "CmdNotify") {
|
||||
// 处理Auth failed通知
|
||||
if (data.notify === "Auth failed" || data.notify === "Kicked out") {
|
||||
// console.error(`WebSocket ${data.notify},断开连接`);
|
||||
Toast.show({
|
||||
content: `WebSocket ${data.notify},断开连接`,
|
||||
position: "top",
|
||||
});
|
||||
|
||||
// 禁用自动重连
|
||||
if (get().config) {
|
||||
set({
|
||||
config: {
|
||||
...get().config!,
|
||||
autoReconnect: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 停止客服状态查询定时器
|
||||
get()._stopAliveStatusTimer();
|
||||
|
||||
// 断开连接
|
||||
get().disconnect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const currentState = get();
|
||||
const newMessage: WebSocketMessage = {
|
||||
id: Date.now().toString(),
|
||||
type: data.type || "message",
|
||||
content: data,
|
||||
timestamp: Date.now(),
|
||||
sender: data.sender,
|
||||
receiver: data.receiver,
|
||||
};
|
||||
|
||||
set({
|
||||
messages: [...currentState.messages, newMessage],
|
||||
unreadCount: currentState.unreadCount + 1,
|
||||
});
|
||||
//消息处理器
|
||||
msgManageCore(data);
|
||||
|
||||
// 可以在这里添加消息处理逻辑
|
||||
// 比如播放提示音、显示通知等
|
||||
} catch (error) {
|
||||
// console.error("解析WebSocket消息失败:", error);
|
||||
}
|
||||
},
|
||||
|
||||
// 内部方法:处理连接关闭
|
||||
_handleClose: (event: CloseEvent) => {
|
||||
const currentState = get();
|
||||
|
||||
// console.log("WebSocket连接关闭:", event.code, event.reason);
|
||||
|
||||
set({
|
||||
status: WebSocketStatus.DISCONNECTED,
|
||||
ws: null,
|
||||
});
|
||||
|
||||
// 自动重连逻辑
|
||||
if (
|
||||
currentState.config?.autoReconnect &&
|
||||
currentState.reconnectAttempts <
|
||||
(currentState.config?.maxReconnectAttempts || 5)
|
||||
) {
|
||||
// console.log("尝试自动重连...");
|
||||
currentState._startReconnectTimer();
|
||||
} else if (!currentState.config?.autoReconnect) {
|
||||
// console.log("自动重连已禁用,不再尝试重连");
|
||||
// 重置重连计数
|
||||
set({ reconnectAttempts: 0 });
|
||||
}
|
||||
},
|
||||
|
||||
// 内部方法:处理连接错误
|
||||
_handleError: (event: Event) => {
|
||||
// console.error("WebSocket连接错误:", event);
|
||||
|
||||
set({ status: WebSocketStatus.ERROR });
|
||||
|
||||
Toast.show({ content: "WebSocket连接错误", position: "top" });
|
||||
},
|
||||
|
||||
// 内部方法:启动重连定时器
|
||||
_startReconnectTimer: () => {
|
||||
const currentState = get();
|
||||
|
||||
currentState._stopReconnectTimer();
|
||||
|
||||
set({
|
||||
status: WebSocketStatus.RECONNECTING,
|
||||
reconnectAttempts: currentState.reconnectAttempts + 1,
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
// console.log(
|
||||
// `尝试重连 (${currentState.reconnectAttempts + 1}/${currentState.config?.maxReconnectAttempts})`,
|
||||
// );
|
||||
currentState.reconnect();
|
||||
}, currentState.config?.reconnectInterval || 3000);
|
||||
|
||||
set({ reconnectTimer: timer });
|
||||
},
|
||||
|
||||
// 内部方法:停止重连定时器
|
||||
_stopReconnectTimer: () => {
|
||||
const currentState = get();
|
||||
|
||||
if (currentState.reconnectTimer) {
|
||||
clearTimeout(currentState.reconnectTimer);
|
||||
set({ reconnectTimer: null });
|
||||
}
|
||||
},
|
||||
|
||||
// 内部方法:启动客服状态查询定时器
|
||||
_startAliveStatusTimer: () => {
|
||||
const currentState = get();
|
||||
|
||||
// 先停止现有定时器
|
||||
currentState._stopAliveStatusTimer();
|
||||
|
||||
// 获取客服用户列表
|
||||
const { kfUserList } = useCkChatStore.getState();
|
||||
|
||||
// 如果没有客服用户,不启动定时器
|
||||
if (!kfUserList || kfUserList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 启动定时器,每5秒查询一次
|
||||
const timer = setInterval(() => {
|
||||
const state = get();
|
||||
// 检查连接状态
|
||||
if (state.status === WebSocketStatus.CONNECTED) {
|
||||
const { kfUserList: currentKfUserList } = useCkChatStore.getState();
|
||||
if (currentKfUserList && currentKfUserList.length > 0) {
|
||||
state.sendCommand("CmdRequestWechatAccountsAliveStatus", {
|
||||
wechatAccountIds: currentKfUserList.map(v => v.id),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 如果连接断开,停止定时器
|
||||
state._stopAliveStatusTimer();
|
||||
}
|
||||
}, 5 * 1000);
|
||||
|
||||
set({ aliveStatusTimer: timer });
|
||||
},
|
||||
|
||||
// 内部方法:停止客服状态查询定时器
|
||||
_stopAliveStatusTimer: () => {
|
||||
const currentState = get();
|
||||
|
||||
if (currentState.aliveStatusTimer) {
|
||||
clearInterval(currentState.aliveStatusTimer);
|
||||
set({ aliveStatusTimer: null });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "websocket-store",
|
||||
partialize: state => ({
|
||||
// 只持久化必要的状态,不持久化WebSocket实例
|
||||
status: state.status,
|
||||
config: state.config,
|
||||
messages: state.messages.slice(-100), // 只保留最近100条消息
|
||||
unreadCount: state.unreadCount,
|
||||
reconnectAttempts: state.reconnectAttempts,
|
||||
// 注意:定时器不需要持久化,重新连接时会重新创建
|
||||
}),
|
||||
onRehydrateStorage: () => state => {
|
||||
// 页面刷新后,如果之前是连接状态,尝试重新连接
|
||||
if (state && state.status === WebSocketStatus.CONNECTED && state.config) {
|
||||
// console.log("页面刷新后恢复WebSocket连接", {
|
||||
// persistedConfig: state.config,
|
||||
// currentDefaultConfig: DEFAULT_CONFIG,
|
||||
// });
|
||||
|
||||
// 使用最新的默认配置,而不是持久化的配置
|
||||
const freshConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
client: state.config.client,
|
||||
accountId: state.config.accountId,
|
||||
accessToken: state.config.accessToken,
|
||||
autoReconnect: state.config.autoReconnect,
|
||||
};
|
||||
|
||||
// console.log("使用刷新后的配置重连:", freshConfig);
|
||||
|
||||
// 延迟一下再重连,确保页面完全加载
|
||||
// 同时检查当前状态,避免重复连接
|
||||
setTimeout(() => {
|
||||
// 重新获取最新的状态,而不是使用闭包中的state
|
||||
const currentState = useWebSocketStore.getState();
|
||||
// console.log("页面刷新后检查状态", {
|
||||
// status: currentState.status,
|
||||
// hasWs: !!currentState.ws,
|
||||
// });
|
||||
|
||||
// 强制重置状态为disconnected,因为页面刷新后WebSocket实例已失效
|
||||
if (
|
||||
currentState.status === WebSocketStatus.CONNECTED &&
|
||||
!currentState.ws
|
||||
) {
|
||||
// console.log("检测到状态不一致,重置为disconnected");
|
||||
useWebSocketStore.setState({
|
||||
status: WebSocketStatus.DISCONNECTED,
|
||||
});
|
||||
}
|
||||
|
||||
// 重新获取状态进行连接
|
||||
const latestState = useWebSocketStore.getState();
|
||||
if (
|
||||
latestState.status === WebSocketStatus.DISCONNECTED ||
|
||||
latestState.status === WebSocketStatus.ERROR
|
||||
) {
|
||||
// console.log("页面刷新后开始重连");
|
||||
latestState.connect(freshConfig);
|
||||
} else {
|
||||
// console.log("WebSocket已连接或正在连接,跳过页面刷新重连", {
|
||||
// status: latestState.status,
|
||||
// });
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -33,7 +33,11 @@ class WebSocketController extends BaseController
|
||||
*/
|
||||
public function __construct($userData = [])
|
||||
{
|
||||
parent::__construct();
|
||||
//parent::__construct();
|
||||
if (empty($userData)){
|
||||
return;
|
||||
}
|
||||
|
||||
$this->initConnection($userData);
|
||||
}
|
||||
|
||||
@@ -51,7 +55,6 @@ class WebSocketController extends BaseController
|
||||
// 检查缓存中是否存在有效的token
|
||||
$cacheKey = 'websocket_token_' . $userData['userName'];
|
||||
$cachedToken = Cache::get($cacheKey);
|
||||
|
||||
if ($cachedToken) {
|
||||
$this->authorized = $cachedToken;
|
||||
$this->accountId = $userData['accountId'];
|
||||
@@ -61,9 +64,11 @@ class WebSocketController extends BaseController
|
||||
'username' => $userData['userName'],
|
||||
'password' => $userData['password']
|
||||
];
|
||||
|
||||
// 调用登录接口获取token
|
||||
$headerData = ['client:kefu-client'];
|
||||
|
||||
$headerData[] = 'verifysessionid:3f21df29-6d8a-4980-ae8a-bf15ef17955f';
|
||||
$headerData[] = 'verifycode:0k3g';
|
||||
$header = setHeader($headerData, '', 'plain');
|
||||
$result = requestCurl('https://kf.quwanzhi.com:9991/token', $params, 'POST', $header);
|
||||
$result_array = handleApiResponse($result);
|
||||
@@ -877,8 +882,10 @@ class WebSocketController extends BaseController
|
||||
"seq" => time(),
|
||||
"wechatAccountId" => $data['wechatAccountId'],
|
||||
"chatroomName" => $data['chatroomName'],
|
||||
"wechatFriendIds" => $data['wechatFriendIds']
|
||||
// "wechatFriendIds" => $data['wechatFriendIds']
|
||||
"wechatFriendIds" => [17453051,17453058]
|
||||
];
|
||||
|
||||
// 记录请求日志
|
||||
Log::info('创建群聊请求:' . json_encode($params, 256));
|
||||
|
||||
|
||||
@@ -249,4 +249,43 @@ class WechatController extends BaseController
|
||||
WechatAccountModel::create($data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function chatroomCreate($data = [])
|
||||
{
|
||||
|
||||
$authorization = $this->authorization;
|
||||
|
||||
if (empty($authorization)) {
|
||||
return json_encode(['code' => 500, 'msg' => '缺少授权信息']);
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置请求头
|
||||
$headerData = ['Client:system'];
|
||||
$header = setHeader($headerData, $authorization,'json');
|
||||
$params = [
|
||||
"chatroomOperateType" => 7,
|
||||
"extra" => "{chatroomName:{$data['chatroomName']}}",
|
||||
"wechatAccountId" => $data['wechatAccountId'],
|
||||
"wechatChatroomId" => 0,
|
||||
"wechatFriendIds" => $data['wechatFriendIds']
|
||||
];
|
||||
|
||||
// 发送请求获取状态信息
|
||||
$result = requestCurl($this->baseUrl . 'api/WechatChatroom/chatroomOperate', $params, 'POST', $header,'json');
|
||||
$response = handleApiResponse($result);
|
||||
if (!empty($response)) {
|
||||
return json_encode(['code' => 500, 'msg' =>$response]);
|
||||
}else{
|
||||
return json_encode(['code' => 200, 'msg' =>'成功']);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
if (empty($authorization)) { // 只有作为独立API调用时才返回
|
||||
return json_encode(['code' => 500, 'msg' => '获取失败:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -9,6 +9,25 @@ use think\facade\Route;
|
||||
Route::group('v1/', function () {
|
||||
|
||||
Route::group('kefu/', function () {
|
||||
//好友相关
|
||||
Route::group('wechatFriend/', function () {
|
||||
Route::get('list', 'app\chukebao\controller\WechatFriendController@getList'); // 获取好友列表
|
||||
});
|
||||
//群相关
|
||||
Route::group('wechatChatroom/', function () {
|
||||
Route::get('list', 'app\chukebao\controller\WechatChatroomController@getList'); // 获取好友列表
|
||||
});
|
||||
|
||||
//客服相关
|
||||
Route::group('customerService/', function () {
|
||||
Route::get('list', 'app\chukebao\controller\CustomerServiceController@getList'); // 获取好友列表
|
||||
});
|
||||
|
||||
//客服相关
|
||||
Route::group('message/', function () {
|
||||
Route::get('list', 'app\chukebao\controller\MessageController@getList'); // 获取好友列表
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
@@ -18,7 +37,7 @@ Route::group('v1/', function () {
|
||||
|
||||
// 客服登录
|
||||
Route::group('v1/kefu', function () {
|
||||
Route::post('login', 'app\chukebao\controller\LoginController@index'); // 获取好友列表
|
||||
Route::post('login', 'app\chukebao\controller\LoginController@index'); // 登录
|
||||
});
|
||||
|
||||
|
||||
|
||||
29
Server/application/chukebao/controller/BaseController.php
Normal file
29
Server/application/chukebao/controller/BaseController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use think\Controller;
|
||||
|
||||
/**
|
||||
* 基础控制器
|
||||
*/
|
||||
class BaseController extends Controller
|
||||
{
|
||||
/**
|
||||
* 获取用户信息
|
||||
*
|
||||
* @param string $column
|
||||
* @return mixed
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function getUserInfo(?string $column = null)
|
||||
{
|
||||
$user = $this->request->userInfo;
|
||||
|
||||
if (!$user) {
|
||||
throw new \Exception('未授权访问,缺少有效的身份凭证', 401);
|
||||
}
|
||||
|
||||
return $column ? $user[$column] : $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
|
||||
class CustomerServiceController extends BaseController
|
||||
{
|
||||
|
||||
public function getList(){
|
||||
$accountId = $this->getUserInfo('s2_accountId');
|
||||
if (empty($accountId)){
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
|
||||
$accountIds1= Db::table('s2_wechat_friend')->where(['accountId' => $accountId,'isDeleted' => 0])->group('wechatAccountId')->column('wechatAccountId');
|
||||
$accountIds2 = Db::table('s2_wechat_chatroom')->where(['accountId' => $accountId,'isDeleted' => 0])->group('wechatAccountId')->column('wechatAccountId');
|
||||
// 确保即使有空数组也不会报错,并且去除重复值
|
||||
$accountIds = array_unique(array_merge($accountIds1 ?: [], $accountIds2 ?: []));
|
||||
|
||||
$list = Db::table('s2_wechat_account')
|
||||
->whereIn('id',$accountIds)
|
||||
->order('id desc')
|
||||
->group('id')
|
||||
->select();
|
||||
foreach ($list as $k=>&$v){
|
||||
$v['createTime'] = !empty($v['createTime']) ? date('Y-m-d H:i:s',$v['createTime']) : '';
|
||||
$v['updateTime'] = !empty($v['updateTime']) ? date('Y-m-d H:i:s',$v['updateTime']) : '';
|
||||
$v['labels'] = json_decode($v['labels'],true);
|
||||
unset(
|
||||
$v['accountUserName'],
|
||||
$v['accountRealName'],
|
||||
$v['accountNickname'],
|
||||
);
|
||||
}
|
||||
unset($v);
|
||||
|
||||
return ResponseHelper::success($list);
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\util\JwtUtil;
|
||||
use Exception;
|
||||
use library\ResponseHelper;
|
||||
use app\api\controller\UserController;
|
||||
use think\Db;
|
||||
use think\Controller;
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* 认证控制器
|
||||
* 处理用户登录和身份验证
|
||||
*/
|
||||
class LoginController extends BaseController
|
||||
class LoginController extends Controller
|
||||
{
|
||||
|
||||
|
||||
@@ -29,18 +29,27 @@ class LoginController extends BaseController
|
||||
$password = !empty($password) ? $password : $this->request->param('password', '');
|
||||
$verifySessionId =!empty($verifySessionId) ? $verifySessionId : $this->request->param('verifySessionId', '');
|
||||
$verifyCode = !empty($verifyCode) ? $verifyCode : $this->request->param('verifyCode', '');
|
||||
$token = JwtUtil::getRequestToken();
|
||||
$payload = '';
|
||||
if (!empty($token)){
|
||||
$payload = JwtUtil::verifyToken($token);
|
||||
}
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
if ((empty($username) || empty($password)) && empty($payload)){
|
||||
return ResponseHelper::error('请输入账号密码');
|
||||
}
|
||||
|
||||
// 验证账号是否存在(支持账号或手机号登录)
|
||||
$user = Db::name('users')
|
||||
->where(function ($query) use ($username) {
|
||||
$query->where('account', $username)->whereOr('phone', $username);
|
||||
})
|
||||
->where('passwordMd5', md5($password))
|
||||
->find();
|
||||
if (empty($payload11)){
|
||||
$user = Db::name('users')
|
||||
->where(function ($query) use ($username) {
|
||||
$query->where('account', $username)->whereOr('phone', $username);
|
||||
})
|
||||
->where('passwordMd5', md5($password))
|
||||
->find();
|
||||
}else{
|
||||
$user = $payload;
|
||||
}
|
||||
|
||||
if (empty($user)) {
|
||||
return ResponseHelper::error('账号不存在或密码错误');
|
||||
@@ -50,37 +59,35 @@ class LoginController extends BaseController
|
||||
return ResponseHelper::error('账号已禁用');
|
||||
}
|
||||
|
||||
|
||||
//登录参数
|
||||
$params = [
|
||||
'grant_type' => 'password',
|
||||
'username' => $user['account'],
|
||||
'password' => !empty($user['passwordLocal']) ? localDecrypt($user['passwordLocal']) : $password
|
||||
];
|
||||
|
||||
try {
|
||||
// 调用登录接口获取token
|
||||
$headerData = ['client:kefu-client'];
|
||||
if (!empty($verifySessionId) && !empty($verifyCode)){
|
||||
$headerData[] = 'verifysessionid:'.$verifySessionId;
|
||||
$headerData[] = 'verifycode:'.$verifyCode;
|
||||
$headerData[] = 'verifycode:'.$verifyCode;
|
||||
}
|
||||
$header = setHeader($headerData, '', 'plain');
|
||||
$result = requestCurl('https://s2.siyuguanli.com:9991/token', $params, 'POST', $header);
|
||||
$result = handleApiResponse($result);
|
||||
|
||||
if (isset($result['access_token']) && !empty($result['access_token'])) {
|
||||
$userData['kefuData']['token'] = $result;
|
||||
$kefuData['token'] = $result;
|
||||
$headerData = ['client:kefu-client'];
|
||||
$header = setHeader($headerData, $result['access_token']);
|
||||
$result2 = requestCurl('https://s2.siyuguanli.com:9991/api/account/self', [], 'GET', $header, 'json');
|
||||
$self = handleApiResponse($result2);
|
||||
$userData['kefuData']['self'] = $self;
|
||||
$kefuData['self'] = $self;
|
||||
Db::name('users')->where('id', $user['id'])->update(['passwordLocal' => localEncrypt($params['password']),'updateTime' => time()]);
|
||||
}else{
|
||||
return ResponseHelper::error($result['error_description']);
|
||||
}
|
||||
|
||||
unset($user['passwordMd5'],$user['deleteTime'],$user['passwordLocal']);
|
||||
unset($user['passwordMd5'],$user['deleteTime']);
|
||||
$userData['member'] = $user;
|
||||
|
||||
// 生成JWT令牌
|
||||
@@ -88,10 +95,9 @@ class LoginController extends BaseController
|
||||
$token = JwtUtil::createToken($user, $expired);
|
||||
$token_expired = time() + $expired;
|
||||
|
||||
|
||||
$userData['token'] = $token;
|
||||
$userData['token_expired'] = $token_expired;
|
||||
|
||||
$userData['kefuData'] = $kefuData;
|
||||
|
||||
return ResponseHelper::success($userData, '登录成功');
|
||||
} catch (Exception $e) {
|
||||
|
||||
72
Server/application/chukebao/controller/MessageController.php
Normal file
72
Server/application/chukebao/controller/MessageController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
|
||||
class MessageController extends BaseController
|
||||
{
|
||||
|
||||
public function getList(){
|
||||
$page = $this->request->param('page', 1);
|
||||
$limit = $this->request->param('limit', 10);
|
||||
$accountId = $this->getUserInfo('s2_accountId');
|
||||
if (empty($accountId)){
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
|
||||
$chatroomList = Db::table('s2_wechat_chatroom')->alias('wc')
|
||||
->join(['s2_wechat_message' => 'm'], 'wc.id = m.wechatChatroomId', 'LEFT')
|
||||
->where(['wc.accountId' => $accountId,'m.type' => 2,' wc.isDeleted' => 0])
|
||||
->order('m.id desc')
|
||||
->group('m.wechatChatroomId')
|
||||
->page($page, $limit)
|
||||
->select();
|
||||
$friendIds = Db::table('s2_wechat_friend')
|
||||
->where(['accountId' => $accountId,' isDeleted' => 0])
|
||||
->group('id')
|
||||
->column('id');
|
||||
$friendList = Db::table('s2_wechat_message')
|
||||
->where(['type' => 1])
|
||||
->whereIn('wechatFriendId',$friendIds)
|
||||
->order('id desc')
|
||||
->group('wechatFriendId')
|
||||
->page($page, $limit)
|
||||
->select();
|
||||
|
||||
$list = array_merge($chatroomList,$friendList);
|
||||
|
||||
// 按createTime字段从大到小排序
|
||||
usort($list, function($a, $b) {
|
||||
return $b['createTime'] <=> $a['createTime'];
|
||||
});
|
||||
|
||||
|
||||
foreach ($list as $k=>&$v){
|
||||
$v['createTime'] = !empty($v['createTime']) ? date('Y-m-d H:i:s',$v['createTime']) : '';
|
||||
$v['wechatTime'] = !empty($v['wechatTime']) ? date('Y-m-d H:i:s',$v['wechatTime']) : '';
|
||||
|
||||
if (!empty($v['wechatFriendId'])){
|
||||
$friend = Db::table('s2_wechat_friend')
|
||||
->where(['id'=>$v['wechatFriendId']])
|
||||
->field('id,nickname,avatar')
|
||||
->find();
|
||||
$v['msgInfo'] = $friend;
|
||||
}
|
||||
|
||||
if (!empty($v['wechatChatroomId'])){
|
||||
$chatroom = Db::table('s2_wechat_chatroom')
|
||||
->where(['id'=>$v['wechatChatroomId']])
|
||||
->field('id,nickname,chatroomAvatar as avatar')
|
||||
->find();
|
||||
$v['msgInfo'] = $chatroom;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
unset($v);
|
||||
|
||||
return ResponseHelper::success($list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
|
||||
class WechatChatroomController extends BaseController
|
||||
{
|
||||
|
||||
public function getList(){
|
||||
$page = $this->request->param('page', 1);
|
||||
$limit = $this->request->param('limit', 10);
|
||||
$accountId = $this->getUserInfo('s2_accountId');
|
||||
if (empty($accountId)){
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
$query = Db::table('s2_wechat_chatroom')
|
||||
->where(['accountId' => $accountId,'isDeleted' => 0])
|
||||
->order('id desc');
|
||||
$list = $query->page($page, $limit)->select();
|
||||
$total = $query->count();
|
||||
|
||||
|
||||
foreach ($list as $k=>&$v){
|
||||
$v['createTime'] = !empty($v['createTime']) ? date('Y-m-d H:i:s',$v['createTime']) : '';
|
||||
$v['updateTime'] = !empty($v['updateTime']) ? date('Y-m-d H:i:s',$v['updateTime']) : '';
|
||||
}
|
||||
unset($v);
|
||||
|
||||
return ResponseHelper::success(['list'=>$list,'total'=>$total]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace app\chukebao\controller;
|
||||
|
||||
use library\ResponseHelper;
|
||||
use think\Db;
|
||||
|
||||
class WechatFriendController extends BaseController
|
||||
{
|
||||
|
||||
public function getList(){
|
||||
$page = $this->request->param('page', 1);
|
||||
$limit = $this->request->param('limit', 10);
|
||||
$accountId = $this->getUserInfo('s2_accountId');
|
||||
if (empty($accountId)){
|
||||
return ResponseHelper::error('请先登录');
|
||||
}
|
||||
$query = Db::table('s2_wechat_friend')
|
||||
->where(['accountId' => $accountId,'isDeleted' => 0])
|
||||
->order('id desc');
|
||||
$list = $query->page($page, $limit)->select();
|
||||
$total = $query->count();
|
||||
|
||||
|
||||
foreach ($list as $k=>&$v){
|
||||
$v['createTime'] = !empty($v['createTime']) ? date('Y-m-d H:i:s',$v['createTime']) : '';
|
||||
$v['updateTime'] = !empty($v['updateTime']) ? date('Y-m-d H:i:s',$v['updateTime']) : '';
|
||||
$v['passTime'] = !empty($v['passTime']) ? date('Y-m-d H:i:s',$v['passTime']) : '';
|
||||
}
|
||||
unset($v);
|
||||
|
||||
return ResponseHelper::success(['list'=>$list,'total'=>$total]);
|
||||
}
|
||||
}
|
||||
@@ -116,11 +116,7 @@ class PasswordLoginController extends BaseController
|
||||
// 生成JWT令牌
|
||||
$token = JwtUtil::createToken($member, 86400 * 30);
|
||||
$token_expired = time() + 86400 * 30;
|
||||
$kefuData = [
|
||||
'token' => [],
|
||||
'self' => [],
|
||||
];
|
||||
return compact('member', 'token', 'token_expired','deviceTotal','kefuData');
|
||||
return compact('member', 'token', 'token_expired','deviceTotal');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -155,11 +155,11 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
|
||||
public function getUser()
|
||||
{
|
||||
|
||||
$userId = $this->request->param('userId', '');
|
||||
$wechatId = $this->request->param('wechatId', '');
|
||||
$companyId = $this->getUserInfo('companyId');
|
||||
|
||||
if (empty($userId)) {
|
||||
return json_encode(['code' => 500, 'msg' => '用户id不能为空']);
|
||||
if (empty($wechatId)) {
|
||||
return json_encode(['code' => 500, 'msg' => '微信id不能为空']);
|
||||
}
|
||||
|
||||
$total = [
|
||||
@@ -175,12 +175,11 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController
|
||||
'wa.nickname', 'wa.avatar', 'wa.gender', 'wa.phone', 'wa.alias'])
|
||||
->join('wechat_account wa', 'p.identifier=wa.wechatId', 'left')
|
||||
->order('p.id DESC')
|
||||
->where(['p.id' => $userId])
|
||||
->where(['p.identifier' => $wechatId])
|
||||
->group('p.identifier')
|
||||
->find();
|
||||
$data['lastMsgTime'] = '';
|
||||
|
||||
|
||||
//来源
|
||||
$source = Db::name('traffic_source')->alias('ts')
|
||||
->field(['wa.nickname', 'wa.avatar', 'wa.gender', 'wa.phone', 'wa.wechatId', 'wa.alias',
|
||||
|
||||
@@ -15,6 +15,7 @@ use think\facade\Cache;
|
||||
use think\facade\Config;
|
||||
use app\api\controller\MomentsController as Moments;
|
||||
use Workerman\Lib\Timer;
|
||||
use app\api\controller\WechatController;
|
||||
|
||||
/**
|
||||
* 工作台群创建任务
|
||||
@@ -127,8 +128,6 @@ class WorkbenchGroupCreateJob
|
||||
$addGroupUser[] = $user;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
foreach ($addGroupUser as $key => $val) {
|
||||
//判断第一组用户是否满足创建群的条件
|
||||
$friendIds = Db::name('wechat_friendship')->alias('f')
|
||||
@@ -162,12 +161,11 @@ class WorkbenchGroupCreateJob
|
||||
$toAccountId = Db::name('users')->where('account', $username)->value('s2_accountId');
|
||||
}
|
||||
$webSocket = new WebSocketController(['userName' => $username, 'password' => $password, 'accountId' => $toAccountId]);
|
||||
//$webSocket = new WebSocketController(['userName' => 'wz_03', 'password' => 'key123456', 'accountId' => 5015]);
|
||||
//拉人进群 $webSocket->CmdChatroomInvite(['wechatChatroomId' => 830794, 'wechatFriendIds' => [21168549]]);
|
||||
//修改群名称 $webSocket->CmdChatroomModifyInfo(['wechatChatroomId' => 830794, 'wechatAccountId' => 300745,'chatroomName' => 'test111']);
|
||||
//修改群公告 $webSocket->CmdChatroomModifyInfo(['wechatChatroomId' => 830794, 'wechatAccountId' => 300745,'announce' => 'test111']);
|
||||
//建群 $webSocket->CmdChatroomCreate(['chatroomName' => '聊天测试群', 'wechatFriendIds' => [17453051,17453058],'wechatAccountId' => 300745]);
|
||||
|
||||
|
||||
foreach ($groupedFriends as $wechatAccountId => $friendId) {
|
||||
//列出所有群
|
||||
$group = '';
|
||||
|
||||
@@ -64,9 +64,14 @@
|
||||
# 工作台群发消息
|
||||
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupPush >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupPush.log 2>&1
|
||||
|
||||
# 工作台建群
|
||||
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupCreate >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupCreate.log 2>&1
|
||||
|
||||
# 工作台流量分发
|
||||
0 9 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:trafficDistribute >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/traffic_distribute.log 2>&1
|
||||
|
||||
|
||||
|
||||
# 预防性切换好友
|
||||
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think switch:friends >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/switch_friends.log 2>&1
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BarChartOutlined, RobotOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
BarChartOutlined,
|
||||
RobotOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
// 菜单项接口
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
@@ -31,7 +35,7 @@ export const drawerMenuData = {
|
||||
appDesc: "AI智能营销系统",
|
||||
},
|
||||
footer: {
|
||||
balanceIcon: "⚡",
|
||||
balanceIcon: <ThunderboltOutlined size={20} />,
|
||||
balanceLabel: "算力余额",
|
||||
balanceValue: "9307.423",
|
||||
},
|
||||
|
||||
@@ -89,8 +89,7 @@
|
||||
cursor: pointer;
|
||||
|
||||
.suanliIcon {
|
||||
font-size: 20px;
|
||||
color: #ffc107;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -330,7 +329,7 @@
|
||||
color: #666;
|
||||
|
||||
.suanliIcon {
|
||||
font-size: 20px;
|
||||
color: #3d9c0d;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
CloseOutlined,
|
||||
LogoutOutlined,
|
||||
UserSwitchOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
// import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { drawerMenuData, menuList } from "./index.data";
|
||||
import styles from "./index.module.scss";
|
||||
@@ -31,7 +31,6 @@ const NavCommon: React.FC<NavCommonProps> = ({
|
||||
const [messageCount] = useState(3); // 模拟消息数量
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
// const { userInfo } = useCkChatStore();
|
||||
const { user, logout } = useUserStore();
|
||||
|
||||
// 处理菜单图标点击
|
||||
@@ -163,9 +162,16 @@ const NavCommon: React.FC<NavCommonProps> = ({
|
||||
<div className={styles.headerRight}>
|
||||
<Space className={styles.userInfo}>
|
||||
<span className={styles.suanli}>
|
||||
<span className={styles.suanliIcon}>⚡</span>
|
||||
<span className={styles.suanliIcon}>
|
||||
<ThunderboltOutlined size={20} />
|
||||
</span>
|
||||
9307.423
|
||||
</span>
|
||||
<div className={styles.messageButton} onClick={handleMessageClick}>
|
||||
<Badge count={messageCount} size="small">
|
||||
<BellOutlined style={{ fontSize: 20 }} />
|
||||
</Badge>
|
||||
</div>
|
||||
<Dropdown
|
||||
menu={{ items: userMenuItems }}
|
||||
placement="bottomRight"
|
||||
@@ -180,11 +186,6 @@ const NavCommon: React.FC<NavCommonProps> = ({
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<div className={styles.messageButton} onClick={handleMessageClick}>
|
||||
<Badge count={messageCount} size="small">
|
||||
<BellOutlined style={{ fontSize: 20 }} />
|
||||
</Badge>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import request from "@/api/request2";
|
||||
import request2 from "@/api/request2";
|
||||
import request from "@/api/request";
|
||||
import {
|
||||
MessageData,
|
||||
ChatHistoryResponse,
|
||||
@@ -11,6 +12,26 @@ import {
|
||||
ChatSettings,
|
||||
} from "./data";
|
||||
|
||||
// 好友列表
|
||||
export function getWechatFriendList(params) {
|
||||
return request("/v1/kefu/wechatFriend/list", params, "GET");
|
||||
}
|
||||
|
||||
// 群列表
|
||||
export function getWechatChatroomList(params) {
|
||||
return request("/v1/kefu/wechatChatroom/list", params, "GET");
|
||||
}
|
||||
|
||||
//群、好友聊天记录列表
|
||||
export function getMessageList() {
|
||||
return request("/v1/kefu/message/list", {}, "GET");
|
||||
}
|
||||
|
||||
//获取客服列表
|
||||
export function getAgentList() {
|
||||
return request("/v1/kefu/customerService/list", {}, "GET");
|
||||
}
|
||||
|
||||
//读取聊天信息
|
||||
//kf.quwanzhi.com:9991/api/WechatFriend/clearUnreadCount
|
||||
function jsonToQueryString(json) {
|
||||
@@ -30,7 +51,7 @@ export function WechatFriendAllot(params: {
|
||||
notifyReceiver: boolean;
|
||||
comment: string;
|
||||
}) {
|
||||
return request(
|
||||
return request2(
|
||||
"/api/wechatFriend/allot?" + jsonToQueryString(params),
|
||||
undefined,
|
||||
"PUT",
|
||||
@@ -39,7 +60,7 @@ export function WechatFriendAllot(params: {
|
||||
|
||||
//获取可转移客服列表
|
||||
export function getTransferableAgentList() {
|
||||
return request("/api/account/myDepartmentAccountsForTransfer", {}, "GET");
|
||||
return request2("/api/account/myDepartmentAccountsForTransfer", {}, "GET");
|
||||
}
|
||||
|
||||
// 微信好友列表
|
||||
@@ -47,7 +68,7 @@ export function WechatFriendRebackAllot(params: {
|
||||
wechatFriendId?: number;
|
||||
wechatChatroomId?: number;
|
||||
}) {
|
||||
return request(
|
||||
return request2(
|
||||
"/api/wechatFriend/rebackAllot?" + jsonToQueryString(params),
|
||||
undefined,
|
||||
"PUT",
|
||||
@@ -56,17 +77,17 @@ export function WechatFriendRebackAllot(params: {
|
||||
|
||||
// 微信群列表
|
||||
export function WechatGroup(params) {
|
||||
return request("/api/WechatGroup/list", params, "GET");
|
||||
return request2("/api/WechatGroup/list", params, "GET");
|
||||
}
|
||||
|
||||
//获取聊天记录-1 清除未读
|
||||
export function clearUnreadCount(params) {
|
||||
return request("/api/WechatFriend/clearUnreadCount", params, "PUT");
|
||||
return request2("/api/WechatFriend/clearUnreadCount", params, "PUT");
|
||||
}
|
||||
|
||||
//更新配置
|
||||
export function updateConfig(params) {
|
||||
return request("/api/WechatFriend/updateConfig", params, "PUT");
|
||||
return request2("/api/WechatFriend/updateConfig", params, "PUT");
|
||||
}
|
||||
//获取聊天记录-2 获取列表
|
||||
export function getChatMessages(params: {
|
||||
@@ -78,7 +99,7 @@ export function getChatMessages(params: {
|
||||
Count: number;
|
||||
olderData: boolean;
|
||||
}) {
|
||||
return request("/api/FriendMessage/SearchMessage", params, "GET");
|
||||
return request2("/api/FriendMessage/SearchMessage", params, "GET");
|
||||
}
|
||||
export function getChatroomMessages(params: {
|
||||
wechatAccountId: number;
|
||||
@@ -89,12 +110,12 @@ export function getChatroomMessages(params: {
|
||||
Count: number;
|
||||
olderData: boolean;
|
||||
}) {
|
||||
return request("/api/ChatroomMessage/SearchMessage", params, "GET");
|
||||
return request2("/api/ChatroomMessage/SearchMessage", params, "GET");
|
||||
}
|
||||
|
||||
//获取群列表
|
||||
export function getGroupList(params: { prevId: number; count: number }) {
|
||||
return request(
|
||||
return request2(
|
||||
"/api/wechatChatroom/listExcludeMembersByPage?",
|
||||
params,
|
||||
"GET",
|
||||
@@ -103,7 +124,7 @@ export function getGroupList(params: { prevId: number; count: number }) {
|
||||
|
||||
//获取群成员
|
||||
export function getGroupMembers(params: { id: number }) {
|
||||
return request(
|
||||
return request2(
|
||||
"/api/WechatChatroom/listMembersByWechatChatroomId",
|
||||
params,
|
||||
"GET",
|
||||
@@ -112,7 +133,7 @@ export function getGroupMembers(params: { id: number }) {
|
||||
|
||||
//触客宝登陆
|
||||
export function loginWithToken(params: any) {
|
||||
return request(
|
||||
return request2(
|
||||
"/token",
|
||||
params,
|
||||
"POST",
|
||||
@@ -127,17 +148,17 @@ export function loginWithToken(params: any) {
|
||||
|
||||
// 获取触客宝用户信息
|
||||
export function getChuKeBaoUserInfo() {
|
||||
return request("/api/account/self", {}, "GET");
|
||||
return request2("/api/account/self", {}, "GET");
|
||||
}
|
||||
|
||||
// 获取联系人列表
|
||||
export const getContactList = (params: { prevId: number; count: number }) => {
|
||||
return request("/api/wechatFriend/list", params, "GET");
|
||||
return request2("/api/wechatFriend/list", params, "GET");
|
||||
};
|
||||
|
||||
//获取控制终端列表
|
||||
export const getControlTerminalList = params => {
|
||||
return request("/api/wechataccount", params, "GET");
|
||||
return request2("/api/wechataccount", params, "GET");
|
||||
};
|
||||
|
||||
// 获取聊天历史
|
||||
@@ -146,7 +167,7 @@ export const getChatHistory = (
|
||||
page: number = 1,
|
||||
pageSize: number = 50,
|
||||
): Promise<ChatHistoryResponse> => {
|
||||
return request(`/v1/chats/${chatId}/messages`, { page, pageSize }, "GET");
|
||||
return request2(`/v1/chats/${chatId}/messages`, { page, pageSize }, "GET");
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
@@ -155,7 +176,7 @@ export const sendMessage = (
|
||||
content: string,
|
||||
type: MessageType = MessageType.TEXT,
|
||||
): Promise<MessageData> => {
|
||||
return request(`/v1/chats/${chatId}/messages`, { content, type }, "POST");
|
||||
return request2(`/v1/chats/${chatId}/messages`, { content, type }, "POST");
|
||||
};
|
||||
|
||||
// 发送文件消息
|
||||
@@ -167,17 +188,17 @@ export const sendFileMessage = (
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("type", type);
|
||||
return request(`/v1/chats/${chatId}/messages/file`, formData, "POST");
|
||||
return request2(`/v1/chats/${chatId}/messages/file`, formData, "POST");
|
||||
};
|
||||
|
||||
// 标记消息为已读
|
||||
export const markMessageAsRead = (messageId: string): Promise<void> => {
|
||||
return request(`/v1/messages/${messageId}/read`, {}, "PUT");
|
||||
return request2(`/v1/messages/${messageId}/read`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 标记聊天为已读
|
||||
export const markChatAsRead = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/read`, {}, "PUT");
|
||||
return request2(`/v1/chats/${chatId}/read`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 添加群组成员
|
||||
@@ -185,7 +206,7 @@ export const addGroupMembers = (
|
||||
groupId: string,
|
||||
memberIds: string[],
|
||||
): Promise<void> => {
|
||||
return request(`/v1/groups/${groupId}/members`, { memberIds }, "POST");
|
||||
return request2(`/v1/groups/${groupId}/members`, { memberIds }, "POST");
|
||||
};
|
||||
|
||||
// 移除群组成员
|
||||
@@ -193,34 +214,34 @@ export const removeGroupMembers = (
|
||||
groupId: string,
|
||||
memberIds: string[],
|
||||
): Promise<void> => {
|
||||
return request(`/v1/groups/${groupId}/members`, { memberIds }, "DELETE");
|
||||
return request2(`/v1/groups/${groupId}/members`, { memberIds }, "DELETE");
|
||||
};
|
||||
|
||||
// 获取在线状态
|
||||
export const getOnlineStatus = (userId: string): Promise<OnlineStatus> => {
|
||||
return request(`/v1/users/${userId}/status`, {}, "GET");
|
||||
return request2(`/v1/users/${userId}/status`, {}, "GET");
|
||||
};
|
||||
|
||||
// 获取消息状态
|
||||
export const getMessageStatus = (messageId: string): Promise<MessageStatus> => {
|
||||
return request(`/v1/messages/${messageId}/status`, {}, "GET");
|
||||
return request2(`/v1/messages/${messageId}/status`, {}, "GET");
|
||||
};
|
||||
|
||||
// 上传文件
|
||||
export const uploadFile = (file: File): Promise<FileUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return request("/v1/upload", formData, "POST");
|
||||
return request2("/v1/upload", formData, "POST");
|
||||
};
|
||||
|
||||
// 获取表情包列表
|
||||
export const getEmojiList = (): Promise<EmojiData[]> => {
|
||||
return request("/v1/emojis", {}, "GET");
|
||||
return request2("/v1/emojis", {}, "GET");
|
||||
};
|
||||
|
||||
// 获取快捷回复列表
|
||||
export const getQuickReplies = (): Promise<QuickReply[]> => {
|
||||
return request("/v1/quick-replies", {}, "GET");
|
||||
return request2("/v1/quick-replies", {}, "GET");
|
||||
};
|
||||
|
||||
// 添加快捷回复
|
||||
@@ -228,49 +249,49 @@ export const addQuickReply = (data: {
|
||||
content: string;
|
||||
category: string;
|
||||
}): Promise<QuickReply> => {
|
||||
return request("/v1/quick-replies", data, "POST");
|
||||
return request2("/v1/quick-replies", data, "POST");
|
||||
};
|
||||
|
||||
// 删除快捷回复
|
||||
export const deleteQuickReply = (id: string): Promise<void> => {
|
||||
return request(`/v1/quick-replies/${id}`, {}, "DELETE");
|
||||
return request2(`/v1/quick-replies/${id}`, {}, "DELETE");
|
||||
};
|
||||
|
||||
// 获取聊天设置
|
||||
export const getChatSettings = (): Promise<ChatSettings> => {
|
||||
return request("/v1/chat/settings", {}, "GET");
|
||||
return request2("/v1/chat/settings", {}, "GET");
|
||||
};
|
||||
|
||||
// 更新聊天设置
|
||||
export const updateChatSettings = (
|
||||
settings: Partial<ChatSettings>,
|
||||
): Promise<ChatSettings> => {
|
||||
return request("/v1/chat/settings", settings, "PUT");
|
||||
return request2("/v1/chat/settings", settings, "PUT");
|
||||
};
|
||||
|
||||
// 删除聊天会话
|
||||
export const deleteChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}`, {}, "DELETE");
|
||||
return request2(`/v1/chats/${chatId}`, {}, "DELETE");
|
||||
};
|
||||
|
||||
// 置顶聊天会话
|
||||
export const pinChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/pin`, {}, "PUT");
|
||||
return request2(`/v1/chats/${chatId}/pin`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 取消置顶聊天会话
|
||||
export const unpinChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/unpin`, {}, "PUT");
|
||||
return request2(`/v1/chats/${chatId}/unpin`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 静音聊天会话
|
||||
export const muteChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/mute`, {}, "PUT");
|
||||
return request2(`/v1/chats/${chatId}/mute`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 取消静音聊天会话
|
||||
export const unmuteChatSession = (chatId: string): Promise<void> => {
|
||||
return request(`/v1/chats/${chatId}/unmute`, {}, "PUT");
|
||||
return request2(`/v1/chats/${chatId}/unmute`, {}, "PUT");
|
||||
};
|
||||
|
||||
// 转发消息
|
||||
@@ -278,10 +299,10 @@ export const forwardMessage = (
|
||||
messageId: string,
|
||||
targetChatIds: string[],
|
||||
): Promise<void> => {
|
||||
return request("/v1/messages/forward", { messageId, targetChatIds }, "POST");
|
||||
return request2("/v1/messages/forward", { messageId, targetChatIds }, "POST");
|
||||
};
|
||||
|
||||
// 撤回消息
|
||||
export const recallMessage = (messageId: string): Promise<void> => {
|
||||
return request(`/v1/messages/${messageId}/recall`, {}, "PUT");
|
||||
return request2(`/v1/messages/${messageId}/recall`, {}, "PUT");
|
||||
};
|
||||
|
||||
@@ -113,6 +113,8 @@ const MessageEnter: React.FC<MessageEnterProps> = ({ contract }) => {
|
||||
msgType = 3;
|
||||
} else if ([FileType.AUDIO].includes(fileType)) {
|
||||
msgType = 34;
|
||||
} else if ([FileType.FILE].includes(fileType)) {
|
||||
msgType = 49;
|
||||
}
|
||||
|
||||
const params = {
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
|
||||
.messageContent {
|
||||
flex-direction: row-reverse;
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.messageBubble {
|
||||
|
||||
@@ -36,6 +36,26 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// 提取content字符串中冒号后面的JSON部分
|
||||
const extractJsonFromContent = (content: string): object | null => {
|
||||
try {
|
||||
// 查找第一个冒号的位置
|
||||
const colonIndex = content.indexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 提取冒号后面的部分并去除前后空格
|
||||
const jsonStr = content.substring(colonIndex + 1).trim();
|
||||
|
||||
// 尝试解析JSON
|
||||
return JSON.parse(jsonStr);
|
||||
} catch (error) {
|
||||
console.error("解析JSON失败:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 解析表情包文字格式[表情名称]并替换为img标签
|
||||
const parseEmojiText = (text: string): React.ReactNode[] => {
|
||||
const emojiRegex = /\[([^\]]+)\]/g;
|
||||
@@ -155,6 +175,9 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
<div className={styles.messageText}>{fallbackText}</div>
|
||||
);
|
||||
|
||||
// 添加调试信息
|
||||
console.log("MessageRecord - msgType:", msgType, "content:", content);
|
||||
|
||||
// 根据msgType进行消息类型判断
|
||||
switch (msgType) {
|
||||
case 1: // 文本消息
|
||||
@@ -193,6 +216,14 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
case 34: // 语音消息
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[语音消息 - 无效内容]");
|
||||
}
|
||||
|
||||
// content直接是音频URL字符串
|
||||
return <AudioMessage audioUrl={content} msgId={String(msg.id)} />;
|
||||
|
||||
case 43: // 视频消息
|
||||
return (
|
||||
<VideoMessage content={content || ""} msg={msg} contract={contract} />
|
||||
@@ -225,14 +256,6 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
}
|
||||
return renderErrorMessage("[表情包]");
|
||||
|
||||
case 34: // 语音消息
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
return renderErrorMessage("[语音消息 - 无效内容]");
|
||||
}
|
||||
|
||||
// content直接是音频URL字符串
|
||||
return <AudioMessage audioUrl={content} msgId={String(msg.id)} />;
|
||||
|
||||
case 49: // 小程序/文章/其他:图文、文件
|
||||
return <SmallProgramMessage content={content || ""} />;
|
||||
|
||||
@@ -577,7 +600,10 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
<div className={styles.messageTime}>{group.time}</div>
|
||||
{group.messages
|
||||
.filter(v => ![10000].includes(v.msgType))
|
||||
.map(renderMessage)}
|
||||
.map(msg => {
|
||||
console.log("Rendering message with msgType:", msg.msgType);
|
||||
return renderMessage(msg);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import request2 from "@/api/request2";
|
||||
import request from "@/api/request";
|
||||
// 静音聊天会话
|
||||
// export const muteChatSession = (chatId: string): Promise<void> => {
|
||||
// return request2(`/v1/chats/${chatId}/mute`, {}, "PUT");
|
||||
// };
|
||||
@@ -0,0 +1,305 @@
|
||||
/* ===== 组件根容器 ===== */
|
||||
.friendsCircle {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
/* 滚动条样式 */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 折叠面板样式 ===== */
|
||||
.collapseContainer {
|
||||
margin-bottom: 1px;
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
padding: 12px 16px !important;
|
||||
background-color: #ffffff;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 16px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
/* 折叠面板头部 */
|
||||
.collapseHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
/* 特殊头像样式 */
|
||||
.specialAvatar {
|
||||
background-color: #1890ff;
|
||||
}
|
||||
|
||||
/* 群组头像样式 */
|
||||
.groupAvatars {
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
.groupAvatar {
|
||||
position: absolute;
|
||||
border: 1px solid #fff;
|
||||
background-color: #52c41a;
|
||||
|
||||
&:nth-child(1) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 特殊文本样式 */
|
||||
.specialText {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 内容区域样式 ===== */
|
||||
.myCircleContent,
|
||||
.squareContent {
|
||||
padding: 0;
|
||||
|
||||
/* 项目包装器 */
|
||||
.itemWrapper {
|
||||
margin-bottom: 1px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ===== 朋友圈项目样式 ===== */
|
||||
.circleItem {
|
||||
background-color: #ffffff;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
/* 头像样式 */
|
||||
.avatar {
|
||||
margin-right: 10px;
|
||||
}
|
||||
/* 项目头部 */
|
||||
.itemHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
/* 用户信息 */
|
||||
.userInfo {
|
||||
flex: 1;
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 项目内容 */
|
||||
.itemContent {
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
.contentText {
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 图片容器 */
|
||||
.imageContainer {
|
||||
margin: 8px 0;
|
||||
|
||||
.contentImage {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 蓝色链接 */
|
||||
.blueLink {
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 项目底部 */
|
||||
.itemFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
/* 时间信息 */
|
||||
.timeInfo {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 操作按钮区域 */
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.actionButton {
|
||||
padding: 4px 8px;
|
||||
color: #666;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background-color: #f0f8ff;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 点赞和评论交互区域
|
||||
.interactionArea {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: #f7f7f7;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
|
||||
.likeArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.likeIcon {
|
||||
color: #ff6b6b;
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.likeList {
|
||||
color: #576b95;
|
||||
|
||||
span {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.commentArea {
|
||||
.commentItem {
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.4;
|
||||
|
||||
.commentUser {
|
||||
color: #576b95;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.commentSeparator {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.commentContent {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 空状态样式 */
|
||||
.emptyText {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 加载更多样式 */
|
||||
.loadingMore {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
|
||||
.anticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Avatar, Button, Collapse, Spin } from "antd";
|
||||
import {
|
||||
HeartOutlined,
|
||||
ChromeOutlined,
|
||||
MessageOutlined,
|
||||
LoadingOutlined,
|
||||
AppstoreOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { InfiniteScroll } from "antd-mobile";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
// 评论数据类型
|
||||
interface CommentItem {
|
||||
commentArg: number;
|
||||
commentId1: number;
|
||||
commentId2: number;
|
||||
commentTime: number;
|
||||
content: string;
|
||||
nickName: string;
|
||||
wechatId: string;
|
||||
}
|
||||
|
||||
// 点赞数据类型
|
||||
interface LikeItem {
|
||||
createTime: number;
|
||||
nickName: string;
|
||||
wechatId: string;
|
||||
}
|
||||
|
||||
// 朋友圈实体数据类型
|
||||
interface MomentEntity {
|
||||
content: string;
|
||||
createTime: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
location: string;
|
||||
objectType: number;
|
||||
picSize: number;
|
||||
resUrls: string[];
|
||||
snsId: string;
|
||||
urls: string[];
|
||||
userName: string;
|
||||
}
|
||||
|
||||
// 朋友圈数据类型定义
|
||||
interface FriendsCircleItem {
|
||||
commentList: CommentItem[];
|
||||
createTime: number;
|
||||
likeList: LikeItem[];
|
||||
momentEntity: MomentEntity;
|
||||
snsId: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
interface ApiResponse {
|
||||
list: FriendsCircleItem[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// 分页参数类型
|
||||
interface PaginationParams {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
type: "my" | "square";
|
||||
}
|
||||
|
||||
// 模拟朋友圈数据
|
||||
const mockFriendsCircleData: FriendsCircleItem[] = [
|
||||
{
|
||||
commentList: [
|
||||
{
|
||||
commentArg: 2,
|
||||
commentId1: 0,
|
||||
commentId2: 35,
|
||||
commentTime: 1758011325,
|
||||
content: "测试评论",
|
||||
nickName: "老坑爹- 解放双手,释放时间",
|
||||
wechatId: "wxid_480es52qsj2812",
|
||||
},
|
||||
],
|
||||
createTime: 1757258659,
|
||||
likeList: [
|
||||
{
|
||||
createTime: 1757315556,
|
||||
nickName: "老坑爹- 解放双手,释放时间",
|
||||
wechatId: "wxid_480es52qsj2812",
|
||||
},
|
||||
],
|
||||
momentEntity: {
|
||||
content: "没看到血月[旺柴]",
|
||||
createTime: 1757258659,
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
location: "",
|
||||
objectType: 1,
|
||||
picSize: 0,
|
||||
resUrls: [
|
||||
"https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/weremote/chat-logs/5E2C38F5A275450D935F3ECEC076124E/aa6d4c2f7b1fe24d04d34f4f409883e6/sns/wxid_480es52qsj2812/-3705790026851937712/-3705790026851937712-14740954047910318662.jpg",
|
||||
],
|
||||
snsId: "-3705790026851937712",
|
||||
urls: ["/sns/3/3/snst_14740954047910318662"],
|
||||
userName: "wxid_dlhi90odctcl22",
|
||||
},
|
||||
snsId: "-3705790026851937712",
|
||||
type: 1,
|
||||
},
|
||||
{
|
||||
commentList: [],
|
||||
createTime: 1757258600,
|
||||
likeList: [],
|
||||
momentEntity: {
|
||||
content:
|
||||
"🎉🎊🎈欢迎小伙伴加入单群聊客宝地盘思慕斯蛋糕的小伙伴们的支持与信任!!!",
|
||||
createTime: 1757258600,
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
location: "",
|
||||
objectType: 1,
|
||||
picSize: 0,
|
||||
resUrls: ["/public/assets/face/1.png"],
|
||||
snsId: "-3705790026851937713",
|
||||
urls: ["/sns/3/3/snst_14740954047910318663"],
|
||||
userName: "wxid_dlhi90odctcl23",
|
||||
},
|
||||
snsId: "-3705790026851937713",
|
||||
type: 1,
|
||||
},
|
||||
{
|
||||
commentList: [],
|
||||
createTime: 1757258500,
|
||||
likeList: [],
|
||||
momentEntity: {
|
||||
content: "一整年卡1好的产品有用户的好评是买卖说的再多不如用户的有说服力",
|
||||
createTime: 1757258500,
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
location: "",
|
||||
objectType: 1,
|
||||
picSize: 0,
|
||||
resUrls: ["/public/assets/face/1.png"],
|
||||
snsId: "-3705790026851937714",
|
||||
urls: ["/sns/3/3/snst_14740954047910318664"],
|
||||
userName: "wxid_dlhi90odctcl24",
|
||||
},
|
||||
snsId: "-3705790026851937714",
|
||||
type: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const FriendsCircle: React.FC = () => {
|
||||
// 状态管理
|
||||
const [myCircleData, setMyCircleData] = useState<FriendsCircleItem[]>([]);
|
||||
const [squareData, setSquareData] = useState<FriendsCircleItem[]>([]);
|
||||
const [myCircleLoading, setMyCircleLoading] = useState(false);
|
||||
const [squareLoading, setSquareLoading] = useState(false);
|
||||
const [myCircleHasMore, setMyCircleHasMore] = useState(true);
|
||||
const [squareHasMore, setSquareHasMore] = useState(true);
|
||||
const [myCirclePage, setMyCirclePage] = useState(1);
|
||||
const [squarePage, setSquarePage] = useState(1);
|
||||
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
|
||||
|
||||
// 模拟API调用函数
|
||||
const fetchFriendsCircleData = async (
|
||||
params: PaginationParams,
|
||||
): Promise<ApiResponse> => {
|
||||
// 模拟网络延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const { pageNum, pageSize, type } = params;
|
||||
const startIndex = (pageNum - 1) * pageSize;
|
||||
|
||||
// 使用模拟数据
|
||||
const allData = mockFriendsCircleData;
|
||||
const typeData = type === "my" ? allData.slice(0, 1) : allData;
|
||||
const paginatedData = typeData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return {
|
||||
list: paginatedData,
|
||||
total: typeData.length,
|
||||
hasMore: startIndex + pageSize < typeData.length,
|
||||
};
|
||||
};
|
||||
|
||||
// 加载我的朋友圈数据
|
||||
const loadMyCircleData = async (
|
||||
pageNum: number = 1,
|
||||
reset: boolean = false,
|
||||
) => {
|
||||
setMyCircleLoading(true);
|
||||
try {
|
||||
const response = await fetchFriendsCircleData({
|
||||
pageNum,
|
||||
pageSize: 10,
|
||||
type: "my",
|
||||
});
|
||||
|
||||
if (reset) {
|
||||
setMyCircleData(response.list);
|
||||
} else {
|
||||
setMyCircleData(prev => [...prev, ...response.list]);
|
||||
}
|
||||
|
||||
setMyCircleHasMore(response.hasMore);
|
||||
setMyCirclePage(pageNum);
|
||||
} catch (error) {
|
||||
console.error("加载我的朋友圈失败:", error);
|
||||
} finally {
|
||||
setMyCircleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载朋友圈广场数据
|
||||
const loadSquareData = async (
|
||||
pageNum: number = 1,
|
||||
reset: boolean = false,
|
||||
) => {
|
||||
setSquareLoading(true);
|
||||
try {
|
||||
const response = await fetchFriendsCircleData({
|
||||
pageNum,
|
||||
pageSize: 10,
|
||||
type: "square",
|
||||
});
|
||||
|
||||
if (reset) {
|
||||
setSquareData(response.list);
|
||||
} else {
|
||||
setSquareData(prev => [...prev, ...response.list]);
|
||||
}
|
||||
|
||||
setSquareHasMore(response.hasMore);
|
||||
setSquarePage(pageNum);
|
||||
} catch (error) {
|
||||
console.error("加载朋友圈广场失败:", error);
|
||||
} finally {
|
||||
setSquareLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多我的朋友圈
|
||||
const loadMoreMyCircle = async () => {
|
||||
if (!myCircleHasMore || myCircleLoading) return;
|
||||
await loadMyCircleData(myCirclePage + 1);
|
||||
};
|
||||
|
||||
// 加载更多朋友圈广场
|
||||
const loadMoreSquare = async () => {
|
||||
if (!squareHasMore || squareLoading) return;
|
||||
await loadSquareData(squarePage + 1);
|
||||
};
|
||||
|
||||
// 处理折叠面板展开/收起
|
||||
const handleCollapseChange = (keys: string | string[]) => {
|
||||
const keyArray = Array.isArray(keys) ? keys : [keys];
|
||||
setExpandedKeys(keyArray);
|
||||
|
||||
// 当展开时加载数据
|
||||
keyArray.forEach(key => {
|
||||
if (key === "1" && myCircleData.length === 0) {
|
||||
loadMyCircleData(1, true);
|
||||
}
|
||||
if (key === "2" && squareData.length === 0) {
|
||||
loadSquareData(1, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleLike = (id: string) => {
|
||||
console.log("点赞:", id);
|
||||
};
|
||||
|
||||
const handleComment = (id: string) => {
|
||||
console.log("评论:", id);
|
||||
};
|
||||
|
||||
// 格式化时间戳
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
// 获取用户昵称
|
||||
const getUserNickName = (item: FriendsCircleItem) => {
|
||||
// 优先从点赞列表或评论列表中获取昵称
|
||||
if (item.likeList.length > 0) {
|
||||
return item.likeList[0].nickName;
|
||||
}
|
||||
if (item.commentList.length > 0) {
|
||||
return item.commentList[0].nickName;
|
||||
}
|
||||
return item.momentEntity.userName;
|
||||
};
|
||||
|
||||
const renderNormalItem = (item: FriendsCircleItem, isNotMy?: boolean) => {
|
||||
const nickName = getUserNickName(item);
|
||||
const content = item.momentEntity.content;
|
||||
const images = item.momentEntity.resUrls;
|
||||
const time = formatTime(item.createTime);
|
||||
const likesCount = item.likeList.length;
|
||||
const commentsCount = item.commentList.length;
|
||||
|
||||
return (
|
||||
<div className={styles.circleItem}>
|
||||
{isNotMy && (
|
||||
<div className={styles.avatar}>
|
||||
<Avatar size={36} shape="square" src="/public/assets/face/1.png" />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.itemWrap}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.username}>{nickName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.itemContent}>
|
||||
<div className={styles.contentText}>{content}</div>
|
||||
{images && images.length > 0 && (
|
||||
<div className={styles.imageContainer}>
|
||||
{images.map((image, index) => (
|
||||
<img
|
||||
key={index}
|
||||
src={image}
|
||||
className={styles.contentImage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{images && images.length > 0 && (
|
||||
<div className={styles.blueLink}>查看图片</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.itemFooter}>
|
||||
<div className={styles.timeInfo}>{time}</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<HeartOutlined />}
|
||||
onClick={() => handleLike(item.snsId)}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
{likesCount > 0 && <span>{likesCount}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<MessageOutlined />}
|
||||
onClick={() => handleComment(item.snsId)}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
{commentsCount > 0 && <span>{commentsCount}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 点赞和评论区域 */}
|
||||
{(item.likeList.length > 0 || item.commentList.length > 0) && (
|
||||
<div className={styles.interactionArea}>
|
||||
{/* 点赞列表 */}
|
||||
{item.likeList.length > 0 && (
|
||||
<div className={styles.likeArea}>
|
||||
<HeartOutlined className={styles.likeIcon} />
|
||||
<span className={styles.likeList}>
|
||||
{item.likeList.map((like, index) => (
|
||||
<span key={like.wechatId}>
|
||||
{like.nickName}
|
||||
{index < item.likeList.length - 1 && "、"}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 评论列表 */}
|
||||
{item.commentList.length > 0 && (
|
||||
<div className={styles.commentArea}>
|
||||
{item.commentList.map(comment => (
|
||||
<div
|
||||
key={`${comment.wechatId}-${comment.commentTime}`}
|
||||
className={styles.commentItem}
|
||||
>
|
||||
<span className={styles.commentUser}>
|
||||
{comment.nickName}
|
||||
</span>
|
||||
<span className={styles.commentSeparator}>: </span>
|
||||
<span className={styles.commentContent}>
|
||||
{comment.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMyFriendsCircle = () => {
|
||||
return (
|
||||
<div className={styles.myCircleContent}>
|
||||
{myCircleData.length > 0 ? (
|
||||
<>
|
||||
{myCircleData.map(item => (
|
||||
<div key={item.snsId} className={styles.itemWrapper}>
|
||||
{renderNormalItem(item, false)}
|
||||
</div>
|
||||
))}
|
||||
<InfiniteScroll
|
||||
loadMore={loadMoreMyCircle}
|
||||
hasMore={myCircleHasMore}
|
||||
threshold={10}
|
||||
>
|
||||
{myCircleLoading && (
|
||||
<div className={styles.loadingMore}>
|
||||
<Spin indicator={<LoadingOutlined spin />} /> 加载中...
|
||||
</div>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</>
|
||||
) : myCircleLoading ? (
|
||||
<div className={styles.loadingMore}>
|
||||
<Spin indicator={<LoadingOutlined spin />} /> 加载中...
|
||||
</div>
|
||||
) : (
|
||||
<p className={styles.emptyText}>暂无我的朋友圈内容</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFriendsSquare = () => {
|
||||
return (
|
||||
<div className={styles.squareContent}>
|
||||
{squareData.length > 0 ? (
|
||||
<>
|
||||
{squareData.map(item => (
|
||||
<div key={item.snsId} className={styles.itemWrapper}>
|
||||
{renderNormalItem(item, true)}
|
||||
</div>
|
||||
))}
|
||||
<InfiniteScroll
|
||||
loadMore={loadMoreSquare}
|
||||
hasMore={squareHasMore}
|
||||
threshold={10}
|
||||
>
|
||||
{squareLoading && (
|
||||
<div className={styles.loadingMore}>
|
||||
<Spin indicator={<LoadingOutlined spin />} /> 加载中...
|
||||
</div>
|
||||
)}
|
||||
</InfiniteScroll>
|
||||
</>
|
||||
) : squareLoading ? (
|
||||
<div className={styles.loadingMore}>
|
||||
<Spin indicator={<LoadingOutlined spin />} /> 加载中...
|
||||
</div>
|
||||
) : (
|
||||
<p className={styles.emptyText}>暂无朋友圈广场内容</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const collapseItems = [
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div className={styles.collapseHeader}>
|
||||
<ChromeOutlined style={{ fontSize: 20 }} />
|
||||
<span className={styles.specialText}>我的朋友圈</span>
|
||||
</div>
|
||||
),
|
||||
children: renderMyFriendsCircle(),
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
label: (
|
||||
<div className={styles.collapseHeader}>
|
||||
<AppstoreOutlined style={{ fontSize: 20 }} />
|
||||
<span className={styles.specialText}>朋友圈广场</span>
|
||||
</div>
|
||||
),
|
||||
children: renderFriendsSquare(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.friendsCircle}>
|
||||
{/* 可折叠的特殊模块,包含所有朋友圈数据 */}
|
||||
<Collapse
|
||||
items={collapseItems}
|
||||
className={styles.collapseContainer}
|
||||
ghost
|
||||
activeKey={expandedKeys}
|
||||
onChange={handleCollapseChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FriendsCircle;
|
||||
@@ -1,13 +1,9 @@
|
||||
import React, { useState } from "react";
|
||||
import { Input, Skeleton } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
ChromeOutlined,
|
||||
MessageOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { SearchOutlined, ChromeOutlined } from "@ant-design/icons";
|
||||
import WechatFriends from "./WechatFriends";
|
||||
import MessageList from "./MessageList/index";
|
||||
import FriendsCircle from "./FriendsCicle";
|
||||
import styles from "./SidebarMenu.module.scss";
|
||||
import { useCkChatStore } from "@/store/module/ckchat/ckchat";
|
||||
interface SidebarMenuProps {
|
||||
@@ -18,6 +14,7 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
|
||||
const searchKeyword = useCkChatStore(state => state.searchKeyword);
|
||||
const setSearchKeyword = useCkChatStore(state => state.setSearchKeyword);
|
||||
const clearSearchKeyword = useCkChatStore(state => state.clearSearchKeyword);
|
||||
const kfSelected = useCkChatStore(state => state.kfSelected);
|
||||
|
||||
const [activeTab, setActiveTab] = useState("chats");
|
||||
|
||||
@@ -97,23 +94,22 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
|
||||
className={`${styles.tabItem} ${activeTab === "chats" ? styles.active : ""}`}
|
||||
onClick={() => setActiveTab("chats")}
|
||||
>
|
||||
<MessageOutlined />
|
||||
<span>聊天</span>
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.tabItem} ${activeTab === "contracts" ? styles.active : ""}`}
|
||||
onClick={() => setActiveTab("contracts")}
|
||||
>
|
||||
<UserOutlined />
|
||||
<span>联系人</span>
|
||||
</div>
|
||||
<div
|
||||
className={`${styles.tabItem} ${activeTab === "groups" ? styles.active : ""}`}
|
||||
onClick={() => setActiveTab("groups")}
|
||||
>
|
||||
<ChromeOutlined />
|
||||
<span>朋友圈</span>
|
||||
</div>
|
||||
{kfSelected != 0 && (
|
||||
<div
|
||||
className={`${styles.tabItem} ${activeTab === "friendsCicle" ? styles.active : ""}`}
|
||||
onClick={() => setActiveTab("friendsCicle")}
|
||||
>
|
||||
<span>朋友圈</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -125,13 +121,8 @@ const SidebarMenu: React.FC<SidebarMenuProps> = ({ loading = false }) => {
|
||||
return <MessageList />;
|
||||
case "contracts":
|
||||
return <WechatFriends />;
|
||||
case "groups":
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<ChromeOutlined style={{ fontSize: 48, color: "#ccc" }} />
|
||||
<p>暂无群组</p>
|
||||
</div>
|
||||
);
|
||||
case "friendsCicle":
|
||||
return <FriendsCircle />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
.allFriends {
|
||||
padding-top: 15px;
|
||||
font-size: 12px;
|
||||
color: #333333;
|
||||
}
|
||||
@@ -48,6 +49,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.allUser {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
border: 6px #1890ff solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #333;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.messageBadge {
|
||||
|
||||
@@ -1,61 +1,68 @@
|
||||
import React from "react";
|
||||
import { Avatar, Badge, Tooltip } from "antd";
|
||||
import { Avatar, Badge } from "antd";
|
||||
import styles from "./VerticalUserList.module.scss";
|
||||
import { useCkChatStore, asyncKfSelected } from "@/store/module/ckchat/ckchat";
|
||||
|
||||
import { TeamOutlined } from "@ant-design/icons";
|
||||
const VerticalUserList: React.FC = () => {
|
||||
// 格式化消息数量显示
|
||||
const formatMessageCount = (count: number) => {
|
||||
if (count > 99) return "99+";
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
const handleUserSelect = (userId: number) => {
|
||||
asyncKfSelected(userId);
|
||||
};
|
||||
const kfUserList = useCkChatStore(state => state.kfUserList);
|
||||
const kfSelected = useCkChatStore(state => state.kfSelected);
|
||||
const chatSessions = useCkChatStore(state => state.chatSessions);
|
||||
const getUnreadCount = (wechatAccountId: number) => {
|
||||
if (wechatAccountId != 0) {
|
||||
const session = chatSessions.filter(
|
||||
v => v.wechatAccountId === wechatAccountId,
|
||||
);
|
||||
return session.reduce((pre, cur) => pre + cur.unreadCount, 0);
|
||||
} else {
|
||||
return chatSessions.reduce((pre, cur) => pre + cur.unreadCount, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.verticalUserList}>
|
||||
<div
|
||||
className={styles.userListHeader}
|
||||
onClick={() => handleUserSelect(0)}
|
||||
>
|
||||
<TeamOutlined style={{ fontSize: "26px" }} />
|
||||
<div className={styles.allFriends}>全部好友</div>
|
||||
<div className={styles.userListHeader}>
|
||||
<div className={styles.allFriends}>微信号</div>
|
||||
</div>
|
||||
<div className={styles.userList}>
|
||||
<div className={styles.userItem} onClick={() => handleUserSelect(0)}>
|
||||
<Badge
|
||||
count={getUnreadCount(0)}
|
||||
overflowCount={99}
|
||||
className={styles.messageBadge}
|
||||
>
|
||||
<div className={styles.allUser}>全部</div>
|
||||
</Badge>
|
||||
<div className={`${styles.onlineIndicator} ${styles.online}`} />
|
||||
</div>
|
||||
{kfUserList.map(user => (
|
||||
<Tooltip key={user.id} title={user.name} placement="right">
|
||||
<div
|
||||
className={`${styles.userItem} ${kfSelected === user.id ? styles.active : ""}`}
|
||||
onClick={() => handleUserSelect(user.id)}
|
||||
<div
|
||||
key={user.id}
|
||||
className={`${styles.userItem} ${kfSelected === user.id ? styles.active : ""}`}
|
||||
onClick={() => handleUserSelect(user.id)}
|
||||
>
|
||||
<Badge
|
||||
count={getUnreadCount(user.id)}
|
||||
overflowCount={99}
|
||||
className={styles.messageBadge}
|
||||
>
|
||||
<Badge
|
||||
count={
|
||||
user.messageCount ? formatMessageCount(user.messageCount) : 0
|
||||
}
|
||||
overflowCount={99}
|
||||
className={styles.messageBadge}
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
size={50}
|
||||
className={styles.userAvatar}
|
||||
style={{
|
||||
backgroundColor: !user.avatar ? "#1890ff" : undefined,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
size={50}
|
||||
className={styles.userAvatar}
|
||||
style={{
|
||||
backgroundColor: !user.avatar ? "#1890ff" : undefined,
|
||||
}}
|
||||
>
|
||||
{!user.avatar && user.name.charAt(0)}
|
||||
</Avatar>
|
||||
</Badge>
|
||||
<div
|
||||
className={`${styles.onlineIndicator} ${user.isOnline ? styles.online : styles.offline}`}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{!user.avatar && user.name.charAt(0)}
|
||||
</Avatar>
|
||||
</Badge>
|
||||
<div
|
||||
className={`${styles.onlineIndicator} ${user.isOnline ? styles.online : styles.offline}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,21 +22,21 @@ const CkboxPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
chatInitAPIdata()
|
||||
.then(response => {
|
||||
const data = response as {
|
||||
contractList: any[];
|
||||
groupList: any[];
|
||||
kfUserList: KfUserListData[];
|
||||
newContractList: { groupName: string; contacts: any[] }[];
|
||||
};
|
||||
const { contractList } = data;
|
||||
// const data = response as {
|
||||
// contractList: any[];
|
||||
// groupList: any[];
|
||||
// kfUserList: KfUserListData[];
|
||||
// newContractList: { groupName: string; contacts: any[] }[];
|
||||
// };
|
||||
// const { contractList } = data;
|
||||
|
||||
//找出已经在聊天的
|
||||
const isChatList = contractList.filter(
|
||||
v => (v?.config && v.config?.chat) || false,
|
||||
);
|
||||
isChatList.forEach(v => {
|
||||
addChatSession(v);
|
||||
});
|
||||
// //找出已经在聊天的
|
||||
// const isChatList = contractList.filter(
|
||||
// v => (v?.config && v.config?.chat) || false,
|
||||
// );
|
||||
// isChatList.forEach(v => {
|
||||
// addChatSession(v);
|
||||
// });
|
||||
|
||||
// 数据加载完成后初始化WebSocket连接
|
||||
initSocket();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getControlTerminalList,
|
||||
getContactList,
|
||||
getGroupList,
|
||||
getMessageList,
|
||||
} from "./api";
|
||||
|
||||
import { useUserStore } from "@/store/module/user";
|
||||
@@ -106,7 +107,7 @@ export const initSocket = () => {
|
||||
|
||||
// 如果已经连接或正在连接,则不重复连接
|
||||
if (["connected", "connecting"].includes(status)) {
|
||||
// console.log("WebSocket已连接或正在连接,跳过重复连接", { status });
|
||||
console.log("WebSocket已连接或正在连接,跳过重复连接", { status });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user