feat(messenger): add local file shortcut to chat cards

This commit is contained in:
kuaifan 2026-08-20 04:30:24 +00:00
parent 098bea3d5b
commit 2a7d635729
20 changed files with 245 additions and 16 deletions

View File

@ -12,7 +12,13 @@ let downloadWindow = null,
downloadLanguageCode = 'zh',
downloadWaiting = false;
function initialize(onStarted = null) {
function initialize(onStarted = null, onChanged = null) {
const notifyChanged = (item) => {
if (typeof onChanged === 'function') {
onChanged(item)
}
}
// 下载配置
electronDl({
showBadge: false,
@ -25,10 +31,12 @@ function initialize(onStarted = null) {
if (typeof onStarted === 'function') {
onStarted(item)
}
notifyChanged(item)
},
onCancel: (item) => {
downloadManager.refresh(item.getSavePath())
syncDownloadItems();
notifyChanged(item)
},
onInterrupted: (item) => {
downloadManager.refresh(item.getSavePath());
@ -41,6 +49,7 @@ function initialize(onStarted = null) {
syncDownloadItems();
}
});
notifyChanged(item)
},
onProgress: (item) => {
downloadManager.refresh(item.path);
@ -49,11 +58,12 @@ function initialize(onStarted = null) {
onCompleted: (item) => {
downloadManager.refresh(item.path);
syncDownloadItems();
notifyChanged(item)
}
});
// IPC
ipcMain.handle('downloadManager', async (event, {action, path}) => {
ipcMain.handle('downloadManager', async (event, {action, path, msgId}) => {
switch (action) {
case "get": {
return {
@ -106,6 +116,20 @@ function initialize(onStarted = null) {
shell.showItemInFolder(path);
return true;
}
case "messageFileStatus": {
const {status} = downloadManager.getMessageFileStatus(msgId);
return {status};
}
case "showMessageFile": {
const file = downloadManager.getMessageFileStatus(msgId);
if (file.status !== 'available') {
return false;
}
shell.showItemInFolder(file.path);
return true;
}
}
});
}

View File

@ -54,9 +54,13 @@ contextBridge.exposeInMainWorld(
},
listener: function (action, callback) {
ipcRenderer.on(action, function (event, args) {
const listener = function (event, args) {
callback(args);
});
};
ipcRenderer.on(action, listener);
return function () {
ipcRenderer.removeListener(action, listener);
};
},
listenOnce: function (action, callback) {
ipcRenderer.once(action, function (event, args) {

View File

@ -101,6 +101,12 @@ electronDown.initialize(() => {
if (mainWindow) {
mainWindow.webContents.send("openDownloadWindow", {})
}
}, () => {
BrowserWindow.getAllWindows().forEach(window => {
if (!window.isDestroyed()) {
window.webContents.send("downloadItemsChanged", {})
}
})
})
/**

View File

@ -1,4 +1,5 @@
const path = require("path");
const fs = require("fs");
const loger = require("electron-log");
const Store = require('electron-store');
const utils = require("./utils");
@ -95,6 +96,52 @@ class DownloadManager {
});
}
/**
* 获取聊天文件消息对应的下载状态
*
* @param {number|string} msgId
* @returns {{status: 'available'|'downloading'|'missing', path?: string}}
*/
getMessageFileStatus(msgId) {
const targetId = parseInt(msgId, 10);
if (!targetId) {
return {status: 'missing'};
}
const items = this.downloadHistory.filter(item => this.getMessageFileId(item) === targetId);
const downloading = items.some(item => item.state === 'progressing' && !item.paused);
if (downloading) {
return {status: 'downloading'};
}
const available = items.find(item => item.state === 'completed' && item.path && fs.existsSync(item.path));
return available ? {status: 'available', path: available.path} : {status: 'missing'};
}
/**
* 从下载地址中识别聊天文件消息 ID
*
* @param {Object} item
* @returns {number}
*/
getMessageFileId(item) {
const urls = [...(Array.isArray(item.urls) ? item.urls : []), item.url].filter(Boolean);
for (const value of urls) {
try {
const url = new URL(value);
if (url.pathname.endsWith('/api/dialog/msg/download')) {
const msgId = parseInt(url.searchParams.get('msg_id'), 10);
if (msgId > 0) {
return msgId;
}
}
} catch {
// Ignore malformed history URLs.
}
}
return 0;
}
/**
* 更新下载项
* @param {string} path

View File

@ -698,6 +698,7 @@ MAC地址
Pro版
上传
下载
在文件夹中显示
个人
今天
任务

View File

@ -39562,5 +39562,17 @@
"fr": "Voulez-vous vraiment supprimer cette priorite ?",
"id": "Yakin ingin menghapus prioritas ini?",
"ru": "Удалить этот приоритет?"
},
{
"key": "在文件夹中显示",
"zh": "",
"zh-CHT": "在資料夾中顯示",
"en": "Show in Folder",
"ko": "폴더에서 보기",
"ja": "フォルダーに表示",
"de": "Im Ordner anzeigen",
"fr": "Afficher dans le dossier",
"id": "Tampilkan di Folder",
"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

@ -19,7 +19,7 @@ negative:
- 单个文件大小受系统设置 file_upload_limit 限制(默认无单独限制,按 PHP 上传配置
- 不支持文件夹直接拖入,需要逐个或压缩后上传
- 不支持已删除文件 ID 复用(通过 sendfileid 发送已删除文件会失败)
last_verified: v1.7.90
last_verified: v1.8.89
---
# 发送文件消息
@ -56,6 +56,16 @@ last_verified: v1.7.90
- 其他 → file普通文件
- mp4 / mov / mkv 等视频会单独处理
## 桌面端下载与本地定位
Electron 桌面客户端的普通文件卡片右下角提供快捷图标:
- 下载图标:文件没有有效的本地下载记录,点击后立即下载,不再弹确认框
- 加载图标:文件正在下载,完成前不能重复点击
- 文件夹图标:文件已下载且本地文件仍存在,点击后在资源管理器或 Finder 中定位并选中文件
文件卡片主体仍保留原有的确认下载操作。清除下载历史,或移动、改名、删除本地文件后,文件夹图标会恢复为下载图标。图片和视频消息继续使用原有预览,不显示该快捷图标;浏览器端和移动端也不显示。
## 通过已有文件 ID 转发
走 sendfileid 可以把「我的文件」里现存文件以分享链接形式发到会话,不重复上传。

View File

@ -19,6 +19,18 @@
<div class="file-name">{{ msg.name }}</div>
<div class="file-size">{{ $A.bytesToSize(msg.size) }}</div>
</div>
<button
v-if="$Electron"
class="file-local-action"
type="button"
:title="localActionTitle"
:aria-label="localActionTitle"
:disabled="localFileStatus === 'downloading'"
@click.stop="handleLocalAction">
<Loading v-if="localFileStatus === 'downloading'"/>
<Icon v-else-if="localFileStatus === 'available'" type="ios-folder-open-outline"/>
<Icon v-else type="md-download"/>
</button>
</div>
<div v-if="msg.percentage" class="file-percentage">
<span :style="fileStyle(msg.percentage)"></span>
@ -30,8 +42,42 @@
<script>
export default {
props: {
msgId: {
type: [Number, String],
default: 0,
},
msg: Object,
},
data() {
return {
localFileStatus: 'missing',
removeDownloadListener: null,
}
},
computed: {
localActionTitle() {
return this.localFileStatus === 'available' ? this.$L('在文件夹中显示') : this.$L('下载');
},
},
watch: {
msgId() {
this.refreshLocalFileStatus();
},
},
mounted() {
if (!this.$Electron) {
return;
}
this.refreshLocalFileStatus();
this.removeDownloadListener = $A.Electron.listener('downloadItemsChanged', () => {
this.refreshLocalFileStatus();
});
},
beforeDestroy() {
if (typeof this.removeDownloadListener === 'function') {
this.removeDownloadListener();
}
},
methods: {
viewFile() {
this.$emit('viewFile');
@ -40,6 +86,47 @@ export default {
this.$emit('downFile');
},
async refreshLocalFileStatus() {
if (!this.$Electron || !this.msgId) {
this.localFileStatus = 'missing';
return;
}
try {
const result = await $A.Electron.sendAsync('downloadManager', {
action: 'messageFileStatus',
msgId: this.msgId,
});
this.localFileStatus = result?.status || 'missing';
} catch {
this.localFileStatus = 'missing';
}
},
async handleLocalAction() {
if (this.localFileStatus === 'downloading') {
return;
}
if (this.localFileStatus === 'available') {
try {
const shown = await $A.Electron.sendAsync('downloadManager', {
action: 'showMessageFile',
msgId: this.msgId,
});
if (shown) {
return;
}
} catch {
// Refresh the action when the local file disappears or cannot be revealed.
}
this.localFileStatus = 'missing';
return;
}
this.localFileStatus = 'downloading';
this.$store.dispatch('downUrl', $A.apiUrl(`dialog/msg/download?msg_id=${this.msgId}`));
setTimeout(() => this.refreshLocalFileStatus(), 500);
},
fileStyle(percentage) {
if (percentage) {
return {

View File

@ -30,7 +30,7 @@
<!--长文本-->
<LongTextMsg v-else-if="msgData.type === 'longtext'" :msgId="msgData.id" :msg="msgData.msg" @viewText="viewText" @downFile="downFile"/>
<!--文件-->
<FileMsg v-else-if="msgData.type === 'file'" :msg="msgData.msg" @viewFile="viewFile" @downFile="downFile"/>
<FileMsg v-else-if="msgData.type === 'file'" :msg-id="msgData.id" :msg="msgData.msg" @viewFile="viewFile" @downFile="downFile"/>
<!--录音-->
<RecordMsg v-else-if="msgData.type === 'record'" :msgId="msgData.id" :msg="msgData.msg" @viewText="viewText" @playRecord="playRecord"/>
<!--位置-->

View File

@ -1111,6 +1111,7 @@
background-color: #ffffff;
display: flex;
align-items: center;
position: relative;
padding: 10px 14px;
border-radius: 3px;
width: 220px;
@ -1123,6 +1124,8 @@
.file-info {
margin-left: 12px;
min-width: 0;
padding-right: 24px;
display: flex;
flex-direction: column;
justify-content: center;
@ -1145,6 +1148,41 @@
font-size: 14px;
}
}
.file-local-action {
position: absolute;
right: 8px;
bottom: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 3px;
color: $primary-text-color;
background: transparent;
cursor: pointer;
&:hover {
color: $flow-status-end-color;
background-color: rgba($flow-status-end-color, 0.08);
}
&:disabled {
cursor: default;
}
> i {
font-size: 18px;
}
.common-loading {
width: 16px;
height: 16px;
}
}
}
}