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:
237899745
2026-04-04 00:19:20 +08:00
parent b02b168eb2
commit f40fd6003c
3 changed files with 247 additions and 65 deletions

View File

@@ -244,16 +244,25 @@ class LocalStorageClient {
const tempPath = `${destPath}.uploading_${Date.now()}`; const tempPath = `${destPath}.uploading_${Date.now()}`;
try { try {
// 复制到临时文件
fs.copyFileSync(localPath, tempPath);
// 如果目标文件存在,先删除 // 如果目标文件存在,先删除
if (fs.existsSync(destPath)) { if (fs.existsSync(destPath)) {
fs.unlinkSync(destPath); fs.unlinkSync(destPath);
} }
// 重命名临时文件为目标文件 // 优先尝试 rename同文件系统下瞬时完成大文件不再需要逐字节复制
fs.renameSync(tempPath, destPath); let movedDirectly = false;
try {
fs.renameSync(localPath, destPath);
movedDirectly = true;
} catch (renameErr) {
if (renameErr.code === 'EXDEV') {
// 跨文件系统,回退到 copy + rename
fs.copyFileSync(localPath, tempPath);
fs.renameSync(tempPath, destPath);
} else {
throw renameErr;
}
}
// 更新已使用空间(使用净增量) // 更新已使用空间(使用净增量)
if (netIncrease !== 0) { if (netIncrease !== 0) {
@@ -262,7 +271,7 @@ class LocalStorageClient {
} catch (error) { } catch (error) {
// 清理临时文件 // 清理临时文件
if (fs.existsSync(tempPath)) { if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath); try { fs.unlinkSync(tempPath); } catch (_) {}
} }
throw error; throw error;
} }

View File

@@ -1032,6 +1032,39 @@ async fn api_get_my_shares(
.await .await
} }
#[tauri::command]
async fn api_get_my_direct_links(
state: tauri::State<'_, ApiState>,
base_url: String,
) -> Result<BridgeResponse, String> {
request_with_optional_csrf(
&state.client,
Method::GET,
&base_url,
"/api/direct-link/my",
None,
false,
)
.await
}
#[tauri::command]
async fn api_delete_direct_link(
state: tauri::State<'_, ApiState>,
base_url: String,
link_id: i64,
) -> Result<BridgeResponse, String> {
request_with_optional_csrf(
&state.client,
Method::DELETE,
&base_url,
&format!("/api/direct-link/{}", link_id),
None,
false,
)
.await
}
#[tauri::command] #[tauri::command]
async fn api_create_share( async fn api_create_share(
state: tauri::State<'_, ApiState>, state: tauri::State<'_, ApiState>,
@@ -2074,6 +2107,8 @@ pub fn run() {
api_delete_file, api_delete_file,
api_get_download_url, api_get_download_url,
api_get_my_shares, api_get_my_shares,
api_get_my_direct_links,
api_delete_direct_link,
api_create_share, api_create_share,
api_delete_share, api_delete_share,
api_create_direct_link, api_create_direct_link,

View File

@@ -35,6 +35,17 @@ type ShareItem = {
storage_type?: string; 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 = { type OnlineDeviceItem = {
session_id: string; session_id: string;
client_type?: string; client_type?: string;
@@ -125,6 +136,8 @@ const files = ref<FileItem[]>([]);
const selectedFileName = ref(""); const selectedFileName = ref("");
const searchKeyword = ref(""); const searchKeyword = ref("");
const shares = ref<ShareItem[]>([]); const shares = ref<ShareItem[]>([]);
const directLinks = ref<DirectLinkItem[]>([]);
const directLinksLoading = ref(false);
const batchMode = ref(false); const batchMode = ref(false);
const batchSelectedNames = ref<string[]>([]); const batchSelectedNames = ref<string[]>([]);
@@ -253,11 +266,11 @@ const toast = reactive({
let toastTimer: ReturnType<typeof setTimeout> | null = null; let toastTimer: ReturnType<typeof setTimeout> | null = null;
const navItems = computed(() => [ const navItems = computed(() => [
{ key: "files" as const, label: "全部文件", hint: `${files.value.length}` }, { 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} 个任务` }, { 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}` }, { 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 ? "已配置" : "未配置" }, { 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 ? "发现新版本" : "系统与更新" }, { 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(() => { const sortedShares = computed(() => {
@@ -546,10 +559,10 @@ function applyNativeUploadProgress(payload: NativeUploadProgressEvent) {
} }
} }
updateTransferTask(taskId, { updateTransferTask(taskId, {
status: "uploading", status: boundedProgress >= 99.5 && !payload?.done ? "processing" : "uploading",
speed: transferSpeed, speed: boundedProgress >= 99.5 && !payload?.done ? "服务器处理中" : transferSpeed,
progress: Number(boundedProgress.toFixed(1)), progress: Number(boundedProgress.toFixed(1)),
note: `${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)}`, note: boundedProgress >= 99.5 && !payload?.done ? "分片已上传完成,等待服务器处理..." : `${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)}`,
}); });
if (taskId === uploadRuntime.taskId) { if (taskId === uploadRuntime.taskId) {
@@ -593,6 +606,20 @@ function fileExtLabel(item: FileItem) {
return ext.slice(0, 4); 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) { function matchFileTypeFilter(item: FileItem, type: string) {
if (item.isDirectory || item.type === "directory") return false; if (item.isDirectory || item.type === "directory") return false;
const name = String(item.name || "").toLowerCase(); const name = String(item.name || "").toLowerCase();
@@ -670,13 +697,14 @@ function getTaskStatusLabel(status: string) {
if (status === "queued") return "排队中"; if (status === "queued") return "排队中";
if (status === "uploading") return "上传中"; if (status === "uploading") return "上传中";
if (status === "downloading") return "下载中"; if (status === "downloading") return "下载中";
if (status === "processing") return "服务器处理中";
if (status === "done") return "已完成"; if (status === "done") return "已完成";
if (status === "failed") return "失败"; if (status === "failed") return "失败";
return status; return status;
} }
function isTaskRunning(status: string) { function isTaskRunning(status: string) {
return status === "uploading" || status === "downloading"; return status === "uploading" || status === "downloading" || status === "processing";
} }
function removeTransferTask(taskId: string) { function removeTransferTask(taskId: string) {
@@ -1565,6 +1593,39 @@ async function loadShares(silent = false) {
if (!silent) sharesLoading.value = 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") { async function getSignedUrlForItem(item: FileItem, mode: "download" | "preview") {
const targetPath = buildItemPath(item); const targetPath = buildItemPath(item);
const response = await invokeBridge("api_get_download_url", { const response = await invokeBridge("api_get_download_url", {
@@ -2524,6 +2585,7 @@ watch(nav, async (next) => {
} }
if (next === "shares" && authenticated.value) { if (next === "shares" && authenticated.value) {
await loadShares(); await loadShares();
await loadDirectLinks();
return; return;
} }
if (next === "settings" && authenticated.value) { if (next === "settings" && authenticated.value) {
@@ -2645,7 +2707,10 @@ onBeforeUnmount(() => {
:class="{ active: nav === item.key }" :class="{ active: nav === item.key }"
@click="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> <small>{{ item.hint }}</small>
</button> </button>
@@ -2705,7 +2770,7 @@ onBeforeUnmount(() => {
<button class="action-btn" @click="clearCompletedTransferTasks">清理已结束</button> <button class="action-btn" @click="clearCompletedTransferTasks">清理已结束</button>
</template> </template>
<template v-else-if="nav === 'shares'"> <template v-else-if="nav === 'shares'">
<button class="action-btn" @click="loadShares()">刷新分享</button> <button class="action-btn" @click="loadShares(); loadDirectLinks()">刷新分享</button>
</template> </template>
<template v-else-if="nav === 'sync'"> <template v-else-if="nav === 'sync'">
<button class="action-btn" :disabled="syncState.syncing || syncState.scanning" @click="runSyncOnce('manual')"> <button class="action-btn" :disabled="syncState.syncing || syncState.scanning" @click="runSyncOnce('manual')">
@@ -2764,13 +2829,8 @@ onBeforeUnmount(() => {
{{ isBatchSelected(item.name) ? "" : "" }} {{ isBatchSelected(item.name) ? "" : "" }}
</div> </div>
<div class="file-icon-shell" :class="`kind-${fileVisualKind(item)}`"> <div class="file-icon-shell" :class="`kind-${fileVisualKind(item)}`">
<template v-if="item.isDirectory || item.type === 'directory'"> <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 class="folder-tab" /> <span v-if="!item.isDirectory && item.type !== 'directory'" class="file-ext">{{ fileExtLabel(item) }}</span>
</template>
<template v-else>
<span class="file-corner" />
<span class="file-ext">{{ fileExtLabel(item) }}</span>
</template>
</div> </div>
<div class="file-name" :title="item.displayName || item.name"> <div class="file-name" :title="item.displayName || item.name">
<template v-if="isInlineRenaming(item)"> <template v-if="isInlineRenaming(item)">
@@ -2832,31 +2892,58 @@ onBeforeUnmount(() => {
<template v-else-if="nav === 'shares'"> <template v-else-if="nav === 'shares'">
<div class="panel-head"> <div class="panel-head">
<h3>我的分享</h3> <h3>我的分享</h3>
<span>仅展示已分享文件及操作</span> <span>分享链接与直链管理</span>
</div> </div>
<div v-if="sharesLoading" class="empty-tip">正在加载分享列表...</div> <div class="shares-section">
<div v-else-if="sortedShares.length === 0" class="empty-tip">暂无分享记录</div> <div class="section-label">分享链接</div>
<div v-else class="share-list"> <div v-if="sharesLoading" class="empty-tip">正在加载分享列表...</div>
<div v-for="share in sortedShares" :key="share.id" class="share-item"> <div v-else-if="sortedShares.length === 0" class="empty-tip">暂无分享记录</div>
<div class="share-main"> <div v-else class="share-list">
<div class="share-title"> <div v-for="share in sortedShares" :key="share.id" class="share-item">
<strong :title="share.share_path">{{ getShareDisplayName(share) }}</strong> <div class="share-main">
<span class="share-badge">{{ share.share_type === "directory" ? "文件夹" : "文件" }}</span> <div class="share-title">
<span class="share-badge">{{ share.has_password ? "密码保护" : "公开" }}</span> <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>
<div class="share-link" :title="share.share_url">{{ share.share_url }}</div> <div class="share-actions">
<div class="share-meta"> <button class="action-btn" @click="openShareLink(share)">打开</button>
<span>分享码 {{ share.share_code }}</span> <button class="action-btn" @click="copyShareLink(share)">复制</button>
<span>访问 {{ share.view_count || 0 }}</span> <button class="action-btn danger" @click="requestDeleteShare(share)">删除</button>
<span>下载 {{ share.download_count || 0 }}</span>
<span>到期 {{ getShareExpireLabel(share.expires_at) }}</span>
</div> </div>
</div> </div>
<div class="share-actions"> </div>
<button class="action-btn" @click="openShareLink(share)">打开</button> </div>
<button class="action-btn" @click="copyShareLink(share)">复制</button>
<button class="action-btn danger" @click="requestDeleteShare(share)">删除</button> <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> </div>
</div> </div>
@@ -3459,6 +3546,19 @@ select:focus {
gap: 2px; 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 { .nav-btn span {
font-size: 14px; font-size: 14px;
color: #223244; color: #223244;
@@ -3478,7 +3578,8 @@ select:focus {
} }
.nav-btn.active span, .nav-btn.active span,
.nav-btn.active small { .nav-btn.active small,
.nav-btn.active .nav-icon {
color: #fff; color: #fff;
} }
@@ -3742,7 +3843,7 @@ select:focus {
.icon-grid { .icon-grid {
display: grid; display: grid;
gap: 8px; gap: 8px;
grid-template-columns: repeat(auto-fill, 108px); grid-template-columns: repeat(auto-fill, 120px);
justify-content: flex-start; justify-content: flex-start;
align-content: start; align-content: start;
min-height: 0; min-height: 0;
@@ -3766,20 +3867,21 @@ select:focus {
.file-card { .file-card {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 9px; border-radius: 10px;
background: transparent; background: transparent;
text-align: left; text-align: left;
padding: 7px 6px 6px; padding: 8px 6px 6px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 4px; gap: 6px;
width: 108px; width: 120px;
min-height: 124px; min-height: 130px;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
} }
.file-card:focus-visible { .file-card:focus-visible {
@@ -3832,23 +3934,32 @@ select:focus {
} }
.file-icon-shell { .file-icon-shell {
width: 60px; width: 64px;
height: 50px; height: 56px;
position: relative; position: relative;
margin-top: 1px; margin-top: 1px;
border-radius: 10px; border-radius: 12px;
border: 1px solid #cfdced; border: 1px solid rgba(0, 0, 0, 0.08);
background: linear-gradient(180deg, #7ab5ff 0%, #4689dd 100%); 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 { .file-icon-shell.kind-folder {
height: 44px; height: 52px;
margin-top: 8px; margin-top: 4px;
border-radius: 8px; border-radius: 10px;
border-color: #d8b860; border-color: rgba(210, 170, 50, 0.3);
background: linear-gradient(180deg, #f2c85e 0%, #e3b145 100%); 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 { .folder-tab {
@@ -3877,13 +3988,14 @@ select:focus {
.file-ext { .file-ext {
position: absolute; position: absolute;
left: 50%; left: 50%;
bottom: 7px; bottom: 4px;
transform: translateX(-50%); transform: translateX(-50%);
color: #fff; color: rgba(255, 255, 255, 0.95);
font-size: 10px; font-size: 9px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.4px; letter-spacing: 0.5px;
line-height: 1; line-height: 1;
text-transform: uppercase;
} }
.file-icon-shell.kind-document { .file-icon-shell.kind-document {
@@ -4679,4 +4791,30 @@ select:focus {
width: 108px; 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> </style>