feat(desktop): remember login in sqlite and streamline update flow
This commit is contained in:
@@ -50,6 +50,14 @@ type NativeDownloadProgressEvent = {
|
||||
done?: boolean;
|
||||
};
|
||||
|
||||
type NativeUploadProgressEvent = {
|
||||
taskId?: string;
|
||||
uploadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
progress?: number;
|
||||
done?: boolean;
|
||||
};
|
||||
|
||||
type LocalSyncFileItem = {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
@@ -85,6 +93,7 @@ const loginForm = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
captcha: "",
|
||||
remember: true,
|
||||
});
|
||||
|
||||
const loginState = reactive({
|
||||
@@ -132,7 +141,7 @@ const syncState = reactive({
|
||||
nextRunAt: "",
|
||||
});
|
||||
const updateState = reactive({
|
||||
currentVersion: "0.1.4",
|
||||
currentVersion: "0.1.5",
|
||||
latestVersion: "",
|
||||
available: false,
|
||||
mandatory: false,
|
||||
@@ -144,6 +153,19 @@ const updateState = reactive({
|
||||
});
|
||||
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,
|
||||
@@ -163,9 +185,22 @@ const dropState = reactive({
|
||||
done: 0,
|
||||
failed: 0,
|
||||
});
|
||||
const uploadRuntime = reactive({
|
||||
active: false,
|
||||
taskId: "",
|
||||
fileName: "",
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
progress: 0,
|
||||
speed: "-",
|
||||
lastMeasureAt: 0,
|
||||
lastMeasureBytes: 0,
|
||||
});
|
||||
let unlistenDragDrop: UnlistenFn | null = null;
|
||||
let unlistenNativeDownloadProgress: UnlistenFn | null = null;
|
||||
let unlistenNativeUploadProgress: UnlistenFn | null = null;
|
||||
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let hasCheckedUpdateAfterAuth = false;
|
||||
|
||||
const toast = reactive({
|
||||
visible: false,
|
||||
@@ -363,6 +398,46 @@ function fileTypeLabel(item: FileItem) {
|
||||
return "文件";
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSecond: number) {
|
||||
if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "-";
|
||||
return `${formatBytes(bytesPerSecond)}/s`;
|
||||
}
|
||||
|
||||
function measureRate(
|
||||
currentBytes: number,
|
||||
state: { lastMeasureAt: number; lastMeasureBytes: number },
|
||||
) {
|
||||
const now = Date.now();
|
||||
if (!state.lastMeasureAt) {
|
||||
state.lastMeasureAt = now;
|
||||
state.lastMeasureBytes = currentBytes;
|
||||
return "-";
|
||||
}
|
||||
const elapsedMs = now - state.lastMeasureAt;
|
||||
if (elapsedMs < 180) {
|
||||
return "-";
|
||||
}
|
||||
const deltaBytes = Math.max(0, currentBytes - state.lastMeasureBytes);
|
||||
state.lastMeasureAt = now;
|
||||
state.lastMeasureBytes = currentBytes;
|
||||
if (deltaBytes <= 0) return "0 B/s";
|
||||
const bytesPerSecond = (deltaBytes * 1000) / elapsedMs;
|
||||
return formatSpeed(bytesPerSecond);
|
||||
}
|
||||
|
||||
function resetUpdateRuntime() {
|
||||
updateRuntime.downloading = false;
|
||||
updateRuntime.installing = false;
|
||||
updateRuntime.taskId = "";
|
||||
updateRuntime.downloadedBytes = 0;
|
||||
updateRuntime.totalBytes = 0;
|
||||
updateRuntime.progress = 0;
|
||||
updateRuntime.speed = "-";
|
||||
updateRuntime.lastMeasureAt = 0;
|
||||
updateRuntime.lastMeasureBytes = 0;
|
||||
updateRuntime.installerPath = "";
|
||||
}
|
||||
|
||||
function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) {
|
||||
const taskId = String(payload?.taskId || "").trim();
|
||||
if (!taskId) return;
|
||||
@@ -378,6 +453,23 @@ function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) {
|
||||
? Math.max(1, Math.min(payload?.done ? 100 : 99.8, calculatedProgress))
|
||||
: NaN;
|
||||
|
||||
if (taskId === updateRuntime.taskId) {
|
||||
updateRuntime.downloadedBytes = downloadedBytes;
|
||||
updateRuntime.totalBytes = Number.isFinite(totalBytes) ? Math.max(0, totalBytes) : 0;
|
||||
updateRuntime.progress = Number.isFinite(boundedProgress)
|
||||
? Number(boundedProgress.toFixed(1))
|
||||
: updateRuntime.progress;
|
||||
const speedText = measureRate(downloadedBytes, updateRuntime);
|
||||
if (speedText !== "-") {
|
||||
updateRuntime.speed = speedText;
|
||||
}
|
||||
if (payload?.done) {
|
||||
updateRuntime.progress = 100;
|
||||
updateRuntime.speed = "-";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const patch: Partial<TransferTask> = {
|
||||
speed: "下载中",
|
||||
status: "downloading",
|
||||
@@ -392,6 +484,45 @@ function applyNativeDownloadProgress(payload: NativeDownloadProgressEvent) {
|
||||
updateTransferTask(taskId, patch);
|
||||
}
|
||||
|
||||
function applyNativeUploadProgress(payload: NativeUploadProgressEvent) {
|
||||
const taskId = String(payload?.taskId || "").trim();
|
||||
if (!taskId) return;
|
||||
|
||||
const uploadedBytes = Number(payload?.uploadedBytes || 0);
|
||||
const totalBytes = Math.max(1, Number(payload?.totalBytes || 0));
|
||||
const eventProgress = Number(payload?.progress);
|
||||
const progressValue = Number.isFinite(eventProgress)
|
||||
? eventProgress
|
||||
: (uploadedBytes / totalBytes) * 100;
|
||||
const boundedProgress = Math.max(1, Math.min(payload?.done ? 100 : 99.8, progressValue));
|
||||
let transferSpeed = "上传中";
|
||||
if (taskId === uploadRuntime.taskId) {
|
||||
const sampledSpeed = measureRate(uploadedBytes, uploadRuntime);
|
||||
if (sampledSpeed !== "-") {
|
||||
transferSpeed = sampledSpeed;
|
||||
}
|
||||
}
|
||||
updateTransferTask(taskId, {
|
||||
status: "uploading",
|
||||
speed: transferSpeed,
|
||||
progress: Number(boundedProgress.toFixed(1)),
|
||||
note: `${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)}`,
|
||||
});
|
||||
|
||||
if (taskId === uploadRuntime.taskId) {
|
||||
uploadRuntime.uploadedBytes = uploadedBytes;
|
||||
uploadRuntime.totalBytes = totalBytes;
|
||||
uploadRuntime.progress = Number(boundedProgress.toFixed(1));
|
||||
if (transferSpeed !== "上传中") {
|
||||
uploadRuntime.speed = transferSpeed;
|
||||
}
|
||||
if (payload?.done) {
|
||||
uploadRuntime.progress = 100;
|
||||
uploadRuntime.speed = "-";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fileIcon(item: FileItem) {
|
||||
if (item.isDirectory || item.type === "directory") return "📁";
|
||||
const name = String(item.name || "").toLowerCase();
|
||||
@@ -690,8 +821,52 @@ async function checkClientUpdate(showResultToast = true): Promise<boolean> {
|
||||
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 = "updates";
|
||||
await installLatestUpdate();
|
||||
} finally {
|
||||
updatePrompt.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function installLatestUpdate(): Promise<boolean> {
|
||||
if (updateRuntime.downloading) {
|
||||
if (updateRuntime.downloading || updateRuntime.installing) {
|
||||
showToast("更新包正在下载,请稍候", "info");
|
||||
return false;
|
||||
}
|
||||
@@ -701,20 +876,13 @@ async function installLatestUpdate(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
resetUpdateRuntime();
|
||||
updateRuntime.downloading = true;
|
||||
const taskId = `UPD-${Date.now()}`;
|
||||
const installerName = `wanwan-cloud-desktop_v${updateState.latestVersion || updateState.currentVersion}.exe`;
|
||||
prependTransferTask({
|
||||
id: taskId,
|
||||
kind: "download",
|
||||
name: installerName,
|
||||
speed: "更新包下载",
|
||||
progress: 2,
|
||||
status: "downloading",
|
||||
note: "正在下载升级安装包",
|
||||
downloadUrl: updateState.downloadUrl,
|
||||
fileName: installerName,
|
||||
});
|
||||
updateRuntime.taskId = taskId;
|
||||
updateRuntime.progress = 1;
|
||||
updateRuntime.speed = "准备下载";
|
||||
|
||||
const response = await invokeBridge("api_native_download", {
|
||||
url: updateState.downloadUrl,
|
||||
@@ -724,25 +892,18 @@ async function installLatestUpdate(): Promise<boolean> {
|
||||
|
||||
try {
|
||||
if (response.ok && response.data?.success) {
|
||||
updateTransferTask(taskId, {
|
||||
speed: "-",
|
||||
progress: 100,
|
||||
status: "done",
|
||||
note: "更新包已下载,准备启动安装",
|
||||
});
|
||||
updateRuntime.downloading = false;
|
||||
updateRuntime.installing = true;
|
||||
updateRuntime.progress = 100;
|
||||
updateRuntime.speed = "-";
|
||||
const savePath = String(response.data?.savePath || "").trim();
|
||||
updateRuntime.installerPath = savePath;
|
||||
if (savePath) {
|
||||
const launchResponse = await invokeBridge("api_launch_installer", {
|
||||
const launchResponse = await invokeBridge("api_silent_install_and_restart", {
|
||||
installerPath: savePath,
|
||||
});
|
||||
if (launchResponse.ok && launchResponse.data?.success) {
|
||||
updateTransferTask(taskId, {
|
||||
speed: "-",
|
||||
progress: 100,
|
||||
status: "done",
|
||||
note: "安装程序已启动,客户端即将退出",
|
||||
});
|
||||
showToast("安装程序已启动,客户端即将退出", "success");
|
||||
showToast("静默安装已启动,完成后会自动重启客户端", "success");
|
||||
setTimeout(() => {
|
||||
void getCurrentWindow().close();
|
||||
}, 400);
|
||||
@@ -756,20 +917,20 @@ async function installLatestUpdate(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
showToast("更新包已下载,请手动运行安装程序", "info");
|
||||
setTimeout(() => {
|
||||
resetUpdateRuntime();
|
||||
}, 1200);
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = String(response.data?.message || "下载更新包失败");
|
||||
updateTransferTask(taskId, {
|
||||
speed: "-",
|
||||
progress: 0,
|
||||
status: "failed",
|
||||
note: message,
|
||||
});
|
||||
resetUpdateRuntime();
|
||||
showToast(message, "error");
|
||||
return false;
|
||||
} finally {
|
||||
updateRuntime.downloading = false;
|
||||
if (!updateRuntime.installing) {
|
||||
updateRuntime.downloading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,12 +950,13 @@ async function chooseSyncDirectory() {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFileWithResume(filePath: string, targetPath: string) {
|
||||
async function uploadFileWithResume(filePath: string, targetPath: string, taskId?: string) {
|
||||
const resumableResponse = await invokeBridge("api_upload_file_resumable", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
filePath,
|
||||
targetPath,
|
||||
chunkSize: 4 * 1024 * 1024,
|
||||
taskId: taskId || null,
|
||||
});
|
||||
|
||||
if (resumableResponse.ok && resumableResponse.data?.success) {
|
||||
@@ -811,6 +973,7 @@ async function uploadFileWithResume(filePath: string, targetPath: string) {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
filePath,
|
||||
targetPath,
|
||||
taskId: taskId || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -918,7 +1081,7 @@ async function runSyncOnce(trigger: "manual" | "auto" = "manual") {
|
||||
});
|
||||
updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 });
|
||||
|
||||
const resp = await uploadFileWithResume(item.path, targetPath);
|
||||
const resp = await uploadFileWithResume(item.path, targetPath, taskId);
|
||||
|
||||
if (resp.ok && resp.data?.success) {
|
||||
syncState.uploadedCount += 1;
|
||||
@@ -1142,12 +1305,59 @@ async function openShareLink(share: ShareItem) {
|
||||
|
||||
async function restoreSession() {
|
||||
const ok = await loadProfile();
|
||||
if (!ok) return;
|
||||
if (!ok) return false;
|
||||
authenticated.value = true;
|
||||
loadSyncConfig();
|
||||
rebuildSyncScheduler();
|
||||
await loadFiles("/");
|
||||
await loadShares(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;
|
||||
loginForm.remember = true;
|
||||
loadSyncConfig();
|
||||
rebuildSyncScheduler();
|
||||
await loadFiles("/");
|
||||
if (!user.value) {
|
||||
await loadProfile();
|
||||
}
|
||||
await checkUpdateAfterLogin();
|
||||
showToast("已恢复登录状态", "success");
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildItemPath(item: FileItem) {
|
||||
@@ -1210,12 +1420,23 @@ async function handleLogin() {
|
||||
user.value = response.data.user || null;
|
||||
nav.value = "files";
|
||||
showToast("登录成功,正在同步文件目录", "success");
|
||||
hasCheckedUpdateAfterAuth = false;
|
||||
loadSyncConfig();
|
||||
rebuildSyncScheduler();
|
||||
await loadFiles("/");
|
||||
if (!user.value) {
|
||||
await loadProfile();
|
||||
}
|
||||
if (loginForm.remember) {
|
||||
await invokeBridge("api_save_login_state", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
username: loginForm.username.trim(),
|
||||
password: loginForm.password,
|
||||
});
|
||||
} else {
|
||||
await invokeBridge("api_clear_login_state", {});
|
||||
}
|
||||
await checkUpdateAfterLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1226,6 +1447,7 @@ async function handleLogin() {
|
||||
|
||||
async function handleLogout() {
|
||||
await invokeBridge("api_logout", { baseUrl: appConfig.baseUrl });
|
||||
await invokeBridge("api_clear_login_state", {});
|
||||
clearSyncScheduler();
|
||||
authenticated.value = false;
|
||||
user.value = null;
|
||||
@@ -1248,6 +1470,9 @@ async function handleLogout() {
|
||||
syncState.lastSummary = "";
|
||||
syncState.nextRunAt = "";
|
||||
updateRuntime.downloading = false;
|
||||
updateRuntime.installing = false;
|
||||
updatePrompt.visible = false;
|
||||
hasCheckedUpdateAfterAuth = false;
|
||||
showToast("已退出客户端", "info");
|
||||
}
|
||||
|
||||
@@ -1454,7 +1679,7 @@ async function retryTransferTask(taskId: string) {
|
||||
}
|
||||
await waitForTransferQueue();
|
||||
updateTransferTask(taskId, { status: "uploading", speed: "重试上传", progress: 10, note: "正在重试" });
|
||||
const response = await uploadFileWithResume(task.filePath, task.targetPath);
|
||||
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") {
|
||||
@@ -1576,6 +1801,14 @@ function handleGlobalKey(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
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 "";
|
||||
@@ -1625,11 +1858,24 @@ async function uploadDroppedFiles(paths: string[]) {
|
||||
});
|
||||
updateTransferTask(taskId, { status: "uploading", speed: "上传中", progress: 10 });
|
||||
|
||||
const response = await uploadFileWithResume(filePath, pathState.currentPath);
|
||||
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,
|
||||
@@ -1639,6 +1885,7 @@ async function uploadDroppedFiles(paths: string[]) {
|
||||
} else {
|
||||
dropState.failed += 1;
|
||||
const message = String(response.data?.message || "上传失败");
|
||||
uploadRuntime.speed = "-";
|
||||
updateTransferTask(taskId, {
|
||||
speed: "-",
|
||||
progress: 0,
|
||||
@@ -1649,6 +1896,10 @@ async function uploadDroppedFiles(paths: string[]) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1699,6 +1950,16 @@ async function registerNativeDownloadProgressListener() {
|
||||
}
|
||||
}
|
||||
|
||||
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 === "shares" && authenticated.value) {
|
||||
await loadShares();
|
||||
@@ -1725,15 +1986,21 @@ watch(
|
||||
onMounted(async () => {
|
||||
window.addEventListener("click", handleGlobalClick);
|
||||
window.addEventListener("keydown", handleGlobalKey);
|
||||
window.addEventListener("contextmenu", handleGlobalContextMenu);
|
||||
await registerDragDropListener();
|
||||
await registerNativeDownloadProgressListener();
|
||||
await registerNativeUploadProgressListener();
|
||||
await initClientVersion();
|
||||
await restoreSession();
|
||||
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();
|
||||
@@ -1743,6 +2010,10 @@ onBeforeUnmount(() => {
|
||||
unlistenNativeDownloadProgress();
|
||||
unlistenNativeDownloadProgress = null;
|
||||
}
|
||||
if (unlistenNativeUploadProgress) {
|
||||
unlistenNativeUploadProgress();
|
||||
unlistenNativeUploadProgress = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1783,6 +2054,10 @@ onBeforeUnmount(() => {
|
||||
密码
|
||||
<input v-model="loginForm.password" type="password" placeholder="请输入密码" @keyup.enter="handleLogin" />
|
||||
</label>
|
||||
<label class="check-line">
|
||||
<input v-model="loginForm.remember" type="checkbox" />
|
||||
<span>记住登录状态(本机 SQLite)</span>
|
||||
</label>
|
||||
<label v-if="loginState.needCaptcha">
|
||||
验证码
|
||||
<input v-model="loginForm.captcha" type="text" placeholder="当前服务要求验证码" />
|
||||
@@ -1891,7 +2166,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-grid">
|
||||
<main class="main-grid" :class="{ 'focus-content': nav === 'files' }">
|
||||
<section class="panel content-panel">
|
||||
<template v-if="nav === 'files'">
|
||||
<div class="panel-head">
|
||||
@@ -1899,7 +2174,7 @@ onBeforeUnmount(() => {
|
||||
<span>{{ pathState.mode === "search" ? "搜索结果" : "当前目录" }} {{ pathState.currentPath }}</span>
|
||||
</div>
|
||||
|
||||
<div class="file-drop-surface" :class="{ active: dropState.active || dropState.uploading }">
|
||||
<div class="file-drop-surface" :class="{ active: dropState.active }">
|
||||
<div v-if="pathState.loading" class="empty-tip">正在加载目录...</div>
|
||||
<div v-else-if="pathState.error" class="empty-tip error">{{ pathState.error }}</div>
|
||||
<div v-else-if="filteredFiles.length === 0" class="empty-tip">当前目录暂无文件</div>
|
||||
@@ -1923,12 +2198,10 @@ onBeforeUnmount(() => {
|
||||
<div class="file-time">{{ formatDate(item.modifiedAt) }}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="dropState.active || dropState.uploading" class="drop-overlay">
|
||||
<div v-if="dropState.active" class="drop-overlay">
|
||||
<div class="drop-overlay-card">
|
||||
<strong v-if="!dropState.uploading">拖拽到此处上传到当前目录</strong>
|
||||
<strong v-else>正在上传 {{ dropState.done + dropState.failed }}/{{ dropState.total }}</strong>
|
||||
<span v-if="!dropState.uploading">仅支持文件,文件夹会自动跳过</span>
|
||||
<span v-else>成功 {{ dropState.done }} 个,失败 {{ dropState.failed }} 个</span>
|
||||
<strong>拖拽到此处上传到当前目录</strong>
|
||||
<span>仅支持文件,文件夹会自动跳过</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2247,6 +2520,51 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="updatePrompt.visible" class="confirm-mask" @click="dismissUpdatePrompt()">
|
||||
<div class="confirm-card" @click.stop>
|
||||
<h4>发现新版本 v{{ updateState.latestVersion || "-" }}</h4>
|
||||
<p>
|
||||
已在登录后检测到可用更新,是否现在进行静默升级?
|
||||
升级完成后将自动重启客户端。
|
||||
</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="action-btn" :disabled="updatePrompt.loading" @click="dismissUpdatePrompt(true)">稍后提醒</button>
|
||||
<button class="action-btn danger" :disabled="updatePrompt.loading" @click="confirmUpdateFromPrompt()">
|
||||
{{ updatePrompt.loading ? "处理中..." : "立即更新" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadRuntime.active || updateRuntime.downloading || updateRuntime.installing" class="status-stack">
|
||||
<div v-if="uploadRuntime.active" class="status-card">
|
||||
<div class="status-head">
|
||||
<strong>上传中</strong>
|
||||
<span>{{ Math.max(0, Math.min(100, Math.round(uploadRuntime.progress))) }}%</span>
|
||||
</div>
|
||||
<div class="status-name" :title="uploadRuntime.fileName">{{ uploadRuntime.fileName || "正在上传文件" }}</div>
|
||||
<div class="progress compact">
|
||||
<div class="bar" :style="{ width: `${uploadRuntime.progress}%` }" />
|
||||
</div>
|
||||
<small>{{ formatBytes(uploadRuntime.uploadedBytes) }} / {{ formatBytes(uploadRuntime.totalBytes) }} · {{ uploadRuntime.speed }}</small>
|
||||
</div>
|
||||
|
||||
<div v-if="updateRuntime.downloading || updateRuntime.installing" class="status-card">
|
||||
<div class="status-head">
|
||||
<strong>{{ updateRuntime.installing ? "安装更新" : "下载更新" }}</strong>
|
||||
<span>{{ Math.max(0, Math.min(100, Math.round(updateRuntime.progress))) }}%</span>
|
||||
</div>
|
||||
<div class="status-name">
|
||||
{{ updateRuntime.installing ? "静默安装中,完成后自动重启" : `v${updateState.latestVersion || "-"}` }}
|
||||
</div>
|
||||
<div class="progress compact">
|
||||
<div class="bar" :style="{ width: `${updateRuntime.progress}%` }" />
|
||||
</div>
|
||||
<small v-if="updateRuntime.downloading">{{ formatBytes(updateRuntime.downloadedBytes) }} / {{ formatBytes(updateRuntime.totalBytes) }} · {{ updateRuntime.speed }}</small>
|
||||
<small v-else>请稍候,应用将自动退出并重启</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="toast.visible" class="toast" :class="toast.type">{{ toast.message }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2268,7 +2586,7 @@ onBeforeUnmount(() => {
|
||||
.desktop-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 18px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
@@ -2701,6 +3019,14 @@ select:focus {
|
||||
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;
|
||||
@@ -2932,6 +3258,10 @@ select:focus {
|
||||
transition: width 0.14s linear;
|
||||
}
|
||||
|
||||
.progress.compact {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.task-percent {
|
||||
justify-self: end;
|
||||
color: #3f5f86;
|
||||
@@ -3253,6 +3583,56 @@ select:focus {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user