style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标
This commit is contained in:
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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