增强QuickWords组件:添加预览功能以确认发送快捷语,优化模态窗样式和内容展示,提升用户体验。
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
PlayCircleOutlined,
|
||||
SearchOutlined,
|
||||
LinkOutlined,
|
||||
QuestionCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
QuickWordsItem,
|
||||
@@ -76,6 +77,8 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
const [addModalVisible, setAddModalVisible] = useState(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [groupModalVisible, setGroupModalVisible] = useState(false);
|
||||
const [previewModalVisible, setPreviewModalVisible] = useState(false);
|
||||
const [previewReply, setPreviewReply] = useState<QuickWordsReply | null>(null);
|
||||
const [editingItem, setEditingItem] = useState<QuickWordsReply | null>(null);
|
||||
const [editingGroup, setEditingGroup] = useState<QuickWordsItem | null>(null);
|
||||
const [isAddingGroup, setIsAddingGroup] = useState(false);
|
||||
@@ -147,6 +150,11 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
};
|
||||
|
||||
const previewAndConfirmSend = (reply: QuickWordsReply) => {
|
||||
setPreviewReply(reply);
|
||||
setPreviewModalVisible(true);
|
||||
};
|
||||
|
||||
const renderPreviewContent = (reply: QuickWordsReply) => {
|
||||
let previewNode: React.ReactNode = null;
|
||||
if (reply.msgType === MessageType.IMAGE) {
|
||||
previewNode = (
|
||||
@@ -198,7 +206,6 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 400,
|
||||
border: "1px solid #e8e8e8",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#fff"
|
||||
@@ -216,7 +223,7 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
lineHeight: 1.4
|
||||
}}
|
||||
>
|
||||
{reply.title}
|
||||
标题: {reply.title}
|
||||
</div>
|
||||
|
||||
{/* 2. 封面图 */}
|
||||
@@ -282,7 +289,7 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
textOverflow: "ellipsis"
|
||||
}}
|
||||
>
|
||||
{linkData.desc}
|
||||
描述: {linkData.desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -310,16 +317,16 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
}
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: "确认发送该快捷语?",
|
||||
content: previewNode,
|
||||
okText: "发送",
|
||||
cancelText: "取消",
|
||||
onOk: () => {
|
||||
sendQuickReplyNow(reply);
|
||||
message.success("已发送");
|
||||
},
|
||||
});
|
||||
return previewNode;
|
||||
};
|
||||
|
||||
const handleConfirmSend = () => {
|
||||
if (previewReply) {
|
||||
sendQuickReplyNow(previewReply);
|
||||
message.success("已发送");
|
||||
setPreviewModalVisible(false);
|
||||
setPreviewReply(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取快捷语数据
|
||||
@@ -765,6 +772,28 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
setIsAddingGroup(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 预览确认发送模态窗 */}
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ textAlign: "center", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", gap: 8 }}>
|
||||
<QuestionCircleOutlined style={{ fontSize: 18, color: "#1890ff" }} />
|
||||
<span>确认发送该快捷语?</span>
|
||||
</div>
|
||||
}
|
||||
open={previewModalVisible}
|
||||
onOk={handleConfirmSend}
|
||||
onCancel={() => {
|
||||
setPreviewModalVisible(false);
|
||||
setPreviewReply(null);
|
||||
}}
|
||||
okText="发送"
|
||||
cancelText="取消"
|
||||
width={520}
|
||||
centered
|
||||
>
|
||||
{previewReply && renderPreviewContent(previewReply)}
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useUserStore } from "@storeModule/user";
|
||||
import { MessageManager } from "@/utils/dbAction/message";
|
||||
import { ContactManager } from "@/utils/dbAction/contact";
|
||||
import { formatWechatTime } from "@/utils/common";
|
||||
import { messageFilter } from "@/utils/filter";
|
||||
import { formatMessagePreview } from "@/utils/messagePreview";
|
||||
import { ChatSession } from "@/utils/db";
|
||||
import { VirtualSessionList } from "@/components/VirtualSessionList";
|
||||
interface MessageListProps {}
|
||||
@@ -68,7 +68,7 @@ const SessionItem: React.FC<SessionItemProps> = React.memo(
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.messageContent}>
|
||||
{messageFilter(session.content)}
|
||||
{formatMessagePreview(session.content)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useWeChatStore } from "@weChatStore/weChat";
|
||||
import { useUserStore } from "@storeModule/user";
|
||||
import { useContactStore } from "@weChatStore/contacts";
|
||||
import { formatWechatTime } from "@/utils/common";
|
||||
import { messageFilter } from "@/utils/filter";
|
||||
import { formatMessagePreview } from "@/utils/messagePreview";
|
||||
import { UserOutlined, TeamOutlined } from "@ant-design/icons";
|
||||
import { Avatar, Badge } from "antd";
|
||||
|
||||
@@ -61,7 +61,7 @@ const SessionItem: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.messageContent}>
|
||||
{messageFilter(session.content)}
|
||||
{formatMessagePreview(session.content)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
250
src/utils/messagePreview.ts
Normal file
250
src/utils/messagePreview.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 消息预览格式化工具
|
||||
* 用于会话列表中显示消息预览,参考 会话列表预览消息规则.md
|
||||
*/
|
||||
|
||||
// 图片扩展名正则
|
||||
const IMAGE_EXT_REGEX = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i;
|
||||
|
||||
// 视频扩展名正则
|
||||
const VIDEO_EXT_REGEX = /\.(mp4|avi|mov|wmv|flv|mkv|webm|m4v)$/i;
|
||||
|
||||
// 音频扩展名正则
|
||||
const AUDIO_EXT_REGEX = /\.(mp3|wav|wma|flac|aac|ogg|m4a)$/i;
|
||||
|
||||
// 阿里云 OSS 前缀
|
||||
const ALIYUN_OSS_PREFIX = 'https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com';
|
||||
|
||||
/**
|
||||
* 尝试解析 JSON
|
||||
*/
|
||||
const tryParseJson = (content: string): Record<string, any> | null => {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 XML 字符串中提取 title
|
||||
*/
|
||||
const extractTitleFromXml = (xmlString: string): string | null => {
|
||||
try {
|
||||
// 尝试提取 <title> 标签内容
|
||||
const titleMatch = xmlString.match(
|
||||
/<title>([^<]*(?:<!\[CDATA\[[^\]]*\]\]>[^<]*)*)<\/title>/i
|
||||
);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
let title = titleMatch[1];
|
||||
// 处理 CDATA
|
||||
title = title.replace(/<!\[CDATA\[(.*?)\]\]>/gi, '$1');
|
||||
// 去除首尾空白
|
||||
title = title.trim();
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 解析失败,返回 null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否为阿里云 OSS 链接,并判断类型
|
||||
*/
|
||||
const checkAliyunOssLink = (url: string): '图片' | '视频' | '音频' | null => {
|
||||
if (!url.includes(ALIYUN_OSS_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 根据文件扩展名判断类型
|
||||
if (IMAGE_EXT_REGEX.test(url)) {
|
||||
return '图片';
|
||||
}
|
||||
if (VIDEO_EXT_REGEX.test(url)) {
|
||||
return '视频';
|
||||
}
|
||||
if (AUDIO_EXT_REGEX.test(url)) {
|
||||
return '音频';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否为小程序消息
|
||||
*/
|
||||
const isMiniProgramMessage = (jsonData: Record<string, any>): boolean => {
|
||||
// 检查是否有 contentXml 且包含 appid
|
||||
if (jsonData.contentXml && typeof jsonData.contentXml === 'string') {
|
||||
const xmlContent = jsonData.contentXml;
|
||||
// 检查是否包含 <appmsg appid= 或 <appid>
|
||||
if (xmlContent.includes('<appmsg') && xmlContent.includes('appid')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有 type: "miniprogram"
|
||||
if (jsonData.type === 'miniprogram') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否有 weappinfo 对象
|
||||
if (jsonData.weappinfo || jsonData.weappInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化消息预览内容
|
||||
* @param content 原始消息内容
|
||||
* @returns 格式化后的预览文本
|
||||
*/
|
||||
export function formatMessagePreview(
|
||||
content: string | null | undefined
|
||||
): string {
|
||||
// 处理空值
|
||||
if (!content || typeof content !== 'string') {
|
||||
return '暂无消息';
|
||||
}
|
||||
|
||||
const trimmed = content.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return '暂无消息';
|
||||
}
|
||||
|
||||
// 1. 检查是否为阿里云 OSS 链接(纯链接字符串)
|
||||
const aliyunOssType = checkAliyunOssLink(trimmed);
|
||||
if (aliyunOssType) {
|
||||
return `[${aliyunOssType}]`;
|
||||
}
|
||||
|
||||
// 2. 尝试解析 JSON
|
||||
const jsonData = tryParseJson(trimmed);
|
||||
|
||||
if (jsonData && typeof jsonData === 'object') {
|
||||
// 2.1 检查是否为小程序消息
|
||||
if (isMiniProgramMessage(jsonData)) {
|
||||
return '[小程序消息]';
|
||||
}
|
||||
|
||||
// 2.2 检查 JSON 中是否有阿里云 OSS 链接
|
||||
// 遍历 JSON 对象的所有值,查找链接
|
||||
const findAliyunOssLink = (obj: any): string | null => {
|
||||
if (typeof obj === 'string' && obj.includes(ALIYUN_OSS_PREFIX)) {
|
||||
return obj;
|
||||
}
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
for (const value of Object.values(obj)) {
|
||||
const link = findAliyunOssLink(value);
|
||||
if (link) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const ossLink = findAliyunOssLink(jsonData);
|
||||
if (ossLink) {
|
||||
const ossType = checkAliyunOssLink(ossLink);
|
||||
if (ossType) {
|
||||
return `[${ossType}]`;
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3 尝试从 contentXml 中提取 title
|
||||
if (jsonData.contentXml && typeof jsonData.contentXml === 'string') {
|
||||
const title = extractTitleFromXml(jsonData.contentXml);
|
||||
if (title) {
|
||||
// 限制长度
|
||||
const maxLength = 50;
|
||||
return title.length > maxLength
|
||||
? title.substring(0, maxLength) + '...'
|
||||
: title;
|
||||
}
|
||||
}
|
||||
|
||||
// 2.4 检查 JSON 是否过长或被截断
|
||||
// 如果 JSON 字符串很长(超过 500 字符),可能被截断
|
||||
if (trimmed.length > 500) {
|
||||
// 尝试提取 title
|
||||
const title = extractTitleFromXml(trimmed);
|
||||
if (title) {
|
||||
const maxLength = 50;
|
||||
return title.length > maxLength
|
||||
? title.substring(0, maxLength) + '...'
|
||||
: title;
|
||||
}
|
||||
return '[文本过长]';
|
||||
}
|
||||
|
||||
// 2.5 尝试从 JSON 中提取有意义的信息
|
||||
if (jsonData.title) {
|
||||
const title = String(jsonData.title);
|
||||
const maxLength = 50;
|
||||
return title.length > maxLength
|
||||
? title.substring(0, maxLength) + '...'
|
||||
: title;
|
||||
}
|
||||
|
||||
if (jsonData.content) {
|
||||
const content = String(jsonData.content);
|
||||
const maxLength = 50;
|
||||
return content.length > maxLength
|
||||
? content.substring(0, maxLength) + '...'
|
||||
: content;
|
||||
}
|
||||
|
||||
// 2.6 无法识别的 JSON,返回通用提示
|
||||
return '[消息]';
|
||||
}
|
||||
|
||||
// 3. 检查是否为普通 HTTP 链接
|
||||
if (/^https?:\/\//i.test(trimmed)) {
|
||||
// 检查是否为图片链接
|
||||
if (IMAGE_EXT_REGEX.test(trimmed)) {
|
||||
return '[图片]';
|
||||
}
|
||||
// 检查是否为视频链接
|
||||
if (VIDEO_EXT_REGEX.test(trimmed)) {
|
||||
return '[视频]';
|
||||
}
|
||||
// 检查是否为音频链接
|
||||
if (AUDIO_EXT_REGEX.test(trimmed)) {
|
||||
return '[音频]';
|
||||
}
|
||||
// 普通链接
|
||||
return '[链接]';
|
||||
}
|
||||
|
||||
// 4. 检查是否为 XML 字符串(但没有被 JSON 包裹)
|
||||
if (
|
||||
trimmed.includes('<?xml') ||
|
||||
trimmed.includes('<msg>') ||
|
||||
trimmed.includes('<appmsg')
|
||||
) {
|
||||
const title = extractTitleFromXml(trimmed);
|
||||
if (title) {
|
||||
const maxLength = 50;
|
||||
return title.length > maxLength
|
||||
? title.substring(0, maxLength) + '...'
|
||||
: title;
|
||||
}
|
||||
return '[文本过长]';
|
||||
}
|
||||
|
||||
// 5. 普通文本消息
|
||||
// 限制长度,避免过长文本影响显示
|
||||
const maxLength = 50;
|
||||
if (trimmed.length > maxLength) {
|
||||
return trimmed.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
Reference in New Issue
Block a user