@@ -44,7 +44,11 @@
@touchend ="onBubbleTouchEnd"
@touchcancel ="onBubbleTouchEnd" >
< rich -text v-if = "searchKeyword" :nodes="highlightContent(message.content)" class="message-rich-text" / >
< text v-else class = "message-text" selectable > { { message . content } } < / text >
< text v-else class = "message-text" selectable
@touchstart.stop
@touchmove.stop
@touchend.stop
@touchcancel.stop > {{ message.content }} < / text >
< / view >
< / view >
< view class = "avatar" @tap ="showAvatarSelector = true" >
@@ -78,7 +82,17 @@
@touchcancel ="onBubbleTouchEnd" >
< ! - - 文本消息 - - >
< view v-if = "message.type === 'text'" class="text-content" >
< ! - - 微信小程序 : rich -text 不支持选中文本 , 降级为 text selectable 以支持长按选择部分文字 - - >
<!-- # ifdef MP - WEIXIN -- >
< text class = "ai-message-text" selectable
@touchstart.stop
@touchmove.stop
@touchend.stop
@touchcancel.stop > {{ _stripMarkdown ( message.content ) }} < / text >
< ! - - # endif - - >
<!-- # ifdef H5 -- >
< rich-text :nodes = "getAIDisplayContent(message.content)" / >
<!-- # endif -- >
< / view >
<!-- Markdown文档 -- >
@@ -341,6 +355,41 @@
< / view >
< / view >
< / view >
<!-- 选取文字复制弹窗 : 长按文字后可自由选取任意范围 ( 含单个字符 / 标点 ) 复制 -- >
< view v-if = "showSelectTextModal" class="select-text-popup" >
< view class = "select-text-mask" @click ="closeSelectTextModal" > < / view >
< view class = "select-text-panel" >
< view class = "select-text-header" >
< text > { { $lang ( 'ai-chat.index.selectTextToCopy' , '选取文字复制' ) } } < / text >
< button class = "close-btn" @click ="closeSelectTextModal" >
< text > × < / text >
< / button >
< / view >
< view class = "select-text-tip" > { { $lang ( 'ai-chat.index.selectTextTip' , '长按文字拖动两端手柄,可自由选择复制范围' ) } } < / view >
< textarea
class = "select-text-textarea"
:value = "selectTextValue"
:maxlength = "20000"
:auto-height = "false"
:show-confirm-bar = "false"
:adjust-position = "true"
:focus = "true"
:selection-start = "selectStart"
:selection-end = "selectEnd"
@input ="onTextInput"
@selectionchange ="onTextSelectionChange"
@blur ="onTextBlur" / >
< view class = "select-text-preview" v-if = "selectedPreview" >
< text class = "select-text-preview-label" > { { $lang ( 'ai-chat.index.selectedText' , '已选文字' ) } } : < / text >
< text class = "select-text-preview-content" > { { selectedPreview } } < / text >
< / view >
< view class = "select-text-footer" >
< button class = "select-text-copy-btn" @click ="copySelectedText" > {{ selectedPreview ? $ lang ( ' ai -chat .index.copySelected ' , ' 已复制选中内容 ' ) : $ lang ( ' ai -chat .index.copyAll ' , ' 复制全部 ' ) }} < / button >
< button class = "select-text-close-btn" @click ="closeSelectTextModal" > {{ $ lang ( ' ai -chat .index.cancel ' , ' 关闭 ' ) }} < / button >
< / view >
< / view >
< / view >
< ! - - 键盘垫高层 : 键盘弹出时平滑撑开空间将输入框上推 - - >
< view class = "keyboard-spacer" : style = "{ height: keyboardHeight + 'px' }" > < / view >
@@ -457,6 +506,12 @@ export default {
localUserNickname : '' , // 当前用户昵称
defaultNickname : '' , // 默认昵称
showNicknameEditor : false , // 昵称编辑弹窗控制
// 选取文字复制弹窗
showSelectTextModal : false ,
selectTextValue : '' , // 弹窗内文本域当前内容
selectStart : 0 , // 当前选中起点
selectEnd : 0 , // 当前选中终点
selectedPreview : '' , // 选中文字预览
// AI头像错误状态
avatarError : false ,
// 当前选中的 AI 模型, 显示在输入框左侧(如 Auto)
@@ -1811,7 +1866,7 @@ _handleStreamError(msg) {
. trim ( )
} ,
// 长按消息→直接 复制
// 长按/右键消息 → 弹出操作菜单(复制 / 分享 / 选取文字 复制)
onMessageLongPress ( message ) {
// loading 消息不允许操作
if ( message . type === 'loading' ) return
@@ -1823,7 +1878,164 @@ _handleStreamError(msg) {
} catch ( e ) { /* 不支持震动的设备忽略 */ }
// #endif
this . c opy Message( message )
this . open MessageMenu ( message )
} ,
// 打开消息操作菜单:复制 / 分享 / 选取文字复制
openMessageMenu ( message ) {
const text = message && message . content ? message . content : ''
if ( ! text ) return
// #ifdef H5
// H5 优先使用原生浏览器上下文菜单体验(右键已出系统菜单时避免重复弹层)
// 仍提供底部操作菜单,保持与小程序一致
// #endif
uni . showActionSheet ( {
itemList : [ '复制' , '分享' , this . $lang ( 'ai-chat.index.selectTextToCopy' , '选取文字复制' ) ] ,
success : ( res ) => {
if ( res . tapIndex === 0 ) {
this . copyMessage ( message )
} else if ( res . tapIndex === 1 ) {
this . shareMessage ( message )
} else if ( res . tapIndex === 2 ) {
this . openSelectTextModal ( message )
}
} ,
fail : ( ) => { /* 用户取消 */ }
} )
} ,
// 分享消息内容(兼容微信小程序与 H5)
shareMessage ( message ) {
const text = message && message . content ? message . content : ''
if ( ! text ) return
// #ifdef MP-WEIXIN
// 微信小程序:调起分享功能
try {
wx . showShareMenu ( {
withShareTicket : true ,
menus : [ 'shareAppMessage' , 'shareTimeline' ]
} )
} catch ( e ) {
// 若当前页面未配置分享,降级为提示复制
uni . showToast ( { title : '请长按使用系统分享' , icon : 'none' } )
}
// #endif
// #ifdef H5
// H5: 调用 Web Share API, 降级为复制到剪贴板
if ( navigator . share ) {
navigator . share ( {
title : '聊天记录' ,
text : text
} ) . catch ( ( ) => { /* 用户取消分享 */ } )
} else {
this . copyMessage ( message )
uni . showToast ( { title : '已复制,可直接粘贴分享' , icon : 'none' } )
}
// #endif
} ,
// 打开"选取文字复制"弹窗:长按后可自由选取任意范围(含单个字符/标点)复制
openSelectTextModal ( message ) {
const text = message && message . content ? message . content : ''
if ( ! text ) return
this . selectTextValue = text
this . selectStart = 0
this . selectEnd = 0
this . selectedPreview = ''
this . showSelectTextModal = true
// 下一帧自动全选, 方便用户直接拖动手柄缩小范围( H5 / 微信小程序均适用)
this . $nextTick ( ( ) => {
this . selectStart = 0
this . selectEnd = text . length
// H5: 绑定 textarea DOM 选区变化事件,实时同步选中范围
// #ifdef H5
try {
const el = document . querySelector ( '.select-text-textarea textarea' ) || document . querySelector ( '.select-text-textarea' )
if ( el ) {
const sync = ( ) => {
try {
const s = el . selectionStart || 0
const e = el . selectionEnd || 0
this . selectStart = s
this . selectEnd = e
this . selectedPreview = ( el . value || '' ) . substring ( s , e )
} catch ( err ) { /* noop */ }
}
el . addEventListener ( 'select' , sync )
el . addEventListener ( 'keyup' , sync )
el . addEventListener ( 'mouseup' , sync )
el . addEventListener ( 'touchend' , sync )
el . focus ( )
el . select && el . select ( )
el . setSelectionRange && el . setSelectionRange ( 0 , el . value . length )
sync ( )
}
} catch ( e ) { /* 部分环境不支持 */ }
// #endif
} )
} ,
// 文本域输入:同步内容(用户若误编辑也能拿到最新文本)
onTextInput ( event ) {
const val = ( event && event . detail && event . detail . value ) || ( event && event . target && event . target . value ) || ''
this . selectTextValue = val
} ,
// 微信小程序:文本域选区变化(拖动两端手柄选择范围)
onTextSelectionChange ( event ) {
try {
const detail = event && event . detail
if ( detail ) {
this . selectStart = detail . start || 0
this . selectEnd = detail . end || 0
this . selectedPreview = ( this . selectTextValue || '' ) . substring ( this . selectStart , this . selectEnd )
}
} catch ( e ) { /* noop */ }
} ,
// 失焦时兜底同步一次选区( H5 拖选结束时)
onTextBlur ( event ) {
try {
// #ifdef H5
const el = document . querySelector ( '.select-text-textarea textarea' ) || document . querySelector ( '.select-text-textarea' )
if ( el ) {
const s = el . selectionStart || 0
const e = el . selectionEnd || 0
this . selectStart = s
this . selectEnd = e
this . selectedPreview = ( el . value || '' ) . substring ( s , e )
}
// #endif
} catch ( e ) { /* noop */ }
} ,
// 复制选中范围(未选则复制全部)
copySelectedText ( ) {
const text = this . selectTextValue || ''
if ( ! text ) {
uni . showToast ( { title : this . $lang ( 'ai-chat.index.noContentToCopy' ) , icon : 'none' } )
return
}
const start = Math . min ( this . selectStart , this . selectEnd )
const end = Math . max ( this . selectStart , this . selectEnd )
const selected = start < end ? text . substring ( start , end ) : ''
const copyText = selected || text
this . copyMessage ( { content : copyText , _selected : ! ! selected } )
this . closeSelectTextModal ( )
} ,
// 关闭"选取文字复制"弹窗
closeSelectTextModal ( ) {
this . showSelectTextModal = false
this . selectTextValue = ''
this . selectStart = 0
this . selectEnd = 0
this . selectedPreview = ''
} ,
// 手动长按检测( touch 事件方案,兼容所有平台)
@@ -1866,17 +2078,28 @@ _handleStreamError(msg) {
return
}
// 由"选取文字复制"弹窗传入的已选范围,直接按传入内容复制,不再读取浏览器选区
const isSelected = ! ! message . _selected
// #ifdef H5
// H5: 检测是否有局部文本被选中, 有则复制选区文本, 无则复制全文
const selection = window . getSelection ( )
cons t selectedText = selection && selection . toString ( ) . trim ( )
const finalText = selectedText || copyText
let finalText = copyText
le t selectedText = ''
if ( ! isSelected ) {
try {
const selection = window . getSelection ( )
selectedText = selection && selection . toString ( ) . trim ( )
finalText = selectedText || copyText
} catch ( e ) { /* noop */ }
} else {
selectedText = copyText
}
// H5 优先使用 Clipboard API, 降级使用 document.execCommand
try {
if ( typeof navigator !== 'undefined' && navigator . clipboard && navigator . clipboard . writeText ) {
navigator . clipboard . writeText ( finalText ) . then ( ( ) => {
uni . showToast ( { title : selectedText ? this . $lang ( 'ai-chat.index.copySelected' ) : this . $lang ( 'ai-chat.index.copySuccess' ) , icon : 'success' } )
uni . showToast ( { title : selectedText ? this . $lang ( 'ai-chat.index.copySelected' , '已复制选中内容' ) : this . $lang ( 'ai-chat.index.copySuccess' ) , icon : 'success' } )
} ) . catch ( ( ) => {
this . _fallbackCopyH5 ( finalText )
} )
@@ -1895,7 +2118,7 @@ _handleStreamError(msg) {
data : copyText ,
success : ( ) => {
console . log ( '[AI Chat] 复制成功' )
uni . showToast ( { title : this . $lang ( 'ai-chat.index.copySuccess' ) , icon : 'success' } )
uni . showToast ( { title : isSelected ? this . $lang ( 'ai-chat.index.copySelected' , '已复制选中内容' ) : this . $lang ( 'ai-chat.index.copySuccess' ) , icon : 'success' } )
} ,
fail : ( err ) => {
console . error ( '[AI Chat] 复制失败, 详细错误:' , JSON . stringify ( err ) )
@@ -2536,6 +2759,15 @@ _handleStreamError(msg) {
letter - spacing : 0.3 rpx ;
color : # 1 f1f1f ;
}
// 微信小程序: AI 文本消息降级为 selectable text,支持长按选择部分文字
. ai - message - text {
font - size : 30 rpx ;
line - height : 1.6 ;
color : # 1 f1f1f ;
white - space : pre - wrap ;
word - break : break - word ;
}
}
. message - time {
@@ -3880,7 +4112,152 @@ _handleStreamError(msg) {
}
}
}
/* 选取文字复制弹窗 */
. select - text - popup {
position : fixed ;
top : 0 ;
left : 0 ;
right : 0 ;
bottom : 0 ;
z - index : 1001 ;
display : flex ;
flex - direction : column ;
align - items : center ;
justify - content : flex - end ;
. select - text - mask {
position : absolute ;
top : 0 ;
left : 0 ;
right : 0 ;
bottom : 0 ;
background - color : rgba ( 0 , 0 , 0 , 0.4 ) ;
}
. select - text - panel {
position : relative ;
width : 100 % ;
height : 70 % ;
display : flex ;
flex - direction : column ;
background - color : # fff ;
border - radius : 24 rpx 24 rpx 0 0 ;
overflow : hidden ;
. select - text - header {
display : flex ;
justify - content : space - between ;
align - items : center ;
padding : 24 rpx 32 rpx ;
border - bottom : 1 rpx solid # e5e5e5 ;
background - color : # f9f9f9 ;
text {
font - size : 32 rpx ;
font - weight : 500 ;
color : # 333 ;
}
. close - btn {
width : 64 rpx ;
height : 64 rpx ;
border - radius : 50 % ;
background - color : transparent ;
display : flex ;
align - items : center ;
justify - content : center ;
border : none ;
padding : 0 ;
text {
font - size : 48 rpx ;
color : # 999 ;
line - height : 1 ;
}
}
}
. select - text - tip {
padding : 16 rpx 32 rpx 0 ;
font - size : 24 rpx ;
color : # 999 ;
background - color : # f9f9f9 ;
}
. select - text - textarea {
flex : 1 ;
width : 100 % ;
padding : 32 rpx ;
font - size : 30 rpx ;
line - height : 1.6 ;
color : # 333 ;
background - color : # fff ;
border : none ;
box - sizing : border - box ;
}
. select - text - preview {
max - height : 200 rpx ;
overflow - y : auto ;
padding : 20 rpx 32 rpx ;
border - top : 1 rpx dashed # e5e5e5 ;
background - color : # f6f8fa ;
font - size : 26 rpx ;
color : # 555 ;
word - break : break - all ;
. select - text - preview - label {
color : # 999 ;
margin - right : 8 rpx ;
}
. select - text - preview - content {
color : # 07 c160 ;
font - weight : 500 ;
}
}
. select - text - footer {
display : flex ;
align - items : center ;
justify - content : space - between ;
padding : 24 rpx 32 rpx ;
padding - bottom : calc ( 24 rpx + env ( safe - area - inset - bottom ) ) ;
border - top : 1 rpx solid # e5e5e5 ;
background - color : # f9f9f9 ;
. select - text - copy - btn {
flex : 1 ;
height : 80 rpx ;
border - radius : 40 rpx ;
background - color : # 07 c160 ;
color : # fff ;
font - size : 28 rpx ;
font - weight : 500 ;
border : none ;
display : flex ;
align - items : center ;
justify - content : center ;
margin - right : 20 rpx ;
}
. select - text - close - btn {
width : 160 rpx ;
height : 80 rpx ;
border - radius : 40 rpx ;
background - color : # f2f2f2 ;
color : # 666 ;
font - size : 28 rpx ;
border : none ;
display : flex ;
align - items : center ;
justify - content : center ;
}
}
}
}
// H5 下允许 rich-text 内容文本选择(鼠标拖选复制)
. text - content ,
. markdown - body {