优化QuickWords组件:更新消息类型处理逻辑,支持LINK类型的内容格式化和展示,增强表单处理以支持多种文件类型上传,改善用户体验。
This commit is contained in:
@@ -273,15 +273,15 @@ const TwoColumnSelection: React.FC<TwoColumnSelectionProps> = ({
|
||||
<>
|
||||
{/* 使用 React.memo 优化列表项渲染 */}
|
||||
{friends.map(friend => {
|
||||
const isSelected = selectedFriendsMap.has(friend.id);
|
||||
return (
|
||||
<FriendListItem
|
||||
key={friend.id}
|
||||
friend={friend}
|
||||
isSelected={isSelected}
|
||||
onSelect={handleSelectFriend}
|
||||
/>
|
||||
);
|
||||
const isSelected = selectedFriendsMap.has(friend.id);
|
||||
return (
|
||||
<FriendListItem
|
||||
key={friend.id}
|
||||
friend={friend}
|
||||
isSelected={isSelected}
|
||||
onSelect={handleSelectFriend}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 加载更多指示器 */}
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface QuickWordsReply {
|
||||
userId: number;
|
||||
title: string;
|
||||
msgType: number;
|
||||
content: string;
|
||||
content: any;
|
||||
createTime: string;
|
||||
lastUpdateTime: string;
|
||||
sortIndex: string;
|
||||
@@ -41,7 +41,7 @@ export interface AddReplyRequest {
|
||||
/**
|
||||
* 1文本 3图片 43视频 49链接 等
|
||||
*/
|
||||
msgType?: string[];
|
||||
msgType?: number;
|
||||
/**
|
||||
* 默认50
|
||||
*/
|
||||
@@ -72,7 +72,7 @@ export interface AddGroupRequest {
|
||||
/**
|
||||
* 0 公共 1私有 2部门
|
||||
*/
|
||||
replyType?: string[];
|
||||
replyType?: number;
|
||||
/**
|
||||
* 默认50
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LinkOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import SimpleFileUpload from "@/components/Upload/SimpleFileUpload";
|
||||
import MainImgUpload from "@/components/Upload/MainImgUpload";
|
||||
// 简化版不再使用样式与解析组件
|
||||
import { AddReplyRequest } from "../api";
|
||||
|
||||
@@ -28,16 +29,53 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({
|
||||
groupOptions,
|
||||
defaultGroupId,
|
||||
}) => {
|
||||
const [form] = Form.useForm<AddReplyRequest>();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const mergedInitialValues = useMemo(() => {
|
||||
return {
|
||||
const baseValues = {
|
||||
groupId: defaultGroupId,
|
||||
msgType: initialValues?.msgType || ["1"],
|
||||
...initialValues,
|
||||
} as Partial<AddReplyRequest>;
|
||||
};
|
||||
|
||||
// 如果是编辑模式且是 link 类型,解析 content 中的 JSON
|
||||
if (initialValues?.msgType && Array.isArray(initialValues.msgType) && initialValues.msgType[0] === "49" && initialValues.content) {
|
||||
try {
|
||||
// 处理 content 可能是对象或字符串两种情况
|
||||
let linkData: any;
|
||||
if (typeof initialValues.content === 'string') {
|
||||
// 如果是字符串,尝试解析 JSON
|
||||
linkData = JSON.parse(initialValues.content);
|
||||
} else if (typeof initialValues.content === 'object') {
|
||||
// 如果已经是对象,直接使用
|
||||
linkData = initialValues.content;
|
||||
} else {
|
||||
return baseValues;
|
||||
}
|
||||
|
||||
return {
|
||||
...baseValues,
|
||||
content: linkData.url || "",
|
||||
thumbPath: linkData.thumbPath || "",
|
||||
desc: linkData.desc || "",
|
||||
};
|
||||
} catch {
|
||||
// 如果解析失败,保持原值
|
||||
return baseValues;
|
||||
}
|
||||
}
|
||||
|
||||
return baseValues;
|
||||
}, [initialValues, defaultGroupId]);
|
||||
|
||||
// 监听 modal 打开和模式变化,重置表单
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
form.resetFields();
|
||||
form.setFieldsValue(mergedInitialValues);
|
||||
}
|
||||
}, [open, mode, form, mergedInitialValues]);
|
||||
|
||||
// 监听类型变化
|
||||
const msgTypeWatch = Form.useWatch("msgType", form);
|
||||
const selectedMsgType = useMemo(() => {
|
||||
@@ -46,6 +84,26 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({
|
||||
return Number(raw || "1");
|
||||
}, [msgTypeWatch]);
|
||||
|
||||
// 监听 content 变化,用于 LINK 类型输入框
|
||||
const contentWatch = Form.useWatch("content", form);
|
||||
|
||||
// 获取 LINK 类型的 url 值
|
||||
const getLinkUrl = useMemo(() => {
|
||||
if (selectedMsgType === 49 && contentWatch) {
|
||||
if (typeof contentWatch === 'string') {
|
||||
try {
|
||||
const linkData = JSON.parse(contentWatch);
|
||||
return linkData.url || "";
|
||||
} catch {
|
||||
return contentWatch;
|
||||
}
|
||||
} else if (typeof contentWatch === 'object' && contentWatch !== null) {
|
||||
return contentWatch.url || "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}, [selectedMsgType, contentWatch]);
|
||||
|
||||
// 根据文件格式判断消息类型
|
||||
const getMsgTypeByFileFormat = (filePath: string): number => {
|
||||
const extension = filePath.toLowerCase().split(".").pop() || "";
|
||||
@@ -84,7 +142,7 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({
|
||||
} as const;
|
||||
|
||||
const handleFileUploaded = (
|
||||
filePath: string | { url: string; durationMs: number },
|
||||
filePath: string | { url: string; durationMs?: number; name?: string },
|
||||
fileType: number,
|
||||
) => {
|
||||
let msgType = 1;
|
||||
@@ -100,11 +158,22 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({
|
||||
msgType = 49;
|
||||
}
|
||||
|
||||
// 根据文件类型处理 content
|
||||
let contentValue: string;
|
||||
if (([FileType.AUDIO, FileType.VIDEO] as number[]).includes(fileType)) {
|
||||
// 音频和视频需要保存完整的 JSON 对象,以供预览组件使用
|
||||
contentValue = JSON.stringify(filePath);
|
||||
} else if (typeof filePath === 'string') {
|
||||
// 其他类型如果是字符串就直接用
|
||||
contentValue = filePath;
|
||||
} else {
|
||||
// 其他类型如果是对象就取 url
|
||||
contentValue = filePath.url;
|
||||
}
|
||||
|
||||
form.setFieldsValue({
|
||||
msgType: [String(msgType)],
|
||||
content: ([FileType.AUDIO] as number[]).includes(fileType)
|
||||
? JSON.stringify(filePath)
|
||||
: (filePath as string),
|
||||
content: contentValue,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -132,11 +201,28 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={values => {
|
||||
// 处理 link 类型,将 content、thumbPath、desc 组合成 JSON
|
||||
let finalValues = { ...values };
|
||||
if (selectedMsgType === 49) {
|
||||
const linkData = {
|
||||
url: values.content || "",
|
||||
thumbPath: values.thumbPath || "",
|
||||
desc: values.desc || "",
|
||||
};
|
||||
finalValues = {
|
||||
...values,
|
||||
content: JSON.stringify(linkData),
|
||||
};
|
||||
// 移除额外的字段
|
||||
delete finalValues.thumbPath;
|
||||
delete finalValues.desc;
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
...values,
|
||||
msgType: Array.isArray(values.msgType)
|
||||
? values.msgType
|
||||
: [String(values.msgType)],
|
||||
...finalValues,
|
||||
msgType: Array.isArray(finalValues.msgType)
|
||||
? finalValues.msgType
|
||||
: [String(finalValues.msgType)],
|
||||
} as AddReplyRequest;
|
||||
onSubmit(normalized);
|
||||
}}
|
||||
@@ -195,32 +281,154 @@ const QuickReplyModal: React.FC<QuickReplyModalProps> = ({
|
||||
/>
|
||||
)}
|
||||
{selectedMsgType === 3 && (
|
||||
<SimpleFileUpload
|
||||
onFileUploaded={filePath =>
|
||||
handleFileUploaded(filePath, FileType.IMAGE)
|
||||
}
|
||||
maxSize={1}
|
||||
type={1}
|
||||
slot={<Button icon={<PictureOutlined />}>上传图片</Button>}
|
||||
/>
|
||||
<div style={{ maxWidth: "50%" }}>
|
||||
<MainImgUpload
|
||||
value={form.getFieldValue("content")}
|
||||
onChange={(url) => {
|
||||
form.setFieldsValue({ content: url });
|
||||
}}
|
||||
maxSize={5}
|
||||
showPreview={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedMsgType === 43 && (
|
||||
<SimpleFileUpload
|
||||
onFileUploaded={filePath =>
|
||||
handleFileUploaded(filePath, FileType.VIDEO)
|
||||
}
|
||||
maxSize={1}
|
||||
type={4}
|
||||
slot={<Button icon={<VideoCameraOutlined />}>上传视频</Button>}
|
||||
/>
|
||||
<>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ marginBottom: 8, fontSize: 14, color: "#666" }}>
|
||||
视频封面图
|
||||
</div>
|
||||
<div style={{ maxWidth: "50%" }}>
|
||||
<MainImgUpload
|
||||
value={(() => {
|
||||
try {
|
||||
const videoData = JSON.parse(form.getFieldValue("content") || "{}");
|
||||
return videoData.previewImage || videoData.thumbPath || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})()}
|
||||
onChange={(previewUrl) => {
|
||||
// 保留原有的视频数据,只更新预览图
|
||||
try {
|
||||
const currentContent = form.getFieldValue("content");
|
||||
const videoData = currentContent ? JSON.parse(currentContent) : {};
|
||||
videoData.previewImage = previewUrl;
|
||||
form.setFieldsValue({ content: JSON.stringify(videoData) });
|
||||
} catch {
|
||||
form.setFieldsValue({ content: JSON.stringify({ previewImage: previewUrl }) });
|
||||
}
|
||||
}}
|
||||
maxSize={5}
|
||||
showPreview={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ marginBottom: 8, fontSize: 14, color: "#666" }}>
|
||||
视频文件
|
||||
</div>
|
||||
<SimpleFileUpload
|
||||
onFileUploaded={filePath =>
|
||||
handleFileUploaded(filePath, FileType.VIDEO)
|
||||
}
|
||||
maxSize={50}
|
||||
type={4}
|
||||
slot={<Button icon={<VideoCameraOutlined />} block>上传视频文件</Button>}
|
||||
/>
|
||||
{(() => {
|
||||
try {
|
||||
const videoData = JSON.parse(form.getFieldValue("content") || "{}");
|
||||
const videoUrl = videoData.url;
|
||||
if (videoUrl) {
|
||||
return (
|
||||
<div style={{ marginTop: 12, position: "relative" }}>
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
style={{
|
||||
width: "100%",
|
||||
maxHeight: 300,
|
||||
borderRadius: 6,
|
||||
border: "1px solid #d9d9d9",
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => {
|
||||
try {
|
||||
const currentContent = form.getFieldValue("content");
|
||||
const videoData = currentContent ? JSON.parse(currentContent) : {};
|
||||
delete videoData.url;
|
||||
delete videoData.name;
|
||||
form.setFieldsValue({ content: JSON.stringify(videoData) });
|
||||
} catch {
|
||||
form.setFieldsValue({ content: "" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除视频
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{selectedMsgType === 49 && (
|
||||
<Input
|
||||
placeholder="请输入链接地址"
|
||||
prefix={<LinkOutlined />}
|
||||
value={form.getFieldValue("content")}
|
||||
onChange={e => form.setFieldsValue({ content: e.target.value })}
|
||||
/>
|
||||
<>
|
||||
<Input
|
||||
placeholder="请输入链接地址"
|
||||
prefix={<LinkOutlined />}
|
||||
value={getLinkUrl}
|
||||
onChange={(e) => {
|
||||
const newUrl = e.target.value;
|
||||
// LINK 类型下,content 字段存储的是 url 字符串
|
||||
// 提交时会与 thumbPath、desc 组合成对象
|
||||
form.setFieldsValue({
|
||||
content: newUrl
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Form.Item
|
||||
name="thumbPath"
|
||||
label="封面图"
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<div style={{ maxWidth: "50%" }}>
|
||||
<MainImgUpload
|
||||
value={form.getFieldValue("thumbPath")}
|
||||
onChange={(url) => {
|
||||
form.setFieldsValue({ thumbPath: url });
|
||||
}}
|
||||
maxSize={5}
|
||||
showPreview={true}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="desc"
|
||||
label="描述"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请输入链接描述"
|
||||
maxLength={200}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
PictureOutlined,
|
||||
PlayCircleOutlined,
|
||||
SearchOutlined,
|
||||
LinkOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
QuickWordsItem,
|
||||
@@ -90,13 +91,29 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
const sendQuickReplyNow = (reply: QuickWordsReply) => {
|
||||
if (!currentContract) return;
|
||||
const messageId = Date.now();
|
||||
|
||||
// 处理 link 类型,转换为文章格式
|
||||
let content = reply.content;
|
||||
if (reply.msgType === MessageType.LINK) {
|
||||
// link 类型按照文章消息格式发送
|
||||
const linkData = reply.content;
|
||||
|
||||
content = JSON.stringify({
|
||||
type: "link",
|
||||
title: reply.title || "文章链接",
|
||||
desc: linkData.desc || "",
|
||||
thumbPath: linkData.thumbPath || "",
|
||||
url: linkData.url || ""
|
||||
});
|
||||
}
|
||||
|
||||
const params = {
|
||||
wechatAccountId: currentContract.wechatAccountId,
|
||||
wechatChatroomId: currentContract?.chatroomId ? currentContract.id : 0,
|
||||
wechatFriendId: currentContract?.chatroomId ? 0 : currentContract.id,
|
||||
msgSubType: 0,
|
||||
msgType: reply.msgType,
|
||||
content: reply.content,
|
||||
content: content,
|
||||
seq: messageId,
|
||||
} as any;
|
||||
|
||||
@@ -141,33 +158,156 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (reply.msgType === MessageType.VIDEO) {
|
||||
} else if (reply.msgType === MessageType.VIDEO) {
|
||||
try {
|
||||
const videoUrl = reply.content
|
||||
if (videoUrl) {
|
||||
// 如果有视频URL,显示视频播放器
|
||||
previewNode = (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
style={{
|
||||
maxWidth: 360,
|
||||
maxHeight: 320,
|
||||
borderRadius: 6,
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// 如果没有视频URL,显示默认提示
|
||||
previewNode = (
|
||||
<div style={{ textAlign: "center", padding: "40px 20px" }}>
|
||||
<div style={{ fontSize: 48, color: "#d9d9d9", marginBottom: 12 }}>
|
||||
📹
|
||||
</div>
|
||||
<div style={{ color: "#999" }}>暂无视频内容</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
previewNode = <div>视频消息</div>;
|
||||
}
|
||||
} else if (reply.msgType === MessageType.LINK) {
|
||||
try {
|
||||
const json = JSON.parse(reply.content || "{}");
|
||||
const cover = json.previewImage || json.thumbPath || "";
|
||||
const linkData = reply.content ;
|
||||
previewNode = (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
{cover ? (
|
||||
<img
|
||||
src={String(cover)}
|
||||
alt="视频预览"
|
||||
style={{ maxWidth: 360, maxHeight: 320, borderRadius: 6 }}
|
||||
/>
|
||||
) : (
|
||||
<div>视频消息</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 400,
|
||||
border: "1px solid #e8e8e8",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#fff"
|
||||
}}
|
||||
>
|
||||
{/* 内容区域 */}
|
||||
<div style={{ padding: "16px" }}>
|
||||
{/* 1. 标题 */}
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: 16,
|
||||
marginBottom: 12,
|
||||
color: "#262626",
|
||||
lineHeight: 1.4
|
||||
}}
|
||||
>
|
||||
{reply.title}
|
||||
</div>
|
||||
|
||||
{/* 2. 封面图 */}
|
||||
{linkData.thumbPath && (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: 12,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#f5f5f5"
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={linkData.thumbPath}
|
||||
alt="链接封面"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
maxHeight: 200,
|
||||
objectFit: "cover",
|
||||
display: "block"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. 链接地址 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 12px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
marginBottom: linkData.desc ? 12 : 0
|
||||
}}
|
||||
>
|
||||
<LinkOutlined style={{ color: "#1677ff", marginRight: 6 }} />
|
||||
<span
|
||||
style={{
|
||||
color: "#1677ff",
|
||||
wordBreak: "break-all",
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
{linkData.url || "未设置链接地址"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 4. 描述 */}
|
||||
{linkData.desc && (
|
||||
<div
|
||||
style={{
|
||||
color: "#8c8c8c",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.5,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis"
|
||||
}}
|
||||
>
|
||||
{linkData.desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
previewNode = <div>视频消息</div>;
|
||||
// 如果解析失败,使用旧格式
|
||||
previewNode = (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 400,
|
||||
padding: 16,
|
||||
border: "1px solid #e8e8e8",
|
||||
borderRadius: 8
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 16 }}>
|
||||
{reply.title}
|
||||
</div>
|
||||
<div style={{ color: "#1677ff", fontSize: 14 }}>
|
||||
{typeof reply.content === 'string' ? reply.content : "链接内容"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (reply.msgType === MessageType.LINK) {
|
||||
previewNode = (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>{reply.title}</div>
|
||||
<div style={{ color: "#1677ff" }}>{reply.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
@@ -326,10 +466,17 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
selectedKeys[0]?.toString().replace("group-", "") ||
|
||||
groupOptions[0]?.value ||
|
||||
"";
|
||||
|
||||
// 处理 msgType:从字符串数组转换为 number
|
||||
const msgType = Array.isArray(values.msgType)
|
||||
? Number(values.msgType[0])
|
||||
: Number(values.msgType);
|
||||
|
||||
await addReply({
|
||||
...values,
|
||||
msgType,
|
||||
groupId: values.groupId || fallbackGroupId,
|
||||
replyType: [activeTab.toString()],
|
||||
replyType: activeTab, // ✅ 直接传 number 类型
|
||||
});
|
||||
message.success("添加快捷回复成功");
|
||||
setAddModalVisible(false);
|
||||
@@ -351,8 +498,14 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
if (!editingItem) return;
|
||||
|
||||
try {
|
||||
// 处理 msgType:从字符串数组转换为 number
|
||||
const msgType = Array.isArray(values.msgType)
|
||||
? Number(values.msgType[0])
|
||||
: Number(values.msgType);
|
||||
|
||||
await updateReply({
|
||||
...values,
|
||||
msgType,
|
||||
id: editingItem.id.toString(),
|
||||
});
|
||||
message.success("更新快捷回复成功");
|
||||
@@ -422,7 +575,7 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
parentId: selectedKeys[0]?.toString().startsWith("group-")
|
||||
? selectedKeys[0]?.toString().replace("group-", "")
|
||||
: "0",
|
||||
replyType: [activeTab.toString()],
|
||||
replyType: activeTab, // ✅ 直接传 number 类型
|
||||
});
|
||||
message.success("新增分组成功");
|
||||
setGroupModalVisible(false);
|
||||
@@ -582,14 +735,14 @@ const QuickWords: React.FC<QuickWordsProps> = ({ onInsert }) => {
|
||||
defaultGroupId={selectedKeys[0]?.toString().replace("group-", "")}
|
||||
initialValues={
|
||||
editingItem
|
||||
? {
|
||||
? ({
|
||||
title: editingItem.title,
|
||||
content: editingItem.content,
|
||||
msgType: [editingItem.msgType.toString()],
|
||||
groupId:
|
||||
editingItem.groupId?.toString?.() ||
|
||||
selectedKeys[0]?.toString().replace("group-", ""),
|
||||
}
|
||||
} as any)
|
||||
: undefined
|
||||
}
|
||||
onSubmit={handleUpdateReply}
|
||||
|
||||
@@ -435,9 +435,9 @@ const MessageList: React.FC<MessageListProps> = () => {
|
||||
|
||||
try {
|
||||
result = await getMessageList({
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
// ⭐ 处理数据结构,提取实际的列表数据
|
||||
let actualData = result;
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
# 会话列表预览消息规则
|
||||
|
||||
> **文档说明**:详细记录会话列表中消息预览的格式化规则和处理逻辑(框架无关,适用于 React/Vue 项目改造)
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [核心概述](#核心概述)
|
||||
- [数据来源](#数据来源)
|
||||
- [处理流程](#处理流程)
|
||||
- [规则详解](#规则详解)
|
||||
- [与旧项目对比](#与旧项目对比)
|
||||
- [代码实现](#代码实现)
|
||||
- [测试用例](#测试用例)
|
||||
|
||||
---
|
||||
|
||||
## 核心概述
|
||||
|
||||
### 基本信息
|
||||
|
||||
| 项目 | 说明 |
|
||||
| ------------ | ----------------------------------------------------------------- |
|
||||
| **工具函数** | `formatMessagePreview()` / `messageFilter()` |
|
||||
| **文件位置** | `utils/messagePreview.ts`(新项目)或 `utils/filter.ts`(旧项目) |
|
||||
| **使用场景** | 会话列表消息预览、通知预览、消息摘要 |
|
||||
| **数据来源** | `session.latestMessage.content` 或 `session.content` |
|
||||
| **返回类型** | `string`(永远不会返回空值) |
|
||||
| **框架支持** | ✅ React、Vue、Angular、原生 JS 等 |
|
||||
|
||||
### 设计原则
|
||||
|
||||
1. **兜底处理**:所有异常情况都有友好提示,不会显示原始错误
|
||||
2. **优先级明确**:按照消息类型的匹配优先级依次判断
|
||||
3. **长度限制**:文本消息最多显示50个字符,超出部分显示省略号
|
||||
4. **特殊符号**:富媒体消息使用中括号包裹,如 `[图片]`、`[视频]`
|
||||
5. **兼容性强**:处理 JSON 不完整、XML 截断等边界情况
|
||||
|
||||
---
|
||||
|
||||
## 数据来源
|
||||
|
||||
### 会话列表数据字段
|
||||
|
||||
在会话列表中,预览消息的数据来源字段:
|
||||
|
||||
| 字段 | 说明 | 优先级 |
|
||||
| ------------------------------- | ------------ | -------- |
|
||||
| `session.latestMessage.content` | 最新消息内容 | 优先使用 |
|
||||
| `session.content` | 会话缓存内容 | 兜底字段 |
|
||||
|
||||
### 调用示例
|
||||
|
||||
**新项目(Vue)**:
|
||||
|
||||
```typescript
|
||||
const previewText = formatMessagePreview(
|
||||
session?.latestMessage?.content || session?.content
|
||||
)
|
||||
```
|
||||
|
||||
**旧项目(React)**:
|
||||
|
||||
```typescript
|
||||
const previewText = messageFilter(session.content)
|
||||
```
|
||||
|
||||
### 函数签名
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 格式化消息预览内容
|
||||
* @param content 原始消息内容
|
||||
* @returns 格式化后的预览文本
|
||||
*/
|
||||
function formatMessagePreview(content: string | null | undefined): string
|
||||
```
|
||||
|
||||
### 使用说明
|
||||
|
||||
- ✅ **框架无关**:可用于 React、Vue、Angular 等任何框架
|
||||
- ✅ **输入类型**:`string | null | undefined`
|
||||
- ✅ **输出类型**:`string`(永远不会返回空值)
|
||||
- ✅ **使用场景**:会话列表预览、通知预览、消息摘要等
|
||||
|
||||
---
|
||||
|
||||
## 处理流程
|
||||
|
||||
### 流程图
|
||||
|
||||
```
|
||||
输入 content
|
||||
↓
|
||||
① 空值检查 → null/undefined/空字符串 → "暂无消息"
|
||||
↓
|
||||
② 阿里云OSS链接检查 → 匹配到 → 根据扩展名返回 [图片]/[视频]/[音频]
|
||||
↓
|
||||
③ JSON解析尝试
|
||||
├─ 成功
|
||||
│ ├─ 小程序消息 → "[小程序消息]"
|
||||
│ ├─ JSON中包含OSS链接 → 根据扩展名返回
|
||||
│ ├─ contentXml提取title → 显示title(最多50字符)
|
||||
│ ├─ JSON过长(>500字符) → "[文本过长]"
|
||||
│ ├─ JSON.title字段 → 显示title(最多50字符)
|
||||
│ ├─ JSON.content字段 → 显示content(最多50字符)
|
||||
│ └─ 无法识别 → "[消息]"
|
||||
└─ 失败(非JSON)
|
||||
↓
|
||||
④ 普通HTTP链接检查
|
||||
├─ 图片扩展名 → "[图片]"
|
||||
├─ 视频扩展名 → "[视频]"
|
||||
├─ 音频扩展名 → "[音频]"
|
||||
└─ 其他链接 → "[链接]"
|
||||
↓
|
||||
⑤ XML字符串检查 → 提取title或返回"[文本过长]"
|
||||
↓
|
||||
⑥ 普通文本 → 显示文本(最多50字符)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 规则详解
|
||||
|
||||
### 1️⃣ 空值处理
|
||||
|
||||
```typescript
|
||||
if (!content || typeof content !== 'string') {
|
||||
return '暂无消息'
|
||||
}
|
||||
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed) {
|
||||
return '暂无消息'
|
||||
}
|
||||
```
|
||||
|
||||
**处理情况**:
|
||||
|
||||
- `null`、`undefined`
|
||||
- 非字符串类型
|
||||
- 空字符串或纯空白字符
|
||||
|
||||
**返回结果**:`"暂无消息"`
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ 阿里云 OSS 链接识别
|
||||
|
||||
#### OSS 前缀
|
||||
|
||||
```typescript
|
||||
const ALIYUN_OSS_PREFIX = 'https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com'
|
||||
```
|
||||
|
||||
#### 文件类型判断
|
||||
|
||||
| 类型 | 扩展名 | 返回值 |
|
||||
| -------- | --------------------------------------------------------------- | -------- |
|
||||
| **图片** | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.bmp`, `.svg` | `[图片]` |
|
||||
| **视频** | `.mp4`, `.avi`, `.mov`, `.wmv`, `.flv`, `.mkv`, `.webm`, `.m4v` | `[视频]` |
|
||||
| **音频** | `.mp3`, `.wav`, `.wma`, `.flac`, `.aac`, `.ogg`, `.m4a` | `[音频]` |
|
||||
|
||||
#### 示例
|
||||
|
||||
**输入**:
|
||||
|
||||
```
|
||||
https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/weremote/chat-logs/xxx/9160773596410687940.jpg
|
||||
```
|
||||
|
||||
**输出**:`[图片]`
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ JSON 格式消息
|
||||
|
||||
#### 3.1 小程序消息
|
||||
|
||||
**识别特征**(满足任一条件):
|
||||
|
||||
1. `contentXml` 包含 `<appmsg` 和 `appid`
|
||||
2. `type === "miniprogram"`
|
||||
3. 存在 `weappinfo` 或 `weappInfo` 对象
|
||||
|
||||
**返回结果**:`[小程序消息]`
|
||||
|
||||
**示例输入**:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentXml": "<msg><appmsg appid=\"wx123456\">...</appmsg></msg>",
|
||||
"type": "miniprogram"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3.2 JSON 中包含 OSS 链接
|
||||
|
||||
递归遍历 JSON 所有字段,查找包含 OSS 前缀的链接:
|
||||
|
||||
```typescript
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"previewImage": "https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/.../image.png",
|
||||
"type": "image"
|
||||
}
|
||||
```
|
||||
|
||||
**返回**:`[图片]`
|
||||
|
||||
---
|
||||
|
||||
#### 3.3 从 contentXml 提取 title
|
||||
|
||||
**匹配规则**:
|
||||
|
||||
```typescript
|
||||
const titleMatch = xmlString.match(
|
||||
/<title>([^<]*(?:<!\[CDATA\[[^\]]*\]\]>[^<]*)*)<\/title>/i
|
||||
)
|
||||
```
|
||||
|
||||
**处理步骤**:
|
||||
|
||||
1. 匹配 `<title>...</title>` 标签
|
||||
2. 处理 CDATA:`<![CDATA[文本]]>` → `文本`
|
||||
3. 去除首尾空白
|
||||
4. 限制长度为 50 字符
|
||||
|
||||
**示例输入**:
|
||||
|
||||
```xml
|
||||
<title><![CDATA[超值预售!抢26年经济师《蓝宝典4.0》]]></title>
|
||||
```
|
||||
|
||||
**返回**:`超值预售!抢26年经济师《蓝宝典4.0》`
|
||||
|
||||
---
|
||||
|
||||
#### 3.4 JSON 过长处理
|
||||
|
||||
**触发条件**:JSON 字符串长度 > 500 字符
|
||||
|
||||
**处理逻辑**:
|
||||
|
||||
1. 尝试从 XML 中提取 title
|
||||
2. 提取成功 → 显示 title(最多50字符)
|
||||
3. 提取失败 → 返回 `[文本过长]`
|
||||
|
||||
---
|
||||
|
||||
#### 3.5 提取 JSON 字段
|
||||
|
||||
**字段优先级**:
|
||||
|
||||
| 优先级 | 字段名 | 处理 |
|
||||
| ------ | --------- | ------------------------------- |
|
||||
| 1 | `title` | 显示 title 内容(最多50字符) |
|
||||
| 2 | `content` | 显示 content 内容(最多50字符) |
|
||||
| 3 | 无匹配 | 返回 `[消息]` |
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ 普通 HTTP 链接
|
||||
|
||||
**匹配规则**:`/^https?:\/\//i`
|
||||
|
||||
| 链接类型 | 扩展名匹配 | 返回值 |
|
||||
| -------- | --------------- | -------- |
|
||||
| 图片链接 | IMAGE_EXT_REGEX | `[图片]` |
|
||||
| 视频链接 | VIDEO_EXT_REGEX | `[视频]` |
|
||||
| 音频链接 | AUDIO_EXT_REGEX | `[音频]` |
|
||||
| 其他链接 | - | `[链接]` |
|
||||
|
||||
**示例**:
|
||||
|
||||
```
|
||||
https://example.com/video.mp4 → [视频]
|
||||
https://example.com/page.html → [链接]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ XML 字符串
|
||||
|
||||
**识别特征**(满足任一条件):
|
||||
|
||||
- 包含 `<?xml`
|
||||
- 包含 `<msg>`
|
||||
- 包含 `<appmsg`
|
||||
|
||||
**处理逻辑**:
|
||||
|
||||
1. 尝试提取 `<title>` 标签内容
|
||||
2. 成功 → 显示 title(最多50字符)
|
||||
3. 失败 → 返回 `[文本过长]`
|
||||
|
||||
---
|
||||
|
||||
### 6️⃣ 普通文本消息
|
||||
|
||||
**处理规则**:
|
||||
|
||||
- 最大长度:50 字符
|
||||
- 超出部分:截断并添加 `...`
|
||||
- 不做任何格式转换
|
||||
|
||||
**示例**:
|
||||
|
||||
```typescript
|
||||
输入: '先生上的飞机啊立刻搭街坊拉萨,这是一条很长的消息内容,超过了五十个字符的限制'
|
||||
输出: '先生上的飞机啊立刻搭街坊拉萨,这是一条很长的消息内容,超过了五...'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 与旧项目对比
|
||||
|
||||
### 旧项目实现(messageFilter)
|
||||
|
||||
旧项目使用 `messageFilter()` 函数(位于 `old/src/utils/filter.ts`):
|
||||
|
||||
```typescript
|
||||
export const messageFilter = (message: string) => {
|
||||
if (!message) return ''
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message)
|
||||
|
||||
switch (true) {
|
||||
case !!(parsed.previewImage || parsed.tencentUrl):
|
||||
return '[图片]'
|
||||
case !!(parsed.videoUrl || parsed.video):
|
||||
return '[视频]'
|
||||
case !!(
|
||||
parsed.voiceUrl ||
|
||||
parsed.voice ||
|
||||
(parsed.url && parsed.durationMs)
|
||||
):
|
||||
return parsed.text ? `[语音] ${parsed.text}` : '[语音]'
|
||||
// ... 其他判断
|
||||
}
|
||||
} catch {
|
||||
return message.length > 30 ? message.substring(0, 30) + '...' : message
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 核心差异
|
||||
|
||||
| 对比项 | 旧项目 | 新项目 | 优势对比 |
|
||||
| ---------------- | --------------------------------------------- | ------------------------------------------ | ------------------------ |
|
||||
| **JSON字段判断** | 硬编码字段名(如 `previewImage`, `videoUrl`) | 动态查找 OSS 链接 + 字段提取 | 新项目更灵活,兼容性更好 |
|
||||
| **小程序识别** | 无专门处理 | 多维度识别(`appid`、`type`、`weappinfo`) | 新项目识别更准确 |
|
||||
| **XML处理** | 无专门处理 | 提取 `<title>` 标签显示有意义内容 | 新项目用户体验更好 |
|
||||
| **长度限制** | 30 字符 | 50 字符 | 新项目显示更多信息 |
|
||||
| **截断处理** | JSON 被截断时显示原始 JSON | 尝试提取 title 或标记 `[文本过长]` | 新项目更优雅 |
|
||||
| **OSS 链接** | 无专门处理 | 递归查找 JSON 中的 OSS 链接 | 新项目支持嵌套结构 |
|
||||
|
||||
### 新项目优势
|
||||
|
||||
✅ **更强大的 XML 解析**:能从复杂的 `contentXml` 中提取 title
|
||||
✅ **递归查找 OSS 链接**:支持深层嵌套的 JSON 结构
|
||||
✅ **小程序消息识别**:多维度判断,更准确
|
||||
✅ **优雅的边界处理**:JSON 不完整、XML 截断都有友好提示
|
||||
✅ **更长的文本预览**:50字符 vs 30字符
|
||||
|
||||
---
|
||||
|
||||
## 代码实现
|
||||
|
||||
### 核心函数(完整实现)
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 消息预览格式化工具
|
||||
* 用于会话列表中显示消息预览,参考 content数据实例.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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### 1. 空值测试
|
||||
|
||||
| 输入 | 输出 |
|
||||
| ----------- | ---------- |
|
||||
| `null` | `暂无消息` |
|
||||
| `undefined` | `暂无消息` |
|
||||
| `""` | `暂无消息` |
|
||||
| `" "` | `暂无消息` |
|
||||
|
||||
---
|
||||
|
||||
### 2. 阿里云 OSS 链接
|
||||
|
||||
| 输入 | 输出 |
|
||||
| ------------------------------------------------------------------ | -------- |
|
||||
| `https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/.../file.jpg` | `[图片]` |
|
||||
| `https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/.../file.mp4` | `[视频]` |
|
||||
| `https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/.../file.mp3` | `[音频]` |
|
||||
|
||||
---
|
||||
|
||||
### 3. 小程序消息
|
||||
|
||||
**输入**:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentXml": "<msg><appmsg appid=\"wx123\">...</appmsg></msg>",
|
||||
"type": "miniprogram"
|
||||
}
|
||||
```
|
||||
|
||||
**输出**:`[小程序消息]`
|
||||
|
||||
---
|
||||
|
||||
### 4. JSON 嵌套 OSS 链接
|
||||
|
||||
**输入**:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"media": {
|
||||
"url": "https://ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com/.../image.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**输出**:`[图片]`
|
||||
|
||||
---
|
||||
|
||||
### 5. XML 提取 title
|
||||
|
||||
**输入**:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentXml": "<msg><title><![CDATA[1kg/瓶【美味可口】海天上等蚝油]]></title></msg>"
|
||||
}
|
||||
```
|
||||
|
||||
**输出**:`1kg/瓶【美味可口】海天上等蚝油`
|
||||
|
||||
---
|
||||
|
||||
### 6. JSON 过长
|
||||
|
||||
**输入**:长度 > 500 字符的 JSON,且无 title
|
||||
|
||||
**输出**:`[文本过长]`
|
||||
|
||||
---
|
||||
|
||||
### 7. 普通 HTTP 链接
|
||||
|
||||
| 输入 | 输出 |
|
||||
| ------------------------------- | -------- |
|
||||
| `https://example.com/image.jpg` | `[图片]` |
|
||||
| `https://example.com/video.mp4` | `[视频]` |
|
||||
| `https://example.com/page.html` | `[链接]` |
|
||||
|
||||
---
|
||||
|
||||
### 8. 纯文本
|
||||
|
||||
| 输入 | 输出 |
|
||||
| ----------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||
| `"你好"` | `你好` |
|
||||
| `"这是一条很长的消息,超过了五十个字符的限制,需要被截断处理..."` | `这是一条很长的消息,超过了五十个字符的限制,需要被截断处理...` |
|
||||
|
||||
---
|
||||
|
||||
## 📌 注意事项
|
||||
|
||||
### 1. 性能优化
|
||||
|
||||
- ✅ **正则表达式**:所有正则都定义在模块顶层,避免重复编译
|
||||
- ✅ **递归查找**:`findAliyunOssLink` 找到第一个匹配后立即返回
|
||||
- ✅ **提前返回**:每个判断成功后立即返回,减少不必要的计算
|
||||
|
||||
### 2. 数据兼容性
|
||||
|
||||
- ✅ **JSON 不完整**:解析失败时走 XML 或文本处理流程
|
||||
- ✅ **XML 截断**:无法提取 title 时返回 `[文本过长]`
|
||||
- ✅ **嵌套结构**:递归查找支持任意深度的 JSON 嵌套
|
||||
|
||||
### 3. 用户体验
|
||||
|
||||
- ✅ **友好提示**:所有异常情况都有清晰的中文提示
|
||||
- ✅ **信息优先**:优先显示有意义的 title/content,而非 `[消息]`
|
||||
- ✅ **长度控制**:50字符刚好能显示完整语义,又不会过长
|
||||
|
||||
### 4. 扩展性
|
||||
|
||||
如需添加新的消息类型识别:
|
||||
|
||||
1. 在 `formatMessagePreview` 函数中添加新的判断分支
|
||||
2. 遵循现有的优先级顺序(从特殊到一般)
|
||||
3. 确保有兜底的返回值
|
||||
|
||||
---
|
||||
|
||||
## 📝 变更记录
|
||||
|
||||
| 日期 | 版本 | 变更内容 |
|
||||
| ---------- | ---- | -------------------------------- |
|
||||
| 2026-01-16 | v1.0 | 创建文档,记录新项目消息预览规则 |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [content数据实例.md](./content数据实例.md) - 消息内容格式说明
|
||||
- [开发日志.md](./开发日志.md) - 项目开发记录
|
||||
- [会话列表排序优化实施总结.md](./会话列表排序优化实施总结.md) - 会话列表优化说明
|
||||
|
||||
---
|
||||
|
||||
**📌 提示**:本文档基于 `src/utils/messagePreview.ts` 实现编写,与实际代码保持同步。
|
||||
Reference in New Issue
Block a user