mirror of
https://github.com/kuaifan/dootask.git
synced 2026-09-10 14:08:57 +00:00
feat(file): sync collaboration download status
This commit is contained in:
parent
2a7d635729
commit
59aff7a356
20
electron/electron-down.js
vendored
20
electron/electron-down.js
vendored
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
93
electron/lib/download-manager.js
vendored
93
electron/lib/download-manager.js
vendored
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -34,6 +34,8 @@ last_verified: v1.8.89
|
||||
|
||||
文件消息和粘贴、插入到聊天正文中的图片都会汇总到协作文件。一条聊天消息包含多张图片时,每张图片会作为一条独立记录展示。图片会在列表和宫格中直接显示缩略图;缩略图不可用时显示对应的文件类型图标。
|
||||
|
||||
Electron 桌面客户端会同步会话文件与协作文件的本地下载状态:未下载时显示下载图标,下载中显示加载图标;下载完成且本地文件仍存在时显示文件夹图标,点击可在资源管理器或 Finder 中定位文件。清除下载历史,或移动、改名、删除本地文件后,操作会恢复为下载图标。正文图片等独立附件也支持相同操作。
|
||||
|
||||
## 权限与范围
|
||||
- 只展示当前用户仍有权访问的来源;退出会话、项目或任务权限变化后,对应文件不再显示。
|
||||
- 已撤回或删除的文件消息、正文图片不显示;编辑消息移除图片后,对应图片也不再显示。
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user