feat(dialog): 消息操作菜单的复制项精简为「复制 / 选择复制」,新增全屏选择复制页

- 移除「复制原文 / 复制选择 / 复制全部」;「选择复制」打开复制页,可自由选字、底部按钮复制全部,Markdown 消息支持预览 / 原文切换
- 复制页标题与其他会话抽屉一致(居中标题),Markdown 预览沿用消息气泡的深色模式处理
- 移动端打开菜单后不再允许长按选字,移除 selectionchange 监听与 operateEnter
- 桌面端保留在消息中选字,直接用键盘复制
- 更新语言包与 ai-kb 移动端手势说明
This commit is contained in:
kuaifan 2026-10-28 10:33:46 +00:00
parent 10378850d2
commit 2556c53fbc
18 changed files with 222 additions and 84 deletions

View File

@ -2855,3 +2855,5 @@ DooTask 新版本 (*) 已发布,建议更新以获得更好的使用体验。
系统配置
使用官方推送服务,无需配置。
使用自己的 DooPush 应用,需搭配自行打包的 App。
选择复制
原文

View File

@ -41194,5 +41194,29 @@
"fr": "Mode de notification invalide",
"id": "Mode push tidak valid",
"ru": "Неверный режим push-уведомлений"
},
{
"key": "选择复制",
"zh": "",
"zh-CHT": "選擇複製",
"en": "Select & Copy",
"ko": "선택하여 복사",
"ja": "選択してコピー",
"de": "Auswählen und kopieren",
"fr": "Sélectionner et copier",
"id": "Pilih dan Salin",
"ru": "Выбрать и копировать"
},
{
"key": "原文",
"zh": "",
"zh-CHT": "原文",
"en": "Source",
"ko": "원문",
"ja": "原文",
"de": "Quelltext",
"fr": "Source",
"id": "Sumber",
"ru": "Исходный текст"
}
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -31,7 +31,7 @@ last_verified: v1.10.12
| 手势 | 触发位置 | 行为 |
|---|---|---|
| 点击 | 任意可交互元素 | 打开 / 选中 |
| 长按 | 消息气泡 | 弹出操作菜单(复制、撤回、转发、回复、翻译等) |
| 长按 | 消息气泡 | 弹出操作菜单(复制、选择复制、撤回、转发、回复、翻译等) |
| 长按 | 任务卡片 | 弹出快捷操作(完成、归档、更多) |
| 双击 | 消息气泡 | 部分版本支持快速点赞 |
| 下拉 | 列表顶部 | 刷新 |
@ -44,6 +44,7 @@ last_verified: v1.10.12
## 消息相关
- **长按消息**:复制 / 撤回(2 分钟内) / 转发 / 回复 / @TA / 翻译 / 收藏 / 删除
- **复制文字**:长按消息 →「复制」复制整条;「选择复制」打开全屏复制页,在页内长按自由选择部分文字,右上角「复制全部」;Markdown 消息可在「预览 / 原文」间切换,「原文」即 Markdown 源码。操作菜单打开时消息本身不再支持长按选字
- **左划聊天列表**:置顶 / 免打扰 / 删除会话
- **下拉聊天页**:加载历史消息

View File

@ -0,0 +1,55 @@
<template>
<div class="dialog-text-select">
<div class="text-select-title" :class="{bordered: !source}">{{ $L('选择复制') }}</div>
<ul v-if="source" class="text-select-tabs">
<li :class="{active: tab === 'preview'}" @click="tab = 'preview'">{{ $L('预览') }}</li>
<li :class="{active: tab === 'source'}" @click="tab = 'source'">{{ $L('原文') }}</li>
</ul>
<div class="text-select-content user-select-auto">
<DialogMarkdown v-if="source && tab === 'preview'" class="no-dark-content" :text="source"/>
<pre v-else>{{ currentText }}</pre>
</div>
<div class="text-select-button">
<Button type="primary" size="large" icon="md-copy" long @click="onCopyAll">{{ $L('复制全部') }}</Button>
</div>
</div>
</template>
<script>
import DialogMarkdown from "./DialogMarkdown.vue";
export default {
name: "DialogTextSelect",
components: {DialogMarkdown},
props: {
// 消息显示的文本
text: {
type: String,
default: ''
},
// Markdown 原文(仅 Markdown 消息)
source: {
type: String,
default: ''
},
},
data() {
return {
tab: 'preview',
}
},
computed: {
currentText() {
return this.source && this.tab === 'source' ? this.source : this.text
}
},
methods: {
onCopyAll() {
this.copyText(this.currentText)
}
}
}
</script>

