|
|
@@ -0,0 +1,710 @@
|
|
|
+<template>
|
|
|
+ <div
|
|
|
+ :class="['jeesite-header-action-button', `${prefixCls}-trigger`, { 'is-busy': activeCount > 0 }]"
|
|
|
+ :title="activeCount > 0 ? `正在下载 ${activeCount} 个文件` : '下载管理'"
|
|
|
+ @click="openModal(true)"
|
|
|
+ >
|
|
|
+ <span class="dl-trigger__icon"><Icon icon="i-mdi:download-outline" /></span>
|
|
|
+ <span v-if="activeCount > 0" class="dl-trigger__badge">{{ activeCount }}</span>
|
|
|
+ <span v-else-if="items.length" class="dl-trigger__dot"></span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <BasicModal v-bind="$attrs" title="下载管理" :width="720" :centered="true" :footer="null" @register="registerModal">
|
|
|
+ <div :class="prefixCls">
|
|
|
+ <!-- 保存位置:一行紧凑展示 -->
|
|
|
+ <div class="dl-path">
|
|
|
+ <span class="dl-path__icon"><FolderOutlined /></span>
|
|
|
+ <span class="dl-path__label">保存到</span>
|
|
|
+ <span class="dl-path__value" :title="downloadDir">{{ downloadDir || '未获取到下载目录' }}</span>
|
|
|
+ <span class="dl-path__actions">
|
|
|
+ <button class="dl-text-btn" type="button" :disabled="!desktop || busy" @click="handleChangeDir">更改</button>
|
|
|
+ <button class="dl-text-btn" type="button" :disabled="!desktop || busy" @click="handleOpenDir">打开</button>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 下载记录 -->
|
|
|
+ <header class="dl-head">
|
|
|
+ <span class="dl-head__title">下载记录</span>
|
|
|
+ <span class="dl-head__count">{{ items.length }}</span>
|
|
|
+ <span class="dl-head__spacer"></span>
|
|
|
+ <Tooltip title="刷新列表">
|
|
|
+ <button class="dl-icon-btn" type="button" :disabled="!desktop || busy" @click="handleRefresh">
|
|
|
+ <ReloadOutlined />
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ <Tooltip title="清空记录(不删文件)">
|
|
|
+ <button
|
|
|
+ class="dl-icon-btn is-danger"
|
|
|
+ type="button"
|
|
|
+ :disabled="!desktop || !items.length || busy"
|
|
|
+ @click="handleClear"
|
|
|
+ >
|
|
|
+ <DeleteOutlined />
|
|
|
+ </button>
|
|
|
+ </Tooltip>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <div v-if="!desktop" class="dl-blank">
|
|
|
+ <DesktopOutlined class="dl-blank__icon" />
|
|
|
+ <div class="dl-blank__title">当前不在桌面客户端内</div>
|
|
|
+ <div class="dl-blank__desc">下载记录保存在本机,请在桌面客户端里查看与管理。</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else-if="!items.length" class="dl-blank">
|
|
|
+ <InboxOutlined class="dl-blank__icon" />
|
|
|
+ <div class="dl-blank__title">还没有下载记录</div>
|
|
|
+ <div class="dl-blank__desc">下载文件、导出报告后,会按上面的保存位置存到本机并出现在这里。</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <ul v-else class="dl-list">
|
|
|
+ <li v-for="item in items" :key="item.id" class="dl-item">
|
|
|
+ <div class="dl-item__icon" :class="`is-${fileKind(item.fileName)}`">
|
|
|
+ <SyncOutlined v-if="item.status === 'downloading'" spin />
|
|
|
+ <component :is="fileIcon(item.fileName)" v-else />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="dl-item__main">
|
|
|
+ <div class="dl-item__name" :title="item.savedPath || item.fileName">{{ item.fileName }}</div>
|
|
|
+
|
|
|
+ <!-- 下载中:进度条 + 百分比 -->
|
|
|
+ <div v-if="item.status === 'downloading'" class="dl-item__progress">
|
|
|
+ <span class="dl-item__bar">
|
|
|
+ <span class="dl-item__bar-fill" :style="{ width: `${progressPercent(item)}%` }"></span>
|
|
|
+ </span>
|
|
|
+ <span class="dl-item__percent">{{ progressText(item) }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else-if="item.status === 'failed'" class="dl-item__meta is-failed">
|
|
|
+ <span>{{ item.message || '下载失败' }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-else class="dl-item__meta">
|
|
|
+ <span>{{ formatSize(item.bytes) }}</span>
|
|
|
+ <span class="dl-item__dot">·</span>
|
|
|
+ <span>{{ formatTime(item.at) }}</span>
|
|
|
+ <span v-if="item.exists === false" class="dl-item__missing">
|
|
|
+ <ExclamationCircleOutlined />
|
|
|
+ 文件已不在原位置
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 行操作:文字按钮(下载中的记录不给「打开/所在目录」) -->
|
|
|
+ <div class="dl-item__actions">
|
|
|
+ <button
|
|
|
+ class="dl-text-btn"
|
|
|
+ type="button"
|
|
|
+ :disabled="busy || item.status === 'downloading'"
|
|
|
+ @click="handleOpenFile(item)"
|
|
|
+ >
|
|
|
+ 打开
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ class="dl-text-btn"
|
|
|
+ type="button"
|
|
|
+ :disabled="busy || item.status === 'downloading'"
|
|
|
+ @click="handleReveal(item)"
|
|
|
+ >
|
|
|
+ 所在目录
|
|
|
+ </button>
|
|
|
+ <button class="dl-text-btn is-danger" type="button" :disabled="busy" @click="handleRemove(item)">
|
|
|
+ 移除
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </li>
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+ </BasicModal>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script lang="ts" setup name="AppDownloadManager">
|
|
|
+ /**
|
|
|
+ * 下载管理器:弹框形式列出本机下载记录,支持打开文件 / 定位文件 / 移除记录与更改保存位置。
|
|
|
+ *
|
|
|
+ * <p>记录由主进程维护(见 electron/service/download/downloadService.js):页面里的下载都经
|
|
|
+ * core/utils/file/download.ts 落到「保存位置」这个目录,这里只是它的界面。入口在顶栏设置图标前。
|
|
|
+ */
|
|
|
+ import { computed, onBeforeUnmount, ref, unref, watch } from 'vue';
|
|
|
+ import { Tooltip } from 'ant-design-vue';
|
|
|
+ import {
|
|
|
+ DeleteOutlined,
|
|
|
+ DesktopOutlined,
|
|
|
+ ExclamationCircleOutlined,
|
|
|
+ FileExcelOutlined,
|
|
|
+ FileImageOutlined,
|
|
|
+ FilePdfOutlined,
|
|
|
+ FileTextOutlined,
|
|
|
+ FileWordOutlined,
|
|
|
+ FileZipOutlined,
|
|
|
+ FolderOpenOutlined,
|
|
|
+ FolderOutlined,
|
|
|
+ InboxOutlined,
|
|
|
+ ReloadOutlined,
|
|
|
+ SyncOutlined,
|
|
|
+ } from '@ant-design/icons-vue';
|
|
|
+ import { BasicModal, useModal } from '@/core/components/Modal';
|
|
|
+ import { Icon } from '@/core/components/Icon';
|
|
|
+ import { useDesign } from '@/core/hooks/web/useDesign';
|
|
|
+ import { useMessage } from '@/core/hooks/web/useMessage';
|
|
|
+ import { pickDirectory } from '@/core/utils/desktop/pathPicker';
|
|
|
+ import {
|
|
|
+ clearDownloads,
|
|
|
+ isDesktopDownloadAvailable,
|
|
|
+ listDownloads,
|
|
|
+ openDownloadDir,
|
|
|
+ openDownloadFile,
|
|
|
+ onDownloadProgress,
|
|
|
+ removeDownload,
|
|
|
+ revealDownload,
|
|
|
+ setDownloadDir,
|
|
|
+ type DownloadItem,
|
|
|
+ } from '@/core/utils/desktop/downloader';
|
|
|
+
|
|
|
+ const { prefixCls } = useDesign('app-download-manager');
|
|
|
+ const { createMessage, createConfirm } = useMessage();
|
|
|
+ const [registerModal, { openModal, closeModal, getOpen }] = useModal();
|
|
|
+
|
|
|
+ const desktop = isDesktopDownloadAvailable();
|
|
|
+ const busy = ref(false);
|
|
|
+ const downloadDir = ref('');
|
|
|
+ const items = ref<DownloadItem[]>([]);
|
|
|
+
|
|
|
+ /** 正在下载的条数:>0 时顶栏图标做动画 */
|
|
|
+ const activeCount = computed(() => items.value.filter((item) => item.status === 'downloading').length);
|
|
|
+
|
|
|
+ /** 进度百分比;服务端没给总大小时按"已下载多少"显示,不编造百分比 */
|
|
|
+ const progressPercent = (item: DownloadItem) => {
|
|
|
+ const total = Number(item.total) || 0;
|
|
|
+ const received = Number(item.received) || 0;
|
|
|
+ if (!total) return received > 0 ? 100 : 0;
|
|
|
+ return Math.min(100, Math.round((received / total) * 100));
|
|
|
+ };
|
|
|
+
|
|
|
+ const progressText = (item: DownloadItem) => {
|
|
|
+ const total = Number(item.total) || 0;
|
|
|
+ if (!total) return `已接收 ${formatSize(Number(item.received) || 0)}`;
|
|
|
+ return `${progressPercent(item)}%(${formatSize(Number(item.received) || 0)} / ${formatSize(total)})`;
|
|
|
+ };
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 订阅主进程的进度推送:下载中/完成/失败都会来。
|
|
|
+ * 新记录(弹框没打开时开始的下载)也会在这里进列表,所以顶栏小圆点/动画一直准。
|
|
|
+ */
|
|
|
+ const stopProgress = onDownloadProgress((item) => {
|
|
|
+ if (!item?.id) return;
|
|
|
+ const index = items.value.findIndex((entry) => entry.id === item.id);
|
|
|
+ if (index >= 0) {
|
|
|
+ items.value[index] = { ...items.value[index], ...item };
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ items.value = [item, ...items.value];
|
|
|
+ });
|
|
|
+
|
|
|
+ onBeforeUnmount(() => stopProgress());
|
|
|
+
|
|
|
+ /** 打开弹框时刷一次;关闭时也顺带取一次,用来决定顶栏入口要不要亮小圆点 */
|
|
|
+ watch(
|
|
|
+ () => unref(getOpen),
|
|
|
+ (open) => {
|
|
|
+ void refresh();
|
|
|
+ },
|
|
|
+ { immediate: false },
|
|
|
+ );
|
|
|
+ void refresh();
|
|
|
+
|
|
|
+ async function refresh() {
|
|
|
+ if (!desktop) return;
|
|
|
+ if (unref(getOpen)) busy.value = true;
|
|
|
+ try {
|
|
|
+ const result = await listDownloads();
|
|
|
+ downloadDir.value = result.dir;
|
|
|
+ items.value = result.items;
|
|
|
+ } catch (error) {
|
|
|
+ // 顶栏入口上的静默拉取失败不打扰用户;弹框打开时的失败要提示
|
|
|
+ if (unref(getOpen)) createMessage.error(error instanceof Error ? error.message : '读取下载列表失败');
|
|
|
+ } finally {
|
|
|
+ busy.value = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleRefresh = () => void refresh();
|
|
|
+
|
|
|
+ const formatSize = (bytes: number) => {
|
|
|
+ const size = Number(bytes) || 0;
|
|
|
+ if (size < 1024) return `${size} B`;
|
|
|
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
|
|
+ if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
|
|
+ return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
|
+ };
|
|
|
+
|
|
|
+ const formatTime = (at: string) => {
|
|
|
+ const date = new Date(at);
|
|
|
+ if (Number.isNaN(date.getTime())) return '';
|
|
|
+ const pad = (value: number) => String(value).padStart(2, '0');
|
|
|
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
|
+ };
|
|
|
+
|
|
|
+ /** 按扩展名归类,图标与底色跟着变(用 antd 图标组件,避免 UnoCSS 扫不到运行时拼的类名) */
|
|
|
+ const fileKind = (fileName: string) => {
|
|
|
+ const ext =
|
|
|
+ String(fileName || '')
|
|
|
+ .split('.')
|
|
|
+ .pop()
|
|
|
+ ?.toLowerCase() || '';
|
|
|
+ if (['xls', 'xlsx', 'csv'].includes(ext)) return 'excel';
|
|
|
+ if (['doc', 'docx'].includes(ext)) return 'word';
|
|
|
+ if (ext === 'pdf') return 'pdf';
|
|
|
+ if (['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) return 'image';
|
|
|
+ if (['zip', 'rar', '7z', 'gz', 'tar'].includes(ext)) return 'zip';
|
|
|
+ return 'file';
|
|
|
+ };
|
|
|
+
|
|
|
+ const fileIcon = (fileName: string) => {
|
|
|
+ const kind = fileKind(fileName);
|
|
|
+ if (kind === 'excel') return FileExcelOutlined;
|
|
|
+ if (kind === 'word') return FileWordOutlined;
|
|
|
+ if (kind === 'pdf') return FilePdfOutlined;
|
|
|
+ if (kind === 'image') return FileImageOutlined;
|
|
|
+ if (kind === 'zip') return FileZipOutlined;
|
|
|
+ return FileTextOutlined;
|
|
|
+ };
|
|
|
+
|
|
|
+ async function handleChangeDir() {
|
|
|
+ const picked = await pickDirectory();
|
|
|
+ if (!picked) return;
|
|
|
+ busy.value = true;
|
|
|
+ try {
|
|
|
+ downloadDir.value = await setDownloadDir(picked);
|
|
|
+ createMessage.success('保存位置已更新');
|
|
|
+ } catch (error) {
|
|
|
+ createMessage.error(error instanceof Error ? error.message : '设置下载目录失败');
|
|
|
+ } finally {
|
|
|
+ busy.value = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleOpenDir() {
|
|
|
+ try {
|
|
|
+ await openDownloadDir();
|
|
|
+ } catch (error) {
|
|
|
+ createMessage.error(error instanceof Error ? error.message : '打开下载目录失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleOpenFile(item: DownloadItem) {
|
|
|
+ try {
|
|
|
+ await openDownloadFile(item.id);
|
|
|
+ } catch (error) {
|
|
|
+ // 文件被移动/删除时主进程会给出具体路径,顺便刷新一下列表状态
|
|
|
+ createMessage.error(error instanceof Error ? error.message : '打开文件失败');
|
|
|
+ void refresh();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleReveal(item: DownloadItem) {
|
|
|
+ try {
|
|
|
+ await revealDownload(item.id);
|
|
|
+ } catch (error) {
|
|
|
+ createMessage.error(error instanceof Error ? error.message : '定位文件失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleRemove(item: DownloadItem) {
|
|
|
+ try {
|
|
|
+ await removeDownload(item.id);
|
|
|
+ items.value = items.value.filter((entry) => entry.id !== item.id);
|
|
|
+ } catch (error) {
|
|
|
+ createMessage.error(error instanceof Error ? error.message : '移除记录失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleClear() {
|
|
|
+ createConfirm({
|
|
|
+ iconType: 'warning',
|
|
|
+ title: '清空下载记录',
|
|
|
+ content: '只清空列表,磁盘上的文件不会被删除。',
|
|
|
+ okText: '清空',
|
|
|
+ okType: 'primary',
|
|
|
+ okButtonProps: { danger: true },
|
|
|
+ cancelText: '取消',
|
|
|
+ onOk: async () => {
|
|
|
+ try {
|
|
|
+ await clearDownloads();
|
|
|
+ items.value = [];
|
|
|
+ createMessage.success('已清空下载记录');
|
|
|
+ } catch (error) {
|
|
|
+ createMessage.error(error instanceof Error ? error.message : '清空记录失败');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="less">
|
|
|
+ @prefix-cls: ~'jeesite-app-download-manager';
|
|
|
+
|
|
|
+ .@{prefix-cls}-trigger {
|
|
|
+ position: relative;
|
|
|
+
|
|
|
+ /* 下载中:箭头上下轻跳,提示还有任务在跑 */
|
|
|
+ &.is-busy .dl-trigger__icon {
|
|
|
+ animation: dl-trigger-bob 1.1s ease-in-out infinite;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__badge {
|
|
|
+ position: absolute;
|
|
|
+ top: 2px;
|
|
|
+ right: 3px;
|
|
|
+ min-width: 15px;
|
|
|
+ height: 15px;
|
|
|
+ padding: 0 4px;
|
|
|
+ font-size: 10px;
|
|
|
+ line-height: 15px;
|
|
|
+ color: #fff;
|
|
|
+ text-align: center;
|
|
|
+ background: #2f54eb;
|
|
|
+ border: 1px solid #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__dot {
|
|
|
+ position: absolute;
|
|
|
+ top: 9px;
|
|
|
+ right: 11px;
|
|
|
+ width: 6px;
|
|
|
+ height: 6px;
|
|
|
+ background: #2f54eb;
|
|
|
+ border: 1px solid #fff;
|
|
|
+ border-radius: 50%;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .@{prefix-cls} {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+
|
|
|
+ /* 保存位置:一行,不占高度 */
|
|
|
+ .dl-path {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ height: 34px;
|
|
|
+ padding: 0 10px;
|
|
|
+ font-size: 12.5px;
|
|
|
+ background: #f6f8fc;
|
|
|
+ border: 1px solid #e8ecf4;
|
|
|
+ border-radius: 8px;
|
|
|
+
|
|
|
+ &__icon {
|
|
|
+ display: inline-flex;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #2f54eb;
|
|
|
+ flex: 0 0 auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__label {
|
|
|
+ color: #6b7280;
|
|
|
+ flex: 0 0 auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__value {
|
|
|
+ min-width: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ font-family: Consolas, Monaco, 'Courier New', monospace;
|
|
|
+ color: #374151;
|
|
|
+ white-space: nowrap;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ flex: 1 1 auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 2px;
|
|
|
+ flex: 0 0 auto;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 纯文字小按钮(保存位置、行操作) */
|
|
|
+ .dl-text-btn {
|
|
|
+ padding: 0 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 22px;
|
|
|
+ color: #2f54eb;
|
|
|
+ background: transparent;
|
|
|
+ border: 0;
|
|
|
+ border-radius: 6px;
|
|
|
+ white-space: nowrap;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.15s ease-out;
|
|
|
+
|
|
|
+ &:hover:not(:disabled) {
|
|
|
+ background: #e8eeff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-danger {
|
|
|
+ color: #d4380d;
|
|
|
+
|
|
|
+ &:hover:not(:disabled) {
|
|
|
+ background: #fdeceb;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &:disabled {
|
|
|
+ color: #b6bec9;
|
|
|
+ cursor: not-allowed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 图标元素按钮(刷新 / 清空 / 行操作),与顶栏动作按钮同一观感 */
|
|
|
+ .dl-icon-btn {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 26px;
|
|
|
+ height: 26px;
|
|
|
+ padding: 0;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #5b6472;
|
|
|
+ background: transparent;
|
|
|
+ border: 0;
|
|
|
+ border-radius: 7px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition:
|
|
|
+ background 0.15s ease-out,
|
|
|
+ color 0.15s ease-out;
|
|
|
+
|
|
|
+ &:hover:not(:disabled) {
|
|
|
+ color: #2f54eb;
|
|
|
+ background: #e8eeff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-danger:hover:not(:disabled) {
|
|
|
+ color: #d4380d;
|
|
|
+ background: #fdeceb;
|
|
|
+ }
|
|
|
+
|
|
|
+ &:disabled {
|
|
|
+ color: #c9cfd8;
|
|
|
+ cursor: not-allowed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .dl-head {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ margin-top: 12px;
|
|
|
+ padding-bottom: 8px;
|
|
|
+ border-bottom: 1px solid #eef1f6;
|
|
|
+
|
|
|
+ &__title {
|
|
|
+ font-size: 13.5px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #111827;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__count {
|
|
|
+ padding: 0 7px;
|
|
|
+ font-size: 11px;
|
|
|
+ line-height: 17px;
|
|
|
+ color: #4b5563;
|
|
|
+ background: #eef2f7;
|
|
|
+ border-radius: 9px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__spacer {
|
|
|
+ flex: 1 1 auto;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .dl-list {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 6px;
|
|
|
+ margin: 10px 0 0;
|
|
|
+ padding: 0 4px 0 0;
|
|
|
+ max-height: 46vh;
|
|
|
+ overflow: auto;
|
|
|
+ list-style: none;
|
|
|
+ }
|
|
|
+
|
|
|
+ .dl-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 8px 10px;
|
|
|
+ border: 1px solid transparent;
|
|
|
+ border-radius: 9px;
|
|
|
+ transition:
|
|
|
+ border-color 0.15s ease-out,
|
|
|
+ background 0.15s ease-out;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: #f8fafd;
|
|
|
+ border-color: #e8eefb;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__icon {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 34px;
|
|
|
+ height: 34px;
|
|
|
+ font-size: 16px;
|
|
|
+ border-radius: 9px;
|
|
|
+ flex: 0 0 auto;
|
|
|
+
|
|
|
+ &.is-excel {
|
|
|
+ color: #15803d;
|
|
|
+ background: #e8f7ee;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-word {
|
|
|
+ color: #1d4ed8;
|
|
|
+ background: #e8efff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-pdf {
|
|
|
+ color: #b91c1c;
|
|
|
+ background: #fdecec;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-image {
|
|
|
+ color: #7c3aed;
|
|
|
+ background: #f1ebfe;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-zip {
|
|
|
+ color: #b45309;
|
|
|
+ background: #fdf1e3;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.is-file {
|
|
|
+ color: #4b5563;
|
|
|
+ background: #eef1f6;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &__main {
|
|
|
+ min-width: 0;
|
|
|
+ flex: 1 1 auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__name {
|
|
|
+ overflow: hidden;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #111827;
|
|
|
+ white-space: nowrap;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__meta {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ margin-top: 2px;
|
|
|
+ font-size: 11.5px;
|
|
|
+ color: #9ca3af;
|
|
|
+
|
|
|
+ &.is-failed {
|
|
|
+ color: #d4380d;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 下载进度 */
|
|
|
+ &__progress {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin-top: 4px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__bar {
|
|
|
+ position: relative;
|
|
|
+ height: 4px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #eef2f7;
|
|
|
+ border-radius: 3px;
|
|
|
+ flex: 1 1 auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__bar-fill {
|
|
|
+ display: block;
|
|
|
+ height: 100%;
|
|
|
+ background: linear-gradient(90deg, #4f7cf7 0%, #2f54eb 100%);
|
|
|
+ border-radius: 3px;
|
|
|
+ transition: width 0.2s ease-out;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__percent {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ font-size: 11px;
|
|
|
+ color: #2f54eb;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__dot {
|
|
|
+ color: #d1d5db;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__missing {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 3px;
|
|
|
+ padding: 0 6px;
|
|
|
+ font-size: 11px;
|
|
|
+ line-height: 16px;
|
|
|
+ color: #b45309;
|
|
|
+ background: #fdf1e3;
|
|
|
+ border-radius: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+ flex: 0 0 auto;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @keyframes dl-trigger-bob {
|
|
|
+ 0%,
|
|
|
+ 100% {
|
|
|
+ transform: translateY(0);
|
|
|
+ }
|
|
|
+
|
|
|
+ 50% {
|
|
|
+ transform: translateY(2px);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .dl-blank {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: 6px;
|
|
|
+ padding: 44px 0 34px;
|
|
|
+
|
|
|
+ &__icon {
|
|
|
+ margin-bottom: 4px;
|
|
|
+ font-size: 38px;
|
|
|
+ color: #cbd5e1;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__title {
|
|
|
+ font-size: 13.5px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #4b5563;
|
|
|
+ }
|
|
|
+
|
|
|
+ &__desc {
|
|
|
+ max-width: 320px;
|
|
|
+ font-size: 12px;
|
|
|
+ line-height: 1.7;
|
|
|
+ color: #9ca3af;
|
|
|
+ text-align: center;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+</style>
|