4325 lines
121 KiB
Vue
4325 lines
121 KiB
Vue
<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>
|