fix: unify client confirmations and inline rename UX

This commit is contained in:
2026-02-19 19:36:52 +08:00
parent d604b8dc7b
commit 5082a5ed04
2 changed files with 283 additions and 26 deletions

View File

@@ -172,6 +172,14 @@ function compareLooseVersion(left, right) {
return 0;
}
function normalizeReleaseNotes(rawValue) {
return String(rawValue || '')
.replace(/\\r\\n/g, '\n')
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\n')
.trim();
}
function getDesktopUpdateConfig() {
const latestVersion = normalizeVersion(
SettingsDB.get('desktop_latest_version') || DEFAULT_DESKTOP_VERSION,
@@ -183,11 +191,11 @@ function getDesktopUpdateConfig() {
DEFAULT_DESKTOP_INSTALLER_URL ||
''
).trim();
const releaseNotes = String(
const releaseNotes = normalizeReleaseNotes(
SettingsDB.get('desktop_release_notes') ||
DEFAULT_DESKTOP_RELEASE_NOTES ||
''
).trim();
);
const mandatory = SettingsDB.get('desktop_force_update') === 'true';
return {
latestVersion,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
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";
@@ -202,6 +202,22 @@ const fileDeleteDialog = reactive({
loading: false,
file: null as FileItem | null,
});
const operationConfirmDialog = reactive({
visible: false,
loading: false,
mode: "" as "" | "kick-device" | "batch-delete",
title: "",
message: "",
confirmText: "确定",
sessionId: "",
batchItems: [] as FileItem[],
});
const inlineRename = reactive({
active: false,
originalName: "",
value: "",
saving: false,
});
const dropState = reactive({
active: false,
uploading: false,
@@ -803,6 +819,14 @@ async function initClientVersion() {
}
}
function normalizeReleaseNotesText(raw: string | undefined) {
return String(raw || "")
.replace(/\\r\\n/g, "\n")
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\n")
.trim();
}
async function checkClientUpdate(showResultToast = true): Promise<boolean> {
if (updateState.checking) {
return false;
@@ -823,7 +847,7 @@ async function checkClientUpdate(showResultToast = true): Promise<boolean> {
updateState.latestVersion = String(response.data.latestVersion || updateState.currentVersion);
updateState.available = Boolean(response.data.updateAvailable);
updateState.downloadUrl = String(response.data.downloadUrl || "");
updateState.releaseNotes = String(response.data.releaseNotes || "");
updateState.releaseNotes = normalizeReleaseNotesText(String(response.data.releaseNotes || ""));
updateState.mandatory = Boolean(response.data.mandatory);
updateState.message = String(response.data.message || "");
if (showResultToast) {
@@ -966,6 +990,36 @@ function formatOnlineDeviceType(value: string | undefined) {
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;
@@ -986,13 +1040,22 @@ async function loadOnlineDevices(silent = false) {
onlineDevices.loading = false;
}
async function kickOnlineDevice(item: OnlineDeviceItem) {
function requestKickOnlineDevice(item: OnlineDeviceItem) {
const sessionId = String(item?.session_id || "").trim();
if (!sessionId || onlineDevices.kickingSessionId) return;
const tip = item?.is_current ? "确定要下线当前设备吗?下线后需要重新登录。" : "确定要强制该设备下线吗?";
const confirmed = window.confirm(tip);
if (!confirmed) 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,
@@ -1213,6 +1276,7 @@ async function loadProfile() {
}
async function loadFiles(targetPath = pathState.currentPath) {
cancelInlineRename(true);
pathState.loading = true;
pathState.error = "";
const normalizedPath = normalizePath(targetPath);
@@ -1589,6 +1653,8 @@ async function handleLogout() {
fileDeleteDialog.visible = false;
fileDeleteDialog.loading = false;
fileDeleteDialog.file = null;
closeOperationConfirmDialog(true);
cancelInlineRename(true);
onlineDevices.loading = false;
onlineDevices.kickingSessionId = "";
onlineDevices.items = [];
@@ -1615,28 +1681,80 @@ async function createFolder() {
showToast(response.data?.message || "创建文件夹失败", "error");
}
async function renameSelected(target?: FileItem | null) {
function isInlineRenaming(item: FileItem | null | undefined) {
if (!item) return false;
return inlineRename.active && inlineRename.originalName === item.name;
}
function focusInlineRenameInput() {
nextTick(() => {
const input = document.querySelector<HTMLInputElement>(".inline-rename-input[data-renaming='1']");
if (!input) return;
input.focus();
input.select();
});
}
function cancelInlineRename(force = false) {
if (inlineRename.saving && !force) return;
inlineRename.active = false;
inlineRename.originalName = "";
inlineRename.value = "";
inlineRename.saving = false;
}
function startInlineRename(target?: FileItem | null) {
const current = target || selectedFile.value;
if (!current) {
showToast("请先选中文件或文件夹", "info");
return;
}
const nextName = window.prompt("请输入新的名称", current.name);
if (!nextName || !nextName.trim() || nextName.trim() === current.name) return;
selectedFileName.value = current.name;
inlineRename.active = true;
inlineRename.originalName = current.name;
inlineRename.value = current.displayName || current.name;
inlineRename.saving = false;
focusInlineRenameInput();
}
async function submitInlineRename(target?: FileItem | null) {
const current = target || files.value.find((item) => item.name === inlineRename.originalName) || null;
if (!current || !inlineRename.active || inlineRename.saving) return;
const nextName = String(inlineRename.value || "").trim();
if (!nextName) {
cancelInlineRename(true);
return;
}
if (nextName === current.name) {
cancelInlineRename(true);
return;
}
inlineRename.saving = true;
const response = await invokeBridge("api_rename_file", {
baseUrl: appConfig.baseUrl,
path: getItemParentPath(current),
oldName: current.name,
newName: nextName.trim(),
newName: nextName,
});
inlineRename.saving = false;
if (response.ok && response.data?.success) {
showToast("重命名成功", "success");
cancelInlineRename(true);
await loadFiles(pathState.currentPath);
selectedFileName.value = nextName;
return;
}
showToast(response.data?.message || "重命名失败", "error");
}
async function renameSelected(target?: FileItem | null) {
startInlineRename(target);
}
async function deleteSelected(target?: FileItem | null, silent = false) {
const current = target || selectedFile.value;
if (!current) {
@@ -1718,8 +1836,19 @@ function selectFile(item: FileItem) {
selectedFileName.value = item.name;
}
function handleFileCardClick(item: FileItem) {
if (isInlineRenaming(item)) return;
selectFile(item);
}
async function handleFileCardDoubleClick(item: FileItem) {
if (isInlineRenaming(item)) return;
await openItem(item);
}
async function openItem(item: FileItem) {
if (batchMode.value) return;
if (isInlineRenaming(item)) return;
selectFile(item);
if (item.isDirectory || item.type === "directory") {
const nextPath = buildItemPath(item);
@@ -1743,6 +1872,9 @@ function closeContextMenu() {
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;
@@ -1844,12 +1976,18 @@ async function batchDeleteSelected() {
showToast("请先勾选批量文件", "info");
return;
}
const confirmed = window.confirm(`确认批量删除 ${batchSelectedItems.value.length} 个项目吗?`);
if (!confirmed) 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;
const items = [...batchSelectedItems.value];
for (const item of items) {
try {
await deleteSelected(item, true);
@@ -1865,6 +2003,31 @@ async function batchDeleteSelected() {
showToast(`批量删除完成:成功 ${success}${failedText}`, failed > 0 ? "info" : "success");
}
async function confirmOperationDialog() {
if (operationConfirmDialog.loading) return;
operationConfirmDialog.loading = true;
try {
if (operationConfirmDialog.mode === "kick-device") {
const sessionId = operationConfirmDialog.sessionId;
if (sessionId) {
await kickOnlineDeviceBySessionId(sessionId);
}
closeOperationConfirmDialog(true);
return;
}
if (operationConfirmDialog.mode === "batch-delete") {
await runBatchDelete([...operationConfirmDialog.batchItems]);
closeOperationConfirmDialog(true);
return;
}
closeOperationConfirmDialog(true);
} catch {
operationConfirmDialog.loading = false;
}
}
async function batchShareSelected() {
if (!batchMode.value || batchSelectedItems.value.length === 0) {
showToast("请先勾选批量文件", "info");
@@ -1914,9 +2077,31 @@ function handleGlobalClick() {
}
function handleGlobalKey(event: KeyboardEvent) {
if (event.key === "Escape" && contextMenu.visible) {
if (event.key === "Escape") {
if (operationConfirmDialog.visible) {
closeOperationConfirmDialog();
return;
}
if (inlineRename.active) {
cancelInlineRename();
return;
}
if (contextMenu.visible) {
closeContextMenu();
}
return;
}
if (
event.key === "F2"
&& authenticated.value
&& nav.value === "files"
&& !batchMode.value
&& !inlineRename.active
) {
event.preventDefault();
startInlineRename();
}
}
function handleGlobalContextMenu(event: MouseEvent) {
@@ -2079,6 +2264,9 @@ async function registerNativeUploadProgressListener() {
}
watch(nav, async (next) => {
if (next !== "files" && inlineRename.active) {
cancelInlineRename(true);
}
if (next === "shares" && authenticated.value) {
await loadShares();
return;
@@ -2297,24 +2485,44 @@ onBeforeUnmount(() => {
<div v-else-if="pathState.error" class="empty-tip error">{{ pathState.error }}</div>
<div v-else-if="filteredFiles.length === 0" class="empty-tip">当前目录暂无文件</div>
<div v-else class="icon-grid">
<button
<div
v-for="item in filteredFiles"
:key="item.name"
type="button"
class="file-card"
:class="{ selected: selectedFileName === item.name, batchSelected: isBatchSelected(item.name) }"
@click="selectFile(item)"
@dblclick="openItem(item)"
:class="{ selected: selectedFileName === item.name, batchSelected: isBatchSelected(item.name), renaming: isInlineRenaming(item) }"
role="button"
tabindex="0"
@click="handleFileCardClick(item)"
@dblclick="handleFileCardDoubleClick(item)"
@keydown.enter.prevent="handleFileCardDoubleClick(item)"
@keydown.space.prevent="handleFileCardClick(item)"
@contextmenu.prevent="openContextMenu($event, item)"
>
<div v-if="batchMode" class="batch-check" :class="{ active: isBatchSelected(item.name) }">
{{ isBatchSelected(item.name) ? "" : "" }}
</div>
<div class="file-icon-glyph">{{ fileIcon(item) }}</div>
<div class="file-name" :title="item.displayName || item.name">{{ item.displayName || item.name }}</div>
<div class="file-name" :title="item.displayName || item.name">
<template v-if="isInlineRenaming(item)">
<input
v-model="inlineRename.value"
class="inline-rename-input"
data-renaming="1"
:disabled="inlineRename.saving"
@click.stop
@dblclick.stop
@keydown.enter.prevent="submitInlineRename(item)"
@keydown.esc.prevent="cancelInlineRename()"
@blur="cancelInlineRename()"
/>
</template>
<template v-else>
{{ item.displayName || item.name }}
</template>
</div>
<div class="file-meta">{{ fileTypeLabel(item) }} · {{ item.isDirectory ? "-" : (item.sizeFormatted || formatBytes(item.size)) }}</div>
<div class="file-time">{{ formatDate(item.modifiedAt) }}</div>
</button>
</div>
</div>
<div v-if="dropState.active" class="drop-overlay">
<div class="drop-overlay-card">
@@ -2512,8 +2720,8 @@ onBeforeUnmount(() => {
</div>
<button
class="action-btn danger"
:disabled="onlineDevices.kickingSessionId === item.session_id"
@click="kickOnlineDevice(item)"
:disabled="onlineDevices.kickingSessionId === item.session_id || operationConfirmDialog.loading"
@click="requestKickOnlineDevice(item)"
>
{{ onlineDevices.kickingSessionId === item.session_id ? "处理中..." : (item.is_current || item.is_local ? "下线本机" : "踢下线") }}
</button>
@@ -2689,6 +2897,19 @@ onBeforeUnmount(() => {
</div>
</div>
<div v-if="operationConfirmDialog.visible" class="confirm-mask" @click="closeOperationConfirmDialog()">
<div class="confirm-card" @click.stop>
<h4>{{ operationConfirmDialog.title || "确认操作" }}</h4>
<p>{{ operationConfirmDialog.message || "确认继续执行该操作吗?" }}</p>
<div class="confirm-actions">
<button class="action-btn" :disabled="operationConfirmDialog.loading" @click="closeOperationConfirmDialog()">取消</button>
<button class="action-btn danger" :disabled="operationConfirmDialog.loading" @click="confirmOperationDialog()">
{{ operationConfirmDialog.loading ? "处理中..." : (operationConfirmDialog.confirmText || "确定") }}
</button>
</div>
</div>
</div>
<div v-if="updatePrompt.visible" class="confirm-mask" @click="dismissUpdatePrompt()">
<div class="confirm-card" @click.stop>
<h4>发现新版本 v{{ updateState.latestVersion || "-" }}</h4>
@@ -3265,6 +3486,11 @@ select:focus {
position: relative;
}
.file-card:focus-visible {
outline: 2px solid #79a8f0;
outline-offset: 1px;
}
.file-card:hover {
border-color: #c6d7ee;
box-shadow: 0 6px 12px rgba(34, 72, 116, 0.08);
@@ -3281,6 +3507,11 @@ select:focus {
box-shadow: 0 0 0 2px rgba(63, 132, 236, 0.14);
}
.file-card.renaming {
border-color: #7baaf8;
box-shadow: 0 0 0 2px rgba(29, 111, 255, 0.14);
}
.batch-check {
position: absolute;
right: 8px;
@@ -3328,6 +3559,24 @@ select:focus {
min-height: 34px;
}
.inline-rename-input {
width: 100%;
height: 28px;
border: 1px solid #7baaf8;
border-radius: 8px;
background: #fff;
padding: 0 8px;
font-size: 13px;
font-weight: 600;
color: #1f2b3a;
}
.inline-rename-input:focus {
outline: 0;
border-color: #3f84ec;
box-shadow: 0 0 0 2px rgba(63, 132, 236, 0.16);
}
.file-meta,
.file-time {
font-size: 11px;