feat: add online device management and desktop settings integration
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user