增强QuickWords组件:添加预览功能以确认发送快捷语,优化模态窗样式和内容展示,提升用户体验。

This commit is contained in:
乘风
2026-01-17 16:19:32 +08:00
parent 87a2cea4fd
commit f96cd3d6d8
4 changed files with 296 additions and 17 deletions

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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
View 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;
}