feat: add online device management and desktop settings integration
This commit is contained in:
@@ -113,6 +113,53 @@ fn join_api_url(base_url: &str, path: &str) -> String {
|
||||
format!("{}{}", normalize_base_url(base_url), path)
|
||||
}
|
||||
|
||||
fn sanitize_device_id_component(raw: &str) -> String {
|
||||
let mut output = String::new();
|
||||
let mut last_is_dash = false;
|
||||
for ch in raw.chars() {
|
||||
let normalized = ch.to_ascii_lowercase();
|
||||
if normalized.is_ascii_alphanumeric() {
|
||||
output.push(normalized);
|
||||
last_is_dash = false;
|
||||
} else if !last_is_dash {
|
||||
output.push('-');
|
||||
last_is_dash = true;
|
||||
}
|
||||
}
|
||||
output.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn build_desktop_client_meta() -> (String, String, String) {
|
||||
let os = match env::consts::OS {
|
||||
"windows" => "Windows",
|
||||
"macos" => "macOS",
|
||||
"linux" => "Linux",
|
||||
other => other,
|
||||
};
|
||||
let platform = format!("{}-{}", os, env::consts::ARCH);
|
||||
let host_name = env::var("COMPUTERNAME")
|
||||
.or_else(|_| env::var("HOSTNAME"))
|
||||
.unwrap_or_default();
|
||||
let host_trimmed = host_name.trim();
|
||||
let device_name = if host_trimmed.is_empty() {
|
||||
format!("桌面客户端 · {}", platform)
|
||||
} else {
|
||||
format!("{} · {}", host_trimmed, platform)
|
||||
};
|
||||
let id_seed = if host_trimmed.is_empty() {
|
||||
platform.clone()
|
||||
} else {
|
||||
format!("{}-{}", host_trimmed, platform)
|
||||
};
|
||||
let normalized = sanitize_device_id_component(&id_seed);
|
||||
let device_id = if normalized.is_empty() {
|
||||
"desktop-client".to_string()
|
||||
} else {
|
||||
format!("desktop-{}", normalized)
|
||||
};
|
||||
(platform, device_name, device_id)
|
||||
}
|
||||
|
||||
fn fallback_json(status: StatusCode, text: &str) -> Value {
|
||||
let mut data = Map::new();
|
||||
data.insert("success".to_string(), Value::Bool(status.is_success()));
|
||||
@@ -390,9 +437,14 @@ async fn api_login(
|
||||
password: String,
|
||||
captcha: Option<String>,
|
||||
) -> Result<BridgeResponse, String> {
|
||||
let (platform, device_name, device_id) = build_desktop_client_meta();
|
||||
let mut body = Map::new();
|
||||
body.insert("username".to_string(), Value::String(username));
|
||||
body.insert("password".to_string(), Value::String(password));
|
||||
body.insert("client_type".to_string(), Value::String("desktop".to_string()));
|
||||
body.insert("platform".to_string(), Value::String(platform));
|
||||
body.insert("device_name".to_string(), Value::String(device_name));
|
||||
body.insert("device_id".to_string(), Value::String(device_id));
|
||||
if let Some(value) = captcha {
|
||||
if !value.trim().is_empty() {
|
||||
body.insert("captcha".to_string(), Value::String(value));
|
||||
@@ -426,6 +478,50 @@ async fn api_get_profile(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn api_list_online_devices(
|
||||
state: tauri::State<'_, ApiState>,
|
||||
base_url: String,
|
||||
) -> Result<BridgeResponse, String> {
|
||||
request_with_optional_csrf(
|
||||
&state.client,
|
||||
Method::GET,
|
||||
&base_url,
|
||||
"/api/user/online-devices",
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn api_kick_online_device(
|
||||
state: tauri::State<'_, ApiState>,
|
||||
base_url: String,
|
||||
session_id: String,
|
||||
) -> Result<BridgeResponse, String> {
|
||||
let session = session_id.trim().to_string();
|
||||
if session.is_empty() {
|
||||
return Err("会话标识不能为空".to_string());
|
||||
}
|
||||
if session.len() > 128 {
|
||||
return Err("会话标识长度无效".to_string());
|
||||
}
|
||||
let api_path = format!(
|
||||
"/api/user/online-devices/{}/kick",
|
||||
urlencoding::encode(&session)
|
||||
);
|
||||
request_with_optional_csrf(
|
||||
&state.client,
|
||||
Method::POST,
|
||||
&base_url,
|
||||
&api_path,
|
||||
Some(Value::Object(Map::new())),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn api_save_login_state(
|
||||
base_url: String,
|
||||
@@ -1483,6 +1579,8 @@ pub fn run() {
|
||||
api_load_login_state,
|
||||
api_clear_login_state,
|
||||
api_get_profile,
|
||||
api_list_online_devices,
|
||||
api_kick_online_device,
|
||||
api_list_files,
|
||||
api_logout,
|
||||
api_search_files,
|
||||
|
||||
@@ -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