View File

@ -300,7 +300,6 @@ export default {
return {
timeShow: false,
operateAction: false,
operateEnter: false,
percentageLoad: 0,
percentageShow: false,
@ -399,7 +398,7 @@ export default {
replyClass() {
const classArray = [];
if (this.operateEnter || this.pointerMouse) {
if (this.pointerMouse) {
classArray.push('user-select-auto')
}
return classArray;
@ -409,7 +408,7 @@ export default {
const {type, msg} = this.msgData;
const classArray = [];
if (this.operateEnter || this.pointerMouse) {
if (this.pointerMouse) {
classArray.push('user-select-auto')
}
@ -439,12 +438,6 @@ export default {
},
immediate: true
},
operateAction(val) {
this.operateEnter = false;
if (val) {
setTimeout(_ => this.operateEnter = true, 500)
}
},
voteData: {
handler(val) {
const voteData = JSON.parse(window.localStorage.getItem('__cache:vote__')) || {}

View File

@ -359,11 +359,10 @@
</li>
<li
v-for="(item, index) in operateCopys"
v-if="item.visible !== false"
:key="index"
@click="onOperate('copy', item)">
<i class="taskfont" v-html="item.icon"></i>
<span>{{ $L(item.label || item.title) }}</span>
<span>{{ $L(item.label) }}</span>
</li>
<li v-if="actionPermission(operateItem, 'forward')" @click="onOperate('forward')">
<i class="taskfont">&#xe638;</i>
@ -711,6 +710,14 @@
</template>
</DrawerOverlay>
<!--选择复制-->
<DrawerOverlay
v-model="textSelectShow"
placement="right"
:size="600">
<DialogTextSelect v-if="textSelectShow" :text="textSelectData.text" :source="textSelectData.source"/>
</DrawerOverlay>
<!-- 群接龙 -->
<DialogGroupWordChain/>
@ -727,6 +734,7 @@ import DialogUpload from "./DialogUpload";
import DrawerOverlay from "../../../components/DrawerOverlay";
import DialogGroupInfo from "./DialogGroupInfo";
import DialogRespond from "./DialogRespond";
import DialogTextSelect from "./DialogTextSelect.vue";
import DialogSessionHistory from "./DialogSessionHistory";
import ChatInput from "./ChatInput";
@ -759,6 +767,7 @@ export default {
UserSelect,
ImgUpload,
DialogRespond,
DialogTextSelect,
DialogSessionHistory,
DialogItem,
VirtualList,
@ -871,6 +880,10 @@ export default {
// 操作菜单状态(通过 provide 下发给消息项,避免作为 props 传递导致打开菜单时所有消息重渲染)
dialogOperate: {visible: false, actionId: 0},
// 选择复制
textSelectShow: false,
textSelectData: {},
multiSelectMode: false,
selectedMsgIds: [],
@ -951,13 +964,11 @@ export default {
emitter.on('websocketMsg', this.onWebsocketMsg);
emitter.on('streamMsgData', this.onMsgChange);
this.keepInterval = setInterval(this.keepIntoInput, 1000)
this.windowTouch && document.addEventListener('selectionchange', this.onSelectionchange);
}
},
beforeDestroy() {
if (!this.isStaticMode) {
this.windowTouch && document.removeEventListener('selectionchange', this.onSelectionchange);
clearInterval(this.keepInterval);
emitter.off('streamMsgData', this.onMsgChange);
emitter.off('websocketMsg', this.onWebsocketMsg);
@ -3359,38 +3370,24 @@ export default {
value: event.target.href,
})
}
this.operateCopys.push({
type: 'selected',
icon: '&#xe7df;',
label: '复制选择',
value: '',
visible: false,
})
if (data.type === 'text') {
if (data.msg.text.replace(/<[^>]+>/g,"").length > 0) {
this.operateCopys.push({
type: 'text',
icon: '&#xe77f;',
label: null,
title: this.operateCopys.length > 1 ? '复制文本' : '复制',
value: '',
})
}
if (data.msg.type === 'md') {
this.operateCopys.push({
type: 'md',
icon: '&#xe77f;',
label: '复制原文',
value: '',
})
}
if (data.type === 'text' && data.msg.text.replace(/<[^>]+>/g,"").length > 0) {
this.operateCopys.push({
type: 'text',
icon: '&#xe77f;',
label: this.operateCopys.length > 0 ? '复制文本' : '复制',
value: '',
}, {
type: 'textselect',
icon: '&#xe7df;',
label: '选择复制',
value: '',
})
}
if (this.operateVisible) {
this.checkMessageFavoriteStatus(this.operateItem);
}
this.operateItem.clientX = event.clientX
this.operateItem.clientY = event.clientY
this.onSelectionchange()
this.onUpdateOperate(element)
})
break;
@ -3421,25 +3418,6 @@ export default {
}
},
onSelectionchange() {
if (!this.operateVisible) {
return
}
const selectedItem = this.operateCopys.find(({type}) => type === 'selected')
if (!selectedItem) {
return;
}
const selectText = this.getSelectedTextInElement(this.$refs.scroller.$el.querySelector(`[data-id="${this.operateItem.id}"]`))
selectedItem.value = selectText
selectedItem.visible = selectText.length > 0
//
const textItem = this.operateCopys.find(({type}) => type === 'text');
if (!textItem) {
return;
}
textItem.label = selectText.length > 0 ? '复制全部' : null
},
onUpdateOperate(el) {
if (!el) {
return
@ -3775,29 +3753,40 @@ export default {
this.copyText(value);
break;
case 'selected':
this.copyText(value);
break;
case 'text':
const copyEl = this.$refs.scroller.$el.querySelector(`[data-id="${this.operateItem.id}"]`)?.querySelector(".dialog-content")
if (copyEl) {
let copyText = copyEl.innerText;
if ($A.getObject(this.operateItem.msg, 'type') !== 'md') {
copyText = copyText.replace(/\n\n/g, "\n").replace(/(^\s*)|(\s*$)/g, "")
}
case 'textselect':
const copyText = this.getOperateText()
if (copyText === null) {
$A.messageWarning('不可复制的内容');
} else if (type === 'text') {
this.copyText(copyText)
} else {
$A.messageWarning('不可复制的内容');
this.textSelectData = {
text: copyText,
source: $A.getObject(this.operateItem.msg, 'type') === 'md' ? this.operateItem.msg.text : '',
}
this.textSelectShow = true
}
break;
case 'md':
this.copyText(this.operateItem.msg.text)
break;
}
},
/**
* 获取当前操作消息显示的文本
* @returns {string|null}
*/
getOperateText() {
const copyEl = this.$refs.scroller.$el.querySelector(`[data-id="${this.operateItem.id}"]`)?.querySelector(".dialog-content")
if (!copyEl) {
return null
}
let copyText = copyEl.innerText;
if ($A.getObject(this.operateItem.msg, 'type') !== 'md') {
copyText = copyText.replace(/\n\n/g, "\n").replace(/(^\s*)|(\s*$)/g, "")
}
return copyText
},
onWithdraw() {
const operateItem = this.operateItem;
$A.modalConfirm({

View File

@ -4,6 +4,7 @@
@import "dialog-modal";
@import "dialog-select";
@import "dialog-session-history";
@import "dialog-text-select";
@import "dialog-wrapper";
@import "favorite-management";
@import "recent-management";

View File

@ -0,0 +1,73 @@
.dialog-text-select {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
.text-select-title {
flex-shrink: 0;
height: 56px;
line-height: 56px;
padding: 0 56px;
text-align: center;
font-size: 16px;
font-weight: 500;
color: $primary-title-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.bordered {
border-bottom: 1px solid #f0f0f0;
}
}
.text-select-tabs {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 20px;
padding: 0 20px;
border-bottom: 1px solid #f0f0f0;
> li {
list-style: none;
line-height: 42px;
font-size: 14px;
color: #888888;
border-bottom: 2px solid transparent;
cursor: pointer;
&.active {
color: $primary-title-color;
font-weight: 500;
border-bottom-color: $primary-color;
}
}
}
.text-select-content {
flex: 1;
overflow: auto;
padding: 16px 20px 24px;
user-select: text;
> pre {
margin: 0;
font-family: inherit;
font-size: 15px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
color: $primary-title-color;
}
}
.text-select-button {
flex-shrink: 0;
margin: 12px 20px 18px;
}
}