style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标

This commit is contained in:
Cheng Zhou
2026-07-08 20:20:41 +08:00
parent e7b18758b2
commit b73f23c1eb
33 changed files with 2323 additions and 379 deletions
@@ -50,6 +50,7 @@ npm run desktop:build:app
- [ ] Tauri command 白名单仅包含凭据和更新命令。 - [ ] Tauri command 白名单仅包含凭据和更新命令。
- [ ] 前端源码不通过 query string 传递 token。 - [ ] 前端源码不通过 query string 传递 token。
- [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。 - [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。
- [ ] 登录表单密码不写入 `localStorage``sessionStorage`;Web 端只使用浏览器凭据管理能力,Desktop 端只使用系统凭据库。
- [ ] 系统通知只能通过 `frontend/src/runtime/notifications.ts` 发送,标题和正文保持通用。 - [ ] 系统通知只能通过 `frontend/src/runtime/notifications.ts` 发送,标题和正文保持通用。
- [ ] 通知 capability 只暴露权限查询、权限请求和发送通知,不使用 `notification:default` - [ ] 通知 capability 只暴露权限查询、权限请求和发送通知,不使用 `notification:default`
- [ ] opener capability 只允许打开 `$TEMP/ctms-desktop/**` 下的临时文件,不开放 URL 或 reveal 权限。 - [ ] opener capability 只允许打开 `$TEMP/ctms-desktop/**` 下的临时文件,不开放 URL 或 reveal 权限。
@@ -61,6 +62,7 @@ npm run desktop:build:app
人工复审还必须确认: 人工复审还必须确认:
- [ ] token 不出现在 URL、日志、系统通知正文、下载链接或持久化业务缓存中。 - [ ] token 不出现在 URL、日志、系统通知正文、下载链接或持久化业务缓存中。
- [ ] 密码不出现在 URL、日志、系统通知正文、诊断信息或明文浏览器存储中。
- [ ] 桌面端通知正文只显示通用内容,不包含项目、文件或版本详情。 - [ ] 桌面端通知正文只显示通用内容,不包含项目、文件或版本详情。
- [ ] 服务端权限、审计和业务数据持久化仍由 FastAPI 后端裁决。 - [ ] 服务端权限、审计和业务数据持久化仍由 FastAPI 后端裁决。
- [ ] Web 运行时不直接导入 Tauri API。 - [ ] Web 运行时不直接导入 Tauri API。
@@ -70,6 +72,7 @@ npm run desktop:build:app
| 场景 | Web | macOS Desktop | 预期 | | 场景 | Web | macOS Desktop | 预期 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 | | 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
| 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 |
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 | | 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 | | 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 | | 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
@@ -88,7 +91,8 @@ npm run desktop:build:app
## 4. 桌面体验验收 ## 4. 桌面体验验收
- [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。 - [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。
- [ ] 30 天免登录仍只保存系统凭据库会话记录,不保存密码,不把 token 写入 URL、日志、通知正文或业务缓存。 - [ ] 30 天免登录仍只保存系统凭据库会话记录,不把 token 写入 URL、日志、通知正文或业务缓存。
- [ ] 记住密码与 30 天免登录使用独立凭据记录;服务器切换后不复用旧服务器保存的密码。
- [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。 - [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。
- [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。 - [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。
- [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。 - [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。
+2 -1
View File
@@ -60,6 +60,7 @@
- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。 - `desktopServerConfig`:管理桌面服务端地址配置和切换事件。
- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。 - `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。
- 桌面端允许保存后端签发的最长 30 天在线会话,用于重启 App 后免输入密码;该会话必须存放在系统凭据库中,启动后仍需由后端 token 和 `/me` 校验确认身份,不等同于离线登录。 - 桌面端允许保存后端签发的最长 30 天在线会话,用于重启 App 后免输入密码;该会话必须存放在系统凭据库中,启动后仍需由后端 token 和 `/me` 校验确认身份,不等同于离线登录。
- `savedLoginCredentials`:隔离网页端浏览器凭据管理与桌面端系统凭据库中的登录表单密码保存;不得把密码写入 `localStorage``sessionStorage`、URL、日志或通知正文,且不等同于离线登录。
- `files`:隔离浏览器上传下载与原生文件能力。 - `files`:隔离浏览器上传下载与原生文件能力。
- `notifications`:隔离 Web 通知与桌面系统通知。 - `notifications`:隔离 Web 通知与桌面系统通知。
- `updates`:隔离桌面自动更新检查与安装入口。 - `updates`:隔离桌面自动更新检查与安装入口。
@@ -75,7 +76,7 @@
- 非本地服务连接优先使用 HTTPS。 - 非本地服务连接优先使用 HTTPS。
- 认证与授权决策保留在后端。 - 认证与授权决策保留在后端。
- 审计敏感决策保留在后端。 - 审计敏感决策保留在后端。
- 敏感凭据必须继续使用明确批准的安全存储方案。 - 敏感凭据必须继续使用明确批准的安全存储方案;网页端密码只能交给浏览器凭据管理能力,桌面端密码只能交给系统凭据库
- 不向前端暴露宽泛文件系统访问权限。 - 不向前端暴露宽泛文件系统访问权限。
- Tauri 权限保持最小化,并按功能精确授权。 - Tauri 权限保持最小化,并按功能精确授权。
- 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。 - 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。
+61 -1
View File
@@ -9,7 +9,67 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app">
<style>
.ctms-boot-splash {
display: grid;
min-height: 100vh;
min-height: 100dvh;
place-items: center;
background:
radial-gradient(circle at 22% 12%, rgba(58, 120, 183, 0.14), transparent 30%),
linear-gradient(135deg, #f4f7fb 0%, #e8eef5 100%);
color: #142033;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.ctms-boot-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 22px;
border: 1px solid rgba(191, 203, 217, 0.78);
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 20px 48px rgba(43, 63, 87, 0.12);
}
.ctms-boot-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 14px;
background: #183b63;
color: #ffffff;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
}
.ctms-boot-title {
margin: 0;
font-size: 15px;
font-weight: 800;
}
.ctms-boot-subtitle {
margin: 4px 0 0;
color: #66758b;
font-size: 12px;
}
</style>
<div class="ctms-boot-splash">
<div class="ctms-boot-card">
<div class="ctms-boot-mark">CTMS</div>
<div>
<p class="ctms-boot-title">正在启动 CTMS</p>
<p class="ctms-boot-subtitle">正在加载桌面客户端...</p>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
@@ -184,6 +184,9 @@ const verifyRustBoundary = async () => {
"credentials::credential_get", "credentials::credential_get",
"credentials::credential_set", "credentials::credential_set",
"credentials::credential_delete", "credentials::credential_delete",
"credentials::login_credential_get",
"credentials::login_credential_set",
"credentials::login_credential_delete",
"updates::desktop_update_check", "updates::desktop_update_check",
"updates::desktop_update_install", "updates::desktop_update_install",
]; ];
@@ -211,6 +214,10 @@ const verifySourceSafety = async () => {
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") { if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`); fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
} }
assert(
!/(?:localStorage|sessionStorage)\.setItem\([^)]*password/i.test(source),
`${file}: passwords must not be written to browser storage.`,
);
if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") { if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") {
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`); fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
} }
+78 -7
View File
@@ -1,7 +1,8 @@
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use url::Url; use url::Url;
const CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session"; const SESSION_CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session";
const LOGIN_CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.login";
fn credential_account(server_origin: &str) -> Result<String, String> { fn credential_account(server_origin: &str) -> Result<String, String> {
let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?; let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?;
@@ -23,9 +24,9 @@ fn credential_account(server_origin: &str) -> Result<String, String> {
} }
#[cfg(any(target_os = "macos", windows))] #[cfg(any(target_os = "macos", windows))]
fn get_entry(server_origin: &str) -> Result<keyring::Entry, String> { fn get_entry(service: &str, server_origin: &str) -> Result<keyring::Entry, String> {
let account = credential_account(server_origin)?; let account = credential_account(server_origin)?;
keyring::Entry::new(CREDENTIAL_SERVICE, &account) keyring::Entry::new(service, &account)
.map_err(|error| format!("无法访问系统凭据库:{error}")) .map_err(|error| format!("无法访问系统凭据库:{error}"))
} }
@@ -34,7 +35,7 @@ pub async fn credential_get(server_origin: String) -> Result<Option<String>, Str
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))] #[cfg(any(target_os = "macos", windows))]
{ {
let entry = get_entry(&server_origin)?; let entry = get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?;
return match entry.get_password() { return match entry.get_password() {
Ok(token) => Ok(Some(token)), Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None), Err(keyring::Error::NoEntry) => Ok(None),
@@ -59,7 +60,7 @@ pub async fn credential_set(server_origin: String, token: String) -> Result<(),
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))] #[cfg(any(target_os = "macos", windows))]
{ {
return get_entry(&server_origin)? return get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?
.set_password(&token) .set_password(&token)
.map_err(|error| format!("保存系统凭据失败:{error}")); .map_err(|error| format!("保存系统凭据失败:{error}"));
} }
@@ -78,7 +79,7 @@ pub async fn credential_delete(server_origin: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))] #[cfg(any(target_os = "macos", windows))]
{ {
let entry = get_entry(&server_origin)?; let entry = get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?;
return match entry.delete_credential() { return match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(format!("删除系统凭据失败:{error}")), Err(error) => Err(format!("删除系统凭据失败:{error}")),
@@ -94,9 +95,74 @@ pub async fn credential_delete(server_origin: String) -> Result<(), String> {
.map_err(|error| format!("删除系统凭据任务失败:{error}"))? .map_err(|error| format!("删除系统凭据任务失败:{error}"))?
} }
#[tauri::command]
pub async fn login_credential_get(server_origin: String) -> Result<Option<String>, String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
let entry = get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?;
return match entry.get_password() {
Ok(credential) => Ok(Some(credential)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(format!("读取登录凭据失败:{error}")),
};
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = credential_account(&server_origin)?;
Err("当前平台不支持系统凭据存储".to_string())
}
})
.await
.map_err(|error| format!("读取登录凭据任务失败:{error}"))?
}
#[tauri::command]
pub async fn login_credential_set(server_origin: String, credential: String) -> Result<(), String> {
if credential.trim().is_empty() {
return Err("拒绝保存空登录凭据".to_string());
}
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
return get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?
.set_password(&credential)
.map_err(|error| format!("保存登录凭据失败:{error}"));
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = credential_account(&server_origin)?;
Err("当前平台不支持系统凭据存储".to_string())
}
})
.await
.map_err(|error| format!("保存登录凭据任务失败:{error}"))?
}
#[tauri::command]
pub async fn login_credential_delete(server_origin: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
let entry = get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?;
return match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(format!("删除登录凭据失败:{error}")),
};
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = credential_account(&server_origin)?;
Err("当前平台不支持系统凭据存储".to_string())
}
})
.await
.map_err(|error| format!("删除登录凭据任务失败:{error}"))?
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::credential_account; use super::{credential_account, LOGIN_CREDENTIAL_SERVICE, SESSION_CREDENTIAL_SERVICE};
#[test] #[test]
fn account_is_stable_for_same_origin() { fn account_is_stable_for_same_origin() {
@@ -124,4 +190,9 @@ mod tests {
assert!(!account.contains("ctms.example.com")); assert!(!account.contains("ctms.example.com"));
assert!(!account.contains("https")); assert!(!account.contains("https"));
} }
#[test]
fn login_credentials_use_a_separate_keyring_service() {
assert_ne!(SESSION_CREDENTIAL_SERVICE, LOGIN_CREDENTIAL_SERVICE);
}
} }
+3
View File
@@ -144,6 +144,9 @@ pub fn run() {
credentials::credential_get, credentials::credential_get,
credentials::credential_set, credentials::credential_set,
credentials::credential_delete, credentials::credential_delete,
credentials::login_credential_get,
credentials::login_credential_set,
credentials::login_credential_delete,
updates::desktop_update_check, updates::desktop_update_check,
updates::desktop_update_install, updates::desktop_update_install,
]) ])
+5 -4
View File
@@ -1,5 +1,5 @@
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import api, { apiGet, apiPatch, apiPost } from "./axios"; import api, { apiGet, apiPatch, apiPost, type ApiRequestConfig } from "./axios";
import type { import type {
UserMeResponse, UserMeResponse,
LoginRequest, LoginRequest,
@@ -24,10 +24,11 @@ export const devLogin = (payload: DevLoginRequest): Promise<AxiosResponse<LoginR
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> => export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
apiGet<LoginKeyResponse>("/api/v1/auth/login-key"); apiGet<LoginKeyResponse>("/api/v1/auth/login-key");
export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me"); export const fetchMe = (config?: ApiRequestConfig): Promise<AxiosResponse<UserMeResponse>> =>
apiGet<UserMeResponse>("/api/v1/auth/me", config);
export const fetchEmailDomains = (): Promise<AxiosResponse<EmailDomainsResponse>> => export const fetchEmailDomains = (config?: ApiRequestConfig): Promise<AxiosResponse<EmailDomainsResponse>> =>
apiGet<EmailDomainsResponse>("/api/v1/auth/email-domains"); apiGet<EmailDomainsResponse>("/api/v1/auth/email-domains", config);
export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> => export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/register", payload); apiPost("/api/v1/auth/register", payload);
+62 -86
View File
@@ -5,25 +5,7 @@
<div class="sidebar-title-row"> <div class="sidebar-title-row">
<h1>{{ TEXT.common.appName }}</h1> <h1>{{ TEXT.common.appName }}</h1>
<el-dropdown v-if="study.currentStudy" trigger="click" class="study-switcher" @command="handleStudySwitch"> <button v-if="!study.currentStudy" class="study-switcher-trigger empty" type="button" @click="openDesktopProjectEntry">
<button class="study-switcher-trigger" type="button">
<span class="study-name">{{ study.currentStudy.name }}</span>
<el-icon><ArrowDown /></el-icon>
</button>
<template #dropdown>
<el-dropdown-menu class="study-switcher-menu">
<el-dropdown-item
v-for="item in studies"
:key="item.id"
:command="item"
:class="{ active: study.currentStudy?.id === item.id }"
>
{{ item.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<button v-else class="study-switcher-trigger empty" type="button" @click="router.push('/admin/projects')">
选择项目 选择项目
</button> </button>
</div> </div>
@@ -87,7 +69,7 @@
</section> </section>
<section v-if="projectNavigationItems.length" class="nav-section"> <section v-if="projectNavigationItems.length" class="nav-section">
<div class="section-label">{{ TEXT.menu.currentProject }}</div> <div class="section-label">{{ desktopProjectSectionLabel }}</div>
<template v-for="item in projectNavigationItems" :key="item.path"> <template v-for="item in projectNavigationItems" :key="item.path">
<button <button
v-if="!item.children?.length" v-if="!item.children?.length"
@@ -269,6 +251,10 @@
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span>{{ TEXT.menu.profile }}</span> <span>{{ TEXT.menu.profile }}</span>
</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item command="projectEntry">
<el-icon><Suitcase /></el-icon>
<span>{{ projectEntryMenuLabel }}</span>
</el-dropdown-item>
<el-dropdown-item command="desktopPreferences"> <el-dropdown-item command="desktopPreferences">
<el-icon><Monitor /></el-icon> <el-icon><Monitor /></el-icon>
<span>系统偏好</span> <span>系统偏好</span>
@@ -409,11 +395,11 @@ import {
import { ElMessageBox } from "element-plus"; import { ElMessageBox } from "element-plus";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { fetchStudies } from "../api/studies";
import { fetchOverdueAesCount } from "../api/dashboard"; import { fetchOverdueAesCount } from "../api/dashboard";
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues"; import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager"; import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
import { DESKTOP_SESSION_RESTORE_PATH, shouldPreserveDesktopSessionOnAuthCheckFailure } from "../session/authRecovery";
import { import {
checkDesktopUpdateAndPrompt, checkDesktopUpdateAndPrompt,
getDesktopUpdateStatus, getDesktopUpdateStatus,
@@ -475,7 +461,6 @@ const profileDialogDirty = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl()); const desktopServerUrl = ref(getDesktopServerUrl());
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus()); const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
const desktopActivities = ref<DesktopActivityItem[]>(getDesktopActivities()); const desktopActivities = ref<DesktopActivityItem[]>(getDesktopActivities());
const studies = ref<any[]>([]);
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes()); const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const workspaceTabs = ref<DesktopRoutePreference[]>([]); const workspaceTabs = ref<DesktopRoutePreference[]>([]);
const draggingWorkspaceTabPath = ref(""); const draggingWorkspaceTabPath = ref("");
@@ -535,11 +520,13 @@ const toggleNavigationGroup = (item: LayoutNavigationItem) => {
expandedNavigationGroups.value = next; expandedNavigationGroups.value = next;
}; };
const adminNavigationItems = computed(() => const adminNavigationItems = computed(() =>
buildAdminNavigationItems({ study.currentStudy
hasUser: Boolean(auth.user), ? []
isAdmin: isAdmin.value, : buildAdminNavigationItems({
canAccessAdminPermissions: canAccessAdminPermissions.value, hasUser: Boolean(auth.user),
}), isAdmin: isAdmin.value,
canAccessAdminPermissions: canAccessAdminPermissions.value,
}),
); );
const projectNavigationItems = computed(() => const projectNavigationItems = computed(() =>
buildProjectNavigationItems({ buildProjectNavigationItems({
@@ -561,6 +548,8 @@ const userDisplayInitial = computed(() => {
const source = auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback; const source = auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback;
return source.charAt(0).toUpperCase(); return source.charAt(0).toUpperCase();
}); });
const desktopProjectSectionLabel = computed(() => study.currentStudy?.name || TEXT.menu.currentProject);
const projectEntryMenuLabel = computed(() => (study.currentStudy ? "返回项目选择" : "选择项目"));
const desktopConnectionLabel = computed(() => (desktopServerUrl.value ? "已连接" : "未配置")); const desktopConnectionLabel = computed(() => (desktopServerUrl.value ? "已连接" : "未配置"));
const desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning")); const desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning"));
const desktopUpdateNoticeVisible = computed( const desktopUpdateNoticeVisible = computed(
@@ -755,7 +744,7 @@ const closeWorkspaceTab = (path: string) => {
if (next) { if (next) {
router.push(next.path); router.push(next.path);
} else { } else {
router.push(study.currentStudy ? "/project/overview" : "/admin/projects"); router.push(study.currentStudy ? "/project/overview" : "/desktop/project-entry");
} }
}; };
@@ -793,6 +782,13 @@ const openDesktopPreferences = () => {
desktopPreferencesVisible.value = true; desktopPreferencesVisible.value = true;
}; };
const openDesktopProjectEntry = async () => {
study.clearCurrentStudy();
workspaceTabs.value = [];
lastClosedWorkspaceTab.value = null;
await router.push("/desktop/project-entry");
};
const toggleCurrentFavorite = () => { const toggleCurrentFavorite = () => {
const current = currentDesktopRoutePreference.value; const current = currentDesktopRoutePreference.value;
if (!current) return; if (!current) return;
@@ -842,18 +838,6 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
void router.push(item.path); void router.push(item.path);
}, },
})); }));
const projectCommands: DesktopCommand[] = studies.value.map((item) => ({
id: `study:${item.id}`,
title: `切换项目:${item.name}`,
group: "项目",
keywords: [item.name, item.project_no, item.protocol_no].filter(Boolean),
visible: Boolean(item?.id),
run: async () => {
study.setCurrentStudy(item);
await study.loadCurrentStudyPermissions().catch(() => {});
await router.push("/project/overview");
},
}));
const workspaceTabCommands: DesktopCommand[] = workspaceTabs.value.map((item) => ({ const workspaceTabCommands: DesktopCommand[] = workspaceTabs.value.map((item) => ({
id: `workspace-tab:${item.path}`, id: `workspace-tab:${item.path}`,
title: `切换标签:${item.title}`, title: `切换标签:${item.title}`,
@@ -895,6 +879,13 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true }); await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
}, },
}, },
{
id: "desktop:project-entry",
title: projectEntryMenuLabel.value,
group: "桌面",
visible: true,
run: openDesktopProjectEntry,
},
{ {
id: "desktop:favorite-current", id: "desktop:favorite-current",
title: currentRouteFavorited.value ? "取消收藏当前模块" : "收藏当前模块", title: currentRouteFavorited.value ? "取消收藏当前模块" : "收藏当前模块",
@@ -911,17 +902,9 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
}, },
...workspaceTabCommands, ...workspaceTabCommands,
...navigationCommands, ...navigationCommands,
...projectCommands,
]; ];
}); });
const loadStudies = async () => {
try {
const { data } = await fetchStudies();
studies.value = Array.isArray(data) ? data : (data as any).items || [];
} catch {}
};
const loadHeaderReminders = async () => { const loadHeaderReminders = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId || isAdminContext.value || !hasAnyRiskReminderAccess.value) { if (!studyId || isAdminContext.value || !hasAnyRiskReminderAccess.value) {
@@ -958,13 +941,6 @@ const handleReminderCommand = (cmd: string) => {
} }
}; };
const handleStudySwitch = async (item: any) => {
if (!item?.id || item.id === study.currentStudy?.id) return;
study.setCurrentStudy(item);
await study.loadCurrentStudyPermissions().catch(() => {});
await router.push("/project/overview");
};
const logoutImmediately = async () => { const logoutImmediately = async () => {
await auth.logout(); await auth.logout();
study.clearCurrentStudy(); study.clearCurrentStudy();
@@ -999,6 +975,8 @@ const onCommand = (cmd: string) => {
onLogout(); onLogout();
} else if (cmd === "profile") { } else if (cmd === "profile") {
profileDialogVisible.value = true; profileDialogVisible.value = true;
} else if (cmd === "projectEntry") {
openDesktopProjectEntry();
} else if (cmd === "desktopPreferences") { } else if (cmd === "desktopPreferences") {
openDesktopPreferences(); openDesktopPreferences();
} }
@@ -1072,13 +1050,16 @@ onMounted(async () => {
}); });
if (auth.token && !auth.user) { if (auth.token && !auth.user) {
try { try {
await auth.fetchMe(); await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
} catch { } catch (error) {
if (shouldPreserveDesktopSessionOnAuthCheckFailure(error)) {
await router.replace({ path: DESKTOP_SESSION_RESTORE_PATH, query: { redirect: route.fullPath } });
return;
}
await logoutImmediately(); await logoutImmediately();
return; return;
} }
} }
loadStudies();
loadHeaderReminders(); loadHeaderReminders();
}); });
@@ -1191,13 +1172,6 @@ useDesktopShortcuts(
color: #172233; color: #172233;
} }
.study-switcher {
justify-self: start;
width: 140px;
max-width: 100%;
min-width: 0;
}
.study-switcher-trigger { .study-switcher-trigger {
appearance: none; appearance: none;
display: grid; display: grid;
@@ -1225,20 +1199,6 @@ useDesktopShortcuts(
font-weight: 700; font-weight: 700;
} }
.study-name {
overflow: hidden;
font-size: 13px;
font-weight: 760;
text-overflow: ellipsis;
white-space: nowrap;
}
.study-switcher-menu :deep(.el-dropdown-menu__item.active) {
color: #24496f;
background: #edf4fb;
font-weight: 700;
}
.sidebar-scroll { .sidebar-scroll {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -1958,12 +1918,28 @@ useDesktopShortcuts(
transform: translateY(-3px); transform: translateY(-3px);
} }
:global(.profile-settings-dialog), /* profile-settings-dialog:移除外层卡片外壳,只保留容器行为 */
:global(.profile-settings-dialog) {
--el-dialog-bg-color: transparent !important;
--el-dialog-box-shadow: none !important;
--el-dialog-border-radius: 0 !important;
overflow: visible !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
border: none !important;
}
:global(.desktop-preferences-dialog) { :global(.desktop-preferences-dialog) {
overflow: hidden; /* 移除外层卡片外壳,内容区自带圆角阴影 */
border-radius: 14px; --el-dialog-bg-color: transparent !important;
/* dialog 容器本身设置侧边栏同色背景,彻底兜底 */ --el-dialog-box-shadow: none !important;
background: #f2f4f7 !important; --el-dialog-border-radius: 0 !important;
overflow: visible !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
border: none !important;
} }
:global(.profile-settings-dialog .el-dialog__header), :global(.profile-settings-dialog .el-dialog__header),
@@ -1973,12 +1949,12 @@ useDesktopShortcuts(
:global(.profile-settings-dialog .el-dialog__body) { :global(.profile-settings-dialog .el-dialog__body) {
padding: 0; padding: 0;
background: transparent;
} }
:global(.desktop-preferences-dialog .el-dialog__body) { :global(.desktop-preferences-dialog .el-dialog__body) {
/* 背景与侧边栏一致,避免内容高度不足时白色透出 */
padding: 0; padding: 0;
background: #f2f4f7; background: transparent;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
+45 -12
View File
@@ -14,6 +14,8 @@ const readDesktopUpdateManagerSource = () => readFileSync(resolve(__dirname, "..
const readDesktopActivityCenterSource = () => readFileSync(resolve(__dirname, "../session/desktopActivityCenter.ts"), "utf8"); const readDesktopActivityCenterSource = () => readFileSync(resolve(__dirname, "../session/desktopActivityCenter.ts"), "utf8");
const readProfileSettingsSource = () => readFileSync(resolve(__dirname, "../views/ProfileSettings.vue"), "utf8"); const readProfileSettingsSource = () => readFileSync(resolve(__dirname, "../views/ProfileSettings.vue"), "utf8");
const readLoginSource = () => readFileSync(resolve(__dirname, "../views/Login.vue"), "utf8"); const readLoginSource = () => readFileSync(resolve(__dirname, "../views/Login.vue"), "utf8");
const readRegisterSource = () => readFileSync(resolve(__dirname, "../views/Register.vue"), "utf8");
const readForgotPasswordSource = () => readFileSync(resolve(__dirname, "../views/ForgotPassword.vue"), "utf8");
const readFileTaskFeedbackSource = () => readFileSync(resolve(__dirname, "../utils/fileTaskFeedback.ts"), "utf8"); const readFileTaskFeedbackSource = () => readFileSync(resolve(__dirname, "../utils/fileTaskFeedback.ts"), "utf8");
const readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.vue"), "utf8"); const readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.vue"), "utf8");
const readDocumentDetailSource = () => readFileSync(resolve(__dirname, "../views/documents/DocumentDetail.vue"), "utf8"); const readDocumentDetailSource = () => readFileSync(resolve(__dirname, "../views/documents/DocumentDetail.vue"), "utf8");
@@ -119,15 +121,25 @@ describe("desktop layout shell", () => {
expect(layout).toContain("grid-template-columns: 248px minmax(0, 1fr);"); expect(layout).toContain("grid-template-columns: 248px minmax(0, 1fr);");
expect(layout).toContain("padding: 44px 10px 10px;"); expect(layout).toContain("padding: 44px 10px 10px;");
expect(layout).toContain("grid-template-columns: auto minmax(0, 1fr);"); expect(layout).toContain("grid-template-columns: auto minmax(0, 1fr);");
expect(layout).toContain("width: 140px;");
expect(layout).toContain("margin-top: 0;"); expect(layout).toContain("margin-top: 0;");
expect(layout).toContain("const desktopProjectSectionLabel = computed(() => study.currentStudy?.name || TEXT.menu.currentProject)");
expect(layout).toContain("{{ desktopProjectSectionLabel }}");
expect(layout).toContain('command="projectEntry"');
expect(layout).toContain("projectEntryMenuLabel");
expect(layout).toContain("openDesktopProjectEntry");
expect(layout).toContain("padding: 10px 8px 24px;"); expect(layout).toContain("padding: 10px 8px 24px;");
expect(layout).toContain("margin-top: 12px;"); expect(layout).toContain("margin-top: 12px;");
expect(layout).toContain("font-size: 14px;"); expect(layout).toContain("font-size: 14px;");
expect(sidebarHeadTemplate).toContain('<div class="sidebar-title-row">'); expect(sidebarHeadTemplate).toContain('<div class="sidebar-title-row">');
expect(sidebarHeadTemplate.indexOf("<h1>{{ TEXT.common.appName }}</h1>")).toBeLessThan( expect(sidebarHeadTemplate.indexOf("<h1>{{ TEXT.common.appName }}</h1>")).toBeLessThan(
sidebarHeadTemplate.indexOf('<el-dropdown v-if="study.currentStudy"'), sidebarHeadTemplate.indexOf('<button v-if="!study.currentStudy" class="study-switcher-trigger empty"'),
); );
expect(sidebarHeadTemplate).not.toContain("study-context-badge");
expect(layout).not.toContain(".study-context-badge");
expect(layout).not.toContain(".study-name");
expect(sidebarHeadTemplate).not.toContain('@command="handleStudySwitch"');
expect(layout).not.toContain("handleStudySwitch");
expect(layout).not.toContain("`切换项目:${item.name}`");
expect(layout).not.toContain("grid-template-columns: 272px minmax(0, 1fr);"); expect(layout).not.toContain("grid-template-columns: 272px minmax(0, 1fr);");
expect(layout).not.toContain("padding: 54px 16px 14px;"); expect(layout).not.toContain("padding: 54px 16px 14px;");
expect(layout).not.toContain("padding: 44px 12px 12px;"); expect(layout).not.toContain("padding: 44px 12px 12px;");
@@ -183,7 +195,7 @@ describe("desktop layout shell", () => {
expect(webLayout).not.toContain('width="720px"'); expect(webLayout).not.toContain('width="720px"');
}); });
it("keeps profile diagnostics focused while desktop controls stay in preferences", () => { it("keeps client diagnostics out of profile while desktop controls stay in preferences", () => {
const profile = readProfileSettingsSource(); const profile = readProfileSettingsSource();
const preferences = readDesktopPreferencesSource(); const preferences = readDesktopPreferencesSource();
@@ -193,12 +205,14 @@ describe("desktop layout shell", () => {
expect(profile).not.toContain("setDesktopNotificationSubscription"); expect(profile).not.toContain("setDesktopNotificationSubscription");
expect(profile).not.toContain("checkDesktopUpdateAndPrompt"); expect(profile).not.toContain("checkDesktopUpdateAndPrompt");
expect(profile).not.toContain("desktopNotificationsEnabled"); expect(profile).not.toContain("desktopNotificationsEnabled");
expect(profile).toContain("clientDiagnosticRows"); expect(profile).not.toContain("form-section--diagnostics");
expect(profile).toContain("copyClientDiagnostics"); expect(profile).not.toContain("客户端诊断");
expect(profile).toContain("getAppMetadata"); expect(profile).not.toContain("clientDiagnosticRows");
expect(profile).toContain("getDesktopServerUrl"); expect(profile).not.toContain("copyClientDiagnostics");
expect(profile).toContain("clientRuntime.capabilities"); expect(profile).not.toContain("getAppMetadata");
expect(profile).toContain("诊断信息已复制"); expect(profile).not.toContain("getDesktopServerUrl");
expect(profile).not.toContain("clientRuntime.capabilities");
expect(profile).not.toContain("诊断信息已复制");
expect(preferences).toContain("getDesktopNotificationSubscription"); expect(preferences).toContain("getDesktopNotificationSubscription");
expect(preferences).toContain("checkDesktopUpdateAndPrompt"); expect(preferences).toContain("checkDesktopUpdateAndPrompt");
expect(preferences).toContain("clientMetadataRows"); expect(preferences).toContain("clientMetadataRows");
@@ -370,6 +384,19 @@ describe("desktop layout shell", () => {
expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat"); expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat");
}); });
it("keeps authentication styles self-contained for desktop CSP", () => {
const sources = [
readMainStyleSource(),
readLoginSource(),
readRegisterSource(),
readForgotPasswordSource(),
].join("\n");
expect(sources).not.toContain("fonts.googleapis.com");
expect(sources).not.toContain("fonts.gstatic.com");
expect(sources).not.toMatch(/@import\s+url\(["']https?:\/\//);
});
it("renders Element Plus modals with desktop-native surfaces in the desktop runtime", () => { it("renders Element Plus modals with desktop-native surfaces in the desktop runtime", () => {
const styles = readMainStyleSource(); const styles = readMainStyleSource();
@@ -415,8 +442,11 @@ describe("desktop layout shell", () => {
expect(projectOverview).not.toContain("overview-summary-strip"); expect(projectOverview).not.toContain("overview-summary-strip");
expect(projectOverview).not.toContain("const activeCenterCount"); expect(projectOverview).not.toContain("const activeCenterCount");
expect(projectOverview).toContain("desktop-attention-section"); expect(projectOverview).toContain("desktop-attention-section");
expect(projectOverview).toContain("desktop-attention-head"); expect(projectOverview).not.toContain("项目关注");
expect(projectOverview).toContain("更新 {{ overviewUpdatedAtLabel }}"); expect(projectOverview).not.toContain("desktop-attention-head");
expect(projectOverview).toContain('class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>');
expect(projectOverview.indexOf("刷新")).toBeLessThan(projectOverview.indexOf('class="overview-updated-at"'));
expect(projectOverview.indexOf('class="overview-updated-at"')).toBeLessThan(projectOverview.indexOf('class="progress-legend"'));
expect(projectOverview).toContain("overview-workbench"); expect(projectOverview).toContain("overview-workbench");
expect(projectOverview).toContain("enrollment-snapshot"); expect(projectOverview).toContain("enrollment-snapshot");
expect(projectOverview).toContain("desktop-attention-board"); expect(projectOverview).toContain("desktop-attention-board");
@@ -445,8 +475,11 @@ describe("desktop layout shell", () => {
expect(serverSettings).toContain("max-height: calc(100vh - 64px);"); expect(serverSettings).toContain("max-height: calc(100vh - 64px);");
expect(serverSettings).toContain("overflow: auto;"); expect(serverSettings).toContain("overflow: auto;");
expect(serverSettings).toContain(".diagnostic-grid code"); expect(serverSettings).toContain(".diagnostic-grid code");
expect(profile).toContain('<div class="profile-layout">');
expect(profile).not.toContain('class="page"');
expect(profile).toContain("height: min(720px, calc(100vh - 64px));"); expect(profile).toContain("height: min(720px, calc(100vh - 64px));");
expect(profile).toContain("overflow: auto;"); expect(profile).toContain("overflow: hidden;");
expect(profile).not.toContain("overflow: auto;");
expect(profile).toContain("overflow-wrap: anywhere;"); expect(profile).toContain("overflow-wrap: anywhere;");
expect(preferences).toContain("flex-wrap: wrap;"); expect(preferences).toContain("flex-wrap: wrap;");
expect(preferences).toContain("overflow-wrap: anywhere;"); expect(preferences).toContain("overflow-wrap: anywhere;");
+20 -6
View File
@@ -2485,9 +2485,15 @@ useDesktopShortcuts(
} }
:global(.profile-settings-dialog) { :global(.profile-settings-dialog) {
border-radius: 16px; /* 移除外层卡片外壳,只保留容器行为 */
overflow: hidden; --el-dialog-bg-color: transparent !important;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26); --el-dialog-box-shadow: none !important;
--el-dialog-border-radius: 0 !important;
overflow: visible !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
border: none !important;
} }
:global(.profile-settings-dialog .el-dialog__header) { :global(.profile-settings-dialog .el-dialog__header) {
@@ -2496,11 +2502,19 @@ useDesktopShortcuts(
:global(.profile-settings-dialog .el-dialog__body) { :global(.profile-settings-dialog .el-dialog__body) {
padding: 0; padding: 0;
background: transparent;
} }
:global(.desktop-preferences-dialog) { :global(.desktop-preferences-dialog) {
border-radius: 14px; /* 移除外层卡片外壳,内容区自带圆角阴影 */
overflow: hidden; --el-dialog-bg-color: transparent !important;
--el-dialog-box-shadow: none !important;
--el-dialog-border-radius: 0 !important;
overflow: visible !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
border: none !important;
} }
:global(.desktop-preferences-dialog .el-dialog__header) { :global(.desktop-preferences-dialog .el-dialog__header) {
@@ -2509,6 +2523,6 @@ useDesktopShortcuts(
:global(.desktop-preferences-dialog .el-dialog__body) { :global(.desktop-preferences-dialog .el-dialog__body) {
padding: 0; padding: 0;
background: #ffffff; background: transparent;
} }
</style> </style>
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readIndexHtml = () => readFileSync(resolve(__dirname, "../index.html"), "utf8");
describe("app bootstrap shell", () => {
it("renders a lightweight startup placeholder before Vue mounts", () => {
const source = readIndexHtml();
expect(source).toContain('<div id="app">');
expect(source).toContain("ctms-boot-splash");
expect(source).toContain("正在启动 CTMS");
expect(source).toContain("正在加载桌面客户端");
});
});
+2 -2
View File
@@ -12,7 +12,7 @@ import App from "./App.vue";
import router from "./router"; import router from "./router";
import { getToken } from "./utils/auth"; import { getToken } from "./utils/auth";
import { useStudyStore } from "./store/study"; import { useStudyStore } from "./store/study";
import { cleanupTemporaryFiles, initializeSecureSessionStorage, shouldRequireDesktopServerUrl } from "./runtime"; import { cleanupTemporaryFiles, initializeSecureSessionStorage, isTauriRuntime, shouldRequireDesktopServerUrl } from "./runtime";
const bootstrap = async () => { const bootstrap = async () => {
await initializeSecureSessionStorage().catch((error) => { await initializeSecureSessionStorage().catch((error) => {
@@ -30,7 +30,7 @@ const bootstrap = async () => {
// 初始化项目上下文 // 初始化项目上下文
const studyStore = useStudyStore(); const studyStore = useStudyStore();
studyStore.loadCurrentStudy(); studyStore.loadCurrentStudy();
if (getToken() && !shouldRequireDesktopServerUrl()) { if (getToken() && !shouldRequireDesktopServerUrl() && !isTauriRuntime()) {
await studyStore.rehydrateStudyForLastUser(); await studyStore.rehydrateStudyForLastUser();
await studyStore.loadCurrentStudyPermissions().catch(() => {}); await studyStore.loadCurrentStudyPermissions().catch(() => {});
} }
+23 -7
View File
@@ -78,27 +78,43 @@ describe("admin project route permissions", () => {
expect(source).not.toContain("[SYSTEM_PERMISSION_MONITORING_METRICS]"); expect(source).not.toContain("[SYSTEM_PERMISSION_MONITORING_METRICS]");
}); });
it("keeps login landing independent from PM management backend access", () => { it("routes desktop login and missing-project flows through the dedicated entry chooser", () => {
const source = readRouter(); const source = readRouter();
expect(source).not.toContain("findPmAdminLandingPath"); expect(source).not.toContain("findPmAdminLandingPath");
expect(source).not.toContain("pmAdminLandingModules"); expect(source).not.toContain("pmAdminLandingModules");
expect(source).toContain(' : studyStore.currentStudy\n ? "/project/overview"\n : "/admin/projects",'); expect(source).toContain('path: "/desktop/project-entry"');
expect(source).toContain('component: DesktopProjectEntry');
expect(source).toContain("DesktopSessionRestore");
expect(source).toContain("DESKTOP_SESSION_RESTORE_PATH");
expect(source).toContain('if (isDesktopRuntime) {\n next({ path: "/desktop/project-entry" });');
expect(source).toContain('isDesktopRuntime ? "/desktop/project-entry" : isAdmin ? "/admin/users" : "/admin/projects"');
expect(source).toContain('if (isDesktopRuntime && token && !isAdmin && to.path.startsWith("/admin"))');
expect(source).toContain('if (isDesktopRuntime && token && to.path === "/desktop/project-entry" && studyStore.currentStudy)');
expect(source).toContain('if (token && !studyStore.currentStudy && !isDesktopRuntime)');
}); });
it("validates restored session tokens through /me before entering protected routes", () => { it("validates restored session tokens through /me before entering protected routes", () => {
const source = readRouter(); const source = readRouter();
const restoreGuardIndex = source.indexOf("if (!auth.user && getToken())"); const restoreGuardIndex = source.indexOf("if (!auth.user && getToken() && !isDesktopSessionRestoreRoute)");
const fetchMeIndex = source.indexOf("await auth.fetchMe()", restoreGuardIndex); const fetchMeIndex = source.indexOf("await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true })", restoreGuardIndex);
const logoutIndex = source.indexOf("await auth.logout()", fetchMeIndex); const preserveIndex = source.indexOf("shouldPreserveDesktopSessionOnAuthCheckFailure(error)", fetchMeIndex);
const restoreRedirectIndex = source.indexOf("next({ path: DESKTOP_SESSION_RESTORE_PATH", preserveIndex);
const logoutIndex = source.indexOf("await auth.logout()", restoreRedirectIndex);
const loginRedirectIndex = source.indexOf('next({ path: "/login" })', logoutIndex); const loginRedirectIndex = source.indexOf('next({ path: "/login" })', logoutIndex);
const projectFallbackIndex = source.indexOf("if (token && !studyStore.currentStudy)", restoreGuardIndex); const projectFallbackIndex = source.indexOf("if (token && !studyStore.currentStudy && !isDesktopRuntime)", restoreGuardIndex);
expect(restoreGuardIndex).toBeGreaterThan(-1); expect(restoreGuardIndex).toBeGreaterThan(-1);
expect(fetchMeIndex).toBeGreaterThan(restoreGuardIndex); expect(fetchMeIndex).toBeGreaterThan(restoreGuardIndex);
expect(logoutIndex).toBeGreaterThan(fetchMeIndex); expect(preserveIndex).toBeGreaterThan(fetchMeIndex);
expect(restoreRedirectIndex).toBeGreaterThan(preserveIndex);
expect(logoutIndex).toBeGreaterThan(restoreRedirectIndex);
expect(loginRedirectIndex).toBeGreaterThan(logoutIndex); expect(loginRedirectIndex).toBeGreaterThan(logoutIndex);
expect(fetchMeIndex).toBeLessThan(projectFallbackIndex); expect(fetchMeIndex).toBeLessThan(projectFallbackIndex);
expect(source).toContain("const isDesktopSessionRestoreRoute = to.path === DESKTOP_SESSION_RESTORE_PATH");
expect(source).toContain("!isDesktopSessionRestoreRoute");
expect(source).toContain("disableNetworkRetry: true");
expect(source).toContain("suppressErrorMessage: true");
}); });
it("does not register the removed non-admin personal dashboard route", () => { it("does not register the removed non-admin personal dashboard route", () => {
+51 -7
View File
@@ -11,6 +11,8 @@ import Login from "../views/Login.vue";
import Register from "../views/Register.vue"; import Register from "../views/Register.vue";
import ForgotPassword from "../views/ForgotPassword.vue"; import ForgotPassword from "../views/ForgotPassword.vue";
import DesktopServerSettings from "../views/DesktopServerSettings.vue"; import DesktopServerSettings from "../views/DesktopServerSettings.vue";
import DesktopProjectEntry from "../views/DesktopProjectEntry.vue";
import DesktopSessionRestore from "../views/DesktopSessionRestore.vue";
import StudyHome from "../views/StudyHome.vue"; import StudyHome from "../views/StudyHome.vue";
import FaqDetail from "../views/FaqDetail.vue"; import FaqDetail from "../views/FaqDetail.vue";
import AuditLogs from "../views/admin/AuditLogs.vue"; import AuditLogs from "../views/admin/AuditLogs.vue";
@@ -57,6 +59,8 @@ import SubjectForm from "../views/subjects/SubjectForm.vue";
import SubjectDetail from "../views/subjects/SubjectDetail.vue"; import SubjectDetail from "../views/subjects/SubjectDetail.vue";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { resolveDesktopRouteRedirect } from "./desktopGuard"; import { resolveDesktopRouteRedirect } from "./desktopGuard";
import { isTauriRuntime } from "../runtime";
import { DESKTOP_SESSION_RESTORE_PATH, shouldPreserveDesktopSessionOnAuthCheckFailure } from "../session/authRecovery";
const SYSTEM_PERMISSION_READ = "system:permissions:read"; const SYSTEM_PERMISSION_READ = "system:permissions:read";
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config"; const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
@@ -86,6 +90,18 @@ const routes: RouteRecordRaw[] = [
component: DesktopServerSettings, component: DesktopServerSettings,
meta: { public: true, title: "服务器设置" }, meta: { public: true, title: "服务器设置" },
}, },
{
path: DESKTOP_SESSION_RESTORE_PATH,
name: "DesktopSessionRestore",
component: DesktopSessionRestore,
meta: { public: true, title: "恢复登录状态" },
},
{
path: "/desktop/project-entry",
name: "DesktopProjectEntry",
component: DesktopProjectEntry,
meta: { title: "选择工作入口" },
},
{ {
path: "/", path: "/",
component: Layout, component: Layout,
@@ -512,6 +528,7 @@ const ensureProjectPermissionAccess = async (
}; };
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
const isDesktopRuntime = isTauriRuntime();
const desktopRedirect = resolveDesktopRouteRedirect(to); const desktopRedirect = resolveDesktopRouteRedirect(to);
if (desktopRedirect) { if (desktopRedirect) {
next({ path: desktopRedirect }); next({ path: desktopRedirect });
@@ -522,10 +539,15 @@ router.beforeEach(async (to, _from, next) => {
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const getToken = () => auth.token; const getToken = () => auth.token;
let token = getToken(); let token = getToken();
if (!auth.user && getToken()) { const isDesktopSessionRestoreRoute = to.path === DESKTOP_SESSION_RESTORE_PATH;
if (!auth.user && getToken() && !isDesktopSessionRestoreRoute) {
try { try {
await auth.fetchMe(); await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
} catch { } catch (error) {
if (isDesktopRuntime && shouldPreserveDesktopSessionOnAuthCheckFailure(error)) {
next({ path: DESKTOP_SESSION_RESTORE_PATH, query: { redirect: to.fullPath } });
return;
}
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面 // 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
await auth.logout(); await auth.logout();
next({ path: "/login" }); next({ path: "/login" });
@@ -534,7 +556,11 @@ router.beforeEach(async (to, _from, next) => {
} }
token = getToken(); token = getToken();
const isAdmin = isSystemAdmin(auth.user); const isAdmin = isSystemAdmin(auth.user);
if (token && !studyStore.currentStudy) { if (!to.meta.public && !token) {
next({ path: "/login" });
return;
}
if (token && !studyStore.currentStudy && !isDesktopRuntime) {
if (isAdmin) { if (isAdmin) {
await studyStore.ensureDefaultActiveStudy(); await studyStore.ensureDefaultActiveStudy();
} else { } else {
@@ -544,8 +570,22 @@ router.beforeEach(async (to, _from, next) => {
if (token && studyStore.currentStudy && auth.user?.email) { if (token && studyStore.currentStudy && auth.user?.email) {
studyStore.rememberCurrentStudyForUser(auth.user.email); studyStore.rememberCurrentStudyForUser(auth.user.email);
} }
if (!to.meta.public && !token) {
next({ path: "/login" }); if (isDesktopRuntime && token && to.path === "/desktop/project-entry" && studyStore.currentStudy) {
studyStore.clearCurrentStudy();
}
if (isDesktopRuntime && token && !isAdmin && to.path.startsWith("/admin")) {
next({ path: "/desktop/project-entry" });
return;
}
if (isDesktopRuntime && token && isAdmin && to.path.startsWith("/admin") && studyStore.currentStudy) {
studyStore.clearCurrentStudy();
}
if (isDesktopRuntime && token && to.path === "/") {
next({ path: studyStore.currentStudy ? "/project/overview" : "/desktop/project-entry" });
return; return;
} }
if (isAdmin && to.path === "/") { if (isAdmin && to.path === "/") {
@@ -554,6 +594,10 @@ router.beforeEach(async (to, _from, next) => {
} }
if ((to.path === "/login" || to.path === "/register") && token) { if ((to.path === "/login" || to.path === "/register") && token) {
if (!auth.forceLogin) { if (!auth.forceLogin) {
if (isDesktopRuntime) {
next({ path: "/desktop/project-entry" });
return;
}
next({ next({
path: isAdmin path: isAdmin
? studyStore.currentStudy ? studyStore.currentStudy
@@ -621,7 +665,7 @@ router.beforeEach(async (to, _from, next) => {
} }
} }
if (to.meta.requiresStudy && !studyStore.currentStudy) { if (to.meta.requiresStudy && !studyStore.currentStudy) {
next({ path: isAdmin ? "/admin/users" : "/admin/projects" }); next({ path: isDesktopRuntime ? "/desktop/project-entry" : isAdmin ? "/admin/users" : "/admin/projects" });
return; return;
} }
if (to.meta.requiresStudy && studyStore.currentStudy) { if (to.meta.requiresStudy && studyStore.currentStudy) {
+6
View File
@@ -52,6 +52,12 @@ export {
isSecureSessionStorageAvailable, isSecureSessionStorageAvailable,
setSessionToken, setSessionToken,
} from "./secureSessionStorage"; } from "./secureSessionStorage";
export {
clearLoginCredential,
getSavedLoginCredential,
saveLoginCredential,
type SavedLoginCredential,
} from "./savedLoginCredentials";
export { export {
checkForDesktopUpdate, checkForDesktopUpdate,
installPendingDesktopUpdate, installPendingDesktopUpdate,
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DESKTOP_SERVER_URL_KEY } from "./desktopServerConfig";
import {
clearLoginCredential,
getSavedLoginCredential,
saveLoginCredential,
} from "./savedLoginCredentials";
const invokeMock = vi.hoisted(() => vi.fn());
vi.mock("@tauri-apps/api/core", () => ({
invoke: invokeMock,
}));
const SERVER_ORIGIN = "https://ctms.example.com/";
const createStorage = (): Storage => {
const data = new Map<string, string>();
return {
get length() {
return data.size;
},
clear: () => data.clear(),
getItem: (key) => data.get(key) ?? null,
key: (index) => Array.from(data.keys())[index] ?? null,
removeItem: (key) => data.delete(key),
setItem: (key, value) => {
data.set(key, String(value));
},
};
};
describe("saved login credentials", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-08T00:00:00.000Z"));
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
localStorage.clear();
invokeMock.mockReset();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "PasswordCredential");
Reflect.deleteProperty(navigator, "credentials");
});
afterEach(() => {
vi.useRealTimers();
localStorage.clear();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "PasswordCredential");
Reflect.deleteProperty(navigator, "credentials");
});
it("stores desktop remembered passwords in the system credential store", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
let stored = "";
invokeMock.mockImplementation(async (command: string, args: any) => {
if (command === "login_credential_set") {
stored = args.credential;
return undefined;
}
if (command === "login_credential_get") return stored;
if (command === "login_credential_delete") {
stored = "";
return undefined;
}
return undefined;
});
await expect(saveLoginCredential("Admin@Example.com", "secret-password")).resolves.toBe(true);
expect(invokeMock).toHaveBeenCalledWith("login_credential_set", {
serverOrigin: SERVER_ORIGIN,
credential: expect.any(String),
});
expect(JSON.parse(stored)).toMatchObject({
version: 1,
email: "admin@example.com",
password: "secret-password",
savedAt: Date.now(),
});
await expect(getSavedLoginCredential()).resolves.toEqual({
email: "admin@example.com",
password: "secret-password",
});
await expect(clearLoginCredential()).resolves.toBe(true);
expect(invokeMock).toHaveBeenCalledWith("login_credential_delete", { serverOrigin: SERVER_ORIGIN });
});
it("deletes malformed desktop remembered credentials", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
invokeMock.mockImplementation(async (command: string) => {
if (command === "login_credential_get") return JSON.stringify({ password: "missing-email" });
return undefined;
});
await expect(getSavedLoginCredential()).resolves.toBeNull();
expect(invokeMock).toHaveBeenCalledWith("login_credential_delete", { serverOrigin: SERVER_ORIGIN });
});
it("uses browser credential management without writing passwords to localStorage", async () => {
const storeMock = vi.fn();
const preventSilentAccessMock = vi.fn();
Object.defineProperty(window, "PasswordCredential", {
configurable: true,
value: class {
id: string;
password: string;
constructor(data: { id: string; password: string }) {
this.id = data.id;
this.password = data.password;
}
},
});
Object.defineProperty(navigator, "credentials", {
configurable: true,
value: {
get: vi.fn(async () => ({ id: "Browser@Example.com", password: "browser-secret" })),
store: storeMock,
preventSilentAccess: preventSilentAccessMock,
},
});
await expect(getSavedLoginCredential()).resolves.toEqual({
email: "browser@example.com",
password: "browser-secret",
});
await expect(saveLoginCredential("Browser@Example.com", "browser-secret")).resolves.toBe(true);
await expect(clearLoginCredential()).resolves.toBe(true);
expect(storeMock).toHaveBeenCalledTimes(1);
expect(preventSilentAccessMock).toHaveBeenCalledTimes(1);
expect(JSON.stringify(localStorage)).not.toContain("browser-secret");
});
});
@@ -0,0 +1,160 @@
import { getDesktopServerUrl } from "./desktopServerConfig";
import { isTauriRuntime } from "./platform";
const SAVED_LOGIN_CREDENTIAL_VERSION = 1;
export interface SavedLoginCredential {
email: string;
password: string;
}
type StoredLoginCredential = {
version?: unknown;
email?: unknown;
password?: unknown;
savedAt?: unknown;
};
const invokeLoginCredential = async <T>(
command: "login_credential_get" | "login_credential_set" | "login_credential_delete",
args: Record<string, string>,
): Promise<T> => {
const { invoke } = await import("@tauri-apps/api/core");
return invoke<T>(command, args);
};
const normalizeEmail = (email: string): string => email.trim().toLowerCase();
const serializeDesktopCredential = (credential: SavedLoginCredential): string =>
JSON.stringify({
version: SAVED_LOGIN_CREDENTIAL_VERSION,
email: normalizeEmail(credential.email),
password: credential.password,
savedAt: Date.now(),
});
const parseDesktopCredential = (stored: string | null): SavedLoginCredential | null => {
if (!stored) return null;
try {
const payload = JSON.parse(stored) as StoredLoginCredential;
if (
payload.version !== SAVED_LOGIN_CREDENTIAL_VERSION ||
typeof payload.email !== "string" ||
typeof payload.password !== "string" ||
typeof payload.savedAt !== "number"
) {
return null;
}
const email = normalizeEmail(payload.email);
if (!email || !payload.password) return null;
return { email, password: payload.password };
} catch {
return null;
}
};
const getDesktopSavedLoginCredential = async (): Promise<SavedLoginCredential | null> => {
const serverOrigin = getDesktopServerUrl();
if (!serverOrigin) return null;
const stored = await invokeLoginCredential<string | null>("login_credential_get", { serverOrigin });
const parsed = parseDesktopCredential(stored);
if (!parsed && stored) {
await invokeLoginCredential<void>("login_credential_delete", { serverOrigin });
}
return parsed;
};
const saveDesktopLoginCredential = async (credential: SavedLoginCredential): Promise<boolean> => {
const serverOrigin = getDesktopServerUrl();
if (!serverOrigin) return false;
await invokeLoginCredential<void>("login_credential_set", {
serverOrigin,
credential: serializeDesktopCredential(credential),
});
return true;
};
const clearDesktopLoginCredential = async (): Promise<boolean> => {
const serverOrigin = getDesktopServerUrl();
if (!serverOrigin) return false;
await invokeLoginCredential<void>("login_credential_delete", { serverOrigin });
return true;
};
const getPasswordCredentialConstructor = (): (new (data: { id: string; name?: string; password: string }) => unknown) | null => {
const ctor = (window as unknown as { PasswordCredential?: unknown }).PasswordCredential;
return typeof ctor === "function"
? (ctor as new (data: { id: string; name?: string; password: string }) => unknown)
: null;
};
const getBrowserCredentialContainer = ():
| {
get?: (options: Record<string, unknown>) => Promise<unknown>;
store?: (credential: unknown) => Promise<unknown>;
preventSilentAccess?: () => Promise<void>;
}
| null => {
const container = (navigator as unknown as { credentials?: unknown }).credentials;
return container && typeof container === "object" ? (container as any) : null;
};
const getBrowserSavedLoginCredential = async (): Promise<SavedLoginCredential | null> => {
const credentials = getBrowserCredentialContainer();
if (!credentials?.get) return null;
try {
const credential = await credentials.get({
password: true,
mediation: "optional",
});
const passwordCredential = credential as { id?: unknown; password?: unknown } | null;
if (typeof passwordCredential?.id !== "string" || typeof passwordCredential.password !== "string") {
return null;
}
const email = normalizeEmail(passwordCredential.id);
if (!email || !passwordCredential.password) return null;
return { email, password: passwordCredential.password };
} catch {
return null;
}
};
const saveBrowserLoginCredential = async (credential: SavedLoginCredential): Promise<boolean> => {
const credentials = getBrowserCredentialContainer();
const PasswordCredential = getPasswordCredentialConstructor();
if (!credentials?.store || !PasswordCredential) return false;
const email = normalizeEmail(credential.email);
if (!email || !credential.password) return false;
await credentials.store(
new PasswordCredential({
id: email,
name: email,
password: credential.password,
}),
);
return true;
};
const clearBrowserLoginCredential = async (): Promise<boolean> => {
const credentials = getBrowserCredentialContainer();
if (!credentials?.preventSilentAccess) return false;
await credentials.preventSilentAccess();
return true;
};
export const getSavedLoginCredential = async (): Promise<SavedLoginCredential | null> => {
if (isTauriRuntime()) return getDesktopSavedLoginCredential();
return getBrowserSavedLoginCredential();
};
export const saveLoginCredential = async (email: string, password: string): Promise<boolean> => {
const credential = { email: normalizeEmail(email), password };
if (!credential.email || !credential.password) return false;
if (isTauriRuntime()) return saveDesktopLoginCredential(credential);
return saveBrowserLoginCredential(credential);
};
export const clearLoginCredential = async (): Promise<boolean> => {
if (isTauriRuntime()) return clearDesktopLoginCredential();
return clearBrowserLoginCredential();
};
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import {
isExplicitAuthCheckFailure,
shouldPreserveDesktopSessionOnAuthCheckFailure,
} from "./authRecovery";
describe("desktop auth recovery classification", () => {
it("clears credentials only for explicit auth failures", () => {
expect(isExplicitAuthCheckFailure({ response: { status: 401 } })).toBe(true);
expect(isExplicitAuthCheckFailure({ response: { status: 403 } })).toBe(true);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ response: { status: 401 } })).toBe(false);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ response: { status: 403 } })).toBe(false);
});
it("preserves desktop credentials for network and server failures", () => {
expect(shouldPreserveDesktopSessionOnAuthCheckFailure(new Error("Network Error"))).toBe(true);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ code: "ECONNABORTED" })).toBe(true);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ response: { status: 500 } })).toBe(true);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ response: { status: 503 } })).toBe(true);
});
});
+14
View File
@@ -0,0 +1,14 @@
export const DESKTOP_SESSION_RESTORE_PATH = "/desktop/session-restore";
const getResponseStatus = (error: unknown): number | undefined => {
const status = (error as { response?: { status?: unknown } })?.response?.status;
return typeof status === "number" ? status : undefined;
};
export const isExplicitAuthCheckFailure = (error: unknown): boolean => {
const status = getResponseStatus(error);
return status === 401 || status === 403;
};
export const shouldPreserveDesktopSessionOnAuthCheckFailure = (error: unknown): boolean =>
!isExplicitAuthCheckFailure(error);
+10 -7
View File
@@ -1,6 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import { getLoginKey, login as apiLogin, devLogin, fetchMe } from "../api/auth"; import { getLoginKey, login as apiLogin, devLogin, fetchMe } from "../api/auth";
import type { ApiRequestConfig } from "../api/axios";
import { setToken, clearToken, getToken } from "../utils/auth"; import { setToken, clearToken, getToken } from "../utils/auth";
import { encryptLoginPayload } from "../utils/loginCrypto"; import { encryptLoginPayload } from "../utils/loginCrypto";
import type { UserInfo } from "../types/api"; import type { UserInfo } from "../types/api";
@@ -17,7 +18,7 @@ export const useAuthStore = defineStore("auth", () => {
const loading = ref(false); const loading = ref(false);
const forceLogin = ref(false); const forceLogin = ref(false);
const login = async (email: string, password: string) => { const login = async (email: string, password: string, options: { restoreStudy?: boolean } = {}) => {
loading.value = true; loading.value = true;
try { try {
const { data } = const { data } =
@@ -29,10 +30,12 @@ export const useAuthStore = defineStore("auth", () => {
useSessionStore().resetActivity(); useSessionStore().resetActivity();
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email); localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email);
const me = await fetchMeAction(); const me = await fetchMeAction();
const studyStore = useStudyStore(); if (options.restoreStudy !== false) {
const userKey = me?.email || email; const studyStore = useStudyStore();
await studyStore.restoreStudyForUser(userKey, { preferActive: me?.is_admin }); const userKey = me?.email || email;
await studyStore.loadCurrentStudyPermissions().catch(() => {}); await studyStore.restoreStudyForUser(userKey, { preferActive: me?.is_admin });
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
forceLogin.value = false; forceLogin.value = false;
} finally { } finally {
loading.value = false; loading.value = false;
@@ -53,8 +56,8 @@ export const useAuthStore = defineStore("auth", () => {
}); });
}; };
const fetchMeAction = async () => { const fetchMeAction = async (config?: ApiRequestConfig) => {
const { data } = await fetchMe(); const { data } = await fetchMe(config);
user.value = data; user.value = data;
if (data?.email) { if (data?.email) {
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, data.email); localStorage.setItem(LAST_LOGIN_EMAIL_KEY, data.email);
+16 -5
View File
@@ -3,8 +3,6 @@
* 参考 shadcn-admin 的轻量中性分层风格 * 参考 shadcn-admin 的轻量中性分层风格
*/ */
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap");
:root { :root {
/* 品牌色 - 低饱和蓝灰 */ /* 品牌色 - 低饱和蓝灰 */
--ctms-primary: #3f5d75; --ctms-primary: #3f5d75;
@@ -1057,6 +1055,17 @@ body.is-desktop-runtime .el-dialog {
max-width: min(calc(100vw - 72px), var(--el-dialog-width, 960px)); max-width: min(calc(100vw - 72px), var(--el-dialog-width, 960px));
} }
/* profile-settings-dialog / desktop-preferences-dialog:移除外层卡片外壳,内容区自带圆角阴影 */
body.is-desktop-runtime .profile-settings-dialog,
body.is-desktop-runtime .desktop-preferences-dialog {
overflow: visible !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
backdrop-filter: none !important;
}
body.is-desktop-runtime .el-message-box { body.is-desktop-runtime .el-message-box {
width: min(420px, calc(100vw - 72px)); width: min(420px, calc(100vw - 72px));
padding: 0; padding: 0;
@@ -1099,12 +1108,14 @@ body.is-desktop-runtime .el-dialog__body {
} }
body.is-desktop-runtime .desktop-preferences-dialog { body.is-desktop-runtime .desktop-preferences-dialog {
background: #f2f4f7; background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
} }
body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body { body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {
padding: 0; padding: 0;
background: #f2f4f7; background: transparent !important;
} }
body.is-desktop-runtime .el-dialog__footer, body.is-desktop-runtime .el-dialog__footer,
@@ -1182,7 +1193,7 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog, :root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body { :root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {
background: #0f1721; background: transparent !important;
} }
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__header, :root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__header,
+10 -6
View File
@@ -705,7 +705,9 @@ onBeforeUnmount(() => {
height: min(700px, calc(100vh - 96px)); height: min(700px, calc(100vh - 96px));
overflow: hidden; overflow: hidden;
background: var(--pref-bg-pane); background: var(--pref-bg-pane);
border-radius: inherit; border-radius: 8px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26);
isolation: isolate;
} }
/* ===================================================== /* =====================================================
@@ -721,6 +723,8 @@ onBeforeUnmount(() => {
/* 用半透明线替代硬边框,消除左右割裂感 */ /* 用半透明线替代硬边框,消除左右割裂感 */
border-right: 1px solid var(--pref-sidebar-border); border-right: 1px solid var(--pref-sidebar-border);
background: var(--pref-bg-sidebar); background: var(--pref-bg-sidebar);
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
} }
.window-close { .window-close {
@@ -862,6 +866,8 @@ h3 {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
background: var(--pref-bg-pane); background: var(--pref-bg-pane);
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
} }
.pane-header { .pane-header {
@@ -870,6 +876,7 @@ h3 {
padding: 22px 30px 18px; padding: 22px 30px 18px;
border-bottom: 1px solid var(--pref-border); border-bottom: 1px solid var(--pref-border);
background: var(--pref-bg-pane); background: var(--pref-bg-pane);
border-top-right-radius: 8px;
} }
.pane-header-content { .pane-header-content {
@@ -910,6 +917,7 @@ h3 {
overscroll-behavior: contain; overscroll-behavior: contain;
padding: 24px 30px 32px; padding: 24px 30px 32px;
scrollbar-gutter: stable; scrollbar-gutter: stable;
border-bottom-right-radius: 8px;
} }
.preference-panel { .preference-panel {
@@ -1043,8 +1051,6 @@ h3 {
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 8px;
margin-top: 14px; margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--pref-border);
} }
.preference-panel :deep(.el-button) { .preference-panel :deep(.el-button) {
@@ -1361,9 +1367,7 @@ code {
color: var(--pref-text-primary); color: var(--pref-text-primary);
} }
:global([data-ctms-theme="dark"] .desktop-preferences .connection-actions) {
border-top-color: var(--pref-border);
}
:global([data-ctms-theme="dark"] .desktop-preferences .theme-segmented) { :global([data-ctms-theme="dark"] .desktop-preferences .theme-segmented) {
border-color: var(--pref-border-card); border-color: var(--pref-border-card);
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readEntryView = () => readFileSync(resolve(__dirname, "./DesktopProjectEntry.vue"), "utf8");
describe("DesktopProjectEntry", () => {
it("offers admin backend and explicit project entry without direct in-project switching", () => {
const source = readEntryView();
expect(source).toContain("工作台总控");
expect(source).toContain("选择目标项目");
expect(source).toContain("Desktop Workbench");
expect(source).toContain('class="entry-background-grid"');
expect(source).toContain("管理后台");
expect(source).toContain("ADMIN SYSTEM");
expect(source).toContain("project-cards-grid");
expect(source).toContain("card-action-bar");
expect(source).toContain("进入项目工作空间");
expect(source).toContain('router.push("/admin/users")');
expect(source).toContain("studyStore.clearCurrentStudy()");
expect(source).toContain("fetchStudies()");
expect(source).toContain("studyStore.setCurrentStudy(project)");
expect(source).toContain("studyStore.loadCurrentStudyPermissions()");
expect(source).toContain("findFirstAccessibleProjectPath");
expect(source).toContain("当前账号暂无该项目可访问模块");
expect(source).toContain("forceLogout(LOGOUT_REASON_MANUAL)");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readRestoreView = () => readFileSync(resolve(__dirname, "./DesktopSessionRestore.vue"), "utf8");
describe("DesktopSessionRestore", () => {
it("waits for network recovery without clearing the desktop session", () => {
const source = readRestoreView();
expect(source).toContain("正在恢复登录状态");
expect(source).toContain("网络恢复后将自动校验账号并回到工作台");
expect(source).toContain("auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true })");
expect(source).toContain("window.setInterval");
expect(source).toContain('window.addEventListener("online", retryNow)');
expect(source).toContain("DESKTOP_SERVER_URL_CHANGED_EVENT");
expect(source).toContain("isExplicitAuthCheckFailure(error)");
expect(source).toContain("await auth.logout()");
expect(source).toContain("retryCount.value += 1");
});
});
@@ -0,0 +1,234 @@
<template>
<div class="desktop-session-restore" data-tauri-drag-region>
<section class="restore-panel">
<div class="restore-mark">CTMS</div>
<div class="restore-copy">
<span class="restore-eyebrow">Desktop Session</span>
<h1>正在恢复登录状态</h1>
<p>{{ statusMessage }}</p>
</div>
<div class="restore-status" :class="{ checking }">
<span class="status-orbit" aria-hidden="true"></span>
<span>{{ checking ? "正在连接服务器" : "等待网络恢复" }}</span>
</div>
<div class="restore-actions">
<el-button type="primary" :loading="checking" @click="retryNow">立即重试</el-button>
<RouterLink to="/desktop/server-settings" class="server-link">服务器设置</RouterLink>
</div>
<small class="restore-note">
桌面端会保留本机登录凭据网络恢复后将自动校验账号并回到工作台
</small>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
import { DESKTOP_SESSION_RESTORE_PATH, isExplicitAuthCheckFailure } from "../session/authRecovery";
import { useAuthStore } from "../store/auth";
const RETRY_INTERVAL_MS = 5_000;
const FALLBACK_REDIRECT = "/desktop/project-entry";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const checking = ref(false);
const retryCount = ref(0);
let retryTimer: number | undefined;
const redirectTarget = computed(() => {
const raw = Array.isArray(route.query.redirect) ? route.query.redirect[0] : route.query.redirect;
if (typeof raw !== "string") return FALLBACK_REDIRECT;
if (!raw.startsWith("/") || raw.startsWith("//") || raw.startsWith(DESKTOP_SESSION_RESTORE_PATH)) {
return FALLBACK_REDIRECT;
}
return raw;
});
const statusMessage = computed(() =>
retryCount.value === 0
? "正在确认服务器连接和账号状态。"
: "当前无法连接服务器,已保留本机登录状态并将自动重试。"
);
const recoverSession = async () => {
if (checking.value) return;
if (!auth.token) {
await router.replace("/login");
return;
}
checking.value = true;
try {
await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
await router.replace(redirectTarget.value);
} catch (error) {
if (isExplicitAuthCheckFailure(error)) {
await auth.logout();
await router.replace("/login");
return;
}
retryCount.value += 1;
} finally {
checking.value = false;
}
};
const retryNow = () => {
void recoverSession();
};
onMounted(() => {
void recoverSession();
retryTimer = window.setInterval(() => {
void recoverSession();
}, RETRY_INTERVAL_MS);
window.addEventListener("online", retryNow);
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, retryNow);
});
onBeforeUnmount(() => {
if (retryTimer) {
window.clearInterval(retryTimer);
}
window.removeEventListener("online", retryNow);
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, retryNow);
});
</script>
<style scoped>
.desktop-session-restore {
min-width: 1180px;
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 40px;
background:
radial-gradient(circle at 14% 16%, rgba(47, 95, 134, 0.1), transparent 30%),
radial-gradient(circle at 84% 24%, rgba(63, 143, 107, 0.1), transparent 28%),
linear-gradient(135deg, #f8fafc 0%, #eef4f8 100%);
color: #102033;
}
.restore-panel {
width: min(520px, 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
padding: 34px 36px;
border: 1px solid #d9e2ec;
border-radius: 10px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 24px 70px rgba(16, 32, 51, 0.12);
text-align: center;
}
.restore-mark {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border-radius: 16px;
background: #15344f;
color: #ffffff;
font-size: 15px;
font-weight: 800;
letter-spacing: 0;
}
.restore-copy {
display: flex;
flex-direction: column;
gap: 8px;
}
.restore-eyebrow {
color: #3f8f6b;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.restore-copy h1 {
margin: 0;
color: #102033;
font-size: 24px;
line-height: 1.25;
}
.restore-copy p,
.restore-note {
margin: 0;
color: #5d7087;
font-size: 14px;
line-height: 1.7;
}
.restore-status {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 14px;
border-radius: 999px;
background: #eef4f8;
color: #3f5d75;
font-size: 13px;
font-weight: 700;
}
.status-orbit {
width: 10px;
height: 10px;
border-radius: 999px;
background: #3f8f6b;
box-shadow: 0 0 0 4px rgba(63, 143, 107, 0.14);
}
.restore-status.checking .status-orbit {
animation: restore-pulse 1.2s ease-in-out infinite;
}
.restore-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.server-link {
color: #3f5d75;
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
.server-link:hover {
color: #15344f;
}
.restore-note {
max-width: 400px;
font-size: 12px;
}
@keyframes restore-pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(0.72);
opacity: 0.55;
}
}
</style>
-2
View File
@@ -422,8 +422,6 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer); });
</script> </script>
<style scoped> <style scoped>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Outfit:wght@600;800&display=swap");
/* /*
根容器 根容器
*/ */
+28 -23
View File
@@ -8,7 +8,7 @@ describe("Login protocol agreement", () => {
it("requires protocol agreement before calling login", () => { it("requires protocol agreement before calling login", () => {
const source = readLoginView(); const source = readLoginView();
const protocolGuardIndex = source.indexOf("!form.agreeProtocol"); const protocolGuardIndex = source.indexOf("!form.agreeProtocol");
const loginCallIndex = source.indexOf("auth.login(form.email, form.password)"); const loginCallIndex = source.indexOf("auth.login(form.email, form.password");
expect(source).toContain('v-model="form.agreeProtocol"'); expect(source).toContain('v-model="form.agreeProtocol"');
expect(source).toContain("我已阅读并同意"); expect(source).toContain("我已阅读并同意");
@@ -18,21 +18,25 @@ describe("Login protocol agreement", () => {
expect(protocolGuardIndex).toBeLessThan(loginCallIndex); expect(protocolGuardIndex).toBeLessThan(loginCallIndex);
}); });
it("does not access browser password credentials on the login page", () => { it("supports remembered passwords through runtime credential helpers only", () => {
const source = readLoginView(); const source = readLoginView();
expect(source).toContain('autocomplete="username"'); expect(source).toContain('autocomplete="username"');
expect(source).toContain('name="username"'); expect(source).toContain('name="username"');
expect(source).toContain('name="ctms-login-password"'); expect(source).toContain('name="password"');
expect(source).toContain('autocomplete="new-password"'); expect(source).toContain('autocomplete="current-password"');
expect(source).not.toContain('v-model="form.rememberPassword"'); expect(source).toContain('v-model="form.rememberPassword"');
expect(source).not.toContain("记住密码"); expect(source).toContain("记住密码");
expect(source).not.toContain("tryLoadBrowserCredential"); expect(source).toContain("getSavedLoginCredential");
expect(source).not.toContain("tryStoreBrowserCredential"); expect(source).toContain("saveLoginCredential");
expect(source).toContain("clearLoginCredential");
expect(source).not.toContain("navigator.credentials"); expect(source).not.toContain("navigator.credentials");
expect(source).not.toContain("PasswordCredential"); expect(source).not.toContain("PasswordCredential");
expect(source).not.toContain('localStorage.setItem("ctms_saved_password"'); const localStorageSetItem = "localStorage" + ".setItem";
expect(source).not.toContain("localStorage.setItem('ctms_saved_password'"); const savedPasswordKey = "ctms_saved_" + "password";
expect(source).not.toContain(`${localStorageSetItem}("${savedPasswordKey}"`);
expect(source).not.toContain(`${localStorageSetItem}('${savedPasswordKey}'`);
expect(source).not.toContain("sessionStorage.setItem");
}); });
it("opens a CTMS-specific protocol dialog from the protocol text", () => { it("opens a CTMS-specific protocol dialog from the protocol text", () => {
@@ -55,25 +59,26 @@ describe("Login protocol agreement", () => {
expect(source).toContain("confirmProtocol"); expect(source).toContain("confirmProtocol");
}); });
it("avoids browser password caching", () => { it("does not write remembered passwords to browser storage", () => {
const source = readLoginView(); const source = readLoginView();
expect(source).toContain('name="ctms-login-password"'); expect(source).not.toMatch(/localStorage\.setItem\([^)]*password/i);
expect(source).toContain('autocomplete="new-password"'); expect(source).not.toMatch(/sessionStorage\.setItem\([^)]*password/i);
expect(source).not.toContain('autocomplete="current-password"');
}); });
it("restores the current user's last project before routing after login", () => { it("sends desktop logins to the entry chooser without restoring a project in the login view", () => {
const source = readLoginView(); const source = readLoginView();
const loginCallIndex = source.indexOf("auth.login(form.email, form.password)"); const loginCallIndex = source.indexOf("auth.login(form.email, form.password, { restoreStudy: !isDesktopLogin })");
const userKeyIndex = source.indexOf("const userKey = auth.user?.email || form.email"); const clearIndex = source.indexOf("studyStore.clearCurrentStudy()");
const restoreIndex = source.indexOf("studyStore.restoreStudyForUser(userKey"); const desktopEntryRouteIndex = source.indexOf('router.push("/desktop/project-entry")');
const projectOverviewRouteIndex = source.indexOf('router.push("/project/overview")'); const projectOverviewRouteIndex = source.indexOf('router.push("/project/overview")');
expect(userKeyIndex).toBeGreaterThan(loginCallIndex); expect(source).toContain("const isDesktopLogin = showDesktopServerSettings");
expect(restoreIndex).toBeGreaterThan(loginCallIndex); expect(loginCallIndex).toBeGreaterThan(-1);
expect(restoreIndex).toBeLessThan(projectOverviewRouteIndex); expect(clearIndex).toBeGreaterThan(loginCallIndex);
expect(source).toContain("preferActive: !!auth.user?.is_admin"); expect(desktopEntryRouteIndex).toBeGreaterThan(clearIndex);
expect(desktopEntryRouteIndex).toBeLessThan(projectOverviewRouteIndex);
expect(source).not.toContain("studyStore.restoreStudyForUser(userKey");
}); });
it("shows persistent logout reason notices on the login card", () => { it("shows persistent logout reason notices on the login card", () => {
@@ -99,7 +104,7 @@ describe("Login protocol agreement", () => {
it("falls back to full email input when no server-configured domains exist", () => { it("falls back to full email input when no server-configured domains exist", () => {
const source = readLoginView(); const source = readLoginView();
expect(source).toContain('fetchEmailDomains()'); expect(source).toContain("fetchEmailDomains({ disableNetworkRetry: true, suppressErrorMessage: true })");
expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)"); expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)");
expect(source).toContain("availableEmailDomains.value.length > 0 && availableEmailDomains.value.includes(form.emailDomain)"); expect(source).toContain("availableEmailDomains.value.length > 0 && availableEmailDomains.value.includes(form.emailDomain)");
expect(source).toContain("const manualEmailInput = computed(() => !hasConfiguredEmailDomains.value)"); expect(source).toContain("const manualEmailInput = computed(() => !hasConfiguredEmailDomains.value)");
+73 -9
View File
@@ -176,11 +176,14 @@
<el-input <el-input
id="password" v-model="form.password" type="password" id="password" v-model="form.password" type="password"
placeholder="请输入密码" show-password size="large" placeholder="请输入密码" show-password size="large"
name="ctms-login-password" autocomplete="new-password" class="login-input"> name="password" autocomplete="current-password" class="login-input">
</el-input> </el-input>
</el-form-item> </el-form-item>
<div class="login-options"> <div class="login-options">
<el-checkbox v-model="form.rememberPassword" class="remember-password-checkbox">
<span class="remember-password-text">记住密码</span>
</el-checkbox>
<el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox"> <el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
<span class="protocol-text"> <span class="protocol-text">
我已阅读并同意 我已阅读并同意
@@ -256,7 +259,14 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { fetchEmailDomains } from "../api/auth"; import { fetchEmailDomains } from "../api/auth";
import { TEXT, requiredMessage } from "../locales"; import { TEXT, requiredMessage } from "../locales";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, getDesktopServerUrl, isTauriRuntime } from "../runtime"; import {
clearLoginCredential,
DESKTOP_SERVER_URL_CHANGED_EVENT,
getDesktopServerUrl,
getSavedLoginCredential,
isTauriRuntime,
saveLoginCredential,
} from "../runtime";
import { import {
consumeLogoutReason, consumeLogoutReason,
LOGOUT_REASON_AUTH_EXPIRED, LOGOUT_REASON_AUTH_EXPIRED,
@@ -270,7 +280,7 @@ const router = useRouter();
const AGREE_PROTOCOL_KEY = "ctms_agree_protocol"; const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const form = reactive({ email: "", emailLocal: "", emailDomain: "", password: "", agreeProtocol: false }); const form = reactive({ email: "", emailLocal: "", emailDomain: "", password: "", rememberPassword: false, agreeProtocol: false });
const configuredEmailDomains = ref<string[]>([]); const configuredEmailDomains = ref<string[]>([]);
const rules: FormRules<typeof form> = { const rules: FormRules<typeof form> = {
@@ -291,6 +301,7 @@ const desktopServerUrl = ref(getDesktopServerUrl());
const refreshDesktopServerUrl = () => { const refreshDesktopServerUrl = () => {
desktopServerUrl.value = getDesktopServerUrl(); desktopServerUrl.value = getDesktopServerUrl();
void loadRememberedCredential(true);
}; };
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, ""); const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
@@ -337,7 +348,7 @@ const applyEmailValue = (email: string) => {
const loadEmailDomains = async () => { const loadEmailDomains = async () => {
try { try {
const { data } = await fetchEmailDomains(); const { data } = await fetchEmailDomains({ disableNetworkRetry: true, suppressErrorMessage: true });
const domains = Array.from(new Set( const domains = Array.from(new Set(
(Array.isArray(data.items) ? data.items : []) (Array.isArray(data.items) ? data.items : [])
.map(item => typeof item === "string" ? normalizeDomain(item) : "") .map(item => typeof item === "string" ? normalizeDomain(item) : "")
@@ -373,6 +384,43 @@ const handleAccountPaste = (event: ClipboardEvent) => {
applyEmailValue(pasted); applyEmailValue(pasted);
}; };
const loadRememberedCredential = async (clearWhenMissing = false) => {
try {
const credential = await getSavedLoginCredential();
if (credential) {
applyEmailValue(credential.email);
form.password = credential.password;
form.rememberPassword = true;
return;
}
} catch {
/* ignore credential loading failures */
}
if (clearWhenMissing) {
form.password = "";
form.rememberPassword = false;
}
};
const syncRememberedCredential = async () => {
try {
if (form.rememberPassword) {
const saved = await saveLoginCredential(form.email, form.password);
if (!saved) {
ElMessage.warning(
showDesktopServerSettings
? "记住密码保存失败,请确认系统凭据库可用。"
: "当前浏览器不支持安全保存密码,请使用浏览器密码管理器。",
);
}
return;
}
await clearLoginCredential();
} catch {
ElMessage.warning("记住密码状态同步失败,本次登录不受影响。");
}
};
onMounted(async () => { onMounted(async () => {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl); window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
await loadEmailDomains(); await loadEmailDomains();
@@ -386,6 +434,7 @@ onMounted(async () => {
} }
applyEmailValue(localStorage.getItem("ctms_last_login_email") || ""); applyEmailValue(localStorage.getItem("ctms_last_login_email") || "");
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true"; form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
await loadRememberedCredential();
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -407,10 +456,15 @@ const onSubmit = async () => {
loginError.value = null; // loginError.value = null; //
loading.value = true; loading.value = true;
try { try {
await auth.login(form.email, form.password);
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const userKey = auth.user?.email || form.email; const isDesktopLogin = showDesktopServerSettings;
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin }); await auth.login(form.email, form.password, { restoreStudy: !isDesktopLogin });
await syncRememberedCredential();
if (isDesktopLogin) {
studyStore.clearCurrentStudy();
router.push("/desktop/project-entry");
return;
}
if (studyStore.currentStudy) { if (studyStore.currentStudy) {
router.push("/project/overview"); router.push("/project/overview");
} else { } else {
@@ -440,8 +494,6 @@ const onSubmit = async () => {
</script> </script>
<style scoped> <style scoped>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Outfit:wght@400;600;800&display=swap");
/* /*
根容器 根容器
*/ */
@@ -1064,25 +1116,37 @@ const onSubmit = async () => {
/* 协议区 */ /* 协议区 */
.login-options { .login-options {
display: flex;
flex-direction: column;
gap: 10px;
margin: 4px 0 24px; margin: 4px 0 24px;
} }
.remember-password-checkbox,
.protocol-checkbox {
align-items: center;
}
.remember-password-checkbox :deep(.el-checkbox__input .el-checkbox__inner),
.protocol-checkbox :deep(.el-checkbox__input .el-checkbox__inner) { .protocol-checkbox :deep(.el-checkbox__input .el-checkbox__inner) {
border-color: #cbd5e1; border-color: #cbd5e1;
border-radius: 4px; border-radius: 4px;
} }
.remember-password-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner),
.protocol-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner) { .protocol-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background: #2563eb; background: #2563eb;
border-color: #2563eb; border-color: #2563eb;
} }
.remember-password-checkbox :deep(.el-checkbox__label),
.protocol-checkbox :deep(.el-checkbox__label) { .protocol-checkbox :deep(.el-checkbox__label) {
padding-left: 8px; padding-left: 8px;
white-space: normal; white-space: normal;
line-height: 1.6; line-height: 1.6;
} }
.remember-password-text,
.protocol-text { .protocol-text {
color: #64748b; color: #64748b;
font-size: 12px; font-size: 12px;
+76 -170
View File
@@ -1,91 +1,73 @@
<template> <template>
<div class="page"> <div class="profile-layout">
<div class="profile-layout"> <aside class="profile-aside">
<aside class="profile-aside"> <div class="avatar-panel">
<div class="avatar-panel"> <el-avatar :size="88" :src="avatarPreview" :alt="form.full_name || form.email" class="profile-avatar">
<el-avatar :size="88" :src="avatarPreview" :alt="form.full_name || form.email" class="profile-avatar"> {{ profileInitial }}
{{ profileInitial }} </el-avatar>
</el-avatar> <div class="avatar-meta">
<div class="avatar-meta"> <div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div> <div class="avatar-email">{{ form.email }}</div>
<div class="avatar-email">{{ form.email }}</div>
</div>
<el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
{{ TEXT.modules.profile.uploadAvatar }}
</el-button>
</div> </div>
</aside> <el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
{{ TEXT.modules.profile.uploadAvatar }}
</el-button>
</div>
</aside>
<main class="profile-main"> <main class="profile-main">
<header class="profile-header"> <header class="profile-header">
<div> <div>
<h3 class="title">{{ TEXT.modules.profile.title }}</h3> <h3 class="title">{{ TEXT.modules.profile.title }}</h3>
</div>
<el-button class="dialog-close" text :icon="Close" aria-label="关闭个人中心" @click="emit('close-request')" />
</header>
<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">
<section class="form-section">
<div class="section-heading">
<span class="section-kicker">Account</span>
<h4>基本信息</h4>
</div> </div>
<el-button class="dialog-close" text :icon="Close" aria-label="关闭个人中心" @click="emit('close-request')" /> <el-form-item :label="TEXT.common.fields.email">
</header> <el-input v-model="form.email" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
</el-form-item>
</section>
<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off"> <section class="form-section form-section--password">
<section class="form-section"> <div class="section-heading">
<div class="section-heading"> <span class="section-kicker">Security</span>
<span class="section-kicker">Account</span> <h4>修改密码</h4>
<h4>基本信息</h4>
</div>
<el-form-item :label="TEXT.common.fields.email">
<el-input v-model="form.email" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
</el-form-item>
</section>
<section class="form-section form-section--password">
<div class="section-heading">
<span class="section-kicker">Security</span>
<h4>修改密码</h4>
</div>
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
<el-input
v-model="form.current_password"
type="password"
show-password
autocomplete="new-password"
name="ctms-profile-current-password"
:placeholder="TEXT.modules.profile.currentPasswordHint"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
</el-form-item>
</section>
<section class="form-section form-section--diagnostics">
<div class="section-heading">
<span class="section-kicker">Diagnostics</span>
<h4>客户端诊断</h4>
</div>
<div class="diagnostics-panel">
<dl>
<template v-for="row in clientDiagnosticRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
<el-button size="small" :icon="DocumentCopy" @click="copyClientDiagnostics">复制诊断信息</el-button>
</div>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div> </div>
</el-form> <el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
</main> <el-input
</div> v-model="form.current_password"
type="password"
show-password
autocomplete="new-password"
name="ctms-profile-current-password"
:placeholder="TEXT.modules.profile.currentPasswordHint"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
</el-form-item>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
</el-form>
</main>
</div> </div>
</template> </template>
@@ -93,12 +75,11 @@
import { computed, onMounted, reactive, ref, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus"; import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Close, DocumentCopy, Upload } from "@element-plus/icons-vue"; import { Close, Upload } from "@element-plus/icons-vue";
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth"; import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { TEXT, requiredMessage } from "../locales"; import { TEXT, requiredMessage } from "../locales";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback"; import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
import { clientRuntime, getAppMetadata, getDesktopServerUrl } from "../runtime";
const emit = defineEmits<{ const emit = defineEmits<{
"close-request": []; "close-request": [];
@@ -123,37 +104,12 @@ const savedProfile = ref({
const avatarPreview = ref<string | undefined>(); const avatarPreview = ref<string | undefined>();
const profileInitial = computed(() => (form.full_name?.charAt(0) || form.email?.charAt(0) || "?").toUpperCase()); const profileInitial = computed(() => (form.full_name?.charAt(0) || form.email?.charAt(0) || "?").toUpperCase());
const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
const clientMetadata = getAppMetadata();
const hasUnsavedChanges = computed( const hasUnsavedChanges = computed(
() => () =>
form.full_name !== savedProfile.value.full_name || form.full_name !== savedProfile.value.full_name ||
form.clinical_department !== savedProfile.value.clinical_department || form.clinical_department !== savedProfile.value.clinical_department ||
Boolean(form.current_password || form.password || form.confirmPassword) Boolean(form.current_password || form.password || form.confirmPassword)
); );
const clientDiagnosticRows = computed(() => {
const capabilities = clientRuntime.capabilities();
return [
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
{ label: "平台", value: clientMetadata.platform },
{ label: "构建通道", value: clientMetadata.channel },
{ label: "提交", value: clientMetadata.commit },
{
label: "服务器",
value: clientMetadata.clientType === "desktop" ? getDesktopServerUrl() || "未配置" : clientRuntime.apiBaseUrl(),
},
{
label: "能力",
value: [
capabilities.serverConfiguration ? "服务器配置" : "固定入口",
capabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
capabilities.nativeFiles ? "原生文件" : "浏览器文件",
capabilities.systemNotifications ? "系统通知" : "无系统通知",
capabilities.automaticUpdates ? "自动更新" : "无自动更新",
].join(" / "),
},
];
});
const rules: FormRules<typeof form> = { const rules: FormRules<typeof form> = {
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }], full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
clinical_department: [{ required: true, message: requiredMessage(TEXT.common.fields.clinicalDepartment), trigger: "blur" }], clinical_department: [{ required: true, message: requiredMessage(TEXT.common.fields.clinicalDepartment), trigger: "blur" }],
@@ -261,16 +217,6 @@ const selectAndUploadAvatar = async () => {
} }
}; };
const copyClientDiagnostics = async () => {
const text = clientDiagnosticRows.value.map((row) => `${row.label}: ${row.value}`).join("\n");
if (!navigator.clipboard?.writeText) {
ElMessage.warning("当前环境无法访问剪贴板");
return;
}
await navigator.clipboard.writeText(text);
ElMessage.success("诊断信息已复制");
};
onMounted(() => { onMounted(() => {
loadProfile(); loadProfile();
}); });
@@ -279,15 +225,16 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
</script> </script>
<style scoped> <style scoped>
.page {
background: #fff;
}
.profile-layout { .profile-layout {
display: grid; display: grid;
grid-template-columns: 260px minmax(0, 1fr); grid-template-columns: 260px minmax(0, 1fr);
height: min(720px, calc(100vh - 64px)); height: min(720px, calc(100vh - 64px));
min-height: 0; min-height: 0;
overflow: hidden;
background: #fff;
border-radius: 8px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26);
isolation: isolate;
} }
.profile-aside { .profile-aside {
@@ -296,6 +243,8 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
overflow: hidden; overflow: hidden;
border-right: 1px solid #e5ebf2; border-right: 1px solid #e5ebf2;
background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%); background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%);
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
} }
.avatar-panel { .avatar-panel {
@@ -351,8 +300,11 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
.profile-main { .profile-main {
min-height: 0; min-height: 0;
padding: 42px 48px 36px; padding: 42px 48px 36px;
overflow: auto; overflow: hidden;
overflow-wrap: anywhere;
background: #fff; background: #fff;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
} }
.profile-header { .profile-header {
@@ -392,12 +344,6 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
border-top: 1px solid #e5ebf2; border-top: 1px solid #e5ebf2;
} }
.form-section--diagnostics {
margin-top: 26px;
padding-top: 28px;
border-top: 1px solid #e5ebf2;
}
.section-heading { .section-heading {
margin-bottom: 18px; margin-bottom: 18px;
padding-left: 112px; padding-left: 112px;
@@ -440,42 +386,6 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
box-shadow: 0 0 0 1px #3f8f6b inset, 0 0 0 3px rgba(63, 143, 107, 0.12); box-shadow: 0 0 0 1px #3f8f6b inset, 0 0 0 3px rgba(63, 143, 107, 0.12);
} }
.diagnostics-panel {
display: flex;
flex-direction: column;
gap: 14px;
margin-left: 112px;
padding: 16px;
border: 1px solid #dfe6ee;
border-radius: 10px;
background: #f8fafc;
}
.diagnostics-panel dl {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 8px 12px;
margin: 0;
}
.diagnostics-panel dt {
color: #7f92ad;
font-size: 12px;
font-weight: 700;
}
.diagnostics-panel dd {
min-width: 0;
margin: 0;
color: #1f2a3d;
font-size: 12px;
overflow-wrap: anywhere;
}
.diagnostics-panel :deep(.el-button) {
align-self: flex-start;
}
.actions { .actions {
margin-top: 28px; margin-top: 28px;
padding-left: 112px; padding-left: 112px;
@@ -518,10 +428,6 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
padding-left: 0; padding-left: 0;
} }
.diagnostics-panel {
margin-left: 0;
}
.actions { .actions {
text-align: left; text-align: left;
} }
-2
View File
@@ -750,8 +750,6 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Outfit:wght@400;600;800&display=swap");
/* /*
根容器 根容器
*/ */
@@ -20,7 +20,7 @@ describe("EtmfPlaceholder desktop layout", () => {
expect(source).toContain('class="document-status-summary"'); expect(source).toContain('class="document-status-summary"');
expect(source).toContain('class="status-summary-list"'); expect(source).toContain('class="status-summary-list"');
expect(source).not.toContain('class="etmf-node-detail"'); expect(source).not.toContain('class="etmf-node-detail"');
expect(source).toContain("grid-template-columns: minmax(280px, 340px) minmax(620px, 1fr);"); expect(source).toContain("grid-template-columns: 280px minmax(0, 1fr);");
expect(source).not.toContain("grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px);"); expect(source).not.toContain("grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px);");
}); });
}); });
+8 -20
View File
@@ -3,10 +3,6 @@
<div v-if="study.currentStudy" class="page-body"> <div v-if="study.currentStudy" class="page-body">
<div class="overview-container"> <div class="overview-container">
<section v-if="isDesktop" class="desktop-attention-section"> <section v-if="isDesktop" class="desktop-attention-section">
<div class="desktop-attention-head">
<span>项目关注</span>
<span>更新 {{ overviewUpdatedAtLabel }}</span>
</div>
<div class="desktop-attention-board"> <div class="desktop-attention-board">
<div class="attention-card attention-card--stages"> <div class="attention-card attention-card--stages">
<div class="attention-title">阶段状态</div> <div class="attention-title">阶段状态</div>
@@ -57,6 +53,7 @@
</template> </template>
刷新 刷新
</el-button> </el-button>
<span v-if="isDesktop" class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>
<div class="progress-legend"> <div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span> <span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span> <span class="legend-item"><span class="legend-dot active"></span>进行中</span>
@@ -420,6 +417,13 @@ watch(
border-radius: 8px; border-radius: 8px;
} }
.overview-updated-at {
color: var(--ctms-text-secondary);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.overview-card { .overview-card {
background: var(--ctms-bg-card); background: var(--ctms-bg-card);
border: 1px solid var(--ctms-border-color); border: 1px solid var(--ctms-border-color);
@@ -832,18 +836,6 @@ watch(
.desktop-attention-section { .desktop-attention-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px;
}
.desktop-attention-head {
display: flex;
min-height: 18px;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #64748b;
font-size: 11px;
font-weight: 800;
} }
.attention-card { .attention-card {
@@ -939,10 +931,6 @@ watch(
border-left-color: #26364a; border-left-color: #26364a;
} }
:global([data-ctms-theme="dark"] .project-overview--desktop .desktop-attention-head) {
color: #e5edf7;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .enrollment-meter), :global([data-ctms-theme="dark"] .project-overview--desktop .enrollment-meter),
:global([data-ctms-theme="dark"] .project-overview--desktop .stage-status-item), :global([data-ctms-theme="dark"] .project-overview--desktop .stage-status-item),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row) { :global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row) {