feat: add online device management and desktop settings integration

This commit is contained in:
2026-02-19 17:34:41 +08:00
parent 365ada1a4a
commit 19f53875c9
7 changed files with 1070 additions and 48 deletions

View File

@@ -8,7 +8,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview";
type NavKey = "files" | "transfers" | "shares" | "sync" | "updates";
type NavKey = "files" | "transfers" | "shares" | "sync" | "settings";
type FileItem = {
name: string;
@@ -35,6 +35,19 @@ type ShareItem = {
storage_type?: string;
};
type OnlineDeviceItem = {
session_id: string;
client_type?: string;
device_name?: string;
platform?: string;
ip_address?: string;
last_active_at?: string;
created_at?: string;
expires_at?: string;
is_current?: boolean;
is_local?: boolean;
};
type BridgeResponse = {
ok: boolean;
status: number;
@@ -93,7 +106,6 @@ const loginForm = reactive({
username: "",
password: "",
captcha: "",
remember: true,
});
const loginState = reactive({
@@ -151,6 +163,13 @@ const updateState = reactive({
lastCheckedAt: "",
message: "",
});
const onlineDevices = reactive({
loading: false,
kickingSessionId: "",
items: [] as OnlineDeviceItem[],
message: "",
lastLoadedAt: "",
});
const updateRuntime = reactive({
downloading: false,
installing: false,
@@ -178,6 +197,11 @@ const shareDeleteDialog = reactive({
loading: false,
share: null as ShareItem | null,
});
const fileDeleteDialog = reactive({
visible: false,
loading: false,
file: null as FileItem | null,
});
const dropState = reactive({
active: false,
uploading: false,
@@ -214,7 +238,7 @@ const navItems = computed(() => [
{ key: "transfers" as const, label: "传输列表", hint: `${transferTasks.value.length} 个任务` },
{ key: "shares" as const, label: "我的分享", hint: `${shares.value.length}` },
{ key: "sync" as const, label: "同步盘", hint: syncState.localDir ? "已配置" : "未配置" },
{ key: "updates" as const, label: "版本更新", hint: updateState.available ? "新版本" : "新" },
{ key: "settings" as const, label: "设置", hint: updateState.available ? "发现新版本" : "系统与更新" },
]);
const sortedShares = computed(() => {
@@ -305,7 +329,7 @@ const toolbarCrumbs = computed(() => {
transfers: "传输列表",
shares: "我的分享",
sync: "同步盘",
updates: "版本更新",
settings: "设置",
};
return [{ label: "工作台", path: "/" }, { label: map[nav.value], path: "" }];
});
@@ -858,7 +882,7 @@ async function confirmUpdateFromPrompt() {
updatePrompt.loading = true;
updatePrompt.visible = false;
try {
nav.value = "updates";
nav.value = "settings";
await installLatestUpdate();
} finally {
updatePrompt.loading = false;
@@ -934,6 +958,61 @@ async function installLatestUpdate(): Promise<boolean> {
}
}
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 "网页端";
}
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;
}
async function kickOnlineDevice(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;
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({
@@ -1254,6 +1333,24 @@ function closeDeleteShareDialog(force = 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,
@@ -1285,6 +1382,25 @@ async function confirmDeleteShare() {
}
}
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) {
@@ -1311,6 +1427,7 @@ async function restoreSession() {
rebuildSyncScheduler();
await loadFiles("/");
await loadShares(true);
void loadOnlineDevices(true);
await checkUpdateAfterLogin();
return true;
}
@@ -1348,13 +1465,13 @@ async function tryAutoLoginFromSavedState() {
user.value = loginResponse.data.user || null;
nav.value = "files";
loginForm.password = savedPassword;
loginForm.remember = true;
loadSyncConfig();
rebuildSyncScheduler();
await loadFiles("/");
if (!user.value) {
await loadProfile();
}
void loadOnlineDevices(true);
await checkUpdateAfterLogin();
showToast("已恢复登录状态", "success");
return true;
@@ -1427,15 +1544,12 @@ async function handleLogin() {
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 invokeBridge("api_save_login_state", {
baseUrl: appConfig.baseUrl,
username: loginForm.username.trim(),
password: loginForm.password,
});
void loadOnlineDevices(true);
await checkUpdateAfterLogin();
return;
}
@@ -1472,6 +1586,14 @@ async function handleLogout() {
updateRuntime.downloading = false;
updateRuntime.installing = false;
updatePrompt.visible = false;
fileDeleteDialog.visible = false;
fileDeleteDialog.loading = false;
fileDeleteDialog.file = null;
onlineDevices.loading = false;
onlineDevices.kickingSessionId = "";
onlineDevices.items = [];
onlineDevices.message = "";
onlineDevices.lastLoadedAt = "";
hasCheckedUpdateAfterAuth = false;
showToast("已退出客户端", "info");
}
@@ -1519,11 +1641,11 @@ async function deleteSelected(target?: FileItem | null, silent = false) {
const current = target || selectedFile.value;
if (!current) {
if (!silent) showToast("请先选中文件或文件夹", "info");
return;
return false;
}
if (!silent) {
const confirmed = window.confirm(`确认删除「${current.displayName || current.name}」吗?`);
if (!confirmed) return;
requestDeleteFile(current);
return false;
}
const response = await invokeBridge("api_delete_file", {
@@ -1532,11 +1654,7 @@ async function deleteSelected(target?: FileItem | null, silent = false) {
fileName: current.name,
});
if (response.ok && response.data?.success) {
if (!silent) {
showToast("删除成功", "success");
await loadFiles(pathState.currentPath);
}
return;
return true;
}
if (!silent) {
showToast(response.data?.message || "删除失败", "error");
@@ -1965,8 +2083,9 @@ watch(nav, async (next) => {
await loadShares();
return;
}
if (next === "updates" && authenticated.value) {
if (next === "settings" && authenticated.value) {
await checkClientUpdate(false);
await loadOnlineDevices(true);
}
});
@@ -2054,10 +2173,6 @@ 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="当前服务要求验证码" />
@@ -2155,13 +2270,16 @@ onBeforeUnmount(() => {
</button>
<button class="action-btn" @click="clearSyncSnapshot">重建索引</button>
</template>
<template v-else-if="nav === 'updates'">
<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>
@@ -2331,10 +2449,10 @@ onBeforeUnmount(() => {
</div>
</template>
<template v-else-if="nav === 'updates'">
<template v-else-if="nav === 'settings'">
<div class="panel-head">
<h3>版本更新</h3>
<span>支持检查新版本并一键跳转下载升级包</span>
<h3>设置</h3>
<span>版本更新与在线设备管理</span>
</div>
<div class="update-layout">
@@ -2367,6 +2485,41 @@ onBeforeUnmount(() => {
<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"
@click="kickOnlineDevice(item)"
>
{{ onlineDevices.kickingSessionId === item.session_id ? "处理中..." : (item.is_current || item.is_local ? "下线本机" : "踢下线") }}
</button>
</div>
</div>
</div>
</div>
</template>
</section>
@@ -2435,7 +2588,7 @@ onBeforeUnmount(() => {
</div>
</template>
<template v-else-if="nav === 'updates'">
<template v-else-if="nav === 'settings'">
<div class="stat-grid">
<div>
<strong>v{{ updateState.currentVersion }}</strong>
@@ -2455,9 +2608,9 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="selected-info">
<h4>升级说明</h4>
<p>点击立即更新会下载并尝试启动安装包</p>
<p>升级后建议重启客户端确保版本信息刷新</p>
<h4>设备与升级</h4>
<p>当前在线设备{{ onlineDevices.items.length }} </p>
<p>更新下载和静默安装状态会显示在右下角状态卡</p>
</div>
</template>
@@ -2520,6 +2673,22 @@ onBeforeUnmount(() => {
</div>
</div>
<div v-if="fileDeleteDialog.visible" class="confirm-mask" @click="closeDeleteFileDialog()">
<div class="confirm-card" @click.stop>
<h4>确认删除文件</h4>
<p>
确认删除 <strong>{{ fileDeleteDialog.file?.displayName || fileDeleteDialog.file?.name || "-" }}</strong>
删除后将无法恢复
</p>
<div class="confirm-actions">
<button class="action-btn" :disabled="fileDeleteDialog.loading" @click="closeDeleteFileDialog()">取消</button>
<button class="action-btn danger" :disabled="fileDeleteDialog.loading" @click="confirmDeleteFile()">
{{ fileDeleteDialog.loading ? "删除中..." : "确定删除" }}
</button>
</div>
</div>
</div>
<div v-if="updatePrompt.visible" class="confirm-mask" @click="dismissUpdatePrompt()">
<div class="confirm-card" @click.stop>
<h4>发现新版本 v{{ updateState.latestVersion || "-" }}</h4>
@@ -3341,6 +3510,97 @@ select:focus {
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;
@@ -3795,6 +4055,10 @@ select:focus {
grid-template-columns: 1fr;
}
.settings-device-item {
grid-template-columns: 1fr;
}
.share-actions {
justify-content: flex-start;
}