diff --git a/Cunkebao/dist/.vite/manifest.json b/Cunkebao/dist/.vite/manifest.json index e78197a2..435e8e43 100644 --- a/Cunkebao/dist/.vite/manifest.json +++ b/Cunkebao/dist/.vite/manifest.json @@ -1,14 +1,14 @@ { - "_charts-B9_ggjgM.js": { - "file": "assets/charts-B9_ggjgM.js", + "_charts-DrYFgxF3.js": { + "file": "assets/charts-DrYFgxF3.js", "name": "charts", "imports": [ - "_ui-CdpU1706.js", + "_ui-BCezweA9.js", "_vendor-2vc8h_ct.js" ] }, - "_ui-CdpU1706.js": { - "file": "assets/ui-CdpU1706.js", + "_ui-BCezweA9.js": { + "file": "assets/ui-BCezweA9.js", "name": "ui", "imports": [ "_vendor-2vc8h_ct.js" @@ -33,18 +33,18 @@ "name": "vendor" }, "index.html": { - "file": "assets/index-BZQSHOtN.js", + "file": "assets/index-B7uWDiaN.js", "name": "index", "src": "index.html", "isEntry": true, "imports": [ "_vendor-2vc8h_ct.js", "_utils-6WF66_dS.js", - "_ui-CdpU1706.js", - "_charts-B9_ggjgM.js" + "_ui-BCezweA9.js", + "_charts-DrYFgxF3.js" ], "css": [ - "assets/index-CciB7EKw.css" + "assets/index-DkU7m7k6.css" ] } } \ No newline at end of file diff --git a/Cunkebao/dist/index.html b/Cunkebao/dist/index.html index 6f790150..4ae4c1fd 100644 --- a/Cunkebao/dist/index.html +++ b/Cunkebao/dist/index.html @@ -11,13 +11,13 @@ - + - - + + - +
diff --git a/Cunkebao/src/pages/mobile/workspace/auto-like/record/index.tsx b/Cunkebao/src/pages/mobile/workspace/auto-like/record/index.tsx index fe08157e..1915fa48 100644 --- a/Cunkebao/src/pages/mobile/workspace/auto-like/record/index.tsx +++ b/Cunkebao/src/pages/mobile/workspace/auto-like/record/index.tsx @@ -137,9 +137,6 @@ export default function AutoLikeRecord() { onChange={handlePageChange} showSizeChanger={false} showQuickJumper - showTotal={(total, range) => - `第 ${range[0]}-${range[1]} 条,共 ${total} 条` - } size="default" className={styles.pagination} /> diff --git a/Cunkebao/src/pages/mobile/workspace/auto-like/record/record.module.scss b/Cunkebao/src/pages/mobile/workspace/auto-like/record/record.module.scss index a6df2c00..b63be1f0 100644 --- a/Cunkebao/src/pages/mobile/workspace/auto-like/record/record.module.scss +++ b/Cunkebao/src/pages/mobile/workspace/auto-like/record/record.module.scss @@ -3,7 +3,7 @@ display: flex; align-items: center; gap: 8px; - padding: 0 16px; + padding: 16px; } .headerSearchInputWrap { position: relative; diff --git a/Cunkebao/src/pages/mobile/workspace/moments-sync/Detail.tsx b/Cunkebao/src/pages/mobile/workspace/moments-sync/list/Detail.tsx similarity index 100% rename from Cunkebao/src/pages/mobile/workspace/moments-sync/Detail.tsx rename to Cunkebao/src/pages/mobile/workspace/moments-sync/list/Detail.tsx diff --git a/Cunkebao/src/pages/mobile/workspace/moments-sync/index.module.scss b/Cunkebao/src/pages/mobile/workspace/moments-sync/list/index.module.scss similarity index 100% rename from Cunkebao/src/pages/mobile/workspace/moments-sync/index.module.scss rename to Cunkebao/src/pages/mobile/workspace/moments-sync/list/index.module.scss diff --git a/Cunkebao/src/pages/mobile/workspace/moments-sync/MomentsSync.tsx b/Cunkebao/src/pages/mobile/workspace/moments-sync/list/index.tsx similarity index 99% rename from Cunkebao/src/pages/mobile/workspace/moments-sync/MomentsSync.tsx rename to Cunkebao/src/pages/mobile/workspace/moments-sync/list/index.tsx index 3e81136d..1bd6d1d4 100644 --- a/Cunkebao/src/pages/mobile/workspace/moments-sync/MomentsSync.tsx +++ b/Cunkebao/src/pages/mobile/workspace/moments-sync/list/index.tsx @@ -124,7 +124,7 @@ const MomentsSync: React.FC = () => { } - onClick={() => navigate(`/workspace/moments-sync/${task.id}`)} + onClick={() => navigate(`/workspace/moments-sync/record/${task.id}`)} > 查看 diff --git a/Cunkebao/src/pages/mobile/workspace/moments-sync/record/api.ts b/Cunkebao/src/pages/mobile/workspace/moments-sync/record/api.ts new file mode 100644 index 00000000..1a131cbc --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/moments-sync/record/api.ts @@ -0,0 +1,63 @@ +import request from "@/api/request"; +import { + LikeTask, + CreateLikeTaskData, + UpdateLikeTaskData, + LikeRecord, + PaginatedResponse, +} from "@/pages/workspace/auto-like/record/data"; + +// 获取自动点赞任务列表 +export function fetchAutoLikeTasks( + params = { type: 1, page: 1, limit: 100 }, +): Promise { + return request("/v1/workbench/list", params, "GET"); +} + +// 获取单个任务详情 +export function fetchAutoLikeTaskDetail(id: string): Promise { + return request("/v1/workbench/detail", { id }, "GET"); +} + +// 创建自动点赞任务 +export function createAutoLikeTask(data: CreateLikeTaskData): Promise { + return request("/v1/workbench/create", { ...data, type: 1 }, "POST"); +} + +// 更新自动点赞任务 +export function updateAutoLikeTask(data: UpdateLikeTaskData): Promise { + return request("/v1/workbench/update", { ...data, type: 1 }, "POST"); +} + +// 删除自动点赞任务 +export function deleteAutoLikeTask(id: string): Promise { + return request("/v1/workbench/delete", { id }, "DELETE"); +} + +// 切换任务状态 +export function toggleAutoLikeTask(id: string, status: string): Promise { + return request("/v1/workbench/update-status", { id, status }, "POST"); +} + +// 复制自动点赞任务 +export function copyAutoLikeTask(id: string): Promise { + return request("/v1/workbench/copy", { id }, "POST"); +} + +// 获取点赞记录 +export function fetchLikeRecords( + workbenchId: string, + page: number = 1, + limit: number = 20, + keyword?: string, +): Promise> { + const params: any = { + workbenchId, + page: page.toString(), + limit: limit.toString(), + }; + if (keyword) { + params.keyword = keyword; + } + return request("/v1/workbench/moments-records", params, "GET"); +} diff --git a/Cunkebao/src/pages/mobile/workspace/moments-sync/record/data.ts b/Cunkebao/src/pages/mobile/workspace/moments-sync/record/data.ts new file mode 100644 index 00000000..de39bd28 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/moments-sync/record/data.ts @@ -0,0 +1,119 @@ +// 自动点赞任务状态 +export type LikeTaskStatus = 1 | 2; // 1: 开启, 2: 关闭 + +// 内容类型 +export type ContentType = "text" | "image" | "video" | "link"; + +// 设备信息 +export interface Device { + id: string; + name: string; + status: "online" | "offline"; + lastActive: string; +} + +// 好友信息 +export interface Friend { + id: string; + nickname: string; + wechatId: string; + avatar: string; + tags: string[]; + region: string; + source: string; +} + +// 点赞记录 +export interface LikeRecord { + id: string; + workbenchId: string; + momentsId: string; + snsId: string; + wechatAccountId: string; + wechatFriendId: string; + likeTime: string; + content: string; + resUrls: string[]; + momentTime: string; + userName: string; + operatorName: string; + operatorAvatar: string; + friendName: string; + friendAvatar: string; +} + +// 自动点赞任务 +export interface LikeTask { + id: string; + name: string; + status: LikeTaskStatus; + deviceCount: number; + targetGroup: string; + likeCount: number; + lastLikeTime: string; + createTime: string; + creator: string; + likeInterval: number; + maxLikesPerDay: number; + timeRange: { start: string; end: string }; + contentTypes: ContentType[]; + targetTags: string[]; + devices: string[]; + friends: string[]; + friendMaxLikes: number; + friendTags: string; + enableFriendTags: boolean; + todayLikeCount: number; + totalLikeCount: number; + updateTime: string; +} + +// 创建任务数据 +export interface CreateLikeTaskData { + name: string; + interval: number; + maxLikes: number; + startTime: string; + endTime: string; + contentTypes: ContentType[]; + devices: string[]; + friends?: string[]; + friendMaxLikes: number; + friendTags?: string; + enableFriendTags: boolean; + targetTags: string[]; +} + +// 更新任务数据 +export interface UpdateLikeTaskData extends CreateLikeTaskData { + id: string; +} + +// 任务配置 +export interface TaskConfig { + interval: number; + maxLikes: number; + startTime: string; + endTime: string; + contentTypes: ContentType[]; + devices: string[]; + friends: string[]; + friendMaxLikes: number; + friendTags: string; + enableFriendTags: boolean; +} + +// API响应类型 +export interface ApiResponse { + code: number; + msg: string; + data: T; +} + +// 分页响应类型 +export interface PaginatedResponse { + list: T[]; + total: number; + page: number; + limit: number; +} diff --git a/Cunkebao/src/pages/mobile/workspace/moments-sync/record/index.tsx b/Cunkebao/src/pages/mobile/workspace/moments-sync/record/index.tsx new file mode 100644 index 00000000..b844e809 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/moments-sync/record/index.tsx @@ -0,0 +1,278 @@ +import React, { useState, useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { + Button, + Input, + Card, + Badge, + Avatar, + Skeleton, + message, + Spin, + Divider, + Pagination, +} from "antd"; +import { + LikeOutlined, + ReloadOutlined, + SearchOutlined, + UserOutlined, +} from "@ant-design/icons"; +import styles from "./record.module.scss"; +import NavCommon from "@/components/NavCommon"; +import { fetchLikeRecords } from "./api"; +import Layout from "@/components/Layout/Layout"; + +// 格式化日期 +const formatDate = (timestamp: number) => { + timestamp = timestamp * 1000; + try { + const date = new Date(timestamp); + return date.toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch (error) { + return timestamp; + } +}; + +export default function AutoLikeRecord() { + const { id } = useParams<{ id: string }>(); + const [records, setRecords] = useState([]); + const [recordsLoading, setRecordsLoading] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [total, setTotal] = useState(0); + const pageSize = 10; + + useEffect(() => { + if (!id) return; + setRecordsLoading(true); + fetchLikeRecords(id, 1, pageSize) + .then((response: any) => { + setRecords(response.list || []); + setTotal(response.total || 0); + setCurrentPage(1); + }) + .catch(() => { + message.error("获取发表记录失败,请稍后重试"); + }) + .finally(() => setRecordsLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const handleSearch = () => { + setCurrentPage(1); + fetchLikeRecords(id!, 1, pageSize, searchTerm) + .then((response: any) => { + setRecords(response.list || []); + setTotal(response.total || 0); + setCurrentPage(1); + }) + .catch(() => { + message.error("获取发表记录失败,请稍后重试"); + }); + }; + + const handleRefresh = () => { + fetchLikeRecords(id!, currentPage, pageSize, searchTerm) + .then((response: any) => { + setRecords(response.list || []); + setTotal(response.total || 0); + }) + .catch(() => { + message.error("获取发表记录失败,请稍后重试"); + }); + }; + + const handlePageChange = (newPage: number) => { + fetchLikeRecords(id!, newPage, pageSize, searchTerm) + .then((response: any) => { + setRecords(response.list || []); + setTotal(response.total || 0); + setCurrentPage(newPage); + }) + .catch(() => { + message.error("获取发表记录失败,请稍后重试"); + }); + }; + + return ( + + +
+
+ } + placeholder="搜索好友昵称或内容" + className={styles.headerSearchInput} + value={searchTerm} + onChange={e => setSearchTerm(e.target.value)} + onPressEnter={handleSearch} + allowClear + /> +
+
+ + } + footer={ + <> +
+ +
+ + } + > +
+
+ {recordsLoading ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+ +
+ + +
+
+ +
+ + +
+ + +
+
+
+ ))} +
+ ) : records.length === 0 ? ( +
+ +

暂无发表记录

+
+ ) : ( + <> + {records.map(record => ( +
+
+
+ } + size={40} + className={styles.avatarImg} + /> +
+
+ {record.operatorName} +
+
+ {formatDate(record.publishTime)} +
+
+
+
+ +
+ {record.content && ( +

{record.content}

+ )} + {Array.isArray(record.resUrls) && + record.resUrls.length > 0 && ( +
+ {record.resUrls + .slice(0, 9) + .map((image: string, idx: number) => ( +
+ {`内容图片 +
+ ))} +
+ )} +
+
+ ))} + + )} +
+
+
+ ); +} diff --git a/Cunkebao/src/pages/mobile/workspace/moments-sync/record/record.module.scss b/Cunkebao/src/pages/mobile/workspace/moments-sync/record/record.module.scss new file mode 100644 index 00000000..22172d79 --- /dev/null +++ b/Cunkebao/src/pages/mobile/workspace/moments-sync/record/record.module.scss @@ -0,0 +1,267 @@ +// 搜索栏 +.headerSearchBar { + display: flex; + align-items: center; + gap: 8px; + padding: 16px; +} +.headerSearchInputWrap { + position: relative; + flex: 1; +} +.headerSearchIcon { + position: absolute; + left: 12px; + top: 10px; + width: 16px; + height: 16px; + color: #a3a3a3; +} +.headerSearchInput { + padding-left: 32px !important; +} +.spin { + animation: spin 1s linear infinite; +} +@keyframes spin { + 100% { + transform: rotate(360deg); + } +} + +// 分页 +.footerPagination { + display: flex; + justify-content: center; + align-items: center; + padding: 12px 0; + background: #fff; +} +.pagination { + :global(.ant-pagination-item) { + border-radius: 6px; + } + :global(.ant-pagination-item-active) { + background: #1890ff; + border-color: #1890ff; + } + :global(.ant-pagination-prev), + :global(.ant-pagination-next) { + border-radius: 6px; + } + :global(.ant-pagination-jump-prev), + :global(.ant-pagination-jump-next) { + border-radius: 6px; + } +} + +// 背景和内容 +.bgWrap { + background: #f7f7fa; + min-height: 100vh; + padding-bottom: 80px; +} +.contentWrap { + padding: 0 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +// 骨架屏 +.skeletonWrap { + display: flex; + flex-direction: column; + gap: 16px; +} +.skeletonCard { + padding: 0px; +} +.skeletonCardHeader { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} +.skeletonAvatar { + width: 40px; + height: 40px; + border-radius: 50%; +} +.skeletonNameWrap { + display: flex; + flex-direction: column; + gap: 8px; +} +.skeletonName { + width: 96px; + height: 16px; +} +.skeletonSub { + width: 64px; + height: 12px; +} +.skeletonSep { + margin: 12px 0; +} +.skeletonContentWrap { + display: flex; + flex-direction: column; + gap: 8px; +} +.skeletonContent1 { + width: 100%; + height: 16px; +} +.skeletonContent2 { + width: 75%; + height: 16px; +} +.skeletonImgWrap { + display: flex; + gap: 8px; + margin-top: 12px; +} +.skeletonImg { + width: 80px; + height: 80px; + border-radius: 8px; +} + +// 空状态 +.emptyWrap { + text-align: center; + padding: 48px 0; +} +.emptyIcon { + width: 48px; + height: 48px; + color: #e5e7eb; + margin: 0 auto 12px auto; +} +.emptyText { + color: #888; + font-size: 16px; +} + +// 记录卡片 +.recordCard { + background: #fff; + border-radius: 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + padding: 16px; +} +.recordCardHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; +} +.recordCardHeaderLeft { + display: flex; + align-items: center; + gap: 12px; +} +.avatarImg { + width: 40px; + height: 40px; + border-radius: 50%; +} +.friendInfo { + min-width: 0; +} +.friendName { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.friendSub { + font-size: 13px; + color: #888; +} +.timeBadge { + background: #e8f0fe; + white-space: nowrap; + flex-shrink: 0; +} +.cardSep { + margin: 12px 0; +} +.cardContent { + margin-bottom: 12px; +} +.contentText { + color: #444; + margin-bottom: 12px; + white-space: pre-line; +} +.imgGrid { + display: grid; + gap: 8px; +} +.grid1 { + grid-template-columns: 1fr; +} +.grid2 { + grid-template-columns: 1fr 1fr; +} +.grid3 { + grid-template-columns: 1fr 1fr 1fr; +} +.grid6 { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr; +} +.grid9 { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr 1fr; +} +.imgItem { + position: relative; + aspect-ratio: 1/1; + border-radius: 8px; + overflow: hidden; +} +.img { + width: 100%; + height: 100%; + object-fit: cover; +} + +// 操作人 +.operatorWrap { + display: flex; + align-items: center; + margin-top: 16px; + padding: 8px; + background: #f3f4f6; + border-radius: 8px; +} +.operatorAvatar { + width: 32px !important; + height: 32px !important; + margin-right: 8px; + flex-shrink: 0; +} +.operatorInfo { + font-size: 14px; + position: relative; + flex: 1; + position: relative; +} +.operatorName { + font-weight: 500; + max-width: 100%; + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.operatorAction { + color: #888; + margin-left: 8px; + font-size: 12px; + position: absolute; + right: 0; + top: 2px; +} diff --git a/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/MessageRecord.module.scss b/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/MessageRecord.module.scss index f35eb61c..6a4ede88 100644 --- a/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/MessageRecord.module.scss +++ b/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/MessageRecord.module.scss @@ -55,7 +55,7 @@ .messageBubble { background: #1890ff; color: white; - border-radius: 18px 4px 18px 18px; + border-radius: 10px; } } @@ -65,7 +65,7 @@ .messageBubble { background: white; color: #262626; - border-radius: 4px 18px 18px 18px; + border-radius: 10px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); } } @@ -146,6 +146,56 @@ 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%; @@ -166,43 +216,317 @@ } } -// 文件消息样式(如果需要) -.fileMessage { - display: flex; - align-items: center; - gap: 8px; - padding: 8px; - border: 1px solid #d9d9d9; - border-radius: 6px; - background: #fafafa; - cursor: pointer; - transition: background 0.2s; +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} - &:hover { - background: #f0f0f0; +// 图片消息 +.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; - } + .fileName { + font-weight: 500; + color: #262626; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; + } - .fileSize { - font-size: 12px; - color: #8c8c8c; - margin-top: 2px; + .fileAction { + font-size: 12px; + color: #1890ff; + cursor: pointer; + + &:hover { + text-decoration: underline; } } } @@ -221,4 +545,40 @@ 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; + } + } + } } diff --git a/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/index.tsx b/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/index.tsx index 662aa53c..4d12191e 100644 --- a/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/index.tsx +++ b/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/components/MessageRecord/index.tsx @@ -1,15 +1,20 @@ import React, { useEffect, useRef } from "react"; import { Avatar, Divider } from "antd"; -import { UserOutlined, LoadingOutlined } from "@ant-design/icons"; +import { + UserOutlined, + LoadingOutlined, + DownloadOutlined, + PlayCircleFilled, +} from "@ant-design/icons"; import { ChatRecord, ContractData, weChatGroup } from "@/pages/pc/ckbox/data"; import { formatWechatTime } from "@/utils/common"; import styles from "./MessageRecord.module.scss"; import { useWeChatStore } from "@/store/module/weChat/weChat"; +import { useWebSocketStore } from "@/store/module/websocket/websocket"; interface MessageRecordProps { contract: ContractData | weChatGroup; } - const MessageRecord: React.FC = ({ contract }) => { const messagesEndRef = useRef(null); const currentMessages = useWeChatStore(state => state.currentMessages); @@ -19,85 +24,779 @@ const MessageRecord: React.FC = ({ contract }) => { const currentGroupMembers = useWeChatStore( state => state.currentGroupMembers, ); + const prevMessagesRef = useRef(currentMessages); + + // 检测是否为直接视频链接的函数 + 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")) + ); + }; + + // 判断是否为表情包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") + ); + }; useEffect(() => { - if (isLoadingData) { + 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" }); }; - // 解析消息内容,判断消息类型并返回对应的渲染内容 - const parseMessageContent = (content: string | null | undefined) => { + // 处理视频播放请求,发送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, + }); + }; + + // 解析消息内容,根据msgType判断消息类型并返回对应的渲染内容 + const parseMessageContent = ( + content: string | null | undefined, + msg: ChatRecord, + msgType?: number, + ) => { // 处理null或undefined的内容 if (content === null || content === undefined) { return
消息内容不可用
; } - // 检查是否为表情包 - if ( - typeof content === "string" && - content.includes("ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com") && - content.includes("#") - ) { - return ( -
- 表情包 window.open(content, "_blank")} - /> -
- ); - } - // 检查是否为带预览图的视频消息 - 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 renderErrorMessage = (fallbackText: string) => ( +
{fallbackText}
+ ); + // 根据msgType进行消息类型判断 + switch (msgType) { + case 1: // 文本消息 + return ( +
+
{content}
+
+ ); + + case 3: // 图片消息 + // 验证是否为有效的图片URL + if (typeof content !== "string" || !content.trim()) { + return renderErrorMessage("[图片消息 - 无效链接]"); + } + return ( +
+
+ 图片消息 window.open(content, "_blank")} + onError={e => { + const target = e.target as HTMLImageElement; + const parent = target.parentElement; + if (parent) { + parent.innerHTML = `
[图片加载失败]
`; + } + }} + /> +
+
+ ); + + case 43: // 视频消息 + if (typeof content !== "string" || !content.trim()) { + return renderErrorMessage("[视频消息 - 无效内容]"); + } + + // 如果content是直接的视频链接(已预览过或下载好的视频) + if (isDirectVideoLink(content)) { return ( -
-
- 视频预览 { - if (videoData.videoUrl) { - window.open(videoData.videoUrl, "_blank"); - } else if (videoData.tencentUrl) { - window.open(videoData.tencentUrl, "_blank"); - } - }} - /> -
- - - + ); } - } - } catch (e) { - // JSON解析失败,按普通文本处理 - } - // 普通文本消息 - return
{content}
; + 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 ( + + ); + } + + // 显示预览图,根据加载状态显示不同的图标 + return ( +
+
+
handlePlayClick(e, msg)} + > + 视频预览 { + const target = e.target as HTMLImageElement; + const parent = target.parentElement?.parentElement; + if (parent) { + parent.innerHTML = `
[视频预览加载失败]
`; + } + }} + /> +
+ {videoData.isLoading ? ( +
+ ) : ( + + )} +
+
+
+
+ ); + } + } + return renderErrorMessage("[视频消息]"); + } catch (e) { + console.warn("视频消息解析失败:", e); + return renderErrorMessage("[视频消息 - 解析失败]"); + } + + case 47: // 动图表情包(gif、其他表情包) + if (typeof content !== "string" || !content.trim()) { + return renderErrorMessage("[表情包 - 无效链接]"); + } + + // 使用工具函数判断表情包URL + if (isEmojiUrl(content)) { + return ( +
+ 表情包 window.open(content, "_blank")} + onError={e => { + const target = e.target as HTMLImageElement; + const parent = target.parentElement; + if (parent) { + parent.innerHTML = `
[表情包加载失败]
`; + } + }} + /> +
+ ); + } + return renderErrorMessage("[表情包]"); + + case 49: // 小程序/文章/其他:图文、文件 + 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 ( +
+
window.open(url, "_blank")} + > + {/* 标题在第一行 */} +
{title}
+ + {/* 下方:文字在左,图片在右 */} +
+
+ {desc && ( +
+ {desc} +
+ )} +
+ {thumbPath && ( +
+ 文章缩略图 { + const target = e.target as HTMLImageElement; + target.style.display = "none"; + }} + /> +
+ )} +
+
+
文章
+
+ ); + } + + // 处理包含contentXml的小程序消息格式 + if (messageData.contentXml && messageData.type === "miniprogram") { + const xmlContent = messageData.contentXml; + + // 从XML中提取title + const titleMatch = xmlContent.match(/([^<]*)<\/title>/); + const title = titleMatch ? titleMatch[1] : "小程序消息"; + + // 从XML中提取type字段 + const typeMatch = xmlContent.match( + /<weappinfo>[\s\S]*?<type>(\d+)<\/type>[\s\S]*?<\/weappinfo>/, + ); + const miniProgramType = typeMatch ? parseInt(typeMatch[1]) : 1; + + // 从XML中提取thumburl或使用previewImage + const thumbUrlMatch = xmlContent.match( + /<thumburl>\s*([^<]*?)\s*<\/thumburl>/, + ); + let thumbUrl = thumbUrlMatch ? thumbUrlMatch[1].trim() : ""; + + // 如果thumburl为空或无效,使用previewImage + if (!thumbUrl || thumbUrl === "`" || thumbUrl.includes("`")) { + thumbUrl = messageData.previewImage || ""; + } + + // 清理URL中的特殊字符 + thumbUrl = thumbUrl.replace(/[`"']/g, "").replace(/&/g, "&"); + + // 从XML中提取appname或使用默认值 + const appNameMatch = + xmlContent.match(/<appname\s*\/?>([^<]*)<\/appname>/) || + xmlContent.match( + /<sourcedisplayname>([^<]*)<\/sourcedisplayname>/, + ); + const appName = appNameMatch ? appNameMatch[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> + {/* 图片 */} + {thumbUrl && ( + <div className={styles.miniProgramImageArea}> + <img + src={thumbUrl} + 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}> + {thumbUrl && ( + <img + src={thumbUrl} + 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> + ); + } + } + + // 验证传统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("[小程序/文件消息 - 解析失败]"); + } + + 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}>{content}</div>; + } + } }; // 获取群成员头像 @@ -152,13 +851,13 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => { className={styles.messageAvatar} /> - <div className={styles.messageBubble}> + <div> {!isOwn && ( <div className={styles.messageSender}> {contract.nickname} </div> )} - {parseMessageContent(msg?.content)} + <>{parseMessageContent(msg?.content, msg, msg?.msgType)}</> </div> </> )} @@ -172,24 +871,24 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => { className={styles.messageAvatar} /> - <div className={styles.messageBubble}> + <div> {!isOwn && ( <div className={styles.messageSender}> {msg?.sender?.nickname} </div> )} - {parseMessageContent( - clearWechatidInContent(msg?.sender, msg?.content), - )} + <> + {parseMessageContent( + clearWechatidInContent(msg?.sender, msg?.content), + msg, + msg?.msgType, + )} + </> </div> </> )} - {isOwn && ( - <div className={styles.messageBubble}> - {parseMessageContent(msg?.content)} - </div> - )} + {isOwn && <>{parseMessageContent(msg?.content, msg, msg?.msgType)}</>} </div> </div> ); diff --git a/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/demo.tsx b/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/demo.tsx new file mode 100644 index 00000000..e1bfb7ca --- /dev/null +++ b/Cunkebao/src/pages/pc/ckbox/components/ChatWindow/demo.tsx @@ -0,0 +1,669 @@ +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; diff --git a/Cunkebao/src/router/module/workspace.tsx b/Cunkebao/src/router/module/workspace.tsx index 5c010122..79878bf3 100644 --- a/Cunkebao/src/router/module/workspace.tsx +++ b/Cunkebao/src/router/module/workspace.tsx @@ -8,9 +8,9 @@ import AutoGroupForm from "@/pages/mobile/workspace/auto-group/form"; import GroupPush from "@/pages/mobile/workspace/group-push/list"; import FormGroupPush from "@/pages/mobile/workspace/group-push/form"; import DetailGroupPush from "@/pages/mobile/workspace/group-push/detail"; -import MomentsSync from "@/pages/mobile/workspace/moments-sync/MomentsSync"; -import MomentsSyncDetail from "@/pages/mobile/workspace/moments-sync/Detail"; +import MomentsSync from "@/pages/mobile/workspace/moments-sync/list"; import NewMomentsSync from "@/pages/mobile/workspace/moments-sync/new/index"; +import MomentsSyncRecord from "@/pages/mobile/workspace/moments-sync/record"; import AIAssistant from "@/pages/mobile/workspace/ai-assistant/AIAssistant"; import TrafficDistribution from "@/pages/mobile/workspace/traffic-distribution/list/index"; import TrafficDistributionDetail from "@/pages/mobile/workspace/traffic-distribution/detail/index"; @@ -98,9 +98,10 @@ const workspaceRoutes = [ element: <NewMomentsSync />, auth: true, }, + { - path: "/workspace/moments-sync/:id", - element: <MomentsSyncDetail />, + path: "/workspace/moments-sync/record/:id", + element: <MomentsSyncRecord />, auth: true, }, {