feat(chat): 将结构化消息解析逻辑从页面组件提取到工具函数中,提高代码复用性。

This commit is contained in:
yangzhe 2026-03-20 17:09:23 +08:00
parent 3e6016fa47
commit 731989eaac
3 changed files with 295 additions and 84 deletions

View File

@ -157,8 +157,56 @@
:src="receiverHeadSculptureUrl"
mode="scaleToFill"
/>
<view class="message-content">
<text>{{ message.message }}</text>
<view
class="message-content"
:class="{
'message-content-structured':
getStructuredMessageBlocks(message).length,
}"
>
<view
v-if="getStructuredMessageBlocks(message).length"
class="structured-message"
>
<view
v-for="(block, blockIndex) in getStructuredMessageBlocks(
message,
)"
:key="`${message.id || index}-block-${blockIndex}`"
class="structured-item"
:class="`structured-item-${block.type}`"
>
<text v-if="block.type === 'text'" class="structured-text">
{{ block.value }}
</text>
<image
v-else-if="block.type === 'image'"
class="structured-image"
:src="block.url"
mode="widthFix"
@click="previewMessageImage(block.url)"
></image>
<view
v-else-if="block.type === 'file'"
class="structured-file"
@click="downloadMessageFile(block.url)"
>
<image
class="structured-file-icon"
src="/static/common/images/icon-file.png"
mode="scaleToFill"
></image>
<text class="structured-file-name">{{ block.name }}</text>
<u-icon
name="download"
size="36"
color="#666666"
class="structured-file-download"
></u-icon>
</view>
</view>
</view>
<markdown-viewer v-else :content="message.message" />
</view>
</view>
</view>
@ -194,9 +242,11 @@
</template>
<script>
import HeaderBar from "@/components/HeaderBar.vue"; //
import HeaderBar from "@/components/HeaderBar.vue";
import MarkdownViewer from "@/components/markdown-viewer/markdown-viewer";
import {
formatChatShowTime,
parseStructuredMessageBlocks,
scrollToBottomByContentHeight,
shouldShowTime,
} from "@/utils/chat.js";
@ -204,7 +254,8 @@ import {
export default {
name: "ChatDetail",
components: {
HeaderBar, //
HeaderBar,
MarkdownViewer,
},
data() {
return {
@ -292,6 +343,27 @@ export default {
},
methods: {
//
getStructuredMessageBlocks(message) {
return parseStructuredMessageBlocks(message, this.baseUrl);
},
previewMessageImage(url) {
if (!url) return;
uni.previewImage({
current: url,
urls: [url],
});
},
downloadMessageFile(url) {
if (!url) return;
if (typeof window !== "undefined" && window.open) {
window.open(url, "_blank");
return;
}
uni.downloadFile({
url,
});
},
//
handleLeftClick() {
uni.navigateBack();
@ -621,6 +693,117 @@ export default {
border-radius: 0 16rpx 16rpx 16rpx;
font-size: 28rpx;
line-height: 1.5;
&.message-content-structured {
background-color: transparent;
padding: 0;
border-radius: 0;
}
/deep/ .markdown-container {
padding: 0;
p {
margin: 0;
padding: 0;
}
pre {
max-width: 100%;
overflow-x: auto;
}
img {
max-width: 100%;
}
table {
font-size: 24rpx;
}
}
.structured-message {
.structured-item + .structured-item {
margin-top: 16rpx;
}
.structured-item {
max-width: 100%;
}
.structured-item-text {
.structured-text {
display: inline-block;
background-color: #ffffff;
color: #333333;
font-size: 28rpx;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
padding: 20rpx 24rpx;
border-radius: 0 16rpx 16rpx 16rpx;
}
}
.structured-text {
display: inline-block;
color: #333333;
font-size: 28rpx;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.structured-item-image {
.structured-image {
display: block;
max-width: 240rpx;
border-radius: 12rpx;
}
}
.structured-image {
display: block;
max-width: 240rpx;
border-radius: 12rpx;
}
.structured-item-file {
.structured-file {
display: flex;
align-items: center;
width: 500rpx;
max-width: 100%;
padding: 24rpx;
background-color: #ffffff;
border-radius: 16rpx;
box-sizing: border-box;
.structured-file-icon {
width: 48rpx;
height: 62rpx;
flex-shrink: 0;
margin-right: 20rpx;
}
.structured-file-name {
flex: 1;
font-size: 28rpx;
color: #333333;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
.structured-file-download {
flex-shrink: 0;
margin-left: 20rpx;
}
}
}
}
}
}

View File

@ -514,6 +514,7 @@ import PerfectInfo from "@/components/PerfectInfo.vue";
import ChatHistory from "@/components/ChatHistory.vue"; //
import HeaderBar from "@/components/HeaderBar.vue"; //
import {
parseStructuredMessageBlocks,
processChatMessageContent,
scrollToBottomByContentHeight,
sortChatMessages,
@ -1299,86 +1300,7 @@ export default {
//
getStructuredMessageBlocks(message) {
const rawMessage =
(message && (message.rawMessage || message.message)) || "";
if (!rawMessage || typeof rawMessage !== "string") return [];
let parsed = null;
try {
parsed = JSON.parse(rawMessage);
} catch (error) {
return [];
}
if (!parsed || typeof parsed !== "object") return [];
const answerTypeLabel = (parsed.answerTypeLabel || "").trim();
const imageUrl = parsed.imageUrl || "";
const nearbyPaths = parsed.nearbyPaths || "";
if (!answerTypeLabel && !imageUrl && !nearbyPaths) return [];
const blocks = [];
if (answerTypeLabel) {
blocks.push({ type: "text", value: answerTypeLabel });
}
this.normalizeResourceList(imageUrl).forEach((item) => {
const url = this.formatMessageResourceUrl(item);
if (url) blocks.push({ type: "image", url });
});
this.normalizeResourceList(nearbyPaths).forEach((item) => {
const url = this.formatMessageResourceUrl(item);
if (!url) return;
blocks.push({
type: "file",
url,
name: this.getFileNameFromPath(item),
});
});
return blocks;
},
normalizeResourceList(value) {
if (!value) return [];
if (Array.isArray(value)) {
return value.map((item) => String(item || "").trim()).filter(Boolean);
}
return String(value)
.split(/[\n,|]/)
.map((item) => item.trim())
.filter(Boolean);
},
formatMessageResourceUrl(path) {
if (!path) return "";
const currentPath = String(path).trim();
if (!currentPath) return "";
if (
currentPath.startsWith("http://") ||
currentPath.startsWith("https://") ||
currentPath.startsWith("blob:")
) {
return currentPath;
}
const cleanPath = currentPath.replace(/\\/g, "/");
const cleanBaseUrl = (this.baseUrl || "")
.replace(/\\/g, "/")
.replace(/\/$/, "");
if (!cleanBaseUrl) return cleanPath;
if (cleanPath.startsWith("/")) return `${cleanBaseUrl}${cleanPath}`;
return `${cleanBaseUrl}/${cleanPath}`;
},
getFileNameFromPath(path) {
const cleanPath = String(path || "")
.replace(/\\/g, "/")
.split("?")[0];
const segments = cleanPath.split("/");
const fileName = segments[segments.length - 1] || "下载文件";
try {
return decodeURIComponent(fileName);
} catch (error) {
return fileName;
}
return parseStructuredMessageBlocks(message, this.baseUrl);
},
previewMessageImage(url) {
if (!url) return;

View File

@ -214,3 +214,109 @@ export function formatHHmm(date) {
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
/**
* 规范资源列表将字符串或数组转换为trimmed非空字符串数组
*
* @param {string|Array<string>} value - 原始资源列表
* @returns {Array<string>} - 规范后的资源数组
*/
function normalizeResourceList(value) {
if (!value) return [];
if (Array.isArray(value)) {
return value.map((item) => String(item || "").trim()).filter(Boolean);
}
return String(value)
.split(/[\n,|]/)
.map((item) => item.trim())
.filter(Boolean);
}
/**
* 格式化消息资源URL若URL已完整包含协议头或blob URI则直接返回否则拼接基础URL
*
* @param {string} path - 原始资源路径
* @param {string} baseUrl - 基础URL可选
* @returns {string} - 格式化后的URL
*/
function formatMessageResourceUrl(path, baseUrl) {
if (!path) return "";
const currentPath = String(path).trim();
if (!currentPath) return "";
if (
currentPath.startsWith("http://") ||
currentPath.startsWith("https://") ||
currentPath.startsWith("blob:")
) {
return currentPath;
}
const cleanPath = currentPath.replace(/\\/g, "/");
const cleanBaseUrl = String(baseUrl || "")
.replace(/\\/g, "/")
.replace(/\/$/, "");
if (!cleanBaseUrl) return cleanPath;
if (cleanPath.startsWith("/")) return `${cleanBaseUrl}${cleanPath}`;
return `${cleanBaseUrl}/${cleanPath}`;
}
function getFileNameFromPath(path) {
const cleanPath = String(path || "")
.replace(/\\/g, "/")
.split("?")[0];
const segments = cleanPath.split("/");
const fileName = segments[segments.length - 1] || "下载文件";
try {
return decodeURIComponent(fileName);
} catch (error) {
return fileName;
}
}
/**
* 解析结构化消息块从JSON字符串中提取文本图片文件资源
*
* @param {string} message - 原始消息字符串包含JSON格式
* @param {string} baseUrl - 基础URL可选用于格式化资源URL
* @returns {Array<{type: string, value?: string, url?: string, name?: string}>} - 解析后的消息块数组
*/
export function parseStructuredMessageBlocks(message, baseUrl) {
const rawMessage =
(message && (message.rawMessage || message.message)) || "";
if (!rawMessage || typeof rawMessage !== "string") return [];
let parsed = null;
try {
parsed = JSON.parse(rawMessage);
} catch (error) {
return [];
}
if (!parsed || typeof parsed !== "object") return [];
const answerTypeLabel = (parsed.answerTypeLabel || "").trim();
const imageUrl = parsed.imageUrl || "";
const nearbyPaths = parsed.nearbyPaths || "";
if (!answerTypeLabel && !imageUrl && !nearbyPaths) return [];
const blocks = [];
if (answerTypeLabel) {
blocks.push({ type: "text", value: answerTypeLabel });
}
normalizeResourceList(imageUrl).forEach((item) => {
const url = formatMessageResourceUrl(item, baseUrl);
if (url) blocks.push({ type: "image", url });
});
normalizeResourceList(nearbyPaths).forEach((item) => {
const url = formatMessageResourceUrl(item, baseUrl);
if (!url) return;
blocks.push({
type: "file",
url,
name: getFileNameFromPath(item),
});
});
return blocks;
}