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

This commit is contained in:
Cheng Zhou
2026-07-08 20:20:41 +08:00
parent e7b18758b2
commit b73f23c1eb
33 changed files with 2323 additions and 379 deletions
+61 -1
View File
@@ -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.`);
}
+78 -7
View File
@@ -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);
}
}
+3
View File
@@ -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,
])
+5 -4
View File
@@ -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);
+62 -86
View File
@@ -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,11 +520,13 @@ const toggleNavigationGroup = (item: LayoutNavigationItem) => {
expandedNavigationGroups.value = next;
};
const adminNavigationItems = computed(() =>
buildAdminNavigationItems({
hasUser: Boolean(auth.user),
isAdmin: isAdmin.value,
canAccessAdminPermissions: canAccessAdminPermissions.value,
}),
study.currentStudy
? []
: buildAdminNavigationItems({
hasUser: Boolean(auth.user),
isAdmin: isAdmin.value,
canAccessAdminPermissions: canAccessAdminPermissions.value,
}),
);
const projectNavigationItems = computed(() =>
buildProjectNavigationItems({
@@ -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;
}
+45 -12
View File
@@ -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;");
+20 -6
View File
@@ -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>
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readIndexHtml = () => readFileSync(resolve(__dirname, "../index.html"), "utf8");
describe("app bootstrap shell", () => {
it("renders a lightweight startup placeholder before Vue mounts", () => {
const source = readIndexHtml();
expect(source).toContain('<div id="app">');
expect(source).toContain("ctms-boot-splash");
expect(source).toContain("正在启动 CTMS");
expect(source).toContain("正在加载桌面客户端");
});
});
+2 -2
View File
@@ -12,7 +12,7 @@ import App from "./App.vue";
import router from "./router";
import { 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(() => {});
}
+23 -7
View File
@@ -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", () => {
+51 -7
View File
@@ -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) {
+6
View File
@@ -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();
};
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import {
isExplicitAuthCheckFailure,
shouldPreserveDesktopSessionOnAuthCheckFailure,
} from "./authRecovery";
describe("desktop auth recovery classification", () => {
it("clears credentials only for explicit auth failures", () => {
expect(isExplicitAuthCheckFailure({ response: { status: 401 } })).toBe(true);
expect(isExplicitAuthCheckFailure({ response: { status: 403 } })).toBe(true);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ response: { status: 401 } })).toBe(false);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ response: { status: 403 } })).toBe(false);
});
it("preserves desktop credentials for network and server failures", () => {
expect(shouldPreserveDesktopSessionOnAuthCheckFailure(new Error("Network Error"))).toBe(true);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ code: "ECONNABORTED" })).toBe(true);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ response: { status: 500 } })).toBe(true);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ response: { status: 503 } })).toBe(true);
});
});
+14
View File
@@ -0,0 +1,14 @@
export const DESKTOP_SESSION_RESTORE_PATH = "/desktop/session-restore";
const getResponseStatus = (error: unknown): number | undefined => {
const status = (error as { response?: { status?: unknown } })?.response?.status;
return typeof status === "number" ? status : undefined;
};
export const isExplicitAuthCheckFailure = (error: unknown): boolean => {
const status = getResponseStatus(error);
return status === 401 || status === 403;
};
export const shouldPreserveDesktopSessionOnAuthCheckFailure = (error: unknown): boolean =>
!isExplicitAuthCheckFailure(error);
+10 -7
View File
@@ -1,6 +1,7 @@
import { defineStore } from "pinia";
import { 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();
const studyStore = useStudyStore();
const userKey = me?.email || email;
await studyStore.restoreStudyForUser(userKey, { preferActive: me?.is_admin });
await studyStore.loadCurrentStudyPermissions().catch(() => {});
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);
+16 -5
View File
@@ -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,
+10 -6
View File
@@ -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>
-2
View File
@@ -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");
/*
根容器
*/
+28 -23
View File
@@ -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)");
+73 -9
View File
@@ -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;
+76 -170
View File
@@ -1,91 +1,73 @@
<template>
<div class="page">
<div class="profile-layout">
<aside class="profile-aside">
<div class="avatar-panel">
<el-avatar :size="88" :src="avatarPreview" :alt="form.full_name || form.email" class="profile-avatar">
{{ profileInitial }}
</el-avatar>
<div class="avatar-meta">
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
<div class="avatar-email">{{ form.email }}</div>
</div>
<el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
{{ TEXT.modules.profile.uploadAvatar }}
</el-button>
<div class="profile-layout">
<aside class="profile-aside">
<div class="avatar-panel">
<el-avatar :size="88" :src="avatarPreview" :alt="form.full_name || form.email" class="profile-avatar">
{{ profileInitial }}
</el-avatar>
<div class="avatar-meta">
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
<div class="avatar-email">{{ form.email }}</div>
</div>
</aside>
<el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
{{ TEXT.modules.profile.uploadAvatar }}
</el-button>
</div>
</aside>
<main class="profile-main">
<header class="profile-header">
<div>
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
<main class="profile-main">
<header class="profile-header">
<div>
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
</div>
<el-button class="dialog-close" text :icon="Close" aria-label="关闭个人中心" @click="emit('close-request')" />
</header>
<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">
<section class="form-section">
<div class="section-heading">
<span class="section-kicker">Account</span>
<h4>基本信息</h4>
</div>
<el-button class="dialog-close" text :icon="Close" aria-label="关闭个人中心" @click="emit('close-request')" />
</header>
<el-form-item :label="TEXT.common.fields.email">
<el-input v-model="form.email" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
</el-form-item>
</section>
<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">
<section class="form-section">
<div class="section-heading">
<span class="section-kicker">Account</span>
<h4>基本信息</h4>
</div>
<el-form-item :label="TEXT.common.fields.email">
<el-input v-model="form.email" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
</el-form-item>
</section>
<section class="form-section form-section--password">
<div class="section-heading">
<span class="section-kicker">Security</span>
<h4>修改密码</h4>
</div>
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
<el-input
v-model="form.current_password"
type="password"
show-password
autocomplete="new-password"
name="ctms-profile-current-password"
:placeholder="TEXT.modules.profile.currentPasswordHint"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
</el-form-item>
</section>
<section class="form-section form-section--diagnostics">
<div class="section-heading">
<span class="section-kicker">Diagnostics</span>
<h4>客户端诊断</h4>
</div>
<div class="diagnostics-panel">
<dl>
<template v-for="row in clientDiagnosticRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
<el-button size="small" :icon="DocumentCopy" @click="copyClientDiagnostics">复制诊断信息</el-button>
</div>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
<section class="form-section form-section--password">
<div class="section-heading">
<span class="section-kicker">Security</span>
<h4>修改密码</h4>
</div>
</el-form>
</main>
</div>
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
<el-input
v-model="form.current_password"
type="password"
show-password
autocomplete="new-password"
name="ctms-profile-current-password"
:placeholder="TEXT.modules.profile.currentPasswordHint"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
</el-form-item>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
</el-form>
</main>
</div>
</template>
@@ -93,12 +75,11 @@
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;
}
-2
View File
@@ -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);");
});
});
+8 -20
View File
@@ -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) {