feat(桌面与监控): 完善工作台导航和登录活动定位
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖
This commit is contained in:
Cheng Zhou
2026-07-13 16:03:20 +08:00
parent b26ebdda02
commit ab59476d10
50 changed files with 2086 additions and 191 deletions
+60 -5
View File
@@ -174,18 +174,56 @@ const verifyRustBoundary = async () => {
singleInstanceIndex,
dialogIndex > singleInstanceIndex ? dialogIndex : singleInstanceIndex + 600,
);
const restoreWindowStart = libSource.indexOf("fn restore_main_window");
const restoreWindowEnd = libSource.indexOf("fn is_allowed_shortcut_key", restoreWindowStart);
const restoreWindowSource = libSource.slice(restoreWindowStart, restoreWindowEnd);
const restoreWindowTokens = ['get_webview_window("main")', "window.unminimize()", "window.show()", "window.set_focus()"];
assert(restoreWindowStart >= 0, "Desktop runtime must define a shared main-window restore helper.");
assert(
singleInstanceSource.includes("restore_main_window(app)"),
"Single-instance duplicate launch handler must use the shared main-window restore helper.",
);
for (const token of restoreWindowTokens) {
assert(singleInstanceSource.includes(token), `Single-instance duplicate launch handler must call ${token}.`);
assert(restoreWindowSource.includes(token), `Main-window restore helper must call ${token}.`);
}
assert(
singleInstanceSource.indexOf("window.unminimize()") < singleInstanceSource.indexOf("window.show()") &&
singleInstanceSource.indexOf("window.show()") < singleInstanceSource.indexOf("window.set_focus()"),
"Single-instance duplicate launch handler must restore, show, then focus the main window.",
restoreWindowSource.indexOf("window.unminimize()") < restoreWindowSource.indexOf("window.show()") &&
restoreWindowSource.indexOf("window.show()") < restoreWindowSource.indexOf("window.set_focus()"),
"Main-window restore helper must restore, show, then focus the main window.",
);
assert(libSource.includes("RunEvent::Reopen"), "macOS Dock reopen events must restore the main window.");
assert(libSource.includes("WebviewWindowBuilder::from_config"), "Dock reopen must rebuild a main window that was actually closed.");
assert(libSource.includes('find(|config| config.label == "main")'), "Main-window rebuild must use only the configured main window.");
assert(!libSource.includes("WindowEvent::CloseRequested"), "The macOS red close button must keep its native close-window semantics.");
assert(!libSource.includes("api.prevent_close()"), "The macOS red close button must not be converted into a hide action.");
assert(!libSource.includes("window.hide()"), "The macOS red close button must not hide the main window.");
const appMenuIndex = libSource.indexOf('package.name.clone()');
const fileMenuIndex = libSource.indexOf('"文件"');
assert(appMenuIndex >= 0 && appMenuIndex < fileMenuIndex, "macOS application menu must precede the File menu.");
for (const token of [
'Some("关于 CTMS")',
"short_version: Some(String::new())",
"icon: handle.default_window_icon().cloned()",
'"ctms.desktop.preferences"',
"PredefinedMenuItem::services",
"PredefinedMenuItem::hide",
"PredefinedMenuItem::hide_others",
"PredefinedMenuItem::show_all",
"PredefinedMenuItem::quit",
"WINDOW_SUBMENU_ID",
"HELP_SUBMENU_ID",
"TOGGLE_SIDEBAR_COMMAND_ID",
'"显示/隐藏导航栏"',
]) {
assert(libSource.includes(token), `Desktop menu must include ${token}.`);
}
const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
const commands = handlerSource
.split(",")
.map((command) => command.trim())
.filter(Boolean);
const allowedCommands = [
"credentials::credential_get",
"credentials::credential_set",
@@ -195,6 +233,8 @@ const verifyRustBoundary = async () => {
"credentials::login_credential_delete",
"updates::desktop_update_check",
"updates::desktop_update_install",
"desktop_menu_set_shortcuts",
"desktop_window_set_theme",
];
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
const missing = allowedCommands.filter((command) => !commands.includes(command));
@@ -202,6 +242,20 @@ const verifyRustBoundary = async () => {
assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || "<none>"}.`);
};
const verifyDesktopUiNativeSync = async () => {
const menuSource = await readFile(resolve(sourceDir, "runtime/desktopMenu.ts"), "utf8");
const preferencesSource = await readFile(resolve(sourceDir, "runtime/desktopUiPreferences.ts"), "utf8");
const appSource = await readFile(resolve(sourceDir, "App.vue"), "utf8");
assert(menuSource.includes('invoke("desktop_menu_set_shortcuts"'), "Desktop shortcuts must sync through the controlled Tauri command.");
assert(menuSource.includes("DESKTOP_SHORTCUTS_CHANGED_EVENT"), "Native menu shortcuts must track preference changes.");
assert(preferencesSource.includes('"system" | "light" | "dark"'), "Desktop theme must support following the system appearance.");
assert(preferencesSource.includes('invoke("desktop_window_set_theme"'), "Desktop window theme must sync through the controlled Tauri command.");
assert(preferencesSource.includes('matchMedia("(prefers-color-scheme: dark)")'), "System theme changes must update the WebView appearance.");
assert(appSource.includes("initializeDesktopThemePreference()"), "Desktop theme synchronization must initialize at app startup.");
assert(appSource.includes("initializeDesktopMenuShortcutSync()"), "Native menu shortcut synchronization must initialize at app startup.");
};
const verifySourceSafety = async () => {
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx", ".rs"]);
const files = [
@@ -358,6 +412,7 @@ const verifyWorkflowGates = async () => {
await verifyTauriConfig();
await verifyCapabilities();
await verifyRustBoundary();
await verifyDesktopUiNativeSync();
await verifySourceSafety();
await verifyNotificationBoundary();
await verifySessionBoundary();
+290 -31
View File
@@ -1,15 +1,90 @@
mod credentials;
mod updates;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::{Emitter, Manager, Runtime};
use std::collections::HashSet;
use serde::Deserialize;
use tauri::menu::{
AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
};
#[cfg(target_os = "macos")]
use tauri::RunEvent;
use tauri::{Emitter, Manager, Runtime, Theme, WebviewWindow, WebviewWindowBuilder};
const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command";
const VIEW_MENU_ID: &str = "ctms.menu.view";
const NAVIGATION_MENU_ID: &str = "ctms.menu.navigation";
const REFRESH_COMMAND_ID: &str = "ctms.desktop.refresh";
const TOGGLE_SIDEBAR_COMMAND_ID: &str = "ctms.desktop.toggleSidebar";
const BACK_COMMAND_ID: &str = "ctms.desktop.back";
const FORWARD_COMMAND_ID: &str = "ctms.desktop.forward";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DesktopMenuShortcuts {
back: String,
forward: String,
refresh: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
enum DesktopThemePreference {
System,
Light,
Dark,
}
fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
let package = handle.package_info();
let config = handle.config();
let about = AboutMetadata {
name: Some(package.name.clone()),
version: Some(package.version.to_string()),
// macOS falls back to CFBundleVersion when this value is omitted, which
// renders duplicated text such as `Version 0.1.0 (0.1.0)`.
#[cfg(target_os = "macos")]
short_version: Some(String::new()),
copyright: config.bundle.copyright.clone(),
authors: config
.bundle
.publisher
.clone()
.map(|publisher| vec![publisher]),
// Supplying the icon explicitly keeps the native About panel from
// falling back to the generic macOS application/folder icon.
icon: handle.default_window_icon().cloned(),
..Default::default()
};
Menu::with_items(
handle,
&[
#[cfg(target_os = "macos")]
&Submenu::with_items(
handle,
package.name.clone(),
true,
&[
&PredefinedMenuItem::about(handle, Some("关于 CTMS"), Some(about.clone()))?,
&PredefinedMenuItem::separator(handle)?,
&MenuItem::with_id(
handle,
"ctms.desktop.preferences",
"设置…",
true,
Some("CmdOrCtrl+,"),
)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::services(handle, None)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::hide(handle, None)?,
&PredefinedMenuItem::hide_others(handle, None)?,
&PredefinedMenuItem::show_all(handle, None)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::quit(handle, None)?,
],
)?,
&Submenu::with_items(
handle,
"文件",
@@ -22,15 +97,17 @@ fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<
true,
Some("CmdOrCtrl+K"),
)?,
#[cfg(not(target_os = "macos"))]
&MenuItem::with_id(
handle,
"ctms.desktop.serverSettings",
"服务器设置",
"ctms.desktop.preferences",
"设置",
true,
None::<&str>,
Some("CmdOrCtrl+,"),
)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::close_window(handle, None)?,
#[cfg(not(target_os = "macos"))]
&PredefinedMenuItem::quit(handle, None)?,
],
)?,
@@ -48,44 +125,49 @@ fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<
&PredefinedMenuItem::select_all(handle, None)?,
],
)?,
&Submenu::with_items(
&Submenu::with_id_and_items(
handle,
"视图",
VIEW_MENU_ID,
"显示",
true,
&[
&MenuItem::with_id(
handle,
"ctms.desktop.refresh",
REFRESH_COMMAND_ID,
"刷新当前视图",
true,
Some("CmdOrCtrl+R"),
)?,
&MenuItem::with_id(
handle,
TOGGLE_SIDEBAR_COMMAND_ID,
"显示/隐藏导航栏",
true,
None::<&str>,
)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::fullscreen(handle, None)?,
],
)?,
&Submenu::with_items(
&Submenu::with_id_and_items(
handle,
NAVIGATION_MENU_ID,
"导航",
true,
&[
&MenuItem::with_id(handle, BACK_COMMAND_ID, "后退", true, Some("CmdOrCtrl+["))?,
&MenuItem::with_id(
handle,
"ctms.desktop.back",
"返回",
true,
None::<&str>,
)?,
&MenuItem::with_id(
handle,
"ctms.desktop.forward",
FORWARD_COMMAND_ID,
"前进",
true,
None::<&str>,
Some("CmdOrCtrl+]"),
)?,
],
)?,
&Submenu::with_items(
&Submenu::with_id_and_items(
handle,
WINDOW_SUBMENU_ID,
"窗口",
true,
&[
@@ -94,17 +176,20 @@ fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<
&PredefinedMenuItem::close_window(handle, None)?,
],
)?,
&Submenu::with_items(
&Submenu::with_id_and_items(
handle,
HELP_SUBMENU_ID,
"帮助",
true,
&[
#[cfg(not(target_os = "macos"))]
&PredefinedMenuItem::about(handle, Some("关于 CTMS"), Some(about))?,
&MenuItem::with_id(
handle,
"ctms.desktop.preferences",
"桌面偏好",
"ctms.desktop.help",
"CTMS 命令与导航",
true,
Some("CmdOrCtrl+,"),
None::<&str>,
)?,
],
)?,
@@ -118,8 +203,148 @@ fn emit_menu_command<R: Runtime>(app: &tauri::AppHandle<R>, command: &str) {
}
}
fn restore_main_window<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), String> {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
return Ok(());
}
let config = app
.config()
.app
.windows
.iter()
.find(|config| config.label == "main")
.cloned()
.ok_or_else(|| "未找到主窗口配置".to_string())?;
let window = WebviewWindowBuilder::from_config(app, &config)
.map_err(|error| format!("读取主窗口配置失败:{error}"))?
.build()
.map_err(|error| format!("重建主窗口失败:{error}"))?;
let _ = window.show();
let _ = window.set_focus();
Ok(())
}
fn is_allowed_shortcut_key(value: &str) -> bool {
matches!(
value,
"[" | "]"
| ","
| "."
| "/"
| ";"
| "="
| "-"
| "ArrowLeft"
| "ArrowRight"
| "ArrowUp"
| "ArrowDown"
| "Home"
| "End"
| "PageUp"
| "PageDown"
) || (value.len() == 1
&& value
.as_bytes()
.first()
.is_some_and(|value| value.is_ascii_uppercase() || value.is_ascii_digit()))
}
fn normalize_menu_shortcut(value: &str) -> Result<String, String> {
const RESERVED: &[&str] = &[
"Mod+K",
"Mod+,",
"Mod+W",
"Mod+Q",
"Mod+C",
"Mod+V",
"Mod+X",
"Mod+A",
"Mod+Z",
"Mod+Shift+Z",
];
if RESERVED.contains(&value) {
return Err("快捷键与系统或应用保留快捷键冲突".to_string());
}
let parts = value.split('+').collect::<Vec<_>>();
if parts.len() < 2 || parts[0] != "Mod" {
return Err("快捷键必须以 Mod 开头".to_string());
}
let key = parts.last().copied().unwrap_or_default();
if !is_allowed_shortcut_key(key) {
return Err("快捷键按键不受支持".to_string());
}
let mut modifiers = HashSet::new();
for modifier in &parts[1..parts.len() - 1] {
if !matches!(*modifier, "Shift" | "Alt") || !modifiers.insert(*modifier) {
return Err("快捷键修饰键不受支持或重复".to_string());
}
}
Ok(format!("CmdOrCtrl+{}", parts[1..].join("+")))
}
fn set_menu_accelerator<R: Runtime>(
app: &tauri::AppHandle<R>,
submenu_id: &str,
item_id: &str,
accelerator: &str,
) -> Result<(), String> {
let menu = app.menu().ok_or_else(|| "桌面菜单尚未初始化".to_string())?;
let submenu = menu
.get(submenu_id)
.and_then(|item| item.as_submenu().cloned())
.ok_or_else(|| format!("未找到桌面菜单:{submenu_id}"))?;
let item = submenu
.get(item_id)
.and_then(|item| item.as_menuitem().cloned())
.ok_or_else(|| format!("未找到桌面菜单项:{item_id}"))?;
item.set_accelerator(Some(accelerator))
.map_err(|error| format!("更新桌面菜单快捷键失败:{error}"))
}
#[tauri::command]
fn desktop_menu_set_shortcuts<R: Runtime>(
app: tauri::AppHandle<R>,
shortcuts: DesktopMenuShortcuts,
) -> Result<(), String> {
let back = normalize_menu_shortcut(&shortcuts.back)?;
let forward = normalize_menu_shortcut(&shortcuts.forward)?;
let refresh = normalize_menu_shortcut(&shortcuts.refresh)?;
if HashSet::from([back.as_str(), forward.as_str(), refresh.as_str()]).len() != 3 {
return Err("桌面快捷键不能重复".to_string());
}
set_menu_accelerator(&app, NAVIGATION_MENU_ID, BACK_COMMAND_ID, &back)?;
set_menu_accelerator(&app, NAVIGATION_MENU_ID, FORWARD_COMMAND_ID, &forward)?;
set_menu_accelerator(&app, VIEW_MENU_ID, REFRESH_COMMAND_ID, &refresh)?;
Ok(())
}
#[tauri::command]
fn desktop_window_set_theme<R: Runtime>(
window: WebviewWindow<R>,
theme: DesktopThemePreference,
) -> Result<(), String> {
let theme = match theme {
DesktopThemePreference::System => None,
DesktopThemePreference::Light => Some(Theme::Light),
DesktopThemePreference::Dark => Some(Theme::Dark),
};
window
.set_theme(theme)
.map_err(|error| format!("更新桌面窗口主题失败:{error}"))
}
pub fn run() {
tauri::Builder::default()
let app = tauri::Builder::default()
.menu(desktop_menu)
.on_menu_event(|app, event| {
let command = event.id().as_ref();
@@ -128,11 +353,7 @@ pub fn run() {
}
})
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
let _ = restore_main_window(app);
}))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
@@ -149,7 +370,45 @@ pub fn run() {
credentials::login_credential_delete,
updates::desktop_update_check,
updates::desktop_update_install,
desktop_menu_set_shortcuts,
desktop_window_set_theme,
])
.run(tauri::generate_context!())
.expect("error while running CTMS desktop application");
.build(tauri::generate_context!())
.expect("error while building CTMS desktop application");
app.run(|app, event| {
#[cfg(target_os = "macos")]
if let RunEvent::Reopen { .. } = event {
if let Err(error) = restore_main_window(app) {
eprintln!("{error}");
}
}
#[cfg(not(target_os = "macos"))]
let _ = (app, event);
});
}
#[cfg(test)]
mod tests {
use super::normalize_menu_shortcut;
#[test]
fn converts_runtime_shortcuts_to_native_accelerators() {
assert_eq!(
normalize_menu_shortcut("Mod+Shift+[").unwrap(),
"CmdOrCtrl+Shift+["
);
assert_eq!(
normalize_menu_shortcut("Mod+Alt+ArrowLeft").unwrap(),
"CmdOrCtrl+Alt+ArrowLeft"
);
}
#[test]
fn rejects_reserved_or_malformed_shortcuts() {
assert!(normalize_menu_shortcut("Mod+Q").is_err());
assert!(normalize_menu_shortcut("Ctrl+R").is_err());
assert!(normalize_menu_shortcut("Mod+Shift+Shift+R").is_err());
assert!(normalize_menu_shortcut("Mod+F12").is_err());
}
}
+3 -2
View File
@@ -10,13 +10,14 @@
import { initSessionManager } from "./session/sessionManager";
import { initDesktopNotificationManager } from "./session/desktopNotificationManager";
import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
import { applyDesktopThemePreference, isTauriRuntime } from "./runtime";
import { initializeDesktopMenuShortcutSync, initializeDesktopThemePreference, isTauriRuntime } from "./runtime";
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
const isDesktopRuntime = isTauriRuntime();
if (isDesktopRuntime) {
applyDesktopThemePreference();
initializeDesktopThemePreference();
initializeDesktopMenuShortcutSync();
document.body.classList.add("is-desktop-runtime");
} else {
document.body.classList.remove("is-desktop-runtime");
+1 -1
View File
@@ -22,7 +22,7 @@ export const updateUser = (
export const deleteUser = (userId: string) =>
apiDelete<void>(`/api/v1/users/${userId}`, { suppressErrorMessage: true });
export const fetchUserLoginActivities = (userId: string, limit = 30) =>
export const fetchUserLoginActivities = (userId: string, limit = 100) =>
apiGet<UserLoginActivity[]>(`/api/v1/users/${userId}/login-activities`, {
params: { limit },
});
@@ -14,6 +14,12 @@ describe("account connection status", () => {
expect(component).toContain("当前账号与连接状态");
expect(component).toContain("API 延迟");
expect(component).toContain("会话状态");
expect(component).toContain("useStudyStore");
expect(component).toContain("getProjectRole(study.currentStudy, study.currentStudyRole)");
expect(component).toContain('isSystemAdmin(auth.user) ? "系统角色" : "当前项目角色"');
expect(component).toContain("projectRole.value ? displayRoleLabel(projectRole.value) : \"未选择项目\"");
expect(component).toContain("`${study.currentStudy.name} · ${roleLabel.value}`");
expect(component).not.toContain('auth.user?.is_admin ? "系统管理员" : "普通账号"');
expect(component).toContain("mode === 'network'");
expect(component).toContain("当前 IP");
expect(component).toContain("clientIpLabel");
@@ -59,8 +59,8 @@
<dl class="connection-details-grid">
<div>
<dt>角色</dt>
<dd>{{ roleLabel }}</dd>
<dt>{{ roleFieldLabel }}</dt>
<dd :title="roleContextTitle">{{ roleLabel }}</dd>
</div>
<div>
<dt>客户端</dt>
@@ -91,11 +91,14 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { useStudyStore } from "../store/study";
import { clientRuntime, getAppMetadata, isTauriRuntime } from "../runtime";
import { IDLE_TIMEOUT_MINUTES } from "../session/sessionManager";
import { parseJwtExp } from "../session/jwt";
import { useConnectionStatus } from "../composables/useConnectionStatus";
import { useRoleTemplateMeta } from "../composables/useRoleTemplateMeta";
import { evaluateConnectionSecurity } from "../utils/connectionSecurity";
import { getProjectRole, isSystemAdmin } from "../utils/roles";
withDefaults(defineProps<{ mode?: "indicator" | "details" | "network" }>(), {
mode: "indicator",
@@ -103,8 +106,10 @@ withDefaults(defineProps<{ mode?: "indicator" | "details" | "network" }>(), {
const auth = useAuthStore();
const session = useSessionStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const appMetadata = getAppMetadata();
const { roleLabel: displayRoleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const {
status,
statusLabel,
@@ -125,7 +130,17 @@ const securityCheckExpanded = ref(true);
const userDisplayName = computed(
() => auth.user?.full_name || auth.user?.username || auth.user?.email || "当前用户",
);
const roleLabel = computed(() => (auth.user?.is_admin ? "系统管理员" : "普通账号"));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const roleFieldLabel = computed(() => (isSystemAdmin(auth.user) ? "系统角色" : "当前项目角色"));
const roleLabel = computed(() => {
if (isSystemAdmin(auth.user)) return "系统管理员";
return projectRole.value ? displayRoleLabel(projectRole.value) : "未选择项目";
});
const roleContextTitle = computed(() => {
if (isSystemAdmin(auth.user)) return roleLabel.value;
if (!study.currentStudy) return "选择项目后显示该项目内的具体角色";
return `${study.currentStudy.name} · ${roleLabel.value}`;
});
const clientLabel = computed(() => {
if (!isDesktop) return "网页端";
const platform = appMetadata.platform === "macos" ? "macOS" : appMetadata.platform || "桌面系统";
@@ -195,6 +210,9 @@ const runSecurityCheck = async () => {
};
onMounted(() => {
if (!isSystemAdmin(auth.user)) {
void loadRoleTemplates().catch(() => {});
}
stopConnectionMonitor = startConnectionMonitor();
nowTs.value = Date.now();
sessionClockTimer = window.setInterval(() => {
+486 -83
View File
@@ -1,5 +1,12 @@
<template>
<div class="desktop-workbench" @click="closeWorkspaceTabMenu">
<div
class="desktop-workbench"
:class="{
'is-sidebar-hidden': !desktopSidebarVisible,
'is-macos': desktopMetadata.platform === 'macos',
}"
@click="closeWorkspaceTabMenu"
>
<aside class="desktop-sidebar">
<header class="sidebar-head">
<div class="sidebar-title-row">
@@ -117,9 +124,54 @@
<section class="desktop-main">
<header class="desktop-toolbar">
<div class="toolbar-left" data-tauri-drag-region>
<button
class="toolbar-nav-button sidebar-toggle-button"
type="button"
:title="desktopSidebarToggleLabel"
:aria-label="desktopSidebarToggleLabel"
:aria-pressed="!desktopSidebarVisible"
@click="toggleDesktopSidebar"
>
<el-icon><Fold v-if="desktopSidebarVisible" /><Expand v-else /></el-icon>
</button>
<div class="workspace-history-controls" aria-label="当前任务导航">
<button
class="toolbar-nav-button"
type="button"
title="后退(当前任务)"
:disabled="!canNavigateWorkspaceTabBack"
@click="navigateCurrentWorkspaceTabHistory(-1)"
>
<el-icon><ArrowLeft /></el-icon>
</button>
<button
class="toolbar-nav-button"
type="button"
title="前进(当前任务)"
:disabled="!canNavigateWorkspaceTabForward"
@click="navigateCurrentWorkspaceTabHistory(1)"
>
<el-icon><ArrowRight /></el-icon>
</button>
</div>
<span class="toolbar-history-divider" aria-hidden="true"></span>
<div class="toolbar-context" data-tauri-drag-region>
<div class="toolbar-title" data-tauri-drag-region>{{ desktopToolbarTitle }}</div>
<div class="toolbar-subtitle" data-tauri-drag-region>{{ desktopToolbarSubtitle }}</div>
<div class="toolbar-subtitle" :title="desktopToolbarSubtitle" data-tauri-drag-region>
<template v-for="(item, index) in desktopToolbarContextItems" :key="`${item.label}:${item.path || index}`">
<span v-if="index" class="toolbar-context-separator" aria-hidden="true">·</span>
<button
v-if="item.path"
class="toolbar-context-link"
type="button"
:title="`返回${item.label}`"
@click="navigateToolbarContext(item.path)"
>
{{ item.label }}
</button>
<span v-else>{{ item.label }}</span>
</template>
</div>
</div>
</div>
@@ -274,7 +326,7 @@
</div>
</header>
<div v-if="workspaceTabs.length" ref="workspaceTabsElement" class="workspace-tabs">
<div v-if="workspaceTabs.length > 1" class="workspace-tabs">
<div
class="workspace-tabs-list"
:style="{ gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))` }"
@@ -306,33 +358,47 @@
</div>
<el-dropdown
v-if="overflowWorkspaceTabs.length"
v-if="hasWorkspaceTabsOverflow"
class="workspace-tabs-overflow"
popper-class="workspace-tabs-overflow-popper"
trigger="click"
placement="bottom-end"
@command="navigateOverflowWorkspaceTab"
@visible-change="workspaceTabsOverflowOpen = $event"
@visible-change="handleWorkspaceTabsOverflowVisibleChange"
>
<button
class="workspace-tabs-overflow-trigger"
:class="{ 'is-open': workspaceTabsOverflowOpen }"
type="button"
:aria-label="`查看其余 ${overflowWorkspaceTabs.length} 标签`"
:aria-label="`查看全部 ${workspaceTabs.length} 任务`"
>
<span>更多</span>
<span class="workspace-tabs-overflow-count">{{ overflowWorkspaceTabs.length }}</span>
<span>全部任务</span>
<span class="workspace-tabs-overflow-count">{{ workspaceTabs.length }}</span>
<el-icon><ArrowDown /></el-icon>
</button>
<template #dropdown>
<div class="workspace-tabs-overflow-menu">
<div class="workspace-tabs-overflow-header">
<span>其他标签</span>
<span>{{ overflowWorkspaceTabs.length }} </span>
<span>全部任务</span>
<span>{{ workspaceTabs.length }} </span>
</div>
<div class="workspace-tabs-overflow-search" @click.stop @keydown.stop>
<el-icon><Search /></el-icon>
<input
v-model="workspaceTabsSearchQuery"
type="search"
autocomplete="off"
aria-label="搜索已打开任务"
placeholder="搜索已打开任务"
/>
</div>
<div class="workspace-tabs-overflow-actions">
<button type="button" @click.stop="closeOtherWorkspaceTabs(activeMenu)">关闭其他</button>
<button type="button" @click.stop="closeAllWorkspaceTabs">关闭全部</button>
</div>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in overflowWorkspaceTabs"
v-for="item in filteredWorkspaceTabs"
:key="item.path"
:command="item.path"
:class="{ 'is-current': activeMenu === item.path }"
@@ -351,6 +417,7 @@
×
</button>
</el-dropdown-item>
<li v-if="!filteredWorkspaceTabs.length" class="workspace-tabs-overflow-empty">没有匹配的任务</li>
</el-dropdown-menu>
</div>
</template>
@@ -432,6 +499,8 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
ArrowDown,
ArrowLeft,
ArrowRight,
Bell,
Box,
Calendar,
@@ -441,8 +510,10 @@ import {
DataAnalysis,
Document,
Download,
Expand,
Files,
Flag,
Fold,
Folder,
House,
Key,
@@ -488,7 +559,9 @@ import {
getDesktopServerUrl,
listenDesktopMenuCommand,
readDesktopFavoriteRoutes,
readDesktopSidebarVisible,
readDesktopShortcutPreferences,
setDesktopSidebarVisible,
toggleDesktopFavoriteRoute,
type DesktopRoutePreference,
type DesktopShortcutPreferences,
@@ -511,6 +584,7 @@ import {
type LayoutNavigationItem,
} from "./layout/navigation";
import {
canMoveWorkspaceTabHistory,
createWorkspaceTabHistory,
currentWorkspaceTabPath,
moveWorkspaceTabHistory,
@@ -518,6 +592,12 @@ import {
replaceWorkspaceTabPath,
type WorkspaceTabHistory,
} from "./layout/workspaceTabHistory";
import {
buildWorkspaceTabTitle,
buildWorkspaceToolbarTitle,
resolveWorkspaceTabContextTitle,
type WorkspaceTabContext,
} from "./layout/workspaceTabTitle";
const auth = useAuthStore();
const study = useStudyStore();
@@ -525,10 +605,8 @@ const router = useRouter();
const route = useRoute();
const desktopMetadata = getAppMetadata();
const DESKTOP_WORKSPACE_TAB_CACHE_MAX = 12;
const DESKTOP_WORKSPACE_TAB_MIN_WIDTH = 130;
const DESKTOP_WORKSPACE_TAB_GAP = 6;
const DESKTOP_WORKSPACE_TABS_HORIZONTAL_PADDING = 20;
const DESKTOP_WORKSPACE_TABS_OVERFLOW_WIDTH = 106;
const DESKTOP_WORKSPACE_TABS_OVERFLOW_THRESHOLD = 5;
const DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT = 4;
const DESKTOP_ADMIN_SECTION_LABEL = "系统管理";
const isAdmin = computed(() => !!auth.user?.is_admin);
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
@@ -554,11 +632,12 @@ const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateSta
const desktopActivities = ref<DesktopActivityItem[]>(getDesktopActivities());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const desktopShortcutPreferences = ref<DesktopShortcutPreferences>(readDesktopShortcutPreferences());
const desktopSidebarVisible = ref(readDesktopSidebarVisible());
const workspaceTabs = ref<DesktopRoutePreference[]>([]);
const workspaceTabHistories = ref<Record<string, WorkspaceTabHistory>>({});
const workspaceTabsElement = ref<HTMLElement | null>(null);
const workspaceTabsAvailableWidth = ref(0);
const workspaceTabContexts = ref<Record<string, WorkspaceTabContext>>({});
const workspaceTabsOverflowOpen = ref(false);
const workspaceTabsSearchQuery = ref("");
const draggingWorkspaceTabPath = ref("");
const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);
const lastClosedWorkspaceTabHistory = ref<WorkspaceTabHistory | null>(null);
@@ -580,7 +659,6 @@ const headerReminderStats = ref({ overdueAes: 0, overdueMonitoringIssues: 0 });
let desktopMenuUnlisten: (() => void) | undefined;
let desktopUpdateStatusUnlisten: (() => void) | undefined;
let desktopActivityUnlisten: (() => void) | undefined;
let workspaceTabsResizeObserver: ResizeObserver | undefined;
const iconComponentMap = {
audit: Document,
@@ -659,6 +737,7 @@ const userDisplayInitial = computed(() => {
return source.charAt(0).toUpperCase();
});
const desktopProjectSectionLabel = computed(() => study.currentStudy?.name || TEXT.menu.currentProject);
const desktopSidebarToggleLabel = computed(() => (desktopSidebarVisible.value ? "隐藏导航栏" : "显示导航栏"));
const projectEntryMenuLabel = computed(() => (study.currentStudy ? "返回项目选择" : "选择项目"));
const desktopConnectionLabel = computed(() => (desktopServerUrl.value ? "已连接" : "未配置"));
const desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning"));
@@ -700,39 +779,60 @@ const formatDesktopActivityTime = (value: string) => {
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
};
const currentWorkspaceContext = computed<WorkspaceTabContext | null>(() => {
const storedContext = workspaceTabContexts.value[activeMenu.value];
const pageTitle = resolveWorkspaceTabContextTitle({
liveContextTitle: study.viewContext?.pageTitle,
storedContext,
routePath: route.path,
});
if (!pageTitle) return null;
const storedContextMatchesRoute = storedContext?.routePath === route.path;
return {
routePath: route.path,
pageTitle,
objectType: study.viewContext?.objectType || (storedContextMatchesRoute ? storedContext.objectType : undefined),
};
});
const currentDesktopRoutePreference = computed(() => {
const item = desktopNavigationItems.value.find((navItem) => navItem.path === activeMenu.value);
if (!item) return null;
return { path: item.path, title: item.label, group: item.group };
});
const visibleWorkspaceTabCapacity = computed(() => {
const tabCount = workspaceTabs.value.length;
const availableWidth = workspaceTabsAvailableWidth.value;
if (!tabCount || availableWidth <= 0) return tabCount;
const maxWithoutOverflow = Math.max(
1,
Math.floor((availableWidth + DESKTOP_WORKSPACE_TAB_GAP) / (DESKTOP_WORKSPACE_TAB_MIN_WIDTH + DESKTOP_WORKSPACE_TAB_GAP)),
);
if (tabCount <= maxWithoutOverflow) return tabCount;
return Math.max(
1,
Math.floor(
(availableWidth - DESKTOP_WORKSPACE_TABS_OVERFLOW_WIDTH) /
(DESKTOP_WORKSPACE_TAB_MIN_WIDTH + DESKTOP_WORKSPACE_TAB_GAP),
),
);
return {
path: item.path,
title: buildWorkspaceTabTitle({
moduleTitle: item.label,
routeTitle: typeof route.meta?.title === "string" ? route.meta.title : undefined,
contextTitle: currentWorkspaceContext.value?.pageTitle,
}),
group: item.group,
};
});
const workspaceTabsByRecentUse = computed(() =>
workspaceTabs.value
.map((item, index) => ({ item, index }))
.sort(
(left, right) =>
left.item.updatedAt.localeCompare(right.item.updatedAt) ||
Number(left.item.path === activeMenu.value) - Number(right.item.path === activeMenu.value) ||
left.index - right.index,
)
.map(({ item }) => item),
);
const hasWorkspaceTabsOverflow = computed(
() => workspaceTabs.value.length > DESKTOP_WORKSPACE_TABS_OVERFLOW_THRESHOLD,
);
const visibleWorkspaceTabs = computed(() => {
const capacity = visibleWorkspaceTabCapacity.value;
if (workspaceTabs.value.length <= capacity) return workspaceTabs.value;
const firstTabs = workspaceTabs.value.slice(0, capacity);
const activeTab = workspaceTabs.value.find((item) => item.path === activeMenu.value);
if (!activeTab || firstTabs.some((item) => item.path === activeTab.path)) return firstTabs;
return [...firstTabs.slice(0, -1), activeTab];
if (!hasWorkspaceTabsOverflow.value) return workspaceTabs.value;
const recentVisiblePaths = new Set(
workspaceTabsByRecentUse.value.slice(-DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT).map((item) => item.path),
);
return workspaceTabs.value.filter((item) => recentVisiblePaths.has(item.path));
});
const overflowWorkspaceTabs = computed(() => {
const visiblePaths = new Set(visibleWorkspaceTabs.value.map((item) => item.path));
return workspaceTabs.value.filter((item) => !visiblePaths.has(item.path));
const filteredWorkspaceTabs = computed(() => {
const query = workspaceTabsSearchQuery.value.trim().toLocaleLowerCase();
const recentFirst = [...workspaceTabsByRecentUse.value].reverse();
if (!query) return recentFirst;
return recentFirst.filter((item) => `${item.title} ${item.path}`.toLocaleLowerCase().includes(query));
});
const currentRouteFavorited = computed(() => {
const current = currentDesktopRoutePreference.value;
@@ -740,25 +840,42 @@ const currentRouteFavorited = computed(() => {
});
const desktopToolbarTitle = computed(() => {
const current = currentDesktopRoutePreference.value;
return current?.title || String(route.meta?.title || TEXT.common.appName);
if (!current) return String(route.meta?.title || TEXT.common.appName);
return buildWorkspaceToolbarTitle({
moduleTitle: desktopNavigationItems.value.find((item) => item.path === current.path)?.label || current.title,
routeTitle: typeof route.meta?.title === "string" ? route.meta.title : undefined,
contextTitle: currentWorkspaceContext.value?.pageTitle,
contextObjectType: currentWorkspaceContext.value?.objectType,
});
});
const desktopToolbarSubtitle = computed(() => {
const desktopToolbarContextItems = computed<Array<{ label: string; path?: string }>>(() => {
const current = currentDesktopRoutePreference.value;
if (!current) return TEXT.common.appName;
if (!current) return [{ label: TEXT.common.appName }];
const moduleItem = desktopNavigationItems.value.find((item) => item.path === current.path);
const items: Array<{ label: string; path?: string }> = [];
const isAdminRoute = adminDesktopNavigationItems.value.some((item) => item.path === current.path);
if (isAdminRoute) {
const section = current.group && current.group !== TEXT.menu.admin ? current.group : "";
return [DESKTOP_ADMIN_SECTION_LABEL, section].filter(Boolean).join(" / ");
}
const isProjectRoute = projectDesktopNavigationItems.value.some((item) => item.path === current.path);
if (isProjectRoute) {
items.push({ label: DESKTOP_ADMIN_SECTION_LABEL });
if (section) items.push({ label: section });
} else if (projectDesktopNavigationItems.value.some((item) => item.path === current.path)) {
const section = current.group && current.group !== TEXT.menu.currentProject ? current.group : "";
const projectName = study.currentStudy?.name || TEXT.menu.currentProject;
return [projectName, section].filter(Boolean).join(" / ");
items.push({ label: projectName });
if (section) items.push({ label: section });
} else {
items.push({ label: current.group || TEXT.common.appName });
}
return current.group || TEXT.common.appName;
if (currentWorkspaceContext.value && moduleItem && !items.some((item) => item.label === moduleItem.label)) {
items.push({ label: moduleItem.label, path: moduleItem.path });
}
return items;
});
const desktopToolbarSubtitle = computed(() => desktopToolbarContextItems.value.map((item) => item.label).join(" · "));
const activeWorkspaceTabHistory = computed(() => workspaceTabHistories.value[activeMenu.value]);
const canNavigateWorkspaceTabBack = computed(() => canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, -1));
const canNavigateWorkspaceTabForward = computed(() => canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, 1));
const canReadRiskIssueAes = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/sae"));
const canReadMonitoringIssues = computed(() => !isAdminContext.value && canAccessProjectPath("/risk-issues/monitoring-visits"));
@@ -823,6 +940,13 @@ const removeWorkspaceTabHistory = (tabPath: string) => {
workspaceTabHistories.value = next;
};
const removeWorkspaceTabContext = (tabPath: string) => {
if (!workspaceTabContexts.value[tabPath]) return;
const next = { ...workspaceTabContexts.value };
delete next[tabPath];
workspaceTabContexts.value = next;
};
const recordWorkspaceTabRoute = (tabPath: string, fullPath: string) => {
setWorkspaceTabHistory(tabPath, recordWorkspaceTabPath(workspaceTabHistories.value[tabPath], fullPath));
};
@@ -849,27 +973,18 @@ const navigateCurrentWorkspaceTabHistory = (offset: -1 | 1) => {
void router.push(movement.path);
};
const navigateToolbarContext = (path: string) => {
if (route.path === path) return;
void router.push(path);
};
const navigateOverflowWorkspaceTab = (path: string) => {
navigateWorkspaceTab(path);
};
const updateWorkspaceTabsAvailableWidth = () => {
const element = workspaceTabsElement.value;
workspaceTabsAvailableWidth.value = element
? Math.max(0, element.clientWidth - DESKTOP_WORKSPACE_TABS_HORIZONTAL_PADDING)
: 0;
};
const observeWorkspaceTabsElement = (element: HTMLElement | null) => {
workspaceTabsResizeObserver?.disconnect();
if (!element) {
workspaceTabsAvailableWidth.value = 0;
return;
}
updateWorkspaceTabsAvailableWidth();
if (typeof ResizeObserver === "undefined") return;
workspaceTabsResizeObserver ||= new ResizeObserver(updateWorkspaceTabsAvailableWidth);
workspaceTabsResizeObserver.observe(element);
const handleWorkspaceTabsOverflowVisibleChange = (visible: boolean) => {
workspaceTabsOverflowOpen.value = visible;
if (!visible) workspaceTabsSearchQuery.value = "";
};
const closeWorkspaceTabMenu = () => {
@@ -932,6 +1047,7 @@ const closeWorkspaceTab = (path: string) => {
}
workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path);
removeWorkspaceTabHistory(path);
removeWorkspaceTabContext(path);
if (activeMenu.value !== path) return;
const next =
workspaceTabs.value[index] ||
@@ -955,10 +1071,36 @@ const closeOtherWorkspaceTabs = (path: string) => {
workspaceTabs.value = [target];
const targetHistory = workspaceTabHistories.value[path];
workspaceTabHistories.value = targetHistory ? { [path]: targetHistory } : {};
const targetContext = workspaceTabContexts.value[path];
workspaceTabContexts.value = targetContext ? { [path]: targetContext } : {};
if (activeMenu.value !== path) void router.push(workspaceTabDestination(path));
closeWorkspaceTabMenu();
};
const closeAllWorkspaceTabs = () => {
const activeTab = workspaceTabs.value.find((tab) => tab.path === activeMenu.value);
if (activeTab) {
lastClosedWorkspaceTab.value = activeTab;
lastClosedWorkspaceTabHistory.value = workspaceTabHistories.value[activeTab.path] || createWorkspaceTabHistory(activeTab.path);
}
workspaceTabs.value = [];
workspaceTabHistories.value = {};
workspaceTabContexts.value = {};
workspaceTabsSearchQuery.value = "";
closeWorkspaceTabMenu();
const fallbackPath = workspaceTabFallbackPath.value;
if (activeMenu.value === fallbackPath) {
const fallbackTab = currentDesktopRoutePreference.value;
if (fallbackTab) {
addWorkspaceTab(fallbackTab);
recordWorkspaceTabRoute(fallbackTab.path, route.fullPath);
}
return;
}
void router.push(fallbackPath);
};
const restoreLastClosedWorkspaceTab = () => {
const tab = lastClosedWorkspaceTab.value;
if (!tab) return;
@@ -989,10 +1131,15 @@ const openDesktopPreferences = () => {
desktopPreferencesVisible.value = true;
};
const toggleDesktopSidebar = () => {
desktopSidebarVisible.value = setDesktopSidebarVisible(!desktopSidebarVisible.value);
};
const openDesktopProjectEntry = async () => {
study.clearCurrentStudy();
workspaceTabs.value = [];
workspaceTabHistories.value = {};
workspaceTabContexts.value = {};
lastClosedWorkspaceTab.value = null;
lastClosedWorkspaceTabHistory.value = null;
await router.push("/desktop/project-entry");
@@ -1011,7 +1158,7 @@ const refreshCurrentDesktopView = () => {
};
const handleDesktopMenuCommand = (command: string) => {
if (command === "ctms.desktop.commandPalette") {
if (command === "ctms.desktop.commandPalette" || command === "ctms.desktop.help") {
openCommandPalette();
return;
}
@@ -1033,6 +1180,10 @@ const handleDesktopMenuCommand = (command: string) => {
}
if (command === "ctms.desktop.forward") {
navigateCurrentWorkspaceTabHistory(1);
return;
}
if (command === "ctms.desktop.toggleSidebar") {
toggleDesktopSidebar();
}
};
@@ -1236,11 +1387,14 @@ const beforeProfileDialogClose = async (done: () => void) => {
watch(
() => route.fullPath,
(fullPath, previousFullPath) => {
study.setViewContext(null);
const previousRoute = previousFullPath ? router.resolve(previousFullPath) : null;
if (!previousRoute || previousRoute.path !== route.path) {
study.setViewContext(null);
}
const current = currentDesktopRoutePreference.value;
if (current) {
addWorkspaceTab(current);
const previousTabPath = previousFullPath ? getActiveLayoutPath(router.resolve(previousFullPath).path) : "";
const previousTabPath = previousRoute ? getActiveLayoutPath(previousRoute.path) : "";
const replacesCurrentTabEntry =
previousTabPath === current.path && Boolean((window.history.state as { replaced?: boolean } | null)?.replaced);
if (replacesCurrentTabEntry) {
@@ -1266,12 +1420,27 @@ watch(
() => refreshDesktopRoutePreferences(),
);
watch(workspaceTabsElement, (element) => observeWorkspaceTabsElement(element), { flush: "post" });
watch(
() => [study.viewContext?.pageTitle, study.viewContext?.objectType] as const,
([pageTitle, objectType]) => {
if (!pageTitle) return;
workspaceTabContexts.value = {
...workspaceTabContexts.value,
[activeMenu.value]: {
routePath: route.path,
pageTitle,
objectType,
},
};
const current = currentDesktopRoutePreference.value;
if (current) addWorkspaceTab(current);
},
{ flush: "sync" },
);
onMounted(async () => {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
window.addEventListener(DESKTOP_SHORTCUTS_CHANGED_EVENT, updateDesktopShortcutPreferences);
window.addEventListener("resize", updateWorkspaceTabsAvailableWidth);
clearLegacyDesktopRouteHistoryPreference();
const current = currentDesktopRoutePreference.value;
if (current) {
@@ -1304,8 +1473,6 @@ onMounted(async () => {
onBeforeUnmount(() => {
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
window.removeEventListener(DESKTOP_SHORTCUTS_CHANGED_EVENT, updateDesktopShortcutPreferences);
window.removeEventListener("resize", updateWorkspaceTabsAvailableWidth);
workspaceTabsResizeObserver?.disconnect();
desktopMenuUnlisten?.();
desktopUpdateStatusUnlisten?.();
desktopActivityUnlisten?.();
@@ -1348,6 +1515,15 @@ useDesktopShortcuts(
overflow: hidden;
background: #eef2f6;
color: #142033;
transition: grid-template-columns 180ms ease;
}
.desktop-workbench.is-sidebar-hidden {
grid-template-columns: 0 minmax(0, 1fr);
}
.desktop-workbench.is-macos.is-sidebar-hidden .desktop-toolbar {
padding-left: 88px;
}
.icon-button {
@@ -1372,6 +1548,18 @@ useDesktopShortcuts(
flex-direction: column;
background: #f8fafc;
border-right: 1px solid #d7e0ea;
opacity: 1;
transform: translateX(0);
visibility: visible;
transition: opacity 140ms ease, transform 180ms ease, visibility 180ms ease;
}
.desktop-workbench.is-sidebar-hidden .desktop-sidebar {
border-right-color: transparent;
opacity: 0;
pointer-events: none;
transform: translateX(-12px);
visibility: hidden;
}
.sidebar-head {
@@ -1601,6 +1789,7 @@ useDesktopShortcuts(
.toolbar-left {
align-self: stretch;
gap: 10px;
cursor: default;
user-select: none;
}
@@ -1612,6 +1801,10 @@ useDesktopShortcuts(
.toolbar-right,
.toolbar-right :deep(*),
.sidebar-toggle-button,
.workspace-history-controls,
.workspace-history-controls *,
.toolbar-context-link,
.desktop-sidebar button,
.desktop-sidebar :deep(.el-dropdown),
.workspace-tabs,
@@ -1621,6 +1814,51 @@ useDesktopShortcuts(
-webkit-app-region: no-drag;
}
.workspace-history-controls {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
}
.toolbar-nav-button {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: #52667d;
cursor: pointer;
font: inherit;
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
}
.toolbar-nav-button:hover:not(:disabled),
.toolbar-nav-button:focus-visible:not(:disabled) {
border-color: #d3dee9;
background: #edf3f8;
color: #183756;
outline: none;
}
.toolbar-nav-button:disabled {
color: #aeb9c7;
cursor: default;
opacity: 0.56;
}
.toolbar-history-divider {
flex: 0 0 1px;
width: 1px;
height: 22px;
background: #d7e0ea;
}
.toolbar-left[data-tauri-drag-region],
.toolbar-context[data-tauri-drag-region],
.toolbar-title[data-tauri-drag-region],
@@ -1630,6 +1868,7 @@ useDesktopShortcuts(
.toolbar-context {
display: grid;
flex: 1 1 auto;
min-width: 0;
align-content: center;
gap: 2px;
@@ -1653,12 +1892,44 @@ useDesktopShortcuts(
}
.toolbar-subtitle {
display: flex;
align-items: center;
gap: 5px;
color: #66778d;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
}
.toolbar-context-separator {
flex: 0 0 auto;
color: #9aa8b8;
}
.toolbar-context-link {
appearance: none;
min-width: 0;
padding: 0;
overflow: hidden;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-weight: inherit;
line-height: inherit;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-context-link:hover,
.toolbar-context-link:focus-visible {
color: #183756;
text-decoration: underline;
text-underline-offset: 2px;
outline: none;
}
.command-trigger,
.update-notice,
.connection-button,
@@ -2033,7 +2304,7 @@ useDesktopShortcuts(
align-items: center;
min-width: 0;
gap: 6px;
padding: 7px 10px;
padding: 0 10px;
overflow: hidden;
border-bottom: 1px solid #dfe7f0;
background: #f4f7fa;
@@ -2048,6 +2319,7 @@ useDesktopShortcuts(
}
.workspace-tab {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@@ -2073,6 +2345,7 @@ useDesktopShortcuts(
border-color: #aebfd1;
background: #eaf1f8;
color: #183756;
box-shadow: inset 0 -2px #315f89;
}
.workspace-tab.dragging {
@@ -2108,6 +2381,14 @@ useDesktopShortcuts(
background: transparent;
color: #7b8da3;
cursor: pointer;
opacity: 0;
transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
}
.workspace-tab:hover .tab-close,
.workspace-tab.active .tab-close,
.tab-close:focus-visible {
opacity: 1;
}
.tab-close:hover {
@@ -2117,7 +2398,7 @@ useDesktopShortcuts(
.workspace-tabs-overflow {
display: flex;
flex: 0 0 106px;
flex: 0 0 132px;
min-width: 0;
}
@@ -2126,7 +2407,7 @@ useDesktopShortcuts(
display: flex;
align-items: center;
justify-content: center;
width: 106px;
width: 132px;
height: 30px;
gap: 6px;
padding: 0 9px 0 11px;
@@ -2181,7 +2462,7 @@ useDesktopShortcuts(
}
.workspace-tabs-overflow-menu {
width: 252px;
width: 292px;
}
.workspace-tabs-overflow-header {
@@ -2202,6 +2483,64 @@ useDesktopShortcuts(
font-weight: 700;
}
.workspace-tabs-overflow-search {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 34px;
margin: 8px 8px 6px;
padding: 0 9px;
border: 1px solid #d6e0eb;
border-radius: 7px;
background: #f8fafc;
color: #8292a6;
}
.workspace-tabs-overflow-search input {
min-width: 0;
padding: 0;
border: 0;
outline: 0;
background: transparent;
color: #223349;
font: inherit;
font-size: 12px;
}
.workspace-tabs-overflow-search input::placeholder {
color: #96a4b5;
}
.workspace-tabs-overflow-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
padding: 0 8px 6px;
border-bottom: 1px solid #e5ebf2;
}
.workspace-tabs-overflow-actions button {
appearance: none;
min-height: 26px;
padding: 0 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: #52667d;
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 700;
}
.workspace-tabs-overflow-actions button:hover,
.workspace-tabs-overflow-actions button:focus-visible {
background: #eef4fb;
color: #183756;
outline: none;
}
:global(.workspace-tabs-overflow-popper .el-dropdown-menu) {
min-width: 0;
max-height: min(360px, calc(100vh - 120px));
@@ -2233,6 +2572,16 @@ useDesktopShortcuts(
color: #183756;
}
.workspace-tabs-overflow-empty {
display: flex;
min-height: 72px;
align-items: center;
justify-content: center;
color: #8292a6;
font-size: 12px;
list-style: none;
}
.workspace-tabs-overflow-label {
display: block;
flex: 1 1 auto;
@@ -2431,6 +2780,30 @@ useDesktopShortcuts(
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .toolbar-nav-button) {
color: #aebed0;
}
:global([data-ctms-theme="dark"] .toolbar-nav-button:hover:not(:disabled)),
:global([data-ctms-theme="dark"] .toolbar-nav-button:focus-visible:not(:disabled)) {
border-color: #334155;
background: #1f2d3d;
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .toolbar-nav-button:disabled) {
color: #64748b;
}
:global([data-ctms-theme="dark"] .toolbar-history-divider) {
background: #334155;
}
:global([data-ctms-theme="dark"] .toolbar-context-link:hover),
:global([data-ctms-theme="dark"] .toolbar-context-link:focus-visible) {
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .study-switcher-trigger),
:global([data-ctms-theme="dark"] .command-trigger),
:global([data-ctms-theme="dark"] .update-notice),
@@ -2505,6 +2878,30 @@ useDesktopShortcuts(
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-search) {
border-color: #334155;
background: #111827;
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-search input) {
color: #e5edf7;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-actions) {
border-bottom-color: #334155;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-actions button) {
color: #aebed0;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-actions button:hover),
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-actions button:focus-visible) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-popper .el-dropdown-menu__item) {
color: #dbe5f1;
}
@@ -2520,6 +2917,10 @@ useDesktopShortcuts(
background: #6f87a1;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .workspace-tabs-overflow-close) {
color: #94a3b8;
}
@@ -2739,7 +3140,7 @@ useDesktopShortcuts(
.workspace-tabs {
gap: 8px;
padding: 9px 14px;
padding: 0 14px;
}
.workspace-tab {
@@ -2779,6 +3180,8 @@ useDesktopShortcuts(
@media (prefers-reduced-motion: reduce) {
.icon-button,
.desktop-workbench,
.desktop-sidebar,
.desktop-route-enter-active,
.desktop-route-leave-active {
transition-duration: 1ms !important;
+105 -15
View File
@@ -37,6 +37,8 @@ describe("desktop layout shell", () => {
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
expect(source).toContain("<WebLayout v-else />");
expect(app).toContain("const isDesktopRuntime = isTauriRuntime();");
expect(app).toContain("initializeDesktopThemePreference();");
expect(app).toContain("initializeDesktopMenuShortcutSync();");
expect(app).toContain('document.body.classList.add("is-desktop-runtime")');
expect(app).toContain('document.body.classList.remove("is-desktop-runtime")');
});
@@ -98,9 +100,14 @@ describe("desktop layout shell", () => {
expect(source).toContain("desktopToolbarSubtitle");
expect(source).not.toContain("desktopBreadcrumbs");
expect(source).not.toContain("breadcrumb-chip");
expect(source).not.toContain("history-controls");
expect(source).not.toContain('title="后退"');
expect(source).not.toContain('title="前进"');
expect(source).toContain('class="workspace-history-controls"');
expect(source).toContain('title="后退(当前任务)"');
expect(source).toContain('title="前进(当前任务)"');
expect(source).toContain(':disabled="!canNavigateWorkspaceTabBack"');
expect(source).toContain(':disabled="!canNavigateWorkspaceTabForward"');
expect(source).toContain('class="toolbar-history-divider"');
expect(source).toContain('v-for="(item, index) in desktopToolbarContextItems"');
expect(source).toContain('class="toolbar-context-link"');
expect(source).not.toContain('title="刷新当前视图"');
expect(source).not.toContain("const sidebarTitle");
expect(source).not.toContain('title="搜索命令"');
@@ -135,6 +142,29 @@ describe("desktop layout shell", () => {
expect(layout).not.toContain(["@tauri-apps", "api", "window"].join("/"));
});
it("uses a native macOS application menu and rebuilds a closed main window from the Dock", () => {
const tauriLib = readTauriLibSource();
expect(tauriLib.indexOf("package.name.clone()")).toBeLessThan(tauriLib.indexOf('"文件"'));
expect(tauriLib).toContain('Some("关于 CTMS")');
expect(tauriLib).toContain("short_version: Some(String::new())");
expect(tauriLib).toContain("icon: handle.default_window_icon().cloned()");
expect(tauriLib).toContain('"ctms.desktop.preferences"');
expect(tauriLib).toContain("PredefinedMenuItem::services");
expect(tauriLib).toContain("PredefinedMenuItem::hide_others");
expect(tauriLib).toContain("PredefinedMenuItem::show_all");
expect(tauriLib).toContain("WINDOW_SUBMENU_ID");
expect(tauriLib).toContain("HELP_SUBMENU_ID");
expect(tauriLib).toContain("TOGGLE_SIDEBAR_COMMAND_ID");
expect(tauriLib).toContain('"显示/隐藏导航栏"');
expect(tauriLib).toContain("RunEvent::Reopen");
expect(tauriLib).toContain("WebviewWindowBuilder::from_config");
expect(tauriLib).toContain('find(|config| config.label == "main")');
expect(tauriLib).not.toContain("WindowEvent::CloseRequested");
expect(tauriLib).not.toContain("api.prevent_close()");
expect(tauriLib).not.toContain("window.hide()");
});
it("keeps the desktop sidebar dense enough for navigation-heavy screens", () => {
const layout = readDesktopLayoutSource();
const sidebarHeadStart = layout.indexOf('<header class="sidebar-head">');
@@ -169,6 +199,24 @@ describe("desktop layout shell", () => {
expect(layout).not.toContain("padding: 44px 12px 12px;");
});
it("persists a fully hidden desktop sidebar with a macOS-safe restore control", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain("const desktopSidebarVisible = ref(readDesktopSidebarVisible());");
expect(layout).toContain("desktopSidebarVisible.value = setDesktopSidebarVisible(!desktopSidebarVisible.value);");
expect(layout).toContain("'is-sidebar-hidden': !desktopSidebarVisible");
expect(layout).toContain("'is-macos': desktopMetadata.platform === 'macos'");
expect(layout).toContain('class="toolbar-nav-button sidebar-toggle-button"');
expect(layout).toContain(':title="desktopSidebarToggleLabel"');
expect(layout).toContain('<Fold v-if="desktopSidebarVisible" /><Expand v-else />');
expect(layout).toContain('command === "ctms.desktop.toggleSidebar"');
expect(layout).toContain(".desktop-workbench.is-sidebar-hidden {");
expect(layout).toContain("grid-template-columns: 0 minmax(0, 1fr);");
expect(layout).toContain(".desktop-workbench.is-macos.is-sidebar-hidden .desktop-toolbar {");
expect(layout).toContain("padding-left: 88px;");
expect(layout).toContain("pointer-events: none;");
});
it("renders desktop preferences as a split settings panel", () => {
const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource();
@@ -183,6 +231,10 @@ describe("desktop layout shell", () => {
expect(preferences).toContain("scrollbar-gutter: stable;");
expect(preferences).toContain('{ id: "connection", label: "连接"');
expect(preferences).toContain('{ id: "appearance", label: "外观"');
expect(preferences).toContain('{ value: "system" as const, label: "跟随系统"');
expect(preferences).toContain("grid-template-columns: repeat(3, minmax(0, 1fr));");
expect(preferences).toContain("width: min(100%, 336px);");
expect(preferences).toContain("white-space: nowrap;");
expect(preferences).toContain('{ id: "shortcuts", label: "快捷键"');
expect(preferences).toContain('{ id: "notifications", label: "通知"');
expect(preferences).toContain('{ id: "updates", label: "更新"');
@@ -249,7 +301,7 @@ describe("desktop layout shell", () => {
it("keeps workspace tabs stable and draggable like browser tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length" ref="workspaceTabsElement" class="workspace-tabs">');
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length > 1" class="workspace-tabs">');
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
@@ -292,6 +344,8 @@ describe("desktop layout shell", () => {
expect(source).toContain("replaceWorkspaceTabRoute(current.path, fullPath);");
expect(source).toContain("currentWorkspaceTabPath(workspaceTabHistories.value[tabPath], tabPath)");
expect(source).toContain("moveWorkspaceTabHistory(workspaceTabHistories.value[tabPath], offset)");
expect(source).toContain("canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, -1)");
expect(source).toContain("canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, 1)");
expect(source).toContain("navigateBack: () => navigateCurrentWorkspaceTabHistory(-1)");
expect(source).toContain("navigateForward: () => navigateCurrentWorkspaceTabHistory(1)");
expect(source).toContain('id: "desktop:tab-back"');
@@ -300,6 +354,25 @@ describe("desktop layout shell", () => {
expect(source).not.toContain("navigateForward: () => router.forward()");
});
it("updates a detail workspace tab after its business context has loaded", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("buildWorkspaceTabTitle,");
expect(source).toContain("buildWorkspaceToolbarTitle,");
expect(source).toContain("resolveWorkspaceTabContextTitle,");
expect(source).toContain('} from "./layout/workspaceTabTitle";');
expect(source).toContain("const workspaceTabContexts = ref<Record<string, WorkspaceTabContext>>({});");
expect(source).toContain("title: buildWorkspaceTabTitle({");
expect(source).toContain("const pageTitle = resolveWorkspaceTabContextTitle({");
expect(source).toContain("() => [study.viewContext?.pageTitle, study.viewContext?.objectType] as const,");
expect(source).toContain("[activeMenu.value]: {");
expect(source).toContain("([pageTitle, objectType]) => {");
expect(source).toContain("if (current) addWorkspaceTab(current);");
expect(source).toContain('{ flush: "sync" },');
expect(source).toContain("if (!previousRoute || previousRoute.path !== route.path) {");
expect(source).toContain("const removeWorkspaceTabContext = (tabPath: string) => {");
});
it("allows desktop shortcuts to be customized from system preferences", () => {
const preferences = readDesktopPreferencesSource();
const tauriLib = readTauriLibSource();
@@ -313,30 +386,45 @@ describe("desktop layout shell", () => {
expect(preferences).toContain('window.addEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true })');
expect(preferences).toContain("event.stopImmediatePropagation()");
expect(preferences).toContain("恢复默认");
expect(tauriLib).not.toContain('Some("CmdOrCtrl+R")');
expect(tauriLib).not.toContain('Some("CmdOrCtrl+[")');
expect(tauriLib).not.toContain('Some("CmdOrCtrl+]")');
expect(tauriLib).toContain('Some("CmdOrCtrl+R")');
expect(tauriLib).toContain('Some("CmdOrCtrl+[")');
expect(tauriLib).toContain('Some("CmdOrCtrl+]")');
expect(tauriLib).toContain("desktop_menu_set_shortcuts");
expect(tauriLib).toContain("set_menu_accelerator");
});
it("moves overflowing workspace tabs into a fixed dropdown without horizontal scrolling", () => {
it("shows the four most recently opened tasks with a searchable all-tasks menu after five tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length" ref="workspaceTabsElement" class="workspace-tabs">');
const tabsStart = source.indexOf('<div v-if="workspaceTabs.length > 1" class="workspace-tabs">');
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
expect(tabsTemplate).toContain('v-for="item in visibleWorkspaceTabs"');
expect(tabsTemplate).toContain('v-if="overflowWorkspaceTabs.length"');
expect(tabsTemplate).toContain('v-if="hasWorkspaceTabsOverflow"');
expect(tabsTemplate).toContain('popper-class="workspace-tabs-overflow-popper"');
expect(tabsTemplate).toContain('{{ overflowWorkspaceTabs.length }}');
expect(tabsTemplate).toContain('<span>全部任务</span>');
expect(tabsTemplate).toContain('{{ workspaceTabs.length }}');
expect(tabsTemplate).toContain('@command="navigateOverflowWorkspaceTab"');
expect(tabsTemplate).toContain('gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))`');
expect(tabsTemplate).toContain('class="workspace-tabs-overflow-header"');
expect(source).toContain("const visibleWorkspaceTabCapacity = computed(() => {");
expect(tabsTemplate).toContain('v-model="workspaceTabsSearchQuery"');
expect(tabsTemplate).toContain('v-for="item in filteredWorkspaceTabs"');
expect(tabsTemplate).toContain('@click.stop="closeOtherWorkspaceTabs(activeMenu)"');
expect(tabsTemplate).toContain('@click.stop="closeAllWorkspaceTabs"');
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_OVERFLOW_THRESHOLD = 5;");
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT = 4;");
expect(source).toContain("const workspaceTabsByRecentUse = computed(() =>");
expect(source).toContain("const visibleWorkspaceTabs = computed(() => {");
expect(source).toContain("const overflowWorkspaceTabs = computed(() => {");
expect(source).toContain('workspaceTabsResizeObserver ||= new ResizeObserver(updateWorkspaceTabsAvailableWidth);');
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_OVERFLOW_WIDTH = 106;");
expect(source).toContain("const recentVisiblePaths = new Set(");
expect(source).toContain("workspaceTabsByRecentUse.value.slice(-DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT)");
expect(source).toContain("return workspaceTabs.value.filter((item) => recentVisiblePaths.has(item.path));");
expect(source).toContain("const filteredWorkspaceTabs = computed(() => {");
expect(source).toContain("const closeAllWorkspaceTabs = () => {");
expect(source).not.toContain("visibleWorkspaceTabCapacity");
expect(source).not.toContain("workspaceTabsResizeObserver");
expect(source).toContain(".workspace-tabs-list {\n display: grid;");
expect(source).toContain("padding: 0 10px;");
expect(source).toContain("padding: 0 14px;");
expect(source).toContain("overflow: hidden;");
expect(source).not.toContain("overflow-x: auto;");
});
@@ -584,6 +672,8 @@ describe("desktop layout shell", () => {
expect(profile).toContain("overflow: hidden;");
expect(profile).not.toContain("overflow: auto;");
expect(profile).toContain("overflow-wrap: anywhere;");
expect(profile).toContain(".dialog-close:hover");
expect(profile).toContain(".dialog-close :deep(.el-icon)");
expect(preferences).toContain("flex-wrap: wrap;");
expect(preferences).toContain("overflow-wrap: anywhere;");
});
@@ -183,6 +183,7 @@ describe("PermissionAccessLogs", () => {
expect(source).toContain("loadIpRankingData");
expect(source).toContain("fetchAccessRowsForIpRanking");
expect(source).toContain("buildIpRankingRows");
expect(source).toContain("const IP_RANKING_LIMIT = 10;");
expect(source).toContain('width="min(1180px, 96vw)"');
expect(source).toContain("ip-rank-grid");
expect(source).toContain("ip-rank-card");
@@ -325,7 +325,7 @@ const MetricIconSpeed = () => h("svg", { viewBox: "0 0 24 24", fill: "none", str
const REALTIME_POLL_INTERVAL_MS = 30_000;
const ACCESS_LOG_EXPORT_PAGE_SIZE = 200;
const IP_RANKING_PAGE_SIZE = 200;
const IP_RANKING_LIMIT = 20;
const IP_RANKING_LIMIT = 10;
const TIME_RANGE_PRESETS = [
{ value: "all", label: "全部", hours: null },
@@ -73,8 +73,11 @@ describe("PermissionIpLocations", () => {
it("uses backend coordinates and keeps unmapped locations in the ranking", () => {
const source = readSource();
expect(source).toContain('const mapView = ref<"china" | "flow">("flow");');
expect(source).toContain("item.longitude");
expect(source).toContain("item.latitude");
expect(source).toContain("external_fallback_ip_count");
expect(source).toContain("外部补全");
expect(source).toContain("chinaRankRows");
expect(source).toContain('全部(保留期 ${periodMetadata.value.retention_days} 天)');
expect(source).not.toContain('from "./worldMapSource"');
@@ -45,6 +45,9 @@
<el-tag v-if="dataQuality && dataQuality.unknown_ip_count" type="warning" effect="plain" round size="small">
定位 {{ dataQuality.location_coverage_rate }}%
</el-tag>
<el-tag v-if="dataQuality?.external_fallback_ip_count" type="info" effect="plain" round size="small">
外部补全 {{ dataQuality.external_fallback_ip_count }} IP
</el-tag>
<el-tooltip content="全屏预览" placement="top">
<el-button class="map-fullscreen-button" size="small" circle aria-label="全屏预览" @click="openFullscreenPreview">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H3v5"/><path d="M16 3h5v5"/><path d="M21 16v5h-5"/><path d="M3 16v5h5"/><path d="M3 3l6 6"/><path d="M21 3l-6 6"/><path d="M21 21l-6-6"/><path d="M3 21l6-6"/></svg>
@@ -205,7 +208,7 @@ const getIpLocationsDays = (preset: TimeRangePreset): number | undefined => {
};
return map[preset];
};
const mapView = ref<"china" | "flow">("china");
const mapView = ref<"china" | "flow">("flow");
const mapMetric = ref<"traffic" | "risk">("traffic");
const items = ref<IpLocationStatItem[]>([]);
const summary = ref<IpLocationsResponse["summary"] | null>(null);
+1 -1
View File
@@ -786,7 +786,7 @@ const refreshCurrentDesktopView = () => {
};
const handleDesktopMenuCommand = (command: string) => {
if (command === "ctms.desktop.commandPalette") {
if (command === "ctms.desktop.commandPalette" || command === "ctms.desktop.help") {
openCommandPalette();
return;
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
buildWorkspaceTabTitle,
buildWorkspaceToolbarTitle,
resolveWorkspaceTabContextTitle,
} from "./workspaceTabTitle";
describe("workspace tab title", () => {
it("keeps the module title when no detail context is available", () => {
expect(buildWorkspaceTabTitle({ moduleTitle: "参与者管理", routeTitle: "参与者详情" })).toBe("参与者管理");
});
it("combines the detail object type with its loaded identifier", () => {
expect(
buildWorkspaceTabTitle({
moduleTitle: "参与者管理",
routeTitle: "参与者详情",
contextTitle: "SUBJ-001",
}),
).toBe("参与者 · SUBJ-001");
});
it("uses a non-detail route title as the context prefix", () => {
expect(
buildWorkspaceTabTitle({
moduleTitle: "文件版本管理",
routeTitle: "文件版本管理",
contextTitle: "知情同意书",
}),
).toBe("文件版本管理 · 知情同意书");
});
it("avoids duplicate fallback titles", () => {
expect(
buildWorkspaceTabTitle({
moduleTitle: "设备管理",
routeTitle: "设备详情",
contextTitle: "设备详情",
}),
).toBe("设备详情");
});
it("keeps a loaded context when the same detail route temporarily clears its live context", () => {
expect(
resolveWorkspaceTabContextTitle({
routePath: "/subjects/subject-1",
storedContext: { routePath: "/subjects/subject-1", pageTitle: "SUBJ-001" },
}),
).toBe("SUBJ-001");
});
it("does not leak a stored title into another detail route", () => {
expect(
resolveWorkspaceTabContextTitle({
routePath: "/subjects/subject-2",
storedContext: { routePath: "/subjects/subject-1", pageTitle: "SUBJ-001" },
}),
).toBeUndefined();
});
it("uses the business object type in the desktop toolbar", () => {
expect(
buildWorkspaceToolbarTitle({
moduleTitle: "文件版本管理",
routeTitle: "文件版本管理",
contextTitle: "11",
contextObjectType: "SOP",
}),
).toBe("SOP · 11");
});
it("derives a concise object label from a detail route", () => {
expect(
buildWorkspaceToolbarTitle({
moduleTitle: "参与者管理",
routeTitle: "参与者详情",
contextTitle: "S01",
}),
).toBe("参与者 · S01");
});
});
@@ -0,0 +1,53 @@
interface WorkspaceTabTitleInput {
moduleTitle: string;
routeTitle?: string;
contextTitle?: string;
}
export interface WorkspaceTabContext {
routePath: string;
pageTitle: string;
objectType?: string;
}
interface WorkspaceTabContextTitleInput {
liveContextTitle?: string;
storedContext?: WorkspaceTabContext;
routePath: string;
}
export const resolveWorkspaceTabContextTitle = ({
liveContextTitle,
storedContext,
routePath,
}: WorkspaceTabContextTitleInput) =>
liveContextTitle || (storedContext?.routePath === routePath ? storedContext.pageTitle : undefined);
interface WorkspaceToolbarTitleInput extends WorkspaceTabTitleInput {
contextObjectType?: string;
}
export const buildWorkspaceToolbarTitle = ({
moduleTitle,
routeTitle,
contextTitle,
contextObjectType,
}: WorkspaceToolbarTitleInput) => {
const contextLabel = contextTitle?.trim();
if (!contextLabel) return moduleTitle.trim();
const objectLabel = contextObjectType?.trim() || routeTitle?.replace(/详情$/, "").trim() || moduleTitle.trim();
if (!objectLabel || contextLabel === objectLabel) return contextLabel;
return `${objectLabel} · ${contextLabel}`;
};
export const buildWorkspaceTabTitle = ({ moduleTitle, routeTitle, contextTitle }: WorkspaceTabTitleInput) => {
const moduleLabel = moduleTitle.trim();
const routeLabel = routeTitle?.trim() || moduleLabel;
const contextLabel = contextTitle?.trim();
if (!contextLabel || contextLabel === moduleLabel) return moduleLabel;
if (contextLabel === routeLabel) return routeLabel;
const objectLabel = routeLabel.replace(/详情$/, "").trim() || moduleLabel;
if (contextLabel === objectLabel) return routeLabel;
return `${objectLabel} · ${contextLabel}`;
};
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_DESKTOP_SHORTCUTS } from "./desktopUiPreferences";
import { syncDesktopMenuShortcuts } from "./desktopMenu";
const invokeMock = vi.hoisted(() => vi.fn());
vi.mock("@tauri-apps/api/core", () => ({
invoke: invokeMock,
}));
afterEach(() => {
invokeMock.mockReset();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
});
describe("desktop native menu", () => {
it("does not invoke desktop commands in the web runtime", async () => {
await syncDesktopMenuShortcuts(DEFAULT_DESKTOP_SHORTCUTS);
expect(invokeMock).not.toHaveBeenCalled();
});
it("synchronizes validated shortcut preferences through the controlled command", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
await syncDesktopMenuShortcuts(DEFAULT_DESKTOP_SHORTCUTS);
expect(invokeMock).toHaveBeenCalledWith("desktop_menu_set_shortcuts", {
shortcuts: DEFAULT_DESKTOP_SHORTCUTS,
});
});
});
+30 -1
View File
@@ -1,17 +1,46 @@
import { isTauriRuntime } from "./platform";
import {
DESKTOP_SHORTCUTS_CHANGED_EVENT,
readDesktopShortcutPreferences,
type DesktopShortcutPreferences,
} from "./desktopUiPreferences";
export const DESKTOP_MENU_COMMAND_EVENT = "ctms:desktop-menu-command";
export type DesktopMenuCommand =
| "ctms.desktop.commandPalette"
| "ctms.desktop.help"
| "ctms.desktop.preferences"
| "ctms.desktop.serverSettings"
| "ctms.desktop.refresh"
| "ctms.desktop.back"
| "ctms.desktop.forward";
| "ctms.desktop.forward"
| "ctms.desktop.toggleSidebar";
type Unlisten = () => void;
let shortcutSyncInitialized = false;
export const syncDesktopMenuShortcuts = async (shortcuts: DesktopShortcutPreferences): Promise<void> => {
if (!isTauriRuntime()) return;
const { invoke } = await import("@tauri-apps/api/core");
await invoke("desktop_menu_set_shortcuts", { shortcuts });
};
export const initializeDesktopMenuShortcutSync = (): void => {
if (shortcutSyncInitialized || !isTauriRuntime() || typeof window === "undefined") return;
shortcutSyncInitialized = true;
const sync = (event?: Event) => {
const shortcuts =
(event as CustomEvent<DesktopShortcutPreferences> | undefined)?.detail || readDesktopShortcutPreferences();
void syncDesktopMenuShortcuts(shortcuts).catch(() => {});
};
window.addEventListener(DESKTOP_SHORTCUTS_CHANGED_EVENT, sync);
sync();
};
export const listenDesktopMenuCommand = async (
handler: (command: DesktopMenuCommand | string) => void,
): Promise<Unlisten> => {
@@ -1,22 +1,31 @@
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_DESKTOP_SHORTCUTS,
DESKTOP_FAVORITE_ROUTES_KEY,
DESKTOP_SHORTCUTS_KEY,
DESKTOP_SIDEBAR_VISIBLE_KEY,
DESKTOP_THEME_KEY,
applyDesktopThemePreference,
clearLegacyDesktopRouteHistoryPreference,
desktopShortcutFromKeyboardEvent,
readDesktopFavoriteRoutes,
readDesktopShortcutPreferences,
readDesktopSidebarVisible,
readDesktopThemePreference,
resetDesktopShortcutPreferences,
resolveDesktopShortcutAction,
setDesktopShortcutPreference,
setDesktopSidebarVisible,
setDesktopThemePreference,
toggleDesktopFavoriteRoute,
} from "./desktopUiPreferences";
const invokeMock = vi.hoisted(() => vi.fn());
vi.mock("@tauri-apps/api/core", () => ({
invoke: invokeMock,
}));
const installLocalStorageMock = () => {
const store = new Map<string, string>();
Object.defineProperty(window, "localStorage", {
@@ -32,11 +41,18 @@ const installLocalStorageMock = () => {
describe("desktop UI preferences", () => {
beforeEach(() => {
invokeMock.mockReset();
installLocalStorageMock();
document.documentElement.removeAttribute("data-ctms-theme");
document.documentElement.removeAttribute("data-ctms-theme-preference");
document.documentElement.style.colorScheme = "";
});
afterEach(() => {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
vi.unstubAllGlobals();
});
it("stores only route metadata for desktop favorites", () => {
toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者", group: "当前项目" });
@@ -76,7 +92,11 @@ describe("desktop UI preferences", () => {
});
it("stores and applies only the desktop theme enum", () => {
expect(readDesktopThemePreference()).toBe("light");
expect(readDesktopThemePreference()).toBe("system");
applyDesktopThemePreference();
expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("light");
expect(document.documentElement.getAttribute("data-ctms-theme-preference")).toBe("system");
setDesktopThemePreference("dark");
@@ -89,6 +109,37 @@ describe("desktop UI preferences", () => {
expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("light");
});
it("persists the desktop sidebar visibility without storing business data", () => {
expect(readDesktopSidebarVisible()).toBe(true);
expect(setDesktopSidebarVisible(false)).toBe(false);
expect(readDesktopSidebarVisible()).toBe(false);
expect(window.localStorage.getItem(DESKTOP_SIDEBAR_VISIBLE_KEY)).toBe("false");
expect(setDesktopSidebarVisible(true)).toBe(true);
expect(readDesktopSidebarVisible()).toBe(true);
});
it("resolves the system desktop theme through prefers-color-scheme", () => {
vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: true })));
applyDesktopThemePreference("system");
expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("dark");
expect(document.documentElement.getAttribute("data-ctms-theme-preference")).toBe("system");
expect(document.documentElement.style.colorScheme).toBe("dark");
});
it("synchronizes the selected theme through the controlled desktop command", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
setDesktopThemePreference("system");
await vi.waitFor(() => {
expect(invokeMock).toHaveBeenCalledWith("desktop_window_set_theme", { theme: "system" });
});
});
it("stores validated non-conflicting desktop shortcuts", () => {
const result = setDesktopShortcutPreference("back", "Mod+Shift+[");
+62 -5
View File
@@ -1,10 +1,14 @@
import { isTauriRuntime } from "./platform";
export const DESKTOP_FAVORITE_ROUTES_KEY = "ctms_desktop_favorite_routes";
export const DESKTOP_THEME_KEY = "ctms_desktop_theme";
export const DESKTOP_THEME_CHANGED_EVENT = "ctms:desktop-theme-changed";
export const DESKTOP_SHORTCUTS_KEY = "ctms_desktop_shortcuts";
export const DESKTOP_SHORTCUTS_CHANGED_EVENT = "ctms:desktop-shortcuts-changed";
export const DESKTOP_SIDEBAR_VISIBLE_KEY = "ctms_desktop_sidebar_visible";
export type DesktopThemePreference = "light" | "dark";
export type DesktopThemePreference = "system" | "light" | "dark";
export type ResolvedDesktopTheme = "light" | "dark";
export type DesktopShortcutAction = "back" | "forward" | "refresh";
export type DesktopShortcutPreferences = Record<DesktopShortcutAction, string>;
@@ -22,7 +26,7 @@ export interface DesktopRoutePreference {
}
const MAX_FAVORITE_ROUTES = 12;
const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "light";
const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "system";
const DESKTOP_THEME_ATTRIBUTE = "data-ctms-theme";
const LEGACY_DESKTOP_ROUTE_HISTORY_KEY = "ctms_desktop_recent_routes";
const DESKTOP_SHORTCUT_ACTIONS: DesktopShortcutAction[] = ["back", "forward", "refresh"];
@@ -75,7 +79,25 @@ const writeRoutePreferences = (key: string, routes: DesktopRoutePreference[]) =>
};
const sanitizeDesktopThemePreference = (value: unknown): DesktopThemePreference =>
value === "dark" ? "dark" : DEFAULT_DESKTOP_THEME;
value === "light" || value === "dark" || value === "system" ? value : DEFAULT_DESKTOP_THEME;
const resolveSystemTheme = (): ResolvedDesktopTheme => {
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return "light";
};
const resolveDesktopTheme = (theme: DesktopThemePreference): ResolvedDesktopTheme =>
theme === "system" ? resolveSystemTheme() : theme;
const syncDesktopWindowTheme = async (theme: DesktopThemePreference): Promise<void> => {
if (!isTauriRuntime()) return;
const { invoke } = await import("@tauri-apps/api/core");
await invoke("desktop_window_set_theme", { theme });
};
let desktopThemeListenerInitialized = false;
const sanitizeDesktopShortcut = (value: unknown): string | null => {
if (typeof value !== "string") return null;
@@ -188,15 +210,50 @@ export const readDesktopThemePreference = (): DesktopThemePreference => {
return sanitizeDesktopThemePreference(window.localStorage.getItem(DESKTOP_THEME_KEY));
};
export const readDesktopSidebarVisible = (): boolean => {
if (!isStorageAvailable()) return true;
return window.localStorage.getItem(DESKTOP_SIDEBAR_VISIBLE_KEY) !== "false";
};
export const setDesktopSidebarVisible = (visible: boolean): boolean => {
if (isStorageAvailable()) {
window.localStorage.setItem(DESKTOP_SIDEBAR_VISIBLE_KEY, visible ? "true" : "false");
}
return visible;
};
export const applyDesktopThemePreference = (theme: DesktopThemePreference = readDesktopThemePreference()): DesktopThemePreference => {
const nextTheme = sanitizeDesktopThemePreference(theme);
const resolvedTheme = resolveDesktopTheme(nextTheme);
if (typeof document !== "undefined") {
document.documentElement.setAttribute(DESKTOP_THEME_ATTRIBUTE, nextTheme);
document.documentElement.style.colorScheme = nextTheme;
document.documentElement.setAttribute(DESKTOP_THEME_ATTRIBUTE, resolvedTheme);
document.documentElement.setAttribute("data-ctms-theme-preference", nextTheme);
document.documentElement.style.colorScheme = resolvedTheme;
}
void syncDesktopWindowTheme(nextTheme).catch(() => {});
return nextTheme;
};
export const initializeDesktopThemePreference = (): DesktopThemePreference => {
const theme = applyDesktopThemePreference();
if (
desktopThemeListenerInitialized ||
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return theme;
}
desktopThemeListenerInitialized = true;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", () => {
if (readDesktopThemePreference() === "system") {
applyDesktopThemePreference("system");
}
});
return theme;
};
export const setDesktopThemePreference = (theme: DesktopThemePreference): DesktopThemePreference => {
const nextTheme = sanitizeDesktopThemePreference(theme);
if (isStorageAvailable()) {
+7
View File
@@ -13,30 +13,37 @@ export {
DEFAULT_DESKTOP_SHORTCUTS,
DESKTOP_SHORTCUTS_CHANGED_EVENT,
DESKTOP_SHORTCUTS_KEY,
DESKTOP_SIDEBAR_VISIBLE_KEY,
DESKTOP_THEME_CHANGED_EVENT,
DESKTOP_THEME_KEY,
applyDesktopThemePreference,
clearLegacyDesktopRouteHistoryPreference,
desktopShortcutFromKeyboardEvent,
formatDesktopShortcut,
initializeDesktopThemePreference,
isDesktopFavoriteRoute,
readDesktopFavoriteRoutes,
readDesktopShortcutPreferences,
readDesktopSidebarVisible,
readDesktopThemePreference,
resetDesktopShortcutPreferences,
resolveDesktopShortcutAction,
setDesktopShortcutPreference,
setDesktopSidebarVisible,
setDesktopThemePreference,
toggleDesktopFavoriteRoute,
type DesktopRoutePreference,
type DesktopShortcutAction,
type DesktopShortcutPreferences,
type DesktopThemePreference,
type ResolvedDesktopTheme,
type SetDesktopShortcutResult,
} from "./desktopUiPreferences";
export {
DESKTOP_MENU_COMMAND_EVENT,
initializeDesktopMenuShortcutSync,
listenDesktopMenuCommand,
syncDesktopMenuShortcuts,
type DesktopMenuCommand,
} from "./desktopMenu";
export {
+8 -2
View File
@@ -4,6 +4,12 @@ import { fetchStudies } from "../api/studies";
import { fetchMyApiEndpointPermissions } from "../api/projectPermissions";
import type { Study } from "../types/api";
export interface StudyViewContext {
siteName?: string;
pageTitle?: string;
objectType?: string;
}
const STUDY_KEY = "ctms_current_study";
const STUDY_ROLE_KEY = "ctms_current_study_role";
const SITE_KEY = "ctms_current_site";
@@ -13,7 +19,7 @@ export const useStudyStore = defineStore("study", () => {
const currentStudy = ref<Study | null>(null);
const currentStudyRole = ref<string | null>(null);
const currentSite = ref<any | null>(null);
const viewContext = ref<{ siteName?: string; pageTitle?: string } | null>(null);
const viewContext = ref<StudyViewContext | null>(null);
const currentPermissions = ref<any | null>(null);
const normalizeUserKey = (value: string) => value.trim().toLowerCase();
@@ -229,7 +235,7 @@ export const useStudyStore = defineStore("study", () => {
}
};
const setViewContext = (ctx: { siteName?: string; pageTitle?: string } | null) => {
const setViewContext = (ctx: StudyViewContext | null) => {
viewContext.value = ctx;
};
+5
View File
@@ -65,10 +65,14 @@ export interface UserLoginActivity {
client_type: "web" | "desktop";
client_platform?: string | null;
client_version?: string | null;
client_source?: string | null;
login_ip?: string | null;
ip_location?: string | null;
login_at: string;
last_seen_at: string;
ended_at?: string | null;
end_reason?: string | null;
activity_status?: "ONLINE" | "OFFLINE" | "ENDED";
}
export type UserMeResponse = UserInfo;
@@ -689,6 +693,7 @@ export interface IpLocationsResponse {
located_ip_count: number;
private_ip_count: number;
unknown_ip_count: number;
external_fallback_ip_count?: number;
location_coverage_rate: number;
returned_region_count: number;
};
+6 -2
View File
@@ -313,6 +313,7 @@ const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateSta
let updateStatusUnlisten: (() => void) | undefined;
let connectionPendingTimer: number | undefined;
const themeOptions = [
{ value: "system" as const, label: "跟随系统", icon: Monitor },
{ value: "light" as const, label: "明亮", icon: Sunny },
{ value: "dark" as const, label: "暗黑", icon: Moon },
];
@@ -1358,7 +1359,9 @@ h3 {
* ===================================================== */
.theme-segmented {
display: inline-grid;
grid-template-columns: repeat(2, minmax(72px, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
flex: 0 1 336px;
width: min(100%, 336px);
gap: 3px;
padding: 3px;
border: 1px solid var(--pref-border-card);
@@ -1372,7 +1375,7 @@ h3 {
align-items: center;
justify-content: center;
gap: 6px;
min-width: 72px;
min-width: 0;
height: 28px;
padding: 0 10px;
border: 0;
@@ -1383,6 +1386,7 @@ h3 {
font: inherit;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
transition:
background-color 140ms ease,
color 140ms ease,
@@ -24,6 +24,10 @@ describe("DesktopProjectEntry", () => {
expect(source).toContain("project-cards-grid");
expect(source).toContain("card-action-bar");
expect(source).toContain("进入项目工作空间");
expect(source).toContain('v-if="isAdmin || project.role_in_study"');
expect(source).toContain('<span class="meta-label">我的角色</span>');
expect(source).toContain('isAdmin.value ? "系统管理员" : project.role_in_study || "未分配"');
expect(source).not.toContain('project.role_in_study && !isAdmin');
expect(source).toContain('router.push(isAdmin.value ? "/admin/users" : "/admin/projects")');
expect(source).toContain("studyStore.clearCurrentStudy()");
expect(source).toContain("studyStore.setCurrentStudy(pmProject)");
+4 -3
View File
@@ -109,9 +109,9 @@
<span class="meta-label">申办方</span>
<span class="meta-val">{{ project.sponsor }}</span>
</div>
<div v-if="project.role_in_study && !isAdmin" class="meta-item">
<span class="meta-label">分配角色</span>
<span class="meta-val">{{ project.role_in_study }}</span>
<div v-if="isAdmin || project.role_in_study" class="meta-item">
<span class="meta-label">我的角色</span>
<span class="meta-val" :title="projectRoleLabel(project)">{{ projectRoleLabel(project) }}</span>
</div>
</div>
@@ -180,6 +180,7 @@ const entrySubtitle = computed(() =>
const statusLabel = (status: string | undefined) =>
status ? TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status : "未知";
const projectRoleLabel = (project: Study) => isAdmin.value ? "系统管理员" : project.role_in_study || "未分配";
const loadProjects = async () => {
loading.value = true;
+20 -1
View File
@@ -316,10 +316,29 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
}
.dialog-close {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-top: -4px;
color: #718096;
padding: 0;
border: 1px solid #d7dee8;
border-radius: 8px;
background: #f8fafc;
color: #475569;
}
.dialog-close:hover,
.dialog-close:focus-visible {
border-color: #b8c4d3;
background: #eef2f7;
color: #172033;
}
.dialog-close :deep(.el-icon) {
font-size: 18px;
}
.title {
@@ -15,6 +15,10 @@ describe("WebWorkbenchEntry", () => {
expect(source).toContain("换项目先回工作台");
expect(source).toContain("项目内不直接切换项目");
expect(source).toContain("项目工作区");
expect(source).toContain('v-if="isAdmin || project.role_in_study"');
expect(source).toContain('<span class="meta-label">我的角色</span>');
expect(source).toContain('isAdmin.value ? "系统管理员" : project.role_in_study || "未分配"');
expect(source).not.toContain('project.role_in_study && !isAdmin');
expect(source).toContain("fetchStudies()");
expect(source).toContain('projects.value.some((project) => project.role_in_study === "PM")');
expect(source).toContain('v-if="canEnterManagement"');
+3 -2
View File
@@ -134,9 +134,9 @@
<span class="meta-label">申办方</span>
<span class="meta-val" :title="project.sponsor">{{ project.sponsor }}</span>
</div>
<div v-if="project.role_in_study && !isAdmin" class="meta-item">
<div v-if="isAdmin || project.role_in_study" class="meta-item">
<span class="meta-label">我的角色</span>
<span class="meta-val" :title="project.role_in_study">{{ project.role_in_study }}</span>
<span class="meta-val" :title="projectRoleLabel(project)">{{ projectRoleLabel(project) }}</span>
</div>
</div>
@@ -219,6 +219,7 @@ const managementActionLabel = computed(() => canEnterManagement.value ? "进入
const statusLabel = (status: string | undefined) =>
status ? TEXT.enums.projectStatus[status as keyof typeof TEXT.enums.projectStatus] || status : "未知";
const projectRoleLabel = (project: Study) => isAdmin.value ? "系统管理员" : project.role_in_study || "未分配";
const loadProjects = async () => {
loading.value = true;
@@ -3,7 +3,7 @@
v-model="visibleProxy"
title="登录记录"
direction="rtl"
size="min(520px, 92vw)"
size="min(760px, 96vw)"
destroy-on-close
>
<div class="login-activity-drawer">
@@ -18,28 +18,50 @@
</span>
</div>
<el-table v-loading="loading" :data="activities" class="login-activity-table" empty-text="暂无登录记录">
<div class="activity-view-toolbar">
<div>
<strong>{{ activityViewMode === 'source' ? '最近登录来源' : '全部登录会话' }}</strong>
<span v-if="activityViewMode === 'all'">展示最近 100 条原始登录会话</span>
</div>
<el-segmented v-model="activityViewMode" :options="activityViewOptions" size="small" />
</div>
<el-table v-loading="loading" :data="displayActivities" class="login-activity-table" empty-text="暂无登录记录">
<el-table-column label="状态" width="102">
<template #default="scope">
<span class="status-badge compact" :class="activityStatusClass(scope.row)">
<span class="status-badge-dot"></span>
<span>{{ activityStatusLabel(scope.row) }}</span>
</span>
<el-tooltip :content="activityStatusDescription(scope.row)" placement="top">
<span class="status-badge compact" :class="activityStatusClass(scope.row)">
<span class="status-badge-dot"></span>
<span>{{ activityStatusLabel(scope.row) }}</span>
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="客户端" min-width="130">
<el-table-column label="客户端" min-width="150">
<template #default="scope">
<div class="client-cell">
<strong>{{ scope.row.client_type === 'desktop' ? '桌面端' : '网页端' }}</strong>
<span>{{ clientDescription(scope.row) }}</span>
<el-tag v-if="scope.row.grouped_count > 1" type="info" effect="plain" size="small">
合并 {{ scope.row.grouped_count }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="登录 / 最近活动" min-width="160">
<el-table-column label="登录 IP(位置)" min-width="190">
<template #default="scope">
<div class="source-cell">
<strong>{{ scope.row.login_ip || 'IP 未记录' }}</strong>
<span>{{ scope.row.ip_location || '--' }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="登录 / 最近活动" min-width="190">
<template #default="scope">
<div class="time-cell">
<span>登录 {{ displayDateTime(scope.row.login_at) }}</span>
<span>活动 {{ displayDateTime(scope.row.last_seen_at) }}</span>
<span v-if="scope.row.ended_at">退出 {{ displayDateTime(scope.row.ended_at) }}</span>
</div>
</template>
</el-table-column>
@@ -63,22 +85,70 @@ const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const activities = ref<UserLoginActivity[]>([]);
const loading = ref(false);
const activityViewMode = ref<"source" | "all">("source");
const activityViewOptions = [
{ label: "最近来源", value: "source" },
{ label: "全部会话", value: "all" },
];
const visibleProxy = computed({
get: () => props.modelValue,
set: (value: boolean) => emit("update:modelValue", value),
});
const isRecent = (value: string) => Date.now() - new Date(value).getTime() <= 5 * 60 * 1000;
type DisplayLoginActivity = UserLoginActivity & { grouped_count: number };
const resolvedActivityStatus = (item: UserLoginActivity) => {
if (item.activity_status) return item.activity_status;
if (item.ended_at) return "ENDED";
return Date.now() - new Date(item.last_seen_at).getTime() <= 5 * 60 * 1000 ? "ONLINE" : "OFFLINE";
};
const activityStatusLabel = (item: UserLoginActivity) => {
if (item.ended_at) return "已退出";
return isRecent(item.last_seen_at) ? "在线" : "已离线";
const status = resolvedActivityStatus(item);
if (status === "ENDED") return "已退出";
return status === "ONLINE" ? "在线" : "已离线";
};
const activityStatusClass = (item: UserLoginActivity) => {
if (item.ended_at) return "is-ended";
return isRecent(item.last_seen_at) ? "is-online" : "is-offline";
const status = resolvedActivityStatus(item);
if (status === "ENDED") return "is-ended";
return status === "ONLINE" ? "is-online" : "is-offline";
};
const activityStatusDescription = (item: UserLoginActivity) => {
const status = resolvedActivityStatus(item);
if (status === "ENDED") return "服务端已收到明确的退出请求";
if (status === "ONLINE") return "最近心跳仍在服务端在线判定时间内";
return "未明确退出,但最近心跳已超过服务端在线判定时间";
};
const clientDescription = (item: UserLoginActivity) =>
[item.client_platform, item.client_version].filter(Boolean).join(" · ") || "--";
const activitySourceKey = (item: UserLoginActivity) => {
if (!item.login_ip) return `session:${item.id}`;
return [item.login_ip, item.client_type, item.client_platform || "", item.client_source || ""].join("|");
};
const sourceActivities = computed<DisplayLoginActivity[]>(() => {
const rows: DisplayLoginActivity[] = [];
const groupedHistory = new Map<string, DisplayLoginActivity>();
activities.value.forEach((item) => {
const row: DisplayLoginActivity = { ...item, grouped_count: 1 };
if (resolvedActivityStatus(item) === "ONLINE") {
rows.push(row);
return;
}
const key = activitySourceKey(item);
const existing = groupedHistory.get(key);
if (existing) {
existing.grouped_count += 1;
return;
}
groupedHistory.set(key, row);
rows.push(row);
});
return rows;
});
const displayActivities = computed<DisplayLoginActivity[]>(() =>
activityViewMode.value === "source"
? sourceActivities.value
: activities.value.map((item) => ({ ...item, grouped_count: 1 })),
);
const loadActivities = async () => {
if (!props.user) return;
@@ -96,7 +166,10 @@ const loadActivities = async () => {
watch(
() => props.modelValue,
(isOpen) => {
if (isOpen) void loadActivities();
if (isOpen) {
activityViewMode.value = "source";
void loadActivities();
}
},
{ immediate: true },
);
@@ -209,6 +282,32 @@ watch(
overflow: hidden;
}
.activity-view-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 2px;
}
.activity-view-toolbar > div {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.activity-view-toolbar strong {
color: #334155;
font-size: 13px;
}
.activity-view-toolbar span {
color: #64748b;
font-size: 11.5px;
line-height: 1.45;
}
.login-activity-table :deep(.el-table__header-wrapper) th {
background-color: #f8fafc !important;
color: #475569 !important;
@@ -224,6 +323,7 @@ watch(
}
.client-cell,
.source-cell,
.time-cell {
display: flex;
flex-direction: column;
@@ -237,12 +337,35 @@ watch(
}
.client-cell span,
.source-cell span,
.time-cell span {
color: #64748b;
font-size: 11.5px;
}
.client-cell .el-tag {
align-self: flex-start;
}
.source-cell strong {
color: #334155;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11.5px;
font-weight: 600;
}
.time-cell span {
line-height: 1.4;
}
@media (max-width: 680px) {
.activity-view-toolbar {
align-items: stretch;
flex-direction: column;
}
.activity-view-toolbar :deep(.el-segmented) {
align-self: flex-start;
}
}
</style>
@@ -5,7 +5,7 @@ import { resolve } from "node:path";
const readSource = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
describe("admin user login status", () => {
it("shows server-provided session state and opens a privacy-safe activity drawer", () => {
it("shows server-provided session state with login source details and grouped history", () => {
const users = readSource("./Users.vue");
const drawer = readSource("./UserLoginActivitiesDrawer.vue");
@@ -15,8 +15,21 @@ describe("admin user login status", () => {
expect(users).toContain("openLoginActivities");
expect(users).toContain("UserLoginActivitiesDrawer");
expect(drawer).toContain("登录记录");
expect(drawer).not.toContain("不会展示 Token、密码或原始 IP");
expect(drawer).toContain("登录 IP(位置)");
expect(drawer).toContain("scope.row.login_ip");
expect(drawer).toContain("scope.row.ip_location");
expect(drawer).toContain("activity_status");
expect(drawer).toContain("resolvedActivityStatus");
expect(drawer).toContain("最近来源");
expect(drawer).toContain("全部会话");
expect(drawer).toContain("activitySourceKey");
expect(drawer).toContain("grouped_count");
expect(drawer).toContain("scope.row.ended_at");
expect(drawer).not.toContain("const isRecent");
expect(drawer).not.toContain("access_token");
const usersApi = readSource("../../api/users.ts");
expect(usersApi).toContain("limit = 100");
});
it("offers responsive account and login-status filters with clear reset feedback", () => {
@@ -93,6 +93,7 @@ describe("DocumentDetail breadcrumbs", () => {
expect(source).toContain("study.setViewContext({");
expect(source).toContain("siteName: displaySite(detail.site_id)");
expect(source).toContain("pageTitle: detail.title || TEXT.modules.fileVersionManagement.title");
expect(source).toContain("objectType: detail.doc_type ? displayText(detail.doc_type, TEXT.enums.documentType) : undefined");
});
});
@@ -478,6 +478,7 @@ const syncBreadcrumbContext = () => {
study.setViewContext({
siteName: displaySite(detail.site_id),
pageTitle: detail.title || TEXT.modules.fileVersionManagement.title,
objectType: detail.doc_type ? displayText(detail.doc_type, TEXT.enums.documentType) : undefined,
});
};