7805 lines
209 KiB
Vue
7805 lines
209 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||
import { invoke } from "@tauri-apps/api/core";
|
||
import { openPath, openUrl } from "@tauri-apps/plugin-opener";
|
||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||
import { getVersion } from "@tauri-apps/api/app";
|
||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||
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>
|