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

4325 lines
121 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, nextTick, 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" | "settings";
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 OnlineDeviceItem = {
session_id: string;
client_type?: string;
device_name?: string;
platform?: string;
ip_address?: string;
last_active_at?: string;
created_at?: string;
expires_at?: string;
is_current?: boolean;
is_local?: boolean;
};
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 NativeUploadProgressEvent = {
taskId?: string;
uploadedBytes?: number;
totalBytes?: number;
progress?: 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.6",
latestVersion: "",
available: false,
mandatory: false,
checking: false,
downloadUrl: "",
releaseNotes: "",
lastCheckedAt: "",
message: "",
});
const onlineDevices = reactive({
loading: false,
kickingSessionId: "",
items: [] as OnlineDeviceItem[],
message: "",
lastLoadedAt: "",
});
const updateRuntime = reactive({
downloading: false,
installing: false,
taskId: "",
downloadedBytes: 0,
totalBytes: 0,
progress: 0,
speed: "-",
lastMeasureAt: 0,
lastMeasureBytes: 0,
installerPath: "",
});
const updatePrompt = reactive({
visible: false,
loading: false,
});
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
item: null as FileItem | null,
});
const shareDeleteDialog = reactive({
visible: false,
loading: false,
share: null as ShareItem | null,
});
const fileDeleteDialog = reactive({
visible: false,
loading: false,
file: null as FileItem | null,
});
const operationConfirmDialog = reactive({
visible: false,
loading: false,
mode: "" as "" | "kick-device" | "batch-delete",
title: "",
message: "",
confirmText: "确定",
sessionId: "",
batchItems: [] as FileItem[],
});
const inlineRename = reactive({
active: false,
originalName: "",
value: "",
saving: false,
});
const dropState = reactive({
active: false,
uploading: false,
total: 0,
done: 0,
failed: 0,
});
const uploadRuntime = reactive({
active: false,
taskId: "",
fileName: "",
uploadedBytes: 0,
totalBytes: 0,
progress: 0,
speed: "-",
lastMeasureAt: 0,
lastMeasureBytes: 0,
});
let unlistenDragDrop: UnlistenFn | null = null;
let unlistenNativeDownloadProgress: UnlistenFn | null = null;
let unlistenNativeUploadProgress: UnlistenFn | null = null;
let syncTimer: ReturnType<typeof setInterval> | null = null;
let hasCheckedUpdateAfterAuth = false;
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: "settings" 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: "同步盘",
settings: "设置",
};
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 formatSpeed(bytesPerSecond: number) {
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "-";
return `${formatBytes(bytesPerSecond)}/s`;
}
function measureRate(
currentBytes: number,
state: { lastMeasureAt: number; lastMeasureBytes: number },
) {
const now = Date.now();
if (!state.lastMeasureAt) {
state.lastMeasureAt = now;
state.lastMeasureBytes = currentBytes;
return "-";
}
const elapsedMs = now - state.lastMeasureAt;
if (elapsedMs < 180) {
return "-";
}
const deltaBytes = Math.max(0, currentBytes - state.lastMeasureBytes);
state.lastMeasureAt = now;
state.lastMeasureBytes = currentBytes;
if (deltaBytes <= 0) return "0 B/s";
const bytesPerSecond = (deltaBytes * 1000) / elapsedMs;
return formatSpeed(bytesPerSecond);
}
function resetUpdateRuntime() {
updateRuntime.downloading = false;
updateRuntime.installing = false;
updateRuntime.taskId = "";
updateRuntime.downloadedBytes = 0;
updateRuntime.totalBytes = 0;
updateRuntime.progress = 0;
updateRuntime.speed = "-";
updateRuntime.lastMeasureAt = 0;
updateRuntime.lastMeasureBytes = 0;
updateRuntime.installerPath = "";
}
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;
if (taskId === updateRuntime.taskId) {
updateRuntime.downloadedBytes = downloadedBytes;
updateRuntime.totalBytes = Number.isFinite(totalBytes) ? Math.max(0, totalBytes) : 0;
updateRuntime.progress = Number.isFinite(boundedProgress)
? Number(boundedProgress.toFixed(1))
: updateRuntime.progress;
const speedText = measureRate(downloadedBytes, updateRuntime);
if (speedText !== "-") {
updateRuntime.speed = speedText;
}
if (payload?.done) {
updateRuntime.progress = 100;
updateRuntime.speed = "-";
}
return;
}
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 applyNativeUploadProgress(payload: NativeUploadProgressEvent) {
const taskId = String(payload?.taskId || "").trim();
if (!taskId) return;
const uploadedBytes = Number(payload?.uploadedBytes || 0);
const totalBytes = Math.max(1, Number(payload?.totalBytes || 0));
const eventProgress = Number(payload?.progress);
const progressValue = Number.isFinite(eventProgress)
? eventProgress
: (uploadedBytes / totalBytes) * 100;
const boundedProgress = Math.max(1, Math.min(payload?.done ? 100 : 99.8, progressValue));
let transferSpeed = "上传中";
if (taskId === uploadRuntime.taskId) {
const sampledSpeed = measureRate(uploadedBytes, uploadRuntime);
if (sampledSpeed !== "-") {
transferSpeed = sampledSpeed;
}
}
updateTransferTask(taskId, {
status: "uploading",
speed: transferSpeed,
progress: Number(boundedProgress.toFixed(1)),
note: `${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)}`,
});
if (taskId === uploadRuntime.taskId) {
uploadRuntime.uploadedBytes = uploadedBytes;
uploadRuntime.totalBytes = totalBytes;
uploadRuntime.progress = Number(boundedProgress.toFixed(1));
if (transferSpeed !== "上传中") {
uploadRuntime.speed = transferSpeed;
}
if (payload?.done) {
uploadRuntime.progress = 100;
uploadRuntime.speed = "-";
}
}
}
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
}
}
function normalizeReleaseNotesText(raw: string | undefined) {
return String(raw || "")
.replace(/\\r\\n/g, "\n")
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\n")
.trim();
}
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 = normalizeReleaseNotesText(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;
}
function getUpdateSkipStorageKey() {
return `wanwan_desktop_skip_update_${String(user.value?.id || "guest")}`;
}
function shouldSkipCurrentUpdatePrompt() {
const latest = String(updateState.latestVersion || "").trim();
if (!latest) return false;
return localStorage.getItem(getUpdateSkipStorageKey()) === latest;
}
function skipCurrentUpdatePrompt() {
const latest = String(updateState.latestVersion || "").trim();
if (!latest) return;
localStorage.setItem(getUpdateSkipStorageKey(), latest);
}
async function checkUpdateAfterLogin() {
if (!authenticated.value || hasCheckedUpdateAfterAuth) return;
hasCheckedUpdateAfterAuth = true;
const checked = await checkClientUpdate(false);
if (!checked || !updateState.available || !updateState.downloadUrl) return;
if (shouldSkipCurrentUpdatePrompt()) return;
updatePrompt.visible = true;
}
function dismissUpdatePrompt(ignoreThisVersion = false) {
if (ignoreThisVersion) {
skipCurrentUpdatePrompt();
}
updatePrompt.visible = false;
}
async function confirmUpdateFromPrompt() {
if (updatePrompt.loading) return;
updatePrompt.loading = true;
updatePrompt.visible = false;
try {
nav.value = "settings";
await installLatestUpdate();
} finally {
updatePrompt.loading = false;
}
}
async function installLatestUpdate(): Promise<boolean> {
if (updateRuntime.downloading || updateRuntime.installing) {
showToast("更新包正在下载,请稍候", "info");
return false;
}
if (!updateState.downloadUrl) {
showToast("当前没有可用的更新下载地址", "info");
return false;
}
resetUpdateRuntime();
updateRuntime.downloading = true;
const taskId = `UPD-${Date.now()}`;
const installerName = `wanwan-cloud-desktop_v${updateState.latestVersion || updateState.currentVersion}.exe`;
updateRuntime.taskId = taskId;
updateRuntime.progress = 1;
updateRuntime.speed = "准备下载";
const response = await invokeBridge("api_native_download", {
url: updateState.downloadUrl,
fileName: installerName,
taskId,
});
try {
if (response.ok && response.data?.success) {
updateRuntime.downloading = false;
updateRuntime.installing = true;
updateRuntime.progress = 100;
updateRuntime.speed = "-";
const savePath = String(response.data?.savePath || "").trim();
updateRuntime.installerPath = savePath;
if (savePath) {
const launchResponse = await invokeBridge("api_silent_install_and_restart", {
installerPath: savePath,
});
if (launchResponse.ok && launchResponse.data?.success) {
showToast("静默安装已启动,完成后会自动重启客户端", "success");
setTimeout(() => {
void getCurrentWindow().close();
}, 400);
return true;
}
try {
await openPath(savePath);
} catch (error) {
console.error("open installer fallback failed", error);
}
}
showToast("更新包已下载,请手动运行安装程序", "info");
setTimeout(() => {
resetUpdateRuntime();
}, 1200);
return true;
}
const message = String(response.data?.message || "下载更新包失败");
resetUpdateRuntime();
showToast(message, "error");
return false;
} finally {
if (!updateRuntime.installing) {
updateRuntime.downloading = false;
}
}
}
function formatOnlineDeviceType(value: string | undefined) {
const kind = String(value || "").trim().toLowerCase();
if (kind === "desktop") return "桌面端";
if (kind === "mobile") return "移动端";
if (kind === "api") return "API";
return "网页端";
}
function openOperationConfirmDialog(options: {
mode: "" | "kick-device" | "batch-delete";
title: string;
message: string;
confirmText?: string;
sessionId?: string;
batchItems?: FileItem[];
}) {
if (operationConfirmDialog.loading) return;
operationConfirmDialog.mode = options.mode;
operationConfirmDialog.title = options.title;
operationConfirmDialog.message = options.message;
operationConfirmDialog.confirmText = String(options.confirmText || "确定");
operationConfirmDialog.sessionId = String(options.sessionId || "").trim();
operationConfirmDialog.batchItems = Array.isArray(options.batchItems) ? [...options.batchItems] : [];
operationConfirmDialog.visible = true;
}
function closeOperationConfirmDialog(force = false) {
if (operationConfirmDialog.loading && !force) return;
operationConfirmDialog.visible = false;
operationConfirmDialog.loading = false;
operationConfirmDialog.mode = "";
operationConfirmDialog.title = "";
operationConfirmDialog.message = "";
operationConfirmDialog.confirmText = "确定";
operationConfirmDialog.sessionId = "";
operationConfirmDialog.batchItems = [];
}
async function loadOnlineDevices(silent = false) {
if (!silent) {
onlineDevices.loading = true;
}
onlineDevices.message = "";
const response = await invokeBridge("api_list_online_devices", {
baseUrl: appConfig.baseUrl,
});
if (response.ok && response.data?.success) {
onlineDevices.items = Array.isArray(response.data?.devices) ? response.data.devices : [];
onlineDevices.lastLoadedAt = new Date().toISOString();
} else {
onlineDevices.message = String(response.data?.message || "加载在线设备失败");
if (!silent) {
showToast(onlineDevices.message, "error");
}
}
onlineDevices.loading = false;
}
function requestKickOnlineDevice(item: OnlineDeviceItem) {
const sessionId = String(item?.session_id || "").trim();
if (!sessionId || onlineDevices.kickingSessionId) return;
const isCurrent = Boolean(item?.is_current || item?.is_local);
openOperationConfirmDialog({
mode: "kick-device",
title: isCurrent ? "确认下线本机" : "确认踢下线设备",
message: isCurrent
? "确定要下线当前设备吗?下线后需要重新登录。"
: "确定要强制该设备下线吗?",
confirmText: isCurrent ? "确认下线" : "确认踢下线",
sessionId,
});
}
async function kickOnlineDeviceBySessionId(sessionId: string) {
onlineDevices.kickingSessionId = sessionId;
const response = await invokeBridge("api_kick_online_device", {
baseUrl: appConfig.baseUrl,
sessionId,
});
onlineDevices.kickingSessionId = "";
if (response.ok && response.data?.success) {
showToast(String(response.data?.message || "设备已下线"), "success");
if (response.data?.kicked_current) {
await handleLogout();
return;
}
await loadOnlineDevices(true);
return;
}
showToast(String(response.data?.message || "踢下线失败"), "error");
}
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, taskId?: string) {
const resumableResponse = await invokeBridge("api_upload_file_resumable", {
baseUrl: appConfig.baseUrl,
filePath,
targetPath,
chunkSize: 4 * 1024 * 1024,
taskId: taskId || null,
});
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,
taskId: taskId || null,
});
}
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, taskId);
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) {
cancelInlineRename(true);
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 || "创建直链失败"));
}
function requestDeleteShare(share: ShareItem) {
if (shareDeleteDialog.loading) return;
shareDeleteDialog.share = share;
shareDeleteDialog.visible = true;
}
function closeDeleteShareDialog(force = false) {
if (shareDeleteDialog.loading && !force) return;
shareDeleteDialog.visible = false;
shareDeleteDialog.loading = false;
shareDeleteDialog.share = null;
}
function requestDeleteFile(target?: FileItem | null) {
if (fileDeleteDialog.loading) return;
const current = target || selectedFile.value;
if (!current) {
showToast("请先选中文件或文件夹", "info");
return;
}
fileDeleteDialog.file = current;
fileDeleteDialog.visible = true;
}
function closeDeleteFileDialog(force = false) {
if (fileDeleteDialog.loading && !force) return;
fileDeleteDialog.visible = false;
fileDeleteDialog.loading = false;
fileDeleteDialog.file = null;
}
async function deleteShare(share: ShareItem) {
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 true;
}
showToast(response.data?.message || "删除分享失败", "error");
return false;
}
async function confirmDeleteShare() {
const share = shareDeleteDialog.share;
if (!share || shareDeleteDialog.loading) return;
shareDeleteDialog.loading = true;
try {
const ok = await deleteShare(share);
if (ok) {
closeDeleteShareDialog(true);
return;
}
shareDeleteDialog.loading = false;
} catch {
shareDeleteDialog.loading = false;
}
}
async function confirmDeleteFile() {
const file = fileDeleteDialog.file;
if (!file || fileDeleteDialog.loading) return;
fileDeleteDialog.loading = true;
try {
const ok = await deleteSelected(file, true);
if (ok) {
closeDeleteFileDialog(true);
showToast("删除成功", "success");
await loadFiles(pathState.currentPath);
return;
}
fileDeleteDialog.loading = false;
} catch {
fileDeleteDialog.loading = false;
}
}
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 false;
authenticated.value = true;
loadSyncConfig();
rebuildSyncScheduler();
await loadFiles("/");
await loadShares(true);
void loadOnlineDevices(true);
await checkUpdateAfterLogin();
return true;
}
async function tryAutoLoginFromSavedState() {
if (authenticated.value) return true;
const stateResponse = await invokeBridge("api_load_login_state", {});
if (!(stateResponse.ok && stateResponse.data?.success && stateResponse.data?.hasState)) {
return false;
}
const savedBase = String(stateResponse.data?.baseUrl || "").trim();
const savedUsername = String(stateResponse.data?.username || "").trim();
const savedPassword = String(stateResponse.data?.password || "");
if (!savedBase || !savedUsername || !savedPassword) {
return false;
}
appConfig.baseUrl = savedBase.replace(/\/+$/, "");
loginForm.username = savedUsername;
loginState.loading = true;
const loginResponse = await invokeBridge("api_login", {
baseUrl: appConfig.baseUrl,
username: savedUsername,
password: savedPassword,
captcha: null,
});
loginState.loading = false;
if (!(loginResponse.ok && loginResponse.data?.success)) {
await invokeBridge("api_clear_login_state", {});
return false;
}
authenticated.value = true;
user.value = loginResponse.data.user || null;
nav.value = "files";
loginForm.password = savedPassword;
loadSyncConfig();
rebuildSyncScheduler();
await loadFiles("/");
if (!user.value) {
await loadProfile();
}
void loadOnlineDevices(true);
await checkUpdateAfterLogin();
showToast("已恢复登录状态", "success");
return 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");
hasCheckedUpdateAfterAuth = false;
loadSyncConfig();
rebuildSyncScheduler();
await loadFiles("/");
if (!user.value) {
await loadProfile();
}
await invokeBridge("api_save_login_state", {
baseUrl: appConfig.baseUrl,
username: loginForm.username.trim(),
password: loginForm.password,
});
void loadOnlineDevices(true);
await checkUpdateAfterLogin();
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 });
await invokeBridge("api_clear_login_state", {});
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;
updateRuntime.installing = false;
updatePrompt.visible = false;
fileDeleteDialog.visible = false;
fileDeleteDialog.loading = false;
fileDeleteDialog.file = null;
closeOperationConfirmDialog(true);
cancelInlineRename(true);
onlineDevices.loading = false;
onlineDevices.kickingSessionId = "";
onlineDevices.items = [];
onlineDevices.message = "";
onlineDevices.lastLoadedAt = "";
hasCheckedUpdateAfterAuth = 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");
}
function isInlineRenaming(item: FileItem | null | undefined) {
if (!item) return false;
return inlineRename.active && inlineRename.originalName === item.name;
}
function focusInlineRenameInput() {
nextTick(() => {
const input = document.querySelector<HTMLInputElement>(".inline-rename-input[data-renaming='1']");
if (!input) return;
input.focus();
input.select();
});
}
function cancelInlineRename(force = false) {
if (inlineRename.saving && !force) return;
inlineRename.active = false;
inlineRename.originalName = "";
inlineRename.value = "";
inlineRename.saving = false;
}
function startInlineRename(target?: FileItem | null) {
const current = target || selectedFile.value;
if (!current) {
showToast("请先选中文件或文件夹", "info");
return;
}
selectedFileName.value = current.name;
inlineRename.active = true;
inlineRename.originalName = current.name;
inlineRename.value = current.displayName || current.name;
inlineRename.saving = false;
focusInlineRenameInput();
}
async function submitInlineRename(target?: FileItem | null) {
const current = target || files.value.find((item) => item.name === inlineRename.originalName) || null;
if (!current || !inlineRename.active || inlineRename.saving) return;
const nextName = String(inlineRename.value || "").trim();
if (!nextName) {
cancelInlineRename(true);
return;
}
if (nextName === current.name) {
cancelInlineRename(true);
return;
}
inlineRename.saving = true;
const response = await invokeBridge("api_rename_file", {
baseUrl: appConfig.baseUrl,
path: getItemParentPath(current),
oldName: current.name,
newName: nextName,
});
inlineRename.saving = false;
if (response.ok && response.data?.success) {
showToast("重命名成功", "success");
cancelInlineRename(true);
await loadFiles(pathState.currentPath);
selectedFileName.value = nextName;
return;
}
showToast(response.data?.message || "重命名失败", "error");
}
async function renameSelected(target?: FileItem | null) {
startInlineRename(target);
}
async function deleteSelected(target?: FileItem | null, silent = false) {
const current = target || selectedFile.value;
if (!current) {
if (!silent) showToast("请先选中文件或文件夹", "info");
return false;
}
if (!silent) {
requestDeleteFile(current);
return false;
}
const response = await invokeBridge("api_delete_file", {
baseUrl: appConfig.baseUrl,
path: getItemParentPath(current),
fileName: current.name,
});
if (response.ok && response.data?.success) {
return true;
}
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;
}
function handleFileCardClick(item: FileItem) {
if (isInlineRenaming(item)) return;
selectFile(item);
}
async function handleFileCardDoubleClick(item: FileItem) {
if (isInlineRenaming(item)) return;
await openItem(item);
}
async function openItem(item: FileItem) {
if (batchMode.value) return;
if (isInlineRenaming(item)) 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;
if (inlineRename.active && !isInlineRenaming(item)) {
cancelInlineRename(true);
}
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, taskId);
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;
}
openOperationConfirmDialog({
mode: "batch-delete",
title: "确认批量删除",
message: `确认删除已勾选的 ${batchSelectedItems.value.length} 个项目吗?删除后将无法恢复。`,
confirmText: "确定删除",
batchItems: [...batchSelectedItems.value],
});
}
async function runBatchDelete(items: FileItem[]) {
let success = 0;
let failed = 0;
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 confirmOperationDialog() {
if (operationConfirmDialog.loading) return;
operationConfirmDialog.loading = true;
try {
if (operationConfirmDialog.mode === "kick-device") {
const sessionId = operationConfirmDialog.sessionId;
if (sessionId) {
await kickOnlineDeviceBySessionId(sessionId);
}
closeOperationConfirmDialog(true);
return;
}
if (operationConfirmDialog.mode === "batch-delete") {
await runBatchDelete([...operationConfirmDialog.batchItems]);
closeOperationConfirmDialog(true);
return;
}
closeOperationConfirmDialog(true);
} catch {
operationConfirmDialog.loading = false;
}
}
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") {
if (operationConfirmDialog.visible) {
closeOperationConfirmDialog();
return;
}
if (inlineRename.active) {
cancelInlineRename();
return;
}
if (contextMenu.visible) {
closeContextMenu();
}
return;
}
if (
event.key === "F2"
&& authenticated.value
&& nav.value === "files"
&& !batchMode.value
&& !inlineRename.active
) {
event.preventDefault();
startInlineRename();
}
}
function handleGlobalContextMenu(event: MouseEvent) {
const target = event.target as HTMLElement | null;
if (!target) return;
if (target.closest(".context-menu")) return;
if (target.closest("input, textarea, [contenteditable=\"true\"]")) return;
event.preventDefault();
}
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 });
uploadRuntime.active = true;
uploadRuntime.taskId = taskId;
uploadRuntime.fileName = displayName;
uploadRuntime.uploadedBytes = 0;
uploadRuntime.totalBytes = 0;
uploadRuntime.progress = 1;
uploadRuntime.speed = "准备上传";
uploadRuntime.lastMeasureAt = 0;
uploadRuntime.lastMeasureBytes = 0;
const response = await uploadFileWithResume(filePath, pathState.currentPath, taskId);
if (response.ok && response.data?.success) {
successCount += 1;
dropState.done += 1;
uploadRuntime.progress = 100;
uploadRuntime.speed = "-";
uploadRuntime.uploadedBytes = Math.max(uploadRuntime.uploadedBytes, uploadRuntime.totalBytes);
updateTransferTask(taskId, {
speed: "-",
progress: 100,
status: "done",
note: "上传成功",
});
} else {
dropState.failed += 1;
const message = String(response.data?.message || "上传失败");
uploadRuntime.speed = "-";
updateTransferTask(taskId, {
speed: "-",
progress: 0,
status: "failed",
note: message,
});
}
}
dropState.uploading = false;
setTimeout(() => {
uploadRuntime.active = false;
uploadRuntime.taskId = "";
}, 1600);
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);
}
}
async function registerNativeUploadProgressListener() {
try {
unlistenNativeUploadProgress = await listen<NativeUploadProgressEvent>("native-upload-progress", (event) => {
applyNativeUploadProgress(event.payload || {});
});
} catch (error) {
console.error("register native upload progress listener failed", error);
}
}
watch(nav, async (next) => {
if (next !== "files" && inlineRename.active) {
cancelInlineRename(true);
}
if (next === "shares" && authenticated.value) {
await loadShares();
return;
}
if (next === "settings" && authenticated.value) {
await checkClientUpdate(false);
await loadOnlineDevices(true);
}
});
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);
window.addEventListener("contextmenu", handleGlobalContextMenu);
await registerDragDropListener();
await registerNativeDownloadProgressListener();
await registerNativeUploadProgressListener();
await initClientVersion();
const restored = await restoreSession();
if (!restored) {
await tryAutoLoginFromSavedState();
}
});
onBeforeUnmount(() => {
window.removeEventListener("click", handleGlobalClick);
window.removeEventListener("keydown", handleGlobalKey);
window.removeEventListener("contextmenu", handleGlobalContextMenu);
clearSyncScheduler();
if (unlistenDragDrop) {
unlistenDragDrop();
unlistenDragDrop = null;
}
if (unlistenNativeDownloadProgress) {
unlistenNativeDownloadProgress();
unlistenNativeDownloadProgress = null;
}
if (unlistenNativeUploadProgress) {
unlistenNativeUploadProgress();
unlistenNativeUploadProgress = 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 === 'settings'">
<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>
<button class="action-btn" :disabled="onlineDevices.loading || !!onlineDevices.kickingSessionId" @click="loadOnlineDevices()">
{{ onlineDevices.loading ? "刷新中..." : "刷新设备" }}
</button>
</template>
</div>
</header>
<main class="main-grid" :class="{ 'focus-content': nav === 'files' }">
<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 }">
<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">
<div
v-for="item in filteredFiles"
:key="item.name"
class="file-card"
:class="{ selected: selectedFileName === item.name, batchSelected: isBatchSelected(item.name), renaming: isInlineRenaming(item) }"
role="button"
tabindex="0"
@click="handleFileCardClick(item)"
@dblclick="handleFileCardDoubleClick(item)"
@keydown.enter.prevent="handleFileCardDoubleClick(item)"
@keydown.space.prevent="handleFileCardClick(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">
<template v-if="isInlineRenaming(item)">
<input
v-model="inlineRename.value"
class="inline-rename-input"
data-renaming="1"
:disabled="inlineRename.saving"
@click.stop
@dblclick.stop
@keydown.enter.prevent="submitInlineRename(item)"
@keydown.esc.prevent="cancelInlineRename()"
@blur="cancelInlineRename()"
/>
</template>
<template v-else>
{{ item.displayName || item.name }}
</template>
</div>
<div class="file-meta">{{ fileTypeLabel(item) }} · {{ item.isDirectory ? "-" : (item.sizeFormatted || formatBytes(item.size)) }}</div>
<div class="file-time">{{ formatDate(item.modifiedAt) }}</div>
</div>
</div>
<div v-if="dropState.active" class="drop-overlay">
<div class="drop-overlay-card">
<strong>拖拽到此处上传到当前目录</strong>
<span>仅支持文件文件夹会自动跳过</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="requestDeleteShare(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 === 'settings'">
<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 class="settings-device-card">
<div class="settings-device-head">
<strong>在线设备</strong>
<span>{{ onlineDevices.items.length }} </span>
</div>
<p class="settings-device-tip">可强制下线异常设备标记本机的为当前客户端</p>
<p v-if="onlineDevices.message" class="settings-device-error">{{ onlineDevices.message }}</p>
<div v-if="onlineDevices.loading && onlineDevices.items.length === 0" class="empty-tip">正在加载在线设备...</div>
<div v-else-if="onlineDevices.items.length === 0" class="empty-tip">暂无在线设备</div>
<div v-else class="settings-device-list">
<div v-for="item in onlineDevices.items" :key="item.session_id" class="settings-device-item">
<div class="settings-device-main">
<div class="settings-device-name-row">
<strong>{{ item.device_name || "未知设备" }}</strong>
<span class="share-badge">{{ formatOnlineDeviceType(item.client_type) }}</span>
<span v-if="item.is_current || item.is_local" class="share-badge local">本机</span>
</div>
<div class="settings-device-meta">
<span>平台 {{ item.platform || "-" }}</span>
<span>IP {{ item.ip_address || "-" }}</span>
<span>活跃 {{ formatDate(item.last_active_at) }}</span>
<span>登录 {{ formatDate(item.created_at) }}</span>
</div>
</div>
<button
class="action-btn danger"
:disabled="onlineDevices.kickingSessionId === item.session_id || operationConfirmDialog.loading"
@click="requestKickOnlineDevice(item)"
>
{{ onlineDevices.kickingSessionId === item.session_id ? "处理中..." : (item.is_current || item.is_local ? "下线本机" : "踢下线") }}
</button>
</div>
</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 === 'settings'">
<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>当前在线设备{{ onlineDevices.items.length }} </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="shareDeleteDialog.visible" class="confirm-mask" @click="closeDeleteShareDialog()">
<div class="confirm-card" @click.stop>
<h4>确认删除分享</h4>
<p>
确认删除分享码 <strong>{{ shareDeleteDialog.share?.share_code || "-" }}</strong>
删除后外链将立即失效
</p>
<div class="confirm-actions">
<button class="action-btn" :disabled="shareDeleteDialog.loading" @click="closeDeleteShareDialog()">取消</button>
<button class="action-btn danger" :disabled="shareDeleteDialog.loading" @click="confirmDeleteShare()">
{{ shareDeleteDialog.loading ? "删除中..." : "确定删除" }}
</button>
</div>
</div>
</div>
<div v-if="fileDeleteDialog.visible" class="confirm-mask" @click="closeDeleteFileDialog()">
<div class="confirm-card" @click.stop>
<h4>确认删除文件</h4>
<p>
确认删除 <strong>{{ fileDeleteDialog.file?.displayName || fileDeleteDialog.file?.name || "-" }}</strong>
删除后将无法恢复
</p>
<div class="confirm-actions">
<button class="action-btn" :disabled="fileDeleteDialog.loading" @click="closeDeleteFileDialog()">取消</button>
<button class="action-btn danger" :disabled="fileDeleteDialog.loading" @click="confirmDeleteFile()">
{{ fileDeleteDialog.loading ? "删除中..." : "确定删除" }}
</button>
</div>
</div>
</div>
<div v-if="operationConfirmDialog.visible" class="confirm-mask" @click="closeOperationConfirmDialog()">
<div class="confirm-card" @click.stop>
<h4>{{ operationConfirmDialog.title || "确认操作" }}</h4>
<p>{{ operationConfirmDialog.message || "确认继续执行该操作吗?" }}</p>
<div class="confirm-actions">
<button class="action-btn" :disabled="operationConfirmDialog.loading" @click="closeOperationConfirmDialog()">取消</button>
<button class="action-btn danger" :disabled="operationConfirmDialog.loading" @click="confirmOperationDialog()">
{{ operationConfirmDialog.loading ? "处理中..." : (operationConfirmDialog.confirmText || "确定") }}
</button>
</div>
</div>
</div>
<div v-if="updatePrompt.visible" class="confirm-mask" @click="dismissUpdatePrompt()">
<div class="confirm-card" @click.stop>
<h4>发现新版本 v{{ updateState.latestVersion || "-" }}</h4>
<p>
已在登录后检测到可用更新是否现在进行静默升级
升级完成后将自动重启客户端
</p>
<div class="confirm-actions">
<button class="action-btn" :disabled="updatePrompt.loading" @click="dismissUpdatePrompt(true)">稍后提醒</button>
<button class="action-btn danger" :disabled="updatePrompt.loading" @click="confirmUpdateFromPrompt()">
{{ updatePrompt.loading ? "处理中..." : "立即更新" }}
</button>
</div>
</div>
</div>
<div v-if="uploadRuntime.active || updateRuntime.downloading || updateRuntime.installing" class="status-stack">
<div v-if="uploadRuntime.active" class="status-card">
<div class="status-head">
<strong>上传中</strong>
<span>{{ Math.max(0, Math.min(100, Math.round(uploadRuntime.progress))) }}%</span>
</div>
<div class="status-name" :title="uploadRuntime.fileName">{{ uploadRuntime.fileName || "正在上传文件" }}</div>
<div class="progress compact">
<div class="bar" :style="{ width: `${uploadRuntime.progress}%` }" />
</div>
<small>{{ formatBytes(uploadRuntime.uploadedBytes) }} / {{ formatBytes(uploadRuntime.totalBytes) }} · {{ uploadRuntime.speed }}</small>
</div>
<div v-if="updateRuntime.downloading || updateRuntime.installing" class="status-card">
<div class="status-head">
<strong>{{ updateRuntime.installing ? "安装更新" : "下载更新" }}</strong>
<span>{{ Math.max(0, Math.min(100, Math.round(updateRuntime.progress))) }}%</span>
</div>
<div class="status-name">
{{ updateRuntime.installing ? "静默安装中,完成后自动重启" : `v${updateState.latestVersion || "-"}` }}
</div>
<div class="progress compact">
<div class="bar" :style="{ width: `${updateRuntime.progress}%` }" />
</div>
<small v-if="updateRuntime.downloading">{{ formatBytes(updateRuntime.downloadedBytes) }} / {{ formatBytes(updateRuntime.totalBytes) }} · {{ updateRuntime.speed }}</small>
<small v-else>请稍候应用将自动退出并重启</small>
</div>
</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: 8px;
}
.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;
}
.main-grid.focus-content {
grid-template-columns: 1fr;
}
.main-grid.focus-content .detail-panel {
display: none;
}
.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:focus-visible {
outline: 2px solid #79a8f0;
outline-offset: 1px;
}
.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);
}
.file-card.renaming {
border-color: #7baaf8;
box-shadow: 0 0 0 2px rgba(29, 111, 255, 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;
}
.inline-rename-input {
width: 100%;
height: 28px;
border: 1px solid #7baaf8;
border-radius: 8px;
background: #fff;
padding: 0 8px;
font-size: 13px;
font-weight: 600;
color: #1f2b3a;
}
.inline-rename-input:focus {
outline: 0;
border-color: #3f84ec;
box-shadow: 0 0 0 2px rgba(63, 132, 236, 0.16);
}
.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;
}
.progress.compact {
height: 8px;
}
.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;
}
.share-badge.local {
background: #e8f8ee;
color: #1f8f4f;
}
.settings-device-card {
border: 1px solid #d8e1ee;
border-radius: 12px;
background: #fff;
padding: 12px;
display: grid;
gap: 10px;
}
.settings-device-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.settings-device-head strong {
font-size: 14px;
color: #203754;
}
.settings-device-head span {
font-size: 12px;
color: #5d7898;
}
.settings-device-tip {
margin: 0;
color: #5a718c;
font-size: 12px;
}
.settings-device-error {
margin: 0;
color: #c24747;
font-size: 12px;
}
.settings-device-list {
display: grid;
gap: 8px;
max-height: 310px;
overflow: auto;
}
.settings-device-item {
border: 1px solid #d8e1ee;
border-radius: 10px;
background: #f8fbff;
padding: 10px;
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: center;
}
.settings-device-main {
min-width: 0;
display: grid;
gap: 6px;
}
.settings-device-name-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.settings-device-name-row strong {
font-size: 13px;
color: #203043;
}
.settings-device-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
row-gap: 4px;
}
.settings-device-meta span {
font-size: 11px;
color: #60768f;
}
.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;
}
.confirm-mask {
position: fixed;
inset: 0;
z-index: 1200;
background: rgba(20, 33, 54, 0.42);
display: grid;
place-items: center;
padding: 20px;
}
.confirm-card {
width: min(460px, 100%);
border-radius: 14px;
background: #fff;
border: 1px solid #d7e2f0;
box-shadow: 0 16px 36px rgba(30, 52, 88, 0.2);
padding: 18px;
display: grid;
gap: 10px;
}
.confirm-card h4 {
margin: 0;
font-size: 16px;
color: #203043;
}
.confirm-card p {
margin: 0;
color: #4f6784;
line-height: 1.6;
font-size: 13px;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.status-stack {
position: fixed;
right: 14px;
bottom: 14px;
z-index: 1250;
width: min(360px, calc(100vw - 24px));
display: grid;
gap: 8px;
}
.status-card {
border-radius: 12px;
border: 1px solid #d3dfef;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 10px 20px rgba(31, 56, 92, 0.16);
padding: 10px 12px;
display: grid;
gap: 6px;
}
.status-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.status-head strong {
font-size: 13px;
color: #203754;
}
.status-head span {
font-size: 12px;
color: #456892;
}
.status-name {
font-size: 12px;
color: #4f6784;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-card small {
color: #5d7898;
font-size: 11px;
}
.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;
}
.settings-device-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>