feat(工作台): 优化入口布局与连接安全状态

This commit is contained in:
Cheng Zhou
2026-07-13 11:04:15 +08:00
parent f2fa10c178
commit b26ebdda02
19 changed files with 1461 additions and 241 deletions
@@ -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;
+135
View File
@@ -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,
+213 -5
View File
@@ -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,