feat(dialog): 撤回文本消息后本地显示"重新编辑"占位

- 撤回自己的文本/MD 消息后,原位置显示仅本人可见的"你撤回了一条消息 重新编辑"占位(仿微信)
- 占位纯本地存储(IndexedDB 持久化,Electron 多窗口 syncDispatch 同步),5 分钟过期自动清除
- 点"重新编辑"回填原内容到输入框(富文本还原 @提及/表情,先退出引用/编辑模式),占位随即移除
- 占位沿用原消息 id 排序定位,仅在已加载消息范围内合并显示,不干扰翻页与已读上报
- 同步更新 ai-kb recall 文档与前端语言文件
This commit is contained in:
kuaifan 2026-07-16 11:20:37 +00:00
parent 966c7b4ef0
commit f7bc724e13
8 changed files with 165 additions and 7 deletions

View File

@ -2532,3 +2532,5 @@ AI任务分析
我共享的
共享给我的
服务器返回错误HTTP (*)
你撤回了一条消息
重新编辑

View File

@ -12,6 +12,8 @@ aliases:
- 收回消息
- 误发了能撤回吗
- 多久内可以撤回
- 撤回后重新编辑
- 撤回了还能改吗
related_tools: []
related_pages: [messenger, dialog_chat]
prerequisites:
@ -21,12 +23,15 @@ negative:
- 默认 msg_rev_limit 为空表示不限制;若设置成 0 也表示不限制
- 机器人发的消息可以无限期撤回msg_rev_limit 校验对机器人放行)
- 个人自聊会话isSelfDialog可以无限期撤回
last_verified: v1.7.90
- 「重新编辑」入口仅本人、仅撤回时所在的设备可见,且只保留 5 分钟;文件、语音等非文本消息撤回后没有该入口
last_verified: v1.8.69
---
# 撤回消息
撤回消息withdraw会软删除自己发送的消息deletes 字段群里所有人看到「XXX 撤回了一条消息」占位。撤回时限受系统配置 `msg_rev_limit` 控制(分钟)。
撤回消息withdraw会软删除自己发送的消息deletes 字段),消息从会话中对所有人消失。撤回时限受系统配置 `msg_rev_limit` 控制(分钟)。
撤回文本消息后,本人在当前设备的原消息位置会看到仅自己可见的「你撤回了一条消息 重新编辑」占位本地保存5 分钟内有效),点「重新编辑」可把原内容回填到输入框修改后重新发送。
## 入口
@ -52,6 +57,13 @@ last_verified: v1.7.90
| 机器人发的消息 | 无限制 |
| 自聊(私人云笔记会话) | 无限制 |
## 撤回后重新编辑
- 仅撤回**文本消息**(含 Markdown时出现「你撤回了一条消息 **重新编辑**」占位
- 占位只有自己可见,且只保存在执行撤回操作的这台设备本地(换设备、其他成员都看不到)
- 撤回后 **5 分钟内**有效,过期自动消失
- 点「重新编辑」:原内容回填到输入框(会覆盖输入框中已有内容),占位随即消失
## 与「编辑」的区别
- 撤回:消息消失,留占位

View File

@ -39,6 +39,10 @@
<div v-else-if="source.type === 'notice'" class="dialog-notice">
{{source.msg.source === 'api' ? source.msg.notice : $L(source.msg.notice)}}
</div>
<div v-else-if="source.type === 'withdraw'" class="dialog-withdraw">
{{$L('你撤回了一条消息')}}
<em class="withdraw-re-edit" @click="onWithdrawReEdit">{{$L('重新编辑')}}</em>
</div>
<template v-else>
<div v-if="multiSelectMode && isSelectableMsg" class="dialog-multi-check" @click.stop="onMultiSelectToggle">
<Icon :type="isSelected ? 'ios-checkmark-circle' : 'ios-radio-button-off'" :class="{checked: isSelected}"/>
@ -182,7 +186,7 @@ export default {
},
isSelectableMsg() {
return !['tag', 'top', 'todo', 'notice', 'word-chain', 'vote', 'template'].includes(this.source.type);
return !['tag', 'top', 'todo', 'notice', 'withdraw', 'word-chain', 'vote', 'template'].includes(this.source.type);
},
classArray() {
@ -328,6 +332,10 @@ export default {
this.dispatch("on-merge-forward-detail", data)
},
onWithdrawReEdit() {
this.dispatch("on-withdraw-re-edit", this.source)
},
dispatch(event, ...arg) {
if (this.isReply) {
this.$emit(event, ...arg)

View File

@ -220,7 +220,8 @@
@on-other="onOther"
@on-show-emoji-user="onShowEmojiUser"
@on-merge-forward-detail="onMergeForwardDetail"
@on-multi-select-toggle="onMultiSelectToggle">
@on-multi-select-toggle="onMultiSelectToggle"
@on-withdraw-re-edit="onWithdrawReEdit">
<template #header v-if="!isChildComponent">
<div class="dialog-item head-box">
<div v-if="loadIng > 0 || prevId > 0" class="loading" :class="{filled: allMsgs.length === 0}">
@ -978,6 +979,7 @@ export default {
'dialogMsgs',
'dialogTodos',
'dialogMsgTops',
'dialogWithdraws',
'dialogMsgTransfer',
'dialogMsgKeep',
'dialogIns',
@ -1069,6 +1071,13 @@ export default {
return this.tempMsgs.filter(item => item.dialog_id == this.dialogId);
},
withdrawMsgList() {
if (!this.isReady) {
return [];
}
return this.dialogWithdraws.filter(item => item.dialog_id == this.dialogId);
},
allMsgList() {
if (this.isStaticMode) {
return this.staticMsgs || []
@ -1088,6 +1097,25 @@ export default {
array.push(...tempMsgList)
}
}
if (this.withdrawMsgList.length > 0 && !this.msgType && !this.msgId) {
const ids = array.map(({id}) => id)
const minId = ids.length > 0 ? Math.min(...ids) : 0
// prev_id
const withdrawMsgList = this.withdrawMsgList
.filter(item => !ids.includes(item.id) && (ids.length === 0 || item.id > minId))
.map(item => ({
id: item.id,
dialog_id: item.dialog_id,
prev_id: item.prev_id,
type: 'withdraw',
userid: this.userId,
msg: item.msg,
estimateSize: 42,
}))
if (withdrawMsgList.length > 0) {
array.push(...withdrawMsgList)
}
}
return array.sort((a, b) => {
return a.id - b.id;
})
@ -3751,6 +3779,7 @@ export default {
},
onWithdraw() {
const operateItem = this.operateItem;
$A.modalConfirm({
content: `确定撤回此信息吗?`,
okText: '撤回',
@ -3760,11 +3789,22 @@ export default {
this.$store.dispatch("call", {
url: 'dialog/msg/withdraw',
data: {
msg_id: this.operateItem.id
msg_id: operateItem.id
},
}).then(() => {
resolve("消息已撤回");
this.$store.dispatch("forgetDialogMsg", this.operateItem);
if (operateItem.type === 'text' && $A.getObject(operateItem.msg, 'text')) {
this.$store.dispatch("saveDialogWithdraw", {
id: operateItem.id,
dialog_id: operateItem.dialog_id,
prev_id: operateItem.prev_id,
msg: {
type: $A.getObject(operateItem.msg, 'type'),
text: $A.getObject(operateItem.msg, 'text'),
},
});
}
this.$store.dispatch("forgetDialogMsg", operateItem);
}).catch(({msg}) => {
reject(msg);
});
@ -3773,6 +3813,21 @@ export default {
});
},
onWithdrawReEdit(source) {
if (this.operateVisible) {
return
}
this.cancelQuote()
const {type, text} = source.msg
if (type === 'md') {
this.$refs.input.setText(text)
} else {
this.$refs.input.setContent(text.replace(/\{\{RemoteURL\}\}/g, $A.mainUrl()))
}
!this.windowTouch && this.inputFocus()
this.$store.dispatch("forgetDialogWithdraw", {id: source.id})
},
onViewReply(data) {
if (this.operateVisible) {
return

View File

@ -7,6 +7,21 @@ import axios from "axios";
const dialogDraftState = { timer: {}, subTemp: null }
// 撤回消息本地保留(重新编辑用),过期自动清除
const dialogWithdrawState = {
timer: {},
expire: 5 * 60 * 1000,
schedule(commit, item) {
if (this.timer[item.id]) {
clearTimeout(this.timer[item.id])
}
this.timer[item.id] = setTimeout(() => {
delete this.timer[item.id]
commit('withdraw/remove', item.id)
}, Math.max(0, item.time + this.expire - new Date().getTime()))
}
}
export default {
/**
* 预加载
@ -1169,6 +1184,7 @@ export default {
'dialogMsgs',
'dialogDrafts',
'dialogQuotes',
'dialogWithdraws',
'fileLists',
'callAt',
'cacheEmojis',
@ -1212,6 +1228,15 @@ export default {
tag: !!item.content,
}));
// 特殊处理 dialogWithdraws清除过期项未过期的重新安排过期清除
const withdrawNow = new Date().getTime()
const withdrawLength = state.dialogWithdraws.length
state.dialogWithdraws = state.dialogWithdraws.filter(item => item.time && withdrawNow - item.time < dialogWithdrawState.expire);
state.dialogWithdraws.forEach(item => dialogWithdrawState.schedule(commit, item));
if (state.dialogWithdraws.length !== withdrawLength) {
$A.IDBSave("dialogWithdraws", state.dialogWithdraws)
}
// TranslationLanguage 检查
if (typeof languageList[state.cacheTranslationLanguage] === "undefined") {
state.cacheTranslationLanguage = languageName;
@ -4052,6 +4077,32 @@ export default {
commit('quote/remove', id)
},
/**
* 保存撤回消息仅本地撤回后显示"重新编辑"入口
* @param commit
* @param data {id, dialog_id, prev_id, msg: {type, text}, ?time}
*/
saveDialogWithdraw({commit}, data) {
data = Object.assign({time: new Date().getTime()}, data)
$A.syncDispatch("saveDialogWithdraw", data)
commit('withdraw/set', data)
dialogWithdrawState.schedule(commit, data)
},
/**
* 移除撤回消息
* @param commit
* @param data {id}
*/
forgetDialogWithdraw({commit}, data) {
$A.syncDispatch("forgetDialogWithdraw", data)
if (dialogWithdrawState.timer[data.id]) {
clearTimeout(dialogWithdrawState.timer[data.id])
delete dialogWithdrawState.timer[data.id]
}
commit('withdraw/remove', data.id)
},
/** *****************************************************************************************/
/** ************************************** 消息 **********************************************/
/** *****************************************************************************************/

View File

@ -342,6 +342,25 @@ export default {
}
},
// 撤回消息管理(仅本地,用于撤回后重新编辑)
'withdraw/set': function(state, data) {
const index = state.dialogWithdraws.findIndex(item => item.id === data.id)
if (index !== -1) {
state.dialogWithdraws.splice(index, 1, data)
} else {
state.dialogWithdraws.push(data)
}
$A.IDBSave("dialogWithdraws", state.dialogWithdraws)
},
'withdraw/remove': function(state, id) {
const index = state.dialogWithdraws.findIndex(item => item.id === id)
if (index !== -1) {
state.dialogWithdraws.splice(index, 1)
$A.IDBSave("dialogWithdraws", state.dialogWithdraws)
}
},
// 长按事件
'longpress/set': function(state, {type, data, element}) {
state.longpressData = {type, data, element}

View File

@ -162,6 +162,7 @@ export default {
dialogHistory: [],
dialogDrafts: [],
dialogQuotes: [],
dialogWithdraws: [],
dialogMsgTransfer: {time: 0},
dialogSseList: [],
dialogDroupWordChain: {},

View File

@ -603,7 +603,8 @@
.dialog-tag,
.dialog-todo,
.dialog-top,
.dialog-notice {
.dialog-notice,
.dialog-withdraw {
font-size: 12px;
max-width: 80%;
margin: 0 auto;
@ -614,6 +615,15 @@
word-wrap: break-word;
}
.dialog-withdraw {
.withdraw-re-edit {
margin-left: 4px;
font-style: normal;
color: $primary-color;
cursor: pointer;
}
}
.dialog-top,
.dialog-tag {
cursor: pointer;