取消<a>原生链接处理,统一在弹窗中点击下载;将消息详情弹窗中链接包装成文件名,并取消原文展示。

This commit is contained in:
2026-07-21 15:32:36 +08:00
parent d5d9423599
commit 9ace485cbd
3 changed files with 408 additions and 206 deletions

View File

@@ -87,7 +87,7 @@
</view>
<view class="chat-content"
:class="{'chat-content-user':message.role==='user', 'chat-content-assistant':message.role==='assistant'}"
@click="showMessageDetail(message)"
@click="handleChatContentClick($event, message)"
v-if="(message.content && String(message.content).trim() !== '') || message._streaming">
<!-- 流式加载中显示跳动的点 -->
<view
@@ -134,7 +134,8 @@
<!-- 没有头像时显示默认图标 -->
<view v-else class="iconfont icon-yonghuziliao"></view>
</view>
<view class="chat-content" :class="{'chat-content-user':message.sender === UserId}">
<view class="chat-content" :class="{'chat-content-user':message.sender === UserId}"
@click="handleChatContentClick($event, message)">
<view v-if="message.content && String(message.content).trim() !== ''"
v-html="pareseMarkdown(message.content)"></view>
<!-- 2. 图片 + 文件 渲染核心新增 -->
@@ -172,7 +173,8 @@
<!-- 没有头像时显示默认图标 -->
<view v-else class="iconfont icon-yonghuziliao"></view>
</view>
<view class="chat-content" :class="{'chat-content-user':message.sender === UserId}">
<view class="chat-content" :class="{'chat-content-user':message.sender === UserId}"
@click="handleChatContentClick($event, message)">
<view v-if="message.message && String(message.message).trim() !== ''"
v-html="pareseMarkdown(message.message)"></view>
<!-- 2. 图片 + 文件 渲染核心新增 -->
@@ -240,13 +242,16 @@
<uni-icons type="closeempty" color="#ff0000" size="24" @click="closeMessageDetail"></uni-icons>
</view>
<scroll-view class="message-detail-content" scroll-y>
<text>{{ detailMessageContent }}</text>
<view v-if="detailLinks.length" class="detail-links-section">
<view class="detail-links-title">链接 ({{ detailLinks.length }})</view>
<view v-for="(link, i) in detailLinks" :key="i" class="detail-link-item" @click="openLink(link)">
<text class="link-text">{{ link }}</text>
<view class="detail-links-title">文件 ({{ detailLinks.length }})</view>
<view v-for="(link, i) in detailLinks" :key="i" class="detail-link-item"
@click="openLink(link.url)">
<text class="link-text">{{ link.name }}</text>
</view>
</view>
<view v-else class="detail-empty">
<text>无可下载文件</text>
</view>
</scroll-view>
</view>
</view>
@@ -439,7 +444,12 @@
content = escapeLoneUnderscores(content)
// 先转换表格,再用 snarkdown 处理其他 Markdown
content = convertMarkdownTable(content)
const html = snarkdown(content)
let html = snarkdown(content)
// 将 <a href="..."> 替换为自定义 span避免 WebView 触发默认下载
html = html.replace(
/<a\s+href="([^"]*)"[^>]*>(.*?)<\/a>/gi,
'<span class="chat-inline-link" data-url="$1">$2</span>'
)
return html
} catch (e) {
console.error('解析失败', e)
@@ -708,38 +718,117 @@
// 消息详情弹窗
const showMessageModal = ref(false)
const detailMessageContent = ref('')
const detailLinks = ref([])
// 点击气泡内容:拦截链接点击,否则 AI 对话弹出详情
const handleChatContentClick = (e, message) => {
// 在事件路径中向上查找 .chat-inline-link 元素
let el = e.target
while (el && el.classList) {
if (el.classList.contains('chat-inline-link')) {
const url = el.getAttribute('data-url') || (el.dataset && el.dataset.url)
if (url) {
openLink(url)
}
return
}
el = el.parentElement
}
// 非链接点击:仅 AI 对话弹出详情窗
if (message.role) {
showMessageDetail(message)
}
}
const extractLinks = (text) => {
const regex = /https?:\/\/[^\s<>"{}|\\^`\[\]()]+/gi
const matches = text.match(regex)
return matches ? [...new Set(matches)] : []
const seen = new Set()
const result = []
// 1. 匹配 Markdown 链接 [文件名](url)
const mdLinkRegex = /\[([^\]]*)\]\((https?:\/\/[^\s<>"{}|\\^`\[\]()]+)\)/gi
const mdLinks = [...text.matchAll(mdLinkRegex)]
mdLinks.forEach(m => {
const name = m[1].trim()
const url = m[2]
if (!seen.has(url)) {
seen.add(url)
result.push({
url,
name: name || url
})
}
})
// 2. 去除已匹配的 [xxx](url) 后,再搜裸 URL
let cleaned = text.replace(mdLinkRegex, '')
const bareRegex = /https?:\/\/[^\s<>"{}|\\^`\[\]()]+/gi
const bareMatches = cleaned.match(bareRegex)
if (bareMatches) {
bareMatches.forEach(url => {
if (!seen.has(url)) {
seen.add(url)
result.push({
url,
name: extractFileNameFromUrl(url)
})
}
})
}
return result
}
const extractFileNameFromUrl = (urlStr) => {
try {
const segments = new URL(urlStr).pathname.split('/').filter(s => s)
if (segments.length > 0) {
return decodeURIComponent(segments[segments.length - 1])
}
} catch (e) {
/* fallback */
}
const parts = urlStr.split('/').filter(s => s)
if (parts.length > 0) {
const last = parts[parts.length - 1]
const qIdx = last.indexOf('?')
return qIdx > -1 ? last.substring(0, qIdx) : last
}
return urlStr
}
const showMessageDetail = (message) => {
detailMessageContent.value = message.content || ''
detailLinks.value = extractLinks(detailMessageContent.value)
detailLinks.value = extractLinks(message.content || '')
showMessageModal.value = true
}
const closeMessageDetail = () => {
showMessageModal.value = false
detailMessageContent.value = ''
detailLinks.value = []
}
const downloadToast = ref({ show: false, type: 'loading', message: '' })
const downloadToast = ref({
show: false,
type: 'loading',
message: ''
})
let downloadToastTimer = null
const showDownloadToast = (type, message, duration) => {
if (downloadToastTimer) clearTimeout(downloadToastTimer)
downloadToast.value = { show: true, type, message }
downloadToast.value = {
show: true,
type,
message
}
if (duration > 0) {
downloadToastTimer = setTimeout(() => {
downloadToast.value = { show: false, type: 'loading', message: '' }
downloadToast.value = {
show: false,
type: 'loading',
message: ''
}
}, duration)
}
}
const hideDownloadToast = () => {
if (downloadToastTimer) clearTimeout(downloadToastTimer)
downloadToast.value = { show: false, type: 'loading', message: '' }
downloadToast.value = {
show: false,
type: 'loading',
message: ''
}
}
const openLink = (url) => {
@@ -1458,6 +1547,13 @@
line-height: 1.6;
margin: 8rpx 0;
}
/* 气泡内的内联链接(替代 <a> 标签,点击由 JS 拦截处理) */
.chat-inline-link {
color: #3b86ff !important;
text-decoration: underline;
word-break: break-all;
}
}
/* 流式气泡中的加载动画 */
@@ -1526,6 +1622,15 @@
word-break: break-all;
}
.detail-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 0;
font-size: 28rpx;
color: #999;
}
/* ========== 自定义下载 Toast层级高于 ncd-overlay 的 9999========== */
.download-toast-overlay {
position: fixed;
@@ -1554,8 +1659,15 @@
}
@keyframes toastFadeIn {
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
from {
opacity: 0;
transform: scale(0.85);
}
to {
opacity: 1;
transform: scale(1);
}
}
.download-toast-loading {
@@ -1590,12 +1702,26 @@
animation: toastDotBlink 1.2s infinite ease-in-out;
}
.toast-dot:nth-child(2) { animation-delay: 0.3s; }
.toast-dot:nth-child(3) { animation-delay: 0.6s; }
.toast-dot:nth-child(2) {
animation-delay: 0.3s;
}
.toast-dot:nth-child(3) {
animation-delay: 0.6s;
}
@keyframes toastDotBlink {
0%, 100% { opacity: 0.3; transform: scale(0.75); }
50% { opacity: 1; transform: scale(1.2); }
0%,
100% {
opacity: 0.3;
transform: scale(0.75);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
.download-toast-text {
@@ -1603,6 +1729,4 @@
color: #fff;
font-weight: 500;
}
</style>