feat(file): sync collaboration download status

This commit is contained in:
kuaifan 2026-08-20 13:15:49 +08:00
parent 2a7d635729
commit 59aff7a356
7 changed files with 330 additions and 18 deletions

View File

@ -63,7 +63,7 @@ function initialize(onStarted = null, onChanged = null) {
});
// IPC
ipcMain.handle('downloadManager', async (event, {action, path, msgId}) => {
ipcMain.handle('downloadManager', async (event, {action, path, msgId, file, files}) => {
switch (action) {
case "get": {
return {
@ -93,12 +93,14 @@ function initialize(onStarted = null, onChanged = null) {
case "remove": {
downloadManager.remove(path);
syncDownloadItems();
notifyChanged();
return true;
}
case "removeAll": {
downloadManager.removeAll();
syncDownloadItems();
notifyChanged();
return true;
}
@ -130,6 +132,22 @@ function initialize(onStarted = null, onChanged = null) {
shell.showItemInFolder(file.path);
return true;
}
case "fileStatuses": {
if (!Array.isArray(files)) {
return {};
}
return downloadManager.getFileStatuses(files.slice(0, 500));
}
case "showFile": {
const localFile = downloadManager.getFileStatus(file);
if (localFile.status !== 'available') {
return false;
}
shell.showItemInFolder(localFile.path);
return true;
}
}
});
}

View File

@ -103,12 +103,27 @@ class DownloadManager {
* @returns {{status: 'available'|'downloading'|'missing', path?: string}}
*/
getMessageFileStatus(msgId) {
const targetId = parseInt(msgId, 10);
if (!targetId) {
return this.getFileStatus({msgId});
}
/**
* 获取会话附件对应的下载状态
*
* @param {{msgId?: number|string, attachmentId?: number|string}} reference
* @returns {{status: 'available'|'downloading'|'missing', path?: string}}
*/
getFileStatus(reference = {}) {
const msgId = parseInt(reference.msgId, 10) || 0;
const attachmentId = parseInt(reference.attachmentId, 10) || 0;
if (!msgId && !attachmentId) {
return {status: 'missing'};
}
const items = this.downloadHistory.filter(item => this.getMessageFileId(item) === targetId);
const items = this.downloadHistory.filter(item => {
const file = this.getFileReference(item);
return (msgId > 0 && file.msgId === msgId)
|| (attachmentId > 0 && file.attachmentId === attachmentId);
});
const downloading = items.some(item => item.state === 'progressing' && !item.paused);
if (downloading) {
return {status: 'downloading'};
@ -118,6 +133,58 @@ class DownloadManager {
return available ? {status: 'available', path: available.path} : {status: 'missing'};
}
/**
* 批量获取会话附件的下载状态只扫描一次下载历史
*
* @param {Array<{key: string, msgId?: number|string, attachmentId?: number|string}>} references
* @returns {Object<string, 'available'|'downloading'|'missing'>}
*/
getFileStatuses(references = []) {
const statuses = {};
const msgKeys = new Map();
const attachmentKeys = new Map();
const addKey = (map, id, key) => {
if (!id) return;
if (!map.has(id)) map.set(id, []);
map.get(id).push(key);
};
references.forEach(reference => {
if (!reference || typeof reference !== 'object') return;
const key = `${reference.key || ''}`;
if (!key) return;
const msgId = parseInt(reference.msgId, 10) || 0;
const attachmentId = parseInt(reference.attachmentId, 10) || 0;
statuses[key] = 'missing';
addKey(msgKeys, msgId, key);
addKey(attachmentKeys, attachmentId, key);
});
this.downloadHistory.forEach(item => {
const file = this.getFileReference(item);
const keys = new Set([
...(msgKeys.get(file.msgId) || []),
...(attachmentKeys.get(file.attachmentId) || []),
]);
if (!keys.size) return;
let status = '';
if (item.state === 'progressing' && !item.paused) {
status = 'downloading';
} else if (item.state === 'completed' && item.path && fs.existsSync(item.path)) {
status = 'available';
}
if (!status) return;
keys.forEach(key => {
if (status === 'downloading' || statuses[key] === 'missing') {
statuses[key] = status;
}
});
});
return statuses;
}
/**
* 从下载地址中识别聊天文件消息 ID
*
@ -125,6 +192,17 @@ class DownloadManager {
* @returns {number}
*/
getMessageFileId(item) {
return this.getFileReference(item).msgId;
}
/**
* 从下载地址中识别会话消息或协作附件 ID
*
* @param {Object} item
* @returns {{msgId: number, attachmentId: number}}
*/
getFileReference(item) {
const reference = {msgId: 0, attachmentId: 0};
const urls = [...(Array.isArray(item.urls) ? item.urls : []), item.url].filter(Boolean);
for (const value of urls) {
try {
@ -132,14 +210,19 @@ class DownloadManager {
if (url.pathname.endsWith('/api/dialog/msg/download')) {
const msgId = parseInt(url.searchParams.get('msg_id'), 10);
if (msgId > 0) {
return msgId;
reference.msgId = msgId;
}
} else if (url.pathname.endsWith('/api/file/collaboration/download')) {
const attachmentId = parseInt(url.searchParams.get('attachment_id'), 10);
if (attachmentId > 0) {
reference.attachmentId = attachmentId;
}
}
} catch {
// Ignore malformed history URLs.
}
}
return 0;
return reference;
}
/**

View File

@ -34,6 +34,8 @@ last_verified: v1.8.89
文件消息和粘贴、插入到聊天正文中的图片都会汇总到协作文件。一条聊天消息包含多张图片时,每张图片会作为一条独立记录展示。图片会在列表和宫格中直接显示缩略图;缩略图不可用时显示对应的文件类型图标。
Electron 桌面客户端会同步会话文件与协作文件的本地下载状态:未下载时显示下载图标,下载中显示加载图标;下载完成且本地文件仍存在时显示文件夹图标,点击可在资源管理器或 Finder 中定位文件。清除下载历史,或移动、改名、删除本地文件后,操作会恢复为下载图标。正文图片等独立附件也支持相同操作。
## 权限与范围
- 只展示当前用户仍有权访问的来源;退出会话、项目或任务权限变化后,对应文件不再显示。
- 已撤回或删除的文件消息、正文图片不显示;编辑消息移除图片后,对应图片也不再显示。

View File

@ -99,8 +99,14 @@
<span class="time-text">{{formatTime(item.created_at)}}</span>
<span class="size-text">{{$A.bytesToSize(item.size)}}</span>
<div class="row-actions">
<ETooltip :content="$L('打开来源')"><button @click="locateMessage(item)"><Icon type="md-open"/></button></ETooltip>
<ETooltip :content="$L('下载')"><button @click="download(item)"><Icon type="md-download"/></button></ETooltip>
<ETooltip :content="$L('打开来源')"><button @click="locateMessage(item)"><CollaborationSourceIcon/></button></ETooltip>
<ETooltip :content="localActionTitle(item)">
<button
:disabled="localFileStatus(item) === 'downloading'"
@click="handleLocalAction(item)">
<LocalFileStatusIcon :status="localFileStatus(item)"/>
</button>
</ETooltip>
</div>
</div>
</div>
@ -123,8 +129,13 @@
</div>
<div class="grid-meta">{{item.sender.nickname || $L('未知成员')}} · {{formatTime(item.created_at)}}</div>
<div class="grid-actions" @click.stop>
<button :title="$L('打开来源')" @click="locateMessage(item)"><Icon type="md-open"/></button>
<button :title="$L('下载')" @click="download(item)"><Icon type="md-download"/></button>
<button :title="$L('打开来源')" @click="locateMessage(item)"><CollaborationSourceIcon/></button>
<button
:title="localActionTitle(item)"
:disabled="localFileStatus(item) === 'downloading'"
@click="handleLocalAction(item)">
<LocalFileStatusIcon :status="localFileStatus(item)"/>
</button>
</div>
</div>
</div>
@ -142,11 +153,14 @@
<script>
import {mapState} from "vuex";
import {openFileInClient} from "../../../utils/file";
import CollaborationSourceIcon from "./CollaborationSourceIcon.vue";
import LocalFileStatusIcon from "./DialogView/LocalFileStatusIcon.vue";
const CACHE_VERSION = 1;
export default {
name: "CollaborationFileList",
components: {CollaborationSourceIcon, LocalFileStatusIcon},
props: {
searchKey: {
type: String,
@ -169,6 +183,8 @@ export default {
initializing: true,
searchTimer: null,
requestId: 0,
localFileStatuses: {},
removeDownloadListener: null,
}
},
computed: {
@ -223,11 +239,20 @@ export default {
},
mounted() {
this.$store.dispatch('getProjects').catch(() => {});
if (this.$Electron) {
this.removeDownloadListener = $A.Electron.listener('downloadItemsChanged', () => {
this.refreshLocalFileStatuses();
});
this.refreshLocalFileStatuses();
}
this.refresh();
this.initializing = false;
},
beforeDestroy() {
clearTimeout(this.searchTimer);
if (typeof this.removeDownloadListener === 'function') {
this.removeDownloadListener();
}
},
methods: {
setScope(scope) {
@ -256,6 +281,7 @@ export default {
this.loading = 0;
if (!keepItems) {
this.items = [];
this.localFileStatuses = {};
}
this.cursor = 0;
this.hasMore = false;
@ -286,6 +312,7 @@ export default {
} else {
this.items.push(...data.list);
}
this.refreshLocalFileStatuses(data.list);
this.cursor = data.next_cursor;
this.hasMore = data.has_more;
if (replace && !this.searchKey.trim()) {
@ -368,6 +395,40 @@ export default {
itemKey(item) {
return item.attachment_id ? `attachment-${item.attachment_id}` : `message-${item.msg_id}`;
},
fileReference(item) {
return {
key: this.itemKey(item),
msgId: item.msg_id,
attachmentId: item.attachment_id,
};
},
localFileStatus(item) {
if (!this.$Electron) return 'missing';
return this.localFileStatuses[this.itemKey(item)] || 'missing';
},
localActionTitle(item) {
if (this.localFileStatus(item) === 'available') {
return this.$L('在文件夹中显示');
}
return this.$L('下载');
},
async refreshLocalFileStatuses(items = this.items) {
if (!this.$Electron || !items.length) return;
const references = items.map(item => this.fileReference(item));
const batches = [];
for (let index = 0; index < references.length; index += 500) {
batches.push(references.slice(index, index + 500));
}
try {
const results = await Promise.all(batches.map(files => $A.Electron.sendAsync('downloadManager', {
action: 'fileStatuses',
files,
})));
this.localFileStatuses = Object.assign({}, this.localFileStatuses, ...results);
} catch {
// Keep the download action available when local history cannot be read.
}
},
showThumbnail(item) {
return !!item.image_url && !item._thumbnailError;
},
@ -437,16 +498,45 @@ export default {
search_msg_id: item.msg_id,
}).catch(({msg}) => msg && $A.modalError(msg));
},
download(item) {
const url = item.attachment_id
downloadUrl(item) {
if (item.attachment_source === 'file_message') {
return `dialog/msg/download?msg_id=${item.msg_id}`;
}
return item.attachment_id
? `file/collaboration/download?attachment_id=${item.attachment_id}`
: `dialog/msg/download?msg_id=${item.msg_id}`;
},
async handleLocalAction(item) {
if (!this.$Electron) {
this.download(item);
return;
}
const status = this.localFileStatus(item);
if (status === 'downloading') return;
if (status === 'available') {
try {
const shown = await $A.Electron.sendAsync('downloadManager', {
action: 'showFile',
file: this.fileReference(item),
});
if (shown) return;
} catch {
// Refresh the action when the local file cannot be revealed.
}
this.$set(this.localFileStatuses, this.itemKey(item), 'missing');
return;
}
this.$set(this.localFileStatuses, this.itemKey(item), 'downloading');
this.$store.dispatch('downUrl', $A.apiUrl(this.downloadUrl(item)));
setTimeout(() => this.refreshLocalFileStatuses([item]), 500);
},
download(item) {
$A.modalConfirm({
language: false,
title: this.$L('下载文件'),
okText: this.$L('立即下载'),
content: `${this.fileName(item)} (${$A.bytesToSize(item.size)})`,
onOk: () => this.$store.dispatch('downUrl', $A.apiUrl(url)),
onOk: () => this.$store.dispatch('downUrl', $A.apiUrl(this.downloadUrl(item))),
});
},
},
@ -724,12 +814,17 @@ export default {
.ivu-tooltip-rel button {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 5px;
color: $primary-text-color;
background: transparent;
cursor: pointer;
&:hover { color: $primary-color; background: rgba($primary-color, 0.08); }
&:disabled { cursor: default; }
.common-loading { width: 16px; height: 16px; }
}
}
.collaboration-grid {
@ -784,7 +879,19 @@ export default {
top: 10px;
right: 8px;
display: flex;
button { width: 28px; height: 28px; border: 0; color: $primary-text-color; background: transparent; cursor: pointer; }
button {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
color: $primary-text-color;
background: transparent;
cursor: pointer;
&:disabled { cursor: default; }
.common-loading { width: 16px; height: 16px; }
}
}
}
.empty-state {

View File

@ -0,0 +1,34 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true">
<path
d="M14 5H19V10"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"/>
<path
d="M19 5L11 13"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"/>
<path
d="M19 14V17A2 2 0 0 1 17 19H7A2 2 0 0 1 5 17V7A2 2 0 0 1 7 5H10"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"/>
</svg>
</template>
<script>
export default {
name: "CollaborationSourceIcon",
}
</script>

View File

@ -0,0 +1,67 @@
<template>
<span class="local-file-status-icon" aria-hidden="true">
<Loading v-if="status === 'downloading'"/>
<svg
v-else-if="status === 'available'"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M3.5 16V6A2 2 0 0 1 5.5 4H8.2A2 2 0 0 1 9.6 4.6L11 6H18.5A2 2 0 0 1 20.5 8V9"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"/>
<path
d="M5.2 19.5H17.7A2 2 0 0 0 19.6 18.1L21.2 12.8A1.5 1.5 0 0 0 19.8 10.8H8.3A2 2 0 0 0 6.4 12.2L3.6 17A1.7 1.7 0 0 0 5.2 19.5Z"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"/>
</svg>
<svg
v-else
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M12 3V15M7.5 10.5L12 15L16.5 10.5M5 20H19"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"/>
</svg>
</span>
</template>
<script>
export default {
name: "LocalFileStatusIcon",
props: {
status: {
type: String,
default: 'missing',
},
},
}
</script>
<style lang="scss" scoped>
.local-file-status-icon {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
.common-loading {
width: 16px;
height: 16px;
}
}
</style>

View File

@ -16,7 +16,7 @@
<div v-else class="file-box" @click="downFile">
<img class="file-thumb" :src="msg.thumb"/>
<div class="file-info">
<div class="file-name">{{ msg.name }}</div>
<div class="file-name" :title="msg.name">{{ msg.name }}</div>
<div class="file-size">{{ $A.bytesToSize(msg.size) }}</div>
</div>
<button
@ -27,9 +27,7 @@
: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"/>
<LocalFileStatusIcon :status="localFileStatus"/>
</button>
</div>
<div v-if="msg.percentage" class="file-percentage">
@ -40,7 +38,10 @@
</template>
<script>
import LocalFileStatusIcon from "./LocalFileStatusIcon.vue";
export default {
components: {LocalFileStatusIcon},
props: {
msgId: {
type: [Number, String],