style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标
This commit is contained in:
@@ -50,6 +50,7 @@ npm run desktop:build:app
|
||||
- [ ] Tauri command 白名单仅包含凭据和更新命令。
|
||||
- [ ] 前端源码不通过 query string 传递 token。
|
||||
- [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。
|
||||
- [ ] 登录表单密码不写入 `localStorage` 或 `sessionStorage`;Web 端只使用浏览器凭据管理能力,Desktop 端只使用系统凭据库。
|
||||
- [ ] 系统通知只能通过 `frontend/src/runtime/notifications.ts` 发送,标题和正文保持通用。
|
||||
- [ ] 通知 capability 只暴露权限查询、权限请求和发送通知,不使用 `notification:default`。
|
||||
- [ ] opener capability 只允许打开 `$TEMP/ctms-desktop/**` 下的临时文件,不开放 URL 或 reveal 权限。
|
||||
@@ -61,6 +62,7 @@ npm run desktop:build:app
|
||||
人工复审还必须确认:
|
||||
|
||||
- [ ] token 不出现在 URL、日志、系统通知正文、下载链接或持久化业务缓存中。
|
||||
- [ ] 密码不出现在 URL、日志、系统通知正文、诊断信息或明文浏览器存储中。
|
||||
- [ ] 桌面端通知正文只显示通用内容,不包含项目、文件或版本详情。
|
||||
- [ ] 服务端权限、审计和业务数据持久化仍由 FastAPI 后端裁决。
|
||||
- [ ] Web 运行时不直接导入 Tauri API。
|
||||
@@ -70,6 +72,7 @@ npm run desktop:build:app
|
||||
| 场景 | Web | macOS Desktop | 预期 |
|
||||
| --- | --- | --- | --- |
|
||||
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
|
||||
| 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 |
|
||||
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
|
||||
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
|
||||
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
|
||||
@@ -88,7 +91,8 @@ npm run desktop:build:app
|
||||
## 4. 桌面体验验收
|
||||
|
||||
- [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。
|
||||
- [ ] 30 天免登录仍只保存系统凭据库会话记录,不保存密码,不把 token 写入 URL、日志、通知正文或业务缓存。
|
||||
- [ ] 30 天免登录仍只保存系统凭据库会话记录,不把 token 写入 URL、日志、通知正文或业务缓存。
|
||||
- [ ] 记住密码与 30 天免登录使用独立凭据记录;服务器切换后不复用旧服务器保存的密码。
|
||||
- [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。
|
||||
- [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。
|
||||
- [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。
|
||||
- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。
|
||||
- 桌面端允许保存后端签发的最长 30 天在线会话,用于重启 App 后免输入密码;该会话必须存放在系统凭据库中,启动后仍需由后端 token 和 `/me` 校验确认身份,不等同于离线登录。
|
||||
- `savedLoginCredentials`:隔离网页端浏览器凭据管理与桌面端系统凭据库中的登录表单密码保存;不得把密码写入 `localStorage`、`sessionStorage`、URL、日志或通知正文,且不等同于离线登录。
|
||||
- `files`:隔离浏览器上传下载与原生文件能力。
|
||||
- `notifications`:隔离 Web 通知与桌面系统通知。
|
||||
- `updates`:隔离桌面自动更新检查与安装入口。
|
||||
@@ -75,7 +76,7 @@
|
||||
- 非本地服务连接优先使用 HTTPS。
|
||||
- 认证与授权决策保留在后端。
|
||||
- 审计敏感决策保留在后端。
|
||||
- 敏感凭据必须继续使用明确批准的安全存储方案。
|
||||
- 敏感凭据必须继续使用明确批准的安全存储方案;网页端密码只能交给浏览器凭据管理能力,桌面端密码只能交给系统凭据库。
|
||||
- 不向前端暴露宽泛文件系统访问权限。
|
||||
- Tauri 权限保持最小化,并按功能精确授权。
|
||||
- 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。
|
||||
|
||||
+61
-1
@@ -9,7 +9,67 @@
|
||||
</head>
|
||||
|
||||
<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>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -184,6 +184,9 @@ const verifyRustBoundary = async () => {
|
||||
"credentials::credential_get",
|
||||
"credentials::credential_set",
|
||||
"credentials::credential_delete",
|
||||
"credentials::login_credential_get",
|
||||
"credentials::login_credential_set",
|
||||
"credentials::login_credential_delete",
|
||||
"updates::desktop_update_check",
|
||||
"updates::desktop_update_install",
|
||||
];
|
||||
@@ -211,6 +214,10 @@ const verifySourceSafety = async () => {
|
||||
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
|
||||
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") {
|
||||
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
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> {
|
||||
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))]
|
||||
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)?;
|
||||
keyring::Entry::new(CREDENTIAL_SERVICE, &account)
|
||||
keyring::Entry::new(service, &account)
|
||||
.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 || {
|
||||
#[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() {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
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 || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
return get_entry(&server_origin)?
|
||||
return get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?
|
||||
.set_password(&token)
|
||||
.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 || {
|
||||
#[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() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(error) => Err(format!("删除系统凭据失败:{error}")),
|
||||
@@ -94,9 +95,74 @@ pub async fn credential_delete(server_origin: String) -> Result<(), String> {
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::credential_account;
|
||||
use super::{credential_account, LOGIN_CREDENTIAL_SERVICE, SESSION_CREDENTIAL_SERVICE};
|
||||
|
||||
#[test]
|
||||
fn account_is_stable_for_same_origin() {
|
||||
@@ -124,4 +190,9 @@ mod tests {
|
||||
assert!(!account.contains("ctms.example.com"));
|
||||
assert!(!account.contains("https"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_credentials_use_a_separate_keyring_service() {
|
||||
assert_ne!(SESSION_CREDENTIAL_SERVICE, LOGIN_CREDENTIAL_SERVICE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,9 @@ pub fn run() {
|
||||
credentials::credential_get,
|
||||
credentials::credential_set,
|
||||
credentials::credential_delete,
|
||||
credentials::login_credential_get,
|
||||
credentials::login_credential_set,
|
||||
credentials::login_credential_delete,
|
||||
updates::desktop_update_check,
|
||||
updates::desktop_update_install,
|
||||
])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AxiosResponse } from "axios";
|
||||
import api, { apiGet, apiPatch, apiPost } from "./axios";
|
||||
import api, { apiGet, apiPatch, apiPost, type ApiRequestConfig } from "./axios";
|
||||
import type {
|
||||
UserMeResponse,
|
||||
LoginRequest,
|
||||
@@ -24,10 +24,11 @@ export const devLogin = (payload: DevLoginRequest): Promise<AxiosResponse<LoginR
|
||||
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
|
||||
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>> =>
|
||||
apiGet<EmailDomainsResponse>("/api/v1/auth/email-domains");
|
||||
export const fetchEmailDomains = (config?: ApiRequestConfig): Promise<AxiosResponse<EmailDomainsResponse>> =>
|
||||
apiGet<EmailDomainsResponse>("/api/v1/auth/email-domains", config);
|
||||
|
||||
export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> =>
|
||||
apiPost("/api/v1/auth/register", payload);
|
||||
|
||||
@@ -5,25 +5,7 @@
|
||||
<div class="sidebar-title-row">
|
||||
<h1>{{ TEXT.common.appName }}</h1>
|
||||
|
||||
<el-dropdown v-if="study.currentStudy" trigger="click" class="study-switcher" @command="handleStudySwitch">
|
||||
<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 v-if="!study.currentStudy" class="study-switcher-trigger empty" type="button" @click="openDesktopProjectEntry">
|
||||
选择项目
|
||||
</button>
|
||||
</div>
|
||||
@@ -87,7 +69,7 @@
|
||||
</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">
|
||||
<button
|
||||
v-if="!item.children?.length"
|
||||
@@ -269,6 +251,10 @@
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ TEXT.menu.profile }}</span>
|
||||
</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-icon><Monitor /></el-icon>
|
||||
<span>系统偏好</span>
|
||||
@@ -409,11 +395,11 @@ import {
|
||||
import { ElMessageBox } from "element-plus";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { fetchStudies } from "../api/studies";
|
||||
import { fetchOverdueAesCount } from "../api/dashboard";
|
||||
import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
|
||||
import { TEXT } from "../locales";
|
||||
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
|
||||
import { DESKTOP_SESSION_RESTORE_PATH, shouldPreserveDesktopSessionOnAuthCheckFailure } from "../session/authRecovery";
|
||||
import {
|
||||
checkDesktopUpdateAndPrompt,
|
||||
getDesktopUpdateStatus,
|
||||
@@ -475,7 +461,6 @@ const profileDialogDirty = ref(false);
|
||||
const desktopServerUrl = ref(getDesktopServerUrl());
|
||||
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
|
||||
const desktopActivities = ref<DesktopActivityItem[]>(getDesktopActivities());
|
||||
const studies = ref<any[]>([]);
|
||||
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
|
||||
const workspaceTabs = ref<DesktopRoutePreference[]>([]);
|
||||
const draggingWorkspaceTabPath = ref("");
|
||||
@@ -535,7 +520,9 @@ const toggleNavigationGroup = (item: LayoutNavigationItem) => {
|
||||
expandedNavigationGroups.value = next;
|
||||
};
|
||||
const adminNavigationItems = computed(() =>
|
||||
buildAdminNavigationItems({
|
||||
study.currentStudy
|
||||
? []
|
||||
: buildAdminNavigationItems({
|
||||
hasUser: Boolean(auth.user),
|
||||
isAdmin: isAdmin.value,
|
||||
canAccessAdminPermissions: canAccessAdminPermissions.value,
|
||||
@@ -561,6 +548,8 @@ const userDisplayInitial = computed(() => {
|
||||
const source = auth.user?.full_name || auth.user?.username || auth.user?.email || TEXT.common.labels.userInitialFallback;
|
||||
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 desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning"));
|
||||
const desktopUpdateNoticeVisible = computed(
|
||||
@@ -755,7 +744,7 @@ const closeWorkspaceTab = (path: string) => {
|
||||
if (next) {
|
||||
router.push(next.path);
|
||||
} 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;
|
||||
};
|
||||
|
||||
const openDesktopProjectEntry = async () => {
|
||||
study.clearCurrentStudy();
|
||||
workspaceTabs.value = [];
|
||||
lastClosedWorkspaceTab.value = null;
|
||||
await router.push("/desktop/project-entry");
|
||||
};
|
||||
|
||||
const toggleCurrentFavorite = () => {
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
if (!current) return;
|
||||
@@ -842,18 +838,6 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
|
||||
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) => ({
|
||||
id: `workspace-tab:${item.path}`,
|
||||
title: `切换标签:${item.title}`,
|
||||
@@ -895,6 +879,13 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
|
||||
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "desktop:project-entry",
|
||||
title: projectEntryMenuLabel.value,
|
||||
group: "桌面",
|
||||
visible: true,
|
||||
run: openDesktopProjectEntry,
|
||||
},
|
||||
{
|
||||
id: "desktop:favorite-current",
|
||||
title: currentRouteFavorited.value ? "取消收藏当前模块" : "收藏当前模块",
|
||||
@@ -911,17 +902,9 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
|
||||
},
|
||||
...workspaceTabCommands,
|
||||
...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 studyId = study.currentStudy?.id;
|
||||
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 () => {
|
||||
await auth.logout();
|
||||
study.clearCurrentStudy();
|
||||
@@ -999,6 +975,8 @@ const onCommand = (cmd: string) => {
|
||||
onLogout();
|
||||
} else if (cmd === "profile") {
|
||||
profileDialogVisible.value = true;
|
||||
} else if (cmd === "projectEntry") {
|
||||
openDesktopProjectEntry();
|
||||
} else if (cmd === "desktopPreferences") {
|
||||
openDesktopPreferences();
|
||||
}
|
||||
@@ -1072,13 +1050,16 @@ onMounted(async () => {
|
||||
});
|
||||
if (auth.token && !auth.user) {
|
||||
try {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
|
||||
} catch (error) {
|
||||
if (shouldPreserveDesktopSessionOnAuthCheckFailure(error)) {
|
||||
await router.replace({ path: DESKTOP_SESSION_RESTORE_PATH, query: { redirect: route.fullPath } });
|
||||
return;
|
||||
}
|
||||
await logoutImmediately();
|
||||
return;
|
||||
}
|
||||
}
|
||||
loadStudies();
|
||||
loadHeaderReminders();
|
||||
});
|
||||
|
||||
@@ -1191,13 +1172,6 @@ useDesktopShortcuts(
|
||||
color: #172233;
|
||||
}
|
||||
|
||||
.study-switcher {
|
||||
justify-self: start;
|
||||
width: 140px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.study-switcher-trigger {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
@@ -1225,20 +1199,6 @@ useDesktopShortcuts(
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1958,12 +1918,28 @@ useDesktopShortcuts(
|
||||
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) {
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
/* dialog 容器本身设置侧边栏同色背景,彻底兜底 */
|
||||
background: #f2f4f7 !important;
|
||||
/* 移除外层卡片外壳,内容区自带圆角阴影 */
|
||||
--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(.profile-settings-dialog .el-dialog__header),
|
||||
@@ -1973,12 +1949,12 @@ useDesktopShortcuts(
|
||||
|
||||
:global(.profile-settings-dialog .el-dialog__body) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.desktop-preferences-dialog .el-dialog__body) {
|
||||
/* 背景与侧边栏一致,避免内容高度不足时白色透出 */
|
||||
padding: 0;
|
||||
background: #f2f4f7;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ const readDesktopUpdateManagerSource = () => readFileSync(resolve(__dirname, "..
|
||||
const readDesktopActivityCenterSource = () => readFileSync(resolve(__dirname, "../session/desktopActivityCenter.ts"), "utf8");
|
||||
const readProfileSettingsSource = () => readFileSync(resolve(__dirname, "../views/ProfileSettings.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 readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.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("padding: 44px 10px 10px;");
|
||||
expect(layout).toContain("grid-template-columns: auto minmax(0, 1fr);");
|
||||
expect(layout).toContain("width: 140px;");
|
||||
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("margin-top: 12px;");
|
||||
expect(layout).toContain("font-size: 14px;");
|
||||
expect(sidebarHeadTemplate).toContain('<div class="sidebar-title-row">');
|
||||
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("padding: 54px 16px 14px;");
|
||||
expect(layout).not.toContain("padding: 44px 12px 12px;");
|
||||
@@ -183,7 +195,7 @@ describe("desktop layout shell", () => {
|
||||
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 preferences = readDesktopPreferencesSource();
|
||||
|
||||
@@ -193,12 +205,14 @@ describe("desktop layout shell", () => {
|
||||
expect(profile).not.toContain("setDesktopNotificationSubscription");
|
||||
expect(profile).not.toContain("checkDesktopUpdateAndPrompt");
|
||||
expect(profile).not.toContain("desktopNotificationsEnabled");
|
||||
expect(profile).toContain("clientDiagnosticRows");
|
||||
expect(profile).toContain("copyClientDiagnostics");
|
||||
expect(profile).toContain("getAppMetadata");
|
||||
expect(profile).toContain("getDesktopServerUrl");
|
||||
expect(profile).toContain("clientRuntime.capabilities");
|
||||
expect(profile).toContain("诊断信息已复制");
|
||||
expect(profile).not.toContain("form-section--diagnostics");
|
||||
expect(profile).not.toContain("客户端诊断");
|
||||
expect(profile).not.toContain("clientDiagnosticRows");
|
||||
expect(profile).not.toContain("copyClientDiagnostics");
|
||||
expect(profile).not.toContain("getAppMetadata");
|
||||
expect(profile).not.toContain("getDesktopServerUrl");
|
||||
expect(profile).not.toContain("clientRuntime.capabilities");
|
||||
expect(profile).not.toContain("诊断信息已复制");
|
||||
expect(preferences).toContain("getDesktopNotificationSubscription");
|
||||
expect(preferences).toContain("checkDesktopUpdateAndPrompt");
|
||||
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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const styles = readMainStyleSource();
|
||||
|
||||
@@ -415,8 +442,11 @@ describe("desktop layout shell", () => {
|
||||
expect(projectOverview).not.toContain("overview-summary-strip");
|
||||
expect(projectOverview).not.toContain("const activeCenterCount");
|
||||
expect(projectOverview).toContain("desktop-attention-section");
|
||||
expect(projectOverview).toContain("desktop-attention-head");
|
||||
expect(projectOverview).toContain("更新 {{ overviewUpdatedAtLabel }}");
|
||||
expect(projectOverview).not.toContain("项目关注");
|
||||
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("enrollment-snapshot");
|
||||
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("overflow: auto;");
|
||||
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("overflow: auto;");
|
||||
expect(profile).toContain("overflow: hidden;");
|
||||
expect(profile).not.toContain("overflow: auto;");
|
||||
expect(profile).toContain("overflow-wrap: anywhere;");
|
||||
expect(preferences).toContain("flex-wrap: wrap;");
|
||||
expect(preferences).toContain("overflow-wrap: anywhere;");
|
||||
|
||||
@@ -2485,9 +2485,15 @@ useDesktopShortcuts(
|
||||
}
|
||||
|
||||
:global(.profile-settings-dialog) {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26);
|
||||
/* 移除外层卡片外壳,只保留容器行为 */
|
||||
--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(.profile-settings-dialog .el-dialog__header) {
|
||||
@@ -2496,11 +2502,19 @@ useDesktopShortcuts(
|
||||
|
||||
:global(.profile-settings-dialog .el-dialog__body) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
: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) {
|
||||
@@ -2509,6 +2523,6 @@ useDesktopShortcuts(
|
||||
|
||||
:global(.desktop-preferences-dialog .el-dialog__body) {
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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("正在加载桌面客户端");
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { getToken } from "./utils/auth";
|
||||
import { useStudyStore } from "./store/study";
|
||||
import { cleanupTemporaryFiles, initializeSecureSessionStorage, shouldRequireDesktopServerUrl } from "./runtime";
|
||||
import { cleanupTemporaryFiles, initializeSecureSessionStorage, isTauriRuntime, shouldRequireDesktopServerUrl } from "./runtime";
|
||||
|
||||
const bootstrap = async () => {
|
||||
await initializeSecureSessionStorage().catch((error) => {
|
||||
@@ -30,7 +30,7 @@ const bootstrap = async () => {
|
||||
// 初始化项目上下文
|
||||
const studyStore = useStudyStore();
|
||||
studyStore.loadCurrentStudy();
|
||||
if (getToken() && !shouldRequireDesktopServerUrl()) {
|
||||
if (getToken() && !shouldRequireDesktopServerUrl() && !isTauriRuntime()) {
|
||||
await studyStore.rehydrateStudyForLastUser();
|
||||
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -78,27 +78,43 @@ describe("admin project route permissions", () => {
|
||||
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();
|
||||
|
||||
expect(source).not.toContain("findPmAdminLandingPath");
|
||||
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", () => {
|
||||
const source = readRouter();
|
||||
const restoreGuardIndex = source.indexOf("if (!auth.user && getToken())");
|
||||
const fetchMeIndex = source.indexOf("await auth.fetchMe()", restoreGuardIndex);
|
||||
const logoutIndex = source.indexOf("await auth.logout()", fetchMeIndex);
|
||||
const restoreGuardIndex = source.indexOf("if (!auth.user && getToken() && !isDesktopSessionRestoreRoute)");
|
||||
const fetchMeIndex = source.indexOf("await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true })", restoreGuardIndex);
|
||||
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 projectFallbackIndex = source.indexOf("if (token && !studyStore.currentStudy)", restoreGuardIndex);
|
||||
const projectFallbackIndex = source.indexOf("if (token && !studyStore.currentStudy && !isDesktopRuntime)", restoreGuardIndex);
|
||||
|
||||
expect(restoreGuardIndex).toBeGreaterThan(-1);
|
||||
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(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", () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ import Login from "../views/Login.vue";
|
||||
import Register from "../views/Register.vue";
|
||||
import ForgotPassword from "../views/ForgotPassword.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 FaqDetail from "../views/FaqDetail.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 { TEXT } from "../locales";
|
||||
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_PROJECT_CONFIG = "system:permissions:project_config";
|
||||
@@ -86,6 +90,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: DesktopServerSettings,
|
||||
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: "/",
|
||||
component: Layout,
|
||||
@@ -512,6 +528,7 @@ const ensureProjectPermissionAccess = async (
|
||||
};
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const isDesktopRuntime = isTauriRuntime();
|
||||
const desktopRedirect = resolveDesktopRouteRedirect(to);
|
||||
if (desktopRedirect) {
|
||||
next({ path: desktopRedirect });
|
||||
@@ -522,10 +539,15 @@ router.beforeEach(async (to, _from, next) => {
|
||||
const studyStore = useStudyStore();
|
||||
const getToken = () => auth.token;
|
||||
let token = getToken();
|
||||
if (!auth.user && getToken()) {
|
||||
const isDesktopSessionRestoreRoute = to.path === DESKTOP_SESSION_RESTORE_PATH;
|
||||
if (!auth.user && getToken() && !isDesktopSessionRestoreRoute) {
|
||||
try {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
|
||||
} catch (error) {
|
||||
if (isDesktopRuntime && shouldPreserveDesktopSessionOnAuthCheckFailure(error)) {
|
||||
next({ path: DESKTOP_SESSION_RESTORE_PATH, query: { redirect: to.fullPath } });
|
||||
return;
|
||||
}
|
||||
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
|
||||
await auth.logout();
|
||||
next({ path: "/login" });
|
||||
@@ -534,7 +556,11 @@ router.beforeEach(async (to, _from, next) => {
|
||||
}
|
||||
token = getToken();
|
||||
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) {
|
||||
await studyStore.ensureDefaultActiveStudy();
|
||||
} else {
|
||||
@@ -544,8 +570,22 @@ router.beforeEach(async (to, _from, next) => {
|
||||
if (token && studyStore.currentStudy && 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;
|
||||
}
|
||||
if (isAdmin && to.path === "/") {
|
||||
@@ -554,6 +594,10 @@ router.beforeEach(async (to, _from, next) => {
|
||||
}
|
||||
if ((to.path === "/login" || to.path === "/register") && token) {
|
||||
if (!auth.forceLogin) {
|
||||
if (isDesktopRuntime) {
|
||||
next({ path: "/desktop/project-entry" });
|
||||
return;
|
||||
}
|
||||
next({
|
||||
path: isAdmin
|
||||
? studyStore.currentStudy
|
||||
@@ -621,7 +665,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (to.meta.requiresStudy && studyStore.currentStudy) {
|
||||
|
||||
@@ -52,6 +52,12 @@ export {
|
||||
isSecureSessionStorageAvailable,
|
||||
setSessionToken,
|
||||
} from "./secureSessionStorage";
|
||||
export {
|
||||
clearLoginCredential,
|
||||
getSavedLoginCredential,
|
||||
saveLoginCredential,
|
||||
type SavedLoginCredential,
|
||||
} from "./savedLoginCredentials";
|
||||
export {
|
||||
checkForDesktopUpdate,
|
||||
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();
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { getLoginKey, login as apiLogin, devLogin, fetchMe } from "../api/auth";
|
||||
import type { ApiRequestConfig } from "../api/axios";
|
||||
import { setToken, clearToken, getToken } from "../utils/auth";
|
||||
import { encryptLoginPayload } from "../utils/loginCrypto";
|
||||
import type { UserInfo } from "../types/api";
|
||||
@@ -17,7 +18,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
const loading = 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;
|
||||
try {
|
||||
const { data } =
|
||||
@@ -29,10 +30,12 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
useSessionStore().resetActivity();
|
||||
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email);
|
||||
const me = await fetchMeAction();
|
||||
if (options.restoreStudy !== false) {
|
||||
const studyStore = useStudyStore();
|
||||
const userKey = me?.email || email;
|
||||
await studyStore.restoreStudyForUser(userKey, { preferActive: me?.is_admin });
|
||||
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||
}
|
||||
forceLogin.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -53,8 +56,8 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
});
|
||||
};
|
||||
|
||||
const fetchMeAction = async () => {
|
||||
const { data } = await fetchMe();
|
||||
const fetchMeAction = async (config?: ApiRequestConfig) => {
|
||||
const { data } = await fetchMe(config);
|
||||
user.value = data;
|
||||
if (data?.email) {
|
||||
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, data.email);
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* 参考 shadcn-admin 的轻量、中性、分层风格
|
||||
*/
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap");
|
||||
|
||||
:root {
|
||||
/* 品牌色 - 低饱和蓝灰 */
|
||||
--ctms-primary: #3f5d75;
|
||||
@@ -1057,6 +1055,17 @@ body.is-desktop-runtime .el-dialog {
|
||||
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 {
|
||||
width: min(420px, calc(100vw - 72px));
|
||||
padding: 0;
|
||||
@@ -1099,12 +1108,14 @@ body.is-desktop-runtime .el-dialog__body {
|
||||
}
|
||||
|
||||
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 {
|
||||
padding: 0;
|
||||
background: #f2f4f7;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
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 .el-dialog__body {
|
||||
background: #0f1721;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__header,
|
||||
|
||||
@@ -705,7 +705,9 @@ onBeforeUnmount(() => {
|
||||
height: min(700px, calc(100vh - 96px));
|
||||
overflow: hidden;
|
||||
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);
|
||||
background: var(--pref-bg-sidebar);
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
}
|
||||
|
||||
.window-close {
|
||||
@@ -862,6 +866,8 @@ h3 {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--pref-bg-pane);
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
@@ -870,6 +876,7 @@ h3 {
|
||||
padding: 22px 30px 18px;
|
||||
border-bottom: 1px solid var(--pref-border);
|
||||
background: var(--pref-bg-pane);
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
|
||||
.pane-header-content {
|
||||
@@ -910,6 +917,7 @@ h3 {
|
||||
overscroll-behavior: contain;
|
||||
padding: 24px 30px 32px;
|
||||
scrollbar-gutter: stable;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
.preference-panel {
|
||||
@@ -1043,8 +1051,6 @@ h3 {
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--pref-border);
|
||||
}
|
||||
|
||||
.preference-panel :deep(.el-button) {
|
||||
@@ -1361,9 +1367,7 @@ code {
|
||||
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) {
|
||||
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>
|
||||
@@ -422,8 +422,6 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Outfit:wght@600;800&display=swap");
|
||||
|
||||
/* ═══════════════════
|
||||
根容器
|
||||
═══════════════════ */
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("Login protocol agreement", () => {
|
||||
it("requires protocol agreement before calling login", () => {
|
||||
const source = readLoginView();
|
||||
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("我已阅读并同意");
|
||||
@@ -18,21 +18,25 @@ describe("Login protocol agreement", () => {
|
||||
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();
|
||||
|
||||
expect(source).toContain('autocomplete="username"');
|
||||
expect(source).toContain('name="username"');
|
||||
expect(source).toContain('name="ctms-login-password"');
|
||||
expect(source).toContain('autocomplete="new-password"');
|
||||
expect(source).not.toContain('v-model="form.rememberPassword"');
|
||||
expect(source).not.toContain("记住密码");
|
||||
expect(source).not.toContain("tryLoadBrowserCredential");
|
||||
expect(source).not.toContain("tryStoreBrowserCredential");
|
||||
expect(source).toContain('name="password"');
|
||||
expect(source).toContain('autocomplete="current-password"');
|
||||
expect(source).toContain('v-model="form.rememberPassword"');
|
||||
expect(source).toContain("记住密码");
|
||||
expect(source).toContain("getSavedLoginCredential");
|
||||
expect(source).toContain("saveLoginCredential");
|
||||
expect(source).toContain("clearLoginCredential");
|
||||
expect(source).not.toContain("navigator.credentials");
|
||||
expect(source).not.toContain("PasswordCredential");
|
||||
expect(source).not.toContain('localStorage.setItem("ctms_saved_password"');
|
||||
expect(source).not.toContain("localStorage.setItem('ctms_saved_password'");
|
||||
const localStorageSetItem = "localStorage" + ".setItem";
|
||||
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", () => {
|
||||
@@ -55,25 +59,26 @@ describe("Login protocol agreement", () => {
|
||||
expect(source).toContain("confirmProtocol");
|
||||
});
|
||||
|
||||
it("avoids browser password caching", () => {
|
||||
it("does not write remembered passwords to browser storage", () => {
|
||||
const source = readLoginView();
|
||||
|
||||
expect(source).toContain('name="ctms-login-password"');
|
||||
expect(source).toContain('autocomplete="new-password"');
|
||||
expect(source).not.toContain('autocomplete="current-password"');
|
||||
expect(source).not.toMatch(/localStorage\.setItem\([^)]*password/i);
|
||||
expect(source).not.toMatch(/sessionStorage\.setItem\([^)]*password/i);
|
||||
});
|
||||
|
||||
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 loginCallIndex = source.indexOf("auth.login(form.email, form.password)");
|
||||
const userKeyIndex = source.indexOf("const userKey = auth.user?.email || form.email");
|
||||
const restoreIndex = source.indexOf("studyStore.restoreStudyForUser(userKey");
|
||||
const loginCallIndex = source.indexOf("auth.login(form.email, form.password, { restoreStudy: !isDesktopLogin })");
|
||||
const clearIndex = source.indexOf("studyStore.clearCurrentStudy()");
|
||||
const desktopEntryRouteIndex = source.indexOf('router.push("/desktop/project-entry")');
|
||||
const projectOverviewRouteIndex = source.indexOf('router.push("/project/overview")');
|
||||
|
||||
expect(userKeyIndex).toBeGreaterThan(loginCallIndex);
|
||||
expect(restoreIndex).toBeGreaterThan(loginCallIndex);
|
||||
expect(restoreIndex).toBeLessThan(projectOverviewRouteIndex);
|
||||
expect(source).toContain("preferActive: !!auth.user?.is_admin");
|
||||
expect(source).toContain("const isDesktopLogin = showDesktopServerSettings");
|
||||
expect(loginCallIndex).toBeGreaterThan(-1);
|
||||
expect(clearIndex).toBeGreaterThan(loginCallIndex);
|
||||
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", () => {
|
||||
@@ -99,7 +104,7 @@ describe("Login protocol agreement", () => {
|
||||
it("falls back to full email input when no server-configured domains exist", () => {
|
||||
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("availableEmailDomains.value.length > 0 && availableEmailDomains.value.includes(form.emailDomain)");
|
||||
expect(source).toContain("const manualEmailInput = computed(() => !hasConfiguredEmailDomains.value)");
|
||||
|
||||
@@ -176,11 +176,14 @@
|
||||
<el-input
|
||||
id="password" v-model="form.password" type="password"
|
||||
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-form-item>
|
||||
|
||||
<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">
|
||||
<span class="protocol-text">
|
||||
我已阅读并同意
|
||||
@@ -256,7 +259,14 @@ import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { fetchEmailDomains } from "../api/auth";
|
||||
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 {
|
||||
consumeLogoutReason,
|
||||
LOGOUT_REASON_AUTH_EXPIRED,
|
||||
@@ -270,7 +280,7 @@ const router = useRouter();
|
||||
const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
|
||||
|
||||
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 rules: FormRules<typeof form> = {
|
||||
@@ -291,6 +301,7 @@ const desktopServerUrl = ref(getDesktopServerUrl());
|
||||
|
||||
const refreshDesktopServerUrl = () => {
|
||||
desktopServerUrl.value = getDesktopServerUrl();
|
||||
void loadRememberedCredential(true);
|
||||
};
|
||||
|
||||
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
|
||||
@@ -337,7 +348,7 @@ const applyEmailValue = (email: string) => {
|
||||
|
||||
const loadEmailDomains = async () => {
|
||||
try {
|
||||
const { data } = await fetchEmailDomains();
|
||||
const { data } = await fetchEmailDomains({ disableNetworkRetry: true, suppressErrorMessage: true });
|
||||
const domains = Array.from(new Set(
|
||||
(Array.isArray(data.items) ? data.items : [])
|
||||
.map(item => typeof item === "string" ? normalizeDomain(item) : "")
|
||||
@@ -373,6 +384,43 @@ const handleAccountPaste = (event: ClipboardEvent) => {
|
||||
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 () => {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
|
||||
await loadEmailDomains();
|
||||
@@ -386,6 +434,7 @@ onMounted(async () => {
|
||||
}
|
||||
applyEmailValue(localStorage.getItem("ctms_last_login_email") || "");
|
||||
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
|
||||
await loadRememberedCredential();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -407,10 +456,15 @@ const onSubmit = async () => {
|
||||
loginError.value = null; // 清除上次错误
|
||||
loading.value = true;
|
||||
try {
|
||||
await auth.login(form.email, form.password);
|
||||
const studyStore = useStudyStore();
|
||||
const userKey = auth.user?.email || form.email;
|
||||
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
|
||||
const isDesktopLogin = showDesktopServerSettings;
|
||||
await auth.login(form.email, form.password, { restoreStudy: !isDesktopLogin });
|
||||
await syncRememberedCredential();
|
||||
if (isDesktopLogin) {
|
||||
studyStore.clearCurrentStudy();
|
||||
router.push("/desktop/project-entry");
|
||||
return;
|
||||
}
|
||||
if (studyStore.currentStudy) {
|
||||
router.push("/project/overview");
|
||||
} else {
|
||||
@@ -440,8 +494,6 @@ const onSubmit = async () => {
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
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) {
|
||||
border-color: #cbd5e1;
|
||||
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) {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.remember-password-checkbox :deep(.el-checkbox__label),
|
||||
.protocol-checkbox :deep(.el-checkbox__label) {
|
||||
padding-left: 8px;
|
||||
white-space: normal;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.remember-password-text,
|
||||
.protocol-text {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="profile-layout">
|
||||
<aside class="profile-aside">
|
||||
<div class="avatar-panel">
|
||||
@@ -64,41 +63,23 @@
|
||||
</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>
|
||||
</el-form>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import type { FormInstance, FormRules } 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 { useAuthStore } from "../store/auth";
|
||||
import { TEXT, requiredMessage } from "../locales";
|
||||
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
|
||||
import { clientRuntime, getAppMetadata, getDesktopServerUrl } from "../runtime";
|
||||
|
||||
const emit = defineEmits<{
|
||||
"close-request": [];
|
||||
@@ -123,37 +104,12 @@ const savedProfile = ref({
|
||||
const avatarPreview = ref<string | undefined>();
|
||||
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 clientMetadata = getAppMetadata();
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
form.full_name !== savedProfile.value.full_name ||
|
||||
form.clinical_department !== savedProfile.value.clinical_department ||
|
||||
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> = {
|
||||
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), 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(() => {
|
||||
loadProfile();
|
||||
});
|
||||
@@ -279,15 +225,16 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
height: min(720px, calc(100vh - 64px));
|
||||
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 {
|
||||
@@ -296,6 +243,8 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
||||
overflow: hidden;
|
||||
border-right: 1px solid #e5ebf2;
|
||||
background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%);
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
}
|
||||
|
||||
.avatar-panel {
|
||||
@@ -351,8 +300,11 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
||||
.profile-main {
|
||||
min-height: 0;
|
||||
padding: 42px 48px 36px;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
background: #fff;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
@@ -392,12 +344,6 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
||||
border-top: 1px solid #e5ebf2;
|
||||
}
|
||||
|
||||
.form-section--diagnostics {
|
||||
margin-top: 26px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid #e5ebf2;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 18px;
|
||||
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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-top: 28px;
|
||||
padding-left: 112px;
|
||||
@@ -518,10 +428,6 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.diagnostics-panel {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -750,8 +750,6 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<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="status-summary-list"');
|
||||
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);");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
<div v-if="study.currentStudy" class="page-body">
|
||||
<div class="overview-container">
|
||||
<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="attention-card attention-card--stages">
|
||||
<div class="attention-title">阶段状态</div>
|
||||
@@ -57,6 +53,7 @@
|
||||
</template>
|
||||
刷新
|
||||
</el-button>
|
||||
<span v-if="isDesktop" class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>
|
||||
<div class="progress-legend">
|
||||
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
|
||||
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
|
||||
@@ -420,6 +417,13 @@ watch(
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.overview-updated-at {
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
background: var(--ctms-bg-card);
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
@@ -832,18 +836,6 @@ watch(
|
||||
.desktop-attention-section {
|
||||
display: flex;
|
||||
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 {
|
||||
@@ -939,10 +931,6 @@ watch(
|
||||
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 .stage-status-item),
|
||||
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row) {
|
||||
|
||||
Reference in New Issue
Block a user