feat: optimize file icons, UI, share direct links, and upload performance
- Replace CSS-only file icons with SVG icons for each file type (folder, image, video, audio, archive, document, app) - Add navigation icons to left sidebar (Baidu Netdisk style) - Enlarge file cards (108px -> 120px) with smoother transitions - Add direct links section to share page with copy/delete actions - Add Rust bridge commands for fetching and deleting direct links - Optimize local storage put() to use rename-first strategy instead of copyFileSync for instant large file completion - Show "server processing" status during upload finalization instead of appearing stuck Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,17 @@ type ShareItem = {
|
||||
storage_type?: string;
|
||||
};
|
||||
|
||||
type DirectLinkItem = {
|
||||
id: number;
|
||||
link_code: string;
|
||||
direct_url: string;
|
||||
file_path: string;
|
||||
file_name?: string;
|
||||
storage_type?: string;
|
||||
created_at?: string;
|
||||
expires_at?: string | null;
|
||||
};
|
||||
|
||||
type OnlineDeviceItem = {
|
||||
session_id: string;
|
||||
client_type?: string;
|
||||
@@ -125,6 +136,8 @@ const files = ref<FileItem[]>([]);
|
||||
const selectedFileName = ref("");
|
||||
const searchKeyword = ref("");
|
||||
const shares = ref<ShareItem[]>([]);
|
||||
const directLinks = ref<DirectLinkItem[]>([]);
|
||||
const directLinksLoading = ref(false);
|
||||
const batchMode = ref(false);
|
||||
const batchSelectedNames = ref<string[]>([]);
|
||||
|
||||
@@ -253,11 +266,11 @@ const toast = reactive({
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ key: "files" as const, label: "全部文件", hint: `${files.value.length} 项` },
|
||||
{ key: "transfers" as const, label: "传输列表", hint: `${transferTasks.value.length} 个任务` },
|
||||
{ key: "shares" as const, label: "我的分享", hint: `${shares.value.length} 条` },
|
||||
{ key: "sync" as const, label: "同步盘", hint: syncState.localDir ? "已配置" : "未配置" },
|
||||
{ key: "settings" as const, label: "设置", hint: updateState.available ? "发现新版本" : "系统与更新" },
|
||||
{ key: "files" as const, label: "全部文件", hint: `${files.value.length} 项`, icon: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" },
|
||||
{ key: "transfers" as const, label: "传输列表", hint: `${transferTasks.value.length} 个任务`, icon: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" },
|
||||
{ key: "shares" as const, label: "我的分享", hint: `${shares.value.length + directLinks.value.length} 条`, icon: "M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" },
|
||||
{ key: "sync" as const, label: "同步盘", hint: syncState.localDir ? "已配置" : "未配置", icon: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" },
|
||||
{ key: "settings" as const, label: "设置", hint: updateState.available ? "发现新版本" : "系统与更新", icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z" },
|
||||
]);
|
||||
|
||||
const sortedShares = computed(() => {
|
||||
@@ -546,10 +559,10 @@ function applyNativeUploadProgress(payload: NativeUploadProgressEvent) {
|
||||
}
|
||||
}
|
||||
updateTransferTask(taskId, {
|
||||
status: "uploading",
|
||||
speed: transferSpeed,
|
||||
status: boundedProgress >= 99.5 && !payload?.done ? "processing" : "uploading",
|
||||
speed: boundedProgress >= 99.5 && !payload?.done ? "服务器处理中" : transferSpeed,
|
||||
progress: Number(boundedProgress.toFixed(1)),
|
||||
note: `${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)}`,
|
||||
note: boundedProgress >= 99.5 && !payload?.done ? "分片已上传完成,等待服务器处理..." : `${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)}`,
|
||||
});
|
||||
|
||||
if (taskId === uploadRuntime.taskId) {
|
||||
@@ -593,6 +606,20 @@ function fileExtLabel(item: FileItem) {
|
||||
return ext.slice(0, 4);
|
||||
}
|
||||
|
||||
function fileIconSvg(kind: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
folder: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z",
|
||||
image: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z",
|
||||
video: "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z",
|
||||
audio: "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z",
|
||||
archive: "M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4",
|
||||
document: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z M9 13h6 M9 17h4",
|
||||
app: "M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5",
|
||||
file: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z",
|
||||
};
|
||||
return icons[kind] || icons.file;
|
||||
}
|
||||
|
||||
function matchFileTypeFilter(item: FileItem, type: string) {
|
||||
if (item.isDirectory || item.type === "directory") return false;
|
||||
const name = String(item.name || "").toLowerCase();
|
||||
@@ -670,13 +697,14 @@ function getTaskStatusLabel(status: string) {
|
||||
if (status === "queued") return "排队中";
|
||||
if (status === "uploading") return "上传中";
|
||||
if (status === "downloading") return "下载中";
|
||||
if (status === "processing") return "服务器处理中";
|
||||
if (status === "done") return "已完成";
|
||||
if (status === "failed") return "失败";
|
||||
return status;
|
||||
}
|
||||
|
||||
function isTaskRunning(status: string) {
|
||||
return status === "uploading" || status === "downloading";
|
||||
return status === "uploading" || status === "downloading" || status === "processing";
|
||||
}
|
||||
|
||||
function removeTransferTask(taskId: string) {
|
||||
@@ -1565,6 +1593,39 @@ async function loadShares(silent = false) {
|
||||
if (!silent) sharesLoading.value = false;
|
||||
}
|
||||
|
||||
async function loadDirectLinks(silent = false) {
|
||||
if (!silent) directLinksLoading.value = true;
|
||||
const response = await invokeBridge("api_get_my_direct_links", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
});
|
||||
if (response.ok && response.data?.success) {
|
||||
directLinks.value = Array.isArray(response.data.links) ? response.data.links : [];
|
||||
} else if (!silent) {
|
||||
showToast(response.data?.message || "获取直链列表失败", "error");
|
||||
}
|
||||
if (!silent) directLinksLoading.value = false;
|
||||
}
|
||||
|
||||
async function copyDirectLink(link: DirectLinkItem) {
|
||||
const url = link.direct_url || "";
|
||||
if (url) {
|
||||
await copyText(url, "直链已复制到剪贴板");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDirectLink(link: DirectLinkItem) {
|
||||
const response = await invokeBridge("api_delete_direct_link", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
linkId: link.id,
|
||||
});
|
||||
if (response.ok && response.data?.success) {
|
||||
showToast("直链已删除", "success");
|
||||
await loadDirectLinks(true);
|
||||
} else {
|
||||
showToast(response.data?.message || "删除直链失败", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function getSignedUrlForItem(item: FileItem, mode: "download" | "preview") {
|
||||
const targetPath = buildItemPath(item);
|
||||
const response = await invokeBridge("api_get_download_url", {
|
||||
@@ -2524,6 +2585,7 @@ watch(nav, async (next) => {
|
||||
}
|
||||
if (next === "shares" && authenticated.value) {
|
||||
await loadShares();
|
||||
await loadDirectLinks();
|
||||
return;
|
||||
}
|
||||
if (next === "settings" && authenticated.value) {
|
||||
@@ -2645,7 +2707,10 @@ onBeforeUnmount(() => {
|
||||
:class="{ active: nav === item.key }"
|
||||
@click="nav = item.key"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<div class="nav-btn-row">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path :d="item.icon" /></svg>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<small>{{ item.hint }}</small>
|
||||
</button>
|
||||
|
||||
@@ -2705,7 +2770,7 @@ onBeforeUnmount(() => {
|
||||
<button class="action-btn" @click="clearCompletedTransferTasks">清理已结束</button>
|
||||
</template>
|
||||
<template v-else-if="nav === 'shares'">
|
||||
<button class="action-btn" @click="loadShares()">刷新分享</button>
|
||||
<button class="action-btn" @click="loadShares(); loadDirectLinks()">刷新分享</button>
|
||||
</template>
|
||||
<template v-else-if="nav === 'sync'">
|
||||
<button class="action-btn" :disabled="syncState.syncing || syncState.scanning" @click="runSyncOnce('manual')">
|
||||
@@ -2764,13 +2829,8 @@ onBeforeUnmount(() => {
|
||||
{{ isBatchSelected(item.name) ? "✓" : "" }}
|
||||
</div>
|
||||
<div class="file-icon-shell" :class="`kind-${fileVisualKind(item)}`">
|
||||
<template v-if="item.isDirectory || item.type === 'directory'">
|
||||
<span class="folder-tab" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="file-corner" />
|
||||
<span class="file-ext">{{ fileExtLabel(item) }}</span>
|
||||
</template>
|
||||
<svg class="file-type-svg" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path :d="fileIconSvg(fileVisualKind(item))" /></svg>
|
||||
<span v-if="!item.isDirectory && item.type !== 'directory'" class="file-ext">{{ fileExtLabel(item) }}</span>
|
||||
</div>
|
||||
<div class="file-name" :title="item.displayName || item.name">
|
||||
<template v-if="isInlineRenaming(item)">
|
||||
@@ -2832,31 +2892,58 @@ onBeforeUnmount(() => {
|
||||
<template v-else-if="nav === 'shares'">
|
||||
<div class="panel-head">
|
||||
<h3>我的分享</h3>
|
||||
<span>仅展示已分享文件及操作</span>
|
||||
<span>分享链接与直链管理</span>
|
||||
</div>
|
||||
|
||||
<div v-if="sharesLoading" class="empty-tip">正在加载分享列表...</div>
|
||||
<div v-else-if="sortedShares.length === 0" class="empty-tip">暂无分享记录</div>
|
||||
<div v-else class="share-list">
|
||||
<div v-for="share in sortedShares" :key="share.id" class="share-item">
|
||||
<div class="share-main">
|
||||
<div class="share-title">
|
||||
<strong :title="share.share_path">{{ getShareDisplayName(share) }}</strong>
|
||||
<span class="share-badge">{{ share.share_type === "directory" ? "文件夹" : "文件" }}</span>
|
||||
<span class="share-badge">{{ share.has_password ? "密码保护" : "公开" }}</span>
|
||||
<div class="shares-section">
|
||||
<div class="section-label">分享链接</div>
|
||||
<div v-if="sharesLoading" class="empty-tip">正在加载分享列表...</div>
|
||||
<div v-else-if="sortedShares.length === 0" class="empty-tip">暂无分享记录</div>
|
||||
<div v-else class="share-list">
|
||||
<div v-for="share in sortedShares" :key="share.id" class="share-item">
|
||||
<div class="share-main">
|
||||
<div class="share-title">
|
||||
<strong :title="share.share_path">{{ getShareDisplayName(share) }}</strong>
|
||||
<span class="share-badge">{{ share.share_type === "directory" ? "文件夹" : "文件" }}</span>
|
||||
<span class="share-badge">{{ share.has_password ? "密码保护" : "公开" }}</span>
|
||||
</div>
|
||||
<div class="share-link" :title="share.share_url">{{ share.share_url }}</div>
|
||||
<div class="share-meta">
|
||||
<span>分享码 {{ share.share_code }}</span>
|
||||
<span>访问 {{ share.view_count || 0 }}</span>
|
||||
<span>下载 {{ share.download_count || 0 }}</span>
|
||||
<span>到期 {{ getShareExpireLabel(share.expires_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="share-link" :title="share.share_url">{{ share.share_url }}</div>
|
||||
<div class="share-meta">
|
||||
<span>分享码 {{ share.share_code }}</span>
|
||||
<span>访问 {{ share.view_count || 0 }}</span>
|
||||
<span>下载 {{ share.download_count || 0 }}</span>
|
||||
<span>到期 {{ getShareExpireLabel(share.expires_at) }}</span>
|
||||
<div class="share-actions">
|
||||
<button class="action-btn" @click="openShareLink(share)">打开</button>
|
||||
<button class="action-btn" @click="copyShareLink(share)">复制</button>
|
||||
<button class="action-btn danger" @click="requestDeleteShare(share)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="share-actions">
|
||||
<button class="action-btn" @click="openShareLink(share)">打开</button>
|
||||
<button class="action-btn" @click="copyShareLink(share)">复制</button>
|
||||
<button class="action-btn danger" @click="requestDeleteShare(share)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shares-section" style="margin-top: 20px;">
|
||||
<div class="section-label">直链列表</div>
|
||||
<div v-if="directLinksLoading" class="empty-tip">正在加载直链列表...</div>
|
||||
<div v-else-if="directLinks.length === 0" class="empty-tip">暂无直链记录</div>
|
||||
<div v-else class="share-list">
|
||||
<div v-for="link in directLinks" :key="link.id" class="share-item direct-link-item">
|
||||
<div class="share-main">
|
||||
<div class="share-title">
|
||||
<strong :title="link.file_path">{{ link.file_name || link.file_path.split('/').pop() || '未命名' }}</strong>
|
||||
<span class="share-badge direct-badge">直链</span>
|
||||
</div>
|
||||
<div class="share-link direct-link-url" :title="link.direct_url">{{ link.direct_url }}</div>
|
||||
<div class="share-meta">
|
||||
<span>到期 {{ getShareExpireLabel(link.expires_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="share-actions">
|
||||
<button class="action-btn" @click="copyDirectLink(link)">复制直链</button>
|
||||
<button class="action-btn danger" @click="deleteDirectLink(link)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3459,6 +3546,19 @@ select:focus {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-btn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
color: #4a6381;
|
||||
}
|
||||
|
||||
.nav-btn span {
|
||||
font-size: 14px;
|
||||
color: #223244;
|
||||
@@ -3478,7 +3578,8 @@ select:focus {
|
||||
}
|
||||
|
||||
.nav-btn.active span,
|
||||
.nav-btn.active small {
|
||||
.nav-btn.active small,
|
||||
.nav-btn.active .nav-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -3742,7 +3843,7 @@ select:focus {
|
||||
.icon-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, 108px);
|
||||
grid-template-columns: repeat(auto-fill, 120px);
|
||||
justify-content: flex-start;
|
||||
align-content: start;
|
||||
min-height: 0;
|
||||
@@ -3766,20 +3867,21 @@ select:focus {
|
||||
|
||||
.file-card {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
padding: 7px 6px 6px;
|
||||
padding: 8px 6px 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
width: 108px;
|
||||
min-height: 124px;
|
||||
gap: 6px;
|
||||
width: 120px;
|
||||
min-height: 130px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.file-card:focus-visible {
|
||||
@@ -3832,23 +3934,32 @@ select:focus {
|
||||
}
|
||||
|
||||
.file-icon-shell {
|
||||
width: 60px;
|
||||
height: 50px;
|
||||
width: 64px;
|
||||
height: 56px;
|
||||
position: relative;
|
||||
margin-top: 1px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #cfdced;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: linear-gradient(180deg, #7ab5ff 0%, #4689dd 100%);
|
||||
box-shadow: 0 2px 4px rgba(32, 77, 131, 0.14);
|
||||
box-shadow: 0 3px 8px rgba(32, 77, 131, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-type-svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.file-icon-shell.kind-folder {
|
||||
height: 44px;
|
||||
margin-top: 8px;
|
||||
border-radius: 8px;
|
||||
border-color: #d8b860;
|
||||
height: 52px;
|
||||
margin-top: 4px;
|
||||
border-radius: 10px;
|
||||
border-color: rgba(210, 170, 50, 0.3);
|
||||
background: linear-gradient(180deg, #f2c85e 0%, #e3b145 100%);
|
||||
box-shadow: 0 2px 4px rgba(152, 106, 14, 0.14);
|
||||
box-shadow: 0 3px 8px rgba(152, 106, 14, 0.15);
|
||||
}
|
||||
|
||||
.folder-tab {
|
||||
@@ -3877,13 +3988,14 @@ select:focus {
|
||||
.file-ext {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 7px;
|
||||
bottom: 4px;
|
||||
transform: translateX(-50%);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.file-icon-shell.kind-document {
|
||||
@@ -4679,4 +4791,30 @@ select:focus {
|
||||
width: 108px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #3a5274;
|
||||
margin-bottom: 10px;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.shares-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.direct-link-item {
|
||||
border-left: 3px solid #06b6d4;
|
||||
}
|
||||
|
||||
.direct-badge {
|
||||
background: #06b6d4 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.direct-link-url {
|
||||
color: #0891b2 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user