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

7805 lines
209 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { openPath, openUrl } from "@tauri-apps/plugin-opener";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { getVersion } from "@tauri-apps/api/app";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import {
PhArrowClockwise,
PhArrowsClockwise,
PhArrowsDownUp,
PhCaretDown,
PhCaretRight,
PhCheck,
PhCloudArrowUp,
PhDownloadSimple,
PhDotsThreeVertical,
PhFile,
PhFileArchive,
PhFileAudio,
PhFileDoc,
PhFileImage,
PhFilePdf,
PhFilePpt,
PhFileText,
PhFileVideo,
PhFileXls,
PhFileZip,
PhFolderSimple,
PhFolderSimplePlus,
PhGearSix,
PhInfo,
PhMagnifyingGlass,
PhMinus,
PhPencilSimple,
PhPlus,
PhSelectionAll,
PhShareNetwork,
PhSignOut,
PhSortAscending,
PhSortDescending,
PhTrash,
PhUploadSimple,
PhX,
} from "@phosphor-icons/vue";
import dogLogo from "../src-tauri/icons/wanwan-dog-source.png";
type NavKey = "files" | "transfers" | "shares" | "sync" | "settings";
type FileItem = {
name: string;
displayName?: string;
path?: string;
type: "file" | "directory";
size?: number;
sizeFormatted?: string;
modifiedAt?: string;
createdAt?: string;
owner?: string;
updatedBy?: string;
tags?: string[];
description?: 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 ShareExpiryType = "never" | "7" | "30" | "custom";
type ShareDeviceLimit = "all" | "mobile" | "desktop";
type ShareCreateOptions = {
password: string | null;
expiryDays: number | null;
maxDownloads: number | null;
ipWhitelist: string | null;
deviceLimit: ShareDeviceLimit;
accessTimeStart: string | null;
accessTimeEnd: string | null;
};
type ShareCreateResult = {
itemName: string;
shareUrl: string;
shareCode: string;
expiresAt: string | null;
hasPassword: boolean;
password: string;
reused: boolean;
securityPolicy: Record<string, any> | null;
};
type ShareCreateFailure = {
itemName: string;
message: string;
};
type DirectLinkItem = {
id: number;
link_code: string;
direct_url: string;
file_path: string;
file_name?: string;
storage_type?: string;
created_at?: string;
expires_at?: string | null;
};
type OnlineDeviceItem = {
session_id: string;
client_type?: string;
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" | "processing" | "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;
};
type RateMeasureState = {
lastMeasureAt: number;
lastMeasureBytes: number;
};
const nav = ref<NavKey>("files");
const authenticated = ref(false);
const user = ref<Record<string, any> | null>(null);
const uiPreviewMode = (import.meta.env.DEV || import.meta.env.VITE_UI_REVIEW === "true")
&& new URLSearchParams(window.location.search).get("ui-preview") === "files";
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 selectedFileKey = ref("");
const searchKeyword = ref("");
const shares = ref<ShareItem[]>([]);
const directLinks = ref<DirectLinkItem[]>([]);
const directLinksLoading = ref(false);
const batchSelectedKeys = ref<string[]>([]);
const batchMode = computed(() => batchSelectedKeys.value.length > 0);
const transferTasks = ref<TransferTask[]>([]);
const unreadTransferTaskIds = ref<string[]>([]);
const unreadTransferCount = computed(() => unreadTransferTaskIds.value.length);
const activeTransferTaskIds = computed(() => transferTasks.value
.filter((task) => task.status === "queued" || isTaskRunning(task.status))
.map((task) => task.id));
const transferBadgeCount = computed(() => new Set([
...activeTransferTaskIds.value,
...unreadTransferTaskIds.value,
]).size);
const downloadRateSamples = new Map<string, RateMeasureState>();
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.38",
latestVersion: "",
available: false,
mandatory: false,
checking: false,
downloadUrl: "",
packageSha256: "",
packageSize: 0,
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 shareCreateDialog = reactive({
visible: false,
loading: false,
submitted: false,
processedCount: 0,
items: [] as FileItem[],
enablePassword: false,
password: "",
expiryType: "never" as ShareExpiryType,
customDays: 7,
enableAdvancedSecurity: false,
maxDownloadsEnabled: false,
maxDownloads: 10,
ipWhitelist: "",
deviceLimit: "all" as ShareDeviceLimit,
accessTimeEnabled: false,
accessTimeStart: "09:00",
accessTimeEnd: "23:00",
error: "",
results: [] as ShareCreateResult[],
failures: [] as ShareCreateFailure[],
});
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,
itemKey: "",
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;
let authRefreshPromise: Promise<boolean> | null = null;
const toast = reactive({
visible: false,
type: "info",
message: "",
});
let toastTimer: ReturnType<typeof setTimeout> | null = null;
let fileViewRequestId = 0;
const navItems = computed(() => [
{ key: "files" as const, label: "全部文件", hint: `${files.value.length}`, icon: PhFolderSimple },
{
key: "transfers" as const,
label: "传输列表",
hint: unreadTransferCount.value > 0
? `${unreadTransferCount.value} 个未读结果`
: (activeTransferTaskIds.value.length > 0
? `${activeTransferTaskIds.value.length} 个任务进行中`
: `${transferTasks.value.length} 个任务`),
icon: PhArrowsDownUp,
},
{ key: "shares" as const, label: "我的分享", hint: `${shares.value.length + directLinks.value.length}`, icon: PhShareNetwork },
{ key: "sync" as const, label: "同步盘", hint: syncState.localDir ? "已配置" : "未配置", icon: PhArrowsClockwise },
{ key: "settings" as const, label: "设置", hint: updateState.available ? "发现新版本" : "系统与更新", icon: PhGearSix },
]);
const currentPageTitle = computed(() => {
const labels: Record<NavKey, string> = {
files: "全部文件",
transfers: "传输列表",
shares: "我的分享",
sync: "同步盘",
settings: "设置",
};
return labels[nav.value];
});
const currentStorage = computed(() => {
const isLocal = user.value?.current_storage_type === "local";
const used = Number(isLocal ? user.value?.local_storage_used : user.value?.storage_used) || 0;
const quota = Number(isLocal ? user.value?.local_storage_quota : user.value?.oss_storage_quota) || 0;
return { used: Math.max(0, used), quota: Math.max(0, quota) };
});
const storageUsagePercent = computed(() => {
if (currentStorage.value.quota <= 0) return 0;
return Math.min(100, Math.max(0, (currentStorage.value.used / currentStorage.value.quota) * 100));
});
const storageUsageLabel = computed(() => {
const { used, quota } = currentStorage.value;
return quota > 0 ? `已用 ${formatBytes(used)} / ${formatBytes(quota)}` : `已用 ${formatBytes(used)}`;
});
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 aIsDirectory = Boolean(a.isDirectory || a.type === "directory");
const bIsDirectory = Boolean(b.isDirectory || b.type === "directory");
if (aIsDirectory !== bIsDirectory) return aIsDirectory ? -1 : 1;
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 (!selectedFileKey.value) return null;
return files.value.find((item) => fileSelectionKey(item) === selectedFileKey.value) || null;
});
const batchSelectedItems = computed(() => {
const selectedSet = new Set(batchSelectedKeys.value);
return files.value.filter((item) => selectedSet.has(fileSelectionKey(item)));
});
const shareDialogIsBatch = computed(() => shareCreateDialog.items.length > 1);
const shareDialogReusedCount = computed(() => shareCreateDialog.results.filter((item) => item.reused).length);
const shareDialogTargetName = computed(() => {
if (shareDialogIsBatch.value) return `已选择 ${shareCreateDialog.items.length}`;
const item = shareCreateDialog.items[0];
return item?.displayName || item?.name || "未选择项目";
});
const shareDialogTargetPath = computed(() => {
const item = shareCreateDialog.items[0];
return item ? buildItemPath(item) : "";
});
const areAllVisibleFilesSelected = computed(() => {
return filteredFiles.value.length > 0
&& filteredFiles.value.every((item) => batchSelectedKeys.value.includes(fileSelectionKey(item)));
});
const areSomeVisibleFilesSelected = computed(() => {
if (filteredFiles.value.length === 0 || areAllVisibleFilesSelected.value) return false;
return filteredFiles.value.some((item) => batchSelectedKeys.value.includes(fileSelectionKey(item)));
});
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,
createdAt: raw?.createdAt || raw?.created_at || undefined,
owner: raw?.owner || raw?.creator_name || raw?.created_by || undefined,
updatedBy: raw?.updatedBy || raw?.updated_by || raw?.modifier_name || undefined,
tags: Array.isArray(raw?.tags) ? raw.tags.map(String) : undefined,
description: typeof raw?.description === "string" ? raw.description : 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 "文件夹";
const ext = getFileExt(item);
return ext ? `${ext.toUpperCase()} 文件` : "文件";
}
function fileSelectionKey(item: FileItem) {
if (item.path && item.path.trim()) return normalizePath(item.path);
return normalizePath(`${pathState.currentPath}/${item.name}`);
}
function formatSpeed(bytesPerSecond: number) {
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "-";
return `${formatBytes(bytesPerSecond)}/s`;
}
function measureRate(
currentBytes: number,
state: RateMeasureState,
) {
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;
// Progress events can repeat the same byte offset. Keep the last valid rate
// instead of briefly flashing a misleading 0 B/s value.
if (deltaBytes <= 0) return "-";
const bytesPerSecond = (deltaBytes * 1000) / elapsedMs;
return formatSpeed(bytesPerSecond);
}
function measureDownloadTaskRate(taskId: string, currentBytes: number) {
let state = downloadRateSamples.get(taskId);
if (!state) {
state = { lastMeasureAt: 0, lastMeasureBytes: 0 };
downloadRateSamples.set(taskId, state);
}
return measureRate(currentBytes, state);
}
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 progressRaw = payload?.progress;
const eventProgress = progressRaw === null || progressRaw === undefined
? NaN
: Number(progressRaw);
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 currentTask = transferTasks.value.find((task) => task.id === taskId);
const sampledSpeed = measureDownloadTaskRate(taskId, downloadedBytes);
const previousSpeed = currentTask?.speed?.endsWith("/s") ? currentTask.speed : "下载中";
const patch: Partial<TransferTask> = {
speed: sampledSpeed === "-" ? previousSpeed : sampledSpeed,
status: "downloading",
note: Number.isFinite(totalBytes) && totalBytes > 0
? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`
: `已下载 ${formatBytes(downloadedBytes)}`,
};
if (Number.isFinite(boundedProgress)) {
patch.progress = Number(boundedProgress.toFixed(1));
}
if (payload?.done) {
patch.speed = "-";
downloadRateSamples.delete(taskId);
}
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: boundedProgress >= 99.5 && !payload?.done ? "processing" : "uploading",
speed: boundedProgress >= 99.5 && !payload?.done ? "服务器处理中" : transferSpeed,
progress: Number(boundedProgress.toFixed(1)),
note: boundedProgress >= 99.5 && !payload?.done ? "分片已上传完成,等待服务器处理..." : `${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 getFileExt(item: FileItem) {
if (item.isDirectory || item.type === "directory") return "";
const text = String(item.displayName || item.name || "").trim();
const idx = text.lastIndexOf(".");
if (idx <= 0 || idx >= text.length - 1) return "";
return text.slice(idx + 1).toLowerCase();
}
function fileVisualKind(item: FileItem) {
if (item.isDirectory || item.type === "directory") return "folder";
const ext = getFileExt(item);
if (!ext) return "file";
if (["jpg", "jpeg", "png", "webp", "gif", "bmp", "svg", "heic"].includes(ext)) return "image";
if (["mp4", "mkv", "mov", "avi", "webm", "flv"].includes(ext)) return "video";
if (["mp3", "wav", "flac", "aac", "ogg", "m4a"].includes(ext)) return "audio";
if (["zip", "rar", "7z", "tar", "gz", "bz2", "xz"].includes(ext)) return "archive";
if (["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv"].includes(ext)) return "document";
if (["apk", "exe", "msi", "dmg", "deb", "rpm"].includes(ext)) return "app";
return "file";
}
function fileExtLabel(item: FileItem) {
const ext = getFileExt(item).toUpperCase();
if (!ext) return "FILE";
return ext.slice(0, 4);
}
function fileIconComponent(item: FileItem) {
if (item.isDirectory || item.type === "directory") return PhFolderSimple;
const ext = getFileExt(item);
if (ext === "pdf") return PhFilePdf;
if (["doc", "docx"].includes(ext)) return PhFileDoc;
if (["xls", "xlsx", "csv"].includes(ext)) return PhFileXls;
if (["ppt", "pptx"].includes(ext)) return PhFilePpt;
if (["zip", "rar", "7z"].includes(ext)) return PhFileZip;
if (["tar", "gz", "bz2", "xz"].includes(ext)) return PhFileArchive;
if (["jpg", "jpeg", "png", "webp", "gif", "bmp", "svg", "heic"].includes(ext)) return PhFileImage;
if (["mp4", "mkv", "mov", "avi", "webm", "flv"].includes(ext)) return PhFileVideo;
if (["mp3", "wav", "flac", "aac", "ogg", "m4a"].includes(ext)) return PhFileAudio;
if (["txt", "md"].includes(ext)) return PhFileText;
return PhFile;
}
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 isTerminalTransferStatus(status: TransferTaskStatus | undefined) {
return status === "done" || status === "failed";
}
function clearUnreadTransferTask(taskId: string) {
unreadTransferTaskIds.value = unreadTransferTaskIds.value.filter((id) => id !== taskId);
}
function markTransferTaskUnread(taskId: string) {
if (nav.value === "transfers" || unreadTransferTaskIds.value.includes(taskId)) return;
unreadTransferTaskIds.value = [...unreadTransferTaskIds.value, taskId];
}
function acknowledgeTransferResults() {
unreadTransferTaskIds.value = [];
}
function pruneUnreadTransferTasks() {
const retainedIds = new Set(transferTasks.value.map((task) => task.id));
unreadTransferTaskIds.value = unreadTransferTaskIds.value.filter((id) => retainedIds.has(id));
}
function prependTransferTask(task: TransferTask) {
transferTasks.value = [task, ...transferTasks.value.slice(0, 119)];
pruneUnreadTransferTasks();
if (isTerminalTransferStatus(task.status)) {
markTransferTaskUnread(task.id);
}
}
function updateTransferTask(
id: string,
patch: Partial<TransferTask>,
) {
let previousStatus: TransferTaskStatus | undefined;
let nextStatus: TransferTaskStatus | undefined;
transferTasks.value = transferTasks.value.map((task) => {
if (task.id !== id) return task;
previousStatus = task.status;
const updated = { ...task, ...patch };
nextStatus = updated.status;
return updated;
});
if (!isTerminalTransferStatus(previousStatus) && isTerminalTransferStatus(nextStatus)) {
markTransferTaskUnread(id);
return;
}
if (isTerminalTransferStatus(previousStatus) && !isTerminalTransferStatus(nextStatus)) {
clearUnreadTransferTask(id);
}
}
function getTaskStatusLabel(status: string) {
if (status === "queued") return "排队中";
if (status === "uploading") return "上传中";
if (status === "downloading") return "下载中";
if (status === "processing") return "服务器处理中";
if (status === "done") return "已完成";
if (status === "failed") return "失败";
return status;
}
function isTaskRunning(status: string) {
return status === "uploading" || status === "downloading" || status === "processing";
}
function removeTransferTask(taskId: string) {
downloadRateSamples.delete(taskId);
transferTasks.value = transferTasks.value.filter((task) => task.id !== taskId);
clearUnreadTransferTask(taskId);
}
function clearCompletedTransferTasks() {
transferTasks.value
.filter((task) => task.status === "done" || task.status === "failed")
.forEach((task) => downloadRateSamples.delete(task.id));
transferTasks.value = transferTasks.value.filter((task) => task.status !== "done" && task.status !== "failed");
pruneUnreadTransferTasks();
}
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(item: FileItem) {
return batchSelectedKeys.value.includes(fileSelectionKey(item));
}
function isFocusedFile(item: FileItem) {
return selectedFileKey.value === fileSelectionKey(item);
}
function clearBatchSelection() {
batchSelectedKeys.value = [];
}
function clearFocusedFile() {
selectedFileKey.value = "";
}
function clearFileInteractionState() {
clearFocusedFile();
clearBatchSelection();
}
function toggleBatchSelection(item: FileItem) {
const key = fileSelectionKey(item);
if (!key) return;
if (isBatchSelected(item)) {
batchSelectedKeys.value = batchSelectedKeys.value.filter((value) => value !== key);
return;
}
clearFocusedFile();
batchSelectedKeys.value = [...batchSelectedKeys.value, key];
}
function toggleRowSelection(item: FileItem) {
toggleBatchSelection(item);
}
function toggleSelectAllVisible() {
const visibleKeys = filteredFiles.value.map(fileSelectionKey).filter(Boolean);
if (areAllVisibleFilesSelected.value) {
const visibleSet = new Set(visibleKeys);
batchSelectedKeys.value = batchSelectedKeys.value.filter((key) => !visibleSet.has(key));
return;
}
clearFocusedFile();
batchSelectedKeys.value = [...new Set([...batchSelectedKeys.value, ...visibleKeys])];
}
function normalizeRelativePath(rawPath: string) {
return String(rawPath || "").replace(/\\/g, "/").replace(/^\/+/, "");
}
function sanitizeSyncFolderSegment(raw: string, fallback = "默认同步盘") {
const normalized = String(raw || "")
.trim()
.replace(/[\/\\:*?"<>|]/g, "_")
.replace(/\.\./g, "_")
.replace(/\s+/g, " ");
const compact = normalized.replace(/^_+|_+$/g, "").trim();
if (!compact) return fallback;
return compact.slice(0, 60);
}
function deriveDefaultSyncRemoteBasePath() {
const localDirName = sanitizeSyncFolderSegment(extractFileNameFromPath(syncState.localDir || ""), "");
const owner = sanitizeSyncFolderSegment(String(user.value?.username || user.value?.id || "desktop"), "desktop");
const folderName = localDirName || `同步盘_${owner}`;
return normalizePath(`/同步盘/${folderName}`);
}
function applyDefaultSyncRemoteBasePath(force = false) {
const normalizedCurrent = normalizePath(syncState.remoteBasePath || "/");
if (!force && normalizedCurrent !== "/") return;
syncState.remoteBasePath = deriveDefaultSyncRemoteBasePath();
}
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) {
applyDefaultSyncRemoteBasePath(true);
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
}
applyDefaultSyncRemoteBasePath(false);
}
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";
}
function isAuthBridgeCommand(command: string) {
return command === "api_login" || command === "api_refresh_token";
}
async function refreshAccessToken() {
if (authRefreshPromise) {
return authRefreshPromise;
}
authRefreshPromise = (async () => {
try {
const response = await invoke<BridgeResponse>("api_refresh_token", {
baseUrl: appConfig.baseUrl,
});
return Boolean(response.ok && response.data?.success);
} catch {
return false;
} finally {
authRefreshPromise = null;
}
})();
return authRefreshPromise;
}
async function invokeBridge(
command: string,
payload: Record<string, any>,
allowAuthRetry = true,
) {
let response: BridgeResponse;
try {
response = await invoke<BridgeResponse>(command, payload);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
response = {
ok: false,
status: 0,
data: {
success: false,
message,
},
} satisfies BridgeResponse;
}
if (
response.status === 401
&& allowAuthRetry
&& !isAuthBridgeCommand(command)
&& authenticated.value
) {
const refreshed = await refreshAccessToken();
if (refreshed) {
return invokeBridge(command, payload, false);
}
}
return response;
}
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();
}
function normalizeSha256(raw: string | undefined) {
const digest = String(raw || "").trim().toLowerCase();
return /^[a-f0-9]{64}$/.test(digest) ? digest : "";
}
function normalizePackageSize(raw: unknown) {
const value = Number(raw);
if (!Number.isFinite(value) || value <= 0) return 0;
return Math.floor(value);
}
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.packageSha256 = normalizeSha256(String(response.data.sha256 || ""));
updateState.packageSize = normalizePackageSize(response.data.packageSize);
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.packageSha256 = "";
updateState.packageSize = 0;
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.progress = 100;
updateRuntime.speed = "校验中";
const savePath = String(response.data?.savePath || "").trim();
updateRuntime.installerPath = savePath;
if (savePath) {
const expectedSha = normalizeSha256(updateState.packageSha256);
const expectedSize = normalizePackageSize(updateState.packageSize);
if (expectedSha || expectedSize > 0) {
const verifyResponse = await invokeBridge("api_compute_file_sha256", {
filePath: savePath,
});
if (!(verifyResponse.ok && verifyResponse.data?.success)) {
const message = String(verifyResponse.data?.message || "校验更新包失败");
resetUpdateRuntime();
showToast(message, "error");
return false;
}
const actualSha = normalizeSha256(String(verifyResponse.data?.sha256 || ""));
const actualSize = normalizePackageSize(verifyResponse.data?.fileSize);
if (expectedSize > 0 && actualSize > 0 && expectedSize !== actualSize) {
resetUpdateRuntime();
showToast(`更新包大小校验失败(期望 ${formatBytes(expectedSize)},实际 ${formatBytes(actualSize)}`, "error");
return false;
}
if (expectedSha && actualSha !== expectedSha) {
resetUpdateRuntime();
showToast("更新包完整性校验失败,请重试下载", "error");
return false;
}
}
updateRuntime.installing = true;
updateRuntime.progress = 100;
updateRuntime.speed = "-";
const launchResponse = await invokeBridge("api_silent_install_and_restart", {
installerPath: savePath,
});
const logFilePath = String(launchResponse.data?.logFilePath || "").trim();
if (launchResponse.ok && launchResponse.data?.success) {
const launchTip = logFilePath
? `静默安装已启动(日志:${logFilePath}`
: "静默安装已启动,完成后会自动重启客户端";
showToast(launchTip, "success");
setTimeout(() => {
const win = getCurrentWindow();
void (async () => {
try {
await win.destroy();
} catch {
try {
await win.close();
} catch {
// ignore
}
}
})();
}, 400);
setTimeout(() => {
if (!updateRuntime.installing) return;
resetUpdateRuntime();
showToast("更新程序未能接管,请重新更新或手动运行安装包", "error");
}, 8000);
return true;
}
if (logFilePath) {
console.warn("silent updater log path:", logFilePath);
}
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()) {
const hadCustomRemoteBase = normalizePath(syncState.remoteBasePath || "/") !== "/";
syncState.localDir = result.trim();
if (!hadCustomRemoteBase) {
applyDefaultSyncRemoteBasePath(true);
}
showToast("同步目录已更新", "success");
}
} catch {
showToast("选择目录失败", "error");
}
}
function isRetryableUploadResponse(response: BridgeResponse) {
const status = Number(response.status || 0);
if ([0, 408, 425, 429, 500, 502, 503, 504].includes(status)) {
return true;
}
const message = String(response.data?.message || "").toLowerCase();
return (
message.includes("timeout")
|| message.includes("timed out")
|| message.includes("network")
|| message.includes("connection")
|| message.includes("超时")
|| message.includes("稍后重试")
);
}
async function waitMs(ms: number) {
await new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
}
async function uploadFileWithResume(filePath: string, targetPath: string, taskId?: string) {
const maxAttempts = 3;
let lastResponse: BridgeResponse | null = null;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
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("上传会话")
) {
const fallbackResponse = await invokeBridge("api_upload_file", {
baseUrl: appConfig.baseUrl,
filePath,
targetPath,
taskId: taskId || null,
});
if (fallbackResponse.ok && fallbackResponse.data?.success) {
return fallbackResponse;
}
lastResponse = fallbackResponse;
} else {
lastResponse = resumableResponse;
}
if (attempt < maxAttempts - 1 && lastResponse && isRetryableUploadResponse(lastResponse)) {
await waitMs(600 * (attempt + 1));
continue;
}
break;
}
return lastResponse || {
ok: false,
status: 0,
data: {
success: false,
message: "上传失败",
},
};
}
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 ensureRemoteFolderPath(targetPath: string) {
const normalized = normalizePath(targetPath || "/");
if (normalized === "/") {
return { ok: true, path: "/" };
}
const segments = normalized.split("/").filter(Boolean);
let current = "/";
for (const segment of segments) {
const response = await invokeBridge("api_mkdir", {
baseUrl: appConfig.baseUrl,
path: current,
folderName: segment,
});
if (!(response.ok && response.data?.success)) {
const message = String(response.data?.message || "");
if (!message.includes("已存在")) {
return {
ok: false,
path: normalizePath(`${current}/${segment}`),
message: message || "创建远程同步目录失败",
};
}
}
current = normalizePath(`${current}/${segment}`);
}
return { ok: true, path: normalized };
}
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;
}
applyDefaultSyncRemoteBasePath(false);
const remoteBase = normalizePath(syncState.remoteBasePath || "/");
const ensuredRemotePaths = new Set<string>();
const baseEnsureResult = await ensureRemoteFolderPath(remoteBase);
if (!baseEnsureResult.ok) {
syncState.syncing = false;
syncState.lastRunAt = new Date().toISOString();
syncState.lastSummary = String(baseEnsureResult.message || "创建远程同步目录失败");
if (trigger === "manual") {
showToast(syncState.lastSummary, "error");
}
return;
}
ensuredRemotePaths.add(remoteBase);
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 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 });
if (!ensuredRemotePaths.has(targetPath)) {
const ensureResult = await ensureRemoteFolderPath(targetPath);
if (!ensureResult.ok) {
syncState.failedCount += 1;
updateTransferTask(taskId, {
speed: "-",
progress: 0,
status: "failed",
note: String(ensureResult.message || "创建远程同步目录失败"),
});
continue;
}
ensuredRemotePaths.add(targetPath);
}
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) {
const requestId = ++fileViewRequestId;
cancelInlineRename(true);
clearFileInteractionState();
pathState.loading = true;
pathState.error = "";
const normalizedPath = normalizePath(targetPath);
const response = await invokeBridge("api_list_files", {
baseUrl: appConfig.baseUrl,
path: normalizedPath,
});
if (requestId !== fileViewRequestId) return;
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";
} 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 loadDirectLinks(silent = false) {
if (!silent) directLinksLoading.value = true;
const response = await invokeBridge("api_get_my_direct_links", {
baseUrl: appConfig.baseUrl,
});
if (response.ok && response.data?.success) {
directLinks.value = Array.isArray(response.data.links) ? response.data.links : [];
} else if (!silent) {
showToast(response.data?.message || "获取直链列表失败", "error");
}
if (!silent) directLinksLoading.value = false;
}
async function copyDirectLink(link: DirectLinkItem) {
const url = link.direct_url || "";
if (url) {
await copyText(url, "直链已复制到剪贴板");
}
}
async function deleteDirectLink(link: DirectLinkItem) {
const response = await invokeBridge("api_delete_direct_link", {
baseUrl: appConfig.baseUrl,
linkId: link.id,
});
if (response.ok && response.data?.success) {
showToast("直链已删除", "success");
await loadDirectLinks(true);
} else {
showToast(response.data?.message || "删除直链失败", "error");
}
}
async function getSignedUrlForItem(item: FileItem, mode: "download" | "preview") {
const targetPath = buildItemPath(item);
const response = await invokeBridge("api_get_download_url", {
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 "";
}
function openShareCreateDialog(items: FileItem[]) {
const targets = items.filter(Boolean);
if (targets.length === 0) {
showToast("请先选择要分享的文件或文件夹", "info");
return;
}
Object.assign(shareCreateDialog, {
visible: true,
loading: false,
submitted: false,
processedCount: 0,
items: [...targets],
enablePassword: false,
password: "",
expiryType: "never" as ShareExpiryType,
customDays: 7,
enableAdvancedSecurity: false,
maxDownloadsEnabled: false,
maxDownloads: 10,
ipWhitelist: "",
deviceLimit: "all" as ShareDeviceLimit,
accessTimeEnabled: false,
accessTimeStart: "09:00",
accessTimeEnd: "23:00",
error: "",
results: [],
failures: [],
});
}
function closeShareCreateDialog(force = false) {
if (shareCreateDialog.loading && !force) return;
shareCreateDialog.visible = false;
shareCreateDialog.loading = false;
shareCreateDialog.submitted = false;
shareCreateDialog.processedCount = 0;
shareCreateDialog.items = [];
shareCreateDialog.error = "";
shareCreateDialog.results = [];
shareCreateDialog.failures = [];
}
function createShareForItem(current: FileItem) {
openShareCreateDialog([current]);
}
function validateShareCreateOptions(): ShareCreateOptions | null {
shareCreateDialog.error = "";
const password = shareCreateDialog.enablePassword ? shareCreateDialog.password.trim() : null;
if (shareCreateDialog.enablePassword && !password) {
shareCreateDialog.error = "已启用密码保护,请输入访问密码";
return null;
}
if (password && password.length > 32) {
shareCreateDialog.error = "访问密码不能超过 32 个字符";
return null;
}
let expiryDays: number | null = null;
if (shareCreateDialog.expiryType !== "never") {
const rawDays = shareCreateDialog.expiryType === "custom"
? Number(shareCreateDialog.customDays)
: Number(shareCreateDialog.expiryType);
if (!Number.isInteger(rawDays) || rawDays < 1 || rawDays > 365) {
shareCreateDialog.error = "有效期必须是 1 到 365 天的整数";
return null;
}
expiryDays = rawDays;
}
let maxDownloads: number | null = null;
let ipWhitelist: string | null = null;
let deviceLimit: ShareDeviceLimit = "all";
let accessTimeStart: string | null = null;
let accessTimeEnd: string | null = null;
if (shareCreateDialog.enableAdvancedSecurity) {
if (shareCreateDialog.maxDownloadsEnabled) {
const value = Number(shareCreateDialog.maxDownloads);
if (!Number.isInteger(value) || value < 1 || value > 1_000_000) {
shareCreateDialog.error = "下载次数上限需为 1 到 1000000 的整数";
return null;
}
maxDownloads = value;
}
ipWhitelist = shareCreateDialog.ipWhitelist.trim() || null;
if (!["all", "mobile", "desktop"].includes(shareCreateDialog.deviceLimit)) {
shareCreateDialog.error = "设备限制参数无效";
return null;
}
deviceLimit = shareCreateDialog.deviceLimit;
if (shareCreateDialog.accessTimeEnabled) {
const start = shareCreateDialog.accessTimeStart.trim();
const end = shareCreateDialog.accessTimeEnd.trim();
const timePattern = /^([01]\d|2[0-3]):([0-5]\d)$/;
if (!timePattern.test(start) || !timePattern.test(end)) {
shareCreateDialog.error = "访问时段格式必须为 HH:mm";
return null;
}
accessTimeStart = start;
accessTimeEnd = end;
}
}
return {
password,
expiryDays,
maxDownloads,
ipWhitelist,
deviceLimit,
accessTimeStart,
accessTimeEnd,
};
}
async function requestShareCreation(current: FileItem, options: ShareCreateOptions): Promise<BridgeResponse> {
if (uiPreviewMode) {
await new Promise((resolve) => setTimeout(resolve, 180));
const shareCode = Math.random().toString(36).slice(2, 8).toUpperCase();
return {
ok: true,
status: 200,
data: {
success: true,
reused: false,
share_code: shareCode,
share_url: `${appConfig.baseUrl}/s/${shareCode}`,
expires_at: options.expiryDays
? new Date(Date.now() + options.expiryDays * 86_400_000).toISOString()
: null,
has_password: Boolean(options.password),
security_policy: {
max_downloads: options.maxDownloads,
ip_whitelist_count: options.ipWhitelist ? options.ipWhitelist.split(/[\s,]+/).filter(Boolean).length : 0,
device_limit: options.deviceLimit,
access_time_start: options.accessTimeStart,
access_time_end: options.accessTimeEnd,
},
},
};
}
return invokeBridge("api_create_share", {
baseUrl: appConfig.baseUrl,
shareType: current.isDirectory || current.type === "directory" ? "directory" : "file",
filePath: buildItemPath(current),
fileName: current.displayName || current.name,
password: options.password,
expiryDays: options.expiryDays,
maxDownloads: options.maxDownloads,
ipWhitelist: options.ipWhitelist,
deviceLimit: options.deviceLimit,
accessTimeStart: options.accessTimeStart,
accessTimeEnd: options.accessTimeEnd,
});
}
async function submitShareCreateDialog() {
if (shareCreateDialog.loading) return;
const options = validateShareCreateOptions();
if (!options) return;
const targets = [...shareCreateDialog.items];
if (targets.length === 0) {
shareCreateDialog.error = "没有可分享的项目";
return;
}
shareCreateDialog.loading = true;
shareCreateDialog.submitted = false;
shareCreateDialog.processedCount = 0;
shareCreateDialog.results = [];
shareCreateDialog.failures = [];
const results: ShareCreateResult[] = [];
const failures: ShareCreateFailure[] = [];
for (const current of targets) {
const itemName = current.displayName || current.name || "未命名项目";
try {
const response = await requestShareCreation(current, options);
if (!response.ok || !response.data?.success) {
throw new Error(String(response.data?.message || "创建分享失败"));
}
results.push({
itemName,
shareUrl: String(response.data.share_url || ""),
shareCode: String(response.data.share_code || ""),
expiresAt: response.data.expires_at ? String(response.data.expires_at) : null,
hasPassword: Boolean(response.data.has_password),
password: response.data.reused ? "" : (options.password || ""),
reused: Boolean(response.data.reused),
securityPolicy: response.data.security_policy && typeof response.data.security_policy === "object"
? response.data.security_policy
: null,
});
} catch (error) {
failures.push({
itemName,
message: error instanceof Error ? error.message : String(error),
});
} finally {
shareCreateDialog.processedCount += 1;
}
}
shareCreateDialog.results = results;
shareCreateDialog.failures = failures;
shareCreateDialog.submitted = true;
shareCreateDialog.loading = false;
if (results.length > 0) {
if (!uiPreviewMode) await loadShares(true);
if (targets.length > 1) clearBatchSelection();
if (targets.length === 1 && results[0].reused) {
showToast("已复用现有分享,原安全设置保持不变", "info");
} else {
const failedText = failures.length > 0 ? `,失败 ${failures.length}` : "";
showToast(`分享创建完成:成功 ${results.length}${failedText}`, failures.length > 0 ? "info" : "success");
}
return;
}
showToast(failures[0]?.message || "创建分享失败", "error");
}
function returnToShareSettings() {
if (shareCreateDialog.loading) return;
shareCreateDialog.submitted = false;
shareCreateDialog.processedCount = 0;
shareCreateDialog.error = "";
shareCreateDialog.results = [];
shareCreateDialog.failures = [];
}
function getShareDeviceLabel(value: unknown) {
if (value === "mobile") return "仅移动端";
if (value === "desktop") return "仅桌面端";
return "全部设备";
}
async function copyCreatedShareLink(result: ShareCreateResult) {
if (!result.shareUrl) {
showToast("分享链接为空", "error");
return;
}
await copyText(result.shareUrl, "分享链接已复制");
}
async function openCreatedShareLink(result: ShareCreateResult) {
if (!result.shareUrl) {
showToast("分享链接为空", "error");
return;
}
if (uiPreviewMode) {
window.open(result.shareUrl, "_blank", "noopener,noreferrer");
return;
}
try {
await openUrl(result.shareUrl);
} catch {
showToast("打开分享链接失败", "error");
}
}
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 (uiPreviewMode) {
pathState.mode = keyword ? "search" : "directory";
clearFileInteractionState();
if (keyword) showToast(`已筛选与“${keyword}”相关的文件`, "success");
return;
}
if (!keyword) {
await loadFiles(pathState.currentPath);
return;
}
const requestId = ++fileViewRequestId;
clearFileInteractionState();
pathState.loading = true;
pathState.error = "";
const response = await invokeBridge("api_search_files", {
baseUrl: appConfig.baseUrl,
path: pathState.currentPath,
keyword,
searchType: "all",
limit: 200,
});
if (requestId !== fileViewRequestId) return;
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";
showToast(`搜索完成,共 ${files.value.length} 条结果`, "success");
} else {
pathState.error = response.data?.message || "搜索失败";
showToast(pathState.error, "error");
}
pathState.loading = false;
}
async function resetFileSearch() {
searchKeyword.value = "";
pathState.mode = "directory";
if (uiPreviewMode) {
clearFileInteractionState();
return;
}
await loadFiles(pathState.currentPath);
}
async function refreshCurrentFiles() {
if (uiPreviewMode) {
showToast("文件列表已刷新", "success");
return;
}
await loadFiles(pathState.currentPath);
}
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 = [];
clearFileInteractionState();
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.itemKey === fileSelectionKey(item);
}
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.itemKey = "";
inlineRename.originalName = "";
inlineRename.value = "";
inlineRename.saving = false;
}
function startInlineRename(target?: FileItem | null) {
const current = target || selectedFile.value;
if (!current) {
showToast("请先选中文件或文件夹", "info");
return;
}
selectedFileKey.value = fileSelectionKey(current);
inlineRename.active = true;
inlineRename.itemKey = fileSelectionKey(current);
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) => fileSelectionKey(item) === inlineRename.itemKey) || 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) {
const renamedPath = normalizePath(`${getItemParentPath(current)}/${nextName}`);
showToast("重命名成功", "success");
cancelInlineRename(true);
await loadFiles(pathState.currentPath);
selectedFileKey.value = renamedPath;
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()}`;
downloadRateSamples.delete(taskId);
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) {
downloadRateSamples.delete(taskId);
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 || "原生下载失败");
downloadRateSamples.delete(taskId);
updateTransferTask(taskId, { speed: "-", progress: 0, status: "failed", note: message });
showToast(message, "error");
}
function selectFile(item: FileItem) {
if (batchMode.value) {
toggleBatchSelection(item);
return;
}
selectedFileKey.value = fileSelectionKey(item);
}
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();
downloadRateSamples.delete(taskId);
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) {
downloadRateSamples.delete(taskId);
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 || "重试下载失败"),
});
downloadRateSamples.delete(taskId);
}
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;
}
}
function batchShareSelected() {
if (!batchMode.value || batchSelectedItems.value.length === 0) {
showToast("请先勾选批量文件", "info");
return;
}
openShareCreateDialog([...batchSelectedItems.value]);
}
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);
clearBatchSelection();
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 (shareCreateDialog.visible) {
closeShareCreateDialog();
return;
}
if (operationConfirmDialog.visible) {
closeOperationConfirmDialog();
return;
}
if (inlineRename.active) {
cancelInlineRename();
return;
}
if (contextMenu.visible) {
closeContextMenu();
return;
}
if (batchMode.value) {
clearBatchSelection();
return;
}
if (selectedFile.value) clearFocusedFile();
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 chooseUploadFiles() {
if (uiPreviewMode) {
showToast("桌面客户端中可选择并上传多个文件", "info");
return;
}
try {
const result = await openDialog({
directory: false,
multiple: true,
title: "选择要上传的文件",
});
const paths = Array.isArray(result) ? result : (typeof result === "string" ? [result] : []);
await uploadDroppedFiles(paths);
} catch {
showToast("选择文件失败", "error");
}
}
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 === "transfers") {
acknowledgeTransferResults();
}
if (next !== "files" && inlineRename.active) {
cancelInlineRename(true);
}
if (next !== "files") {
fileViewRequestId += 1;
pathState.loading = false;
clearFileInteractionState();
}
if (uiPreviewMode) return;
if (next === "shares" && authenticated.value) {
await loadShares();
await loadDirectLinks();
return;
}
if (next === "settings" && authenticated.value) {
await checkClientUpdate(false);
await loadOnlineDevices(true);
}
});
watch(
() => [fileViewState.filter, searchKeyword.value],
() => clearFileInteractionState(),
);
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();
},
);
function applyUiPreviewData() {
authenticated.value = true;
nav.value = "files";
user.value = {
id: 1,
username: "张小明",
current_storage_type: "local",
local_storage_used: 42.36 * 1024 * 1024 * 1024,
local_storage_quota: 200 * 1024 * 1024 * 1024,
};
pathState.currentPath = "/项目资料";
pathState.mode = "directory";
files.value = [
{ name: "产品资料", displayName: "产品资料", type: "directory", isDirectory: true, modifiedAt: "2026-07-20 18:32" },
{ name: "项目归档", displayName: "项目归档", type: "directory", isDirectory: true, modifiedAt: "2026-07-15 10:21" },
{ name: "设计素材", displayName: "设计素材", type: "directory", isDirectory: true, modifiedAt: "2026-07-10 09:44" },
{
name: "需求评审.pdf",
displayName: "需求评审.pdf",
type: "file",
size: 2.45 * 1024 * 1024,
modifiedAt: "2026-07-21 14:28",
createdAt: "2026-07-21 14:28",
owner: "张小明",
updatedBy: "张小明",
tags: ["项目评审"],
},
{ name: "功能清单.docx", displayName: "功能清单.docx", type: "file", size: 1.28 * 1024 * 1024, modifiedAt: "2026-07-19 16:03" },
{ name: "项目排期表.xlsx", displayName: "项目排期表.xlsx", type: "file", size: 98.64 * 1024, modifiedAt: "2026-07-18 11:22" },
{ name: "客户交付.zip", displayName: "客户交付.zip", type: "file", size: 156.35 * 1024 * 1024, modifiedAt: "2026-07-16 17:45" },
{ name: "使用说明.txt", displayName: "使用说明.txt", type: "file", size: 3.21 * 1024, modifiedAt: "2026-07-12 08:53" },
];
const previewTransferId = "D-PREVIEW-001";
transferTasks.value = [{
id: previewTransferId,
kind: "download",
name: "需求评审.pdf",
speed: "-",
progress: 100,
status: "done",
note: "下载成功",
}];
unreadTransferTaskIds.value = [previewTransferId];
clearFileInteractionState();
}
onMounted(async () => {
window.addEventListener("click", handleGlobalClick);
window.addEventListener("keydown", handleGlobalKey);
window.addEventListener("contextmenu", handleGlobalContextMenu);
if (uiPreviewMode) {
applyUiPreviewData();
return;
}
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="login-brand-mark">
<img :src="dogLogo" alt="玩玩云" />
<span>玩玩云 Desktop</span>
</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">
<img class="app-mark" :src="dogLogo" alt="玩玩云" />
<div>
<strong>玩玩云</strong>
<span>Desktop</span>
</div>
</div>
<nav class="nav-list" aria-label="主导航">
<button
v-for="item in navItems"
:key="item.key"
class="nav-btn"
:class="{ active: nav === item.key }"
:title="item.hint"
@click="nav = item.key"
>
<component :is="item.icon" class="nav-icon" :size="21" weight="regular" />
<span>{{ item.label }}</span>
<span
v-if="item.key === 'transfers' && transferBadgeCount"
class="nav-count"
:aria-label="`${transferBadgeCount} 个待关注传输任务`"
>{{ transferBadgeCount }}</span>
<span v-else-if="item.key === 'settings' && updateState.available" class="nav-dot" aria-label="发现新版本"></span>
</button>
</nav>
<div class="user-card">
<div class="user-card-main">
<img class="avatar" :src="dogLogo" alt="用户头像" />
<div class="meta">
<strong>{{ user?.username || "-" }}</strong>
<span>{{ user?.current_storage_type === "local" ? "本地存储" : "OSS 存储" }}</span>
</div>
<button class="logout-btn" title="退出登录" aria-label="退出登录" @click="handleLogout">
<PhSignOut :size="18" />
</button>
</div>
<div class="storage-meta">
<span>{{ storageUsageLabel }}</span>
<span>{{ Math.round(storageUsagePercent) }}%</span>
</div>
<div class="storage-track" aria-label="存储空间使用率">
<span :style="{ width: `${storageUsagePercent}%` }"></span>
</div>
<div class="storage-footer">
<span>{{ user?.current_storage_type === "local" ? "本地空间" : "云存储" }}</span>
<button @click="nav = 'settings'">管理空间</button>
</div>
</div>
</aside>
<section class="work-area">
<header class="top-tools">
<div class="topbar-main">
<div class="page-title-block">
<h1>{{ currentPageTitle }}</h1>
<p>{{ nav === "files" ? `${filteredFiles.length} 个项目` : toolbarCrumbs[toolbarCrumbs.length - 1]?.label }}</p>
</div>
<div v-if="nav === 'files'" class="search-box">
<PhMagnifyingGlass :size="19" />
<input
v-model="searchKeyword"
type="text"
placeholder="搜索文件或文件夹"
aria-label="搜索文件或文件夹"
@keyup.enter="runGlobalSearch"
/>
<button v-if="searchKeyword" title="清空搜索" aria-label="清空搜索" @click="resetFileSearch">
<PhX :size="16" />
</button>
<span v-else>Enter</span>
</div>
</div>
<div class="crumbs">
<template v-for="(crumb, idx) in toolbarCrumbs" :key="`${crumb.path}-${idx}`">
<button
class="crumb-btn"
:class="{ current: idx === toolbarCrumbs.length - 1 }"
@click="nav === 'files' ? jumpToPath(crumb.path) : null"
>
{{ crumb.label }}
</button>
<PhCaretRight v-if="idx < toolbarCrumbs.length - 1" class="crumb-separator" :size="13" />
</template>
</div>
<div class="tool-right" :class="{ 'tool-right-files': nav === 'files' }">
<template v-if="nav === 'files'">
<div class="file-actions-row">
<template v-if="batchMode">
<span class="batch-selection-summary">
<PhSelectionAll :size="18" />
已选 <strong>{{ batchSelectedItems.length }}</strong>
</span>
<button class="action-btn danger" @click="batchDeleteSelected">批量删除</button>
<button class="action-btn" @click="batchShareSelected">批量分享</button>
<button class="action-btn" @click="batchDirectLinkSelected">批量直链</button>
<button class="action-btn" @click="clearBatchSelection">取消选择</button>
</template>
<template v-else>
<button class="solid-btn" @click="chooseUploadFiles">
<PhCloudArrowUp :size="19" />
上传
</button>
<button class="action-btn" @click="createFolder">
<PhFolderSimplePlus :size="18" />
新建文件夹
</button>
<button class="action-btn" aria-label="下载当前查看文件" :disabled="!selectedFile || selectedFile.isDirectory" @click="downloadSelected()">
<PhDownloadSimple :size="18" />
下载
</button>
<button class="action-btn" aria-label="分享当前查看项目" :disabled="!selectedFile" @click="selectedFile && createShareForItem(selectedFile)">
<PhShareNetwork :size="18" />
分享
</button>
<button class="action-btn danger subtle" aria-label="删除当前查看项目" :disabled="!selectedFile" @click="deleteSelected()">
<PhTrash :size="18" />
删除
</button>
</template>
<span class="toolbar-spacer"></span>
<select v-model="fileViewState.filter" class="compact-select" title="文件类型筛选" aria-label="文件类型筛选">
<option v-for="opt in fileTypeFilterOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<select v-model="fileViewState.sortBy" class="compact-select" title="排序字段" aria-label="排序字段">
<option v-for="opt in fileSortOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<button class="icon-btn" :title="fileViewState.sortOrder === 'asc' ? '当前升序' : '当前降序'" @click="toggleFileSortOrder">
<component :is="fileViewState.sortOrder === 'asc' ? PhSortAscending : PhSortDescending" :size="18" />
</button>
<button class="icon-btn" title="刷新" aria-label="刷新" @click="refreshCurrentFiles">
<PhArrowClockwise :size="18" />
</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(); loadDirectLinks()">刷新分享</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">
<section class="panel content-panel">
<template v-if="nav === 'files'">
<div class="file-drop-surface" :class="{ active: dropState.active }">
<div class="file-table-head" role="row">
<div class="selection-cell">
<button
class="table-checkbox"
:class="{ active: areAllVisibleFilesSelected || areSomeVisibleFilesSelected, mixed: areSomeVisibleFilesSelected }"
role="checkbox"
:aria-checked="areAllVisibleFilesSelected ? 'true' : (areSomeVisibleFilesSelected ? 'mixed' : 'false')"
:title="areAllVisibleFilesSelected ? '取消全选当前列表' : '全选当前列表'"
:aria-label="areAllVisibleFilesSelected ? '取消全选当前列表' : '全选当前列表'"
@click="toggleSelectAllVisible"
>
<PhCheck v-if="areAllVisibleFilesSelected" :size="12" weight="bold" />
<PhMinus v-else-if="areSomeVisibleFilesSelected" :size="12" weight="bold" />
</button>
</div>
<button class="column-sort name-column" @click="fileViewState.sortBy = 'name'; toggleFileSortOrder()">
文件名
<PhCaretDown :size="13" />
</button>
<button class="column-sort size-column" @click="fileViewState.sortBy = 'size'; toggleFileSortOrder()">大小</button>
<button class="column-sort time-column" @click="fileViewState.sortBy = 'modifiedAt'; toggleFileSortOrder()">
修改时间
<PhCaretDown :size="13" />
</button>
<div class="row-menu-cell"><PhInfo :size="17" /></div>
</div>
<div v-if="pathState.loading" class="file-state">
<span class="state-icon loading"><PhArrowsClockwise :size="28" /></span>
<strong>正在加载目录</strong>
<p>文件列表马上就好</p>
</div>
<div v-else-if="pathState.error" class="file-state error">
<span class="state-icon"><PhX :size="28" /></span>
<strong>目录加载失败</strong>
<p>{{ pathState.error }}</p>
<button class="action-btn" @click="loadFiles(pathState.currentPath)">重新加载</button>
</div>
<div v-else-if="filteredFiles.length === 0" class="file-state">
<span class="state-icon"><PhFolderSimple :size="30" /></span>
<strong>当前目录暂无文件</strong>
<p>上传文件或新建文件夹开始整理</p>
<button class="solid-btn" @click="chooseUploadFiles"><PhUploadSimple :size="18" />上传文件</button>
</div>
<div v-else class="file-table-body" role="rowgroup" @click.self="clearFocusedFile">
<div
v-for="item in filteredFiles"
:key="fileSelectionKey(item)"
class="file-row"
:class="{ selected: isFocusedFile(item), batchSelected: isBatchSelected(item), renaming: isInlineRenaming(item) }"
role="row"
:aria-selected="isBatchSelected(item)"
:aria-current="isFocusedFile(item) ? 'true' : undefined"
tabindex="0"
@click="handleFileCardClick(item)"
@dblclick="handleFileCardDoubleClick(item)"
@keydown.enter.prevent="handleFileCardDoubleClick(item)"
@keydown.space.prevent="toggleRowSelection(item)"
@contextmenu.prevent="openContextMenu($event, item)"
>
<div class="selection-cell">
<button
class="table-checkbox"
:class="{ active: isBatchSelected(item) }"
role="checkbox"
:aria-checked="isBatchSelected(item) ? 'true' : 'false'"
:aria-label="`${isBatchSelected(item) ? '取消选择' : '选择'} ${item.displayName || item.name}`"
@click.stop="toggleRowSelection(item)"
>
<PhCheck v-if="isBatchSelected(item)" :size="12" weight="bold" />
</button>
</div>
<div class="file-primary" role="cell">
<span class="file-icon" :class="[`kind-${fileVisualKind(item)}`, `ext-${getFileExt(item) || 'file'}`]">
<component :is="fileIconComponent(item)" :size="24" :weight="item.isDirectory ? 'fill' : 'duotone'" />
</span>
<div class="file-name-wrap">
<input
v-if="isInlineRenaming(item)"
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="submitInlineRename(item)"
/>
<template v-else>
<strong :title="item.displayName || item.name">{{ item.displayName || item.name }}</strong>
</template>
</div>
</div>
<span class="file-size" role="cell">{{ item.isDirectory || item.type === "directory" ? "—" : (item.sizeFormatted || formatBytes(item.size)) }}</span>
<span class="file-time" role="cell">{{ formatDate(item.modifiedAt) }}</span>
<button class="row-more" title="更多操作" :aria-label="`更多操作 ${item.displayName || item.name}`" @click.stop="openContextMenu($event, item)">
<PhDotsThreeVertical :size="20" weight="bold" />
</button>
</div>
</div>
<div v-if="!pathState.loading && !pathState.error && filteredFiles.length" class="file-table-footer">
<span>{{ pathState.mode === "search" ? `搜索到 ${filteredFiles.length} 个结果` : `${filteredFiles.length}` }}</span>
<span>双击打开 · 右键查看更多操作</span>
</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 class="shares-section">
<div class="section-label">分享链接</div>
<div v-if="sharesLoading" class="empty-tip">正在加载分享列表...</div>
<div v-else-if="sortedShares.length === 0" class="empty-tip">暂无分享记录</div>
<div v-else class="share-list">
<div v-for="share in sortedShares" :key="share.id" class="share-item">
<div class="share-main">
<div class="share-title">
<strong :title="share.share_path">{{ getShareDisplayName(share) }}</strong>
<span class="share-badge">{{ share.share_type === "directory" ? "文件夹" : "文件" }}</span>
<span class="share-badge">{{ share.has_password ? "密码保护" : "公开" }}</span>
</div>
<div class="share-link" :title="share.share_url">{{ share.share_url }}</div>
<div class="share-meta">
<span>分享码 {{ share.share_code }}</span>
<span>访问 {{ share.view_count || 0 }}</span>
<span>下载 {{ share.download_count || 0 }}</span>
<span>到期 {{ getShareExpireLabel(share.expires_at) }}</span>
</div>
</div>
<div class="share-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>
</div>
<div class="shares-section" style="margin-top: 20px;">
<div class="section-label">直链列表</div>
<div v-if="directLinksLoading" class="empty-tip">正在加载直链列表...</div>
<div v-else-if="directLinks.length === 0" class="empty-tip">暂无直链记录</div>
<div v-else class="share-list">
<div v-for="link in directLinks" :key="link.id" class="share-item direct-link-item">
<div class="share-main">
<div class="share-title">
<strong :title="link.file_path">{{ link.file_name || link.file_path.split('/').pop() || '未命名' }}</strong>
<span class="share-badge direct-badge">直链</span>
</div>
<div class="share-link direct-link-url" :title="link.direct_url">{{ link.direct_url }}</div>
<div class="share-meta">
<span>到期 {{ getShareExpireLabel(link.expires_at) }}</span>
</div>
</div>
<div class="share-actions">
<button class="action-btn" @click="copyDirectLink(link)">复制直链</button>
<button class="action-btn danger" @click="deleteDirectLink(link)">删除</button>
</div>
</div>
</div>
</div>
</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 v-if="nav !== 'files'">详情面板</h3>
<template v-if="nav === 'files'">
<div class="detail-head">
<h3>详情信息</h3>
<button v-if="selectedFile || batchMode" title="关闭详情或选择" aria-label="关闭详情或选择" @click="clearFileInteractionState">
<PhX :size="18" />
</button>
</div>
<div v-if="batchSelectedItems.length" class="batch-detail">
<span class="detail-file-icon batch"><PhSelectionAll :size="34" /></span>
<strong>已选择 {{ batchSelectedItems.length }} </strong>
<p>可以在顶部工具栏执行批量删除分享或生成直链</p>
</div>
<div v-else-if="selectedFile" class="file-detail-content">
<span class="detail-file-icon" :class="[`kind-${fileVisualKind(selectedFile)}`, `ext-${getFileExt(selectedFile) || 'file'}`]">
<component :is="fileIconComponent(selectedFile)" :size="52" :weight="selectedFile.isDirectory ? 'fill' : 'duotone'" />
</span>
<h4 :title="selectedFile.displayName || selectedFile.name">{{ selectedFile.displayName || selectedFile.name }}</h4>
<p class="detail-file-size">{{ selectedFile.isDirectory ? "文件夹" : (selectedFile.sizeFormatted || formatBytes(selectedFile.size)) }}</p>
<dl class="detail-list">
<div>
<dt>类型</dt>
<dd>{{ fileTypeLabel(selectedFile) }}</dd>
</div>
<div>
<dt>位置</dt>
<dd :title="buildItemPath(selectedFile)">{{ buildItemPath(selectedFile) }}</dd>
</div>
<div>
<dt>修改时间</dt>
<dd>{{ formatDate(selectedFile.modifiedAt) }}</dd>
</div>
<div>
<dt>文件格式</dt>
<dd>{{ selectedFile.isDirectory ? "目录" : fileExtLabel(selectedFile) }}</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ formatDate(selectedFile.createdAt || selectedFile.modifiedAt) }}</dd>
</div>
<div>
<dt>创建者</dt>
<dd>{{ selectedFile.owner || user?.display_name || user?.username || "当前用户" }}</dd>
</div>
<div>
<dt>最近更新者</dt>
<dd>{{ selectedFile.updatedBy || selectedFile.owner || user?.display_name || user?.username || "当前用户" }}</dd>
</div>
</dl>
<div class="detail-extra">
<div class="detail-extra-title"><span>标签</span></div>
<div class="detail-tags">
<span v-for="tag in selectedFile.tags || []" :key="tag">{{ tag }}</span>
<button @click="showToast('标签编辑将在后续版本开放', 'info')"><PhPlus :size="14" />添加标签</button>
</div>
<div class="detail-description">
<span>描述</span>
<button @click="showToast('描述编辑将在后续版本开放', 'info')"><PhPencilSimple :size="14" />{{ selectedFile.description || "添加描述..." }}</button>
</div>
</div>
<div class="detail-actions">
<button class="action-btn" aria-label="下载详情中的文件" :disabled="selectedFile.isDirectory" @click="downloadSelected(selectedFile)">
<PhDownloadSimple :size="17" />下载
</button>
<button class="action-btn" aria-label="分享详情中的项目" @click="createShareForItem(selectedFile)">
<PhShareNetwork :size="17" />分享
</button>
</div>
</div>
<div v-else class="detail-empty">
<span><PhInfo :size="26" /></span>
<strong>选择一个项目</strong>
<p>查看文件类型位置大小和修改时间</p>
</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="shareCreateDialog.visible" class="share-dialog-mask" @click.self="closeShareCreateDialog()">
<section class="share-create-card" role="dialog" aria-modal="true" aria-labelledby="share-create-title" @click.stop>
<header class="share-dialog-header">
<div>
<span class="share-dialog-eyebrow">安全分享</span>
<h3 id="share-create-title">{{ shareDialogIsBatch ? "批量创建分享" : "创建分享链接" }}</h3>
</div>
<button class="share-dialog-close" :disabled="shareCreateDialog.loading" aria-label="关闭分享设置" @click="closeShareCreateDialog()">
<PhX :size="18" />
</button>
</header>
<template v-if="!shareCreateDialog.submitted">
<div class="share-target-card">
<span class="share-target-icon">
<PhSelectionAll v-if="shareDialogIsBatch" :size="22" />
<PhFolderSimple v-else-if="shareCreateDialog.items[0]?.isDirectory" :size="22" weight="duotone" />
<PhFile v-else :size="22" weight="duotone" />
</span>
<div>
<strong :title="shareDialogTargetName">{{ shareDialogTargetName }}</strong>
<span>{{ shareDialogIsBatch ? "以下设置将应用到全部选中项" : shareDialogTargetPath }}</span>
</div>
</div>
<div class="share-settings-scroll">
<section class="share-setting-section">
<div class="share-section-heading">
<div>
<strong>访问设置</strong>
<span>设置链接有效期和访问密码</span>
</div>
</div>
<div class="share-field-grid">
<label class="share-field">
<span>有效期</span>
<select v-model="shareCreateDialog.expiryType">
<option value="never">永久有效</option>
<option value="7">7 </option>
<option value="30">30 </option>
<option value="custom">自定义</option>
</select>
</label>
<label v-if="shareCreateDialog.expiryType === 'custom'" class="share-field">
<span>自定义天数</span>
<input v-model.number="shareCreateDialog.customDays" type="number" min="1" max="365" inputmode="numeric" />
</label>
</div>
<label class="share-switch-row">
<span>
<strong>密码保护</strong>
<small>访问链接时必须输入密码</small>
</span>
<input v-model="shareCreateDialog.enablePassword" type="checkbox" />
</label>
<label v-if="shareCreateDialog.enablePassword" class="share-field share-password-field">
<span>访问密码</span>
<input
v-model="shareCreateDialog.password"
type="password"
maxlength="32"
autocomplete="new-password"
placeholder="请输入访问密码(最多 32 个字符)"
/>
<small>建议通过其他安全渠道单独发送密码</small>
</label>
</section>
<section class="share-setting-section advanced">
<label class="share-switch-row advanced-toggle">
<span>
<strong>高级安全策略</strong>
<small>限制下载次数IP设备和访问时段</small>
</span>
<input v-model="shareCreateDialog.enableAdvancedSecurity" type="checkbox" />
</label>
<div v-if="shareCreateDialog.enableAdvancedSecurity" class="share-advanced-fields">
<label class="share-switch-row compact">
<span>
<strong>限制下载次数</strong>
<small>达到上限后停止下载</small>
</span>
<input v-model="shareCreateDialog.maxDownloadsEnabled" type="checkbox" />
</label>
<label v-if="shareCreateDialog.maxDownloadsEnabled" class="share-field">
<span>下载次数上限</span>
<input v-model.number="shareCreateDialog.maxDownloads" type="number" min="1" max="1000000" inputmode="numeric" />
</label>
<label class="share-field">
<span>IP 白名单 <small>可选</small></span>
<textarea
v-model="shareCreateDialog.ipWhitelist"
rows="2"
spellcheck="false"
placeholder="支持逗号或空格分隔例如1.2.3.4, 5.6.7.*"
></textarea>
</label>
<label class="share-field">
<span>设备限制</span>
<select v-model="shareCreateDialog.deviceLimit">
<option value="all">全部设备</option>
<option value="mobile">仅移动端</option>
<option value="desktop">仅桌面端</option>
</select>
</label>
<label class="share-switch-row compact">
<span>
<strong>限制访问时段</strong>
<small>支持跨天时段例如 22:00 06:00</small>
</span>
<input v-model="shareCreateDialog.accessTimeEnabled" type="checkbox" />
</label>
<div v-if="shareCreateDialog.accessTimeEnabled" class="share-time-grid">
<label class="share-field">
<span>开始时间</span>
<input v-model="shareCreateDialog.accessTimeStart" type="time" />
</label>
<label class="share-field">
<span>结束时间</span>
<input v-model="shareCreateDialog.accessTimeEnd" type="time" />
</label>
</div>
</div>
</section>
</div>
<div v-if="shareCreateDialog.error" class="share-dialog-error" role="alert">{{ shareCreateDialog.error }}</div>
<footer class="share-dialog-actions">
<button class="action-btn" :disabled="shareCreateDialog.loading" @click="closeShareCreateDialog()">取消</button>
<button class="solid-btn" :disabled="shareCreateDialog.loading" @click="submitShareCreateDialog()">
<template v-if="shareCreateDialog.loading">
正在创建 {{ shareCreateDialog.processedCount }}/{{ shareCreateDialog.items.length }}
</template>
<template v-else>{{ shareDialogIsBatch ? `创建 ${shareCreateDialog.items.length} 个分享` : "创建分享" }}</template>
</button>
</footer>
</template>
<template v-else>
<div class="share-result-summary" :class="{ partial: shareCreateDialog.failures.length > 0 }">
<span><PhCheck :size="24" weight="bold" /></span>
<div>
<strong>{{ shareCreateDialog.results.length > 0 ? "分享创建完成" : "分享创建失败" }}</strong>
<small>
成功 {{ shareCreateDialog.results.length }}
<template v-if="shareDialogReusedCount">复用已有 {{ shareDialogReusedCount }} </template>
<template v-if="shareCreateDialog.failures.length">失败 {{ shareCreateDialog.failures.length }} </template>
</small>
</div>
</div>
<div v-if="shareCreateDialog.failures.length" class="share-failure-list" role="alert">
<div v-for="failure in shareCreateDialog.failures" :key="`${failure.itemName}-${failure.message}`">
<strong>{{ failure.itemName }}</strong>
<span>{{ failure.message }}</span>
</div>
</div>
<div v-if="shareCreateDialog.results.length" class="share-created-list">
<article v-for="result in shareCreateDialog.results" :key="`${result.itemName}-${result.shareCode}`" class="share-created-item">
<div class="share-created-head">
<strong :title="result.itemName">{{ result.itemName }}</strong>
<span v-if="result.reused" class="share-result-badge reused">已复用</span>
<span v-else class="share-result-badge">新建</span>
</div>
<div class="share-created-url" :title="result.shareUrl">{{ result.shareUrl || "未返回分享链接" }}</div>
<div class="share-created-meta">
<span>{{ result.hasPassword ? "密码保护" : "公开访问" }}</span>
<span v-if="result.password">密码 {{ result.password }}</span>
<span>{{ getShareExpireLabel(result.expiresAt) }}</span>
<span v-if="result.shareCode">分享码 {{ result.shareCode }}</span>
<span v-if="result.securityPolicy?.max_downloads">最多下载 {{ result.securityPolicy.max_downloads }} </span>
<span v-if="result.securityPolicy?.ip_whitelist_count">IP 白名单 {{ result.securityPolicy.ip_whitelist_count }} </span>
<span v-if="result.securityPolicy?.device_limit && result.securityPolicy.device_limit !== 'all'">
{{ getShareDeviceLabel(result.securityPolicy.device_limit) }}
</span>
<span v-if="result.securityPolicy?.access_time_start && result.securityPolicy?.access_time_end">
{{ result.securityPolicy.access_time_start }} - {{ result.securityPolicy.access_time_end }}
</span>
</div>
<div v-if="result.reused" class="share-reused-note">已存在相同目标的分享本次设置未覆盖原分享的安全策略</div>
<div class="share-created-actions">
<button class="action-btn" @click="copyCreatedShareLink(result)">复制链接</button>
<button class="action-btn" @click="openCreatedShareLink(result)">打开链接</button>
<button v-if="result.password" class="action-btn" @click="copyText(result.password, '访问密码已复制')">复制密码</button>
</div>
</article>
</div>
<footer class="share-dialog-actions result-actions">
<button v-if="shareCreateDialog.results.length === 0" class="action-btn" @click="returnToShareSettings">返回设置</button>
<button class="solid-btn" @click="closeShareCreateDialog(true)">完成</button>
</footer>
</template>
</section>
</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 danger" :disabled="shareDeleteDialog.loading" @click="confirmDeleteShare()">
{{ shareDeleteDialog.loading ? "删除中..." : "确定删除" }}
</button>
<button class="action-btn" :disabled="shareDeleteDialog.loading" @click="closeDeleteShareDialog()">取消</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 danger" :disabled="fileDeleteDialog.loading" @click="confirmDeleteFile()">
{{ fileDeleteDialog.loading ? "删除中..." : "确定删除" }}
</button>
<button class="action-btn" :disabled="fileDeleteDialog.loading" @click="closeDeleteFileDialog()">取消</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 danger" :disabled="operationConfirmDialog.loading" @click="confirmOperationDialog()">
{{ operationConfirmDialog.loading ? "处理中..." : (operationConfirmDialog.confirmText || "确定") }}
</button>
<button class="action-btn" :disabled="operationConfirmDialog.loading" @click="closeOperationConfirmDialog()">取消</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 danger" :disabled="updatePrompt.loading" @click="confirmUpdateFromPrompt()">
{{ updatePrompt.loading ? "处理中..." : "立即更新" }}
</button>
<button class="action-btn" :disabled="updatePrompt.loading" @click="dismissUpdatePrompt(true)">稍后提醒</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-row {
display: flex;
align-items: center;
gap: 10px;
}
.nav-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
color: #4a6381;
}
.nav-btn span {
font-size: 14px;
color: #223244;
}
.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,
.nav-btn.active .nav-icon {
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-start;
}
.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;
}
.files-summary-bar {
height: 34px;
border: 1px solid #e8eef7;
border-radius: 10px;
background: #f8fbff;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.files-summary-main {
font-size: 12px;
color: #385373;
font-weight: 600;
}
.files-summary-sub {
font-size: 12px;
color: #6b8097;
}
.icon-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fill, 120px);
justify-content: flex-start;
align-content: start;
min-height: 0;
overflow: auto;
padding: 4px;
}
.file-drop-surface {
position: relative;
min-height: 0;
flex: 1;
border: 1px solid #e8eef7;
border-radius: 12px;
background: #fff;
padding: 12px 10px 10px;
}
.file-drop-surface.active .icon-grid {
filter: saturate(1.04) blur(0.2px);
}
.file-card {
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
text-align: left;
padding: 8px 6px 6px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 6px;
width: 120px;
min-height: 130px;
overflow: hidden;
position: relative;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.file-card:focus-visible {
outline: 2px solid #79a8f0;
outline-offset: 1px;
}
.file-card:hover {
border-color: #d7e6fb;
background: #f3f8ff;
}
.file-card.selected {
border-color: #a5c7f8;
background: #e8f2ff;
box-shadow: 0 0 0 1px rgba(73, 128, 216, 0.14) inset;
}
.file-card.batchSelected {
border-color: #82b0f3;
background: #eaf3ff;
box-shadow: 0 0 0 1px rgba(63, 132, 236, 0.2) inset;
}
.file-card.renaming {
border-color: #7baaf8;
background: #eff6ff;
}
.batch-check {
position: absolute;
right: 6px;
top: 6px;
width: 16px;
height: 16px;
border-radius: 999px;
border: 1px solid #bad0eb;
background: #fff;
color: #1f67c9;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 700;
}
.batch-check.active {
border-color: #2e7be8;
background: #2e7be8;
color: #fff;
}
.file-icon-shell {
width: 64px;
height: 56px;
position: relative;
margin-top: 1px;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: linear-gradient(180deg, #7ab5ff 0%, #4689dd 100%);
box-shadow: 0 3px 8px rgba(32, 77, 131, 0.15);
display: flex;
align-items: center;
justify-content: center;
}
.file-type-svg {
width: 28px;
height: 28px;
margin-top: -4px;
}
.file-icon-shell.kind-folder {
height: 52px;
margin-top: 4px;
border-radius: 10px;
border-color: rgba(210, 170, 50, 0.3);
background: linear-gradient(180deg, #f2c85e 0%, #e3b145 100%);
box-shadow: 0 3px 8px rgba(152, 106, 14, 0.15);
}
.folder-tab {
position: absolute;
left: 9px;
top: -7px;
width: 24px;
height: 10px;
border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, #f8d379 0%, #e8bc53 100%);
border: 1px solid #d8b860;
border-bottom: 0;
}
.file-corner {
position: absolute;
right: 0;
top: 0;
width: 16px;
height: 16px;
background: rgba(255, 255, 255, 0.9);
clip-path: polygon(100% 0, 0 0, 100% 100%);
border-top-right-radius: 8px;
}
.file-ext {
position: absolute;
left: 50%;
bottom: 4px;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.95);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.5px;
line-height: 1;
text-transform: uppercase;
}
.file-icon-shell.kind-document {
background: linear-gradient(180deg, #79b6ff 0%, #4c8ee2 100%);
}
.file-icon-shell.kind-image {
background: linear-gradient(180deg, #6fc7a1 0%, #3fa67b 100%);
}
.file-icon-shell.kind-video {
background: linear-gradient(180deg, #88b4ff 0%, #5b7fd9 100%);
}
.file-icon-shell.kind-audio {
background: linear-gradient(180deg, #ff9f7b 0%, #eb7059 100%);
}
.file-icon-shell.kind-archive {
background: linear-gradient(180deg, #f6bc74 0%, #e58e3a 100%);
}
.file-icon-shell.kind-app {
background: linear-gradient(180deg, #9cc36f 0%, #6ea649 100%);
}
.file-icon-shell.kind-file {
background: linear-gradient(180deg, #95b0d3 0%, #6684ad 100%);
}
.file-name {
font-size: 12px;
font-weight: 500;
color: #1f2b3a;
line-height: 1.4;
width: 100%;
text-align: center;
overflow: hidden;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
min-height: 32px;
max-height: 32px;
}
.inline-rename-input {
width: 100%;
height: 30px;
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);
}
.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-start;
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;
}
}
.section-label {
font-size: 13px;
font-weight: 600;
color: #3a5274;
margin-bottom: 10px;
padding-left: 2px;
}
.shares-section {
display: flex;
flex-direction: column;
}
.direct-link-item {
border-left: 3px solid #06b6d4;
}
.direct-badge {
background: #06b6d4 !important;
color: #fff !important;
}
.direct-link-url {
color: #0891b2 !important;
}
/* 2026 desktop redesign: large-company cloud-drive workspace */
:global(html),
:global(body),
:global(#app) {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
font-family: "Noto Sans SC Variable", "Noto Sans SC", "Microsoft YaHei", sans-serif;
background: #f7f9fc;
color: #172033;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
:global(button),
:global(input),
:global(select),
:global(textarea) {
font: inherit;
}
:global(button:focus-visible),
:global(input:focus-visible),
:global(select:focus-visible) {
outline: 2px solid rgba(31, 111, 235, 0.55);
outline-offset: 2px;
}
.desktop-root {
width: 100%;
height: 100dvh;
min-height: 100dvh;
padding: 0;
background: #f7f9fc;
}
.work-shell {
height: 100dvh;
min-width: 0;
display: grid;
grid-template-columns: 224px minmax(0, 1fr);
gap: 0;
background: #fff;
}
.left-nav {
min-width: 0;
padding: 22px 14px 16px;
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
background: #f7f9fc;
border: 0;
border-right: 1px solid #e6ebf2;
border-radius: 0;
}
.left-header {
min-height: 46px;
display: flex;
align-items: center;
gap: 11px;
padding: 0 10px;
margin: 0 0 24px;
}
.app-mark {
width: 38px;
height: 38px;
flex: 0 0 38px;
display: block;
object-fit: cover;
border-radius: 50%;
background: #ffe1a6;
box-shadow: 0 0 0 1px rgba(158, 107, 40, 0.12);
}
.left-header strong {
display: block;
font-size: 17px;
line-height: 1.25;
letter-spacing: -0.02em;
color: #162238;
}
.left-header span {
display: block;
margin-top: 2px;
color: #1f6feb;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
}
.nav-list {
display: grid;
gap: 7px;
}
.nav-btn {
width: 100%;
min-height: 48px;
padding: 0 14px;
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
border: 0;
border-radius: 10px;
background: transparent;
color: #344054;
text-align: left;
cursor: pointer;
transition: color 150ms ease, background-color 150ms ease, transform 150ms ease;
}
.nav-btn:hover {
background: #eef3fa;
color: #18243a;
}
.nav-btn:active {
transform: translateY(1px);
}
.nav-btn.active {
color: #1f6feb;
background: #eaf2ff;
}
.nav-btn > span:not(.nav-count):not(.nav-dot) {
overflow: hidden;
color: inherit;
font-size: 14px;
font-weight: 520;
white-space: nowrap;
text-overflow: ellipsis;
}
.nav-btn.active > span:not(.nav-count):not(.nav-dot) {
color: inherit;
font-weight: 650;
}
.nav-icon {
width: 21px;
height: 21px;
color: currentColor;
}
.nav-btn small,
.nav-btn-row {
display: none;
}
.nav-count {
min-width: 20px;
height: 20px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #e6ebf2;
color: #536175;
font-size: 11px;
font-weight: 650;
}
.nav-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #1f6feb;
}
.user-card {
margin-top: auto;
padding: 16px 10px 0;
display: block;
border-top: 1px solid #e3e8f0;
border-radius: 0;
background: transparent;
}
.user-card-main {
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 30px;
align-items: center;
gap: 10px;
}
.avatar {
width: 38px;
height: 38px;
display: block;
object-fit: cover;
border-radius: 50%;
background: #ffe1a6;
box-shadow: 0 0 0 1px rgba(158, 107, 40, 0.12);
}
.user-card .meta {
min-width: 0;
display: grid;
gap: 2px;
}
.user-card .meta strong,
.user-card .meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-card .meta strong {
color: #1d2939;
font-size: 13px;
font-weight: 650;
}
.user-card .meta span {
color: #7b8798;
font-size: 11px;
}
.logout-btn,
.top-icon-btn,
.icon-btn,
.row-more,
.detail-head button,
.search-box button {
border: 0;
background: transparent;
color: #667085;
cursor: pointer;
}
.logout-btn {
width: 30px;
height: 30px;
padding: 0;
display: grid;
place-items: center;
border-radius: 7px;
}
.logout-btn:hover {
color: #c53d3d;
background: #fff0f0;
}
.storage-meta,
.storage-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #7a8799;
font-size: 10px;
}
.storage-meta {
margin-top: 16px;
}
.storage-track {
height: 5px;
margin-top: 8px;
overflow: hidden;
border-radius: 999px;
background: #e3e8ef;
}
.storage-track span {
height: 100%;
display: block;
border-radius: inherit;
background: #1f6feb;
transition: width 220ms ease;
}
.storage-footer {
margin-top: 10px;
}
.storage-footer button {
padding: 0;
border: 0;
background: transparent;
color: #1f6feb;
font-size: 10px;
cursor: pointer;
}
.work-area {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0;
overflow: hidden;
background: #fff;
}
.top-tools {
min-width: 0;
padding: 20px 28px 13px;
display: grid;
grid-template-columns: 1fr;
gap: 12px;
border: 0;
border-bottom: 1px solid #e8edf3;
border-radius: 0;
background: #fff;
}
.topbar-main {
min-width: 0;
min-height: 46px;
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(280px, 420px) minmax(180px, 1fr);
align-items: center;
gap: 20px;
}
.page-title-block {
min-width: 0;
}
.page-title-block h1 {
margin: 0;
color: #172033;
font-size: 22px;
font-weight: 720;
letter-spacing: -0.025em;
line-height: 1.25;
}
.page-title-block p {
margin: 3px 0 0;
color: #8792a3;
font-size: 11px;
}
.search-box {
height: 42px;
min-width: 0;
padding: 0 12px;
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
border: 1px solid #dce3ec;
border-radius: 9px;
color: #7c889a;
background: #fbfcfe;
transition: border-color 150ms ease, box-shadow 150ms ease, background-color 150ms ease;
}
.search-box:focus-within {
border-color: #8eb9f7;
background: #fff;
box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.09);
}
.search-box input {
min-width: 0;
height: 38px;
padding: 0;
border: 0;
outline: 0;
color: #26364d;
background: transparent;
font-size: 13px;
}
.search-box input::placeholder {
color: #a0a9b8;
}
.search-box span {
padding: 3px 6px;
border-radius: 5px;
background: #eef1f5;
color: #98a2b3;
font-size: 9px;
}
.search-box button {
width: 26px;
height: 26px;
padding: 0;
display: grid;
place-items: center;
border-radius: 6px;
}
.search-box button:hover {
color: #1f6feb;
background: #edf4ff;
}
.crumbs {
min-width: 0;
padding: 0;
display: flex;
align-items: center;
gap: 5px;
overflow-x: auto;
}
.crumb-btn {
height: 24px;
padding: 0 2px;
border: 0;
background: transparent;
color: #7a8799;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.crumb-btn:hover,
.crumb-btn.current {
color: #344054;
}
.crumb-btn.current {
font-weight: 600;
}
.crumb-separator {
flex: 0 0 auto;
color: #b1bac7;
}
.tool-right,
.tool-right-files {
min-width: 0;
width: 100%;
display: block;
}
.file-actions-row {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
flex-wrap: nowrap;
}
.batch-selection-summary {
min-width: 108px;
height: 38px;
padding: 0 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid #cfe0fa;
border-radius: 8px;
background: #edf5ff;
color: #315f9f;
font-size: 12px;
white-space: nowrap;
}
.batch-selection-summary strong {
color: #1f6feb;
font-size: 13px;
}
.toolbar-spacer {
min-width: 12px;
flex: 1 1 24px;
}
.primary-btn,
.solid-btn,
.ghost-btn,
.action-btn,
.icon-btn,
.mini-btn {
transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease, transform 140ms ease;
}
.primary-btn:active,
.solid-btn:active,
.action-btn:active,
.icon-btn:active,
.mini-btn:active {
transform: translateY(1px);
}
.primary-btn,
.solid-btn {
height: 38px;
min-width: 78px;
padding: 0 15px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid #1f6feb;
border-radius: 8px;
background: #1f6feb;
color: #fff;
font-size: 13px;
font-weight: 620;
line-height: 1;
cursor: pointer;
box-shadow: 0 2px 7px rgba(31, 111, 235, 0.16);
}
.primary-btn:hover,
.solid-btn:hover {
border-color: #185fc8;
background: #185fc8;
}
.action-btn {
height: 38px;
min-width: 0;
padding: 0 13px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid #d7dee8;
border-radius: 8px;
background: #fff;
color: #344054;
font-size: 12px;
line-height: 1;
white-space: nowrap;
cursor: pointer;
}
.action-btn:hover {
border-color: #b9c7da;
background: #f7f9fc;
}
.action-btn.active {
border-color: #a6c6f5;
background: #edf4ff;
color: #1f6feb;
}
.action-btn.danger,
.action-btn.danger.subtle {
border-color: #ead6d6;
background: #fff;
color: #b54747;
}
.action-btn.danger:hover {
border-color: #e4b8b8;
background: #fff4f4;
}
.action-btn:disabled,
.solid-btn:disabled,
.icon-btn:disabled {
opacity: 0.42;
cursor: not-allowed;
transform: none;
}
.icon-btn {
width: 38px;
height: 38px;
flex: 0 0 38px;
padding: 0;
display: grid;
place-items: center;
border: 1px solid #d7dee8;
border-radius: 8px;
background: #fff;
color: #667085;
cursor: pointer;
}
.icon-btn:hover,
.icon-btn.active,
.icon-btn.active-view {
border-color: #b4cef3;
color: #1f6feb;
background: #edf4ff;
}
.compact-select {
width: 96px;
height: 38px;
padding: 0 26px 0 9px;
border: 1px solid #d7dee8;
border-radius: 8px;
background-color: #fff;
color: #536175;
font-size: 11px;
}
.main-grid {
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 288px;
gap: 0;
overflow: hidden;
background: #fff;
}
.main-grid.focus-content {
grid-template-columns: minmax(0, 1fr) 288px;
}
.main-grid.focus-content .detail-panel {
display: flex;
}
.panel,
.content-panel,
.detail-panel {
min-width: 0;
min-height: 0;
border: 0;
border-radius: 0;
background: #fff;
box-shadow: none;
}
.content-panel {
padding: 0 28px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.detail-panel {
padding: 18px 20px;
display: flex;
flex-direction: column;
overflow: auto;
border-left: 1px solid #e8edf3;
}
.file-drop-surface {
position: relative;
min-height: 0;
flex: 1;
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border: 0;
border-radius: 0;
background: #fff;
}
.file-table-head,
.file-row {
min-width: 680px;
display: grid;
grid-template-columns: 36px minmax(250px, 1fr) 118px 170px 36px;
align-items: center;
column-gap: 12px;
}
.file-table-head {
min-height: 44px;
flex: 0 0 44px;
border-bottom: 1px solid #e8edf3;
color: #7a8799;
}
.column-sort {
min-width: 0;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
border: 0;
background: transparent;
color: #7a8799;
font-size: 11px;
cursor: pointer;
}
.column-sort:hover {
color: #344054;
}
.row-menu-cell {
display: grid;
place-items: center;
color: #a0a9b8;
}
.file-table-body {
min-width: 0;
min-height: 0;
flex: 1;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: #d3dbe6 transparent;
}
.file-row {
min-height: 66px;
padding: 5px 0;
border-bottom: 1px solid #edf1f5;
color: #344054;
cursor: default;
transition: background-color 130ms ease, box-shadow 130ms ease;
}
.file-row:hover {
background: #f8faff;
}
.file-row.selected,
.file-row.batchSelected {
background: #eef5ff;
box-shadow: inset 2px 0 0 #1f6feb;
}
.file-row:focus-visible {
outline: 2px solid rgba(31, 111, 235, 0.5);
outline-offset: -2px;
}
.selection-cell {
display: grid;
place-items: center;
}
.table-checkbox {
width: 17px;
height: 17px;
padding: 0;
display: grid;
place-items: center;
border: 1px solid #c9d2df;
border-radius: 4px;
background: #fff;
color: #fff;
cursor: pointer;
}
.table-checkbox:hover {
border-color: #7ba9ea;
}
.table-checkbox.active {
border-color: #1f6feb;
background: #1f6feb;
}
.table-checkbox:focus-visible {
outline: 2px solid rgba(31, 111, 235, 0.38);
outline-offset: 2px;
}
.file-primary {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.file-icon {
width: 38px;
height: 38px;
flex: 0 0 38px;
display: grid;
place-items: center;
border-radius: 8px;
color: #4e8fe8;
background: #eef5ff;
}
.file-icon.kind-folder,
.detail-file-icon.kind-folder {
color: #efa91c;
background: #fff7df;
}
.file-icon.ext-pdf,
.detail-file-icon.ext-pdf {
color: #e65353;
background: #fff0f0;
}
.file-icon.ext-doc,
.file-icon.ext-docx,
.detail-file-icon.ext-doc,
.detail-file-icon.ext-docx {
color: #3478db;
background: #edf4ff;
}
.file-icon.ext-xls,
.file-icon.ext-xlsx,
.file-icon.ext-csv,
.detail-file-icon.ext-xls,
.detail-file-icon.ext-xlsx,
.detail-file-icon.ext-csv {
color: #2f9d66;
background: #ecf8f1;
}
.file-icon.ext-zip,
.file-icon.ext-rar,
.file-icon.ext-7z,
.detail-file-icon.ext-zip,
.detail-file-icon.ext-rar,
.detail-file-icon.ext-7z {
color: #6d79d8;
background: #f0f1ff;
}
.file-name-wrap {
min-width: 0;
display: grid;
gap: 3px;
}
.file-name-wrap strong {
overflow: hidden;
color: #253247;
font-size: 13px;
font-weight: 560;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-name-wrap span {
color: #98a2b3;
font-size: 10px;
}
.file-size,
.file-time {
overflow: hidden;
color: #667085;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-more {
width: 30px;
height: 30px;
padding: 0;
display: grid;
place-items: center;
border-radius: 7px;
opacity: 0.25;
}
.file-row:hover .row-more,
.file-row.selected .row-more,
.row-more:focus-visible {
opacity: 1;
}
.row-more:hover {
color: #1f6feb;
background: #e6f0ff;
}
.inline-rename-input {
width: min(340px, 100%);
height: 32px;
padding: 0 9px;
border: 1px solid #78aaf0;
border-radius: 6px;
background: #fff;
color: #253247;
font-size: 12px;
font-weight: 560;
}
.file-table-footer {
min-height: 42px;
flex: 0 0 42px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #98a2b3;
font-size: 10px;
}
.file-state {
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #667085;
text-align: center;
}
.state-icon,
.detail-empty > span {
width: 56px;
height: 56px;
display: grid;
place-items: center;
border-radius: 16px;
background: #eef4fc;
color: #7193c4;
}
.state-icon.loading svg {
animation: cloud-spin 900ms linear infinite;
}
@keyframes cloud-spin {
to { transform: rotate(360deg); }
}
.file-state strong {
color: #344054;
font-size: 14px;
}
.file-state p {
margin: 0 0 4px;
color: #98a2b3;
font-size: 11px;
}
.file-state.error .state-icon {
color: #c84a4a;
background: #fff0f0;
}
.detail-head {
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.detail-head h3,
.detail-panel > h3 {
margin: 0;
color: #27364c;
font-size: 14px;
font-weight: 680;
}
.detail-head button {
width: 30px;
height: 30px;
padding: 0;
display: grid;
place-items: center;
border-radius: 7px;
}
.detail-head button:hover {
color: #344054;
background: #f0f3f7;
}
.file-detail-content {
width: 100%;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-height: 0;
padding-top: 34px;
overflow: auto;
scrollbar-width: thin;
}
.detail-file-icon {
flex: 0 0 auto;
width: 88px;
height: 88px;
display: grid;
place-items: center;
border-radius: 18px;
color: #4e8fe8;
background: #eef5ff;
}
.file-detail-content h4 {
flex: 0 0 auto;
width: 100%;
margin: 20px 0 0;
overflow: hidden;
color: #253247;
font-size: 14px;
font-weight: 650;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-file-size {
flex: 0 0 auto;
margin: 6px 0 0;
color: #8a96a8;
font-size: 11px;
}
.detail-list {
flex: 0 0 auto;
width: 100%;
margin: 28px 0 0;
display: grid;
gap: 0;
}
.detail-list > div {
min-width: 0;
padding: 12px 0;
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px;
border-bottom: 1px solid #edf1f5;
}
.detail-list dt,
.detail-list dd {
margin: 0;
font-size: 11px;
}
.detail-list dt {
color: #8a96a8;
}
.detail-list dd {
overflow: hidden;
color: #536175;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-extra {
flex: 0 0 auto;
width: 100%;
}
.detail-extra-title,
.detail-description {
padding: 12px 0;
color: #8a96a8;
font-size: 11px;
}
.detail-tags {
min-height: 36px;
padding: 0 0 12px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
border-bottom: 1px solid #edf1f5;
}
.detail-tags span,
.detail-tags button {
min-height: 26px;
padding: 0 9px;
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid #cfe0fb;
border-radius: 6px;
color: #2871d6;
background: #f6f9ff;
font-size: 10px;
}
.detail-description {
display: grid;
gap: 8px;
border-bottom: 1px solid #edf1f5;
}
.detail-description button {
width: 100%;
padding: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 0;
color: #9aa5b5;
background: transparent;
font-size: 10px;
text-align: left;
}
.detail-actions {
flex: 0 0 auto;
width: 100%;
margin-top: auto;
padding-top: 18px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.detail-empty,
.batch-detail {
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #667085;
text-align: center;
}
.detail-empty strong,
.batch-detail strong {
color: #344054;
font-size: 13px;
}
.detail-empty p,
.batch-detail p {
max-width: 210px;
margin: 0;
color: #98a2b3;
font-size: 11px;
line-height: 1.55;
}
.detail-file-icon.batch {
color: #1f6feb;
background: #edf4ff;
}
.share-dialog-mask {
position: fixed;
inset: 0;
z-index: 1400;
padding: 24px;
display: grid;
place-items: center;
background: rgba(24, 34, 51, 0.5);
backdrop-filter: blur(3px);
}
.share-create-card {
width: min(680px, 100%);
max-height: calc(100dvh - 48px);
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid #dbe4f0;
border-radius: 18px;
background: #fff;
box-shadow: 0 28px 70px rgba(24, 45, 76, 0.24);
}
.share-dialog-header {
flex: 0 0 auto;
min-height: 76px;
padding: 18px 22px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid #e8edf4;
}
.share-dialog-eyebrow {
display: block;
margin-bottom: 3px;
color: #1f6feb;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
}
.share-dialog-header h3 {
margin: 0;
color: #172033;
font-size: 19px;
font-weight: 700;
letter-spacing: -0.02em;
}
.share-dialog-close {
width: 34px;
height: 34px;
flex: 0 0 34px;
display: grid;
place-items: center;
border: 0;
border-radius: 9px;
color: #667085;
background: transparent;
cursor: pointer;
}
.share-dialog-close:hover {
color: #344054;
background: #f2f5f9;
}
.share-dialog-close:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.share-target-card {
flex: 0 0 auto;
margin: 18px 22px 0;
padding: 12px 14px;
display: flex;
align-items: center;
gap: 12px;
border: 1px solid #d8e6fa;
border-radius: 12px;
background: #f5f9ff;
}
.share-target-icon {
width: 38px;
height: 38px;
flex: 0 0 38px;
display: grid;
place-items: center;
border-radius: 10px;
color: #1f6feb;
background: #e8f1ff;
}
.share-target-card > div {
min-width: 0;
}
.share-target-card strong,
.share-target-card span {
display: block;
}
.share-target-card strong {
overflow: hidden;
color: #253247;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-target-card > div > span {
margin-top: 3px;
overflow: hidden;
color: #718096;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-settings-scroll {
min-height: 0;
padding: 16px 22px 4px;
display: grid;
gap: 12px;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: #c9d5e5 transparent;
}
.share-setting-section {
padding: 16px;
display: grid;
gap: 14px;
border: 1px solid #e3e9f1;
border-radius: 13px;
background: #fff;
}
.share-setting-section.advanced {
background: #fbfcfe;
}
.share-section-heading strong,
.share-section-heading span {
display: block;
}
.share-section-heading strong {
color: #253247;
font-size: 13px;
}
.share-section-heading span {
margin-top: 3px;
color: #8a96a8;
font-size: 11px;
}
.share-field-grid,
.share-time-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.share-field {
min-width: 0;
display: grid;
gap: 7px;
color: #4b5b70;
font-size: 11px;
font-weight: 600;
}
.share-field > span small {
color: #98a2b3;
font-size: 10px;
font-weight: 500;
}
.share-field > small {
color: #8a96a8;
font-size: 10px;
font-weight: 500;
line-height: 1.5;
}
.share-field input,
.share-field select,
.share-field textarea {
width: 100%;
min-width: 0;
border: 1px solid #d5deea;
border-radius: 9px;
color: #253247;
background: #fff;
font-size: 12px;
font-weight: 500;
transition: border-color 140ms ease, box-shadow 140ms ease;
}
.share-field input,
.share-field select {
height: 38px;
padding: 0 11px;
}
.share-field textarea {
min-height: 64px;
padding: 9px 11px;
resize: vertical;
line-height: 1.5;
}
.share-field input:focus,
.share-field select:focus,
.share-field textarea:focus {
outline: 0;
border-color: #77a7ee;
box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.1);
}
.share-password-field {
padding: 12px;
border-radius: 10px;
background: #f7f9fc;
}
.share-switch-row {
min-height: 52px;
padding: 0 2px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 18px;
cursor: pointer;
}
.share-switch-row > span {
min-width: 0;
}
.share-switch-row strong,
.share-switch-row small {
display: block;
}
.share-switch-row strong {
color: #344054;
font-size: 12px;
font-weight: 650;
}
.share-switch-row small {
margin-top: 3px;
color: #8a96a8;
font-size: 10px;
line-height: 1.45;
}
.share-switch-row input[type="checkbox"] {
width: 34px;
height: 20px;
flex: 0 0 34px;
position: relative;
margin: 0;
padding: 0;
appearance: none;
border: 1px solid #c7d1df;
border-radius: 999px;
background: #dfe5ed;
cursor: pointer;
transition: border-color 140ms ease, background-color 140ms ease;
}
.share-switch-row input[type="checkbox"]::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(35, 52, 76, 0.28);
transition: transform 140ms ease;
}
.share-switch-row input[type="checkbox"]:checked {
border-color: #1f6feb;
background: #1f6feb;
}
.share-switch-row input[type="checkbox"]:checked::after {
transform: translateX(14px);
}
.share-switch-row input[type="checkbox"]:focus-visible {
outline: 2px solid rgba(31, 111, 235, 0.42);
outline-offset: 2px;
}
.share-switch-row.advanced-toggle {
min-height: 44px;
}
.share-switch-row.compact {
min-height: 44px;
padding: 0;
}
.share-advanced-fields {
display: grid;
gap: 13px;
padding-top: 13px;
border-top: 1px solid #e6ebf2;
}
.share-dialog-error {
flex: 0 0 auto;
margin: 10px 22px 0;
padding: 9px 11px;
border: 1px solid #f0caca;
border-radius: 9px;
color: #b54747;
background: #fff7f7;
font-size: 11px;
line-height: 1.5;
}
.share-dialog-actions {
flex: 0 0 auto;
min-height: 70px;
padding: 15px 22px 17px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 9px;
border-top: 1px solid #e8edf4;
background: #fff;
}
.share-dialog-actions .solid-btn,
.share-dialog-actions .action-btn {
min-width: 92px;
}
.share-result-summary {
flex: 0 0 auto;
margin: 20px 22px 0;
padding: 15px;
display: flex;
align-items: center;
gap: 12px;
border: 1px solid #bfe5d2;
border-radius: 12px;
background: #f1fbf6;
}
.share-result-summary.partial {
border-color: #ead8b8;
background: #fffaf0;
}
.share-result-summary > span {
width: 40px;
height: 40px;
flex: 0 0 40px;
display: grid;
place-items: center;
border-radius: 50%;
color: #fff;
background: #27a66b;
}
.share-result-summary.partial > span {
background: #d5902e;
}
.share-result-summary strong,
.share-result-summary small {
display: block;
}
.share-result-summary strong {
color: #253247;
font-size: 14px;
}
.share-result-summary small {
margin-top: 4px;
color: #667085;
font-size: 11px;
}
.share-failure-list {
flex: 0 0 auto;
margin: 12px 22px 0;
max-height: 112px;
padding: 8px 11px;
overflow: auto;
display: grid;
gap: 7px;
border: 1px solid #f0caca;
border-radius: 10px;
color: #8f3e3e;
background: #fff8f8;
font-size: 11px;
}
.share-failure-list > div {
display: grid;
grid-template-columns: minmax(100px, 0.45fr) minmax(0, 1fr);
gap: 10px;
}
.share-failure-list span {
overflow-wrap: anywhere;
}
.share-created-list {
min-height: 0;
margin: 12px 22px 0;
padding-right: 3px;
overflow: auto;
display: grid;
gap: 10px;
scrollbar-width: thin;
}
.share-created-item {
padding: 14px;
display: grid;
gap: 9px;
border: 1px solid #e0e7f0;
border-radius: 12px;
background: #fbfcfe;
}
.share-created-head {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.share-created-head strong {
min-width: 0;
overflow: hidden;
color: #253247;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-result-badge {
flex: 0 0 auto;
padding: 3px 7px;
border-radius: 999px;
color: #2568c6;
background: #eaf2ff;
font-size: 9px;
font-weight: 700;
}
.share-result-badge.reused {
color: #9a6617;
background: #fff1d6;
}
.share-created-url {
overflow: hidden;
color: #1f6feb;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-created-meta {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.share-created-meta span {
padding: 3px 7px;
border: 1px solid #dde5ef;
border-radius: 999px;
color: #667085;
background: #fff;
font-size: 9px;
}
.share-reused-note {
padding: 8px 9px;
border-radius: 8px;
color: #946319;
background: #fff7e6;
font-size: 10px;
line-height: 1.5;
}
.share-created-actions {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.share-created-actions .action-btn {
height: 32px;
padding-inline: 10px;
font-size: 10px;
}
.share-dialog-actions.result-actions {
margin-top: 16px;
}
.drop-overlay {
border: 2px dashed rgba(31, 111, 235, 0.62);
border-radius: 10px;
background: rgba(235, 244, 255, 0.9);
}
.drop-overlay-card {
border: 1px solid #c6daf8;
border-radius: 10px;
background: #fff;
box-shadow: 0 10px 24px rgba(55, 96, 153, 0.12);
}
.progress {
height: 7px;
border: 0;
background: #e8edf4;
}
.bar {
background: #1f6feb;
}
.login-shell {
height: 100%;
padding: clamp(24px, 5vw, 72px);
display: grid;
grid-template-columns: minmax(0, 1.12fr) minmax(340px, 0.72fr);
gap: clamp(24px, 4vw, 56px);
align-items: center;
background: #f4f7fc;
}
.login-brand,
.login-panel {
border: 1px solid #e0e6ef;
border-radius: 18px;
background: #fff;
box-shadow: 0 18px 45px rgba(48, 77, 119, 0.08);
}
.login-brand {
min-height: 470px;
padding: clamp(32px, 5vw, 64px);
justify-content: center;
}
.login-brand-mark {
display: flex;
align-items: center;
gap: 10px;
color: #1f6feb;
font-size: 13px;
font-weight: 650;
}
.login-brand-mark img {
width: 42px;
height: 42px;
object-fit: cover;
border-radius: 50%;
}
.brand-chip {
display: none;
}
.login-brand h1 {
max-width: 560px;
margin: 14px 0 0;
color: #172033;
font-size: clamp(30px, 4vw, 48px);
font-weight: 720;
letter-spacing: -0.04em;
line-height: 1.16;
}
.login-brand p {
max-width: 540px;
color: #68778d;
font-size: 14px;
line-height: 1.7;
}
.brand-grid {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.brand-card {
min-height: 82px;
padding: 16px;
border: 1px solid #e5eaf1;
border-radius: 10px;
background: #f8fafc;
}
.login-panel {
padding: 34px;
}
.login-panel h2 {
color: #172033;
font-size: 22px;
font-weight: 680;
}
.login-panel input[type="text"],
.login-panel input[type="password"] {
height: 44px;
border-radius: 8px;
}
.login-panel .primary-btn {
width: 100%;
height: 44px;
margin-top: 4px;
}
@media (max-width: 1260px) {
.work-shell {
grid-template-columns: 204px minmax(0, 1fr);
}
.top-tools {
padding-inline: 22px;
display: grid;
}
.topbar-main {
grid-template-columns: minmax(160px, 1fr) minmax(260px, 380px) minmax(40px, 0.45fr);
}
.main-grid {
grid-template-columns: minmax(0, 1fr) 260px;
}
.content-panel {
padding-inline: 22px;
}
.file-table-head,
.file-row {
grid-template-columns: 34px minmax(220px, 1fr) 100px 148px 34px;
column-gap: 10px;
}
}
@media (max-width: 1160px) {
.main-grid,
.main-grid.focus-content {
grid-template-columns: minmax(0, 1fr);
}
.detail-panel,
.main-grid.focus-content .detail-panel {
display: none;
}
.compact-select {
display: none;
}
}
@media (max-height: 760px) {
.top-tools {
padding-top: 13px;
padding-bottom: 10px;
gap: 8px;
}
.topbar-main {
min-height: 40px;
}
.file-row {
min-height: 58px;
}
.file-table-footer {
min-height: 34px;
flex-basis: 34px;
}
.share-dialog-mask {
padding: 12px;
}
.share-create-card {
max-height: calc(100dvh - 24px);
}
.share-dialog-header {
min-height: 64px;
padding-block: 13px;
}
.share-target-card {
margin-top: 12px;
}
.share-settings-scroll {
padding-top: 12px;
}
}
@media (max-height: 900px) {
.file-detail-content {
padding-top: 18px;
}
.detail-file-icon {
width: 72px;
height: 72px;
border-radius: 14px;
}
.file-detail-content h4 {
margin-top: 12px;
}
.detail-list {
margin-top: 18px;
}
.detail-list > div {
padding: 8px 0;
}
.detail-extra-title,
.detail-description {
padding: 9px 0;
}
.detail-tags {
padding-bottom: 9px;
}
.detail-actions {
padding-top: 10px;
}
}
@media (max-width: 720px) {
.share-dialog-mask {
padding: 10px;
}
.share-create-card {
max-height: calc(100dvh - 20px);
border-radius: 14px;
}
.share-dialog-header,
.share-dialog-actions {
padding-inline: 16px;
}
.share-target-card,
.share-result-summary,
.share-failure-list,
.share-created-list {
margin-inline: 16px;
}
.share-settings-scroll {
padding-inline: 16px;
}
.share-field-grid,
.share-time-grid {
grid-template-columns: 1fr;
}
.share-dialog-actions .solid-btn,
.share-dialog-actions .action-btn {
flex: 1;
}
.share-failure-list > div {
grid-template-columns: 1fr;
gap: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
</style>