feat(工作台): 优化入口布局与连接安全状态
This commit is contained in:
@@ -36,6 +36,7 @@ export type LoginKeyResponse = {
|
||||
export type SessionHeartbeatResponse = {
|
||||
status: "online";
|
||||
last_seen_at: string;
|
||||
client_ip: string | null;
|
||||
};
|
||||
|
||||
export type EncryptedPasswordRequest = {
|
||||
|
||||
@@ -14,6 +14,22 @@ describe("account connection status", () => {
|
||||
expect(component).toContain("当前账号与连接状态");
|
||||
expect(component).toContain("API 延迟");
|
||||
expect(component).toContain("会话状态");
|
||||
expect(component).toContain("mode === 'network'");
|
||||
expect(component).toContain("当前 IP");
|
||||
expect(component).toContain("clientIpLabel");
|
||||
expect(component).toContain("连接安全检查");
|
||||
expect(component).toContain("runSecurityCheck");
|
||||
expect(component).toContain("evaluateConnectionSecurity");
|
||||
expect(component).toContain("const securityChecked = ref(true)");
|
||||
expect(component).toContain("const securityCheckExpanded = ref(true)");
|
||||
expect(component).toContain("const nowTs = ref(Date.now())");
|
||||
expect(component).toContain("const tokenExpiryAt = computed");
|
||||
expect(component).toContain("const credentialDeadlineAt = computed");
|
||||
expect(component).toContain("return session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000");
|
||||
expect(component).toContain("sessionDeadlineAt: credentialDeadlineAt.value");
|
||||
expect(component).toContain("Math.min(IDLE_TIMEOUT_MINUTES, calculatedMinutes)");
|
||||
expect(component).toContain("sessionClockTimer !== undefined");
|
||||
expect(component).toContain("window.clearInterval(sessionClockTimer)");
|
||||
expect(component).toContain("不代表系统健康评分");
|
||||
expect(component).not.toContain("ip_address");
|
||||
expect(component).not.toContain("access_token");
|
||||
|
||||
@@ -9,6 +9,43 @@
|
||||
<span>{{ statusLabel }}</span>
|
||||
</span>
|
||||
|
||||
<section v-else-if="mode === 'network'" class="network-overview" aria-label="当前网络状态">
|
||||
<div class="network-overview-head">
|
||||
<span>网络状态</span>
|
||||
<span class="connection-state-badge" :class="`is-${status}`">
|
||||
<i class="account-connection-dot"></i>
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<dl class="network-overview-grid">
|
||||
<div class="network-ip-row">
|
||||
<dt>当前 IP</dt>
|
||||
<dd :title="clientIpLabel">{{ clientIpLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>API 延迟</dt>
|
||||
<dd>{{ latencyLabel }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最近通信</dt>
|
||||
<dd>{{ lastCommunicationLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button class="security-check-trigger" type="button" :disabled="securityChecking" @click="runSecurityCheck">
|
||||
<span>连接安全检查</span>
|
||||
<strong :class="`is-${securityResult.level}`">
|
||||
{{ securityChecking ? "检查中" : securityChecked ? securityResult.label : "立即检查" }}
|
||||
</strong>
|
||||
</button>
|
||||
<ul v-if="securityCheckExpanded" class="security-check-list" aria-live="polite">
|
||||
<li v-for="item in securityResult.checks" :key="item.key">
|
||||
<i :class="`is-${item.level}`"></i>
|
||||
<span>{{ item.label }}</span>
|
||||
<small>{{ item.detail }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div v-else class="account-connection-details" aria-label="当前账号与连接状态" @click.stop>
|
||||
<div class="connection-details-head">
|
||||
<span class="connection-details-title">当前账号</span>
|
||||
@@ -51,15 +88,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
import { getAppMetadata, isTauriRuntime } from "../runtime";
|
||||
import { clientRuntime, getAppMetadata, isTauriRuntime } from "../runtime";
|
||||
import { IDLE_TIMEOUT_MINUTES } from "../session/sessionManager";
|
||||
import { parseJwtExp } from "../session/jwt";
|
||||
import { useConnectionStatus } from "../composables/useConnectionStatus";
|
||||
import { evaluateConnectionSecurity } from "../utils/connectionSecurity";
|
||||
|
||||
withDefaults(defineProps<{ mode?: "indicator" | "details" }>(), {
|
||||
withDefaults(defineProps<{ mode?: "indicator" | "details" | "network" }>(), {
|
||||
mode: "indicator",
|
||||
});
|
||||
|
||||
@@ -71,11 +109,18 @@ const {
|
||||
status,
|
||||
statusLabel,
|
||||
latencyMs,
|
||||
clientIp,
|
||||
lastSuccessAt,
|
||||
lastCheckedAt,
|
||||
checkConnection,
|
||||
startConnectionMonitor,
|
||||
} = useConnectionStatus();
|
||||
let stopConnectionMonitor: (() => void) | undefined;
|
||||
const nowTs = ref(Date.now());
|
||||
let sessionClockTimer: number | undefined;
|
||||
const securityChecking = ref(false);
|
||||
const securityChecked = ref(true);
|
||||
const securityCheckExpanded = ref(true);
|
||||
|
||||
const userDisplayName = computed(
|
||||
() => auth.user?.full_name || auth.user?.username || auth.user?.email || "当前用户",
|
||||
@@ -89,25 +134,34 @@ const clientLabel = computed(() => {
|
||||
const environmentLabel = computed(() =>
|
||||
import.meta.env.VITE_RUNTIME_ENV === "production" ? "生产环境" : "开发环境",
|
||||
);
|
||||
const effectiveNow = computed(() => Math.max(lastCheckedAt.value || 0, Date.now()));
|
||||
const effectiveNow = computed(() => Math.max(lastCheckedAt.value || 0, nowTs.value));
|
||||
const tokenExpiryAt = computed(() => (auth.token ? parseJwtExp(auth.token) : null));
|
||||
const sessionDeadlineAt = computed(() => {
|
||||
const tokenExpiryAt = auth.token ? parseJwtExp(auth.token) : null;
|
||||
if (!tokenExpiryAt) return null;
|
||||
if (isDesktop) return tokenExpiryAt;
|
||||
return Math.min(tokenExpiryAt, session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000);
|
||||
if (!auth.token) return null;
|
||||
if (isDesktop) return tokenExpiryAt.value;
|
||||
return session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000;
|
||||
});
|
||||
const credentialDeadlineAt = computed(() => {
|
||||
if (!tokenExpiryAt.value) return sessionDeadlineAt.value;
|
||||
if (!sessionDeadlineAt.value) return tokenExpiryAt.value;
|
||||
return Math.min(tokenExpiryAt.value, sessionDeadlineAt.value);
|
||||
});
|
||||
const sessionRemainingLabel = computed(() => {
|
||||
if (!auth.token) return "未登录";
|
||||
if (!sessionDeadlineAt.value) return "有效";
|
||||
const remainingMs = sessionDeadlineAt.value - effectiveNow.value;
|
||||
if (remainingMs <= 0) return "即将失效";
|
||||
const totalMinutes = Math.max(1, Math.ceil(remainingMs / 60_000));
|
||||
const calculatedMinutes = Math.max(1, Math.ceil(remainingMs / 60_000));
|
||||
const totalMinutes = isDesktop
|
||||
? calculatedMinutes
|
||||
: Math.min(IDLE_TIMEOUT_MINUTES, calculatedMinutes);
|
||||
if (totalMinutes < 60) return `剩余 ${totalMinutes} 分钟`;
|
||||
const totalHours = Math.floor(totalMinutes / 60);
|
||||
if (totalHours < 24) return `剩余 ${totalHours} 小时`;
|
||||
return `剩余 ${Math.floor(totalHours / 24)} 天`;
|
||||
});
|
||||
const latencyLabel = computed(() => (latencyMs.value === null ? "--" : `${latencyMs.value} ms`));
|
||||
const clientIpLabel = computed(() => clientIp.value || (status.value === "checking" ? "检测中" : "--"));
|
||||
const lastCommunicationLabel = computed(() => {
|
||||
if (!lastSuccessAt.value) return status.value === "checking" ? "检测中" : "暂无成功记录";
|
||||
return new Date(lastSuccessAt.value).toLocaleTimeString("zh-CN", {
|
||||
@@ -116,13 +170,41 @@ const lastCommunicationLabel = computed(() => {
|
||||
second: "2-digit",
|
||||
});
|
||||
});
|
||||
const securityResult = computed(() => evaluateConnectionSecurity({
|
||||
apiBaseUrl: clientRuntime.apiBaseUrl(),
|
||||
pageOrigin: window.location.origin,
|
||||
status: status.value,
|
||||
hasToken: Boolean(auth.token),
|
||||
sessionDeadlineAt: credentialDeadlineAt.value,
|
||||
now: effectiveNow.value,
|
||||
clientIp: clientIp.value,
|
||||
latencyMs: latencyMs.value,
|
||||
sessionRemainingLabel: sessionRemainingLabel.value,
|
||||
}));
|
||||
|
||||
const runSecurityCheck = async () => {
|
||||
securityCheckExpanded.value = true;
|
||||
securityChecking.value = true;
|
||||
try {
|
||||
await checkConnection();
|
||||
nowTs.value = Date.now();
|
||||
securityChecked.value = true;
|
||||
} finally {
|
||||
securityChecking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
stopConnectionMonitor = startConnectionMonitor();
|
||||
nowTs.value = Date.now();
|
||||
sessionClockTimer = window.setInterval(() => {
|
||||
nowTs.value = Date.now();
|
||||
}, 1_000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopConnectionMonitor?.();
|
||||
if (sessionClockTimer !== undefined) window.clearInterval(sessionClockTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -138,6 +220,156 @@ onBeforeUnmount(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.network-overview {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(203, 213, 225, 0.82);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.66);
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.network-overview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #334155;
|
||||
font-size: 12.5px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.network-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 14px;
|
||||
margin: 12px 0 0;
|
||||
padding-top: 11px;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.9);
|
||||
}
|
||||
|
||||
.network-overview-grid div,
|
||||
.network-overview-grid dt,
|
||||
.network-overview-grid dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.security-check-trigger {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 10px 0 0;
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.9);
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.security-check-trigger:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.security-check-trigger strong {
|
||||
font-size: 10.5px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.security-check-trigger .is-safe {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.security-check-trigger .is-warning {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.security-check-trigger .is-danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.security-check-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 10px 0 0;
|
||||
padding: 10px 0 0;
|
||||
border-top: 1px dashed rgba(203, 213, 225, 0.9);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.security-check-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 7px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.security-check-list i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.security-check-list i.is-safe {
|
||||
background: #22a663;
|
||||
}
|
||||
|
||||
.security-check-list i.is-warning {
|
||||
background: #d99b21;
|
||||
}
|
||||
|
||||
.security-check-list i.is-danger {
|
||||
background: #e05252;
|
||||
}
|
||||
|
||||
.security-check-list span,
|
||||
.security-check-list small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.security-check-list span {
|
||||
color: #475569;
|
||||
font-size: 10.5px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.security-check-list small {
|
||||
color: #94a3b8;
|
||||
font-size: 9.5px;
|
||||
}
|
||||
|
||||
.network-overview-grid .network-ip-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.network-overview-grid dt {
|
||||
margin-bottom: 3px;
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.network-overview-grid dd {
|
||||
overflow: hidden;
|
||||
color: #334155;
|
||||
font-size: 11.5px;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-connection-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
@@ -252,6 +484,30 @@ onBeforeUnmount(() => {
|
||||
color: #dbe5f1;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .network-overview {
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .network-overview-head,
|
||||
:global([data-ctms-theme="dark"]) .network-overview-grid dd {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .network-overview-grid {
|
||||
border-top-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .security-check-trigger,
|
||||
:global([data-ctms-theme="dark"]) .security-check-list {
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .security-check-trigger,
|
||||
:global([data-ctms-theme="dark"]) .security-check-list span {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .connection-account-name,
|
||||
:global([data-ctms-theme="dark"]) .connection-details-grid dd {
|
||||
color: #f8fafc;
|
||||
|
||||
@@ -2642,6 +2642,141 @@ useDesktopShortcuts(
|
||||
background: #131b28;
|
||||
}
|
||||
|
||||
/* 小尺寸桌面窗口:工作台保持可用宽度,内容区负责滚动而不是把整个窗口撑开。 */
|
||||
@media (max-width: 1240px) {
|
||||
.desktop-workbench {
|
||||
grid-template-columns: 216px minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.desktop-toolbar {
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.update-notice {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.command-trigger,
|
||||
.connection-button,
|
||||
.account-button {
|
||||
width: 32px;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.command-trigger span,
|
||||
.command-trigger kbd,
|
||||
.connection-button > span:not(.connection-dot),
|
||||
.account-name,
|
||||
.account-button > .el-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.account-button {
|
||||
padding: 0 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.desktop-workbench {
|
||||
grid-template-columns: 196px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar-head {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.sidebar-scroll {
|
||||
padding-right: 6px;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toolbar-subtitle {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大屏桌面端:扩大导航和工作区的节奏,避免高分辨率下内容过度拥挤。 */
|
||||
@media (min-width: 1440px) {
|
||||
.desktop-workbench {
|
||||
grid-template-columns: 264px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.desktop-sidebar .sidebar-head {
|
||||
padding-right: 14px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.sidebar-scroll {
|
||||
padding: 14px 12px 28px;
|
||||
}
|
||||
|
||||
.desktop-main {
|
||||
grid-template-rows: 56px auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.desktop-toolbar {
|
||||
gap: 16px;
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.workspace-tabs {
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
.workspace-tab {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.desktop-workbench .desktop-route-shell .table-card-toolbar,
|
||||
.desktop-workbench .desktop-route-shell .ctms-page-header,
|
||||
.desktop-workbench .desktop-route-shell .ctms-section-header,
|
||||
.desktop-workbench .desktop-route-shell .unified-action-bar,
|
||||
.desktop-workbench .desktop-route-shell .module-header {
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.desktop-workbench {
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.desktop-toolbar {
|
||||
padding-right: 20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.desktop-workbench .desktop-route-shell .module-content,
|
||||
.desktop-workbench .desktop-route-shell .unified-section {
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.icon-button,
|
||||
.desktop-route-enter-active,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<template>
|
||||
<el-container class="layout-container web-layout-container">
|
||||
<el-aside :width="isCollapsed ? '68px' : '200px'" class="layout-aside" :class="{ collapsed: isCollapsed }">
|
||||
<el-container
|
||||
class="layout-container web-layout-container"
|
||||
:class="{ 'mobile-nav-open': isMobileNavOpen, 'mobile-layout': isMobileViewport }"
|
||||
@keydown.esc="closeMobileNav"
|
||||
>
|
||||
<el-aside
|
||||
:width="isMobileViewport ? '280px' : (isCollapsed ? '68px' : '200px')"
|
||||
class="layout-aside"
|
||||
:class="{ collapsed: isCollapsed, 'mobile-open': isMobileNavOpen }"
|
||||
>
|
||||
<div class="aside-logo">
|
||||
<img class="logo-icon" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<span class="logo-text">{{ TEXT.common.appName }}</span>
|
||||
@@ -129,7 +137,12 @@
|
||||
<el-container class="main-container">
|
||||
<el-header class="layout-header">
|
||||
<div class="header-left">
|
||||
<el-button class="collapse-btn" @click="toggleCollapse" :aria-label="TEXT.common.labels.collapseSidebar">
|
||||
<el-button
|
||||
class="collapse-btn"
|
||||
:aria-expanded="isMobileViewport ? isMobileNavOpen : undefined"
|
||||
@click="toggleCollapse"
|
||||
:aria-label="isMobileViewport ? '打开导航菜单' : TEXT.common.labels.collapseSidebar"
|
||||
>
|
||||
<el-icon class="collapse-icon">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="4" y="5" width="16" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.8" />
|
||||
@@ -358,6 +371,14 @@
|
||||
</el-container>
|
||||
</el-container>
|
||||
|
||||
<button
|
||||
v-if="isMobileNavOpen"
|
||||
type="button"
|
||||
class="mobile-nav-backdrop"
|
||||
aria-label="关闭导航菜单"
|
||||
@click="closeMobileNav"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
v-model="profileDialogVisible"
|
||||
class="profile-settings-dialog"
|
||||
@@ -444,7 +465,12 @@ const route = useRoute();
|
||||
const isDesktop = isTauriRuntime();
|
||||
const desktopMetadata = getAppMetadata();
|
||||
const isAdmin = computed(() => !!auth.user?.is_admin);
|
||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||
const isCollapsed = ref(typeof localStorage !== "undefined" && localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||
const isMobileViewport = ref(
|
||||
typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(max-width: 767px)").matches,
|
||||
);
|
||||
const isMobileNavOpen = ref(false);
|
||||
let mobileViewportMediaQuery: MediaQueryList | undefined;
|
||||
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||
const canAccessAdminPermissions = computed(() => isAdmin.value || projectRole.value === "PM");
|
||||
const canAccessAuditLogs = computed(() =>
|
||||
@@ -478,13 +504,14 @@ const headerReminderStats = ref({
|
||||
const headerClockNow = ref(new Date());
|
||||
let headerClockTimer: number | undefined;
|
||||
let desktopMenuUnlisten: (() => void) | undefined;
|
||||
let mobileViewportChangeHandler: ((event: MediaQueryListEvent) => void) | undefined;
|
||||
const isAdminContext = computed(() => route.path.startsWith("/admin"));
|
||||
const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);
|
||||
|
||||
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
||||
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
|
||||
watchEffect(() => {
|
||||
const width = !hasSidebar.value ? '0px' : (isCollapsed.value ? '68px' : '200px');
|
||||
const width = !hasSidebar.value || isMobileViewport.value ? '0px' : (isCollapsed.value ? '68px' : '200px');
|
||||
document.body.style.setProperty('--ctms-sidebar-width', width);
|
||||
// 在 body 上添加/移除标记类,以便全局 CSS :has() 降级使用
|
||||
if (hasSidebar.value) {
|
||||
@@ -1021,6 +1048,7 @@ const handleBreadcrumbCommand = async (cmd: any) => {
|
||||
};
|
||||
|
||||
const handleMenuSelect = (index: string) => {
|
||||
closeMobileNav();
|
||||
if (!index || index === route.path) {
|
||||
return;
|
||||
}
|
||||
@@ -1047,6 +1075,7 @@ const handleReminderCommand = (cmd: string) => {
|
||||
};
|
||||
|
||||
watch(() => route.path, () => {
|
||||
closeMobileNav();
|
||||
study.setViewContext(null);
|
||||
if (isDesktop) {
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
@@ -1070,7 +1099,20 @@ watch(
|
||||
() => refreshDesktopRoutePreferences(),
|
||||
);
|
||||
|
||||
const closeMobileNav = () => {
|
||||
isMobileNavOpen.value = false;
|
||||
};
|
||||
|
||||
const syncMobileViewport = (matches: boolean) => {
|
||||
isMobileViewport.value = matches;
|
||||
if (!matches) closeMobileNav();
|
||||
};
|
||||
|
||||
const toggleCollapse = () => {
|
||||
if (isMobileViewport.value) {
|
||||
isMobileNavOpen.value = !isMobileNavOpen.value;
|
||||
return;
|
||||
}
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
localStorage.setItem("ctms_sidebar_collapsed", isCollapsed.value ? "1" : "0");
|
||||
};
|
||||
@@ -1110,6 +1152,12 @@ const onLogout = async () => {
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window.matchMedia === "function") {
|
||||
mobileViewportMediaQuery = window.matchMedia("(max-width: 767px)");
|
||||
syncMobileViewport(mobileViewportMediaQuery.matches);
|
||||
mobileViewportChangeHandler = (event) => syncMobileViewport(event.matches);
|
||||
mobileViewportMediaQuery.addEventListener("change", mobileViewportChangeHandler);
|
||||
}
|
||||
headerClockNow.value = new Date();
|
||||
headerClockTimer = window.setInterval(() => {
|
||||
headerClockNow.value = new Date();
|
||||
@@ -1139,6 +1187,9 @@ onBeforeUnmount(() => {
|
||||
window.clearInterval(headerClockTimer);
|
||||
}
|
||||
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
||||
if (mobileViewportMediaQuery && mobileViewportChangeHandler) {
|
||||
mobileViewportMediaQuery.removeEventListener("change", mobileViewportChangeHandler);
|
||||
}
|
||||
desktopMenuUnlisten?.();
|
||||
});
|
||||
|
||||
@@ -2400,6 +2451,163 @@ useDesktopShortcuts(
|
||||
transform: translateY(-4px) scale(0.998);
|
||||
}
|
||||
|
||||
.mobile-nav-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.web-layout-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.web-layout-container .layout-aside {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 280px !important;
|
||||
max-width: calc(100vw - 42px);
|
||||
transform: translateX(-105%);
|
||||
box-shadow: 14px 0 36px rgba(15, 23, 42, 0.24);
|
||||
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.web-layout-container.mobile-nav-open .layout-aside {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.web-layout-container .main-container {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.web-layout-container .layout-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.web-layout-container .content-wrapper {
|
||||
min-height: calc(100dvh - 52px);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-breadcrumb {
|
||||
max-width: none;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.header-breadcrumb > :not(:last-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-breadcrumb .breadcrumb-item-wrapper {
|
||||
max-width: min(48vw, 210px);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.workbench-return-button {
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.workbench-return-button .el-icon {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.header-account-role,
|
||||
.user-profile .username,
|
||||
.user-profile .el-icon,
|
||||
.user-profile :deep(.account-connection-status) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-trigger.compact {
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.mobile-nav-backdrop {
|
||||
position: fixed;
|
||||
z-index: 90;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: rgba(15, 23, 42, 0.38);
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.web-layout-container .layout-header {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.web-layout-container .content-wrapper {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.header-breadcrumb .breadcrumb-item-wrapper {
|
||||
max-width: 42vw;
|
||||
}
|
||||
|
||||
.header-reminder-button,
|
||||
.collapse-btn,
|
||||
.workbench-return-button,
|
||||
.dropdown-trigger.compact {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大屏网页端:增加呼吸感,但保留业务表格的全宽工作区。 */
|
||||
@media (min-width: 1440px) {
|
||||
.web-layout-container .layout-header {
|
||||
height: 56px !important;
|
||||
padding-right: 20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.web-layout-container .aside-logo {
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.web-layout-container .content-wrapper {
|
||||
min-height: calc(100dvh - 56px);
|
||||
padding: 18px 24px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.web-layout-container .layout-header {
|
||||
padding-right: 28px;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.web-layout-container .content-wrapper {
|
||||
padding: 22px 32px 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.layout-aside,
|
||||
.aside-menu,
|
||||
|
||||
@@ -16,7 +16,9 @@ vi.mock("../utils/auth", () => ({
|
||||
describe("connection status heartbeat", () => {
|
||||
beforeEach(() => {
|
||||
heartbeatSessionMock.mockReset();
|
||||
heartbeatSessionMock.mockResolvedValue({ data: { status: "online" } });
|
||||
heartbeatSessionMock.mockResolvedValue({
|
||||
data: { status: "online", last_seen_at: "2026-07-13T02:00:00Z", client_ip: "203.0.113.10" },
|
||||
});
|
||||
getTokenMock.mockReturnValue("session-token");
|
||||
Object.defineProperty(window.navigator, "onLine", { configurable: true, value: true });
|
||||
});
|
||||
@@ -29,6 +31,7 @@ describe("connection status heartbeat", () => {
|
||||
await vi.waitFor(() => expect(connection.status.value).toBe("online"));
|
||||
expect(heartbeatSessionMock).toHaveBeenCalledWith("session-token");
|
||||
expect(connection.latencyMs.value).toBeGreaterThan(0);
|
||||
expect(connection.clientIp.value).toBe("203.0.113.10");
|
||||
expect(connection.lastSuccessAt.value).not.toBeNull();
|
||||
|
||||
Object.defineProperty(window.navigator, "onLine", { configurable: true, value: false });
|
||||
|
||||
@@ -8,6 +8,7 @@ const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
|
||||
const status = ref<ConnectionStatus>("checking");
|
||||
const latencyMs = ref<number | null>(null);
|
||||
const clientIp = ref<string | null>(null);
|
||||
const lastSuccessAt = ref<number | null>(null);
|
||||
const lastCheckedAt = ref<number | null>(null);
|
||||
let consumerCount = 0;
|
||||
@@ -30,10 +31,12 @@ const checkConnection = async () => {
|
||||
if (!token) {
|
||||
status.value = "offline";
|
||||
latencyMs.value = null;
|
||||
clientIp.value = null;
|
||||
return;
|
||||
}
|
||||
await heartbeatSession(token);
|
||||
const { data } = await heartbeatSession(token);
|
||||
latencyMs.value = Math.max(1, Math.round(performance.now() - startedAt));
|
||||
clientIp.value = data.client_ip || null;
|
||||
lastSuccessAt.value = Date.now();
|
||||
status.value = "online";
|
||||
} catch (error: any) {
|
||||
@@ -92,6 +95,7 @@ export const useConnectionStatus = () => ({
|
||||
status: readonly(status),
|
||||
statusLabel,
|
||||
latencyMs: readonly(latencyMs),
|
||||
clientIp: readonly(clientIp),
|
||||
lastSuccessAt: readonly(lastSuccessAt),
|
||||
lastCheckedAt: readonly(lastCheckedAt),
|
||||
checkConnection,
|
||||
|
||||
@@ -1377,3 +1377,309 @@ body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 4px 12px rgba(2, 132, 199, 0.3) !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Responsive layout foundation
|
||||
*
|
||||
* 页面组件来自多个业务模块,统一在这里处理“可收缩容器”和常见
|
||||
* 操作栏的换行;具体页面仍可针对自身信息密度补充局部规则。
|
||||
* ============================================================ */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page,
|
||||
.page-inner,
|
||||
.page-body,
|
||||
.page-header,
|
||||
.ctms-page,
|
||||
.ctms-page-shell,
|
||||
.ctms-route-shell,
|
||||
.desktop-route-shell,
|
||||
.main-content,
|
||||
.main-content-card,
|
||||
.main-content-flat,
|
||||
.table-card,
|
||||
.unified-shell,
|
||||
.content-area,
|
||||
.module-content {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.page > *,
|
||||
.page-inner > *,
|
||||
.page-body > *,
|
||||
.ctms-page > *,
|
||||
.ctms-page-shell > *,
|
||||
.table-card > *,
|
||||
.unified-shell > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-card-toolbar,
|
||||
.ctms-page-header,
|
||||
.ctms-page-header-row,
|
||||
.page-header,
|
||||
.section-card-header,
|
||||
.section-card-title-row,
|
||||
.hero-top,
|
||||
.toolbar-left,
|
||||
.toolbar-right,
|
||||
.filter-form,
|
||||
.filter-container,
|
||||
.ctms-filter-row,
|
||||
.ctms-page-actions,
|
||||
.ctms-section-actions {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.el-table,
|
||||
.el-table__inner-wrapper,
|
||||
.el-table__header-wrapper,
|
||||
.el-table__body-wrapper,
|
||||
.el-table__footer-wrapper {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.el-table__header-wrapper,
|
||||
.el-table__body-wrapper,
|
||||
.el-table__footer-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.el-form,
|
||||
.el-form-item,
|
||||
.el-form-item__content,
|
||||
.el-input,
|
||||
.el-select,
|
||||
.el-date-editor,
|
||||
.el-cascader,
|
||||
.el-tree-select {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.table-card-toolbar,
|
||||
.ctms-page-header,
|
||||
.ctms-page-header-row,
|
||||
.page-header,
|
||||
.section-card-header,
|
||||
.hero-top {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-form,
|
||||
.ctms-filter-row {
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-item,
|
||||
.ctms-filter-item {
|
||||
flex: 1 1 168px;
|
||||
min-width: min(168px, 100%);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-input,
|
||||
.filter-select,
|
||||
.filter-item .el-input,
|
||||
.filter-item .el-select,
|
||||
.ctms-filter-item .el-input,
|
||||
.ctms-filter-item .el-select {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hero-stats,
|
||||
.stats-grid,
|
||||
.overview-grid,
|
||||
.kpi-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)) !important;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
max-width: calc(100vw - 32px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page,
|
||||
.ctms-page,
|
||||
.ctms-page-shell {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-card-toolbar,
|
||||
.ctms-page-header,
|
||||
.ctms-page-header-row,
|
||||
.page-header,
|
||||
.section-card-header,
|
||||
.hero-top {
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-card-toolbar > .toolbar-filters,
|
||||
.table-card-toolbar > .toolbar-right,
|
||||
.ctms-page-header > .ctms-page-actions,
|
||||
.ctms-page-header-row > .ctms-page-actions,
|
||||
.page-header > .actions,
|
||||
.page-header > .page-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.toolbar-filters,
|
||||
.filter-form,
|
||||
.ctms-filter-row,
|
||||
.ctms-page-actions,
|
||||
.ctms-section-actions,
|
||||
.filter-actions,
|
||||
.toolbar-right {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-item,
|
||||
.ctms-filter-item,
|
||||
.filter-input,
|
||||
.filter-select,
|
||||
.filter-item .el-input,
|
||||
.filter-item .el-select,
|
||||
.ctms-filter-item .el-input,
|
||||
.ctms-filter-item .el-select {
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ctms-filter-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctms-page-actions,
|
||||
.ctms-section-actions,
|
||||
.toolbar-right,
|
||||
.filter-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.ctms-page-actions .el-button,
|
||||
.ctms-section-actions .el-button,
|
||||
.toolbar-right .el-button,
|
||||
.filter-actions .el-button {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hero-stats,
|
||||
.stats-grid,
|
||||
.overview-grid,
|
||||
.kpi-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)) !important;
|
||||
}
|
||||
|
||||
.section-card-header,
|
||||
.section-card-title-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.el-descriptions {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.el-drawer {
|
||||
width: min(620px, calc(100vw - 16px)) !important;
|
||||
}
|
||||
|
||||
.el-drawer__body {
|
||||
padding-right: 14px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
width: calc(100vw - 24px) !important;
|
||||
max-width: calc(100vw - 24px) !important;
|
||||
margin: 12px auto !important;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
max-height: calc(100dvh - 148px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.el-pagination {
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
row-gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.page-title,
|
||||
.ctms-page-title {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.page-subtitle,
|
||||
.ctms-page-subtitle {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.hero-stats,
|
||||
.stats-grid,
|
||||
.overview-grid,
|
||||
.kpi-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大屏内容密度:统计卡片与概览网格随可用宽度舒展。 */
|
||||
@media (min-width: 1440px) {
|
||||
.hero-stats,
|
||||
.stats-grid,
|
||||
.overview-grid,
|
||||
.kpi-grid {
|
||||
gap: 16px !important;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)) !important;
|
||||
}
|
||||
|
||||
.ctms-page-content-grid {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.hero-stats,
|
||||
.stats-grid,
|
||||
.overview-grid,
|
||||
.kpi-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,3 +278,93 @@
|
||||
padding-right: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ctms-page-shell {
|
||||
padding: 10px 12px 14px;
|
||||
}
|
||||
|
||||
.ctms-page-shell.page--flush,
|
||||
.page.page--flush {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.unified-action-bar,
|
||||
.unified-section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.unified-action-bar .ctms-page-title,
|
||||
.unified-action-bar .page-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.unified-action-bar .ctms-page-actions,
|
||||
.unified-action-bar .actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.unified-shell .el-card__body,
|
||||
.el-card.unified-shell > .el-card__body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.unified-shell .el-table th.el-table__cell,
|
||||
.unified-shell .el-table td.el-table__cell {
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.unified-shell .kpi-enterprise {
|
||||
min-height: 112px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.unified-shell .kpi-enterprise .kpi-value {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.ctms-page-shell:not(.page--flush) {
|
||||
padding: 20px 28px 28px;
|
||||
}
|
||||
|
||||
.ctms-page-content-grid {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.unified-action-bar,
|
||||
.unified-section {
|
||||
padding-right: 20px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.unified-shell .kpi-enterprise {
|
||||
min-height: 148px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.unified-shell .kpi-enterprise .kpi-title {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.unified-shell .kpi-enterprise .kpi-value {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.ctms-page-shell:not(.page--flush) {
|
||||
padding-right: 36px;
|
||||
padding-left: 36px;
|
||||
}
|
||||
|
||||
.unified-action-bar,
|
||||
.unified-section {
|
||||
padding-right: 28px;
|
||||
padding-left: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateConnectionSecurity } from "./connectionSecurity";
|
||||
|
||||
const baseInput = {
|
||||
apiBaseUrl: "https://ctms.example.com/",
|
||||
pageOrigin: "https://ctms.example.com",
|
||||
status: "online" as const,
|
||||
hasToken: true,
|
||||
sessionDeadlineAt: Date.now() + 30 * 60_000,
|
||||
now: Date.now(),
|
||||
clientIp: "203.0.113.10",
|
||||
latencyMs: 20,
|
||||
sessionRemainingLabel: "剩余 30 分钟",
|
||||
};
|
||||
|
||||
describe("connection security evaluation", () => {
|
||||
it("marks an authenticated HTTPS heartbeat with a recognized IP as safe", () => {
|
||||
const result = evaluateConnectionSecurity(baseInput);
|
||||
|
||||
expect(result.level).toBe("safe");
|
||||
expect(result.checks).toHaveLength(4);
|
||||
expect(result.checks.find((item) => item.key === "connection")?.detail).toBe("20 ms");
|
||||
expect(result.checks.find((item) => item.key === "session")?.detail).toBe("剩余 30 分钟");
|
||||
expect(result.checks.find((item) => item.key === "client-ip")?.detail).toBe("203.0.113.10");
|
||||
});
|
||||
|
||||
it("warns for a public HTTP endpoint", () => {
|
||||
const result = evaluateConnectionSecurity({ ...baseInput, apiBaseUrl: "http://ctms.example.com/" });
|
||||
|
||||
expect(result.level).toBe("warning");
|
||||
expect(result.checks.find((item) => item.key === "transport")?.detail).toBe("未使用 HTTPS");
|
||||
});
|
||||
|
||||
it("marks an offline or expired session as abnormal", () => {
|
||||
const result = evaluateConnectionSecurity({
|
||||
...baseInput,
|
||||
status: "offline",
|
||||
sessionDeadlineAt: Date.now() - 1_000,
|
||||
});
|
||||
|
||||
expect(result.level).toBe("danger");
|
||||
expect(result.label).toBe("异常");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ConnectionStatus } from "../composables/useConnectionStatus";
|
||||
|
||||
export type ConnectionSecurityLevel = "safe" | "warning" | "danger";
|
||||
|
||||
export type ConnectionSecurityCheck = {
|
||||
key: "connection" | "transport" | "session" | "client-ip";
|
||||
label: string;
|
||||
detail: string;
|
||||
level: ConnectionSecurityLevel;
|
||||
};
|
||||
|
||||
export type ConnectionSecurityResult = {
|
||||
level: ConnectionSecurityLevel;
|
||||
label: "安全" | "需注意" | "异常";
|
||||
checks: ConnectionSecurityCheck[];
|
||||
};
|
||||
|
||||
const isLoopbackHost = (hostname: string) =>
|
||||
hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
||||
|
||||
const resolveTransportCheck = (apiBaseUrl: string, pageOrigin: string): ConnectionSecurityCheck => {
|
||||
try {
|
||||
const target = new URL(apiBaseUrl || "/", pageOrigin);
|
||||
if (target.protocol === "https:") {
|
||||
return { key: "transport", label: "传输加密", detail: "HTTPS", level: "safe" };
|
||||
}
|
||||
if (isLoopbackHost(target.hostname)) {
|
||||
return { key: "transport", label: "传输加密", detail: "HTTP(本地)", level: "safe" };
|
||||
}
|
||||
return { key: "transport", label: "传输加密", detail: "未使用 HTTPS", level: "warning" };
|
||||
} catch {
|
||||
return { key: "transport", label: "传输加密", detail: "无法识别服务地址", level: "warning" };
|
||||
}
|
||||
};
|
||||
|
||||
export const evaluateConnectionSecurity = (input: {
|
||||
apiBaseUrl: string;
|
||||
pageOrigin: string;
|
||||
status: ConnectionStatus;
|
||||
hasToken: boolean;
|
||||
sessionDeadlineAt: number | null;
|
||||
now: number;
|
||||
clientIp: string | null;
|
||||
latencyMs: number | null;
|
||||
sessionRemainingLabel: string;
|
||||
}): ConnectionSecurityResult => {
|
||||
const connectionCheck: ConnectionSecurityCheck = input.status === "online"
|
||||
? {
|
||||
key: "connection",
|
||||
label: "服务连接",
|
||||
detail: input.latencyMs === null ? "在线" : `${input.latencyMs} ms`,
|
||||
level: "safe",
|
||||
}
|
||||
: input.status === "offline"
|
||||
? { key: "connection", label: "服务连接", detail: "无法连接后端", level: "danger" }
|
||||
: { key: "connection", label: "服务连接", detail: input.status === "checking" ? "正在检测" : "连接异常", level: "warning" };
|
||||
|
||||
const sessionValid = input.hasToken && (!input.sessionDeadlineAt || input.sessionDeadlineAt > input.now);
|
||||
const sessionCheck: ConnectionSecurityCheck = sessionValid
|
||||
? { key: "session", label: "登录会话", detail: input.sessionRemainingLabel, level: "safe" }
|
||||
: { key: "session", label: "登录会话", detail: "会话无效或已过期", level: "danger" };
|
||||
|
||||
const ipCheck: ConnectionSecurityCheck = input.clientIp
|
||||
? { key: "client-ip", label: "来源 IP", detail: input.clientIp, level: "safe" }
|
||||
: { key: "client-ip", label: "来源 IP", detail: "暂未识别", level: "warning" };
|
||||
|
||||
const checks = [
|
||||
connectionCheck,
|
||||
resolveTransportCheck(input.apiBaseUrl, input.pageOrigin),
|
||||
sessionCheck,
|
||||
ipCheck,
|
||||
];
|
||||
const level: ConnectionSecurityLevel = checks.some((item) => item.level === "danger")
|
||||
? "danger"
|
||||
: checks.some((item) => item.level === "warning")
|
||||
? "warning"
|
||||
: "safe";
|
||||
|
||||
return {
|
||||
level,
|
||||
label: level === "safe" ? "安全" : level === "warning" ? "需注意" : "异常",
|
||||
checks,
|
||||
};
|
||||
};
|
||||
@@ -13,9 +13,12 @@ describe("DesktopProjectEntry", () => {
|
||||
expect(source).toContain("Desktop Workbench");
|
||||
expect(source).toContain('class="entry-background-grid"');
|
||||
expect(source).toContain("系统管理");
|
||||
expect(source).toContain("ADMIN SYSTEM");
|
||||
expect(source).toContain("SYSTEM MANAGEMENT");
|
||||
expect(source).toContain("<strong>系统管理</strong>");
|
||||
expect(source).toContain('class="admin-arrow"');
|
||||
expect(source).toContain('loggingOut ? "正在退出" : "退出登录"');
|
||||
expect(source).toContain('<AccountConnectionStatus mode="network" />');
|
||||
expect(source).not.toContain("当前操作员");
|
||||
expect(source).not.toContain('class="admin-desc"');
|
||||
expect(source).toContain('v-if="canEnterManagement"');
|
||||
expect(source).toContain('projects.value.some((project) => project.role_in_study === "PM")');
|
||||
expect(source).toContain("project-cards-grid");
|
||||
|
||||
@@ -20,40 +20,33 @@
|
||||
<span class="status-dot-active"></span>
|
||||
</div>
|
||||
<div class="account-details">
|
||||
<small>当前操作员</small>
|
||||
<strong>{{ userDisplayName }}</strong>
|
||||
<span class="account-email">{{ auth.user?.email || "Operator" }}</span>
|
||||
</div>
|
||||
|
||||
<button class="logout-btn" type="button" :disabled="loggingOut" @click="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>{{ loggingOut ? "正在退出" : "注销账号" }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ADMIN / PM 可进入系统管理,其他角色仅可选择项目 -->
|
||||
<div v-if="canEnterManagement" class="sidebar-admin">
|
||||
<button class="admin-action-card" type="button" @click="enterManagement">
|
||||
<div class="admin-card-head">
|
||||
<div class="admin-icon-box">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</div>
|
||||
<div class="admin-text-box">
|
||||
<strong>系统管理</strong>
|
||||
</div>
|
||||
<el-icon class="admin-arrow"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AccountConnectionStatus mode="network" />
|
||||
</div>
|
||||
|
||||
<!-- ADMIN / PM 可进入系统管理,其他角色仅可选择项目 -->
|
||||
<div v-if="canEnterManagement" class="sidebar-admin">
|
||||
<button class="admin-action-card" type="button" @click="enterManagement">
|
||||
<span class="admin-glow-layer" aria-hidden="true"></span>
|
||||
<div class="admin-card-head">
|
||||
<div class="admin-icon-box">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</div>
|
||||
<div class="admin-text-box">
|
||||
<span class="admin-kicker">{{ isAdmin ? "ADMIN SYSTEM" : "SYSTEM MANAGEMENT" }}</span>
|
||||
<strong>系统管理</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="admin-desc">
|
||||
{{ isAdmin ? "人员账号管理、项目授权、审计及系统配置" : "查看并维护您负责项目的授权与范围配置" }}
|
||||
</p>
|
||||
<div class="admin-foot">
|
||||
<span>进入控制台</span>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button class="logout-btn" type="button" :disabled="loggingOut" @click="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>{{ loggingOut ? "正在退出" : "退出登录" }}</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main class="entry-main">
|
||||
@@ -163,6 +156,7 @@ import { useStudyStore } from "../store/study";
|
||||
import type { Study } from "../types/api";
|
||||
import { findFirstAccessibleProjectPath } from "../utils/projectRoutePermissions";
|
||||
import { isSystemAdmin } from "../utils/roles";
|
||||
import AccountConnectionStatus from "../components/AccountConnectionStatus.vue";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const studyStore = useStudyStore();
|
||||
@@ -302,7 +296,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 48px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
@@ -335,11 +329,10 @@ onMounted(() => {
|
||||
/* 个人信息卡片(浅色拟物化) */
|
||||
.sidebar-account {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 24px 20px;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.02);
|
||||
@@ -347,19 +340,20 @@ onMounted(() => {
|
||||
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 14px;
|
||||
flex: 0 0 auto;
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 14px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1e3a8a 100%);
|
||||
color: #ffffff;
|
||||
font-size: 21px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 8px 24px rgba(29, 78, 216, 0.15);
|
||||
}
|
||||
@@ -378,29 +372,27 @@ onMounted(() => {
|
||||
.account-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
gap: 2px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.account-details small {
|
||||
color: #64748b;
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
gap: 3px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.account-details strong {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
overflow: hidden;
|
||||
font-size: 14.5px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
color: #475569;
|
||||
font-size: 11.5px;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
@@ -430,6 +422,7 @@ onMounted(() => {
|
||||
/* 常驻系统管理卡片(精致浅色流光) */
|
||||
.sidebar-admin {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.admin-action-card {
|
||||
@@ -438,7 +431,7 @@ onMounted(() => {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 18px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.12);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0f7ff 100%);
|
||||
@@ -449,18 +442,6 @@ onMounted(() => {
|
||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.admin-glow-layer {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
right: -40px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
filter: blur(15px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-action-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(59, 130, 246, 0.28);
|
||||
@@ -470,16 +451,15 @@ onMounted(() => {
|
||||
.admin-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-icon-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
color: #2563eb;
|
||||
@@ -489,44 +469,23 @@ onMounted(() => {
|
||||
.admin-text-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-kicker {
|
||||
color: #2563eb;
|
||||
font-size: 8.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-text-box strong {
|
||||
color: #0f172a;
|
||||
font-size: 13.5px;
|
||||
font-size: 14.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-desc {
|
||||
margin: 0 0 12px;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.admin-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.admin-arrow {
|
||||
margin-left: auto;
|
||||
color: #2563eb;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-foot .el-icon {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.admin-action-card:hover .admin-foot .el-icon {
|
||||
.admin-action-card:hover .admin-arrow {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
@@ -931,6 +890,11 @@ onMounted(() => {
|
||||
border-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .account-details strong),
|
||||
:global([data-ctms-theme="dark"] .admin-text-box strong) {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .logout-btn) {
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
color: #94a3b8;
|
||||
|
||||
@@ -103,6 +103,17 @@ describe("Login protocol agreement", () => {
|
||||
expect(source).toContain("max-width: calc(100vw - 40px);");
|
||||
});
|
||||
|
||||
it("keeps the login shell inside the viewport without a visible page scrollbar", () => {
|
||||
const source = readLoginView();
|
||||
|
||||
expect(source).toContain("height: 100dvh;");
|
||||
expect(source).toContain('document.body.classList.add("is-login-page")');
|
||||
expect(source).toContain("scrollbar-width: none;");
|
||||
expect(source).toContain(".login-wrapper::-webkit-scrollbar");
|
||||
expect(source).toContain("overflow-y: auto;");
|
||||
expect(source).toContain("@media (min-width: 961px) and (max-height: 820px)");
|
||||
});
|
||||
|
||||
it("falls back to full email input when no server-configured domains exist", () => {
|
||||
const source = readLoginView();
|
||||
|
||||
|
||||
@@ -410,6 +410,8 @@ const syncRememberedCredential = async () => {
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
document.documentElement.classList.add("is-login-page");
|
||||
document.body.classList.add("is-login-page");
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
|
||||
await loadEmailDomains();
|
||||
const reason = consumeLogoutReason();
|
||||
@@ -426,6 +428,8 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.documentElement.classList.remove("is-login-page");
|
||||
document.body.classList.remove("is-login-page");
|
||||
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
|
||||
});
|
||||
|
||||
@@ -480,20 +484,39 @@ const onSubmit = async () => {
|
||||
═══════════════════════ */
|
||||
.login-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
overflow: hidden;
|
||||
scrollbar-width: none;
|
||||
background: #ffffff;
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", sans-serif;
|
||||
}
|
||||
|
||||
.login-wrapper::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
:global(html.is-login-page),
|
||||
:global(body.is-login-page) {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-split-container {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ═══════════════════════
|
||||
@@ -502,6 +525,8 @@ const onSubmit = async () => {
|
||||
.login-left-brand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #e0f2fe 0%, #e0e7ff 50%, #f3e8ff 100%);
|
||||
padding: 60px 80px;
|
||||
position: relative;
|
||||
@@ -749,12 +774,15 @@ const onSubmit = async () => {
|
||||
.login-right-form {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
background: #ffffff;
|
||||
padding: 60px 80px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 上方浏览器兼容建议 */
|
||||
@@ -1404,4 +1432,115 @@ const onSubmit = async () => {
|
||||
margin-top: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 统一收口:小屏允许触摸滚动,但不显示页面右侧滚动条。 */
|
||||
@media (max-width: 960px) {
|
||||
.login-wrapper {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.login-split-container {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.login-right-form {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: clamp(20px, 5vh, 40px) clamp(16px, 6vw, 56px) 24px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.login-card-container {
|
||||
width: min(440px, 100%);
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 中等高度桌面窗口:压缩纵向间距,确保双栏始终锁在视口内。 */
|
||||
@media (min-width: 961px) and (max-height: 820px) {
|
||||
.login-left-brand,
|
||||
.login-right-form {
|
||||
padding-top: clamp(24px, 3.5vh, 44px);
|
||||
padding-bottom: clamp(24px, 3.5vh, 44px);
|
||||
}
|
||||
|
||||
.brand-slogan-block {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.brand-main-title {
|
||||
margin-bottom: 12px;
|
||||
font-size: clamp(30px, 5vh, 36px);
|
||||
}
|
||||
|
||||
.brand-features-grid {
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.login-brand-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.login-options {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-right-footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 14px;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) and (max-height: 700px) {
|
||||
.login-right-form {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-brand-header {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.login-options {
|
||||
gap: 6px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-right-footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,6 +19,11 @@ describe("WebWorkbenchEntry", () => {
|
||||
expect(source).toContain('projects.value.some((project) => project.role_in_study === "PM")');
|
||||
expect(source).toContain('v-if="canEnterManagement"');
|
||||
expect(source).toContain('<strong>系统管理</strong>');
|
||||
expect(source).toContain('class="admin-arrow"');
|
||||
expect(source).toContain('loggingOut ? "正在退出" : "退出登录"');
|
||||
expect(source).toContain('<AccountConnectionStatus mode="network" />');
|
||||
expect(source).not.toContain("当前操作员");
|
||||
expect(source).not.toContain('class="admin-desc"');
|
||||
expect(source).toContain('const managementActionLabel = computed(() => canEnterManagement.value ? "进入系统管理" : "");');
|
||||
expect(source).toContain("studyStore.clearCurrentStudy()");
|
||||
expect(source).toContain("studyStore.setCurrentStudy(pmProject)");
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<aside class="entry-sidebar">
|
||||
<div class="sidebar-top">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-mark"><span>CTMS</span></div>
|
||||
<img class="brand-mark" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<div class="brand-text">
|
||||
<span class="brand-kicker">Web Workspace</span>
|
||||
<h1>工作台总控</h1>
|
||||
@@ -20,40 +20,33 @@
|
||||
<span class="status-dot-active"></span>
|
||||
</div>
|
||||
<div class="account-details">
|
||||
<small>当前操作员</small>
|
||||
<strong>{{ userDisplayName }}</strong>
|
||||
<span class="account-email">{{ auth.user?.email || "Operator" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="logout-btn" type="button" :disabled="loggingOut" @click="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>{{ loggingOut ? "正在退出" : "注销账号" }}</span>
|
||||
<!-- ADMIN / PM 可进入系统管理,其他角色仅可选择项目 -->
|
||||
<div v-if="canEnterManagement" class="sidebar-admin">
|
||||
<button class="admin-action-card" type="button" @click="enterManagement">
|
||||
<div class="admin-card-head">
|
||||
<div class="admin-icon-box">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</div>
|
||||
<div class="admin-text-box">
|
||||
<strong>系统管理</strong>
|
||||
</div>
|
||||
<el-icon class="admin-arrow"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AccountConnectionStatus mode="network" />
|
||||
</div>
|
||||
|
||||
<!-- ADMIN / PM 可进入系统管理,其他角色仅可选择项目 -->
|
||||
<div v-if="canEnterManagement" class="sidebar-admin">
|
||||
<button class="admin-action-card" type="button" @click="enterManagement">
|
||||
<span class="admin-glow-layer" aria-hidden="true"></span>
|
||||
<div class="admin-card-head">
|
||||
<div class="admin-icon-box">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</div>
|
||||
<div class="admin-text-box">
|
||||
<span class="admin-kicker">{{ isAdmin ? "ADMIN SYSTEM" : "SYSTEM MANAGEMENT" }}</span>
|
||||
<strong>系统管理</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="admin-desc">
|
||||
{{ isAdmin ? "人员账号管理、项目授权、审计及系统配置" : "查看已授权研究项目的详细授权与范围配置" }}
|
||||
</p>
|
||||
<div class="admin-foot">
|
||||
<span>进入控制台</span>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button class="logout-btn" type="button" :disabled="loggingOut" @click="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>{{ loggingOut ? "正在退出" : "退出登录" }}</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main class="entry-main">
|
||||
@@ -158,7 +151,7 @@
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="empty-project-workspace">
|
||||
<div class="empty-icon-capsule"><span>CTMS</span></div>
|
||||
<img class="empty-icon-capsule" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<h3>未检测到可用项目</h3>
|
||||
<p>您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。</p>
|
||||
</div>
|
||||
@@ -168,7 +161,7 @@
|
||||
<div class="transition-overlay" :class="{ active: isTransitioning }" aria-hidden="true">
|
||||
<div class="overlay-glow"></div>
|
||||
<div class="overlay-spinner">
|
||||
<span class="spinner-brand">CTMS</span>
|
||||
<img class="spinner-brand" src="/icons/ctms-icon-192.png" alt="" aria-hidden="true" />
|
||||
<div class="spinner-ring"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,6 +190,7 @@ import { useStudyStore } from "../store/study";
|
||||
import type { Study } from "../types/api";
|
||||
import { findFirstAccessibleProjectPath } from "../utils/projectRoutePermissions";
|
||||
import { isSystemAdmin } from "../utils/roles";
|
||||
import AccountConnectionStatus from "../components/AccountConnectionStatus.vue";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const studyStore = useStudyStore();
|
||||
@@ -343,22 +337,14 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 48px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.1);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
color: #ffffff;
|
||||
font-size: 13.5px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.05em;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
@@ -382,11 +368,10 @@ onMounted(() => {
|
||||
/* 个人信息卡片(浅色拟物化) */
|
||||
.sidebar-account {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 24px 20px;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.02);
|
||||
@@ -394,19 +379,20 @@ onMounted(() => {
|
||||
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 14px;
|
||||
flex: 0 0 auto;
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 14px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1e3a8a 100%);
|
||||
color: #ffffff;
|
||||
font-size: 21px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 8px 24px rgba(29, 78, 216, 0.15);
|
||||
}
|
||||
@@ -425,29 +411,27 @@ onMounted(() => {
|
||||
.account-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
gap: 2px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.account-details small {
|
||||
color: #64748b;
|
||||
font-size: 9.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
gap: 3px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.account-details strong {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
overflow: hidden;
|
||||
font-size: 14.5px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
color: #475569;
|
||||
font-size: 11.5px;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
@@ -477,6 +461,7 @@ onMounted(() => {
|
||||
/* 常驻系统管理卡片(精致浅色流光) */
|
||||
.sidebar-admin {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.admin-action-card {
|
||||
@@ -485,7 +470,7 @@ onMounted(() => {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 18px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.12);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0f7ff 100%);
|
||||
@@ -496,18 +481,6 @@ onMounted(() => {
|
||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.admin-glow-layer {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
right: -40px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: rgba(59, 130, 246, 0.06);
|
||||
filter: blur(15px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-action-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(59, 130, 246, 0.28);
|
||||
@@ -517,16 +490,15 @@ onMounted(() => {
|
||||
.admin-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-icon-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
color: #2563eb;
|
||||
@@ -536,44 +508,23 @@ onMounted(() => {
|
||||
.admin-text-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-kicker {
|
||||
color: #2563eb;
|
||||
font-size: 8.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-text-box strong {
|
||||
color: #0f172a;
|
||||
font-size: 13.5px;
|
||||
font-size: 14.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-desc {
|
||||
margin: 0 0 12px;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.admin-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.admin-arrow {
|
||||
margin-left: auto;
|
||||
color: #2563eb;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-foot .el-icon {
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.admin-action-card:hover .admin-foot .el-icon {
|
||||
.admin-action-card:hover .admin-arrow {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
@@ -895,17 +846,10 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.empty-icon-capsule {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
object-fit: cover;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -1006,6 +950,11 @@ onMounted(() => {
|
||||
border-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .account-details strong),
|
||||
:global([data-ctms-theme="dark"] .admin-text-box strong) {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .logout-btn) {
|
||||
border-color: rgba(255, 255, 255, 0.05);
|
||||
color: #94a3b8;
|
||||
@@ -1153,11 +1102,11 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.spinner-brand {
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.1em;
|
||||
text-shadow: 0 0 12px rgba(59, 130, 246, 0.4);
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
filter: drop-shadow(0 0 12px rgba(59, 130, 246, 0.4));
|
||||
animation: pulseBrand 1.4s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@@ -1213,26 +1162,19 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.sidebar-account {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.account-details {
|
||||
margin-bottom: 0;
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: auto;
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user