Files
vue-driven-cloud-storage/desktop-client/src/App.vue

3342 lines
92 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { openPath, openUrl } from "@tauri-apps/plugin-opener";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { getVersion } from "@tauri-apps/api/app";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview";
type NavKey = "files" | "transfers" | "shares" | "sync" | "updates";
type FileItem = {
name: string;
displayName?: string;
path?: string;
type: "file" | "directory";
size?: number;
sizeFormatted?: string;
modifiedAt?: string;
isDirectory?: boolean;
};
type ShareItem = {
id: number;
share_code: string;
share_url: string;
share_path: string;
share_type: "file" | "directory";
has_password?: boolean;
view_count?: number;
download_count?: number;
created_at?: string;
expires_at?: string | null;
storage_type?: string;
};
type BridgeResponse = {
ok: boolean;
status: number;
data: Record<string, any>;
};
type NativeDownloadProgressEvent = {
taskId?: string;
downloadedBytes?: number;
totalBytes?: number | null;
progress?: number | null;
resumedBytes?: number;
done?: boolean;
};
type LocalSyncFileItem = {
path: string;
relativePath: string;
size: number;
modifiedMs: number;
};
type TransferTaskKind = "upload" | "download";
type TransferTaskStatus = "queued" | "uploading" | "downloading" | "done" | "failed";
type TransferTask = {
id: string;
kind: TransferTaskKind;
name: string;
speed: string;
progress: number;
status: TransferTaskStatus;
note?: string;
filePath?: string;
targetPath?: string;
downloadUrl?: string;
fileName?: string;
};
const nav = ref<NavKey>("files");
const authenticated = ref(false);
const user = ref<Record<string, any> | null>(null);
const appConfig = reactive({
baseUrl: "https://cs.workyai.cn",
});
const loginForm = reactive({
username: "",
password: "",
captcha: "",
});
const loginState = reactive({
loading: false,
error: "",
needCaptcha: false,
});
const pathState = reactive({
currentPath: "/",
loading: false,
error: "",
mode: "directory" as "directory" | "search",
});
const files = ref<FileItem[]>([]);
const selectedFileName = ref("");
const searchKeyword = ref("");
const shares = ref<ShareItem[]>([]);
const batchMode = ref(false);
const batchSelectedNames = ref<string[]>([]);
const transferTasks = ref<TransferTask[]>([]);
const sharesLoading = ref(false);
const transferQueue = reactive({
paused: false,
});
const fileViewState = reactive({
filter: "all",
sortBy: "modifiedAt",
sortOrder: "desc" as "asc" | "desc",
});
const syncState = reactive({
localDir: "",
remoteBasePath: "/",
autoEnabled: false,
intervalMinutes: 15,
syncing: false,
scanning: false,
pendingCount: 0,
uploadedCount: 0,
failedCount: 0,
lastRunAt: "",
lastSummary: "",
nextRunAt: "",
});
const updateState = reactive({
currentVersion: "0.1.3",
latestVersion: "",
available: false,
mandatory: false,
checking: false,
downloadUrl: "",
releaseNotes: "",
lastCheckedAt: "",
message: "",
});
const updateRuntime = reactive({
downloading: false,
});
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
item: null as FileItem | null,
});
const dropState = reactive({
active: false,
uploading: false,
total: 0,
done: 0,
failed: 0,
});
let unlistenDragDrop: UnlistenFn | null = null;
let unlistenNativeDownloadProgress: UnlistenFn | null = null;
let syncTimer: ReturnType<typeof setInterval> | null = null;
const toast = reactive({
visible: false,
type: "info",
message: "",
});
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: "updates" as const, label: "版本更新", hint: updateState.available ? "有新版本" : "最新" },
]);
const sortedShares = computed(() => {
return [...shares.value].sort((a, b) => {
const ta = new Date(a.created_at || 0).getTime();
const tb = new Date(b.created_at || 0).getTime();
return tb - ta;
});
});
const fileTypeFilterOptions = [
{ value: "all", label: "全部类型" },
{ value: "directory", label: "仅文件夹" },
{ value: "file", label: "仅文件" },
{ value: "image", label: "图片" },
{ value: "video", label: "视频" },
{ value: "document", label: "文档" },
{ value: "archive", label: "压缩包" },
];
const fileSortOptions = [
{ value: "modifiedAt", label: "按时间" },
{ value: "name", label: "按名称" },
{ value: "size", label: "按大小" },
{ value: "type", label: "按类型" },
];
const filteredFiles = computed(() => {
const key = searchKeyword.value.trim().toLowerCase();
const filtered = files.value.filter((item) => {
const name = String(item.displayName || item.name || "").toLowerCase();
if (key && !name.includes(key)) return false;
const typeFilter = fileViewState.filter;
if (typeFilter === "all") return true;
if (typeFilter === "directory") return Boolean(item.isDirectory || item.type === "directory");
if (typeFilter === "file") return !item.isDirectory && item.type !== "directory";
return matchFileTypeFilter(item, typeFilter);
});
const orderFactor = fileViewState.sortOrder === "asc" ? 1 : -1;
const sorted = [...filtered].sort((a, b) => {
const sortBy = fileViewState.sortBy;
if (sortBy === "name") {
const av = String(a.displayName || a.name || "");
const bv = String(b.displayName || b.name || "");
return av.localeCompare(bv, "zh-CN", { sensitivity: "base" }) * orderFactor;
}
if (sortBy === "size") {
const av = Number(a.size || 0);
const bv = Number(b.size || 0);
return (av - bv) * orderFactor;
}
if (sortBy === "type") {
const av = a.isDirectory || a.type === "directory" ? "directory" : "file";
const bv = b.isDirectory || b.type === "directory" ? "directory" : "file";
if (av === bv) {
return String(a.displayName || a.name || "").localeCompare(String(b.displayName || b.name || ""), "zh-CN", { sensitivity: "base" }) * orderFactor;
}
return av.localeCompare(bv, "zh-CN", { sensitivity: "base" }) * orderFactor;
}
const av = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0;
const bv = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0;
return (av - bv) * orderFactor;
});
return sorted;
});
const breadcrumbs = computed(() => {
const normalized = normalizePath(pathState.currentPath);
if (normalized === "/") {
return [{ label: "全部文件", path: "/" }];
}
const segments = normalized.split("/").filter(Boolean);
const nodes = [{ label: "全部文件", path: "/" }];
let cursor = "";
for (const seg of segments) {
cursor += `/${seg}`;
nodes.push({ label: seg, path: cursor });
}
return nodes;
});
const toolbarCrumbs = computed(() => {
if (nav.value === "files") return breadcrumbs.value;
const map: Record<NavKey, string> = {
files: "全部文件",
transfers: "传输列表",
shares: "我的分享",
sync: "同步盘",
updates: "版本更新",
};
return [{ label: "工作台", path: "/" }, { label: map[nav.value], path: "" }];
});
const selectedFile = computed(() => {
if (!selectedFileName.value) return null;
return files.value.find((item) => item.name === selectedFileName.value) || null;
});
const batchSelectedItems = computed(() => {
const selectedSet = new Set(batchSelectedNames.value);
return files.value.filter((item) => selectedSet.has(item.name));
});
const fileStats = computed(() => {
const folders = files.value.filter((item) => item.isDirectory || item.type === "directory").length;
const docs = files.value.length - folders;
const totalBytes = files.value.reduce((sum, item) => sum + Number(item.size || 0), 0);
return {
folders,
docs,
total: files.value.length,
totalBytes,
};
});
function mapApiItem(raw: Record<string, any>): FileItem {
const isDirectory = Boolean(raw?.isDirectory || raw?.is_directory || raw?.type === "directory" || raw?.type === "d");
const fallbackName = String(raw?.name || raw?.displayName || raw?.file_name || "").trim();
return {
name: fallbackName,
displayName: String(raw?.displayName || fallbackName),
path: typeof raw?.path === "string" ? raw.path : (typeof raw?.file_path === "string" ? raw.file_path : undefined),
type: isDirectory ? "directory" : "file",
size: Number(raw?.size || 0),
sizeFormatted: raw?.sizeFormatted || raw?.size_formatted || undefined,
modifiedAt: raw?.modifiedAt || raw?.modified_at || raw?.modifyTime || raw?.modifiedTime || raw?.updatedAt || undefined,
isDirectory,
};
}
function normalizePath(rawPath: string) {
const source = String(rawPath || "/").replace(/\\/g, "/");
const normalized = source.replace(/\/+/g, "/");
if (!normalized || normalized === ".") return "/";
return normalized.startsWith("/") ? normalized : `/${normalized}`;
}
function formatDate(value: string | undefined) {
if (!value) return "-";
const raw = String(value).trim();
if (!raw) return "-";
const localMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/);
let date: Date | null = null;
if (localMatch) {
date = new Date(
Number(localMatch[1]),
Number(localMatch[2]) - 1,
Number(localMatch[3]),
Number(localMatch[4] || 0),
Number(localMatch[5] || 0),
Number(localMatch[6] || 0),
);
} else {
const fallback = new Date(raw);
if (!Number.isNaN(fallback.getTime())) {
date = fallback;
}
}
if (!date) return raw;
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}
function formatBytes(value: number | undefined) {
const bytes = Number(value || 0);
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let num = bytes;
let unit = 0;
while (num >= 1024 && unit < units.length - 1) {
num /= 1024;
unit += 1;
}
const fixed = num >= 10 || unit === 0 ? 1 : 2;
return `${num.toFixed(fixed)} ${units[unit]}`;
}
function fileTypeLabel(item: FileItem) {
if (item.isDirectory || item.type === "directory") return "文件夹";
return "文件";
}
function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) {
const taskId = String(payload?.taskId || "").trim();
if (!taskId) return;
const downloadedBytes = Number(payload?.downloadedBytes || 0);
const totalBytesRaw = payload?.totalBytes;
const totalBytes = totalBytesRaw === null || totalBytesRaw === undefined ? NaN : Number(totalBytesRaw);
const eventProgress = Number(payload?.progress);
const calculatedProgress = Number.isFinite(eventProgress)
? eventProgress
: (Number.isFinite(totalBytes) && totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : NaN);
const boundedProgress = Number.isFinite(calculatedProgress)
? Math.max(1, Math.min(payload?.done ? 100 : 99.8, calculatedProgress))
: NaN;
const patch: Partial<TransferTask> = {
speed: "下载中",
status: "downloading",
note: Number.isFinite(totalBytes) && totalBytes > 0
? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`
: `已下载 ${formatBytes(downloadedBytes)}`,
};
if (Number.isFinite(boundedProgress)) {
patch.progress = Number(boundedProgress.toFixed(1));
}
updateTransferTask(taskId, patch);
}
function fileIcon(item: FileItem) {
if (item.isDirectory || item.type === "directory") return "📁";
const name = String(item.name || "").toLowerCase();
if (/\.(jpg|jpeg|png|webp|gif|bmp|svg)$/.test(name)) return "🖼️";
if (/\.(mp4|mkv|mov|avi)$/.test(name)) return "🎬";
if (/\.(mp3|wav|flac|aac)$/.test(name)) return "🎵";
if (/\.(zip|rar|7z|tar|gz)$/.test(name)) return "🗜️";
if (/\.(pdf)$/.test(name)) return "📕";
if (/\.(doc|docx)$/.test(name)) return "📘";
if (/\.(xls|xlsx)$/.test(name)) return "📗";
return "📄";
}
function matchFileTypeFilter(item: FileItem, type: string) {
if (item.isDirectory || item.type === "directory") return false;
const name = String(item.name || "").toLowerCase();
if (type === "image") return /\.(jpg|jpeg|png|webp|gif|bmp|svg)$/.test(name);
if (type === "video") return /\.(mp4|mkv|mov|avi|webm)$/.test(name);
if (type === "document") return /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|md|csv)$/.test(name);
if (type === "archive") return /\.(zip|rar|7z|tar|gz|bz2|xz)$/.test(name);
return true;
}
function normalizeSharePath(rawPath: string | undefined) {
const normalized = normalizePath(rawPath || "/");
return normalized;
}
function getShareDisplayName(share: ShareItem) {
const normalized = normalizeSharePath(share.share_path);
if (normalized === "/") return "全部文件";
const segments = normalized.split("/").filter(Boolean);
return segments[segments.length - 1] || normalized;
}
function getShareExpireLabel(value: string | null | undefined) {
if (!value) return "永久有效";
const text = formatDate(value);
const expired = new Date(value).getTime() <= Date.now();
return expired ? `已过期 · ${text}` : text;
}
async function copyText(text: string, successMessage: string) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
showToast(successMessage, "success");
return;
}
} catch {
// fall through to legacy copy path
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
showToast(ok ? successMessage : "复制失败,请手动复制", ok ? "success" : "error");
}
function showToast(message: string, type = "info") {
toast.message = message;
toast.type = type;
toast.visible = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast.visible = false;
}, 2500);
}
function prependTransferTask(task: TransferTask) {
transferTasks.value = [task, ...transferTasks.value.slice(0, 119)];
}
function updateTransferTask(
id: string,
patch: Partial<TransferTask>,
) {
transferTasks.value = transferTasks.value.map((task) => (task.id === id ? { ...task, ...patch } : task));
}
function getTaskStatusLabel(status: string) {
if (status === "queued") return "排队中";
if (status === "uploading") return "上传中";
if (status === "downloading") return "下载中";
if (status === "done") return "已完成";
if (status === "failed") return "失败";
return status;
}
function isTaskRunning(status: string) {
return status === "uploading" || status === "downloading";
}
function removeTransferTask(taskId: string) {
transferTasks.value = transferTasks.value.filter((task) => task.id !== taskId);
}
function clearCompletedTransferTasks() {
transferTasks.value = transferTasks.value.filter((task) => task.status !== "done" && task.status !== "failed");
}
function toggleTransferQueuePause() {
transferQueue.paused = !transferQueue.paused;
showToast(transferQueue.paused ? "传输队列已暂停(进行中的任务会跑完)" : "传输队列已恢复", "info");
}
async function waitForTransferQueue() {
while (transferQueue.paused) {
await new Promise((resolve) => setTimeout(resolve, 240));
}
}
function isBatchSelected(name: string) {
return batchSelectedNames.value.includes(name);
}
function toggleBatchMode() {
batchMode.value = !batchMode.value;
batchSelectedNames.value = [];
}
function clearBatchSelection() {
batchSelectedNames.value = [];
}
function toggleBatchSelection(item: FileItem) {
const name = item.name;
if (!name) return;
if (isBatchSelected(name)) {
batchSelectedNames.value = batchSelectedNames.value.filter((v) => v !== name);
return;
}
batchSelectedNames.value = [...batchSelectedNames.value, name];
}
function normalizeRelativePath(rawPath: string) {
return String(rawPath || "").replace(/\\/g, "/").replace(/^\/+/, "");
}
function getSyncConfigStorageKey() {
const userId = String(user.value?.id || "guest");
return `wanwan_desktop_sync_config_v2_${userId}`;
}
function getSyncSnapshotStorageKey(localDir: string) {
const userId = String(user.value?.id || "guest");
return `wanwan_desktop_sync_snapshot_v2_${userId}_${encodeURIComponent(localDir || "")}`;
}
function safeParseObject(raw: string | null) {
if (!raw) return {} as Record<string, string>;
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === "object" ? parsed as Record<string, string> : {};
} catch {
return {};
}
}
function loadSyncConfig() {
const key = getSyncConfigStorageKey();
const raw = localStorage.getItem(key);
if (!raw) return;
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return;
syncState.localDir = typeof parsed.localDir === "string" ? parsed.localDir : "";
syncState.remoteBasePath = normalizePath(typeof parsed.remoteBasePath === "string" ? parsed.remoteBasePath : "/");
syncState.autoEnabled = Boolean(parsed.autoEnabled);
const interval = Number(parsed.intervalMinutes || 15);
syncState.intervalMinutes = Number.isFinite(interval) && interval >= 5 ? Math.floor(interval) : 15;
} catch {
// ignore invalid cache
}
}
function saveSyncConfig() {
const key = getSyncConfigStorageKey();
const payload = {
localDir: syncState.localDir,
remoteBasePath: normalizePath(syncState.remoteBasePath || "/"),
autoEnabled: syncState.autoEnabled,
intervalMinutes: syncState.intervalMinutes,
};
localStorage.setItem(key, JSON.stringify(payload));
}
function loadSyncSnapshot(localDir: string) {
if (!localDir) return {} as Record<string, string>;
const key = getSyncSnapshotStorageKey(localDir);
return safeParseObject(localStorage.getItem(key));
}
function saveSyncSnapshot(localDir: string, snapshot: Record<string, string>) {
if (!localDir) return;
const key = getSyncSnapshotStorageKey(localDir);
localStorage.setItem(key, JSON.stringify(snapshot));
}
function clearSyncScheduler() {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
}
syncState.nextRunAt = "";
}
function syncFingerprint(item: LocalSyncFileItem) {
return `${Number(item.size || 0)}:${Number(item.modifiedMs || 0)}`;
}
function getRelativeParentPath(relativePath: string) {
const normalized = normalizeRelativePath(relativePath);
if (!normalized) return "";
const segments = normalized.split("/").filter(Boolean);
if (segments.length <= 1) return "";
return segments.slice(0, -1).join("/");
}
function toggleFileSortOrder() {
fileViewState.sortOrder = fileViewState.sortOrder === "asc" ? "desc" : "asc";
}
async function invokeBridge(command: string, payload: Record<string, any>) {
try {
return await invoke<BridgeResponse>(command, payload);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
ok: false,
status: 0,
data: {
success: false,
message,
},
} satisfies BridgeResponse;
}
}
async function initClientVersion() {
try {
const current = await getVersion();
if (current) {
updateState.currentVersion = current;
}
} catch {
// keep fallback version
}
}
async function checkClientUpdate(showResultToast = true): Promise<boolean> {
if (updateState.checking) {
return false;
}
updateState.checking = true;
updateState.message = "";
const response = await invokeBridge("api_check_client_update", {
baseUrl: appConfig.baseUrl,
currentVersion: updateState.currentVersion,
platform: "windows-x64",
channel: "stable",
});
updateState.checking = false;
updateState.lastCheckedAt = new Date().toISOString();
if (response.ok && response.data?.success) {
updateState.latestVersion = String(response.data.latestVersion || updateState.currentVersion);
updateState.available = Boolean(response.data.updateAvailable);
updateState.downloadUrl = String(response.data.downloadUrl || "");
updateState.releaseNotes = String(response.data.releaseNotes || "");
updateState.mandatory = Boolean(response.data.mandatory);
updateState.message = String(response.data.message || "");
if (showResultToast) {
if (updateState.available) {
showToast(`发现新版本 ${updateState.latestVersion}`, "success");
} else {
showToast("当前已是最新版本", "info");
}
}
return true;
}
updateState.available = false;
updateState.downloadUrl = "";
updateState.message = String(response.data?.message || "检查更新失败");
if (showResultToast) {
showToast(updateState.message, "error");
}
return false;
}
async function installLatestUpdate(): Promise<boolean> {
if (updateRuntime.downloading) {
showToast("更新包正在下载,请稍候", "info");
return false;
}
if (!updateState.downloadUrl) {
showToast("当前没有可用的更新下载地址", "info");
return false;
}
updateRuntime.downloading = true;
const taskId = `UPD-${Date.now()}`;
const installerName = `wanwan-cloud-desktop_v${updateState.latestVersion || updateState.currentVersion}.exe`;
prependTransferTask({
id: taskId,
kind: "download",
name: installerName,
speed: "更新包下载",
progress: 2,
status: "downloading",
note: "正在下载升级安装包",
downloadUrl: updateState.downloadUrl,
fileName: installerName,
});
const response = await invokeBridge("api_native_download", {
url: updateState.downloadUrl,
fileName: installerName,
taskId,
});
try {
if (response.ok && response.data?.success) {
updateTransferTask(taskId, {
speed: "-",
progress: 100,
status: "done",
note: "更新包已下载,准备启动安装",
});
const savePath = String(response.data?.savePath || "").trim();
if (savePath) {
const launchResponse = await invokeBridge("api_launch_installer", {
installerPath: savePath,
});
if (launchResponse.ok && launchResponse.data?.success) {
updateTransferTask(taskId, {
speed: "-",
progress: 100,
status: "done",
note: "安装程序已启动,客户端即将退出",
});
showToast("安装程序已启动,客户端即将退出", "success");
setTimeout(() => {
void getCurrentWindow().close();
}, 400);
return true;
}
try {
await openPath(savePath);
} catch (error) {
console.error("open installer fallback failed", error);
}
}
showToast("更新包已下载,请手动运行安装程序", "info");
return true;
}
const message = String(response.data?.message || "下载更新包失败");
updateTransferTask(taskId, {
speed: "-",
progress: 0,
status: "failed",
note: message,
});
showToast(message, "error");
return false;
} finally {
updateRuntime.downloading = false;
}
}
async function chooseSyncDirectory() {
try {
const result = await openDialog({
directory: true,
multiple: false,
title: "选择本地同步文件夹",
});
if (typeof result === "string" && result.trim()) {
syncState.localDir = result.trim();
showToast("同步目录已更新", "success");
}
} catch {
showToast("选择目录失败", "error");
}
}
async function uploadFileWithResume(filePath: string, targetPath: string) {
const resumableResponse = await invokeBridge("api_upload_file_resumable", {
baseUrl: appConfig.baseUrl,
filePath,
targetPath,
chunkSize: 4 * 1024 * 1024,
});
if (resumableResponse.ok && resumableResponse.data?.success) {
return resumableResponse;
}
const message = String(resumableResponse.data?.message || "");
if (
message.includes("当前存储模式不支持分片上传")
|| message.includes("分片上传会话")
|| message.includes("上传会话")
) {
return await invokeBridge("api_upload_file", {
baseUrl: appConfig.baseUrl,
filePath,
targetPath,
});
}
return resumableResponse;
}
function rebuildSyncScheduler() {
clearSyncScheduler();
if (!authenticated.value || !syncState.autoEnabled || !syncState.localDir.trim()) {
return;
}
const intervalMinutes = Math.max(5, Math.floor(Number(syncState.intervalMinutes) || 15));
syncState.intervalMinutes = intervalMinutes;
syncState.nextRunAt = new Date(Date.now() + intervalMinutes * 60 * 1000).toISOString();
syncTimer = setInterval(() => {
void runSyncOnce("auto");
}, intervalMinutes * 60 * 1000);
}
async function clearSyncSnapshot() {
if (!syncState.localDir.trim()) {
showToast("请先配置本地同步目录", "info");
return;
}
localStorage.removeItem(getSyncSnapshotStorageKey(syncState.localDir.trim()));
syncState.lastSummary = "本地同步索引已重置";
showToast("同步索引已清理,下次会全量扫描", "success");
}
async function runSyncOnce(trigger: "manual" | "auto" = "manual") {
if (!authenticated.value) return;
const localDir = syncState.localDir.trim();
if (!localDir) {
if (trigger === "manual") {
showToast("请先配置本地同步目录", "info");
}
return;
}
if (syncState.syncing || syncState.scanning) {
if (trigger === "manual") {
showToast("同步任务正在执行,请稍后", "info");
}
return;
}
syncState.scanning = true;
const listResp = await invokeBridge("api_list_local_files", {
dirPath: localDir,
});
syncState.scanning = false;
if (!(listResp.ok && listResp.data?.success)) {
const message = String(listResp.data?.message || "扫描本地目录失败");
syncState.lastSummary = message;
if (trigger === "manual") showToast(message, "error");
return;
}
const items = Array.isArray(listResp.data?.items) ? listResp.data.items as LocalSyncFileItem[] : [];
const previousSnapshot = loadSyncSnapshot(localDir);
const changedItems = items.filter((item) => {
const rel = normalizeRelativePath(item.relativePath);
return rel && previousSnapshot[rel] !== syncFingerprint(item);
});
syncState.pendingCount = changedItems.length;
syncState.uploadedCount = 0;
syncState.failedCount = 0;
syncState.syncing = true;
if (changedItems.length === 0) {
syncState.syncing = false;
syncState.lastRunAt = new Date().toISOString();
syncState.lastSummary = "没有检测到变更文件";
if (trigger === "manual") {
showToast("没有检测到需要上传的变更", "info");
}
if (syncState.autoEnabled) {
syncState.nextRunAt = new Date(Date.now() + syncState.intervalMinutes * 60 * 1000).toISOString();
}
return;
}
const successPaths = new Set<string>();
for (let index = 0; index < changedItems.length; index += 1) {
await waitForTransferQueue();
const item = changedItems[index];
const relPath = normalizeRelativePath(item.relativePath);
const fileName = extractFileNameFromPath(relPath) || `同步文件${index + 1}`;
const parent = getRelativeParentPath(relPath);
const remoteBase = normalizePath(syncState.remoteBasePath || "/");
const targetPath = parent ? normalizePath(`${remoteBase}/${parent}`) : remoteBase;
const taskId = `S-${Date.now()}-${index}`;
prependTransferTask({
id: taskId,
kind: "upload",
name: fileName,
speed: trigger === "auto" ? "自动同步" : "手动同步",
progress: 2,
status: "queued",
note: targetPath,
filePath: item.path,
targetPath,
fileName,
});
updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 });
const resp = await uploadFileWithResume(item.path, targetPath);
if (resp.ok && resp.data?.success) {
syncState.uploadedCount += 1;
successPaths.add(relPath);
updateTransferTask(taskId, { speed: "-", progress: 100, status: "done", note: "同步完成" });
} else {
syncState.failedCount += 1;
updateTransferTask(taskId, {
speed: "-",
progress: 0,
status: "failed",
note: String(resp.data?.message || "同步失败"),
});
}
}
const nextSnapshot: Record<string, string> = {};
for (const item of items) {
const rel = normalizeRelativePath(item.relativePath);
if (!rel) continue;
const fingerprint = syncFingerprint(item);
if (previousSnapshot[rel] === fingerprint || successPaths.has(rel)) {
nextSnapshot[rel] = fingerprint;
}
}
saveSyncSnapshot(localDir, nextSnapshot);
syncState.syncing = false;
syncState.lastRunAt = new Date().toISOString();
syncState.lastSummary = `变更 ${changedItems.length} 个,成功 ${syncState.uploadedCount} 个,失败 ${syncState.failedCount}`;
if (syncState.autoEnabled) {
syncState.nextRunAt = new Date(Date.now() + syncState.intervalMinutes * 60 * 1000).toISOString();
}
if (trigger === "manual") {
const toastType = syncState.failedCount > 0 ? "info" : "success";
showToast(syncState.lastSummary, toastType);
}
if (syncState.uploadedCount > 0 && nav.value === "files") {
await loadFiles(pathState.currentPath);
}
}
async function loadProfile() {
const response = await invokeBridge("api_get_profile", { baseUrl: appConfig.baseUrl });
if (response.ok && response.data?.success && response.data?.user) {
user.value = response.data.user;
return true;
}
return false;
}
async function loadFiles(targetPath = pathState.currentPath) {
pathState.loading = true;
pathState.error = "";
const normalizedPath = normalizePath(targetPath);
const response = await invokeBridge("api_list_files", {
baseUrl: appConfig.baseUrl,
path: normalizedPath,
});
if (response.ok && response.data?.success) {
files.value = Array.isArray(response.data.items) ? response.data.items.map((item: Record<string, any>) => mapApiItem(item)) : [];
pathState.currentPath = normalizePath(response.data.path || normalizedPath);
pathState.mode = "directory";
selectedFileName.value = files.value[0]?.name || "";
batchSelectedNames.value = [];
} else {
pathState.error = response.data?.message || "读取文件列表失败";
showToast(pathState.error, "error");
}
pathState.loading = false;
}
async function loadShares(silent = false) {
if (!silent) sharesLoading.value = true;
const response = await invokeBridge("api_get_my_shares", {
baseUrl: appConfig.baseUrl,
});
if (response.ok && response.data?.success) {
shares.value = Array.isArray(response.data.shares) ? response.data.shares : [];
} else if (!silent) {
showToast(response.data?.message || "获取分享列表失败", "error");
}
if (!silent) sharesLoading.value = false;
}
async function getSignedUrlForItem(item: FileItem, mode: "download" | "preview") {
const targetPath = buildItemPath(item);
const response = await invokeBridge("api_get_download_url", {
baseUrl: appConfig.baseUrl,
path: targetPath,
mode,
});
if (response.ok && response.data?.success && response.data?.downloadUrl) {
return String(response.data.downloadUrl);
}
showToast(response.data?.message || "获取链接失败", "error");
return "";
}
async function createShareForItem(current: FileItem, silent = false) {
const response = await invokeBridge("api_create_share", {
baseUrl: appConfig.baseUrl,
shareType: current.isDirectory || current.type === "directory" ? "directory" : "file",
filePath: buildItemPath(current),
fileName: current.displayName || current.name,
password: null,
expiryDays: null,
});
if (response.ok && response.data?.success) {
if (!silent) {
showToast("分享创建成功", "success");
}
await loadShares(true);
const shareUrl = String(response.data.share_url || "");
if (!silent && shareUrl) {
await copyText(shareUrl, "分享链接已复制");
}
return;
}
if (!silent) {
showToast(response.data?.message || "创建分享失败", "error");
}
throw new Error(String(response.data?.message || "创建分享失败"));
}
async function createDirectLinkForItem(current: FileItem, silent = false) {
if (current.isDirectory || current.type === "directory") {
if (!silent) {
showToast("文件夹不支持生成直链", "info");
}
throw new Error("文件夹不支持生成直链");
}
const response = await invokeBridge("api_create_direct_link", {
baseUrl: appConfig.baseUrl,
filePath: buildItemPath(current),
fileName: current.displayName || current.name,
expiryDays: null,
});
if (response.ok && response.data?.success) {
if (!silent) {
showToast("直链创建成功", "success");
}
const directUrl = String(response.data.direct_url || "");
if (!silent && directUrl) {
await copyText(directUrl, "直链已复制");
}
return;
}
if (!silent) {
showToast(response.data?.message || "创建直链失败", "error");
}
throw new Error(String(response.data?.message || "创建直链失败"));
}
async function deleteShare(share: ShareItem) {
const confirmed = window.confirm(`确认删除分享 ${share.share_code} 吗?`);
if (!confirmed) return;
const response = await invokeBridge("api_delete_share", {
baseUrl: appConfig.baseUrl,
shareId: share.id,
});
if (response.ok && response.data?.success) {
showToast("分享已删除", "success");
await loadShares(true);
return;
}
showToast(response.data?.message || "删除分享失败", "error");
}
async function copyShareLink(share: ShareItem) {
const url = String(share.share_url || "").trim();
if (!url) {
showToast("分享链接为空", "error");
return;
}
await copyText(url, "链接已复制");
}
async function openShareLink(share: ShareItem) {
const url = String(share.share_url || "").trim();
if (!url) {
showToast("分享链接为空", "error");
return;
}
await openUrl(url);
}
async function restoreSession() {
const ok = await loadProfile();
if (!ok) return;
authenticated.value = true;
loadSyncConfig();
rebuildSyncScheduler();
await loadFiles("/");
await loadShares(true);
}
function buildItemPath(item: FileItem) {
if (item.path && item.path.trim()) {
return normalizePath(item.path);
}
return normalizePath(`${pathState.currentPath}/${item.name}`);
}
function getItemParentPath(item: FileItem) {
const fullPath = buildItemPath(item);
const segments = fullPath.split("/").filter(Boolean);
if (segments.length <= 1) return "/";
return `/${segments.slice(0, -1).join("/")}`;
}
async function runGlobalSearch() {
const keyword = searchKeyword.value.trim();
if (!keyword) {
await loadFiles(pathState.currentPath);
return;
}
pathState.loading = true;
pathState.error = "";
const response = await invokeBridge("api_search_files", {
baseUrl: appConfig.baseUrl,
path: pathState.currentPath,
keyword,
searchType: "all",
limit: 200,
});
if (response.ok && response.data?.success) {
files.value = Array.isArray(response.data.items) ? response.data.items.map((item: Record<string, any>) => mapApiItem(item)) : [];
pathState.mode = "search";
selectedFileName.value = files.value[0]?.name || "";
showToast(`搜索完成,共 ${files.value.length} 条结果`, "success");
} else {
pathState.error = response.data?.message || "搜索失败";
showToast(pathState.error, "error");
}
pathState.loading = false;
}
async function handleLogin() {
if (loginState.loading) return;
loginState.loading = true;
loginState.error = "";
const response = await invokeBridge("api_login", {
baseUrl: appConfig.baseUrl,
username: loginForm.username.trim(),
password: loginForm.password,
captcha: loginForm.captcha.trim() || null,
});
loginState.loading = false;
if (response.ok && response.data?.success) {
authenticated.value = true;
user.value = response.data.user || null;
nav.value = "files";
showToast("登录成功,正在同步文件目录", "success");
loadSyncConfig();
rebuildSyncScheduler();
await loadFiles("/");
if (!user.value) {
await loadProfile();
}
return;
}
loginState.needCaptcha = !!response.data?.needCaptcha;
loginState.error = response.data?.message || "登录失败";
showToast(loginState.error, "error");
}
async function handleLogout() {
await invokeBridge("api_logout", { baseUrl: appConfig.baseUrl });
clearSyncScheduler();
authenticated.value = false;
user.value = null;
files.value = [];
selectedFileName.value = "";
batchMode.value = false;
batchSelectedNames.value = [];
loginForm.password = "";
nav.value = "files";
syncState.localDir = "";
syncState.remoteBasePath = "/";
syncState.autoEnabled = false;
syncState.intervalMinutes = 15;
syncState.syncing = false;
syncState.scanning = false;
syncState.pendingCount = 0;
syncState.uploadedCount = 0;
syncState.failedCount = 0;
syncState.lastRunAt = "";
syncState.lastSummary = "";
syncState.nextRunAt = "";
updateRuntime.downloading = false;
showToast("已退出客户端", "info");
}
async function createFolder() {
if (pathState.loading) return;
const folderName = window.prompt("请输入新文件夹名称");
if (!folderName || !folderName.trim()) return;
const response = await invokeBridge("api_mkdir", {
baseUrl: appConfig.baseUrl,
path: pathState.currentPath,
folderName: folderName.trim(),
});
if (response.ok && response.data?.success) {
showToast("文件夹创建成功", "success");
await loadFiles(pathState.currentPath);
return;
}
showToast(response.data?.message || "创建文件夹失败", "error");
}
async function renameSelected(target?: FileItem | null) {
const current = target || selectedFile.value;
if (!current) {
showToast("请先选中文件或文件夹", "info");
return;
}
const nextName = window.prompt("请输入新的名称", current.name);
if (!nextName || !nextName.trim() || nextName.trim() === current.name) return;
const response = await invokeBridge("api_rename_file", {
baseUrl: appConfig.baseUrl,
path: getItemParentPath(current),
oldName: current.name,
newName: nextName.trim(),
});
if (response.ok && response.data?.success) {
showToast("重命名成功", "success");
await loadFiles(pathState.currentPath);
return;
}
showToast(response.data?.message || "重命名失败", "error");
}
async function deleteSelected(target?: FileItem | null, silent = false) {
const current = target || selectedFile.value;
if (!current) {
if (!silent) showToast("请先选中文件或文件夹", "info");
return;
}
if (!silent) {
const confirmed = window.confirm(`确认删除「${current.displayName || current.name}」吗?`);
if (!confirmed) return;
}
const response = await invokeBridge("api_delete_file", {
baseUrl: appConfig.baseUrl,
path: getItemParentPath(current),
fileName: current.name,
});
if (response.ok && response.data?.success) {
if (!silent) {
showToast("删除成功", "success");
await loadFiles(pathState.currentPath);
}
return;
}
if (!silent) {
showToast(response.data?.message || "删除失败", "error");
}
throw new Error(String(response.data?.message || "删除失败"));
}
async function downloadSelected(target?: FileItem | null) {
const current = target || selectedFile.value;
if (!current) {
showToast("请先选中文件", "info");
return;
}
if (current.isDirectory || current.type === "directory") {
showToast("当前仅支持下载文件", "info");
return;
}
const signedUrl = await getSignedUrlForItem(current, "download");
if (!signedUrl) return;
const taskId = `D-${Date.now()}`;
prependTransferTask({
id: taskId,
kind: "download",
name: current.displayName || current.name,
speed: "等待下载",
progress: 1,
status: "queued",
downloadUrl: signedUrl,
fileName: current.displayName || current.name,
note: "支持断点续传",
});
await waitForTransferQueue();
updateTransferTask(taskId, { speed: "原生下载", status: "downloading", progress: 10, note: "下载中" });
const nativeResponse = await invokeBridge("api_native_download", {
url: signedUrl,
fileName: current.displayName || current.name,
taskId,
});
if (nativeResponse.ok && nativeResponse.data?.success) {
const resumedBytes = Number(nativeResponse.data?.resumedBytes || 0);
const resumeText = resumedBytes > 0 ? `,已续传 ${formatBytes(resumedBytes)}` : "";
updateTransferTask(taskId, { speed: "-", progress: 100, status: "done", note: `下载成功${resumeText}` });
const savedPath = nativeResponse.data?.savePath ? `\n${nativeResponse.data.savePath}` : "";
showToast(`下载完成${savedPath}`, "success");
return;
}
const message = String(nativeResponse.data?.message || "原生下载失败");
updateTransferTask(taskId, { speed: "-", progress: 0, status: "failed", note: message });
showToast(message, "error");
}
function selectFile(item: FileItem) {
if (batchMode.value) {
toggleBatchSelection(item);
return;
}
selectedFileName.value = item.name;
}
async function openItem(item: FileItem) {
if (batchMode.value) return;
selectFile(item);
if (item.isDirectory || item.type === "directory") {
const nextPath = buildItemPath(item);
await loadFiles(nextPath);
return;
}
const previewUrl = await getSignedUrlForItem(item, "preview");
if (previewUrl) {
await openUrl(previewUrl);
}
}
async function jumpToPath(nextPath: string) {
await loadFiles(nextPath);
}
function closeContextMenu() {
contextMenu.visible = false;
contextMenu.item = null;
}
function openContextMenu(event: MouseEvent, item: FileItem) {
if (batchMode.value) return;
selectFile(item);
contextMenu.item = item;
const maxX = window.innerWidth - 220;
const maxY = window.innerHeight - 260;
contextMenu.x = Math.max(8, Math.min(event.clientX, maxX));
contextMenu.y = Math.max(8, Math.min(event.clientY, maxY));
contextMenu.visible = true;
}
async function executeContextAction(action: "open" | "download" | "rename" | "delete" | "share" | "direct") {
const item = contextMenu.item;
closeContextMenu();
if (!item) return;
try {
if (action === "open") {
await openItem(item);
return;
}
if (action === "download") {
await downloadSelected(item);
return;
}
if (action === "rename") {
await renameSelected(item);
return;
}
if (action === "delete") {
await deleteSelected(item);
return;
}
if (action === "share") {
await createShareForItem(item);
return;
}
await createDirectLinkForItem(item);
} catch {
// errors already handled by action method
}
}
async function retryTransferTask(taskId: string) {
const task = transferTasks.value.find((item) => item.id === taskId);
if (!task) return;
if (isTaskRunning(task.status)) return;
if (task.status === "done") return;
if (task.kind === "upload") {
if (!task.filePath || !task.targetPath) {
updateTransferTask(taskId, { status: "failed", note: "缺少上传任务参数" });
return;
}
await waitForTransferQueue();
updateTransferTask(taskId, { status: "uploading", speed: "重试上传", progress: 10, note: "正在重试" });
const response = await uploadFileWithResume(task.filePath, task.targetPath);
if (response.ok && response.data?.success) {
updateTransferTask(taskId, { status: "done", speed: "-", progress: 100, note: "重试成功" });
if (nav.value === "files") {
await loadFiles(pathState.currentPath);
}
return;
}
updateTransferTask(taskId, {
status: "failed",
speed: "-",
progress: 0,
note: String(response.data?.message || "重试上传失败"),
});
return;
}
if (!task.downloadUrl) {
updateTransferTask(taskId, { status: "failed", note: "缺少下载地址" });
return;
}
await waitForTransferQueue();
updateTransferTask(taskId, { status: "downloading", speed: "重试下载", progress: 10, note: "正在重试" });
const response = await invokeBridge("api_native_download", {
url: task.downloadUrl,
fileName: task.fileName || task.name,
taskId,
});
if (response.ok && response.data?.success) {
const resumedBytes = Number(response.data?.resumedBytes || 0);
const resumeText = resumedBytes > 0 ? `,已续传 ${formatBytes(resumedBytes)}` : "";
updateTransferTask(taskId, { status: "done", speed: "-", progress: 100, note: `下载成功${resumeText}` });
return;
}
updateTransferTask(taskId, {
status: "failed",
speed: "-",
progress: 0,
note: String(response.data?.message || "重试下载失败"),
});
}
async function batchDeleteSelected() {
if (!batchMode.value || batchSelectedItems.value.length === 0) {
showToast("请先勾选批量文件", "info");
return;
}
const confirmed = window.confirm(`确认批量删除 ${batchSelectedItems.value.length} 个项目吗?`);
if (!confirmed) return;
let success = 0;
let failed = 0;
const items = [...batchSelectedItems.value];
for (const item of items) {
try {
await deleteSelected(item, true);
success += 1;
} catch {
failed += 1;
}
}
await loadFiles(pathState.currentPath);
clearBatchSelection();
const failedText = failed > 0 ? `,失败 ${failed}` : "";
showToast(`批量删除完成:成功 ${success}${failedText}`, failed > 0 ? "info" : "success");
}
async function batchShareSelected() {
if (!batchMode.value || batchSelectedItems.value.length === 0) {
showToast("请先勾选批量文件", "info");
return;
}
let success = 0;
let failed = 0;
const items = [...batchSelectedItems.value];
for (const item of items) {
try {
await createShareForItem(item, true);
success += 1;
} catch {
failed += 1;
}
}
await loadShares(true);
const failedText = failed > 0 ? `,失败 ${failed}` : "";
showToast(`批量分享完成:成功 ${success}${failedText}`, failed > 0 ? "info" : "success");
}
async function batchDirectLinkSelected() {
if (!batchMode.value || batchSelectedItems.value.length === 0) {
showToast("请先勾选批量文件", "info");
return;
}
let success = 0;
let failed = 0;
const items = [...batchSelectedItems.value];
for (const item of items) {
try {
await createDirectLinkForItem(item, true);
success += 1;
} catch {
failed += 1;
}
}
await loadShares(true);
const failedText = failed > 0 ? `,失败 ${failed}` : "";
showToast(`批量直链完成:成功 ${success}${failedText}`, failed > 0 ? "info" : "success");
}
function handleGlobalClick() {
if (contextMenu.visible) closeContextMenu();
}
function handleGlobalKey(event: KeyboardEvent) {
if (event.key === "Escape" && contextMenu.visible) {
closeContextMenu();
}
}
function extractFileNameFromPath(filePath: string) {
const trimmed = String(filePath || "").trim();
if (!trimmed) return "";
const normalized = trimmed.replace(/\\/g, "/");
const segments = normalized.split("/").filter(Boolean);
return segments[segments.length - 1] || normalized;
}
function canUseDragUpload() {
return authenticated.value && nav.value === "files";
}
async function uploadDroppedFiles(paths: string[]) {
const uniquePaths = [...new Set((paths || []).map((item) => String(item || "").trim()).filter(Boolean))];
if (uniquePaths.length === 0) {
showToast("未识别到可上传文件", "info");
return;
}
if (dropState.uploading) {
showToast("已有上传任务进行中,请稍后再试", "info");
return;
}
dropState.uploading = true;
dropState.total = uniquePaths.length;
dropState.done = 0;
dropState.failed = 0;
let successCount = 0;
for (let index = 0; index < uniquePaths.length; index += 1) {
await waitForTransferQueue();
const filePath = uniquePaths[index];
const displayName = extractFileNameFromPath(filePath) || `文件${index + 1}`;
const taskId = `U-${Date.now()}-${index}`;
prependTransferTask({
id: taskId,
kind: "upload",
name: displayName,
speed: "等待上传",
progress: 2,
status: "queued",
filePath,
targetPath: pathState.currentPath,
fileName: displayName,
});
updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 });
const response = await uploadFileWithResume(filePath, pathState.currentPath);
if (response.ok && response.data?.success) {
successCount += 1;
dropState.done += 1;
updateTransferTask(taskId, {
speed: "-",
progress: 100,
status: "done",
note: "上传成功",
});
} else {
dropState.failed += 1;
const message = String(response.data?.message || "上传失败");
updateTransferTask(taskId, {
speed: "-",
progress: 0,
status: "failed",
note: message,
});
}
}
dropState.uploading = false;
const failedMessage = dropState.failed > 0 ? `,失败 ${dropState.failed}` : "";
showToast(`上传完成:成功 ${dropState.done}${failedMessage}`, dropState.failed > 0 ? "info" : "success");
if (successCount > 0) {
await loadFiles(pathState.currentPath);
}
}
async function registerDragDropListener() {
try {
const currentWebview = getCurrentWebview();
unlistenDragDrop = await currentWebview.onDragDropEvent((event) => {
const payload = event.payload;
if (payload.type === "enter" || payload.type === "over") {
if (canUseDragUpload()) {
dropState.active = true;
}
return;
}
if (payload.type === "leave") {
dropState.active = false;
return;
}
if (payload.type === "drop") {
dropState.active = false;
if (!canUseDragUpload()) {
if (authenticated.value) {
showToast("请先切换到“全部文件”页面再上传", "info");
}
return;
}
void uploadDroppedFiles(payload.paths || []);
}
});
} catch (error) {
console.error("register drag drop listener failed", error);
}
}
async function registerNativeDownloadProgressListener() {
try {
unlistenNativeDownloadProgress = await listen<NativeDownloadProgressEvent>("native-download-progress", (event) => {
applyNativeDownloadProgress(event.payload || {});
});
} catch (error) {
console.error("register native download progress listener failed", error);
}
}
watch(nav, async (next) => {
if (next === "shares" && authenticated.value) {
await loadShares();
return;
}
if (next === "updates" && authenticated.value) {
await checkClientUpdate(false);
}
});
watch(
() => [syncState.localDir, syncState.remoteBasePath, syncState.autoEnabled, syncState.intervalMinutes, authenticated.value],
() => {
if (!authenticated.value) return;
syncState.remoteBasePath = normalizePath(syncState.remoteBasePath || "/");
if (syncState.intervalMinutes < 5) {
syncState.intervalMinutes = 5;
}
saveSyncConfig();
rebuildSyncScheduler();
},
);
onMounted(async () => {
window.addEventListener("click", handleGlobalClick);
window.addEventListener("keydown", handleGlobalKey);
await registerDragDropListener();
await registerNativeDownloadProgressListener();
await initClientVersion();
await restoreSession();
});
onBeforeUnmount(() => {
window.removeEventListener("click", handleGlobalClick);
window.removeEventListener("keydown", handleGlobalKey);
clearSyncScheduler();
if (unlistenDragDrop) {
unlistenDragDrop();
unlistenDragDrop = null;
}
if (unlistenNativeDownloadProgress) {
unlistenNativeDownloadProgress();
unlistenNativeDownloadProgress = null;
}
});
</script>
<template>
<div class="desktop-root">
<div v-if="!authenticated" class="login-shell">
<section class="login-brand">
<div class="brand-chip">玩玩云 Desktop</div>
<h1>企业网盘桌面客户端</h1>
<p>更快的目录操作更清晰的传输队列更像桌面软件的使用体验</p>
<div class="brand-grid">
<div class="brand-card">
<strong>多任务传输</strong>
<span>上传下载独立队列管理</span>
</div>
<div class="brand-card">
<strong>目录式检索</strong>
<span>路径导航 + 快速筛选</span>
</div>
<div class="brand-card">
<strong>独立工作台</strong>
<span>文件分享传输一体化</span>
</div>
<div class="brand-card">
<strong>可接现有后端</strong>
<span>直接复用当前接口体系</span>
</div>
</div>
</section>
<section class="login-panel">
<h2>登录云盘</h2>
<label>
用户名
<input v-model="loginForm.username" type="text" placeholder="请输入账号" />
</label>
<label>
密码
<input v-model="loginForm.password" type="password" placeholder="请输入密码" @keyup.enter="handleLogin" />
</label>
<label v-if="loginState.needCaptcha">
验证码
<input v-model="loginForm.captcha" type="text" placeholder="当前服务要求验证码" />
</label>
<button class="primary-btn" :disabled="loginState.loading" @click="handleLogin">
{{ loginState.loading ? "登录中..." : "进入客户端" }}
</button>
<p v-if="loginState.error" class="error-text">{{ loginState.error }}</p>
</section>
</div>
<div v-else class="work-shell">
<aside class="left-nav">
<div class="left-header">
<div class="app-mark">W</div>
<div>
<strong>玩玩云</strong>
<span>Desktop</span>
</div>
</div>
<button
v-for="item in navItems"
:key="item.key"
class="nav-btn"
:class="{ active: nav === item.key }"
@click="nav = item.key"
>
<span>{{ item.label }}</span>
<small>{{ item.hint }}</small>
</button>
<div class="user-card">
<div class="avatar">{{ String(user?.username || "U").slice(0, 1).toUpperCase() }}</div>
<div class="meta">
<strong>{{ user?.username || "-" }}</strong>
<span>{{ user?.current_storage_type === "local" ? "本地存储" : "OSS 存储" }}</span>
</div>
<button class="ghost-btn" @click="handleLogout">退出</button>
</div>
</aside>
<section class="work-area">
<header class="top-tools">
<div class="crumbs">
<button
v-for="(crumb, idx) in toolbarCrumbs"
:key="crumb.path"
class="crumb-btn"
@click="nav === 'files' ? jumpToPath(crumb.path) : null"
>
{{ crumb.label }}<span v-if="idx < toolbarCrumbs.length - 1"> / </span>
</button>
</div>
<div class="tool-right" :class="{ 'tool-right-files': nav === 'files' }">
<template v-if="nav === 'files'">
<div class="file-search-row">
<input
v-model="searchKeyword"
type="text"
class="search-input"
placeholder="输入关键词回车全局搜索..."
@keyup.enter="runGlobalSearch"
/>
<button class="action-btn" @click="runGlobalSearch">搜索</button>
</div>
<div class="file-actions-row">
<select v-model="fileViewState.filter" class="compact-select">
<option v-for="opt in fileTypeFilterOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<select v-model="fileViewState.sortBy" class="compact-select">
<option v-for="opt in fileSortOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<button class="action-btn" @click="toggleFileSortOrder">{{ fileViewState.sortOrder === "asc" ? "升序" : "降序" }}</button>
<button class="action-btn" :class="{ active: batchMode }" @click="toggleBatchMode">{{ batchMode ? "退出批量" : "批量选择" }}</button>
<button v-if="batchMode" class="action-btn" @click="clearBatchSelection">清空勾选</button>
<button v-if="batchMode" class="action-btn danger" :disabled="batchSelectedItems.length === 0" @click="batchDeleteSelected">批量删除</button>
<button v-if="batchMode" class="action-btn" :disabled="batchSelectedItems.length === 0" @click="batchShareSelected">批量分享</button>
<button v-if="batchMode" class="action-btn" :disabled="batchSelectedItems.length === 0" @click="batchDirectLinkSelected">批量直链</button>
<button class="action-btn" @click="createFolder">新建文件夹</button>
<button class="action-btn" @click="loadFiles(pathState.currentPath)">刷新</button>
</div>
</template>
<template v-else-if="nav === 'transfers'">
<button class="action-btn" @click="toggleTransferQueuePause">{{ transferQueue.paused ? "继续队列" : "暂停队列" }}</button>
<button class="action-btn" @click="clearCompletedTransferTasks">清理已结束</button>
</template>
<template v-else-if="nav === 'shares'">
<button class="action-btn" @click="loadShares()">刷新分享</button>
</template>
<template v-else-if="nav === 'sync'">
<button class="action-btn" :disabled="syncState.syncing || syncState.scanning" @click="runSyncOnce('manual')">
{{ syncState.syncing ? "同步中..." : "立即同步" }}
</button>
<button class="action-btn" @click="clearSyncSnapshot">重建索引</button>
</template>
<template v-else-if="nav === 'updates'">
<button class="action-btn" :disabled="updateState.checking || updateRuntime.downloading" @click="checkClientUpdate()">
{{ updateState.checking ? "检查中..." : "检查更新" }}
</button>
<button class="action-btn" :disabled="updateRuntime.downloading || !updateState.available || !updateState.downloadUrl" @click="installLatestUpdate()">
{{ updateRuntime.downloading ? "下载中..." : "立即更新" }}
</button>
</template>
</div>
</header>
<main class="main-grid">
<section class="panel content-panel">
<template v-if="nav === 'files'">
<div class="panel-head">
<h3>文件视图</h3>
<span>{{ pathState.mode === "search" ? "搜索结果" : "当前目录" }} {{ pathState.currentPath }}</span>
</div>
<div class="file-drop-surface" :class="{ active: dropState.active || dropState.uploading }">
<div v-if="pathState.loading" class="empty-tip">正在加载目录...</div>
<div v-else-if="pathState.error" class="empty-tip error">{{ pathState.error }}</div>
<div v-else-if="filteredFiles.length === 0" class="empty-tip">当前目录暂无文件</div>
<div v-else class="icon-grid">
<button
v-for="item in filteredFiles"
:key="item.name"
type="button"
class="file-card"
:class="{ selected: selectedFileName === item.name, batchSelected: isBatchSelected(item.name) }"
@click="selectFile(item)"
@dblclick="openItem(item)"
@contextmenu.prevent="openContextMenu($event, item)"
>
<div v-if="batchMode" class="batch-check" :class="{ active: isBatchSelected(item.name) }">
{{ isBatchSelected(item.name) ? "" : "" }}
</div>
<div class="file-icon-glyph">{{ fileIcon(item) }}</div>
<div class="file-name" :title="item.displayName || item.name">{{ item.displayName || item.name }}</div>
<div class="file-meta">{{ fileTypeLabel(item) }} · {{ item.isDirectory ? "-" : (item.sizeFormatted || formatBytes(item.size)) }}</div>
<div class="file-time">{{ formatDate(item.modifiedAt) }}</div>
</button>
</div>
<div v-if="dropState.active || dropState.uploading" class="drop-overlay">
<div class="drop-overlay-card">
<strong v-if="!dropState.uploading">拖拽到此处上传到当前目录</strong>
<strong v-else>正在上传 {{ dropState.done + dropState.failed }}/{{ dropState.total }}</strong>
<span v-if="!dropState.uploading">仅支持文件文件夹会自动跳过</span>
<span v-else>成功 {{ dropState.done }} 失败 {{ dropState.failed }} </span>
</div>
</div>
</div>
</template>
<template v-else-if="nav === 'transfers'">
<div class="panel-head">
<h3>传输任务</h3>
<span>{{ transferQueue.paused ? "队列已暂停(进行中任务会继续)" : "上传/下载队列" }}</span>
</div>
<div v-if="transferTasks.length === 0" class="empty-tip">暂无传输任务</div>
<div v-else class="task-list">
<div v-for="task in transferTasks" :key="task.id" class="task-row">
<div>
<strong>{{ task.name }}</strong>
<small>{{ task.id }} · {{ getTaskStatusLabel(task.status) }}</small>
<small v-if="task.note" class="task-note" :class="{ error: task.status === 'failed' }">{{ task.note }}</small>
</div>
<div class="task-right">
<span>{{ task.speed }}</span>
<div class="progress">
<div class="bar" :style="{ width: `${task.progress}%` }" />
</div>
<small class="task-percent">{{ Math.max(0, Math.min(100, Math.round(task.progress))) }}%</small>
<div class="task-actions">
<button v-if="task.status === 'failed'" class="mini-btn" @click="retryTransferTask(task.id)">重试</button>
<button v-if="task.status === 'done' || task.status === 'failed'" class="mini-btn ghost" @click="removeTransferTask(task.id)">移除</button>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="nav === 'shares'">
<div class="panel-head">
<h3>我的分享</h3>
<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>
<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-actions">
<button class="action-btn" @click="openShareLink(share)">打开</button>
<button class="action-btn" @click="copyShareLink(share)">复制</button>
<button class="action-btn danger" @click="deleteShare(share)">删除</button>
</div>
</div>
</div>
</template>
<template v-else-if="nav === 'sync'">
<div class="panel-head">
<h3>同步盘</h3>
<span>本地目录增量上传到云端目录仅上传变更文件</span>
</div>
<div class="sync-layout">
<label>
本地同步目录
<div class="sync-path-row">
<input :value="syncState.localDir || ''" type="text" readonly placeholder="请选择本地目录" />
<button class="action-btn" @click="chooseSyncDirectory">选择目录</button>
</div>
</label>
<label>
云端目标目录
<input v-model="syncState.remoteBasePath" type="text" placeholder="/项目同步目录" />
</label>
<div class="sync-option-row">
<label class="check-line">
<input v-model="syncState.autoEnabled" type="checkbox" />
<span>开启自动同步</span>
</label>
<div class="sync-interval">
<span>间隔</span>
<select v-model.number="syncState.intervalMinutes" :disabled="!syncState.autoEnabled" class="compact-select">
<option :value="5">5 分钟</option>
<option :value="15">15 分钟</option>
<option :value="30">30 分钟</option>
<option :value="60">60 分钟</option>
</select>
</div>
</div>
<div class="sync-summary-grid">
<div>
<strong>{{ syncState.pendingCount }}</strong>
<span>待同步</span>
</div>
<div>
<strong>{{ syncState.uploadedCount }}</strong>
<span>已成功</span>
</div>
<div>
<strong>{{ syncState.failedCount }}</strong>
<span>失败数</span>
</div>
<div>
<strong>{{ syncState.autoEnabled ? "开启" : "关闭" }}</strong>
<span>自动同步</span>
</div>
</div>
<div class="sync-meta">
<p>上次执行{{ syncState.lastRunAt ? formatDate(syncState.lastRunAt) : "-" }}</p>
<p>下次执行{{ syncState.nextRunAt ? formatDate(syncState.nextRunAt) : "-" }}</p>
<p>结果{{ syncState.lastSummary || "等待执行同步任务" }}</p>
</div>
</div>
</template>
<template v-else-if="nav === 'updates'">
<div class="panel-head">
<h3>版本更新</h3>
<span>支持检查新版本并一键跳转下载升级包</span>
</div>
<div class="update-layout">
<div class="update-main">
<div class="update-version-row">
<div>
<strong>当前版本</strong>
<span>v{{ updateState.currentVersion }}</span>
</div>
<div>
<strong>最新版本</strong>
<span>v{{ updateState.latestVersion || updateState.currentVersion }}</span>
</div>
<div>
<strong>更新状态</strong>
<span :class="{ 'update-available': updateState.available }">
{{ updateState.available ? "发现新版本" : "已是最新版" }}
</span>
</div>
</div>
<div class="update-notes">
<h4>更新说明</h4>
<p>{{ updateState.releaseNotes || "暂无发布说明" }}</p>
<p v-if="updateState.mandatory" class="update-mandatory">该版本为强制更新版本</p>
</div>
<div class="update-meta">
<span>上次检查{{ updateState.lastCheckedAt ? formatDate(updateState.lastCheckedAt) : "-" }}</span>
<span>提示{{ updateState.message || "可手动点击“检查更新”获取最新信息" }}</span>
</div>
</div>
</div>
</template>
</section>
<aside class="panel detail-panel">
<h3>详情面板</h3>
<template v-if="nav === 'files'">
<div class="stat-grid">
<div>
<strong>{{ fileStats.total }}</strong>
<span>当前项目数</span>
</div>
<div>
<strong>{{ fileStats.folders }}</strong>
<span>文件夹</span>
</div>
<div>
<strong>{{ fileStats.docs }}</strong>
<span>文件数</span>
</div>
<div>
<strong>{{ formatBytes(fileStats.totalBytes) }}</strong>
<span>目录容量</span>
</div>
</div>
<div v-if="batchMode" class="selected-info">
<h4>批量操作</h4>
<p>已勾选{{ batchSelectedItems.length }} </p>
<p>可执行批量删除批量分享批量直链</p>
</div>
<div v-if="selectedFile" class="selected-info">
<h4>{{ selectedFile.displayName || selectedFile.name }}</h4>
<p>类型{{ fileTypeLabel(selectedFile) }}</p>
<p>路径{{ buildItemPath(selectedFile) }}</p>
<p>大小{{ selectedFile.isDirectory ? "-" : (selectedFile.sizeFormatted || formatBytes(selectedFile.size)) }}</p>
<p>时间{{ formatDate(selectedFile.modifiedAt) }}</p>
</div>
<div v-else class="empty-tip">选中一个文件可查看详细信息</div>
</template>
<template v-else-if="nav === 'sync'">
<div class="stat-grid">
<div>
<strong>{{ syncState.autoEnabled ? "已开启" : "已关闭" }}</strong>
<span>自动同步</span>
</div>
<div>
<strong>{{ syncState.intervalMinutes }} 分钟</strong>
<span>同步周期</span>
</div>
<div>
<strong>{{ syncState.localDir ? "已设置" : "未设置" }}</strong>
<span>本地目录</span>
</div>
<div>
<strong>{{ syncState.remoteBasePath }}</strong>
<span>云端目录</span>
</div>
</div>
<div class="selected-info">
<h4>同步说明</h4>
<p>仅上传本地变更文件不会自动删除云端历史文件</p>
<p>网络中断或失败项会在下次同步自动重试</p>
<p>目录切换到全部文件后可立即看到最新结果</p>
</div>
</template>
<template v-else-if="nav === 'updates'">
<div class="stat-grid">
<div>
<strong>v{{ updateState.currentVersion }}</strong>
<span>当前版本</span>
</div>
<div>
<strong>v{{ updateState.latestVersion || updateState.currentVersion }}</strong>
<span>最新版本</span>
</div>
<div>
<strong>{{ updateState.available ? "可升级" : "最新" }}</strong>
<span>状态</span>
</div>
<div>
<strong>{{ updateState.downloadUrl ? "已提供" : "未配置" }}</strong>
<span>下载地址</span>
</div>
</div>
<div class="selected-info">
<h4>升级说明</h4>
<p>点击立即更新会下载并尝试启动安装包</p>
<p>升级后建议重启客户端确保版本信息刷新</p>
</div>
</template>
<template v-else>
<div class="stat-grid">
<div>
<strong>{{ transferTasks.length }}</strong>
<span>传输任务</span>
</div>
<div>
<strong>{{ shares.length }}</strong>
<span>分享数量</span>
</div>
<div>
<strong>{{ files.length }}</strong>
<span>目录项目</span>
</div>
<div>
<strong>{{ formatBytes(fileStats.totalBytes) }}</strong>
<span>目录容量</span>
</div>
</div>
</template>
</aside>
</main>
</section>
</div>
<div v-if="contextMenu.visible" class="context-mask" @click="closeContextMenu"></div>
<div
v-if="contextMenu.visible && contextMenu.item"
class="context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
>
<button class="context-item" @click="executeContextAction('open')">
{{ contextMenu.item.isDirectory ? "打开文件夹" : "打开预览" }}
</button>
<button v-if="!contextMenu.item.isDirectory" class="context-item" @click="executeContextAction('download')">下载文件</button>
<button class="context-item" @click="executeContextAction('rename')">重命名</button>
<button class="context-item danger" @click="executeContextAction('delete')">删除</button>
<div class="context-divider"></div>
<button class="context-item" @click="executeContextAction('share')">生成分享链接</button>
<button v-if="!contextMenu.item.isDirectory" class="context-item" @click="executeContextAction('direct')">生成直链</button>
</div>
<div v-if="toast.visible" class="toast" :class="toast.type">{{ toast.message }}</div>
</div>
</template>
<style scoped>
:global(html, body, #app) {
width: 100%;
height: 100%;
margin: 0;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: linear-gradient(155deg, #f4f7fb 0%, #eef3ff 40%, #e8eef8 100%);
color: #1f2b3a;
}
:global(*) {
box-sizing: border-box;
}
.desktop-root {
width: 100%;
height: 100%;
padding: 18px;
}
.login-shell {
height: 100%;
display: grid;
grid-template-columns: 1.2fr 0.9fr;
gap: 16px;
}
.login-brand,
.login-panel,
.panel {
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(141, 164, 196, 0.25);
border-radius: 20px;
box-shadow: 0 14px 28px rgba(28, 59, 102, 0.08);
}
.login-brand {
padding: 32px;
display: flex;
flex-direction: column;
gap: 12px;
}
.brand-chip {
width: fit-content;
background: #1d6fff;
color: #fff;
padding: 6px 12px;
border-radius: 999px;
font-size: 12px;
letter-spacing: 0.6px;
}
.login-brand h1 {
margin: 0;
font-size: 34px;
line-height: 1.2;
}
.login-brand p {
margin: 0;
color: #51667f;
font-size: 15px;
}
.brand-grid {
margin-top: 10px;
display: grid;
gap: 10px;
grid-template-columns: 1fr 1fr;
}
.brand-card {
padding: 16px;
border-radius: 14px;
background: #f2f7ff;
border: 1px solid #d7e5ff;
display: flex;
flex-direction: column;
gap: 6px;
}
.brand-card strong {
font-size: 14px;
}
.brand-card span {
font-size: 12px;
color: #5c7189;
}
.login-panel {
padding: 28px;
display: flex;
flex-direction: column;
gap: 12px;
}
.login-panel h2 {
margin: 0 0 8px;
font-size: 23px;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: #435872;
}
input[type="text"],
input[type="password"],
input[type="number"],
input[type="email"] {
width: 100%;
height: 42px;
border: 1px solid #d6dfe9;
border-radius: 12px;
padding: 0 12px;
font-size: 14px;
color: #1f2b3a;
background: #fff;
}
select {
width: 100%;
height: 42px;
border: 1px solid #d6dfe9;
border-radius: 12px;
padding: 0 12px;
font-size: 14px;
color: #1f2b3a;
background: #fff;
}
input[type="text"]:focus,
input[type="password"]:focus,
input[type="number"]:focus,
input[type="email"]:focus,
select:focus {
border-color: #1d6fff;
outline: none;
box-shadow: 0 0 0 3px rgba(29, 111, 255, 0.12);
}
.primary-btn,
.solid-btn,
.ghost-btn {
height: 40px;
border-radius: 12px;
border: 0;
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
.primary-btn,
.solid-btn {
background: linear-gradient(90deg, #1d6fff 0%, #3f8cff 100%);
color: #fff;
}
.primary-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.error-text {
margin: 0;
color: #d04848;
font-size: 13px;
}
.work-shell {
height: 100%;
display: grid;
grid-template-columns: 248px 1fr;
gap: 14px;
}
.left-nav {
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(141, 164, 196, 0.3);
border-radius: 20px;
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.left-header {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
margin-bottom: 4px;
}
.app-mark {
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(140deg, #1d6fff, #57a2ff);
color: #fff;
display: grid;
place-items: center;
font-weight: 800;
}
.left-header strong {
display: block;
font-size: 16px;
}
.left-header span {
font-size: 12px;
color: #6b8097;
}
.nav-btn {
text-align: left;
padding: 10px 12px;
border-radius: 12px;
border: 0;
background: transparent;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-btn span {
font-size: 14px;
color: #223244;
}
.nav-btn small {
color: #6b8097;
font-size: 11px;
}
.nav-btn:hover {
background: #eef4ff;
}
.nav-btn.active {
background: #1d6fff;
}
.nav-btn.active span,
.nav-btn.active small {
color: #fff;
}
.user-card {
margin-top: auto;
background: #eff4fb;
border-radius: 14px;
padding: 10px;
display: grid;
grid-template-columns: 38px 1fr auto;
gap: 10px;
align-items: center;
}
.avatar {
width: 38px;
height: 38px;
border-radius: 12px;
background: #d9e8ff;
display: grid;
place-items: center;
font-weight: 700;
color: #2456a8;
}
.user-card .meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-card .meta strong {
font-size: 13px;
}
.user-card .meta span {
font-size: 11px;
color: #60768f;
}
.ghost-btn {
width: 56px;
height: 28px;
background: #fff;
border: 1px solid #d8e2ee;
font-size: 12px;
}
.work-area {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
}
.top-tools {
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(141, 164, 196, 0.3);
border-radius: 16px;
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.crumbs {
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
overflow-x: auto;
padding-top: 3px;
}
.crumb-btn {
height: 30px;
display: inline-flex;
align-items: center;
border: 0;
background: transparent;
color: #3f5876;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
}
.crumb-btn:hover {
color: #1d6fff;
}
.tool-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
min-width: 0;
}
.tool-right-files {
flex: 1 1 700px;
min-width: 460px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.file-search-row {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.file-actions-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.search-input {
width: 280px;
height: 36px;
border-radius: 10px;
font-size: 13px;
}
.tool-right-files .search-input {
width: auto;
flex: 1 1 420px;
min-width: 220px;
}
.compact-select {
width: 120px;
height: 36px;
border-radius: 10px;
font-size: 12px;
padding: 0 8px;
}
.solid-btn {
height: 36px;
min-width: 78px;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.action-btn {
height: 36px;
min-width: 74px;
border: 1px solid #cad8ea;
border-radius: 10px;
background: #fff;
color: #284666;
padding: 0 12px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
white-space: nowrap;
cursor: pointer;
font-size: 13px;
}
.action-btn:hover {
background: #f2f7ff;
}
.action-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.action-btn.active {
border-color: #6a99de;
background: #eaf3ff;
color: #1d4f93;
}
.action-btn.danger {
border-color: #efc3c3;
color: #b53f3f;
background: #fff8f8;
}
.main-grid {
display: grid;
grid-template-columns: 1fr 270px;
gap: 14px;
min-height: 0;
}
.content-panel,
.detail-panel {
padding: 16px;
display: flex;
flex-direction: column;
min-height: 0;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.panel-head h3 {
margin: 0;
font-size: 16px;
}
.panel-head span {
color: #60768f;
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.icon-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, 144px);
justify-content: flex-start;
align-content: start;
min-height: 0;
overflow: auto;
padding: 2px 4px 2px 2px;
}
.file-drop-surface {
position: relative;
min-height: 0;
flex: 1;
}
.file-drop-surface.active .icon-grid {
filter: saturate(1.04) blur(0.2px);
}
.file-card {
border: 1px solid #d8e1ee;
border-radius: 14px;
background: #fff;
text-align: left;
padding: 12px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 6px;
width: 144px;
height: 144px;
overflow: hidden;
position: relative;
}
.file-card:hover {
border-color: #c6d7ee;
box-shadow: 0 6px 12px rgba(34, 72, 116, 0.08);
}
.file-card.selected {
border-color: #7baaf8;
background: #eef5ff;
box-shadow: 0 0 0 2px rgba(29, 111, 255, 0.1);
}
.file-card.batchSelected {
border-color: #3f84ec;
box-shadow: 0 0 0 2px rgba(63, 132, 236, 0.14);
}
.batch-check {
position: absolute;
right: 8px;
top: 8px;
width: 18px;
height: 18px;
border-radius: 6px;
border: 1px solid #c3d3e7;
background: #fff;
color: #2a5da6;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 700;
}
.batch-check.active {
border-color: #3f84ec;
background: #e6f0ff;
}
.file-icon-glyph {
width: 48px;
height: 48px;
border-radius: 10px;
border: 1px solid #d7e4f8;
background: #f2f7ff;
display: grid;
place-items: center;
font-size: 24px;
line-height: 1;
margin-bottom: 2px;
}
.file-name {
font-size: 13px;
font-weight: 600;
color: #1f2b3a;
line-height: 1.35;
width: 100%;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
min-height: 34px;
}
.file-meta,
.file-time {
font-size: 11px;
color: #60768f;
line-height: 1.35;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-time {
margin-top: auto;
}
.task-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.task-row {
padding: 10px;
border: 1px solid #d8e1ee;
border-radius: 12px;
display: grid;
grid-template-columns: 1fr 190px;
gap: 10px;
align-items: center;
}
.task-row strong {
display: block;
font-size: 13px;
}
.task-row small {
display: block;
color: #647b94;
}
.task-note {
margin-top: 2px;
color: #5f7895;
}
.task-note.error {
color: #c24747;
}
.task-right {
display: grid;
gap: 6px;
}
.task-right span {
font-size: 12px;
color: #4a6381;
}
.task-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.mini-btn {
height: 26px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid #c5d4e8;
background: #fff;
color: #2f4f74;
cursor: pointer;
font-size: 12px;
}
.mini-btn:hover {
background: #f3f8ff;
}
.mini-btn.ghost {
color: #6882a0;
}
.progress {
height: 10px;
background: #dbe7f8;
border: 1px solid #cadef6;
border-radius: 999px;
overflow: hidden;
}
.bar {
height: 100%;
background: linear-gradient(90deg, #1d6fff, #57a2ff);
transition: width 0.14s linear;
}
.task-percent {
justify-self: end;
color: #3f5f86;
font-size: 11px;
font-weight: 600;
}
.share-list {
display: flex;
flex-direction: column;
gap: 10px;
overflow: auto;
min-height: 0;
}
.share-item {
border: 1px solid #d8e1ee;
border-radius: 12px;
background: #fff;
padding: 12px;
display: grid;
gap: 12px;
grid-template-columns: 1fr auto;
align-items: center;
}
.share-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.share-title {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.share-title strong {
font-size: 14px;
color: #203043;
}
.share-badge {
display: inline-flex;
align-items: center;
height: 22px;
border-radius: 999px;
padding: 0 8px;
background: #eef5ff;
color: #3d5f8a;
font-size: 11px;
}
.share-link,
.share-meta {
font-size: 12px;
color: #5a718c;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
row-gap: 4px;
}
.share-actions {
display: flex;
align-items: center;
gap: 8px;
}
.sync-layout,
.update-layout {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.sync-path-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.sync-option-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.check-line {
display: inline-flex;
align-items: center;
gap: 8px;
color: #3b5574;
}
.check-line input[type="checkbox"] {
width: 16px;
height: 16px;
}
.sync-interval {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5c738f;
}
.sync-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.sync-summary-grid > div {
border: 1px solid #d8e1ee;
border-radius: 10px;
background: #f7faff;
padding: 10px 12px;
}
.sync-summary-grid strong {
display: block;
font-size: 15px;
color: #1f3653;
}
.sync-summary-grid span {
font-size: 11px;
color: #60768f;
}
.sync-meta {
border: 1px dashed #cfdced;
border-radius: 10px;
background: #f9fbff;
padding: 10px 12px;
}
.sync-meta p {
margin: 4px 0;
font-size: 12px;
color: #48627f;
}
.update-main {
border: 1px solid #d8e1ee;
border-radius: 12px;
background: #fff;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.update-version-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.update-version-row > div {
border: 1px solid #d8e1ee;
border-radius: 10px;
background: #f7faff;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.update-version-row strong {
font-size: 12px;
color: #60748e;
}
.update-version-row span {
font-size: 14px;
color: #1e3857;
}
.update-available {
color: #1f9a63 !important;
font-weight: 700;
}
.update-notes {
border: 1px dashed #cedbeb;
border-radius: 10px;
padding: 10px 12px;
background: #f9fbff;
}
.update-notes h4 {
margin: 0 0 8px;
font-size: 13px;
}
.update-notes p {
margin: 0 0 6px;
font-size: 12px;
color: #4f6984;
line-height: 1.5;
white-space: pre-wrap;
}
.update-mandatory {
color: #c24747 !important;
font-weight: 700;
}
.update-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.update-meta span {
font-size: 12px;
color: #4f6984;
}
.context-mask {
position: fixed;
inset: 0;
z-index: 80;
}
.context-menu {
position: fixed;
z-index: 90;
width: 224px;
border: 1px solid #d1dced;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 12px 24px rgba(25, 52, 86, 0.2);
padding: 8px;
}
.context-item {
width: 100%;
height: 36px;
border: 0;
border-radius: 8px;
background: transparent;
text-align: left;
padding: 0 12px;
display: flex;
align-items: center;
font-size: 13px;
color: #2a4664;
cursor: pointer;
}
.context-item:hover {
background: #eef4ff;
}
.context-item.danger {
color: #b53f3f;
}
.context-divider {
height: 1px;
background: #e5edf7;
margin: 6px 4px;
}
.detail-panel h3 {
margin: 0;
font-size: 16px;
}
.stat-grid {
margin-top: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.stat-grid > div {
border-radius: 10px;
border: 1px solid #d8e1ee;
background: #f7faff;
padding: 12px;
min-height: 68px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 4px;
}
.stat-grid strong {
display: block;
font-size: 15px;
}
.stat-grid span {
font-size: 11px;
color: #60768f;
}
.selected-info {
margin-top: 14px;
border-top: 1px solid #dbe6f2;
padding-top: 14px;
}
.selected-info h4 {
margin: 0 0 8px;
font-size: 14px;
}
.selected-info p {
margin: 0 0 7px;
font-size: 12px;
line-height: 1.45;
color: #4f6984;
word-break: break-all;
}
.empty-tip {
margin: auto;
padding: 24px 12px;
text-align: center;
line-height: 1.5;
color: #5a7089;
font-size: 13px;
}
.empty-tip.error {
color: #cc4242;
}
.drop-overlay {
position: absolute;
inset: 0;
z-index: 12;
border-radius: 12px;
background: rgba(29, 111, 255, 0.08);
border: 1px dashed rgba(29, 111, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
pointer-events: none;
}
.drop-overlay-card {
min-width: 260px;
max-width: 380px;
border-radius: 12px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(141, 164, 196, 0.4);
box-shadow: 0 10px 20px rgba(22, 44, 73, 0.12);
text-align: center;
}
.drop-overlay-card strong {
display: block;
margin-bottom: 4px;
font-size: 14px;
color: #1d3f73;
}
.drop-overlay-card span {
display: block;
font-size: 12px;
color: #5f7896;
}
.toast {
position: fixed;
right: 24px;
bottom: 24px;
padding: 10px 14px;
border-radius: 10px;
color: #fff;
font-size: 13px;
box-shadow: 0 10px 18px rgba(22, 44, 73, 0.18);
}
.toast.info {
background: #4f6e8f;
}
.toast.success {
background: #1f9a63;
}
.toast.error {
background: #d04848;
}
@media (max-width: 1260px) {
.top-tools {
flex-direction: column;
align-items: stretch;
}
.crumbs {
padding-top: 0;
}
.tool-right {
justify-content: flex-start;
}
.tool-right-files {
min-width: 0;
}
.file-search-row,
.file-actions-row {
justify-content: flex-start;
}
.tool-right-files .search-input {
min-width: 0;
}
.main-grid {
grid-template-columns: 1fr;
}
.share-item {
grid-template-columns: 1fr;
}
.share-actions {
justify-content: flex-start;
}
.sync-summary-grid,
.update-version-row {
grid-template-columns: 1fr 1fr;
}
.compact-select {
width: 108px;
}
}
</style>