优化桌面端界面布局

This commit is contained in:
Cheng Zhou
2026-07-07 11:11:43 +08:00
parent 84d5daebab
commit e7b18758b2
30 changed files with 3158 additions and 659 deletions
+2 -1
View File
@@ -64,7 +64,7 @@
- `notifications`:隔离 Web 通知与桌面系统通知。 - `notifications`:隔离 Web 通知与桌面系统通知。
- `updates`:隔离桌面自动更新检查与安装入口。 - `updates`:隔离桌面自动更新检查与安装入口。
- `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。 - `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。
- `desktopMenu``desktopUiPreferences`:承接桌面菜单命令、最近访问和收藏等桌面体验状态。 - `desktopMenu``desktopUiPreferences`:承接桌面菜单命令、收藏模块等桌面体验状态。
- `clientRuntime`:作为业务侧获取平台能力的聚合入口。 - `clientRuntime`:作为业务侧获取平台能力的聚合入口。
业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。 业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。
@@ -156,6 +156,7 @@
已补齐的桌面体验收口: 已补齐的桌面体验收口:
- 个人中心新增客户端诊断信息展示与复制能力,内容仅包含客户端类型、版本、平台、构建通道、提交、服务器和能力状态,不包含 token 或业务敏感数据。 - 个人中心新增客户端诊断信息展示与复制能力,内容仅包含客户端类型、版本、平台、构建通道、提交、服务器和能力状态,不包含 token 或业务敏感数据。
- 桌面侧栏已移除被动“最近访问”入口和对应本地记录,只保留用户主动维护的收藏入口;收藏仅保存路由元数据,不保存业务数据或敏感凭据。
- 登录页、服务器设置页、个人中心和系统偏好增加最小窗口布局约束,长服务器地址、健康检查 URL、诊断值和更新/通知状态说明均可在容器内换行。 - 登录页、服务器设置页、个人中心和系统偏好增加最小窗口布局约束,长服务器地址、健康检查 URL、诊断值和更新/通知状态说明均可在容器内换行。
- 服务器设置页和个人中心在内容高度超过窗口时使用内部滚动,避免 `1180x760` 下对话框或面板溢出。 - 服务器设置页和个人中心在内容高度超过窗口时使用内部滚动,避免 `1180x760` 下对话框或面板溢出。
- 通知权限运行时已区分 WebView `Notification.permission` 的授权、拒绝和待授权状态;取消 macOS 权限请求时继续保持“待授权”,避免错误显示为“已拒绝”。 - 通知权限运行时已区分 WebView `Notification.permission` 的授权、拒绝和待授权状态;取消 macOS 权限请求时继续保持“待授权”,避免错误显示为“已拒绝”。
+6 -1
View File
@@ -13,8 +13,13 @@ import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
import { applyDesktopThemePreference, isTauriRuntime } from "./runtime"; import { applyDesktopThemePreference, isTauriRuntime } from "./runtime";
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue"; import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
if (isTauriRuntime()) { const isDesktopRuntime = isTauriRuntime();
if (isDesktopRuntime) {
applyDesktopThemePreference(); applyDesktopThemePreference();
document.body.classList.add("is-desktop-runtime");
} else {
document.body.classList.remove("is-desktop-runtime");
} }
initSessionManager(); initSessionManager();
initDesktopNotificationManager(); initDesktopNotificationManager();
+51 -63
View File
@@ -3,10 +3,7 @@
<aside class="desktop-sidebar"> <aside class="desktop-sidebar">
<header class="sidebar-head"> <header class="sidebar-head">
<div class="sidebar-title-row"> <div class="sidebar-title-row">
<div>
<h1>{{ TEXT.common.appName }}</h1> <h1>{{ TEXT.common.appName }}</h1>
</div>
</div>
<el-dropdown v-if="study.currentStudy" trigger="click" class="study-switcher" @command="handleStudySwitch"> <el-dropdown v-if="study.currentStudy" trigger="click" class="study-switcher" @command="handleStudySwitch">
<button class="study-switcher-trigger" type="button"> <button class="study-switcher-trigger" type="button">
@@ -29,6 +26,7 @@
<button v-else class="study-switcher-trigger empty" type="button" @click="router.push('/admin/projects')"> <button v-else class="study-switcher-trigger empty" type="button" @click="router.push('/admin/projects')">
选择项目 选择项目
</button> </button>
</div>
</header> </header>
<div class="sidebar-scroll"> <div class="sidebar-scroll">
@@ -47,21 +45,6 @@
</button> </button>
</section> </section>
<section v-if="desktopRecentRoutes.length" class="nav-section">
<div class="section-label">最近访问</div>
<button
v-for="item in desktopRecentRoutes"
:key="`recent:${item.path}`"
type="button"
class="sidebar-link compact"
:class="{ active: activeMenu === item.path }"
@click="router.push(item.path)"
>
<el-icon><Clock /></el-icon>
<span>{{ item.title }}</span>
</button>
</section>
<section v-if="adminNavigationItems.length" class="nav-section"> <section v-if="adminNavigationItems.length" class="nav-section">
<div class="section-label">{{ DESKTOP_ADMIN_SECTION_LABEL }}</div> <div class="section-label">{{ DESKTOP_ADMIN_SECTION_LABEL }}</div>
<template v-for="item in adminNavigationItems" :key="item.path"> <template v-for="item in adminNavigationItems" :key="item.path">
@@ -315,7 +298,6 @@
> >
<button class="workspace-tab-action" type="button" @click="navigateWorkspaceTab(item.path)"> <button class="workspace-tab-action" type="button" @click="navigateWorkspaceTab(item.path)">
<span>{{ item.title }}</span> <span>{{ item.title }}</span>
<span class="tab-group">{{ item.group }}</span>
</button> </button>
<button class="tab-close" type="button" title="关闭标签" @click.stop="closeWorkspaceTab(item.path)"> <button class="tab-close" type="button" title="关闭标签" @click.stop="closeWorkspaceTab(item.path)">
× ×
@@ -447,12 +429,11 @@ import {
} from "../session/desktopActivityCenter"; } from "../session/desktopActivityCenter";
import { import {
DESKTOP_SERVER_URL_CHANGED_EVENT, DESKTOP_SERVER_URL_CHANGED_EVENT,
clearLegacyDesktopRouteHistoryPreference,
getAppMetadata, getAppMetadata,
getDesktopServerUrl, getDesktopServerUrl,
listenDesktopMenuCommand, listenDesktopMenuCommand,
readDesktopFavoriteRoutes, readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
recordDesktopRecentRoute,
toggleDesktopFavoriteRoute, toggleDesktopFavoriteRoute,
type DesktopRoutePreference, type DesktopRoutePreference,
} from "../runtime"; } from "../runtime";
@@ -495,7 +476,6 @@ const desktopServerUrl = ref(getDesktopServerUrl());
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus()); const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
const desktopActivities = ref<DesktopActivityItem[]>(getDesktopActivities()); const desktopActivities = ref<DesktopActivityItem[]>(getDesktopActivities());
const studies = ref<any[]>([]); const studies = ref<any[]>([]);
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes()); const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const workspaceTabs = ref<DesktopRoutePreference[]>([]); const workspaceTabs = ref<DesktopRoutePreference[]>([]);
const draggingWorkspaceTabPath = ref(""); const draggingWorkspaceTabPath = ref("");
@@ -689,7 +669,6 @@ const headerReminderBadgeValue = computed(() => (headerReminderTotal.value > 99
const refreshDesktopRoutePreferences = () => { const refreshDesktopRoutePreferences = () => {
const allowedPaths = new Set(desktopNavigationItems.value.map((item) => item.path)); const allowedPaths = new Set(desktopNavigationItems.value.map((item) => item.path));
desktopRecentRoutes.value = readDesktopRecentRoutes().filter((item) => allowedPaths.has(item.path));
desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) => allowedPaths.has(item.path)); desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) => allowedPaths.has(item.path));
}; };
@@ -769,7 +748,10 @@ const closeWorkspaceTab = (path: string) => {
if (index >= 0) lastClosedWorkspaceTab.value = workspaceTabs.value[index]; if (index >= 0) lastClosedWorkspaceTab.value = workspaceTabs.value[index];
workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path); workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path);
if (activeMenu.value !== path) return; if (activeMenu.value !== path) return;
const next = workspaceTabs.value[index] || workspaceTabs.value[index - 1] || desktopRecentRoutes.value[0]; const next =
workspaceTabs.value[index] ||
workspaceTabs.value[index - 1] ||
desktopFavoriteRoutes.value.find((item) => item.path !== path);
if (next) { if (next) {
router.push(next.path); router.push(next.path);
} else { } else {
@@ -1054,7 +1036,6 @@ watch(
study.setViewContext(null); study.setViewContext(null);
const current = currentDesktopRoutePreference.value; const current = currentDesktopRoutePreference.value;
if (current) { if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
addWorkspaceTab(current); addWorkspaceTab(current);
refreshDesktopRoutePreferences(); refreshDesktopRoutePreferences();
} }
@@ -1076,9 +1057,9 @@ watch(
onMounted(async () => { onMounted(async () => {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl); window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
clearLegacyDesktopRouteHistoryPreference();
const current = currentDesktopRoutePreference.value; const current = currentDesktopRoutePreference.value;
if (current) { if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
addWorkspaceTab(current); addWorkspaceTab(current);
} }
refreshDesktopRoutePreferences(); refreshDesktopRoutePreferences();
@@ -1137,7 +1118,7 @@ useDesktopShortcuts(
<style scoped> <style scoped>
.desktop-workbench { .desktop-workbench {
display: grid; display: grid;
grid-template-columns: 272px minmax(0, 1fr); grid-template-columns: 248px minmax(0, 1fr);
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
min-height: 0; min-height: 0;
@@ -1173,23 +1154,23 @@ useDesktopShortcuts(
.sidebar-head { .sidebar-head {
flex: 0 0 auto; flex: 0 0 auto;
padding: 54px 16px 14px; padding: 44px 10px 10px;
border-bottom: 1px solid #dfe7f0; border-bottom: 1px solid #dfe7f0;
} }
.sidebar-title-row { .sidebar-title-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr);
align-items: center; align-items: center;
gap: 12px; gap: 10px;
} }
.sidebar-head h1 { .sidebar-head h1 {
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
color: #101827; color: #101827;
font-size: 18px; font-size: 16px;
line-height: 1.3; line-height: 1.25;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -1210,6 +1191,13 @@ useDesktopShortcuts(
color: #172233; color: #172233;
} }
.study-switcher {
justify-self: start;
width: 140px;
max-width: 100%;
min-width: 0;
}
.study-switcher-trigger { .study-switcher-trigger {
appearance: none; appearance: none;
display: grid; display: grid;
@@ -1217,11 +1205,11 @@ useDesktopShortcuts(
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
min-height: 34px; min-height: 32px;
margin-top: 14px; margin-top: 0;
padding: 0 10px; padding: 0 8px;
border: 1px solid #d7e0ea; border: 1px solid #d7e0ea;
border-radius: 8px; border-radius: 6px;
background: #ffffff; background: #ffffff;
color: #26384f; color: #26384f;
cursor: pointer; cursor: pointer;
@@ -1231,6 +1219,8 @@ useDesktopShortcuts(
.study-switcher-trigger.empty { .study-switcher-trigger.empty {
display: inline-flex; display: inline-flex;
justify-content: center; justify-content: center;
justify-self: start;
width: 104px;
color: #3f5d75; color: #3f5d75;
font-weight: 700; font-weight: 700;
} }
@@ -1238,7 +1228,7 @@ useDesktopShortcuts(
.study-name { .study-name {
overflow: hidden; overflow: hidden;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 760;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -1255,7 +1245,7 @@ useDesktopShortcuts(
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
padding: 14px 10px 34px; padding: 10px 8px 24px;
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
@@ -1278,11 +1268,11 @@ useDesktopShortcuts(
} }
.nav-section + .nav-section { .nav-section + .nav-section {
margin-top: 18px; margin-top: 12px;
} }
.section-label { .section-label {
padding: 0 8px 7px; padding: 0 6px 6px;
color: #7b8da3; color: #7b8da3;
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;
@@ -1291,34 +1281,40 @@ useDesktopShortcuts(
.sidebar-link { .sidebar-link {
appearance: none; appearance: none;
display: grid; display: grid;
grid-template-columns: 18px minmax(0, 1fr); grid-template-columns: 16px minmax(0, 1fr);
align-items: center; align-items: center;
gap: 9px; gap: 8px;
width: 100%; width: 100%;
min-height: 34px; min-height: 32px;
margin: 1px 0; margin: 1px 0;
padding: 0 9px; padding: 0 8px;
border: 0; border: 0;
border-radius: 8px; border-radius: 6px;
background: transparent; background: transparent;
color: #3d5068; color: #3d5068;
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
font-size: 14px;
line-height: 1.3;
text-align: left; text-align: left;
} }
.sidebar-link.compact { .sidebar-link.compact {
grid-template-columns: 18px minmax(0, 1fr); grid-template-columns: 16px minmax(0, 1fr);
} }
.sidebar-link.child { .sidebar-link.child {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
min-height: 30px; min-height: 28px;
padding-left: 36px; padding-left: 32px;
color: #607289; color: #607289;
font-size: 12px; font-size: 12px;
} }
.sidebar-link :deep(.el-icon) {
font-size: 16px;
}
.sidebar-link span { .sidebar-link span {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1337,11 +1333,11 @@ useDesktopShortcuts(
} }
.sidebar-group { .sidebar-group {
margin: 1px 0 5px; margin: 1px 0 4px;
} }
.group-title { .group-title {
grid-template-columns: 18px minmax(0, 1fr) 14px; grid-template-columns: 16px minmax(0, 1fr) 14px;
font-weight: 700; font-weight: 700;
} }
@@ -1858,10 +1854,7 @@ useDesktopShortcuts(
.workspace-tab-action { .workspace-tab-action {
appearance: none; appearance: none;
display: grid; display: block;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-width: 0; min-width: 0;
padding: 0; padding: 0;
border: 0; border: 0;
@@ -1872,18 +1865,13 @@ useDesktopShortcuts(
font-weight: inherit; font-weight: inherit;
} }
.workspace-tab-action span:first-child { .workspace-tab-action span {
display: block;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.tab-group {
color: #8394a9;
font-size: 10px;
font-weight: 700;
}
.tab-close { .tab-close {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -1946,11 +1934,12 @@ useDesktopShortcuts(
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
padding: 8px 10px; padding: 0;
background: #eef2f6; background: #eef2f6;
} }
.desktop-route-shell { .desktop-route-shell {
height: 100%;
min-height: 100%; min-height: 100%;
} }
@@ -2015,7 +2004,6 @@ useDesktopShortcuts(
:global([data-ctms-theme="dark"] .section-label), :global([data-ctms-theme="dark"] .section-label),
:global([data-ctms-theme="dark"] .panel-subtitle), :global([data-ctms-theme="dark"] .panel-subtitle),
:global([data-ctms-theme="dark"] .tab-group),
:global([data-ctms-theme="dark"] .toolbar-subtitle) { :global([data-ctms-theme="dark"] .toolbar-subtitle) {
color: #94a3b8; color: #94a3b8;
} }
+60 -20
View File
@@ -88,12 +88,18 @@ const openManager = () => {
<style scoped> <style scoped>
.panel { .panel {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
overflow: hidden; overflow: hidden;
padding: 24px 14px; padding: 24px 14px;
background: transparent; background:
radial-gradient(circle at 18% 0%, rgba(47, 123, 232, 0.12) 0%, transparent 38%),
linear-gradient(180deg, #eef7ff 0%, #f8fbff 52%, #edf3fb 100%);
} }
.header { .header {
@@ -101,37 +107,69 @@ const openManager = () => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 0 8px 16px; padding: 12px 12px;
border-bottom: 1px solid #edf2f8; border: 1px solid rgba(110, 153, 205, 0.18);
color: #8b98aa; border-radius: 12px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba(232, 244, 255, 0.86) 100%);
box-shadow:
0 10px 24px rgba(42, 86, 143, 0.07),
0 1px 0 rgba(255, 255, 255, 0.9) inset;
color: #385b7f;
font-size: 14px; font-size: 14px;
font-weight: 800; font-weight: 800;
} }
.header > span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.header > span::before {
content: "";
width: 6px;
height: 20px;
border-radius: 999px;
background: linear-gradient(180deg, #2f7be8 0%, #23b7d9 100%);
box-shadow: 0 0 14px rgba(47, 123, 232, 0.26);
}
.menu { .menu {
flex: 1 1 auto;
max-height: calc(100vh - 240px); max-height: calc(100vh - 240px);
min-height: 0;
overflow: auto; overflow: auto;
border-right: 0; border-right: 0;
padding: 14px 0 0; padding: 14px 2px 0;
background: transparent; background: transparent;
} }
.menu :deep(.el-menu-item) { .menu :deep(.el-menu-item) {
position: relative;
height: 44px; height: 44px;
margin: 4px 0; margin: 4px 0;
padding: 0 10px !important; padding: 0 10px !important;
border: 1px solid transparent;
border-radius: 10px; border-radius: 10px;
color: #5f6f7a; color: #506475;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
line-height: 44px; line-height: 44px;
transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
} }
.menu :deep(.el-menu-item:hover) { .menu :deep(.el-menu-item:hover) {
background: rgba(31, 95, 184, 0.06); border-color: rgba(47, 123, 232, 0.1);
background: rgba(255, 255, 255, 0.66);
color: #1f5fb8; color: #1f5fb8;
} }
.menu :deep(.el-menu-item.is-active) { .menu :deep(.el-menu-item.is-active) {
background: #e7f4ff; border-color: rgba(47, 123, 232, 0.18);
color: #1f5fb8; background:
linear-gradient(135deg, rgba(220, 239, 255, 0.96) 0%, rgba(240, 248, 255, 0.96) 100%);
box-shadow:
0 10px 22px rgba(47, 123, 232, 0.12),
3px 0 0 #2f7be8 inset;
color: #1559ad;
} }
.cat-icon { .cat-icon {
display: inline-flex; display: inline-flex;
@@ -142,10 +180,11 @@ const openManager = () => {
height: 24px; height: 24px;
margin-right: 10px; margin-right: 10px;
border-radius: 7px; border-radius: 7px;
background: rgba(95, 111, 122, 0.08); background: linear-gradient(135deg, rgba(221, 234, 249, 0.9) 0%, rgba(242, 248, 255, 0.9) 100%);
color: #697982; color: #517194;
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.82) inset;
} }
.cat-icon :deep(svg) { .cat-icon :deep(svg) {
font-size: 13px; font-size: 13px;
@@ -153,20 +192,21 @@ const openManager = () => {
height: 1em; height: 1em;
} }
.menu :deep(.el-menu-item.is-active) .cat-icon { .menu :deep(.el-menu-item.is-active) .cat-icon {
background: #d7ebff; background: linear-gradient(135deg, #2f7be8 0%, #24b7d8 100%);
color: #1f5fb8; color: #ffffff;
box-shadow: 0 8px 18px rgba(47, 123, 232, 0.25);
} }
.category-create-btn { .category-create-btn {
height: 34px; height: 34px;
border: 0; border: 0;
border-radius: 10px; border-radius: 10px;
background: #415a77; background: linear-gradient(135deg, #2f527a 0%, #203a5c 100%);
font-weight: 800; font-weight: 800;
box-shadow: 0 8px 18px rgba(65, 90, 119, 0.16); box-shadow: 0 8px 18px rgba(37, 67, 105, 0.2);
} }
.category-create-btn:hover, .category-create-btn:hover,
.category-create-btn:focus { .category-create-btn:focus {
background: #344960; background: linear-gradient(135deg, #254866 0%, #172f4e 100%);
} }
.name { .name {
flex: 1; flex: 1;
@@ -183,8 +223,8 @@ const openManager = () => {
height: 18px; height: 18px;
padding: 0 6px; padding: 0 6px;
border-radius: 999px; border-radius: 999px;
background: rgba(95, 111, 122, 0.08); background: rgba(88, 111, 137, 0.1);
color: #697982; color: #5d7590;
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
line-height: 18px; line-height: 18px;
@@ -192,7 +232,7 @@ const openManager = () => {
} }
.menu :deep(.el-menu-item.is-active) .cat-count { .menu :deep(.el-menu-item.is-active) .cat-count {
background: #d7ebff; background: linear-gradient(135deg, #cfe7ff 0%, #dcf7ff 100%);
color: #1f5fb8; color: #1559ad;
} }
</style> </style>
+156 -19
View File
@@ -1,18 +1,20 @@
<template> <template>
<div class="faq-list-card"> <div class="faq-list-card" :class="{ 'faq-list-card--desktop': isDesktop }">
<el-table <el-table
:data="items" :data="items"
v-loading="loading" v-loading="loading"
style="width: 100%" style="width: 100%"
class="faq-table" class="faq-table"
:class="{ 'faq-table--with-actions': canDelete, 'faq-table--without-actions': !canDelete }"
:row-class-name="rowClassName" :row-class-name="rowClassName"
:height="isDesktop ? '100%' : undefined"
@row-click="onRowClick" @row-click="onRowClick"
table-layout="fixed" table-layout="fixed"
> >
<el-table-column prop="question" :label="TEXT.common.fields.question" show-overflow-tooltip> <el-table-column prop="question" :label="TEXT.common.fields.question" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
<div class="question-cell"> <div class="question-cell">
<span class="question-icon">{{ questionInitial(scope.row.question) }}</span> <span class="question-icon"><el-icon><QuestionFilled /></el-icon></span>
<el-link type="primary" :underline="false" class="question-link"> <el-link type="primary" :underline="false" class="question-link">
{{ scope.row.question }} {{ scope.row.question }}
</el-link> </el-link>
@@ -52,12 +54,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { QuestionFilled } from "@element-plus/icons-vue";
import { computed } from "vue"; import { computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { deleteFaqItem } from "../api/faqs"; import { deleteFaqItem } from "../api/faqs";
import type { FaqItem, FaqCategory } from "../api/faqs"; import type { FaqItem, FaqCategory } from "../api/faqs";
import { displayDateTime, displayEnum } from "../utils/display"; import { displayDateTime, displayEnum } from "../utils/display";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { isTauriRuntime } from "../runtime";
const props = defineProps<{ const props = defineProps<{
items: FaqItem[]; items: FaqItem[];
@@ -69,6 +73,7 @@ const props = defineProps<{
const emit = defineEmits(["refresh"]); const emit = defineEmits(["refresh"]);
const router = useRouter(); const router = useRouter();
const isDesktop = isTauriRuntime();
const categoryMap = computed(() => const categoryMap = computed(() =>
props.categories.reduce<Record<string, string>>((acc, cur) => { props.categories.reduce<Record<string, string>>((acc, cur) => {
@@ -81,10 +86,6 @@ const categoryLabel = (categoryId: string) => {
return categoryMap.value[categoryId] || categoryId || "--"; return categoryMap.value[categoryId] || categoryId || "--";
}; };
const questionInitial = (question?: string) => {
return String(question || "?").trim().slice(0, 1) || "?";
};
const splitDateTime = (value?: string | number | Date | null) => { const splitDateTime = (value?: string | number | Date | null) => {
const displayValue = displayDateTime(value); const displayValue = displayDateTime(value);
const [date, time] = displayValue.split(" "); const [date, time] = displayValue.split(" ");
@@ -145,9 +146,10 @@ const remove = async (row: FaqItem) => {
} }
.faq-table :deep(.el-table__header-wrapper th.el-table__cell) { .faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
border-bottom: 0; height: 46px;
background: transparent; border-bottom: 1px solid #dce8f7;
color: #8a98aa; background: linear-gradient(180deg, #f7fbff 0%, #eef5fc 100%);
color: #5d7088;
font-size: 14px; font-size: 14px;
font-weight: 800; font-weight: 800;
} }
@@ -166,21 +168,23 @@ const remove = async (row: FaqItem) => {
} }
.faq-table :deep(.faq-row td.el-table__cell) { .faq-table :deep(.faq-row td.el-table__cell) {
padding: 12px 0; padding: 14px 0;
border-bottom: 1px solid #e8eef6; border-bottom: 1px solid #e8eef6;
background: #ffffff; background: #ffffff;
transition: background 0.18s ease; transition: background 0.18s ease;
} }
.faq-table :deep(.faq-row:hover td.el-table__cell) { .faq-table :deep(.faq-row:hover td.el-table__cell) {
background: #f8f9fb; background: #f7fbff;
} }
.faq-table :deep(col:nth-child(1)) { width: 43%; } .faq-table--with-actions :deep(col) {
.faq-table :deep(col:nth-child(2)) { width: 14%; } width: 20%;
.faq-table :deep(col:nth-child(3)) { width: 14%; } }
.faq-table :deep(col:nth-child(4)) { width: 14%; }
.faq-table :deep(col:nth-child(5)) { width: 15%; } .faq-table--without-actions :deep(col) {
width: 25%;
}
@@ -205,10 +209,19 @@ const remove = async (row: FaqItem) => {
width: 34px; width: 34px;
height: 34px; height: 34px;
border-radius: 10px; border-radius: 10px;
background: #f3f7fb; background:
color: #5e6f7b; radial-gradient(circle at 26% 22%, rgba(255, 255, 255, 0.42) 0%, transparent 28%),
font-size: 15px; linear-gradient(135deg, #2f7be8 0%, #23b7d9 100%);
color: #ffffff;
font-size: 17px;
font-weight: 800; font-weight: 800;
box-shadow:
0 10px 20px rgba(47, 123, 232, 0.2),
0 1px 0 rgba(255, 255, 255, 0.45) inset;
}
.question-icon :deep(.el-icon) {
font-size: 1em;
} }
.question-link { .question-link {
@@ -257,4 +270,128 @@ const remove = async (row: FaqItem) => {
.question-link { .question-link {
cursor: pointer; cursor: pointer;
} }
.faq-list-card--desktop {
display: flex;
height: 100%;
min-height: 0;
flex: 1 1 auto;
background: #ffffff;
}
.faq-list-card--desktop .faq-table {
height: 100%;
min-height: 0;
flex: 1 1 auto;
}
.faq-list-card--desktop .faq-table :deep(.el-table__inner-wrapper) {
height: 100%;
}
.faq-list-card--desktop .faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
height: 42px;
padding: 0 14px;
border-bottom: 1px solid rgba(121, 152, 188, 0.28) !important;
background: linear-gradient(180deg, #f4f9ff 0%, #eaf3fc 100%) !important;
color: #49647f;
font-size: 12px;
font-weight: 850;
}
.faq-list-card--desktop .faq-table :deep(.el-table__body) {
border-collapse: separate;
border-spacing: 0 6px;
}
.faq-list-card--desktop .faq-table :deep(.el-table__body-wrapper) {
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
scrollbar-width: none;
}
.faq-list-card--desktop .faq-table :deep(.el-table__body-wrapper::-webkit-scrollbar),
.faq-list-card--desktop .faq-table :deep(.el-scrollbar__wrap::-webkit-scrollbar) {
display: none;
width: 0;
height: 0;
}
.faq-list-card--desktop .faq-table :deep(.el-scrollbar__wrap) {
scrollbar-width: none;
}
.faq-list-card--desktop .faq-table :deep(.el-scrollbar__bar.is-vertical) {
display: none !important;
}
.faq-list-card--desktop .faq-table :deep(.el-table__empty-block) {
min-height: 100%;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.faq-list-card--desktop .faq-table :deep(.faq-row td.el-table__cell) {
padding: 10px 14px;
border-top: 1px solid rgba(226, 235, 246, 0.92);
border-bottom: 1px solid rgba(226, 235, 246, 0.92);
background: #ffffff;
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.faq-list-card--desktop .faq-table :deep(.faq-row td.el-table__cell:first-child) {
border-left: 1px solid rgba(226, 235, 246, 0.92);
border-radius: 9px 0 0 9px;
}
.faq-list-card--desktop .faq-table :deep(.faq-row td.el-table__cell:last-child) {
border-right: 1px solid rgba(226, 235, 246, 0.92);
border-radius: 0 9px 9px 0;
}
.faq-list-card--desktop .faq-table :deep(.faq-row:hover td.el-table__cell) {
border-color: rgba(111, 159, 220, 0.34);
background: #f5faff;
box-shadow: 0 8px 18px rgba(45, 86, 143, 0.06);
}
.faq-list-card--desktop .question-cell {
gap: 10px;
}
.faq-list-card--desktop .question-icon {
width: 28px;
height: 28px;
border-radius: 8px;
font-size: 14px;
box-shadow:
0 8px 16px rgba(47, 123, 232, 0.18),
0 1px 0 rgba(255, 255, 255, 0.45) inset;
}
.faq-list-card--desktop .question-link {
color: #0f172a;
font-size: 13px;
font-weight: 700;
}
.faq-list-card--desktop .category-pill {
height: 24px;
padding: 0 9px;
border-radius: 7px;
background: linear-gradient(135deg, #e7f3ff 0%, #eefaff 100%);
font-size: 12px;
}
.faq-list-card--desktop .time-text {
gap: 0;
font-size: 11px;
}
:global([data-ctms-theme="dark"] .faq-list-card--desktop .faq-table .faq-row td.el-table__cell) {
border-color: #26364a;
background: #172033;
}
:global([data-ctms-theme="dark"] .faq-list-card--desktop .faq-table .faq-row:hover td.el-table__cell) {
background: #1d2b42;
}
</style> </style>
+117 -3
View File
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8"); const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
const readAppSource = () => readFileSync(resolve(__dirname, "../App.vue"), "utf8");
const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8"); const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8");
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8"); const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8"); const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8");
@@ -17,23 +18,40 @@ const readFileTaskFeedbackSource = () => readFileSync(resolve(__dirname, "../uti
const readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.vue"), "utf8"); const readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.vue"), "utf8");
const readDocumentDetailSource = () => readFileSync(resolve(__dirname, "../views/documents/DocumentDetail.vue"), "utf8"); const readDocumentDetailSource = () => readFileSync(resolve(__dirname, "../views/documents/DocumentDetail.vue"), "utf8");
const readMainStyleSource = () => readFileSync(resolve(__dirname, "../styles/main.css"), "utf8"); const readMainStyleSource = () => readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
const readFaqSource = () => readFileSync(resolve(__dirname, "../views/Faq.vue"), "utf8");
const readFaqListSource = () => readFileSync(resolve(__dirname, "./FaqList.vue"), "utf8");
const readProjectOverviewSource = () => readFileSync(resolve(__dirname, "../views/ia/ProjectOverview.vue"), "utf8");
describe("desktop layout shell", () => { describe("desktop layout shell", () => {
it("routes desktop and web shells through the runtime flag", () => { it("routes desktop and web shells through the runtime flag", () => {
const source = readLayoutSource(); const source = readLayoutSource();
const app = readAppSource();
expect(source).toContain("const isDesktop = isTauriRuntime()"); expect(source).toContain("const isDesktop = isTauriRuntime()");
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />"); expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
expect(source).toContain("<WebLayout v-else />"); expect(source).toContain("<WebLayout v-else />");
expect(app).toContain("const isDesktopRuntime = isTauriRuntime();");
expect(app).toContain('document.body.classList.add("is-desktop-runtime")');
expect(app).toContain('document.body.classList.remove("is-desktop-runtime")');
}); });
it("uses route-only desktop preference storage", () => { it("uses route-only desktop preference storage", () => {
const source = readDesktopLayoutSource(); const source = readDesktopLayoutSource();
const webSource = readWebLayoutSource();
expect(source).toContain("readDesktopRecentRoutes");
expect(source).toContain("readDesktopFavoriteRoutes"); expect(source).toContain("readDesktopFavoriteRoutes");
expect(source).toContain("recordDesktopRecentRoute");
expect(source).toContain("currentDesktopRoutePreference"); expect(source).toContain("currentDesktopRoutePreference");
expect(source).toContain("toggleDesktopFavoriteRoute");
expect(source).not.toContain("readDesktopRecentRoutes");
expect(source).not.toContain("recordDesktopRecentRoute");
expect(source).not.toContain("desktopRecentRoutes");
expect(source).not.toContain("最近访问");
expect(webSource).toContain("readDesktopFavoriteRoutes");
expect(webSource).toContain("toggleDesktopFavoriteRoute");
expect(webSource).not.toContain("readDesktopRecentRoutes");
expect(webSource).not.toContain("recordDesktopRecentRoute");
expect(webSource).not.toContain("desktopRecentRoutes");
expect(webSource).not.toContain("最近访问");
}); });
it("shares active route mapping between web and desktop shells", () => { it("shares active route mapping between web and desktop shells", () => {
@@ -86,12 +104,35 @@ describe("desktop layout shell", () => {
expect(tauriConfig).toContain('"titleBarStyle": "Overlay"'); expect(tauriConfig).toContain('"titleBarStyle": "Overlay"');
expect(tauriConfig).toContain('"hiddenTitle": true'); expect(tauriConfig).toContain('"hiddenTitle": true');
expect(tauriConfig).toContain('"trafficLightPosition": { "x": 16, "y": 18 }'); expect(tauriConfig).toContain('"trafficLightPosition": { "x": 16, "y": 18 }');
expect(layout).toContain("padding: 54px 16px 14px;"); expect(layout).toContain("padding: 44px 10px 10px;");
expect(layout).toContain("-webkit-app-region: drag;"); expect(layout).toContain("-webkit-app-region: drag;");
expect(layout).toContain("-webkit-app-region: no-drag;"); expect(layout).toContain("-webkit-app-region: no-drag;");
expect(layout).not.toContain(["@tauri-apps", "api", "window"].join("/")); expect(layout).not.toContain(["@tauri-apps", "api", "window"].join("/"));
}); });
it("keeps the desktop sidebar dense enough for navigation-heavy screens", () => {
const layout = readDesktopLayoutSource();
const sidebarHeadStart = layout.indexOf('<header class="sidebar-head">');
const sidebarHeadEnd = layout.indexOf("</header>", sidebarHeadStart);
const sidebarHeadTemplate = layout.slice(sidebarHeadStart, sidebarHeadEnd);
expect(layout).toContain("grid-template-columns: 248px minmax(0, 1fr);");
expect(layout).toContain("padding: 44px 10px 10px;");
expect(layout).toContain("grid-template-columns: auto minmax(0, 1fr);");
expect(layout).toContain("width: 140px;");
expect(layout).toContain("margin-top: 0;");
expect(layout).toContain("padding: 10px 8px 24px;");
expect(layout).toContain("margin-top: 12px;");
expect(layout).toContain("font-size: 14px;");
expect(sidebarHeadTemplate).toContain('<div class="sidebar-title-row">');
expect(sidebarHeadTemplate.indexOf("<h1>{{ TEXT.common.appName }}</h1>")).toBeLessThan(
sidebarHeadTemplate.indexOf('<el-dropdown v-if="study.currentStudy"'),
);
expect(layout).not.toContain("grid-template-columns: 272px minmax(0, 1fr);");
expect(layout).not.toContain("padding: 54px 16px 14px;");
expect(layout).not.toContain("padding: 44px 12px 12px;");
});
it("renders desktop preferences as a split settings panel", () => { it("renders desktop preferences as a split settings panel", () => {
const preferences = readDesktopPreferencesSource(); const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource(); const desktopLayout = readDesktopLayoutSource();
@@ -187,6 +228,8 @@ describe("desktop layout shell", () => {
expect(tabsTemplate).not.toContain('@click="router.push(item.path)"'); expect(tabsTemplate).not.toContain('@click="router.push(item.path)"');
expect(source).not.toContain(".slice(-7)"); expect(source).not.toContain(".slice(-7)");
expect(tabsTemplate).toContain('@contextmenu.prevent.stop="openWorkspaceTabMenu(item, $event)"'); expect(tabsTemplate).toContain('@contextmenu.prevent.stop="openWorkspaceTabMenu(item, $event)"');
expect(tabsTemplate).not.toContain('class="tab-group"');
expect(tabsTemplate).not.toContain("{{ item.group }}");
expect(source).toContain("const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);"); expect(source).toContain("const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);");
expect(source).toContain("const workspaceTabMenu = ref<"); expect(source).toContain("const workspaceTabMenu = ref<");
expect(source).toContain("const closeOtherWorkspaceTabs = (path: string) => {"); expect(source).toContain("const closeOtherWorkspaceTabs = (path: string) => {");
@@ -306,18 +349,89 @@ describe("desktop layout shell", () => {
const styles = readMainStyleSource(); const styles = readMainStyleSource();
expect(styles).toContain("/* Desktop workbench density */"); expect(styles).toContain("/* Desktop workbench density */");
expect(styles).toContain(".desktop-workbench .desktop-route-shell.page");
expect(styles).toContain(".desktop-workbench .desktop-route-shell.ctms-page-shell");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .page-body");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .page-inner");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .overview");
expect(styles).toContain("padding: 0 !important;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .table-card"); expect(styles).toContain(".desktop-workbench .desktop-route-shell .table-card");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .hero-banner"); expect(styles).toContain(".desktop-workbench .desktop-route-shell .hero-banner");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .faq-hero"); expect(styles).toContain(".desktop-workbench .desktop-route-shell .faq-hero");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .page-bg-dots");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .module-placeholder-surface");
expect(styles).toContain("box-shadow: none !important;"); expect(styles).toContain("box-shadow: none !important;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table th.el-table__cell"); expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table th.el-table__cell");
expect(styles).toContain("height: 34px;"); expect(styles).toContain("height: 34px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table td.el-table__cell"); expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table td.el-table__cell");
expect(styles).toContain("padding: 7px 10px;"); expect(styles).toContain("padding: 7px 10px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-pagination");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-drawer__body"); expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-drawer__body");
expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat"); expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat");
}); });
it("renders Element Plus modals with desktop-native surfaces in the desktop runtime", () => {
const styles = readMainStyleSource();
expect(styles).toContain("/* Desktop runtime modal surfaces */");
expect(styles).toContain("body.is-desktop-runtime .el-overlay");
expect(styles).toContain("left: 0 !important;");
expect(styles).toContain("backdrop-filter: blur(12px) saturate(135%);");
expect(styles).toContain("body.is-desktop-runtime .el-overlay-dialog");
expect(styles).toContain("body.is-desktop-runtime .el-overlay-message-box");
expect(styles).toContain("body.is-desktop-runtime .el-dialog,");
expect(styles).toContain("body.is-desktop-runtime .el-message-box");
expect(styles).toContain("border-radius: 12px;");
expect(styles).toContain("body.is-desktop-runtime .el-dialog__header");
expect(styles).toContain("body.is-desktop-runtime .el-message-box__header");
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog");
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body");
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {\n padding: 0;");
expect(styles).toContain("body.is-desktop-runtime .el-message-box__btns");
expect(styles).toContain("body.is-desktop-runtime .dialog-fade-enter-from .el-dialog");
expect(styles).toContain("body.is-desktop-runtime .msgbox-fade-enter-from .el-message-box");
expect(styles).toContain(':root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog');
expect(styles).toContain(':root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog');
});
it("keeps first-pass desktop content pages workbench-oriented", () => {
const faq = readFaqSource();
const faqList = readFaqListSource();
const projectOverview = readProjectOverviewSource();
expect(faq).toContain("const isDesktop = isTauriRuntime();");
expect(faq).toContain(":class=\"{ 'medical-consult-page--desktop': isDesktop }\"");
expect(faq).toContain('v-if="!isDesktop" class="page-bg-dots"');
expect(faq).toContain('<h1 v-if="!isDesktop"');
expect(faq).toContain('v-else class="desktop-toolbar-meta"');
expect(faq).toContain('v-if="isDesktop" action="faq.create"');
expect(faq).toContain('<div v-if="!isDesktop" class="list-toolbar">');
expect(faq).toContain(".medical-consult-page--desktop .faq-workspace");
expect(faq).toContain("grid-template-columns: 236px minmax(0, 1fr);");
expect(faqList).toContain(":class=\"{ 'faq-list-card--desktop': isDesktop }\"");
expect(faqList).toContain("const isDesktop = isTauriRuntime();");
expect(faqList).toContain(".faq-list-card--desktop .faq-table");
expect(projectOverview).toContain(":class=\"{ 'project-overview--desktop': isDesktop }\"");
expect(projectOverview).not.toContain("overview-summary-strip");
expect(projectOverview).not.toContain("const activeCenterCount");
expect(projectOverview).toContain("desktop-attention-section");
expect(projectOverview).toContain("desktop-attention-head");
expect(projectOverview).toContain("更新 {{ overviewUpdatedAtLabel }}");
expect(projectOverview).toContain("overview-workbench");
expect(projectOverview).toContain("enrollment-snapshot");
expect(projectOverview).toContain("desktop-attention-board");
expect(projectOverview).toContain("const stageStatusSummary");
expect(projectOverview).toContain("const activeStageItems");
expect(projectOverview).toContain("const attentionItems");
expect(projectOverview).toContain("grid-template-columns: minmax(620px, 1fr) minmax(300px, 360px);");
expect(projectOverview).toContain("max-height: min(360px, calc(100vh - 300px));");
expect(projectOverview).toContain("@media (max-width: 1240px)");
expect(projectOverview).toContain(".project-overview--desktop .overview-card");
expect(projectOverview).toContain(".project-overview--desktop .overview-card--enrollment");
expect(projectOverview).toContain(".project-overview--desktop :deep(.center-row)");
expect(projectOverview).toContain(".project-overview--desktop :deep(.stage-node)");
});
it("keeps desktop entry surfaces stable at the minimum window size", () => { it("keeps desktop entry surfaces stable at the minimum window size", () => {
const login = readLoginSource(); const login = readLoginSource();
const serverSettings = readDesktopServerSettingsSource(); const serverSettings = readDesktopServerSettingsSource();
+2 -21
View File
@@ -22,16 +22,6 @@
</el-menu-item> </el-menu-item>
</el-menu-item-group> </el-menu-item-group>
<el-menu-item-group v-if="isDesktop && desktopRecentRoutes.length" class="menu-group desktop-menu-group">
<template #title>
<span class="menu-divider">最近访问</span>
</template>
<el-menu-item v-for="item in desktopRecentRoutes" :key="`recent:${item.path}`" :index="item.path">
<el-icon><Clock /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="auth.user" class="menu-group"> <el-menu-item-group v-if="auth.user" class="menu-group">
<template #title> <template #title>
<span class="menu-divider">{{ TEXT.menu.admin }}</span> <span class="menu-divider">{{ TEXT.menu.admin }}</span>
@@ -434,13 +424,12 @@ import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager"; import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import { import {
DESKTOP_SERVER_URL_CHANGED_EVENT, DESKTOP_SERVER_URL_CHANGED_EVENT,
clearLegacyDesktopRouteHistoryPreference,
getAppMetadata, getAppMetadata,
getDesktopServerUrl, getDesktopServerUrl,
isTauriRuntime, isTauriRuntime,
listenDesktopMenuCommand, listenDesktopMenuCommand,
readDesktopFavoriteRoutes, readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
recordDesktopRecentRoute,
toggleDesktopFavoriteRoute, toggleDesktopFavoriteRoute,
type DesktopRoutePreference, type DesktopRoutePreference,
} from "../runtime"; } from "../runtime";
@@ -472,7 +461,6 @@ const profileDialogDirty = ref(false);
const commandPaletteVisible = ref(false); const commandPaletteVisible = ref(false);
const desktopPreferencesVisible = ref(false); const desktopPreferencesVisible = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl()); const desktopServerUrl = ref(getDesktopServerUrl());
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes()); const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const headerOverviewStats = ref<{ const headerOverviewStats = ref<{
centerActual: number; centerActual: number;
@@ -720,9 +708,6 @@ const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
const refreshDesktopRoutePreferences = () => { const refreshDesktopRoutePreferences = () => {
if (!isDesktop) return; if (!isDesktop) return;
desktopRecentRoutes.value = readDesktopRecentRoutes().filter((item) =>
desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
);
desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) => desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) =>
desktopNavigationItems.value.some((navItem) => navItem.path === item.path), desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
); );
@@ -1084,7 +1069,6 @@ watch(() => route.path, () => {
if (isDesktop) { if (isDesktop) {
const current = currentDesktopRoutePreference.value; const current = currentDesktopRoutePreference.value;
if (current) { if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
refreshDesktopRoutePreferences(); refreshDesktopRoutePreferences();
} }
} }
@@ -1145,10 +1129,7 @@ onMounted(async () => {
}, 1000); }, 1000);
if (isDesktop) { if (isDesktop) {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl); window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
const current = currentDesktopRoutePreference.value; clearLegacyDesktopRouteHistoryPreference();
if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
}
refreshDesktopRoutePreferences(); refreshDesktopRoutePreferences();
desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined); desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined);
} }
-1
View File
@@ -736,7 +736,6 @@ export const TEXT = {
treeTitle: "TMF 目录", treeTitle: "TMF 目录",
detailTitle: "归档状态", detailTitle: "归档状态",
noNodeSelected: "未选择目录", noNodeSelected: "未选择目录",
selectNodeHint: "请选择左侧目录查看文件",
emptyDocuments: "当前目录暂无文件", emptyDocuments: "当前目录暂无文件",
rootNode: "根目录", rootNode: "根目录",
actions: { actions: {
@@ -1,13 +1,11 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { import {
DESKTOP_FAVORITE_ROUTES_KEY, DESKTOP_FAVORITE_ROUTES_KEY,
DESKTOP_RECENT_ROUTES_KEY,
DESKTOP_THEME_KEY, DESKTOP_THEME_KEY,
applyDesktopThemePreference, applyDesktopThemePreference,
clearLegacyDesktopRouteHistoryPreference,
readDesktopFavoriteRoutes, readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
readDesktopThemePreference, readDesktopThemePreference,
recordDesktopRecentRoute,
setDesktopThemePreference, setDesktopThemePreference,
toggleDesktopFavoriteRoute, toggleDesktopFavoriteRoute,
} from "./desktopUiPreferences"; } from "./desktopUiPreferences";
@@ -32,17 +30,17 @@ describe("desktop UI preferences", () => {
document.documentElement.style.colorScheme = ""; document.documentElement.style.colorScheme = "";
}); });
it("stores only route metadata for recent desktop routes", () => { it("stores only route metadata for desktop favorites", () => {
recordDesktopRecentRoute({ path: "/subjects", title: "受试者", group: "当前项目" }); toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者", group: "当前项目" });
expect(readDesktopRecentRoutes()).toEqual([ expect(readDesktopFavoriteRoutes()).toEqual([
expect.objectContaining({ expect.objectContaining({
path: "/subjects", path: "/subjects",
title: "受试者", title: "受试者",
group: "当前项目", group: "当前项目",
}), }),
]); ]);
expect(window.localStorage.getItem(DESKTOP_RECENT_ROUTES_KEY)).not.toContain("token"); expect(window.localStorage.getItem(DESKTOP_FAVORITE_ROUTES_KEY)).not.toContain("token");
}); });
it("deduplicates favorites by path", () => { it("deduplicates favorites by path", () => {
@@ -55,6 +53,21 @@ describe("desktop UI preferences", () => {
expect(window.localStorage.getItem(DESKTOP_FAVORITE_ROUTES_KEY)).not.toContain("subject_no"); expect(window.localStorage.getItem(DESKTOP_FAVORITE_ROUTES_KEY)).not.toContain("subject_no");
}); });
it("clears legacy desktop route history storage without changing favorites", () => {
window.localStorage.setItem("ctms_desktop_recent_routes", JSON.stringify([{ path: "/subjects", title: "受试者" }]));
toggleDesktopFavoriteRoute({ path: "/file-versions", title: "文件版本" });
clearLegacyDesktopRouteHistoryPreference();
expect(window.localStorage.getItem("ctms_desktop_recent_routes")).toBeNull();
expect(readDesktopFavoriteRoutes()).toEqual([
expect.objectContaining({
path: "/file-versions",
title: "文件版本",
}),
]);
});
it("stores and applies only the desktop theme enum", () => { it("stores and applies only the desktop theme enum", () => {
expect(readDesktopThemePreference()).toBe("light"); expect(readDesktopThemePreference()).toBe("light");
+4 -13
View File
@@ -1,4 +1,3 @@
export const DESKTOP_RECENT_ROUTES_KEY = "ctms_desktop_recent_routes";
export const DESKTOP_FAVORITE_ROUTES_KEY = "ctms_desktop_favorite_routes"; export const DESKTOP_FAVORITE_ROUTES_KEY = "ctms_desktop_favorite_routes";
export const DESKTOP_THEME_KEY = "ctms_desktop_theme"; export const DESKTOP_THEME_KEY = "ctms_desktop_theme";
export const DESKTOP_THEME_CHANGED_EVENT = "ctms:desktop-theme-changed"; export const DESKTOP_THEME_CHANGED_EVENT = "ctms:desktop-theme-changed";
@@ -12,10 +11,10 @@ export interface DesktopRoutePreference {
updatedAt: string; updatedAt: string;
} }
const MAX_RECENT_ROUTES = 8;
const MAX_FAVORITE_ROUTES = 12; const MAX_FAVORITE_ROUTES = 12;
const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "light"; const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "light";
const DESKTOP_THEME_ATTRIBUTE = "data-ctms-theme"; const DESKTOP_THEME_ATTRIBUTE = "data-ctms-theme";
const LEGACY_DESKTOP_ROUTE_HISTORY_KEY = "ctms_desktop_recent_routes";
const isStorageAvailable = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined"; const isStorageAvailable = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined";
@@ -80,19 +79,11 @@ export const setDesktopThemePreference = (theme: DesktopThemePreference): Deskto
return nextTheme; return nextTheme;
}; };
export const readDesktopRecentRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_RECENT_ROUTES_KEY);
export const readDesktopFavoriteRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY); export const readDesktopFavoriteRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY);
export const recordDesktopRecentRoute = (route: Pick<DesktopRoutePreference, "path" | "title" | "group">): DesktopRoutePreference[] => { export const clearLegacyDesktopRouteHistoryPreference = () => {
const item = sanitizeRoutePreference({ ...route, updatedAt: new Date().toISOString() }); if (!isStorageAvailable()) return;
if (!item) return readDesktopRecentRoutes(); window.localStorage.removeItem(LEGACY_DESKTOP_ROUTE_HISTORY_KEY);
const next = [
item,
...readDesktopRecentRoutes().filter((existing) => existing.path !== item.path),
].slice(0, MAX_RECENT_ROUTES);
writeRoutePreferences(DESKTOP_RECENT_ROUTES_KEY, next);
return next;
}; };
export const isDesktopFavoriteRoute = (path: string): boolean => export const isDesktopFavoriteRoute = (path: string): boolean =>
+1 -3
View File
@@ -10,15 +10,13 @@ export {
} from "./desktopServerConfig"; } from "./desktopServerConfig";
export { export {
DESKTOP_FAVORITE_ROUTES_KEY, DESKTOP_FAVORITE_ROUTES_KEY,
DESKTOP_RECENT_ROUTES_KEY,
DESKTOP_THEME_CHANGED_EVENT, DESKTOP_THEME_CHANGED_EVENT,
DESKTOP_THEME_KEY, DESKTOP_THEME_KEY,
applyDesktopThemePreference, applyDesktopThemePreference,
clearLegacyDesktopRouteHistoryPreference,
isDesktopFavoriteRoute, isDesktopFavoriteRoute,
readDesktopFavoriteRoutes, readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
readDesktopThemePreference, readDesktopThemePreference,
recordDesktopRecentRoute,
setDesktopThemePreference, setDesktopThemePreference,
toggleDesktopFavoriteRoute, toggleDesktopFavoriteRoute,
type DesktopRoutePreference, type DesktopRoutePreference,
@@ -8,14 +8,32 @@ vi.mock("../router", () => ({
})); }));
const channelPostMessage = vi.fn(); const channelPostMessage = vi.fn();
const runtimeMocks = vi.hoisted(() => ({
isTauriRuntime: vi.fn(() => false),
}));
vi.mock("../runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("../runtime")>();
return {
...actual,
isTauriRuntime: runtimeMocks.isTauriRuntime,
};
});
vi.mock("../api/authClient", () => ({ vi.mock("../api/authClient", () => ({
extendToken: vi.fn(), extendToken: vi.fn(),
})); }));
const encodeJson = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url");
const createJwt = (expiresAtMs: number): string =>
`${encodeJson({ alg: "none", typ: "JWT" })}.${encodeJson({ exp: Math.floor(expiresAtMs / 1000) })}.signature`;
describe("session manager idle logout", () => { describe("session manager idle logout", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
vi.clearAllMocks();
runtimeMocks.isTauriRuntime.mockReturnValue(false);
vi.useFakeTimers(); vi.useFakeTimers();
setActivePinia(createPinia()); setActivePinia(createPinia());
const storage = (() => { const storage = (() => {
@@ -82,6 +100,24 @@ describe("session manager idle logout", () => {
expect(router.replace).toHaveBeenCalledWith("/login"); expect(router.replace).toHaveBeenCalledWith("/login");
}); });
it("does not force desktop logout after local inactivity", async () => {
runtimeMocks.isTauriRuntime.mockReturnValue(true);
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { useSessionStore } = await import("../store/session");
const router = (await import("../router")).default;
const { IDLE_TIMEOUT_MINUTES, markUserActive } = await import("./sessionManager");
const session = useSessionStore();
const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000);
session.recordUserActivity(oldTs);
markUserActive(Date.now());
expect(window.sessionStorage.getItem("ctms_logout_reason")).toBeNull();
expect(router.replace).not.toHaveBeenCalled();
expect(session.timeoutWarningVisible).toBe(false);
});
it("clears the timeout warning when user activity resumes", async () => { it("clears the timeout warning when user activity resumes", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z")); vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { useSessionStore } = await import("../store/session"); const { useSessionStore } = await import("../store/session");
@@ -115,4 +151,27 @@ describe("session manager idle logout", () => {
expect(channelPostMessage).toHaveBeenCalledWith({ type: "TOKEN_UPDATED", token: "new-token" }); expect(channelPostMessage).toHaveBeenCalledWith({ type: "TOKEN_UPDATED", token: "new-token" });
expect(window.localStorage.getItem("ctms_auth_broadcast")).toBeNull(); expect(window.localStorage.getItem("ctms_auth_broadcast")).toBeNull();
}); });
it("extends desktop tokens even when there has been no recent local activity", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { setToken } = await import("../utils/auth");
await setToken(createJwt(Date.now() + 30_000));
runtimeMocks.isTauriRuntime.mockReturnValue(true);
const { extendToken } = await import("../api/authClient");
vi.mocked(extendToken).mockResolvedValue({
data: {
accessToken: createJwt(Date.now() + 3_600_000),
expiresAt: "2026-03-10T02:00:00.000Z",
},
} as any);
const { useSessionStore } = await import("../store/session");
const { IDLE_TIMEOUT_MINUTES, startTokenKeepAlive } = await import("./sessionManager");
const session = useSessionStore();
session.recordUserActivity(Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000));
startTokenKeepAlive();
await vi.advanceTimersByTimeAsync(10_000);
expect(extendToken).toHaveBeenCalledTimes(1);
});
}); });
+12 -1
View File
@@ -1,6 +1,7 @@
import router from "../router"; import router from "../router";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session"; import { useSessionStore } from "../store/session";
import { isTauriRuntime } from "../runtime";
import { getToken, setToken } from "../utils/auth"; import { getToken, setToken } from "../utils/auth";
import { extendToken } from "../api/authClient"; import { extendToken } from "../api/authClient";
import { parseJwtExp } from "./jwt"; import { parseJwtExp } from "./jwt";
@@ -24,11 +25,17 @@ let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null
let initialized = false; let initialized = false;
const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null; const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null;
const shouldEnforceIdleTimeout = () => !isTauriRuntime();
const getTimeoutAt = (session: ReturnType<typeof useSessionStore>) => const getTimeoutAt = (session: ReturnType<typeof useSessionStore>) =>
session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000; session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000;
const reconcileSessionState = (now: number = Date.now()) => { const reconcileSessionState = (now: number = Date.now()) => {
const session = useSessionStore(); const session = useSessionStore();
if (!shouldEnforceIdleTimeout()) {
session.clearTimeoutWarning();
return true;
}
if (now >= getTimeoutAt(session)) { if (now >= getTimeoutAt(session)) {
void forceLogout(LOGOUT_REASON_TIMEOUT); void forceLogout(LOGOUT_REASON_TIMEOUT);
return false; return false;
@@ -70,6 +77,10 @@ const scheduleIdleCheck = () => {
window.clearTimeout(idleTimer); window.clearTimeout(idleTimer);
} }
const session = useSessionStore(); const session = useSessionStore();
if (!shouldEnforceIdleTimeout()) {
session.clearTimeoutWarning();
return;
}
const now = Date.now(); const now = Date.now();
const timeoutAt = getTimeoutAt(session); const timeoutAt = getTimeoutAt(session);
const warningAt = timeoutAt - TIMEOUT_WARNING_SECONDS * 1000; const warningAt = timeoutAt - TIMEOUT_WARNING_SECONDS * 1000;
@@ -224,7 +235,7 @@ export const startTokenKeepAlive = () => {
const remaining = expAt - Date.now(); const remaining = expAt - Date.now();
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const active = Date.now() - session.lastUserActiveAt < timeoutMs; const active = Date.now() - session.lastUserActiveAt < timeoutMs;
if (active && remaining < EXTEND_EARLY_SECONDS * 1000) { if ((!shouldEnforceIdleTimeout() || active) && remaining < EXTEND_EARLY_SECONDS * 1000) {
void extendAccessToken("early"); void extendAccessToken("early");
} }
}, 10000); }, 10000);
+335 -4
View File
@@ -655,9 +655,33 @@ body {
} }
.desktop-workbench .desktop-route-shell.page, .desktop-workbench .desktop-route-shell.page,
.desktop-workbench .desktop-route-shell .ctms-page { .desktop-workbench .desktop-route-shell.ctms-page,
gap: 10px; .desktop-workbench .desktop-route-shell.ctms-page-shell,
padding: 0; .desktop-workbench .desktop-route-shell.module-page,
.desktop-workbench .desktop-route-shell.medical-consult-page {
min-width: 0;
gap: 8px;
padding: 0 !important;
}
.desktop-workbench .desktop-route-shell .page,
.desktop-workbench .desktop-route-shell .ctms-page,
.desktop-workbench .desktop-route-shell .ctms-page-shell,
.desktop-workbench .desktop-route-shell .page-body,
.desktop-workbench .desktop-route-shell .page-inner,
.desktop-workbench .desktop-route-shell .main-content,
.desktop-workbench .desktop-route-shell .main-content-card,
.desktop-workbench .desktop-route-shell .main-content-flat,
.desktop-workbench .desktop-route-shell .study-home-container,
.desktop-workbench .desktop-route-shell .overview,
.desktop-workbench .desktop-route-shell .overview-grid,
.desktop-workbench .desktop-route-shell .content-area {
min-width: 0;
max-width: none;
width: 100%;
gap: 8px;
margin: 0 !important;
padding: 0 !important;
} }
.desktop-workbench .desktop-route-shell .el-card, .desktop-workbench .desktop-route-shell .el-card,
@@ -675,6 +699,25 @@ body {
border: 1px solid #d9e2ec; border: 1px solid #d9e2ec;
border-radius: 6px !important; border-radius: 6px !important;
background: #ffffff; background: #ffffff;
margin: 0 !important;
box-shadow: none !important;
}
/* page--flush 场景:表格铺满全屏,不需要圆角(desktop-route-shell 与 page--flush 在同一元素上)*/
.desktop-workbench .desktop-route-shell.page--flush > .unified-shell,
.desktop-workbench .desktop-route-shell.page--flush > .ctms-table-card {
border-radius: 0 !important;
}
/* 药品流向管理页面桌面端:table-card 铺满,去圆角 */
.desktop-workbench .desktop-route-shell .shipment-page--desktop .table-card {
border-radius: 0 !important;
box-shadow: none !important;
}
/* 设备管理页面桌面端:table-card 铺满,去圆角 */
.desktop-workbench .desktop-route-shell .equipment-page--desktop .table-card {
border-radius: 0 !important;
box-shadow: none !important; box-shadow: none !important;
} }
@@ -700,7 +743,7 @@ body {
.desktop-workbench .desktop-route-shell .hero-top, .desktop-workbench .desktop-route-shell .hero-top,
.desktop-workbench .desktop-route-shell .hero-content, .desktop-workbench .desktop-route-shell .hero-content,
.desktop-workbench .desktop-route-shell .hero-body { .desktop-workbench .desktop-route-shell .hero-body {
margin-bottom: 10px !important; margin-bottom: 8px !important;
} }
.desktop-workbench .desktop-route-shell .hero-title, .desktop-workbench .desktop-route-shell .hero-title,
@@ -737,6 +780,26 @@ body {
gap: 8px; gap: 8px;
} }
.desktop-workbench .desktop-route-shell .ctms-table-card,
.desktop-workbench .desktop-route-shell .ctms-section-card,
.desktop-workbench .desktop-route-shell .detail-card,
.desktop-workbench .desktop-route-shell .section-card,
.desktop-workbench .desktop-route-shell .form-card,
.desktop-workbench .desktop-route-shell .stats-card,
.desktop-workbench .desktop-route-shell .notifications-card,
.desktop-workbench .desktop-route-shell .es-detail-card,
.desktop-workbench .desktop-route-shell .kpi-enterprise {
margin: 0 !important;
}
.desktop-workbench .desktop-route-shell .ctms-table-card + .ctms-table-card,
.desktop-workbench .desktop-route-shell .ctms-section-card + .ctms-section-card,
.desktop-workbench .desktop-route-shell .detail-card + .section-card,
.desktop-workbench .desktop-route-shell .section-card + .section-card,
.desktop-workbench .desktop-route-shell .form-card + .form-card {
margin-top: 8px !important;
}
.desktop-workbench .desktop-route-shell .toolbar-filters, .desktop-workbench .desktop-route-shell .toolbar-filters,
.desktop-workbench .desktop-route-shell .toolbar-right, .desktop-workbench .desktop-route-shell .toolbar-right,
.desktop-workbench .desktop-route-shell .ctms-page-actions, .desktop-workbench .desktop-route-shell .ctms-page-actions,
@@ -795,6 +858,49 @@ body {
font-size: 13px; font-size: 13px;
} }
.desktop-workbench .desktop-route-shell .page-bg-dots,
.desktop-workbench .desktop-route-shell .decorative-bg,
.desktop-workbench .desktop-route-shell .hero-decoration {
display: none !important;
}
.desktop-workbench .desktop-route-shell .unified-action-bar,
.desktop-workbench .desktop-route-shell .module-header {
min-height: 0;
padding: 10px 12px;
border-bottom: 1px solid #d9e2ec;
background: #ffffff;
}
.desktop-workbench .desktop-route-shell .module-title {
font-size: 14px;
font-weight: 750;
}
.desktop-workbench .desktop-route-shell .module-subtitle {
margin-top: 2px;
font-size: 12px;
}
.desktop-workbench .desktop-route-shell .module-content,
.desktop-workbench .desktop-route-shell .unified-section {
padding: 12px;
}
.desktop-workbench .desktop-route-shell .module-placeholder-surface {
min-height: 118px;
border: 1px dashed #cbd7e5;
border-radius: 6px;
background: #f8fafc;
}
.desktop-workbench .desktop-route-shell .module-placeholder-main {
color: #475569;
font-size: 13px;
font-weight: 650;
letter-spacing: 0;
}
.desktop-workbench .desktop-route-shell .el-table th.el-table__cell { .desktop-workbench .desktop-route-shell .el-table th.el-table__cell {
height: 34px; height: 34px;
padding: 8px 10px; padding: 8px 10px;
@@ -817,6 +923,19 @@ body {
padding: 8px 10px; padding: 8px 10px;
} }
.desktop-workbench .desktop-route-shell .el-pagination {
--el-pagination-button-height: 26px;
--el-pagination-button-width: 26px;
gap: 4px;
font-size: 12px;
}
.desktop-workbench .desktop-route-shell .el-radio-button__inner {
min-height: 26px;
padding: 5px 10px;
border-radius: 5px;
}
.desktop-workbench .desktop-route-shell .el-drawer__header { .desktop-workbench .desktop-route-shell .el-drawer__header {
margin-bottom: 0; margin-bottom: 0;
padding: 14px 18px 8px; padding: 14px 18px 8px;
@@ -856,10 +975,17 @@ body {
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .table-card-toolbar, :root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .table-card-toolbar,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-page-header, :root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-page-header,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-section-header, :root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-section-header,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .unified-action-bar,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .module-header,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .el-drawer__footer { :root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .el-drawer__footer {
border-color: #26364a; border-color: #26364a;
} }
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .module-placeholder-surface {
border-color: #31435b;
background: #111827;
}
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .el-table th.el-table__cell { :root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .el-table th.el-table__cell {
border-bottom-color: #26364a !important; border-bottom-color: #26364a !important;
background: #111827; background: #111827;
@@ -885,3 +1011,208 @@ body {
.el-overlay { .el-overlay {
left: var(--ctms-sidebar-width, 0px) !important; left: var(--ctms-sidebar-width, 0px) !important;
} }
/* Desktop runtime modal surfaces */
body.is-desktop-runtime .el-message {
left: 50% !important;
min-height: 32px;
padding: 8px 12px;
border: 1px solid rgba(148, 163, 184, 0.32);
border-radius: 8px;
background: rgba(248, 250, 252, 0.96);
box-shadow: 0 16px 42px rgba(15, 23, 42, 0.16);
backdrop-filter: blur(16px) saturate(140%);
}
body.is-desktop-runtime .el-overlay {
left: 0 !important;
background: rgba(15, 23, 42, 0.22);
backdrop-filter: blur(12px) saturate(135%);
}
body.is-desktop-runtime .el-overlay-dialog,
body.is-desktop-runtime .el-overlay-message-box {
display: flex;
align-items: center;
justify-content: center;
padding: 52px 32px 32px;
overflow: auto;
}
body.is-desktop-runtime .el-dialog,
body.is-desktop-runtime .el-message-box {
overflow: hidden;
margin: 0 auto !important;
border: 1px solid rgba(148, 163, 184, 0.42);
border-radius: 12px;
background: rgba(248, 250, 252, 0.98);
box-shadow:
0 28px 84px rgba(15, 23, 42, 0.26),
0 1px 0 rgba(255, 255, 255, 0.82) inset;
backdrop-filter: blur(18px) saturate(145%);
color: #0f172a;
}
body.is-desktop-runtime .el-dialog {
max-width: min(calc(100vw - 72px), var(--el-dialog-width, 960px));
}
body.is-desktop-runtime .el-message-box {
width: min(420px, calc(100vw - 72px));
padding: 0;
}
body.is-desktop-runtime .el-dialog__header,
body.is-desktop-runtime .el-message-box__header {
min-height: 38px;
margin: 0;
padding: 10px 42px 9px 14px;
border-bottom: 1px solid rgba(203, 213, 225, 0.82);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(241, 245, 249, 0.92));
}
body.is-desktop-runtime .el-dialog__title,
body.is-desktop-runtime .el-message-box__title {
color: #0f172a;
font-size: 13px;
font-weight: 750;
line-height: 18px;
}
body.is-desktop-runtime .el-dialog__headerbtn,
body.is-desktop-runtime .el-message-box__headerbtn {
top: 0;
right: 0;
width: 40px;
height: 38px;
}
body.is-desktop-runtime .el-dialog__headerbtn .el-dialog__close,
body.is-desktop-runtime .el-message-box__headerbtn .el-message-box__close {
color: #64748b;
}
body.is-desktop-runtime .el-dialog__body {
padding: 14px;
color: #334155;
font-size: 13px;
}
body.is-desktop-runtime .desktop-preferences-dialog {
background: #f2f4f7;
}
body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {
padding: 0;
background: #f2f4f7;
}
body.is-desktop-runtime .el-dialog__footer,
body.is-desktop-runtime .el-message-box__btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px 12px;
border-top: 1px solid rgba(226, 232, 240, 0.92);
background: rgba(241, 245, 249, 0.76);
}
body.is-desktop-runtime .el-message-box__content {
padding: 16px 14px 14px;
color: #334155;
font-size: 13px;
}
body.is-desktop-runtime .el-message-box__message {
color: #334155;
line-height: 1.6;
}
body.is-desktop-runtime .el-message-box__status {
font-size: 18px !important;
}
body.is-desktop-runtime .el-message-box__input {
padding-top: 10px;
}
body.is-desktop-runtime .el-dialog__footer .el-button,
body.is-desktop-runtime .el-message-box__btns .el-button {
min-height: 28px;
padding: 0 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 650;
}
body.is-desktop-runtime .dialog-fade-enter-active .el-dialog,
body.is-desktop-runtime .dialog-fade-leave-active .el-dialog,
body.is-desktop-runtime .msgbox-fade-enter-active .el-message-box,
body.is-desktop-runtime .msgbox-fade-leave-active .el-message-box {
transition: opacity 130ms ease, transform 150ms ease;
}
body.is-desktop-runtime .dialog-fade-enter-from .el-dialog,
body.is-desktop-runtime .dialog-fade-leave-to .el-dialog,
body.is-desktop-runtime .msgbox-fade-enter-from .el-message-box,
body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
opacity: 0;
transform: translateY(-8px) scale(0.985);
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message {
border-color: rgba(71, 85, 105, 0.7);
background: rgba(17, 24, 39, 0.94);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.36);
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-overlay {
background: rgba(2, 6, 23, 0.42);
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box {
border-color: rgba(51, 65, 85, 0.92);
background: rgba(17, 24, 39, 0.98);
box-shadow:
0 28px 84px rgba(0, 0, 0, 0.42),
0 1px 0 rgba(148, 163, 184, 0.12) inset;
color: #e5edf7;
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {
background: #0f1721;
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__header,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__header {
border-bottom-color: rgba(51, 65, 85, 0.92);
background: linear-gradient(180deg, rgba(30, 41, 59, 0.96), rgba(17, 24, 39, 0.94));
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__title,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__title {
color: #e5edf7;
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__body,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__content,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__message {
color: #cbd5e1;
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__footer,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__btns {
border-top-color: rgba(51, 65, 85, 0.92);
background: rgba(15, 23, 42, 0.78);
}
@media (prefers-reduced-motion: reduce) {
body.is-desktop-runtime .dialog-fade-enter-active .el-dialog,
body.is-desktop-runtime .dialog-fade-leave-active .el-dialog,
body.is-desktop-runtime .msgbox-fade-enter-active .el-message-box,
body.is-desktop-runtime .msgbox-fade-leave-active .el-message-box {
transition-duration: 1ms !important;
}
}
+261 -6
View File
@@ -1,8 +1,12 @@
<template> <template>
<div class="page ctms-page-shell page--flush medical-consult-page"> <div class="page ctms-page-shell page--flush medical-consult-page" :class="{ 'medical-consult-page--desktop': isDesktop }">
<div class="page-bg-dots"></div> <div v-if="!isDesktop" class="page-bg-dots"></div>
<section class="faq-hero"> <section class="faq-hero">
<h1><el-icon class="hero-title-icon"><Collection /></el-icon>项目知识库</h1> <h1 v-if="!isDesktop"><el-icon class="hero-title-icon"><Collection /></el-icon>项目知识库</h1>
<div v-else class="desktop-toolbar-meta">
<span>知识条目</span>
<small>{{ activeCategoryName }} · {{ resultSummary }}</small>
</div>
<div class="hero-tools"> <div class="hero-tools">
<el-autocomplete <el-autocomplete
v-model="keyword" v-model="keyword"
@@ -19,7 +23,13 @@
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
</template> </template>
</el-autocomplete> </el-autocomplete>
<div class="spacer" /> <PermissionAction v-if="isDesktop" action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建
</el-button>
</PermissionAction>
<div v-else class="spacer" />
</div> </div>
</section> </section>
@@ -34,7 +44,7 @@
</el-col> </el-col>
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col"> <el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
<div class="faq-main unified-shell"> <div class="faq-main unified-shell">
<div class="list-toolbar"> <div v-if="!isDesktop" class="list-toolbar">
<PermissionAction action="faq.create"> <PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()"> <el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon> <el-icon class="el-icon--left"><Plus /></el-icon>
@@ -79,6 +89,7 @@ import { fetchFaqCategories, fetchFaqItems } from "../api/faqs";
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue"; import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
import FaqList from "../components/FaqList.vue"; import FaqList from "../components/FaqList.vue";
import FaqItemForm from "../components/FaqItemForm.vue"; import FaqItemForm from "../components/FaqItemForm.vue";
import { isTauriRuntime } from "../runtime";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import PermissionAction from "../components/PermissionAction.vue"; import PermissionAction from "../components/PermissionAction.vue";
@@ -87,6 +98,7 @@ import { TEXT } from "../locales";
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const isDesktop = isTauriRuntime();
const categories = ref<any[]>([]); const categories = ref<any[]>([]);
const faqs = ref<any[]>([]); const faqs = ref<any[]>([]);
@@ -104,6 +116,14 @@ const { can } = usePermission();
const canReadCategories = computed(() => can("faq.category.read")); const canReadCategories = computed(() => can("faq.category.read"));
const canUpdateFaq = computed(() => can("faq.update")); const canUpdateFaq = computed(() => can("faq.update"));
const canDeleteFaq = computed(() => can("faq.delete")); const canDeleteFaq = computed(() => can("faq.delete"));
const activeCategoryName = computed(() => {
if (!activeCategory.value) return TEXT.common.labels.all;
return categories.value.find((item: any) => item.id === activeCategory.value)?.name || "已筛选分类";
});
const resultSummary = computed(() => {
const suffix = keyword.value.trim() ? ",已应用搜索" : "";
return `${total.value}${suffix}`;
});
const loadCategories = async () => { const loadCategories = async () => {
try { try {
@@ -247,7 +267,7 @@ onMounted(async () => {
color: #0f172a; color: #0f172a;
font-size: 32px; font-size: 32px;
font-weight: 900; font-weight: 900;
letter-spacing: -0.02em; letter-spacing: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -361,6 +381,228 @@ onMounted(async () => {
flex: 1; flex: 1;
} }
.desktop-toolbar-meta {
display: flex;
position: relative;
min-width: 160px;
padding-left: 14px;
flex-direction: column;
gap: 2px;
text-align: left;
}
.desktop-toolbar-meta::before {
content: "";
position: absolute;
top: 2px;
bottom: 2px;
left: 0;
width: 4px;
border-radius: 999px;
background: linear-gradient(180deg, #2f7be8 0%, #23b7d9 100%);
box-shadow: 0 0 16px rgba(47, 123, 232, 0.35);
}
.desktop-toolbar-meta span {
color: #11335a;
font-size: 15px;
font-weight: 850;
}
.desktop-toolbar-meta small {
color: #436785;
font-size: 12px;
font-weight: 600;
}
.medical-consult-page--desktop {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 0 !important;
overflow: hidden;
background: linear-gradient(180deg, #eef5ff 0%, #f7fbff 100%);
}
.medical-consult-page--desktop .faq-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 18px !important;
border: 1px solid rgba(90, 145, 220, 0.24) !important;
background:
radial-gradient(circle at 0% 0%, rgba(42, 132, 255, 0.22) 0%, transparent 34%),
radial-gradient(circle at 78% 18%, rgba(35, 183, 217, 0.16) 0%, transparent 30%),
linear-gradient(135deg, #ecf7ff 0%, #edf3ff 46%, #f8fcff 100%) !important;
box-shadow:
0 10px 24px rgba(30, 73, 128, 0.08),
0 1px 0 rgba(255, 255, 255, 0.86) inset !important;
text-align: left;
border-radius: 0 !important;
}
.medical-consult-page--desktop .faq-hero::before {
display: none;
}
.medical-consult-page--desktop .hero-tools {
width: auto;
max-width: none;
margin: 0;
justify-content: flex-end;
}
.medical-consult-page--desktop .hero-search {
width: min(460px, 44vw);
}
.medical-consult-page--desktop .hero-search :deep(.el-input__wrapper) {
height: 34px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow:
0 8px 20px rgba(48, 92, 150, 0.08),
0 0 0 1px rgba(93, 146, 214, 0.24) inset;
transition: border-color 0.2s, box-shadow 0.2s;
}
.medical-consult-page--desktop .hero-search :deep(.el-input__inner) {
font-size: 13px;
font-weight: 600;
}
.medical-consult-page--desktop .hero-search :deep(.el-input__prefix) {
color: #6d93bc;
font-size: 15px;
}
.medical-consult-page--desktop .new-consult-btn {
height: 34px;
padding: 0 16px;
border-radius: 8px;
background: linear-gradient(135deg, #2f7be8 0%, #2560bd 100%);
border: none;
box-shadow: 0 10px 18px rgba(47, 123, 232, 0.24);
font-size: 13px;
font-weight: 800;
transition: all 0.2s;
}
.medical-consult-page--desktop .new-consult-btn:hover,
.medical-consult-page--desktop .new-consult-btn:focus {
background: linear-gradient(135deg, #3d6bbf 0%, #254d90 100%);
box-shadow: 0 4px 14px rgba(79, 126, 207, 0.4);
transform: translateY(-1px);
}
.medical-consult-page--desktop .faq-workspace {
display: grid;
grid-template-columns: 236px minmax(0, 1fr);
flex: 1 1 auto;
height: 0;
min-height: 0;
margin-top: 0;
border: 1px solid rgba(130, 158, 190, 0.22);
border-radius: 0;
background: #ffffff;
overflow: hidden;
}
.medical-consult-page--desktop .faq-sidebar-col,
.medical-consult-page--desktop .faq-content-col {
width: auto;
max-width: none !important;
flex: initial !important;
}
.medical-consult-page--desktop .faq-sidebar-col {
min-height: 0;
border-right: 1px solid rgba(134, 163, 194, 0.22);
background:
radial-gradient(circle at 18% 0%, rgba(47, 123, 232, 0.13) 0%, transparent 34%),
linear-gradient(180deg, #e9f4ff 0%, #f6faff 44%, #eaf1fa 100%);
overflow: hidden;
}
.medical-consult-page--desktop .faq-content-col {
display: flex;
min-height: 0;
overflow: hidden;
}
.medical-consult-page--desktop .faq-main {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
flex-direction: column;
border: 0 !important;
padding: 0;
overflow: hidden;
background: #ffffff;
}
.medical-consult-page--desktop .pagination-wrap {
flex: 0 0 auto;
padding: 10px 14px;
border-top: 1px solid #edf3fa;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.medical-consult-page--desktop :deep(.faq-category-panel) {
height: 100%;
padding: 12px 10px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .header) {
padding: 10px 10px;
font-size: 12px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .menu) {
flex: 1 1 auto;
max-height: none;
min-height: 0;
padding-top: 10px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .menu .el-menu-item) {
height: 36px;
margin: 4px 0;
border-radius: 8px;
font-size: 13px;
line-height: 36px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .cat-icon) {
width: 22px;
height: 22px;
border-radius: 7px;
font-size: 12px;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-hero),
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-workspace) {
border-color: #26364a;
background:
radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.2) 0%, transparent 34%),
linear-gradient(135deg, #172033 0%, #111827 100%) !important;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-sidebar-col) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .desktop-toolbar-meta span) {
color: #e5edf7;
}
@media (max-width: 1080px) { @media (max-width: 1080px) {
.hero-tools { .hero-tools {
flex-wrap: wrap; flex-wrap: wrap;
@@ -376,5 +618,18 @@ onMounted(async () => {
.faq-main { .faq-main {
padding: 18px 16px 0; padding: 18px 16px 0;
} }
.medical-consult-page--desktop .faq-workspace {
grid-template-columns: 1fr;
}
.medical-consult-page--desktop .faq-sidebar-col {
border-right: 0;
border-bottom: 1px solid #d9e2ec;
}
.medical-consult-page--desktop .faq-main {
padding: 0;
}
} }
</style> </style>
@@ -65,12 +65,33 @@ describe("DocumentList permissions", () => {
expect(source).not.toContain('@row-click="handleRowClick"'); expect(source).not.toContain('@row-click="handleRowClick"');
}); });
it("routes desktop document actions through preview and context menu controls", () => { it("stretches the desktop document workbench flush to the desktop content edges", () => {
const source = readSource();
expect(source).toContain(":global(.desktop-content:has(> .document-page--desktop))");
expect(source).toContain("padding: 0;");
expect(source).toContain(".document-page--desktop {");
expect(source).toContain("width: 100%;");
expect(source).toContain("height: 100%;");
expect(source).toContain("min-height: 100%;");
expect(source).toContain("margin: 0;");
expect(source).toContain("overflow-x: hidden;");
expect(source).toContain("border-radius: 0 !important;");
expect(source).toContain(".document-page--desktop .main-content-flat");
expect(source).toContain(".document-page--desktop .document-table-section");
expect(source).toContain(".document-page--desktop .document-workbench.is-desktop");
expect(source).toContain(".document-page--desktop .document-table-pane");
expect(source).toContain("flex: 1 1 auto;");
expect(source).toContain("min-height: 0;");
});
it("routes desktop document actions through context menu controls", () => {
const source = readSource(); const source = readSource();
expect(source).toContain('@row-contextmenu="openDocumentContextMenu"'); expect(source).toContain('@row-contextmenu="openDocumentContextMenu"');
expect(source).toContain("documentContextMenu"); expect(source).toContain("documentContextMenu");
expect(source).toContain('class="document-context-menu"'); expect(source).toContain('class="document-context-menu"');
expect(source).not.toContain('class="preview-actions"');
expect(source).toContain("@click=\"openSelectedDocument\""); expect(source).toContain("@click=\"openSelectedDocument\"");
expect(source).toContain("@click=\"openSelectedDocumentEditor\""); expect(source).toContain("@click=\"openSelectedDocumentEditor\"");
expect(source).toContain("@click=\"deleteSelectedDocument\""); expect(source).toContain("@click=\"deleteSelectedDocument\"");
+47 -23
View File
@@ -120,22 +120,6 @@
<dt>{{ TEXT.modules.fileVersionManagement.columns.updatedAt }}</dt> <dt>{{ TEXT.modules.fileVersionManagement.columns.updatedAt }}</dt>
<dd>{{ displayDateTime(selectedDocument.updated_at) }}</dd> <dd>{{ displayDateTime(selectedDocument.updated_at) }}</dd>
</dl> </dl>
<div class="preview-actions">
<el-button size="small" @click="openSelectedDocument">打开详情</el-button>
<el-button v-if="canUpdate" size="small" :disabled="isInactiveSite(selectedDocument.site_id)" @click="openSelectedDocumentEditor">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDelete"
size="small"
type="danger"
plain
:disabled="isInactiveSite(selectedDocument.site_id)"
@click="deleteSelectedDocument"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template> </template>
<div v-else class="preview-empty"> <div v-else class="preview-empty">
<span>选择一行查看文档摘要</span> <span>选择一行查看文档摘要</span>
@@ -620,6 +604,31 @@ watch(
gap: 0; gap: 0;
} }
:global(.desktop-content:has(> .document-page--desktop)) {
overflow-x: hidden;
padding: 0;
}
.document-page--desktop {
width: 100%;
height: 100%;
min-height: 100%;
margin: 0;
overflow-x: hidden;
}
.document-page--desktop .main-content-flat {
display: flex;
width: auto;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
border-radius: 0 !important;
background: #ffffff;
box-shadow: none !important;
overflow-x: hidden;
}
.filter-form { .filter-form {
display: flex; display: flex;
width: 100%; width: 100%;
@@ -705,6 +714,15 @@ watch(
0 2px 8px rgba(0, 0, 0, 0.02); 0 2px 8px rgba(0, 0, 0, 0.02);
} }
.document-page--desktop .document-table-section {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
border-radius: 0 !important;
box-shadow: none !important;
}
.document-workbench { .document-workbench {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -716,11 +734,24 @@ watch(
min-height: min(570px, calc(100vh - 178px)); min-height: min(570px, calc(100vh - 178px));
} }
.document-page--desktop .document-workbench.is-desktop {
height: 100%;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
.document-table-pane { .document-table-pane {
min-width: 0; min-width: 0;
outline: none; outline: none;
} }
.document-page--desktop .document-table-pane {
min-height: 0;
overflow: hidden;
background: #ffffff;
}
.document-workbench.is-desktop .document-table-pane { .document-workbench.is-desktop .document-table-pane {
border-right: 1px solid #e3e9f1; border-right: 1px solid #e3e9f1;
} }
@@ -782,13 +813,6 @@ watch(
font-weight: 650; font-weight: 650;
} }
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty { .preview-empty {
display: flex; display: flex;
min-height: 180px; min-height: 180px;
@@ -35,4 +35,19 @@ describe("ContractFees.vue", () => {
expect(source).not.toContain('router.push("/fees/contracts/new")'); expect(source).not.toContain('router.push("/fees/contracts/new")');
expect(source).not.toContain("`/fees/contracts/${contractId.value}`"); expect(source).not.toContain("`/fees/contracts/${contractId.value}`");
}); });
it("uses a desktop-only density class for the fee workspace", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain(":class=\"{ 'fee-contracts-page--desktop': isDesktop }\"");
expect(source).toContain("contract-shell");
expect(source).toContain(".fee-contracts-page--desktop .contract-shell");
expect(source).toContain("flex: 1 1 0;");
expect(source).toContain(".fee-contracts-page--desktop :deep(.kpi-card)");
expect(source).toContain("min-height: 104px;");
expect(source).toContain(".fee-contracts-page--desktop .table-empty");
expect(source).toContain("min-height: 132px;");
expect(source).not.toContain("empty-create-btn");
});
}); });
+75 -3
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="page ctms-page-shell page--flush"> <div class="page ctms-page-shell page--flush" :class="{ 'fee-contracts-page--desktop': isDesktop }">
<div class="overview"> <div class="overview fee-overview">
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6"> <el-col :xs="24" :sm="12" :md="6">
<KpiCard <KpiCard
@@ -53,7 +53,7 @@
<StateLoading v-else-if="loading" :rows="6" /> <StateLoading v-else-if="loading" :rows="6" />
<div class="main-content-flat unified-shell" v-else> <div class="main-content-flat unified-shell contract-shell" v-else>
<div class="filter-container unified-action-bar"> <div class="filter-container unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form"> <el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item"> <div class="filter-item">
@@ -208,9 +208,11 @@ import StateError from "../../components/StateError.vue";
import KpiCard from "../../components/KpiCard.vue"; import KpiCard from "../../components/KpiCard.vue";
import ContractFeeEditorDrawer from "./ContractFeeEditorDrawer.vue"; import ContractFeeEditorDrawer from "./ContractFeeEditorDrawer.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { isTauriRuntime } from "../../runtime";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const isDesktop = isTauriRuntime();
const { can } = usePermission(); const { can } = usePermission();
const canCreate = computed(() => can("fees.contract.create")); const canCreate = computed(() => can("fees.contract.create"));
const canUpdate = computed(() => can("fees.contract.update")); const canUpdate = computed(() => can("fees.contract.update"));
@@ -522,6 +524,76 @@ onMounted(async () => {
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.fee-contracts-page--desktop {
height: 100%;
min-height: 0;
overflow: hidden;
gap: 8px;
}
.fee-contracts-page--desktop .fee-overview {
flex: 0 0 auto;
}
.fee-contracts-page--desktop .contract-shell {
display: flex;
flex: 1 1 0;
min-height: 0;
flex-direction: column;
}
.fee-contracts-page--desktop .contract-table-section {
flex: 1 1 0;
min-height: 0;
background: var(--ctms-bg-card);
}
.fee-contracts-page--desktop .table-empty {
min-height: 132px;
letter-spacing: 0;
}
.fee-contracts-page--desktop :deep(.kpi-card) {
min-height: 104px;
padding: 12px 16px;
border-radius: 8px;
box-shadow: none;
transform: none;
}
.fee-contracts-page--desktop :deep(.kpi-card:hover) {
box-shadow: none;
transform: none;
}
.fee-contracts-page--desktop :deep(.kpi-badge) {
margin-bottom: 8px;
padding: 3px 9px;
border-radius: 5px;
}
.fee-contracts-page--desktop :deep(.kpi-title) {
overflow: hidden;
font-size: 15px;
line-height: 1.25;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.fee-contracts-page--desktop :deep(.kpi-footer) {
padding-top: 8px;
}
.fee-contracts-page--desktop :deep(.kpi-value) {
font-size: 30px;
letter-spacing: 0;
}
.fee-contracts-page--desktop :deep(.kpi-unit) {
font-size: 13px;
}
</style> </style>
<style> <style>
@@ -110,6 +110,7 @@ describe("DrugShipments project permissions", () => {
expect(source).toContain('@row-contextmenu="openShipmentContextMenu"'); expect(source).toContain('@row-contextmenu="openShipmentContextMenu"');
expect(source).toContain("shipmentContextMenu"); expect(source).toContain("shipmentContextMenu");
expect(source).toContain('class="shipment-context-menu"'); expect(source).toContain('class="shipment-context-menu"');
expect(source).toContain('class="preview-actions"');
expect(source).toContain('@click="openSelectedShipment"'); expect(source).toContain('@click="openSelectedShipment"');
expect(source).toContain('@click="openSelectedShipmentEditor"'); expect(source).toContain('@click="openSelectedShipmentEditor"');
expect(source).toContain('@click="removeSelectedShipment"'); expect(source).toContain('@click="removeSelectedShipment"');
+41 -3
View File
@@ -164,8 +164,12 @@
<dd>{{ selectedShipment.remark || TEXT.common.fallback }}</dd> <dd>{{ selectedShipment.remark || TEXT.common.fallback }}</dd>
</dl> </dl>
<div class="preview-actions"> <div class="preview-actions">
<el-button size="small" @click="openSelectedShipment">打开详情</el-button> <el-button
<el-button v-if="canUpdate" size="small" :disabled="isInactiveSite(selectedShipment.center_id)" @click="openSelectedShipmentEditor"> v-if="canUpdate"
size="small"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="openSelectedShipmentEditor"
>
{{ TEXT.common.actions.edit }} {{ TEXT.common.actions.edit }}
</el-button> </el-button>
<el-button <el-button
@@ -818,12 +822,26 @@ onMounted(async () => {
background: transparent; background: transparent;
} }
/* 桌面端:撑满父容器高度 */
.shipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner { .page-inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
/* 桌面端:page-inner 撑满 .page */
.shipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Page Header ==================== */ /* ==================== Page Header ==================== */
.create-btn { .create-btn {
height: 34px; height: 34px;
@@ -903,6 +921,16 @@ onMounted(async () => {
0 2px 8px rgba(0, 0, 0, 0.02); 0 2px 8px rgba(0, 0, 0, 0.02);
} }
/* 桌面端:table-card 填满、无圆角 */
.shipment-page--desktop .table-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
box-shadow: none;
}
.shipment-workbench { .shipment-workbench {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -911,7 +939,9 @@ onMounted(async () => {
.shipment-workbench.is-desktop { .shipment-workbench.is-desktop {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 316px; grid-template-columns: minmax(0, 1fr) 316px;
min-height: min(590px, calc(100vh - 186px)); /* 桌面端:不限定固定高度,由父容器 flex 撑满 */
flex: 1;
min-height: 0;
} }
.shipment-table-pane { .shipment-table-pane {
@@ -919,6 +949,14 @@ onMounted(async () => {
outline: none; outline: none;
} }
/* 禁用表格横向滚动:阻断滚动行为 + 隐藏滚动条元素 */
.shipment-table-pane :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.shipment-table-pane :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.shipment-workbench.is-desktop .shipment-table-pane { .shipment-workbench.is-desktop .shipment-table-pane {
border-right: 1px solid #e3e9f1; border-right: 1px solid #e3e9f1;
} }
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readEtmfSource = () => readFileSync(resolve(__dirname, "./EtmfPlaceholder.vue"), "utf8");
describe("EtmfPlaceholder copy", () => {
it("does not show redundant directory count or select-node helper copy", () => {
const source = readEtmfSource();
expect(source).not.toContain("个目录节点");
expect(source).not.toContain("selectNodeHint");
});
});
describe("EtmfPlaceholder desktop layout", () => {
it("keeps archive status in the document header instead of a standalone side column", () => {
const source = readEtmfSource();
expect(source).toContain('class="document-status-summary"');
expect(source).toContain('class="status-summary-list"');
expect(source).not.toContain('class="etmf-node-detail"');
expect(source).toContain("grid-template-columns: minmax(280px, 340px) minmax(620px, 1fr);");
expect(source).not.toContain("grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px);");
});
});
+364 -224
View File
@@ -5,7 +5,7 @@
<div class="etmf-toolbar"> <div class="etmf-toolbar">
<div class="etmf-toolbar-main"> <div class="etmf-toolbar-main">
<div class="etmf-filter-item"> <div class="etmf-filter-item">
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable class="filter-select-comp" @change="loadNodeDocuments"> <el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable size="small" class="filter-select-comp" @change="loadNodeDocuments">
<template #prefix> <template #prefix>
<el-icon><OfficeBuilding /></el-icon> <el-icon><OfficeBuilding /></el-icon>
</template> </template>
@@ -14,7 +14,7 @@
</el-select> </el-select>
</div> </div>
<div class="etmf-filter-item"> <div class="etmf-filter-item">
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable class="filter-select-comp"> <el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable size="small" class="filter-select-comp">
<template #prefix> <template #prefix>
<el-icon><CircleCheck /></el-icon> <el-icon><CircleCheck /></el-icon>
</template> </template>
@@ -23,25 +23,23 @@
</el-select> </el-select>
</div> </div>
</div> </div>
<!-- 内联状态摘要徽章 -->
<div class="etmf-status-inline">
<div v-for="card in overviewCards" :key="card.key" class="etmf-status-badge" :class="`etmf-status-badge--${card.key}`">
<span class="status-badge-value">{{ card.value }}</span>
<span class="status-badge-label">{{ card.label }}</span>
</div>
</div>
<div class="etmf-toolbar-actions"> <div class="etmf-toolbar-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button> <el-button size="small" :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button>
<el-button :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button> <el-button v-if="canCreate && selectedNode" size="small" type="primary" @click="openDocumentDialog">
<el-button v-if="canCreate" @click="openNodeDialog">
<el-icon class="el-icon--left"><FolderAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newNode }}
</el-button>
<el-button v-if="canCreate && selectedNode" type="primary" @click="openDocumentDialog">
<el-icon class="el-icon--left"><DocumentAdd /></el-icon> <el-icon class="el-icon--left"><DocumentAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newDocument }} {{ TEXT.modules.etmf.actions.newDocument }}
</el-button> </el-button>
</div> </div>
</div> </div>
<div class="etmf-status-strip">
<div v-for="card in overviewCards" :key="card.key" class="etmf-status-card" :class="`etmf-status-card--${card.key}`">
<div class="status-card-value">{{ card.value }}</div>
<div class="status-card-label">{{ card.label }}</div>
</div>
</div>
</div> </div>
<section class="unified-section section--flush etmf-workspace"> <section class="unified-section section--flush etmf-workspace">
@@ -49,7 +47,6 @@
<div class="panel-heading panel-heading--compact"> <div class="panel-heading panel-heading--compact">
<div> <div>
<div class="panel-title">{{ TEXT.modules.etmf.treeTitle }}</div> <div class="panel-title">{{ TEXT.modules.etmf.treeTitle }}</div>
<div class="panel-subtitle">{{ totalNodeCount }} 个目录节点</div>
</div> </div>
</div> </div>
<div v-if="!filteredTree.length && !treeLoading" class="etmf-empty-block etmf-empty-block--tree"> <div v-if="!filteredTree.length && !treeLoading" class="etmf-empty-block etmf-empty-block--tree">
@@ -86,69 +83,30 @@
<main class="etmf-document-panel"> <main class="etmf-document-panel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div class="panel-heading-left">
<div class="panel-title">{{ selectedNode?.name || TEXT.modules.etmf.noNodeSelected }}</div> <div class="panel-title">{{ selectedNode?.name || TEXT.modules.etmf.noNodeSelected }}</div>
<div class="panel-subtitle"> <span v-if="selectedNode" class="panel-subtitle">{{ selectedNode.code }} · {{ scopeLabel(selectedNode.scope_type) }}</span>
{{ selectedNode ? `${selectedNode.code} · ${scopeLabel(selectedNode.scope_type)}` : TEXT.modules.etmf.selectNodeHint }}
</div>
</div> </div>
<div v-if="selectedNode" class="document-panel-meta"> <div v-if="selectedNode" class="document-panel-meta">
<span>{{ selectedNode.document_count }} 份文件</span> <span>{{ selectedNode.document_count }} 份文件</span>
<span>{{ selectedNode.effective_document_count }} 生效版本</span> <span>{{ selectedNode.effective_document_count }} 生效</span>
<el-tag effect="plain" :type="statusType(selectedNode.status)"> <el-tag size="small" effect="plain" :type="statusType(selectedNode.status)">
{{ statusLabel(selectedNode.status) }} {{ statusLabel(selectedNode.status) }}
</el-tag> </el-tag>
</div> </div>
</div> </div>
<el-table <section v-if="selectedNode" class="document-status-summary">
:data="documents" <div class="status-summary-head">
v-loading="documentLoading" <span class="status-summary-label">{{ TEXT.modules.etmf.detailTitle }}</span>
class="ctms-table etmf-document-table" <el-tag size="small" effect="plain" :type="statusType(selectedNode.status)">
table-layout="fixed" {{ statusLabel(selectedNode.status) }}
@row-click="goDocument" </el-tag>
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip />
<el-table-column prop="doc_type" :label="TEXT.modules.fileVersionManagement.columns.docType" width="140">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ displaySite(row.site_id) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" width="120">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="150">
<template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
</el-table-column>
<template #empty>
<div class="etmf-empty-block etmf-empty-block--documents">
<div class="empty-icon">
<el-icon><DocumentAdd /></el-icon>
</div>
<div class="empty-title">{{ selectedNode ? TEXT.modules.etmf.emptyDocuments : TEXT.modules.etmf.selectNodeHint }}</div>
<div class="empty-desc">{{ selectedNode ? "当前目录下还没有归档文件。" : "选择目录后可查看文件、版本与中心范围。" }}</div>
<el-button v-if="selectedNode && canCreate" size="small" type="primary" @click.stop="openDocumentDialog">
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</div>
</template>
</el-table>
</main>
<aside class="etmf-node-detail">
<div class="panel-title">{{ TEXT.modules.etmf.detailTitle }}</div>
<template v-if="selectedNode">
<dl class="node-meta">
<div>
<dt>{{ TEXT.modules.etmf.fields.code }}</dt>
<dd>{{ selectedNode.code }}</dd>
</div> </div>
<span class="status-summary-note" :class="`status-summary-note--${selectedNode.status.toLowerCase()}`">
{{ statusDescription(selectedNode.status) }}
</span>
<dl class="status-summary-list">
<div> <div>
<dt>{{ TEXT.modules.etmf.fields.scope }}</dt> <dt>{{ TEXT.modules.etmf.fields.scope }}</dt>
<dd>{{ scopeLabel(selectedNode.scope_type) }}</dd> <dd>{{ scopeLabel(selectedNode.scope_type) }}</dd>
@@ -166,18 +124,44 @@
<dd>{{ selectedNode.effective_document_count }}</dd> <dd>{{ selectedNode.effective_document_count }}</dd>
</div> </div>
</dl> </dl>
<div class="node-status-note" :class="`node-status-note--${selectedNode.status.toLowerCase()}`"> </section>
{{ statusDescription(selectedNode.status) }}
<el-table
:data="documents"
v-loading="documentLoading"
class="ctms-table etmf-document-table"
table-layout="fixed"
@row-click="goDocument"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip min-width="1" />
<el-table-column prop="doc_type" :label="TEXT.modules.fileVersionManagement.columns.docType" min-width="1">
<template #default="{ row }">
<el-tag effect="plain" type="info" size="small">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" min-width="1" show-overflow-tooltip>
<template #default="{ row }">{{ displaySite(row.site_id) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" min-width="1">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" min-width="1">
<template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
</el-table-column>
<template #empty>
<div class="etmf-empty-block etmf-empty-block--documents">
<div v-if="selectedNode" class="empty-title">{{ TEXT.modules.etmf.emptyDocuments }}</div>
<div v-if="selectedNode" class="empty-desc">当前目录下还没有归档文件</div>
<el-button v-if="selectedNode && canCreate" size="small" type="primary" @click.stop="openDocumentDialog">
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</div> </div>
</template> </template>
<div v-else class="etmf-empty-block etmf-empty-block--detail"> </el-table>
<div class="empty-icon"> </main>
<el-icon><CircleCheck /></el-icon>
</div>
<div class="empty-title">等待选择目录</div>
<div class="empty-desc">{{ TEXT.modules.etmf.selectNodeHint }}</div>
</div>
</aside>
</section> </section>
</div> </div>
@@ -522,151 +506,233 @@ onMounted(load);
</script> </script>
<style scoped> <style scoped>
.etmf-action-bar { /* ==================== 高度链:让工作区撑满页面剩余空间 ==================== */
display: grid; /* .page 本身占满路由容器给它的全部高度 */
gap: 12px; .page {
padding: 14px 20px; display: flex;
background: #f8fafc; flex-direction: column;
border-bottom: 1px solid var(--ctms-border-light); height: 100%;
min-height: 0;
} }
/* unified-shellmain-content-flat)是直接子元素,flex:1 让它撑满 .page */
.page :deep(.main-content-flat) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ==================== 顶部单行紧凑工具栏 ==================== */
.etmf-action-bar {
padding: 8px 16px;
background: linear-gradient(135deg, #f8faff 0%, #f0f5ff 100%);
border-bottom: 1px solid rgba(79, 126, 207, 0.12);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
flex-shrink: 0;
}
.etmf-toolbar { .etmf-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 14px; gap: 10px;
width: 100%; width: 100%;
min-width: 0;
} }
.etmf-toolbar-main, .etmf-toolbar-main,
.etmf-toolbar-actions { .etmf-toolbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
min-width: 0; flex-shrink: 0;
}
.etmf-toolbar-actions {
flex-wrap: wrap;
justify-content: flex-end;
} }
.etmf-filter-item { .etmf-filter-item {
width: 240px; width: 180px;
min-width: 180px; min-width: 140px;
} }
.etmf-filter-item :deep(.el-select) { .etmf-filter-item :deep(.el-select) {
width: 100%; width: 100%;
} }
.etmf-status-strip { /* 内联状态徽章条 */
display: grid; .etmf-status-inline {
grid-template-columns: repeat(4, minmax(120px, 1fr)); display: flex;
gap: 10px; align-items: center;
gap: 8px;
flex: 1;
justify-content: center;
min-width: 0;
overflow: hidden;
padding: 0 8px;
} }
.etmf-status-card { .etmf-status-badge {
min-height: 64px; display: inline-flex;
display: grid; align-items: center;
align-content: center; gap: 8px;
gap: 2px; padding: 5px 14px 5px 10px;
padding: 10px 14px;
border: 1px solid #e4eaf2;
border-radius: 8px; border-radius: 8px;
background: #fff; background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03); border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.07);
white-space: nowrap;
position: relative;
overflow: hidden;
transition: box-shadow 0.15s;
} }
.status-card-value { /* 左侧彩色竖线 */
font-size: 22px; .etmf-status-badge::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
border-radius: 8px 0 0 8px;
background: #c8d8ef;
}
.etmf-status-badge:hover {
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
}
.status-badge-value {
font-size: 17px;
font-weight: 900;
color: #1e2a3a;
line-height: 1; line-height: 1;
font-weight: 750; letter-spacing: -0.03em;
color: #172033; min-width: 1ch;
text-align: right;
} }
.status-card-label { .status-badge-label {
font-size: 12px; font-size: 11px;
color: #68758a; font-weight: 600;
color: #8a9ab0;
letter-spacing: 0.01em;
line-height: 1.2;
} }
.etmf-status-card--missing .status-card-value { /* 目录 — 蓝色 */
color: #c24141; .etmf-status-badge--nodes::before { background: #4f7ecf; }
} .etmf-status-badge--nodes .status-badge-value { color: #1e3a6e; }
.etmf-status-card--uploaded .status-card-value { /* 缺失 — 红色 */
color: #b7791f; .etmf-status-badge--missing {
background: linear-gradient(90deg, #fff8f8 0%, #fff 50%);
border-color: rgba(224, 82, 82, 0.2);
} }
.etmf-status-badge--missing::before { background: #e05252; }
.etmf-status-badge--missing .status-badge-value { color: #c03434; }
.etmf-status-badge--missing .status-badge-label { color: #c06060; }
.etmf-status-card--effective .status-card-value { /* 已上传 — 琥珀色 */
color: #24764b; .etmf-status-badge--uploaded {
background: linear-gradient(90deg, #fffbf2 0%, #fff 50%);
border-color: rgba(212, 146, 10, 0.2);
} }
.etmf-status-badge--uploaded::before { background: #d4920a; }
.etmf-status-badge--uploaded .status-badge-value { color: #a06b06; }
.etmf-status-badge--uploaded .status-badge-label { color: #b08030; }
/* 已生效 — 绿色 */
.etmf-status-badge--effective {
background: linear-gradient(90deg, #f4fdf8 0%, #fff 50%);
border-color: rgba(45, 172, 110, 0.2);
}
.etmf-status-badge--effective::before { background: #2dac6e; }
.etmf-status-badge--effective .status-badge-value { color: #1a7a4e; }
.etmf-status-badge--effective .status-badge-label { color: #4a9a70; }
/* 旧状态卡片已替换为内联徽章,保留空占位避免其他引用报错 */
.etmf-workspace { .etmf-workspace {
display: grid; display: grid;
grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px); grid-template-columns: 280px minmax(0, 1fr);
min-height: calc(100vh - 260px); min-height: 0;
flex: 1;
border-top: 0; border-top: 0;
background: #fff; background: #fff;
overflow: hidden;
} }
.etmf-tree-panel, .etmf-tree-panel,
.etmf-document-panel, .etmf-document-panel {
.etmf-node-detail {
min-width: 0; min-width: 0;
padding: 16px; min-height: 0;
padding: 12px 14px;
overflow-y: auto;
} }
.etmf-tree-panel { .etmf-tree-panel {
border-right: 1px solid var(--ctms-border-light); border-right: 1px solid #e4e8ef;
background: linear-gradient(180deg, #fbfcfe 0%, #f7f9fc 100%); background: linear-gradient(180deg, #f9fbff 0%, #f4f7fd 100%);
} }
.etmf-document-panel { .etmf-document-panel {
border-right: 1px solid var(--ctms-border-light);
background: #fff; background: #fff;
overflow-x: hidden;
} }
.panel-heading { .panel-heading {
min-height: 48px;
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 10px;
margin-bottom: 12px; margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(79, 126, 207, 0.08);
} }
.panel-heading--compact { .panel-heading--compact {
min-height: 38px; margin-bottom: 8px;
margin-bottom: 10px; }
.panel-heading-left {
display: flex;
align-items: baseline;
gap: 0;
min-width: 0;
overflow: hidden;
} }
.panel-title { .panel-title {
font-size: 15px; font-size: 13px;
font-weight: 700; font-weight: 700;
color: var(--ctms-text-main); color: var(--ctms-text-main);
letter-spacing: -0.01em;
} }
.panel-subtitle { .panel-subtitle {
margin-top: 4px; font-size: 11px;
font-size: 12px;
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
font-weight: 500;
margin-left: 6px;
} }
.etmf-tree { .etmf-tree {
--el-tree-node-hover-bg-color: #eef4ff; --el-tree-node-hover-bg-color: rgba(79, 126, 207, 0.07);
background: transparent; background: transparent;
padding-top: 2px; padding-top: 2px;
} }
.etmf-tree :deep(.el-tree-node__content) { .etmf-tree :deep(.el-tree-node__content) {
height: 34px; height: 30px;
border-radius: 7px; border-radius: 6px;
margin: 2px 0; margin: 1px 0;
transition: background 0.15s ease;
} }
.etmf-tree :deep(.is-current > .el-tree-node__content) { .etmf-tree :deep(.is-current > .el-tree-node__content) {
background: #eaf2ff; background: linear-gradient(90deg, rgba(79, 126, 207, 0.12) 0%, rgba(79, 126, 207, 0.04) 100%);
box-shadow: inset 3px 0 0 #4f7ecf; box-shadow: inset 3px 0 0 #4f7ecf;
} }
@@ -674,16 +740,21 @@ onMounted(load);
width: 100%; width: 100%;
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-columns: max-content minmax(0, 1fr) 28px max-content; grid-template-columns: max-content minmax(0, 1fr) 22px max-content;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
padding-right: 8px; padding-right: 6px;
} }
.tree-node-code { .tree-node-code {
font-size: 12px; font-size: 10px;
font-weight: 700; font-weight: 800;
color: #315f9f; color: #4f7ecf;
background: rgba(79, 126, 207, 0.1);
padding: 1px 5px;
border-radius: 3px;
letter-spacing: 0.02em;
flex-shrink: 0;
} }
.tree-node-name { .tree-node-name {
@@ -691,159 +762,228 @@ onMounted(load);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: var(--ctms-text-main); color: var(--ctms-text-main);
font-size: 12px;
} }
.tree-node-count { .tree-node-count {
height: 20px; height: 18px;
min-width: 22px; min-width: 20px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 999px; border-radius: 999px;
background: #edf2f7; background: linear-gradient(135deg, #e8edf5, #dce4f0);
color: #526174; color: #526174;
font-size: 12px; font-size: 10px;
font-weight: 650; font-weight: 700;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
} }
.document-panel-meta { .document-panel-meta {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: nowrap;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 6px;
font-size: 12px; font-size: 11px;
font-weight: 500;
color: #69778d; color: #69778d;
flex-shrink: 0;
}
.document-status-summary {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 10px;
padding: 8px 12px;
border: 1px solid rgba(79, 126, 207, 0.1);
border-radius: 8px;
background: linear-gradient(135deg, #f9fbff 0%, #f3f7fd 100%);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
}
.status-summary-head {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.status-summary-label {
color: var(--ctms-text-main);
font-size: 12px;
font-weight: 700;
}
.status-summary-note {
font-size: 11px;
color: var(--ctms-text-secondary);
line-height: 1.4;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-summary-note--effective { color: #1a5c3a; }
.status-summary-note--missing { color: #7a2020; }
.status-summary-note--uploaded { color: #7a4e0a; }
.status-summary-list {
display: flex;
gap: 6px;
margin: 0;
flex-shrink: 0;
}
.status-summary-list div {
display: flex;
align-items: baseline;
gap: 3px;
min-width: 0;
padding: 3px 8px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(79, 126, 207, 0.1);
}
.status-summary-list dt {
color: var(--ctms-text-secondary);
font-size: 10px;
font-weight: 600;
order: 2;
}
.status-summary-list dd {
min-width: 0;
margin: 0;
color: var(--ctms-text-main);
font-size: 13px;
font-weight: 800;
order: 1;
} }
.etmf-document-table { .etmf-document-table {
width: 100%; width: 100%;
} }
.node-meta { /* 文档表格行悬停效果 */
display: grid; .etmf-document-table :deep(.el-table__body tr:hover > td) {
gap: 12px; background: rgba(79, 126, 207, 0.04) !important;
margin: 16px 0; cursor: pointer;
} }
.node-meta div { /* 表格头部紧凑化 */
display: grid; .etmf-document-table :deep(th.el-table__cell) {
grid-template-columns: 96px minmax(0, 1fr); padding: 8px 12px;
gap: 10px; font-size: 11px;
font-weight: 700;
color: #526174;
background: #f8fafc;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
} }
.node-meta dt { .etmf-document-table :deep(td.el-table__cell) {
color: var(--ctms-text-secondary); padding: 8px 12px;
}
.node-meta dd {
min-width: 0;
margin: 0;
font-weight: 600;
color: var(--ctms-text-main);
overflow-wrap: anywhere;
}
.node-status-note {
border-left: 3px solid #8aa0bd;
background: #f6f8fb;
padding: 10px 12px;
font-size: 13px; font-size: 13px;
line-height: 1.5;
color: var(--ctms-text-main);
} }
.node-status-note--effective { /* 隐藏表格底部分隔线(el-table inner-wrapper 的 ::before 伪元素) */
border-left-color: #2f8f5b; .etmf-document-table :deep(.el-table__inner-wrapper::before) {
display: none;
} }
.node-status-note--missing { /* 隐藏横向滚动条 */
border-left-color: #c84646; .etmf-document-table :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
} }
.node-status-note--uploaded { .etmf-document-table :deep(.el-scrollbar__wrap) {
border-left-color: #c58b20; overflow-x: hidden;
} }
.etmf-empty-block { .etmf-empty-block {
min-height: 168px; min-height: 120px;
display: grid; display: grid;
place-items: center; place-items: center;
align-content: center; align-content: center;
gap: 8px; gap: 8px;
padding: 22px; padding: 20px;
text-align: center; text-align: center;
color: #7b8797; color: #7b8797;
} }
.etmf-empty-block--tree { .etmf-empty-block--tree {
min-height: 280px; min-height: 200px;
border: 1px dashed #d8e0eb; border-radius: 10px;
border-radius: 8px; background: linear-gradient(135deg, #f4f7fd 0%, #eef3fb 100%);
background: rgba(255, 255, 255, 0.68);
} }
.etmf-empty-block--documents { .etmf-empty-block--documents {
min-height: 420px; min-height: 300px;
}
.etmf-empty-block--detail {
min-height: 280px;
} }
.empty-icon { .empty-icon {
width: 44px; width: 40px;
height: 44px; height: 40px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 10px; border-radius: 10px;
background: #eef3f9; background: linear-gradient(135deg, #e8f0fc, #dce8f8);
color: #6b7c91; color: #4f7ecf;
font-size: 22px; font-size: 18px;
box-shadow: 0 3px 8px rgba(79, 126, 207, 0.15);
} }
.empty-title { .empty-title {
font-size: 15px; font-size: 13px;
font-weight: 700; font-weight: 700;
color: #2f3a4c; color: #2a3550;
letter-spacing: -0.01em;
} }
.empty-desc { .empty-desc {
max-width: 320px; max-width: 280px;
font-size: 13px; font-size: 12px;
line-height: 1.55; line-height: 1.5;
color: #7b8797; color: #7b8797;
} }
@media (max-width: 1180px) { @media (max-width: 900px) {
.etmf-toolbar { .etmf-toolbar {
align-items: stretch; flex-wrap: wrap;
flex-direction: column;
} }
.etmf-toolbar-actions { .etmf-status-inline {
justify-content: flex-start; display: none;
} }
.etmf-workspace { .etmf-workspace {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); grid-template-columns: 240px minmax(0, 1fr);
} }
.etmf-node-detail { .status-summary-list {
grid-column: 1 / -1; display: none;
border-top: 1px solid var(--ctms-border-light); }
.document-status-summary {
flex-wrap: wrap;
} }
} }
@media (max-width: 760px) { @media (max-width: 700px) {
.etmf-status-strip { .etmf-toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr)); flex-direction: column;
align-items: stretch;
} }
.etmf-toolbar-main { .etmf-toolbar-main {
flex-direction: column; flex-wrap: wrap;
align-items: stretch;
} }
.etmf-filter-item { .etmf-filter-item {
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipment.vue"), "utf8");
describe("MaterialEquipment desktop list workflow", () => {
it("keeps desktop equipment browsing in a master detail workflow", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="equipment-workbench"');
expect(source).toContain(":class=\"{ 'is-desktop': isDesktop }\"");
expect(source).toContain('class="equipment-preview-pane"');
expect(source).toContain('ref="equipmentTablePaneRef"');
expect(source).toContain("selectedEquipment");
expect(source).toContain('@row-click="handleEquipmentRowClick"');
expect(source).toContain('@row-dblclick="openEquipmentDetail"');
expect(source).toContain('@keydown.enter.prevent="openSelectedEquipment"');
expect(source).toContain("selectEquipment(row);");
expect(source).toContain("equipmentTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain('router.push({ name: "MaterialEquipmentDetail"');
expect(source).toContain('"row-selected"');
expect(source).not.toContain('@row-click="onRowClick"');
});
it("routes desktop equipment actions through preview and context menu controls", () => {
const source = readSource();
expect(source).toContain('@row-contextmenu="openEquipmentContextMenu"');
expect(source).toContain("equipmentContextMenu");
expect(source).toContain('class="equipment-context-menu"');
expect(source).toContain('@click="openSelectedEquipment"');
expect(source).toContain('@click="openSelectedEquipmentEditor"');
expect(source).toContain('@click="removeSelectedEquipment"');
expect(source).toContain('@click="copySelectedEquipmentName"');
expect(source).toContain("if (!isDesktop || !row?.id) return;");
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
});
});
+428 -34
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="page"> <div class="page" :class="{ 'equipment-page--desktop': isDesktop }">
<div v-if="study.currentStudy" class="page-inner"> <div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== --> <!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card"> <div class="table-card">
@@ -24,6 +24,13 @@
</el-button> </el-button>
</div> </div>
</div> </div>
<div class="equipment-workbench" :class="{ 'is-desktop': isDesktop }">
<div
ref="equipmentTablePaneRef"
class="equipment-table-pane"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedEquipment"
>
<el-table <el-table
v-loading="loading" v-loading="loading"
:data="rows" :data="rows"
@@ -31,7 +38,9 @@
style="width: 100%" style="width: 100%"
table-layout="fixed" table-layout="fixed"
:row-class-name="equipmentRowClass" :row-class-name="equipmentRowClass"
@row-click="onRowClick" @row-click="handleEquipmentRowClick"
@row-dblclick="openEquipmentDetail"
@row-contextmenu="openEquipmentContextMenu"
> >
<el-table-column prop="name" label="设备名称" show-overflow-tooltip> <el-table-column prop="name" label="设备名称" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
@@ -60,7 +69,7 @@
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="130" fixed="right"> <el-table-column v-if="!isDesktop && (canUpdate || canDelete)" label="操作" width="130" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="cell-actions"> <div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button> <el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button>
@@ -78,6 +87,56 @@
</template> </template>
</el-table> </el-table>
</div> </div>
<aside v-if="isDesktop" class="equipment-preview-pane">
<template v-if="selectedEquipment">
<div class="preview-head">
<div>
<div class="preview-kicker">当前设备</div>
<div class="preview-title">{{ selectedEquipment.name || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSelectedEquipment">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>规格型号</dt>
<dd>{{ selectedEquipment.specModel || TEXT.common.fallback }}</dd>
<dt>单位</dt>
<dd>{{ selectedEquipment.unit || TEXT.common.fallback }}</dd>
<dt>品牌</dt>
<dd>{{ selectedEquipment.brand || TEXT.common.fallback }}</dd>
<dt>产地</dt>
<dd>{{ selectedEquipment.origin || TEXT.common.fallback }}</dd>
<dt>校准</dt>
<dd>{{ selectedEquipment.needCalibration ? "需要校准" : "无需校准" }}</dd>
<dt>周期</dt>
<dd>{{ selectedEquipment.needCalibration ? `${selectedEquipment.calibrationCycleDays || TEXT.common.fallback}` : TEXT.common.fallback }}</dd>
</dl>
<div class="preview-actions">
<el-button v-if="canUpdate" size="small" @click="openSelectedEquipmentEditor">编辑</el-button>
<el-button v-if="canDelete" size="small" type="danger" plain @click="removeSelectedEquipment">删除</el-button>
</div>
</template>
<div v-else class="preview-empty">
<div class="empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>
</div>
<span>选择一行查看详情摘要</span>
</div>
</aside>
</div>
</div>
<div
v-if="isDesktop && equipmentContextMenu.visible && selectedEquipment"
class="equipment-context-menu"
:style="{ left: `${equipmentContextMenu.x}px`, top: `${equipmentContextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedEquipment">打开详情</button>
<button v-if="canUpdate" type="button" @click="openSelectedEquipmentEditor">编辑</button>
<button type="button" @click="copySelectedEquipmentName">复制设备名称</button>
<button v-if="canDelete" type="button" class="danger" @click="removeSelectedEquipment">删除</button>
</div>
</div> </div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" /> <StateEmpty v-else :description="TEXT.common.empty.selectProject" />
@@ -174,7 +233,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus"; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue"; import { Plus } from "@element-plus/icons-vue";
@@ -192,6 +251,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles"; import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard"; import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { isTauriRuntime } from "../../runtime";
interface EquipmentRow { interface EquipmentRow {
id: string; id: string;
@@ -209,12 +269,16 @@ type FormModel = Omit<EquipmentRow, "id">;
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
const isDesktop = isTauriRuntime();
const filters = reactive({ name: "" }); const filters = reactive({ name: "" });
const rows = ref<EquipmentRow[]>([]); const rows = ref<EquipmentRow[]>([]);
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const drawerVisible = ref(false); const drawerVisible = ref(false);
const editingId = ref(""); const editingId = ref("");
const selectedEquipmentId = ref("");
const equipmentContextMenu = ref({ visible: false, x: 0, y: 0 });
const equipmentTablePaneRef = ref<HTMLElement | null>(null);
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null); const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
@@ -239,6 +303,7 @@ const canCreate = computed(() => isAdmin.value || isApiPermissionAllowed(study.c
const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"])); const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"]));
const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:delete"])); const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:delete"]));
const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value)); const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value));
const selectedEquipment = computed(() => rows.value.find((item) => item.id === selectedEquipmentId.value) || null);
const rules: FormRules<FormModel> = { const rules: FormRules<FormModel> = {
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }], name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
@@ -326,11 +391,76 @@ const openEdit = (row: EquipmentRow) => {
drawerVisible.value = true; drawerVisible.value = true;
}; };
const equipmentRowClass = ({ row }: { row: EquipmentRow }) => (row?.id ? "clickable-row" : ""); const equipmentRowClass = ({ row }: { row: EquipmentRow }) =>
[
row?.id ? "clickable-row" : "",
isDesktop && row?.id === selectedEquipmentId.value ? "row-selected" : "",
]
.filter(Boolean)
.join(" ");
const onRowClick = (row: EquipmentRow) => { const selectEquipment = (row: EquipmentRow) => {
if (!row?.id) return; if (!row?.id) return;
router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: row.id } }); closeEquipmentContextMenu();
selectedEquipmentId.value = row.id;
equipmentTablePaneRef.value?.focus({ preventScroll: true });
};
const handleEquipmentRowClick = (row: EquipmentRow) => {
if (!row?.id) return;
if (isDesktop) {
selectEquipment(row);
return;
}
openEquipmentDetail(row);
};
const openEquipmentDetail = (row?: EquipmentRow | null) => {
const target = row?.id ? row : selectedEquipment.value;
if (target?.id) router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: target.id } });
};
const openSelectedEquipment = () => {
closeEquipmentContextMenu();
openEquipmentDetail(selectedEquipment.value);
};
const openSelectedEquipmentEditor = () => {
const target = selectedEquipment.value;
closeEquipmentContextMenu();
if (target) openEdit(target);
};
const removeSelectedEquipment = () => {
const target = selectedEquipment.value;
closeEquipmentContextMenu();
if (target) {
void removeRow(target);
}
};
const openEquipmentContextMenu = (row: EquipmentRow, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
selectEquipment(row);
equipmentContextMenu.value = {
visible: true,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 172)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 142)),
};
};
const closeEquipmentContextMenu = () => {
if (!equipmentContextMenu.value.visible) return;
equipmentContextMenu.value = { visible: false, x: 0, y: 0 };
};
const copySelectedEquipmentName = async () => {
const name = selectedEquipment.value?.name;
closeEquipmentContextMenu();
if (!name || !navigator.clipboard) return;
await navigator.clipboard.writeText(name);
ElMessage.success("设备名称已复制");
}; };
const saveForm = async () => { const saveForm = async () => {
@@ -405,10 +535,25 @@ watch(
filters.name = ""; filters.name = "";
loadRows(); loadRows();
drawerVisible.value = false; drawerVisible.value = false;
selectedEquipmentId.value = "";
}, },
{ immediate: true } { immediate: true }
); );
watch(
() => rows.value.map((item) => item.id).join("|"),
() => {
if (!isDesktop) return;
if (!rows.value.length) {
selectedEquipmentId.value = "";
return;
}
if (!rows.value.some((item) => item.id === selectedEquipmentId.value)) {
selectedEquipmentId.value = rows.value[0].id;
}
},
);
watch( watch(
() => form.needCalibration, () => form.needCalibration,
(need) => { (need) => {
@@ -416,6 +561,14 @@ watch(
if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30; if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30;
} }
); );
onMounted(() => {
if (isDesktop) document.addEventListener("click", closeEquipmentContextMenu);
});
onBeforeUnmount(() => {
if (isDesktop) document.removeEventListener("click", closeEquipmentContextMenu);
});
</script> </script>
<style scoped> <style scoped>
@@ -427,20 +580,35 @@ watch(
background: transparent; background: transparent;
} }
/* 桌面端:撑满父容器高度 */
.equipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner { .page-inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
/* 桌面端:page-inner 撑满 .page */
.equipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Table Toolbar ==================== */ /* ==================== Table Toolbar ==================== */
.table-card-toolbar { .table-card-toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 14px 20px; padding: 14px 20px;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid rgba(79, 126, 207, 0.1);
gap: 12px; gap: 12px;
background: linear-gradient(135deg, #f8faff 0%, #f2f6ff 100%);
} }
.toolbar-filters { .toolbar-filters {
@@ -466,16 +634,30 @@ watch(
.filter-label { .filter-label {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 700;
color: #8a8a8a; color: #7a8ca8;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.04em;
} }
.filter-input { .filter-input {
width: 200px; width: 200px;
} }
.filter-input :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(79, 126, 207, 0.2);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
transition: border-color 0.2s, box-shadow 0.2s;
}
.filter-input :deep(.el-input__wrapper:hover),
.filter-input :deep(.el-input__wrapper.is-focus) {
border-color: rgba(79, 126, 207, 0.5);
box-shadow: 0 0 0 3px rgba(79, 126, 207, 0.1);
}
.filter-actions { .filter-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -484,21 +666,35 @@ watch(
} }
.filter-btn { .filter-btn {
border-radius: 6px; border-radius: 8px;
} }
.filter-summary { .filter-summary {
display: inline-flex;
align-items: center;
padding: 2px 10px;
background: rgba(79, 126, 207, 0.08);
border: 1px solid rgba(79, 126, 207, 0.15);
border-radius: 20px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 700;
color: #a3a3a3; color: #4f7ecf;
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.01em;
} }
.create-btn { .create-btn {
height: 34px; height: 34px;
border-radius: 8px; border-radius: 8px;
padding: 0 16px; padding: 0 18px;
font-weight: 500; font-weight: 600;
box-shadow: 0 2px 6px rgba(64, 128, 220, 0.25);
transition: box-shadow 0.2s, transform 0.15s;
}
.create-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(64, 128, 220, 0.35);
} }
/* ==================== Table Card ==================== */ /* ==================== Table Card ==================== */
@@ -511,6 +707,58 @@ watch(
0 2px 8px rgba(0, 0, 0, 0.02); 0 2px 8px rgba(0, 0, 0, 0.02);
} }
/* 桌面端:table-card 填满、无圆角 */
.equipment-page--desktop .table-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
box-shadow: none;
}
.equipment-workbench {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.equipment-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 304px;
/* 桌面端:不限定固定高度,由父容器 flex 撑满 */
flex: 1;
min-height: 0;
}
.equipment-table-pane {
min-width: 0;
outline: none;
}
/* 禁用表格横向滚动:阻断滚动行为 + 隐藏滚动条元素 */
.equipment-table-pane :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.equipment-table-pane :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.equipment-workbench.is-desktop .equipment-table-pane {
border-right: 1px solid #e3e9f1;
}
.equipment-preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 14px;
padding: 16px;
background: linear-gradient(180deg, #f5f8ff 0%, #eef3fb 100%);
border-left: 1px solid rgba(79, 126, 207, 0.1);
}
.equipment-table { .equipment-table {
--el-table-border-color: transparent; --el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb; --el-table-row-hover-bg-color: #f8f9fb;
@@ -543,6 +791,10 @@ watch(
transition: background 0.1s ease; transition: background 0.1s ease;
} }
.equipment-table :deep(.el-table__body tr.row-selected > td) {
background: #edf5ff !important;
}
/* ==================== Cell Helpers ==================== */ /* ==================== Cell Helpers ==================== */
.cell-nowrap { .cell-nowrap {
white-space: nowrap; white-space: nowrap;
@@ -573,21 +825,151 @@ watch(
.status-pill { .status-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 2px 12px; padding: 2px 10px;
font-size: 12px; font-size: 11px;
font-weight: 700; font-weight: 700;
border-radius: 20px; border-radius: 20px;
line-height: 1.6; line-height: 1.7;
letter-spacing: 0.02em;
} }
.status-pill--yes { .status-pill--yes {
background: #dcfce7; background: linear-gradient(135deg, #d1fae5, #a7f3d0);
color: #16a34a; color: #065f46;
border: 1px solid rgba(16, 185, 129, 0.2);
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.15);
} }
.status-pill--no { .status-pill--no {
background: #f5f5f5; background: #f1f5f9;
color: #737373; color: #64748b;
border: 1px solid rgba(100, 116, 139, 0.15);
}
/* ==================== Desktop Preview ==================== */
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(79, 126, 207, 0.12);
}
.preview-kicker {
display: inline-flex;
align-items: center;
gap: 5px;
color: #4f7ecf;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
background: rgba(79, 126, 207, 0.1);
padding: 2px 8px;
border-radius: 20px;
}
.preview-title {
margin-top: 8px;
color: #0f172a;
font-size: 17px;
font-weight: 800;
word-break: break-word;
letter-spacing: -0.01em;
line-height: 1.3;
}
.preview-list {
display: block;
margin: 0;
border: 1px solid rgba(79, 126, 207, 0.1);
border-radius: 10px;
overflow: hidden;
background: #fff;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05);
}
/* dt/dd float 布局:dt 左浮动作为标签列,dd 跟随在右侧 */
.preview-list dt {
float: left;
clear: left;
width: 64px;
padding: 9px 6px 9px 14px;
color: #7a8ca8;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1.5;
}
.preview-list dd {
margin-left: 0;
padding: 9px 14px 9px 78px;
border-bottom: 1px solid rgba(79, 126, 207, 0.07);
color: #1a2540;
font-size: 13px;
font-weight: 700;
overflow-wrap: anywhere;
min-width: 0;
}
.preview-list dd:last-child {
border-bottom: none;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
padding-top: 4px;
}
.preview-empty {
min-height: 320px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #94a3b8;
font-size: 13px;
text-align: center;
}
.equipment-context-menu {
position: fixed;
z-index: 3000;
min-width: 152px;
padding: 5px;
border: 1px solid #d7e2f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
}
.equipment-context-menu button {
display: block;
width: 100%;
min-height: 30px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #0f172a;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.equipment-context-menu button:hover:not(:disabled) {
background: #eef4ff;
}
.equipment-context-menu button.danger {
color: #dc2626;
} }
/* ==================== Empty State ==================== */ /* ==================== Empty State ==================== */
@@ -597,30 +979,33 @@ watch(
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 12px;
} }
.empty-icon { .empty-icon {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 48px; width: 52px;
height: 48px; height: 52px;
border-radius: 50%; border-radius: 14px;
background: #f5f5f5; background: linear-gradient(135deg, #e8f0fc, #dce8f8);
color: #c4c4c4; color: #4f7ecf;
box-shadow: 0 4px 12px rgba(79, 126, 207, 0.15);
} }
.table-empty span { .table-empty span {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 600;
color: #a3a3a3; color: #8aa0bd;
} }
/* ==================== Drawer Editor ==================== */ /* ==================== Drawer Editor ==================== */
:deep(.equipment-editor-drawer > .el-drawer__header) { :deep(.equipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0; margin-bottom: 0;
padding: 20px 24px 8px; padding: 20px 24px 16px;
background: linear-gradient(135deg, #f8faff 0%, #f0f5ff 100%);
border-bottom: 1px solid rgba(79, 126, 207, 0.1);
} }
:deep(.equipment-editor-drawer > .el-drawer__body) { :deep(.equipment-editor-drawer > .el-drawer__body) {
@@ -634,10 +1019,10 @@ watch(
.editor-title { .editor-title {
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 800;
color: #0a0a0a; color: #0a0a0a;
line-height: 1.2; line-height: 1.2;
letter-spacing: -0.01em; letter-spacing: -0.02em;
} }
.equipment-form { .equipment-form {
@@ -746,6 +1131,15 @@ watch(
/* ==================== Responsive ==================== */ /* ==================== Responsive ==================== */
@media (max-width: 960px) { @media (max-width: 960px) {
.equipment-workbench.is-desktop {
grid-template-columns: 1fr;
}
.equipment-workbench.is-desktop .equipment-table-pane {
border-right: 0;
}
.equipment-preview-pane {
display: none;
}
.field-grid--2, .field-grid--2,
.field-grid--3 { .field-grid--3 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -462,6 +462,32 @@ watch(
.page { .page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 撑满父容器(desktop-route-shell 已经是 height: 100%*/
height: 100%;
min-height: 0;
}
/* 让白色卡片壳撑满 .page 的剩余高度,并去掉圆角 */
.page > .unified-shell {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
}
/* 让表格 section 撑满卡片壳的剩余高度 */
.page > .unified-shell > .unified-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* el-table 本身填满 section */
.page > .unified-shell > .unified-section :deep(.el-table),
.page > .unified-shell > .unified-section :deep(.el-table__inner-wrapper) {
height: 100%;
} }
.ctms-page-content-grid { .ctms-page-content-grid {
@@ -689,4 +715,5 @@ watch(
font-weight: 500; font-weight: 500;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
</style> </style>
+599 -5
View File
@@ -1,8 +1,48 @@
<template> <template>
<div class="page ctms-page-shell page--flush"> <div class="page ctms-page-shell page--flush" :class="{ 'project-overview--desktop': isDesktop }">
<div v-if="study.currentStudy" class="page-body"> <div v-if="study.currentStudy" class="page-body">
<div class="overview-container"> <div class="overview-container">
<section class="overview-card"> <section v-if="isDesktop" class="desktop-attention-section">
<div class="desktop-attention-head">
<span>项目关注</span>
<span>更新 {{ overviewUpdatedAtLabel }}</span>
</div>
<div class="desktop-attention-board">
<div class="attention-card attention-card--stages">
<div class="attention-title">阶段状态</div>
<div class="stage-status-grid">
<div v-for="item in stageStatusSummary" :key="item.key" class="stage-status-item">
<span class="legend-dot" :class="item.dotClass"></span>
<span>{{ item.label }}</span>
<strong>{{ item.count }}</strong>
</div>
</div>
</div>
<div class="attention-card">
<div class="attention-title">当前推进</div>
<div v-if="activeStageItems.length" class="attention-list">
<div v-for="item in activeStageItems" :key="item.key" class="attention-list-row">
<strong>{{ item.centerName }}</strong>
<span>{{ item.stageLabel }}</span>
</div>
</div>
<div v-else class="desktop-empty-note">暂无进行中阶段</div>
</div>
<div class="attention-card">
<div class="attention-title">需关注</div>
<div class="attention-list">
<div v-for="item in attentionItems" :key="item" class="attention-list-row attention-list-row--plain">
<span>{{ item }}</span>
</div>
</div>
</div>
</div>
</section>
<div class="overview-workbench">
<section class="overview-card overview-card--progress">
<div class="card-header"> <div class="card-header">
<div class="card-header-left"> <div class="card-header-left">
<span class="card-icon card-icon--progress"> <span class="card-icon card-icon--progress">
@@ -44,7 +84,7 @@
</div> </div>
</section> </section>
<section class="overview-card"> <section class="overview-card overview-card--enrollment">
<div class="card-header"> <div class="card-header">
<div class="card-header-left"> <div class="card-header-left">
<span class="card-icon card-icon--enrollment"> <span class="card-icon card-icon--enrollment">
@@ -55,12 +95,46 @@
<div class="card-subtitle">{{ enrollmentSummary }}</div> <div class="card-subtitle">{{ enrollmentSummary }}</div>
</div> </div>
</div> </div>
<el-radio-group v-model="chartMode" size="small" class="mode-switch"> <el-radio-group v-if="!isDesktop" v-model="chartMode" size="small" class="mode-switch">
<el-radio-button label="center">按中心</el-radio-button> <el-radio-button label="center">按中心</el-radio-button>
<el-radio-button label="month">按月份</el-radio-button> <el-radio-button label="month">按月份</el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
<StateLoading v-if="isDesktop && loading" :rows="4" />
<div v-else-if="isDesktop" class="enrollment-snapshot">
<div class="enrollment-meter">
<div class="meter-head">
<span>达成率</span>
<strong>{{ enrollmentCompletionLabel }}</strong>
</div>
<div class="meter-track">
<span class="meter-fill" :style="{ width: enrollmentCompletionWidth }"></span>
</div>
<div class="meter-foot">
<span>已入组 {{ overview?.summary.total_actual || 0 }}</span>
<span>目标 {{ overview?.summary.total_target || 0 }}</span>
</div>
</div>
<div class="enrollment-center-list">
<div v-for="row in enrollmentCenterRows" :key="row.key" class="enrollment-center-row">
<div class="enrollment-center-meta">
<span>{{ row.label }}</span>
<strong>{{ row.actual }} / {{ row.target }}</strong>
</div>
<div class="mini-progress-track">
<span class="mini-progress-fill" :style="{ width: row.percentWidth }"></span>
</div>
</div>
<div v-if="enrollmentCenterRows.length === 0" class="desktop-empty-note">
暂无中心入组数据
</div>
</div>
</div>
<EnrollmentBarChart <EnrollmentBarChart
v-else
:mode="chartMode" :mode="chartMode"
:items="chartItems" :items="chartItems"
:loading="loading" :loading="loading"
@@ -69,6 +143,7 @@
</section> </section>
</div> </div>
</div> </div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" /> <StateEmpty v-else :description="TEXT.common.empty.selectProject" />
</div> </div>
</template> </template>
@@ -80,18 +155,127 @@ import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { fetchProjectOverview } from "../../api/overview"; import { fetchProjectOverview } from "../../api/overview";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { isTauriRuntime } from "../../runtime";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue"; import StateLoading from "../../components/StateLoading.vue";
import CenterProgressRow from "./project-overview/CenterProgressRow.vue"; import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue"; import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue";
import { adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter"; import { STAGE_ORDER, adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel, type StageStatus } from "./project-overview/overview.adapter";
const study = useStudyStore(); const study = useStudyStore();
const isDesktop = isTauriRuntime();
const loading = ref(false); const loading = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null); const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center"); const chartMode = ref<"center" | "month">("center");
const centers = computed(() => overview.value?.centers || []); const centers = computed(() => overview.value?.centers || []);
const enrollmentCompletionRate = computed(() => {
const summary = overview.value?.summary;
if (!summary?.total_target) return 0;
return Math.min(100, Math.round((summary.total_actual / summary.total_target) * 100));
});
const enrollmentCompletionLabel = computed(() => {
const summary = overview.value?.summary;
if (!summary?.total_target) return "未设目标";
return `${enrollmentCompletionRate.value}%`;
});
const enrollmentCompletionWidth = computed(() => `${enrollmentCompletionRate.value}%`);
const overviewUpdatedAtLabel = computed(() => {
if (!overview.value?.updated_at) return "-";
const date = new Date(overview.value.updated_at);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
});
const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成",
IN_PROGRESS: "进行中",
NOT_STARTED: "未开始",
BLOCKED: "阻塞/延期",
};
const statusDotClassMap: Record<StageStatus, string> = {
COMPLETED: "completed",
IN_PROGRESS: "active",
NOT_STARTED: "pending",
BLOCKED: "blocked",
};
const stageStatusCounts = computed<Record<StageStatus, number>>(() => {
const counts: Record<StageStatus, number> = {
COMPLETED: 0,
IN_PROGRESS: 0,
NOT_STARTED: 0,
BLOCKED: 0,
};
centers.value.forEach((center) => {
STAGE_ORDER.forEach((stage) => {
counts[center[stage.key] || "NOT_STARTED"] += 1;
});
});
return counts;
});
const stageStatusSummary = computed(() =>
(["COMPLETED", "IN_PROGRESS", "NOT_STARTED", "BLOCKED"] as StageStatus[]).map((status) => ({
key: status,
label: statusLabelMap[status],
count: stageStatusCounts.value[status],
dotClass: statusDotClassMap[status],
}))
);
const activeStageItems = computed(() =>
centers.value
.flatMap((center, centerIndex) =>
STAGE_ORDER
.filter((stage) => center[stage.key] === "IN_PROGRESS")
.map((stage) => ({
key: `${center.center_id || centerIndex}-${stage.key}`,
centerName: center.center_name || TEXT.common.fallback,
stageLabel: stage.label,
}))
)
.slice(0, 4)
);
const attentionItems = computed(() => {
const items: string[] = [];
const targetlessCenters = centers.value.filter((center) => !center.enrollment_target).length;
const inactiveCenters = centers.value.filter((center) => center.is_active === false).length;
if (stageStatusCounts.value.BLOCKED > 0) {
items.push(`${stageStatusCounts.value.BLOCKED} 个阶段阻塞/延期`);
}
if (targetlessCenters > 0) {
items.push(`${targetlessCenters} 个中心未设置入组目标`);
}
if (overview.value && overview.value.months.length === 0) {
items.push("暂无月度入组趋势数据");
}
if (inactiveCenters > 0) {
items.push(`${inactiveCenters} 个中心已停用`);
}
if (items.length === 0) {
items.push("暂无需要额外关注的总览风险");
}
return items.slice(0, 4);
});
const enrollmentCenterRows = computed(() =>
centers.value.slice(0, 4).map((center, index) => {
const target = center.enrollment_target || 0;
const actual = center.enrollment_actual || 0;
const percent = target > 0 ? Math.min(100, Math.round((actual / target) * 100)) : 0;
return {
key: center.center_id || center.center_name || `center-${index}`,
label: center.center_name || TEXT.common.fallback,
actual,
target,
percentWidth: `${percent}%`,
};
})
);
const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] => const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] =>
siteList.map((site: any) => ({ siteList.map((site: any) => ({
@@ -218,6 +402,12 @@ watch(
gap: 10px; gap: 10px;
} }
.overview-workbench {
display: flex;
flex-direction: column;
gap: 10px;
}
.card-header-right { .card-header-right {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -389,6 +579,401 @@ watch(
border-radius: 8px; border-radius: 8px;
} }
.project-overview--desktop {
min-height: 0;
}
.project-overview--desktop .page-body {
min-height: 0;
gap: 8px;
}
.project-overview--desktop .overview-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 0;
gap: 10px;
}
.project-overview--desktop .overview-workbench {
display: grid;
grid-template-columns: minmax(620px, 1fr) minmax(300px, 360px);
align-items: stretch;
overflow: hidden;
border: 1px solid #d9e2ec;
border-radius: 6px;
background: #ffffff;
}
.project-overview--desktop .overview-card {
min-width: 0;
border: 0;
border-radius: 0;
padding: 12px 14px;
background: transparent;
box-shadow: none;
}
.project-overview--desktop .overview-card:hover {
box-shadow: none;
}
.project-overview--desktop .overview-card--enrollment {
border-left: 1px solid #d9e2ec;
}
.project-overview--desktop .card-header {
margin-bottom: 8px;
}
.project-overview--desktop .card-header-left {
gap: 8px;
}
.project-overview--desktop .card-icon {
width: 24px;
height: 24px;
border-radius: 5px;
}
.project-overview--desktop .card-title {
font-size: 13px;
}
.project-overview--desktop .card-subtitle,
.project-overview--desktop .progress-legend {
font-size: 11px;
}
.project-overview--desktop .progress-legend {
gap: 8px;
}
.project-overview--desktop .legend-dot {
width: 8px;
height: 8px;
}
.enrollment-snapshot {
display: flex;
flex-direction: column;
gap: 14px;
}
.enrollment-meter {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-radius: 6px;
background: #f8fafc;
}
.meter-head,
.meter-foot,
.enrollment-center-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.meter-head span,
.meter-foot,
.enrollment-center-meta span {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.meter-head strong {
color: #15344f;
font-size: 18px;
line-height: 1;
}
.meter-track,
.mini-progress-track {
overflow: hidden;
height: 6px;
border-radius: 999px;
background: #e8eef5;
}
.meter-fill,
.mini-progress-fill {
display: block;
height: 100%;
border-radius: inherit;
background: #3f5d75;
}
.enrollment-center-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.enrollment-center-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.enrollment-center-meta strong {
color: #1f2f45;
font-size: 12px;
}
.project-overview--desktop .progress-list {
max-height: min(360px, calc(100vh - 300px));
overflow: auto;
padding-right: 2px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.project-overview--desktop .progress-list::-webkit-scrollbar {
width: 8px;
}
.project-overview--desktop .progress-list::-webkit-scrollbar-track {
background: transparent;
}
.project-overview--desktop .progress-list::-webkit-scrollbar-thumb {
border: 2px solid #ffffff;
border-radius: 999px;
background: #c8d4e1;
}
.project-overview--desktop :deep(.center-row) {
grid-template-columns: minmax(126px, 156px) minmax(0, 1fr);
gap: 8px;
padding: 6px;
border-radius: 5px;
}
.project-overview--desktop :deep(.center-name) {
font-size: 13px;
}
.project-overview--desktop :deep(.center-enrollment) {
font-size: 11px;
}
.project-overview--desktop :deep(.center-timeline) {
padding: 4px 0 0;
}
.project-overview--desktop :deep(.timeline-segment) {
gap: 6px;
}
.project-overview--desktop :deep(.stage-node) {
min-width: 52px;
gap: 4px;
}
.project-overview--desktop :deep(.stage-dot) {
width: 13px;
height: 13px;
}
.project-overview--desktop :deep(.stage-in_progress .stage-dot) {
width: 16px;
height: 16px;
}
.project-overview--desktop :deep(.stage-label) {
max-width: 62px;
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
}
.project-overview--desktop :deep(.stage-in_progress .stage-label) {
padding: 1px 6px;
}
.project-overview--desktop :deep(.stage-connector) {
min-width: 30px;
}
.project-overview--desktop :deep(.chart-scroll) {
overflow-x: auto;
overflow-y: hidden;
}
.project-overview--desktop :deep(.chart-plot) {
border-radius: 6px;
background: #f8fafc;
}
.project-overview--desktop :deep(.chart-empty-shell) {
min-height: 112px;
border-radius: 6px;
padding: 14px;
}
.project-overview--desktop .overview-empty-panel {
min-height: 104px;
padding: 12px;
border-radius: 6px;
}
.desktop-attention-board {
display: grid;
grid-template-columns: minmax(220px, 0.75fr) minmax(260px, 1fr) minmax(260px, 1fr);
gap: 10px;
}
.desktop-attention-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.desktop-attention-head {
display: flex;
min-height: 18px;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #64748b;
font-size: 11px;
font-weight: 800;
}
.attention-card {
min-width: 0;
min-height: 126px;
padding: 12px 14px;
border: 1px solid #d9e2ec;
border-radius: 6px;
background: #ffffff;
}
.attention-title {
margin-bottom: 10px;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.stage-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.stage-status-item,
.attention-list-row {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 0 8px;
border-radius: 6px;
background: #f8fafc;
}
.stage-status-item span:not(.legend-dot),
.attention-list-row span {
min-width: 0;
overflow: hidden;
color: #53677f;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage-status-item strong {
margin-left: auto;
color: #15253a;
font-size: 13px;
}
.attention-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attention-list-row strong {
min-width: 0;
overflow: hidden;
color: #15253a;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.attention-list-row--plain {
align-items: flex-start;
padding-top: 6px;
padding-bottom: 6px;
}
.attention-list-row--plain span {
white-space: normal;
}
.desktop-empty-note {
min-height: 32px;
display: flex;
align-items: center;
color: #7b8da3;
font-size: 12px;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .overview-workbench),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-card) {
border-color: #26364a;
background: #172033;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .overview-card--enrollment) {
border-left-color: #26364a;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .desktop-attention-head) {
color: #e5edf7;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .enrollment-meter),
:global([data-ctms-theme="dark"] .project-overview--desktop .stage-status-item),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row) {
background: #111a2a;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-title),
:global([data-ctms-theme="dark"] .project-overview--desktop .stage-status-item strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .enrollment-center-meta strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .meter-head strong) {
color: #e5edf7;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .progress-list::-webkit-scrollbar-thumb) {
border-color: #172033;
background: #3b4b60;
}
@media (max-width: 1240px) {
.project-overview--desktop .overview-workbench,
.desktop-attention-board {
grid-template-columns: 1fr;
}
.project-overview--desktop .overview-card--enrollment {
border-top: 1px solid #d9e2ec;
border-left: 0;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.overview-container { .overview-container {
padding: 8px; padding: 8px;
@@ -406,5 +991,14 @@ watch(
.card-header-right { .card-header-right {
justify-content: flex-start; justify-content: flex-start;
} }
.project-overview--desktop .overview-container {
grid-template-columns: 1fr;
padding-top: 8px;
}
.project-overview--desktop :deep(.center-row) {
grid-template-columns: 1fr;
}
} }
</style> </style>
@@ -5,15 +5,17 @@ import { resolve } from "node:path";
const readSubjectManagementSource = () => readFileSync(resolve(__dirname, "./SubjectManagement.vue"), "utf8"); const readSubjectManagementSource = () => readFileSync(resolve(__dirname, "./SubjectManagement.vue"), "utf8");
describe("SubjectManagement project permissions", () => { describe("SubjectManagement project permissions", () => {
it("hides create and delete actions when the current role lacks backend operation permissions", () => { it("hides create, edit, and delete actions when the current role lacks backend operation permissions", () => {
const source = readSubjectManagementSource(); const source = readSubjectManagementSource();
expect(source).toContain("isSystemAdmin"); expect(source).toContain("isSystemAdmin");
expect(source).toContain("isApiPermissionAllowed"); expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("canUseApiPermission"); expect(source).toContain("canUseApiPermission");
expect(source).toContain('canUseApiPermission("subjects:create")'); expect(source).toContain('canUseApiPermission("subjects:create")');
expect(source).toContain('canUseApiPermission("subjects:update")');
expect(source).toContain('canUseApiPermission("subjects:delete")'); expect(source).toContain('canUseApiPermission("subjects:delete")');
expect(source).toContain('v-if="canCreateSubject"'); expect(source).toContain('v-if="canCreateSubject"');
expect(source).toContain('v-if="canUpdateSubject"');
expect(source).toContain('v-if="canDeleteSubject"'); expect(source).toContain('v-if="canDeleteSubject"');
}); });
}); });
@@ -24,6 +26,8 @@ describe("SubjectManagement drawer editor", () => {
expect(source).toContain("SubjectEditorDrawer"); expect(source).toContain("SubjectEditorDrawer");
expect(source).toContain("subjectDrawerVisible"); expect(source).toContain("subjectDrawerVisible");
expect(source).toContain("editingSubjectId");
expect(source).toContain(':subject-id="editingSubjectId || undefined"');
expect(source).not.toContain('router.push("/subjects/new")'); expect(source).not.toContain('router.push("/subjects/new")');
}); });
}); });
@@ -32,10 +36,49 @@ describe("SubjectManagement desktop list workflow", () => {
it("selects rows for preview and opens details on explicit desktop actions", () => { it("selects rows for preview and opens details on explicit desktop actions", () => {
const source = readSubjectManagementSource(); const source = readSubjectManagementSource();
expect(source).toContain("@row-click=\"selectSubject\""); expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="subject-workbench"');
expect(source).toContain(":class=\"{ 'is-desktop': isDesktop }\"");
expect(source).toContain('class="subject-preview-pane"');
expect(source).toContain('ref="subjectTablePaneRef"');
expect(source).toContain("selectedSubject");
expect(source).toContain("@row-click=\"handleSubjectRowClick\"");
expect(source).toContain("@row-dblclick=\"openSubjectDetail\""); expect(source).toContain("@row-dblclick=\"openSubjectDetail\"");
expect(source).toContain("@keydown.enter.prevent=\"openSelectedSubject\"");
expect(source).toContain("selectSubject(row);");
expect(source).toContain("subjectTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain("goDetail(row.id);");
expect(source).toContain("@row-contextmenu=\"openSubjectContextMenu\""); expect(source).toContain("@row-contextmenu=\"openSubjectContextMenu\"");
expect(source).toContain("if (!isDesktop || !row?.id) return;");
expect(source).toContain("subject-preview-pane"); expect(source).toContain("subject-preview-pane");
expect(source).toContain('class="preview-actions"');
expect(source).toContain("@click=\"openSelectedSubjectEditor\"");
expect(source).toContain("@click=\"removeSelectedSubject\"");
expect(source).not.toContain('<el-button size="small" @click="copySelectedSubjectNo">复制编号</el-button>');
expect(source).toContain("subject-context-menu"); expect(source).toContain("subject-context-menu");
expect(source).toContain('v-if="!isDesktop && canDeleteSubject"');
expect(source).toContain('"row-selected"');
});
it("does not render unused table selection controls", () => {
const source = readSubjectManagementSource();
expect(source).not.toContain('type="selection"');
expect(source).not.toContain("@selection-change");
expect(source).not.toContain("selectedRows");
expect(source).not.toContain("selection-count");
});
it("keeps desktop footer controls at the bottom of the workbench", () => {
const source = readSubjectManagementSource();
expect(source).toContain("subject-page--desktop");
expect(source).toContain(".subject-page--desktop .table-card");
expect(source).toContain(".subject-workbench.is-desktop");
expect(source).toContain(".subject-table-pane");
expect(source).toContain(".pagination-wrap");
expect(source).toContain(".preview-actions");
expect(source.match(/margin-top: auto;/g)?.length).toBeGreaterThanOrEqual(2);
}); });
}); });
+132 -38
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="page"> <div class="page" :class="{ 'subject-page--desktop': isDesktop }">
<div class="table-card"> <div class="table-card">
<div class="table-card-toolbar"> <div class="table-card-toolbar">
<div class="toolbar-filters"> <div class="toolbar-filters">
@@ -24,7 +24,6 @@
</div> </div>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<span v-if="selectedRows.length" class="selection-count">已选 {{ selectedRows.length }} </span>
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="create-btn"> <el-button v-if="canCreateSubject" type="primary" @click="goNew" class="create-btn">
<el-icon class="el-icon--left"><Plus /></el-icon> <el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }} {{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
@@ -32,8 +31,13 @@
</div> </div>
</div> </div>
<div class="subject-workbench"> <div class="subject-workbench" :class="{ 'is-desktop': isDesktop }">
<div class="subject-table-pane" tabindex="0" @keydown.enter.prevent="openSelectedSubject"> <div
class="subject-table-pane"
ref="subjectTablePaneRef"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedSubject"
>
<el-table <el-table
:data="pagedItems" :data="pagedItems"
v-loading="loading" v-loading="loading"
@@ -41,13 +45,11 @@
class="subject-table" class="subject-table"
:row-class-name="subjectRowClass" :row-class-name="subjectRowClass"
highlight-current-row highlight-current-row
@selection-change="onSelectionChange" @row-click="handleSubjectRowClick"
@row-click="selectSubject"
@row-dblclick="openSubjectDetail" @row-dblclick="openSubjectDetail"
@row-contextmenu="openSubjectContextMenu" @row-contextmenu="openSubjectContextMenu"
table-layout="fixed" table-layout="fixed"
> >
<el-table-column type="selection" width="42" />
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip> <el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
<div class="subject-info-cell"> <div class="subject-info-cell">
@@ -73,7 +75,7 @@
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate"> <el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template> <template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
</el-table-column> </el-table-column>
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right"> <el-table-column v-if="!isDesktop && canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<template #default="scope"> <template #default="scope">
<div class="cell-actions"> <div class="cell-actions">
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)"> <el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
@@ -104,7 +106,7 @@
</div> </div>
</div> </div>
<aside class="subject-preview-pane"> <aside v-if="isDesktop" class="subject-preview-pane">
<template v-if="selectedSubject"> <template v-if="selectedSubject">
<div class="preview-head"> <div class="preview-head">
<div> <div>
@@ -130,6 +132,26 @@
{{ badge.label }} {{ badge.label }}
</span> </span>
</div> </div>
<div class="preview-actions">
<el-button
v-if="canUpdateSubject"
size="small"
:disabled="isInactiveSite(selectedSubject.site_id)"
@click="openSelectedSubjectEditor"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteSubject"
size="small"
type="danger"
plain
:disabled="isInactiveSite(selectedSubject.site_id)"
@click="removeSelectedSubject"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template> </template>
<div v-else class="preview-empty"> <div v-else class="preview-empty">
<div class="empty-icon"> <div class="empty-icon">
@@ -142,7 +164,7 @@
</div> </div>
<div <div
v-if="contextMenu.visible" v-if="isDesktop && contextMenu.visible && selectedSubject"
class="subject-context-menu" class="subject-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }" :style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop @click.stop
@@ -154,13 +176,17 @@
type="button" type="button"
class="danger" class="danger"
:disabled="!selectedSubject || isInactiveSite(selectedSubject.site_id)" :disabled="!selectedSubject || isInactiveSite(selectedSubject.site_id)"
@click="selectedSubject && remove(selectedSubject)" @click="removeSelectedSubject"
> >
删除 删除
</button> </button>
</div> </div>
<SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" /> <SubjectEditorDrawer
v-model="subjectDrawerVisible"
:subject-id="editingSubjectId || undefined"
@success="handleSubjectEditorSuccess"
/>
</div> </div>
</template> </template>
@@ -179,15 +205,18 @@ import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue"; import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh"; import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { isTauriRuntime } from "../../runtime";
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const isDesktop = isTauriRuntime();
const loading = ref(false); const loading = ref(false);
const subjectDrawerVisible = ref(false); const subjectDrawerVisible = ref(false);
const editingSubjectId = ref("");
const subjectTablePaneRef = ref<HTMLElement | null>(null);
const items = ref<any[]>([]); const items = ref<any[]>([]);
const selectedSubjectId = ref(""); const selectedSubjectId = ref("");
const selectedRows = ref<any[]>([]);
const contextMenu = ref({ visible: false, x: 0, y: 0 }); const contextMenu = ref({ visible: false, x: 0, y: 0 });
const siteOptions = ref<Array<{ id: string; name: string }>>([]); const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const siteMap = ref<Record<string, string>>({}); const siteMap = ref<Record<string, string>>({});
@@ -215,6 +244,7 @@ const canUseApiPermission = (operationKey: string) => {
return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]); return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
}; };
const canCreateSubject = computed(() => canUseApiPermission("subjects:create")); const canCreateSubject = computed(() => canUseApiPermission("subjects:create"));
const canUpdateSubject = computed(() => canUseApiPermission("subjects:update"));
const canDeleteSubject = computed(() => canUseApiPermission("subjects:delete")); const canDeleteSubject = computed(() => canUseApiPermission("subjects:delete"));
const loadSites = async () => { const loadSites = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
@@ -251,14 +281,26 @@ const load = async () => {
} }
}; };
const goNew = () => { subjectDrawerVisible.value = true; }; const goNew = () => {
editingSubjectId.value = "";
subjectDrawerVisible.value = true;
};
const goDetail = (id: string) => router.push(`/subjects/${id}`); const goDetail = (id: string) => router.push(`/subjects/${id}`);
const handleSubjectEditorSuccess = () => { load(); }; const handleSubjectEditorSuccess = () => { load(); };
const selectedSubject = computed(() => items.value.find((item) => item.id === selectedSubjectId.value) || null); const selectedSubject = computed(() => items.value.find((item) => item.id === selectedSubjectId.value) || null);
const selectSubject = (row: any) => { const selectSubject = (row: any) => {
contextMenu.value.visible = false; closeSubjectContextMenu();
if (!row?.id) return; if (!row?.id) return;
selectedSubjectId.value = row.id; selectedSubjectId.value = row.id;
subjectTablePaneRef.value?.focus({ preventScroll: true });
};
const handleSubjectRowClick = (row: any) => {
if (!row?.id) return;
if (isDesktop) {
selectSubject(row);
return;
}
goDetail(row.id);
}; };
const openSubjectDetail = (row: any) => { const openSubjectDetail = (row: any) => {
if (!row?.id) return; if (!row?.id) return;
@@ -268,21 +310,19 @@ const openSelectedSubject = () => {
contextMenu.value.visible = false; contextMenu.value.visible = false;
if (selectedSubject.value?.id) goDetail(selectedSubject.value.id); if (selectedSubject.value?.id) goDetail(selectedSubject.value.id);
}; };
const onSelectionChange = (rows: any[]) => {
selectedRows.value = rows;
};
const openSubjectContextMenu = (row: any, _column: unknown, event: MouseEvent) => { const openSubjectContextMenu = (row: any, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault(); event.preventDefault();
if (!row?.id) return; selectSubject(row);
selectedSubjectId.value = row.id;
contextMenu.value = { contextMenu.value = {
visible: true, visible: true,
x: event.clientX, x: Math.max(8, Math.min(event.clientX, window.innerWidth - 152)),
y: event.clientY, y: Math.max(8, Math.min(event.clientY, window.innerHeight - 122)),
}; };
}; };
const closeSubjectContextMenu = () => { const closeSubjectContextMenu = () => {
contextMenu.value.visible = false; if (!contextMenu.value.visible) return;
contextMenu.value = { visible: false, x: 0, y: 0 };
}; };
const copySelectedSubjectNo = async () => { const copySelectedSubjectNo = async () => {
contextMenu.value.visible = false; contextMenu.value.visible = false;
@@ -291,6 +331,28 @@ const copySelectedSubjectNo = async () => {
await navigator.clipboard?.writeText(subjectNo); await navigator.clipboard?.writeText(subjectNo);
ElMessage.success("编号已复制"); ElMessage.success("编号已复制");
}; };
const openSelectedSubjectEditor = () => {
const target = selectedSubject.value;
closeSubjectContextMenu();
if (!target) return;
if (!canUpdateSubject.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(target.site_id)) {
ElMessage.warning("中心已停用");
return;
}
editingSubjectId.value = target.id;
subjectDrawerVisible.value = true;
};
const removeSelectedSubject = () => {
const target = selectedSubject.value;
closeSubjectContextMenu();
if (target) {
void remove(target);
}
};
const remove = async (row: any) => { const remove = async (row: any) => {
contextMenu.value.visible = false; contextMenu.value.visible = false;
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
@@ -349,6 +411,7 @@ watch(() => filteredItems.value.length, (total) => {
}); });
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; }); watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
watch(pagedItems, (rows) => { watch(pagedItems, (rows) => {
if (!isDesktop) return;
if (!rows.length) { if (!rows.length) {
selectedSubjectId.value = ""; selectedSubjectId.value = "";
return; return;
@@ -359,7 +422,7 @@ watch(pagedItems, (rows) => {
}); });
onMounted(async () => { onMounted(async () => {
document.addEventListener("click", closeSubjectContextMenu); if (isDesktop) document.addEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => { desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
loadSites(); loadSites();
load(); load();
@@ -369,7 +432,7 @@ onMounted(async () => {
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener("click", closeSubjectContextMenu); if (isDesktop) document.removeEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup?.(); desktopRefreshCleanup?.();
}); });
</script> </script>
@@ -383,25 +446,53 @@ onBeforeUnmount(() => {
background: #ffffff; background: #ffffff;
} }
.subject-page--desktop {
height: 100%;
min-height: 0;
}
.table-card { .table-card {
background: #ffffff; background: #ffffff;
overflow: hidden; overflow: hidden;
} }
.subject-page--desktop .table-card {
flex: 1;
display: flex;
min-height: 0;
flex-direction: column;
}
.subject-workbench { .subject-workbench {
min-width: 0;
min-height: 0;
}
.subject-workbench.is-desktop {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 300px; grid-template-columns: minmax(0, 1fr) 300px;
min-height: 520px; flex: 1;
min-height: 0;
} }
.subject-table-pane { .subject-table-pane {
display: flex;
flex-direction: column;
min-width: 0; min-width: 0;
border-right: 1px solid #edf1f7; min-height: 0;
outline: none; outline: none;
} }
.subject-workbench.is-desktop .subject-table-pane {
border-right: 1px solid #edf1f7;
}
.subject-preview-pane { .subject-preview-pane {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0; min-width: 0;
min-height: 0;
padding: 16px; padding: 16px;
background: #fbfcff; background: #fbfcff;
} }
@@ -431,13 +522,6 @@ onBeforeUnmount(() => {
flex-shrink: 0; flex-shrink: 0;
} }
.selection-count {
color: #64748b;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.filter-item { .filter-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -473,6 +557,8 @@ onBeforeUnmount(() => {
/* ==================== Table ==================== */ /* ==================== Table ==================== */
.subject-table { .subject-table {
flex: 1;
min-height: 0;
--el-table-border-color: transparent; --el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb; --el-table-row-hover-bg-color: #f8f9fb;
} }
@@ -546,7 +632,7 @@ onBeforeUnmount(() => {
display: grid; display: grid;
grid-template-columns: 72px minmax(0, 1fr); grid-template-columns: 72px minmax(0, 1fr);
gap: 10px 12px; gap: 10px 12px;
margin: 16px 0 0; margin: 0;
} }
.preview-list dt { .preview-list dt {
@@ -566,10 +652,17 @@ onBeforeUnmount(() => {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-top: 16px; }
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
} }
.preview-empty { .preview-empty {
flex: 1;
min-height: 360px; min-height: 360px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -651,6 +744,7 @@ onBeforeUnmount(() => {
.pagination-wrap { .pagination-wrap {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: auto;
padding: 12px 20px; padding: 12px 20px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
} }
@@ -680,8 +774,8 @@ onBeforeUnmount(() => {
/* ==================== Responsive ==================== */ /* ==================== Responsive ==================== */
@media (max-width: 960px) { @media (max-width: 960px) {
.subject-workbench { grid-template-columns: 1fr; } .subject-workbench.is-desktop { grid-template-columns: 1fr; }
.subject-table-pane { border-right: 0; } .subject-workbench.is-desktop .subject-table-pane { border-right: 0; }
.subject-preview-pane { display: none; } .subject-preview-pane { display: none; }
.toolbar-filters { flex-direction: column; align-items: stretch; } .toolbar-filters { flex-direction: column; align-items: stretch; }
.filter-input, .filter-select { width: 100%; } .filter-input, .filter-select { width: 100%; }
@@ -1,5 +1,5 @@
<template> <template>
<div class="enrollment-chart"> <div class="enrollment-chart" :class="{ 'enrollment-chart--compact': compact }">
<StateLoading v-if="loading" :rows="5" /> <StateLoading v-if="loading" :rows="5" />
<div v-else-if="items.length === 0" class="chart-empty-shell"> <div v-else-if="items.length === 0" class="chart-empty-shell">
<div class="chart-empty-body"> <div class="chart-empty-body">
@@ -9,7 +9,7 @@
</div> </div>
<div v-else class="chart-body"> <div v-else class="chart-body">
<div class="chart-scroll"> <div class="chart-scroll">
<div class="chart-plot"> <div class="chart-plot" :style="chartPlotStyle">
<svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet"> <svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
<defs> <defs>
<linearGradient :id="gradientTargetId" x1="0" y1="0" x2="0" y2="1"> <linearGradient :id="gradientTargetId" x1="0" y1="0" x2="0" y2="1">
@@ -105,23 +105,40 @@ const props = withDefaults(
items: EnrollmentBarItem[]; items: EnrollmentBarItem[];
loading?: boolean; loading?: boolean;
emptyText?: string; emptyText?: string;
compact?: boolean;
}>(), }>(),
{ {
loading: false, loading: false,
emptyText: "暂无入组数据", emptyText: "暂无入组数据",
compact: false,
} }
); );
const compact = computed(() => props.compact);
const showTarget = computed(() => props.mode === "center"); const showTarget = computed(() => props.mode === "center");
const chartWidth = 960; const baseChartWidth = computed(() => (compact.value ? 560 : 960));
const chartHeight = 260; const chartHeight = computed(() => (compact.value ? 240 : 260));
const axisPadding = { const axisPadding = computed(() => ({
top: 28, top: compact.value ? 24 : 28,
right: 32, right: compact.value ? 24 : 32,
bottom: 48, bottom: compact.value ? 42 : 48,
left: 56, left: compact.value ? 46 : 56,
}; }));
const chartWidth = computed(() => {
if (!compact.value) return baseChartWidth.value;
const itemWidth = showTarget.value ? 72 : 64;
return Math.max(
baseChartWidth.value,
props.items.length * itemWidth + axisPadding.value.left + axisPadding.value.right,
);
});
const chartPlotStyle = computed(() => ({
"--chart-min-width": `${chartWidth.value}px`,
"--chart-aspect": `${chartWidth.value} / ${chartHeight.value}`,
}));
const gradientTargetId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`; const gradientTargetId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`;
const gradientActualId = `enroll-actual-${Math.random().toString(36).slice(2, 8)}`; const gradientActualId = `enroll-actual-${Math.random().toString(36).slice(2, 8)}`;
@@ -146,36 +163,37 @@ const yTicks = computed(() => {
}); });
const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1)); const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1));
const plotWidth = computed(() => chartWidth - axisPadding.left - axisPadding.right); const plotWidth = computed(() => chartWidth.value - axisPadding.value.left - axisPadding.value.right);
const plotHeight = computed(() => chartHeight - axisPadding.top - axisPadding.bottom); const plotHeight = computed(() => chartHeight.value - axisPadding.value.top - axisPadding.value.bottom);
const axisLeft = axisPadding.left; const axisLeft = computed(() => axisPadding.value.left);
const axisRight = chartWidth - axisPadding.right; const axisRight = computed(() => chartWidth.value - axisPadding.value.right);
const axisTop = axisPadding.top; const axisTop = computed(() => axisPadding.value.top);
const axisBottom = chartHeight - axisPadding.bottom; const axisBottom = computed(() => chartHeight.value - axisPadding.value.bottom);
const labelY = axisBottom + 20; const labelY = computed(() => axisBottom.value + (compact.value ? 18 : 20));
const valueGap = 10; const valueGap = computed(() => (compact.value ? 8 : 10));
const barRadius = 6; const barRadius = computed(() => (compact.value ? 5 : 6));
const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value)); const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value));
const barWidth = computed(() => Math.min(52, bandWidth.value * 0.5)); const barWidth = computed(() => Math.min(compact.value ? 44 : 52, bandWidth.value * 0.5));
const barCenter = (index: number) => axisLeft + bandWidth.value * index + bandWidth.value / 2; const barCenter = (index: number) => axisLeft.value + bandWidth.value * index + bandWidth.value / 2;
const barLeft = (index: number) => barCenter(index) - barWidth.value / 2; const barLeft = (index: number) => barCenter(index) - barWidth.value / 2;
const barHeight = (value: number) => { const barHeight = (value: number) => {
if (value <= 0) return 0; if (value <= 0) return 0;
return (value / axisMax.value) * plotHeight.value; return (value / axisMax.value) * plotHeight.value;
}; };
const barTop = (value: number) => axisTop + plotHeight.value - barHeight(value); const barTop = (value: number) => axisTop.value + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop + ((axisMax.value - value) / axisMax.value) * plotHeight.value; const tickY = (value: number) => axisTop.value + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const valueY = (item: EnrollmentBarItem) => { const valueY = (item: EnrollmentBarItem) => {
const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual; const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return barTop(anchor) - valueGap; return barTop(anchor) - valueGap.value;
}; };
const truncateLabel = (label: string) => { const truncateLabel = (label: string) => {
if (label.length <= 8) return label; const limit = compact.value ? 7 : 8;
return `${label.slice(0, 7)}...`; if (label.length <= limit) return label;
return `${label.slice(0, limit - 1)}...`;
}; };
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0); const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
@@ -228,7 +246,8 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
border-radius: 10px; border-radius: 10px;
background: linear-gradient(180deg, #fafbfd 0%, #f6f8fb 100%); background: linear-gradient(180deg, #fafbfd 0%, #f6f8fb 100%);
position: relative; position: relative;
aspect-ratio: 960 / 260; width: 100%;
aspect-ratio: var(--chart-aspect);
min-height: 200px; min-height: 200px;
} }
@@ -297,6 +316,33 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
font-weight: 500; font-weight: 500;
} }
.enrollment-chart--compact .chart-plot {
width: max(100%, var(--chart-min-width));
min-height: 0;
border-radius: 6px;
}
.enrollment-chart--compact .chart-scroll {
overflow-x: auto;
overflow-y: hidden;
}
.enrollment-chart--compact .tick-label {
font-size: 10px;
}
.enrollment-chart--compact .bar-value {
font-size: 11px;
}
.enrollment-chart--compact .bar-value-actual {
font-size: 12px;
}
.enrollment-chart--compact .bar-label {
font-size: 10px;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.bar-label { .bar-label {
font-size: 11px; font-size: 11px;