优化顶栏导航与登录请求体验
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
import type { AxiosAdapter, AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const errorMessage = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("element-plus", () => ({
|
||||||
|
ElMessage: {
|
||||||
|
error: errorMessage,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../router", () => ({
|
||||||
|
default: {
|
||||||
|
push: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../utils/auth", () => ({
|
||||||
|
getToken: vi.fn(() => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../store/study", () => ({
|
||||||
|
useStudyStore: vi.fn(() => ({
|
||||||
|
clearCurrentStudy: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../session/sessionManager", () => ({
|
||||||
|
extendAccessToken: vi.fn(),
|
||||||
|
forceLogout: vi.fn(),
|
||||||
|
LOGOUT_REASON_AUTH_EXPIRED: "auth-expired",
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("api axios retry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
errorMessage.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries network failures 10 times at 30 second intervals before showing one error", async () => {
|
||||||
|
const api = (await import("./axios")).default;
|
||||||
|
const adapter = vi.fn<AxiosAdapter>(async (config) => {
|
||||||
|
const error = new Error("Network Error") as AxiosError;
|
||||||
|
error.config = config as InternalAxiosRequestConfig;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = api.get("/api/v1/projects", { adapter }).catch((error) => error);
|
||||||
|
await vi.dynamicImportSettled();
|
||||||
|
|
||||||
|
expect(adapter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(errorMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
for (let retry = 1; retry < 10; retry += 1) {
|
||||||
|
await vi.advanceTimersByTimeAsync(30000);
|
||||||
|
expect(adapter).toHaveBeenCalledTimes(retry + 1);
|
||||||
|
expect(errorMessage).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(30000);
|
||||||
|
expect(adapter).toHaveBeenCalledTimes(11);
|
||||||
|
await expect(request).resolves.toMatchObject({ message: "Network Error" });
|
||||||
|
expect(errorMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+37
-12
@@ -1,20 +1,41 @@
|
|||||||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from "axios";
|
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from "axios";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import router from "../router";
|
|
||||||
import { getToken } from "../utils/auth";
|
import { getToken } from "../utils/auth";
|
||||||
import type { ApiError } from "../types/api";
|
import type { ApiError } from "../types/api";
|
||||||
import { useStudyStore } from "../store/study";
|
|
||||||
import { TEXT } from "../locales";
|
import { TEXT } from "../locales";
|
||||||
import { extendAccessToken, forceLogout, LOGOUT_REASON_AUTH_EXPIRED } from "../session/sessionManager";
|
|
||||||
|
|
||||||
const instance: AxiosInstance = axios.create({
|
const instance: AxiosInstance = axios.create({
|
||||||
baseURL: "/",
|
baseURL: "/",
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const NETWORK_RETRY_LIMIT = 10;
|
||||||
|
const NETWORK_RETRY_DELAY_MS = 30000;
|
||||||
|
|
||||||
export type ApiRequestConfig = AxiosRequestConfig & {
|
export type ApiRequestConfig = AxiosRequestConfig & {
|
||||||
suppressErrorMessage?: boolean;
|
suppressErrorMessage?: boolean;
|
||||||
_retry?: boolean;
|
_retry?: boolean;
|
||||||
|
_networkRetryCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const clearInvalidStudyAndNavigate = async () => {
|
||||||
|
try {
|
||||||
|
const [{ useStudyStore }, { default: router }] = await Promise.all([
|
||||||
|
import("../store/study"),
|
||||||
|
import("../router"),
|
||||||
|
]);
|
||||||
|
useStudyStore().clearCurrentStudy();
|
||||||
|
router.push("/admin/projects");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const forceAuthExpiredLogout = async () => {
|
||||||
|
const { forceLogout, LOGOUT_REASON_AUTH_EXPIRED } = await import("../session/sessionManager");
|
||||||
|
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||||
};
|
};
|
||||||
|
|
||||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => {
|
instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => {
|
||||||
@@ -40,19 +61,23 @@ instance.interceptors.response.use(
|
|||||||
// 认证相关的错误由具体页面自行处理,避免重复提示
|
// 认证相关的错误由具体页面自行处理,避免重复提示
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
if (!status && error.config) {
|
||||||
|
const config = error.config as ApiRequestConfig;
|
||||||
|
const retryCount = config._networkRetryCount || 0;
|
||||||
|
if (retryCount < NETWORK_RETRY_LIMIT) {
|
||||||
|
config._networkRetryCount = retryCount + 1;
|
||||||
|
await wait(NETWORK_RETRY_DELAY_MS);
|
||||||
|
return instance(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
// 如果当前项目不存在或已失效,清理项目并跳到项目管理页
|
// 如果当前项目不存在或已失效,清理项目并跳到项目管理页
|
||||||
if (status === 404 && typeof data?.message === "string" && data.message.toLowerCase().includes("study")) {
|
if (status === 404 && typeof data?.message === "string" && data.message.toLowerCase().includes("study")) {
|
||||||
try {
|
await clearInvalidStudyAndNavigate();
|
||||||
const studyStore = useStudyStore();
|
|
||||||
studyStore.clearCurrentStudy();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
router.push("/admin/projects");
|
|
||||||
}
|
}
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
const config = error.config as ApiRequestConfig;
|
const config = error.config as ApiRequestConfig;
|
||||||
if (config && !config._retry) {
|
if (config && !config._retry) {
|
||||||
|
const { extendAccessToken } = await import("../session/sessionManager");
|
||||||
const result = await extendAccessToken("response-401");
|
const result = await extendAccessToken("response-401");
|
||||||
if (result.token) {
|
if (result.token) {
|
||||||
config._retry = true;
|
config._retry = true;
|
||||||
@@ -61,11 +86,11 @@ instance.interceptors.response.use(
|
|||||||
return instance(config);
|
return instance(config);
|
||||||
}
|
}
|
||||||
if (result.authFailed) {
|
if (result.authFailed) {
|
||||||
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
await forceAuthExpiredLogout();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (status === 403 && (data as any)?.detail === "账号已停用") {
|
} else if (status === 403 && (data as any)?.detail === "账号已停用") {
|
||||||
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
await forceAuthExpiredLogout();
|
||||||
} else {
|
} else {
|
||||||
const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage;
|
const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage;
|
||||||
if (!suppressErrorMessage) {
|
if (!suppressErrorMessage) {
|
||||||
|
|||||||
+1154
-120
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ describe("Layout breadcrumbs", () => {
|
|||||||
it("shows the file version module before document detail titles", () => {
|
it("shows the file version module before document detail titles", () => {
|
||||||
const source = readLayout();
|
const source = readLayout();
|
||||||
|
|
||||||
expect(source).toContain('"/documents": { label: TEXT.menu.fileVersionManagement');
|
expect(source).toContain('match: ["/file-versions", "/documents", "/trial/"]');
|
||||||
expect(source).toContain("study.viewContext?.pageTitle");
|
expect(source).toContain("study.viewContext?.pageTitle");
|
||||||
expect(source).toContain("items.push({ label: study.viewContext.pageTitle, path: route.path })");
|
expect(source).toContain("items.push({ label: study.viewContext.pageTitle, path: route.path })");
|
||||||
});
|
});
|
||||||
@@ -28,6 +28,171 @@ describe("Layout breadcrumbs", () => {
|
|||||||
expect(source).toContain('path: "/admin/projects"');
|
expect(source).toContain('path: "/admin/projects"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("restores the permission management parent for admin permission sub-pages", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain('if (route.path.startsWith("/admin/permissions/"))');
|
||||||
|
expect(source).toContain("label: TEXT.menu.permissionManagement");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects the project name level on the admin sites page", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain('if (route.path.includes("/sites"))');
|
||||||
|
expect(source).toContain("studies.value.find((s) => String(s.id) === pid)");
|
||||||
|
expect(source).toContain("items.push({ label: proj.name, path: `/admin/projects/${pid}` })");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the admin root breadcrumb as a non-clickable label", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain("items.push({ label: TEXT.menu.admin });");
|
||||||
|
expect(source).not.toContain('path: isAdmin.value ? "/admin/users" : "/admin/projects"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables sibling navigation dropdowns on admin breadcrumbs", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain("item.navDropdown && item.siblings?.length");
|
||||||
|
expect(source).toContain('{ type: \'nav\', value: sib.path }');
|
||||||
|
expect(source).toContain("if (cmd.type === 'nav')");
|
||||||
|
expect(source).toContain("siblings: topItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds project breadcrumbs from a permission-filtered nav tree", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain("const navTree: NavNode[] = [");
|
||||||
|
expect(source).toContain("label: TEXT.menu.sharedLibrary");
|
||||||
|
expect(source).toContain("label: TEXT.menu.riskIssues");
|
||||||
|
expect(source).toContain("label: TEXT.menu.materialManagement");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders shared-library items as a third-level group, not top-level", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
// 三级子项(医学咨询等)必须挂在顶级分组的 children 内,而非顶级数组
|
||||||
|
expect(source).toContain("children: [");
|
||||||
|
expect(source).toContain("canAccessProjectPath(c.path)");
|
||||||
|
expect(source).toContain("childSiblings.length > 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a header center region with project status but not ambiguous phase", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain('class="header-center"');
|
||||||
|
expect(source).toContain("const headerProjectInfo = computed");
|
||||||
|
expect(source).toContain("statusTone");
|
||||||
|
expect(source).toContain('class="header-status-pill"');
|
||||||
|
expect(source).toContain("header-status-dot");
|
||||||
|
expect(source).not.toContain("headerProjectInfo.roleLabel");
|
||||||
|
expect(source).not.toContain("headerProjectInfo.phase");
|
||||||
|
expect(source).not.toContain("TEXT.common.labels.phase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the project role next to the account menu", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain('class="header-account-role"');
|
||||||
|
expect(source).toContain("accountProjectRoleLabel");
|
||||||
|
expect(source).toContain("v-if=\"accountProjectRoleLabel\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses chip styling instead of vertical divider lines for header metadata", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain("class=\"header-meta-item header-metric\"");
|
||||||
|
expect(source).toContain("class=\"header-context-rail\"");
|
||||||
|
expect(source).toContain("border-radius: 14px");
|
||||||
|
expect(source).toContain("border: 1px solid transparent");
|
||||||
|
expect(source).toContain("background: transparent");
|
||||||
|
expect(source).toContain("box-shadow: none");
|
||||||
|
expect(source).toContain("overflow: visible");
|
||||||
|
expect(source).toContain("class=\"header-meta-value\"");
|
||||||
|
expect(source).toContain("font-variant-numeric: tabular-nums");
|
||||||
|
expect(source).toContain("border: 1px solid rgba(213, 224, 237, 0.94)");
|
||||||
|
expect(source).not.toContain("0 5px 14px rgba(31, 109, 80, 0.14)");
|
||||||
|
expect(source).not.toContain("border-left: 1px solid #e2e8f0");
|
||||||
|
expect(source).not.toContain("border-right: 1px solid #e2e8f0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate the account identity in the admin header center", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).not.toContain("const headerAdminInfo = computed");
|
||||||
|
expect(source).not.toContain("headerAdminInfo.email");
|
||||||
|
expect(source).not.toContain("header-admin-email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a denser project context rail with planned site and enrollment metrics", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain('class="header-context-rail"');
|
||||||
|
expect(source).toContain("headerProjectInfo.metrics");
|
||||||
|
expect(source).toContain("plannedSiteCount");
|
||||||
|
expect(source).toContain("plannedEnrollmentCount");
|
||||||
|
expect(source).toContain("headerOverviewStats");
|
||||||
|
expect(source).toContain("fetchProjectOverview");
|
||||||
|
expect(source).toContain("formatHeaderProgress");
|
||||||
|
expect(source).toContain("headerMetric.key");
|
||||||
|
expect(source).toContain("TEXT.common.labels.plannedSites");
|
||||||
|
expect(source).toContain("TEXT.common.labels.plannedEnrollment");
|
||||||
|
expect(source).toContain("TEXT.common.labels.dataUpdatedAt");
|
||||||
|
expect(source).toContain("loadHeaderOverviewStats");
|
||||||
|
expect(source).toContain("study.currentStudy?.id !== studyId");
|
||||||
|
expect(source).toContain("headerClockNow");
|
||||||
|
expect(source).toContain("formatHeaderDateTime");
|
||||||
|
expect(source).toContain("window.setInterval");
|
||||||
|
expect(source).toContain("window.clearInterval");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a compact project reminder dropdown for overdue risks", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain('class="header-reminder-dropdown"');
|
||||||
|
expect(source).toContain("headerReminderTotal");
|
||||||
|
expect(source).toContain("headerReminderItems");
|
||||||
|
expect(source).toContain("fetchOverdueAesCount");
|
||||||
|
expect(source).toContain("listMonitoringVisitIssues");
|
||||||
|
expect(source).toContain("TEXT.common.labels.projectReminders");
|
||||||
|
expect(source).toContain("TEXT.common.labels.overdueAes");
|
||||||
|
expect(source).toContain("TEXT.common.labels.overdueMonitoringIssues");
|
||||||
|
expect(source).toContain("handleReminderCommand");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("styles the app header as a fixed chrome layer above page content", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain("position: sticky");
|
||||||
|
expect(source).toContain("top: 0");
|
||||||
|
expect(source).toContain("z-index: 30");
|
||||||
|
expect(source).toContain(".layout-header::after");
|
||||||
|
expect(source).toContain("0 10px 26px rgba(15, 23, 42, 0.08)");
|
||||||
|
expect(source).toContain("border-bottom: 1px solid rgba(203, 213, 225, 0.78)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits visual breadcrumb separators and truncates long breadcrumb labels", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).not.toContain("breadcrumb-separator");
|
||||||
|
expect(source).not.toContain("TEXT.common.separators.dot");
|
||||||
|
expect(source).toContain("text-overflow: ellipsis");
|
||||||
|
expect(source).toContain("max-width: 52vw");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("styles header breadcrumbs as compact navigation controls", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain('class="breadcrumb-item-wrapper is-link is-study"');
|
||||||
|
expect(source).toContain("'is-current': index === breadcrumbs.length - 1");
|
||||||
|
expect(source).toContain(".breadcrumb-item-wrapper.is-study");
|
||||||
|
expect(source).toContain(".breadcrumb-item-wrapper.is-current");
|
||||||
|
expect(source).toContain("border-color: rgba(198, 214, 235, 0.92)");
|
||||||
|
expect(source).toContain("height: 26px");
|
||||||
|
expect(source).toContain("border-radius: 7px");
|
||||||
|
expect(source).toContain("gap: 2px");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses stable breadcrumb keys when context titles change", () => {
|
it("uses stable breadcrumb keys when context titles change", () => {
|
||||||
const source = readLayout();
|
const source = readLayout();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ export const TEXT = {
|
|||||||
common: {
|
common: {
|
||||||
appName: "CTMS",
|
appName: "CTMS",
|
||||||
fallback: "—",
|
fallback: "—",
|
||||||
|
separators: {
|
||||||
|
dot: "·",
|
||||||
|
},
|
||||||
loading: "加载中",
|
loading: "加载中",
|
||||||
actions: {
|
actions: {
|
||||||
add: "新增",
|
add: "新增",
|
||||||
@@ -88,6 +91,18 @@ export const TEXT = {
|
|||||||
userFallback: "用户",
|
userFallback: "用户",
|
||||||
basicInfo: "基本信息",
|
basicInfo: "基本信息",
|
||||||
allSites: "所有中心",
|
allSites: "所有中心",
|
||||||
|
locked: "已锁定",
|
||||||
|
phase: "分期",
|
||||||
|
plannedSites: "中心",
|
||||||
|
plannedEnrollment: "入组",
|
||||||
|
dataUpdatedAt: "数据",
|
||||||
|
projectReminders: "提醒",
|
||||||
|
projectRemindersSubtitle: "逾期与风险提示",
|
||||||
|
projectRemindersEmpty: "暂无逾期风险",
|
||||||
|
overdueAes: "逾期 AE",
|
||||||
|
overdueAesDesc: "需关注安全性事件处理时效",
|
||||||
|
overdueMonitoringIssues: "逾期监查问题",
|
||||||
|
overdueMonitoringIssuesDesc: "需跟进问题关闭与整改",
|
||||||
yes: "是",
|
yes: "是",
|
||||||
no: "否",
|
no: "否",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,18 +18,19 @@ describe("Login protocol agreement", () => {
|
|||||||
expect(protocolGuardIndex).toBeLessThan(loginCallIndex);
|
expect(protocolGuardIndex).toBeLessThan(loginCallIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers remember password through browser credentials without local password storage", () => {
|
it("does not access browser password credentials on the login page", () => {
|
||||||
const source = readLoginView();
|
const source = readLoginView();
|
||||||
|
|
||||||
expect(source).toContain('v-model="form.rememberPassword"');
|
|
||||||
expect(source).toContain("记住密码");
|
|
||||||
expect(source).toContain('autocomplete="username"');
|
expect(source).toContain('autocomplete="username"');
|
||||||
expect(source).toContain('name="username"');
|
expect(source).toContain('name="username"');
|
||||||
expect(source).toContain(":name=\"form.rememberPassword ? 'password' : 'ctms-login-password'\"");
|
expect(source).toContain('name="ctms-login-password"');
|
||||||
expect(source).toContain("tryLoadBrowserCredential");
|
expect(source).toContain('autocomplete="new-password"');
|
||||||
expect(source).toContain("tryStoreBrowserCredential");
|
expect(source).not.toContain('v-model="form.rememberPassword"');
|
||||||
expect(source).toContain("navigator.credentials");
|
expect(source).not.toContain("记住密码");
|
||||||
expect(source).toContain("password: true");
|
expect(source).not.toContain("tryLoadBrowserCredential");
|
||||||
|
expect(source).not.toContain("tryStoreBrowserCredential");
|
||||||
|
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"');
|
||||||
expect(source).not.toContain("localStorage.setItem('ctms_saved_password'");
|
expect(source).not.toContain("localStorage.setItem('ctms_saved_password'");
|
||||||
});
|
});
|
||||||
@@ -54,11 +55,11 @@ describe("Login protocol agreement", () => {
|
|||||||
expect(source).toContain("confirmProtocol");
|
expect(source).toContain("confirmProtocol");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("avoids browser password caching when remember password is off", () => {
|
it("avoids browser password caching", () => {
|
||||||
const source = readLoginView();
|
const source = readLoginView();
|
||||||
|
|
||||||
expect(source).toContain(":name=\"form.rememberPassword ? 'password' : 'ctms-login-password'\"");
|
expect(source).toContain('name="ctms-login-password"');
|
||||||
expect(source).toContain(":autocomplete=\"form.rememberPassword ? 'current-password' : 'new-password'\"");
|
expect(source).toContain('autocomplete="new-password"');
|
||||||
expect(source).not.toContain('autocomplete="current-password"');
|
expect(source).not.toContain('autocomplete="current-password"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,8 @@
|
|||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
show-password
|
show-password
|
||||||
size="large"
|
size="large"
|
||||||
:name="form.rememberPassword ? 'password' : 'ctms-login-password'"
|
name="ctms-login-password"
|
||||||
:autocomplete="form.rememberPassword ? 'current-password' : 'new-password'"
|
autocomplete="new-password"
|
||||||
class="login-input"
|
class="login-input"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@@ -94,9 +94,6 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<div class="login-options">
|
<div class="login-options">
|
||||||
<el-checkbox v-model="form.rememberPassword" class="remember-checkbox">
|
|
||||||
<span class="remember-text">记住密码</span>
|
|
||||||
</el-checkbox>
|
|
||||||
<el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
|
<el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
|
||||||
<span class="protocol-text">
|
<span class="protocol-text">
|
||||||
我已阅读并同意
|
我已阅读并同意
|
||||||
@@ -160,17 +157,12 @@ import { authProtocolSections } from "../content/authProtocol";
|
|||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const REMEMBER_PASSWORD_KEY = "ctms_remember_password";
|
|
||||||
const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
|
const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
|
||||||
|
|
||||||
type PasswordCredentialConstructor = new (data: { id: string; password: string; name?: string }) => Credential;
|
|
||||||
type StoredPasswordCredential = Credential & { id?: string; password?: string };
|
|
||||||
|
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
rememberPassword: false,
|
|
||||||
agreeProtocol: false,
|
agreeProtocol: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,9 +202,7 @@ onMounted(async () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
form.email = localStorage.getItem("ctms_last_login_email") || "";
|
form.email = localStorage.getItem("ctms_last_login_email") || "";
|
||||||
form.rememberPassword = localStorage.getItem(REMEMBER_PASSWORD_KEY) === "true";
|
|
||||||
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
|
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
|
||||||
await tryLoadBrowserCredential();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -222,40 +212,6 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const tryLoadBrowserCredential = async () => {
|
|
||||||
if (!form.rememberPassword || !navigator.credentials?.get) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const credential = (await navigator.credentials.get({
|
|
||||||
password: true,
|
|
||||||
mediation: "optional",
|
|
||||||
} as CredentialRequestOptions)) as StoredPasswordCredential | null;
|
|
||||||
if (!credential?.password) return;
|
|
||||||
form.email = credential.id || form.email;
|
|
||||||
form.password = credential.password;
|
|
||||||
} catch {
|
|
||||||
// 浏览器可能不支持读取密码凭据,保留用户手动输入流程。
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const tryStoreBrowserCredential = async (email: string, password: string) => {
|
|
||||||
if (!form.rememberPassword) {
|
|
||||||
localStorage.removeItem(REMEMBER_PASSWORD_KEY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(REMEMBER_PASSWORD_KEY, "true");
|
|
||||||
const PasswordCredential = (window as typeof window & { PasswordCredential?: PasswordCredentialConstructor })
|
|
||||||
.PasswordCredential;
|
|
||||||
if (!navigator.credentials?.store || !PasswordCredential) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.credentials.store(new PasswordCredential({ id: email, password, name: email }));
|
|
||||||
} catch {
|
|
||||||
// 浏览器或用户可能拒绝保存凭据,不影响登录主流程。
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openProtocolDialog = () => {
|
const openProtocolDialog = () => {
|
||||||
protocolDialogVisible.value = true;
|
protocolDialogVisible.value = true;
|
||||||
};
|
};
|
||||||
@@ -276,7 +232,6 @@ const onSubmit = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
await auth.login(form.email, form.password);
|
await auth.login(form.email, form.password);
|
||||||
await tryStoreBrowserCredential(form.email, form.password);
|
|
||||||
const studyStore = useStudyStore();
|
const studyStore = useStudyStore();
|
||||||
const userKey = auth.user?.email || form.email;
|
const userKey = auth.user?.email || form.email;
|
||||||
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
|
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
|
||||||
@@ -511,7 +466,6 @@ const onSubmit = async () => {
|
|||||||
margin: -4px 0 26px;
|
margin: -4px 0 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remember-checkbox,
|
|
||||||
.protocol-checkbox {
|
.protocol-checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -519,12 +473,10 @@ const onSubmit = async () => {
|
|||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remember-checkbox :deep(.el-checkbox__input),
|
|
||||||
.protocol-checkbox :deep(.el-checkbox__input) {
|
.protocol-checkbox :deep(.el-checkbox__input) {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remember-checkbox :deep(.el-checkbox__label),
|
|
||||||
.protocol-checkbox :deep(.el-checkbox__label) {
|
.protocol-checkbox :deep(.el-checkbox__label) {
|
||||||
display: block;
|
display: block;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
@@ -532,13 +484,6 @@ const onSubmit = async () => {
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remember-text {
|
|
||||||
color: #52657b;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.protocol-text {
|
.protocol-text {
|
||||||
color: #475569;
|
color: #475569;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readSource = () => readFileSync(resolve(__dirname, "./SystemMonitoringPage.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("SystemMonitoringPage", () => {
|
||||||
|
it("uses a fixed viewport shell so monitoring tabs manage their own scrolling", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).toContain("display: flex");
|
||||||
|
expect(source).toContain("height: calc(100dvh - 48px)");
|
||||||
|
expect(source).toContain("min-height: 0");
|
||||||
|
expect(source).toContain("overflow: hidden");
|
||||||
|
expect(source).not.toContain("min-height: calc(100dvh - 52px)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,9 +10,13 @@ import PermissionMonitoring from "@/components/PermissionMonitoring.vue";
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.system-monitoring-page {
|
.system-monitoring-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 48px);
|
||||||
|
height: calc(100dvh - 48px);
|
||||||
|
min-height: 0;
|
||||||
margin: -6px -8px;
|
margin: -6px -8px;
|
||||||
min-height: calc(100vh - 52px);
|
overflow: hidden;
|
||||||
min-height: calc(100dvh - 52px);
|
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user