优化桌面端界面布局

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 通知与桌面系统通知。
- `updates`:隔离桌面自动更新检查与安装入口。
- `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。
- `desktopMenu``desktopUiPreferences`:承接桌面菜单命令、最近访问和收藏等桌面体验状态。
- `desktopMenu``desktopUiPreferences`:承接桌面菜单命令、收藏模块等桌面体验状态。
- `clientRuntime`:作为业务侧获取平台能力的聚合入口。
业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。
@@ -156,6 +156,7 @@
已补齐的桌面体验收口:
- 个人中心新增客户端诊断信息展示与复制能力,内容仅包含客户端类型、版本、平台、构建通道、提交、服务器和能力状态,不包含 token 或业务敏感数据。
- 桌面侧栏已移除被动“最近访问”入口和对应本地记录,只保留用户主动维护的收藏入口;收藏仅保存路由元数据,不保存业务数据或敏感凭据。
- 登录页、服务器设置页、个人中心和系统偏好增加最小窗口布局约束,长服务器地址、健康检查 URL、诊断值和更新/通知状态说明均可在容器内换行。
- 服务器设置页和个人中心在内容高度超过窗口时使用内部滚动,避免 `1180x760` 下对话框或面板溢出。
- 通知权限运行时已区分 WebView `Notification.permission` 的授权、拒绝和待授权状态;取消 macOS 权限请求时继续保持“待授权”,避免错误显示为“已拒绝”。
+6 -1
View File
@@ -13,8 +13,13 @@ import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
import { applyDesktopThemePreference, isTauriRuntime } from "./runtime";
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
if (isTauriRuntime()) {
const isDesktopRuntime = isTauriRuntime();
if (isDesktopRuntime) {
applyDesktopThemePreference();
document.body.classList.add("is-desktop-runtime");
} else {
document.body.classList.remove("is-desktop-runtime");
}
initSessionManager();
initDesktopNotificationManager();
+72 -84
View File
@@ -3,32 +3,30 @@
<aside class="desktop-sidebar">
<header class="sidebar-head">
<div class="sidebar-title-row">
<div>
<h1>{{ TEXT.common.appName }}</h1>
</div>
</div>
<h1>{{ TEXT.common.appName }}</h1>
<el-dropdown v-if="study.currentStudy" trigger="click" class="study-switcher" @command="handleStudySwitch">
<button class="study-switcher-trigger" type="button">
<span class="study-name">{{ study.currentStudy.name }}</span>
<el-icon><ArrowDown /></el-icon>
<el-dropdown v-if="study.currentStudy" trigger="click" class="study-switcher" @command="handleStudySwitch">
<button class="study-switcher-trigger" type="button">
<span class="study-name">{{ study.currentStudy.name }}</span>
<el-icon><ArrowDown /></el-icon>
</button>
<template #dropdown>
<el-dropdown-menu class="study-switcher-menu">
<el-dropdown-item
v-for="item in studies"
:key="item.id"
:command="item"
:class="{ active: study.currentStudy?.id === item.id }"
>
{{ item.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<button v-else class="study-switcher-trigger empty" type="button" @click="router.push('/admin/projects')">
选择项目
</button>
<template #dropdown>
<el-dropdown-menu class="study-switcher-menu">
<el-dropdown-item
v-for="item in studies"
:key="item.id"
:command="item"
:class="{ active: study.currentStudy?.id === item.id }"
>
{{ item.name }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<button v-else class="study-switcher-trigger empty" type="button" @click="router.push('/admin/projects')">
选择项目
</button>
</div>
</header>
<div class="sidebar-scroll">
@@ -47,21 +45,6 @@
</button>
</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">
<div class="section-label">{{ DESKTOP_ADMIN_SECTION_LABEL }}</div>
<template v-for="item in adminNavigationItems" :key="item.path">
@@ -315,7 +298,6 @@
>
<button class="workspace-tab-action" type="button" @click="navigateWorkspaceTab(item.path)">
<span>{{ item.title }}</span>
<span class="tab-group">{{ item.group }}</span>
</button>
<button class="tab-close" type="button" title="关闭标签" @click.stop="closeWorkspaceTab(item.path)">
×
@@ -447,12 +429,11 @@ import {
} from "../session/desktopActivityCenter";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
clearLegacyDesktopRouteHistoryPreference,
getAppMetadata,
getDesktopServerUrl,
listenDesktopMenuCommand,
readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
recordDesktopRecentRoute,
toggleDesktopFavoriteRoute,
type DesktopRoutePreference,
} from "../runtime";
@@ -495,7 +476,6 @@ const desktopServerUrl = ref(getDesktopServerUrl());
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
const desktopActivities = ref<DesktopActivityItem[]>(getDesktopActivities());
const studies = ref<any[]>([]);
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const workspaceTabs = ref<DesktopRoutePreference[]>([]);
const draggingWorkspaceTabPath = ref("");
@@ -689,7 +669,6 @@ const headerReminderBadgeValue = computed(() => (headerReminderTotal.value > 99
const refreshDesktopRoutePreferences = () => {
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));
};
@@ -769,7 +748,10 @@ const closeWorkspaceTab = (path: string) => {
if (index >= 0) lastClosedWorkspaceTab.value = workspaceTabs.value[index];
workspaceTabs.value = workspaceTabs.value.filter((tab) => tab.path !== path);
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) {
router.push(next.path);
} else {
@@ -1054,7 +1036,6 @@ watch(
study.setViewContext(null);
const current = currentDesktopRoutePreference.value;
if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
addWorkspaceTab(current);
refreshDesktopRoutePreferences();
}
@@ -1076,9 +1057,9 @@ watch(
onMounted(async () => {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
clearLegacyDesktopRouteHistoryPreference();
const current = currentDesktopRoutePreference.value;
if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
addWorkspaceTab(current);
}
refreshDesktopRoutePreferences();
@@ -1137,7 +1118,7 @@ useDesktopShortcuts(
<style scoped>
.desktop-workbench {
display: grid;
grid-template-columns: 272px minmax(0, 1fr);
grid-template-columns: 248px minmax(0, 1fr);
height: 100vh;
height: 100dvh;
min-height: 0;
@@ -1173,23 +1154,23 @@ useDesktopShortcuts(
.sidebar-head {
flex: 0 0 auto;
padding: 54px 16px 14px;
padding: 44px 10px 10px;
border-bottom: 1px solid #dfe7f0;
}
.sidebar-title-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 12px;
gap: 10px;
}
.sidebar-head h1 {
margin: 0;
overflow: hidden;
color: #101827;
font-size: 18px;
line-height: 1.3;
font-size: 16px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1210,6 +1191,13 @@ useDesktopShortcuts(
color: #172233;
}
.study-switcher {
justify-self: start;
width: 140px;
max-width: 100%;
min-width: 0;
}
.study-switcher-trigger {
appearance: none;
display: grid;
@@ -1217,11 +1205,11 @@ useDesktopShortcuts(
align-items: center;
gap: 8px;
width: 100%;
min-height: 34px;
margin-top: 14px;
padding: 0 10px;
min-height: 32px;
margin-top: 0;
padding: 0 8px;
border: 1px solid #d7e0ea;
border-radius: 8px;
border-radius: 6px;
background: #ffffff;
color: #26384f;
cursor: pointer;
@@ -1231,6 +1219,8 @@ useDesktopShortcuts(
.study-switcher-trigger.empty {
display: inline-flex;
justify-content: center;
justify-self: start;
width: 104px;
color: #3f5d75;
font-weight: 700;
}
@@ -1238,7 +1228,7 @@ useDesktopShortcuts(
.study-name {
overflow: hidden;
font-size: 13px;
font-weight: 700;
font-weight: 760;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1255,7 +1245,7 @@ useDesktopShortcuts(
max-height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
padding: 14px 10px 34px;
padding: 10px 8px 24px;
scrollbar-gutter: stable;
}
@@ -1278,11 +1268,11 @@ useDesktopShortcuts(
}
.nav-section + .nav-section {
margin-top: 18px;
margin-top: 12px;
}
.section-label {
padding: 0 8px 7px;
padding: 0 6px 6px;
color: #7b8da3;
font-size: 11px;
font-weight: 800;
@@ -1291,34 +1281,40 @@ useDesktopShortcuts(
.sidebar-link {
appearance: none;
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
grid-template-columns: 16px minmax(0, 1fr);
align-items: center;
gap: 9px;
gap: 8px;
width: 100%;
min-height: 34px;
min-height: 32px;
margin: 1px 0;
padding: 0 9px;
padding: 0 8px;
border: 0;
border-radius: 8px;
border-radius: 6px;
background: transparent;
color: #3d5068;
cursor: pointer;
font: inherit;
font-size: 14px;
line-height: 1.3;
text-align: left;
}
.sidebar-link.compact {
grid-template-columns: 18px minmax(0, 1fr);
grid-template-columns: 16px minmax(0, 1fr);
}
.sidebar-link.child {
grid-template-columns: minmax(0, 1fr);
min-height: 30px;
padding-left: 36px;
min-height: 28px;
padding-left: 32px;
color: #607289;
font-size: 12px;
}
.sidebar-link :deep(.el-icon) {
font-size: 16px;
}
.sidebar-link span {
overflow: hidden;
text-overflow: ellipsis;
@@ -1337,11 +1333,11 @@ useDesktopShortcuts(
}
.sidebar-group {
margin: 1px 0 5px;
margin: 1px 0 4px;
}
.group-title {
grid-template-columns: 18px minmax(0, 1fr) 14px;
grid-template-columns: 16px minmax(0, 1fr) 14px;
font-weight: 700;
}
@@ -1858,10 +1854,7 @@ useDesktopShortcuts(
.workspace-tab-action {
appearance: none;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
display: block;
min-width: 0;
padding: 0;
border: 0;
@@ -1872,18 +1865,13 @@ useDesktopShortcuts(
font-weight: inherit;
}
.workspace-tab-action span:first-child {
.workspace-tab-action span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-group {
color: #8394a9;
font-size: 10px;
font-weight: 700;
}
.tab-close {
width: 18px;
height: 18px;
@@ -1946,11 +1934,12 @@ useDesktopShortcuts(
min-width: 0;
min-height: 0;
overflow: auto;
padding: 8px 10px;
padding: 0;
background: #eef2f6;
}
.desktop-route-shell {
height: 100%;
min-height: 100%;
}
@@ -2015,7 +2004,6 @@ useDesktopShortcuts(
:global([data-ctms-theme="dark"] .section-label),
:global([data-ctms-theme="dark"] .panel-subtitle),
:global([data-ctms-theme="dark"] .tab-group),
:global([data-ctms-theme="dark"] .toolbar-subtitle) {
color: #94a3b8;
}
+60 -20
View File
@@ -88,12 +88,18 @@ const openManager = () => {
<style scoped>
.panel {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
border: 0;
border-radius: 0;
box-shadow: none;
overflow: hidden;
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 {
@@ -101,37 +107,69 @@ const openManager = () => {
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 0 8px 16px;
border-bottom: 1px solid #edf2f8;
color: #8b98aa;
padding: 12px 12px;
border: 1px solid rgba(110, 153, 205, 0.18);
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-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 {
flex: 1 1 auto;
max-height: calc(100vh - 240px);
min-height: 0;
overflow: auto;
border-right: 0;
padding: 14px 0 0;
padding: 14px 2px 0;
background: transparent;
}
.menu :deep(.el-menu-item) {
position: relative;
height: 44px;
margin: 4px 0;
padding: 0 10px !important;
border: 1px solid transparent;
border-radius: 10px;
color: #5f6f7a;
color: #506475;
font-size: 14px;
font-weight: 700;
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) {
background: rgba(31, 95, 184, 0.06);
border-color: rgba(47, 123, 232, 0.1);
background: rgba(255, 255, 255, 0.66);
color: #1f5fb8;
}
.menu :deep(.el-menu-item.is-active) {
background: #e7f4ff;
color: #1f5fb8;
border-color: rgba(47, 123, 232, 0.18);
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 {
display: inline-flex;
@@ -142,10 +180,11 @@ const openManager = () => {
height: 24px;
margin-right: 10px;
border-radius: 7px;
background: rgba(95, 111, 122, 0.08);
color: #697982;
background: linear-gradient(135deg, rgba(221, 234, 249, 0.9) 0%, rgba(242, 248, 255, 0.9) 100%);
color: #517194;
font-size: 13px;
font-weight: 800;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.82) inset;
}
.cat-icon :deep(svg) {
font-size: 13px;
@@ -153,20 +192,21 @@ const openManager = () => {
height: 1em;
}
.menu :deep(.el-menu-item.is-active) .cat-icon {
background: #d7ebff;
color: #1f5fb8;
background: linear-gradient(135deg, #2f7be8 0%, #24b7d8 100%);
color: #ffffff;
box-shadow: 0 8px 18px rgba(47, 123, 232, 0.25);
}
.category-create-btn {
height: 34px;
border: 0;
border-radius: 10px;
background: #415a77;
background: linear-gradient(135deg, #2f527a 0%, #203a5c 100%);
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:focus {
background: #344960;
background: linear-gradient(135deg, #254866 0%, #172f4e 100%);
}
.name {
flex: 1;
@@ -183,8 +223,8 @@ const openManager = () => {
height: 18px;
padding: 0 6px;
border-radius: 999px;
background: rgba(95, 111, 122, 0.08);
color: #697982;
background: rgba(88, 111, 137, 0.1);
color: #5d7590;
font-size: 10px;
font-weight: 700;
line-height: 18px;
@@ -192,7 +232,7 @@ const openManager = () => {
}
.menu :deep(.el-menu-item.is-active) .cat-count {
background: #d7ebff;
color: #1f5fb8;
background: linear-gradient(135deg, #cfe7ff 0%, #dcf7ff 100%);
color: #1559ad;
}
</style>
+156 -19
View File
@@ -1,18 +1,20 @@
<template>
<div class="faq-list-card">
<div class="faq-list-card" :class="{ 'faq-list-card--desktop': isDesktop }">
<el-table
:data="items"
v-loading="loading"
style="width: 100%"
class="faq-table"
:class="{ 'faq-table--with-actions': canDelete, 'faq-table--without-actions': !canDelete }"
:row-class-name="rowClassName"
:height="isDesktop ? '100%' : undefined"
@row-click="onRowClick"
table-layout="fixed"
>
<el-table-column prop="question" :label="TEXT.common.fields.question" show-overflow-tooltip>
<template #default="scope">
<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">
{{ scope.row.question }}
</el-link>
@@ -52,12 +54,14 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { QuestionFilled } from "@element-plus/icons-vue";
import { computed } from "vue";
import { useRouter } from "vue-router";
import { deleteFaqItem } from "../api/faqs";
import type { FaqItem, FaqCategory } from "../api/faqs";
import { displayDateTime, displayEnum } from "../utils/display";
import { TEXT } from "../locales";
import { isTauriRuntime } from "../runtime";
const props = defineProps<{
items: FaqItem[];
@@ -69,6 +73,7 @@ const props = defineProps<{
const emit = defineEmits(["refresh"]);
const router = useRouter();
const isDesktop = isTauriRuntime();
const categoryMap = computed(() =>
props.categories.reduce<Record<string, string>>((acc, cur) => {
@@ -81,10 +86,6 @@ const categoryLabel = (categoryId: string) => {
return categoryMap.value[categoryId] || categoryId || "--";
};
const questionInitial = (question?: string) => {
return String(question || "?").trim().slice(0, 1) || "?";
};
const splitDateTime = (value?: string | number | Date | null) => {
const displayValue = displayDateTime(value);
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) {
border-bottom: 0;
background: transparent;
color: #8a98aa;
height: 46px;
border-bottom: 1px solid #dce8f7;
background: linear-gradient(180deg, #f7fbff 0%, #eef5fc 100%);
color: #5d7088;
font-size: 14px;
font-weight: 800;
}
@@ -166,21 +168,23 @@ const remove = async (row: FaqItem) => {
}
.faq-table :deep(.faq-row td.el-table__cell) {
padding: 12px 0;
padding: 14px 0;
border-bottom: 1px solid #e8eef6;
background: #ffffff;
transition: background 0.18s ease;
}
.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 :deep(col:nth-child(2)) { width: 14%; }
.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--with-actions :deep(col) {
width: 20%;
}
.faq-table--without-actions :deep(col) {
width: 25%;
}
@@ -205,10 +209,19 @@ const remove = async (row: FaqItem) => {
width: 34px;
height: 34px;
border-radius: 10px;
background: #f3f7fb;
color: #5e6f7b;
font-size: 15px;
background:
radial-gradient(circle at 26% 22%, rgba(255, 255, 255, 0.42) 0%, transparent 28%),
linear-gradient(135deg, #2f7be8 0%, #23b7d9 100%);
color: #ffffff;
font-size: 17px;
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 {
@@ -257,4 +270,128 @@ const remove = async (row: FaqItem) => {
.question-link {
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>
+117 -3
View File
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
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 readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "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 readDocumentDetailSource = () => readFileSync(resolve(__dirname, "../views/documents/DocumentDetail.vue"), "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", () => {
it("routes desktop and web shells through the runtime flag", () => {
const source = readLayoutSource();
const app = readAppSource();
expect(source).toContain("const isDesktop = isTauriRuntime()");
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
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", () => {
const source = readDesktopLayoutSource();
const webSource = readWebLayoutSource();
expect(source).toContain("readDesktopRecentRoutes");
expect(source).toContain("readDesktopFavoriteRoutes");
expect(source).toContain("recordDesktopRecentRoute");
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", () => {
@@ -86,12 +104,35 @@ describe("desktop layout shell", () => {
expect(tauriConfig).toContain('"titleBarStyle": "Overlay"');
expect(tauriConfig).toContain('"hiddenTitle": true');
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: no-drag;");
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", () => {
const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource();
@@ -187,6 +228,8 @@ describe("desktop layout shell", () => {
expect(tabsTemplate).not.toContain('@click="router.push(item.path)"');
expect(source).not.toContain(".slice(-7)");
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 workspaceTabMenu = ref<");
expect(source).toContain("const closeOtherWorkspaceTabs = (path: string) => {");
@@ -306,18 +349,89 @@ describe("desktop layout shell", () => {
const styles = readMainStyleSource();
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 .hero-banner");
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(".desktop-workbench .desktop-route-shell .el-table th.el-table__cell");
expect(styles).toContain("height: 34px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table td.el-table__cell");
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(":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", () => {
const login = readLoginSource();
const serverSettings = readDesktopServerSettingsSource();
+2 -21
View File
@@ -22,16 +22,6 @@
</el-menu-item>
</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">
<template #title>
<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 {
DESKTOP_SERVER_URL_CHANGED_EVENT,
clearLegacyDesktopRouteHistoryPreference,
getAppMetadata,
getDesktopServerUrl,
isTauriRuntime,
listenDesktopMenuCommand,
readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
recordDesktopRecentRoute,
toggleDesktopFavoriteRoute,
type DesktopRoutePreference,
} from "../runtime";
@@ -472,7 +461,6 @@ const profileDialogDirty = ref(false);
const commandPaletteVisible = ref(false);
const desktopPreferencesVisible = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl());
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const headerOverviewStats = ref<{
centerActual: number;
@@ -720,9 +708,6 @@ const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
const refreshDesktopRoutePreferences = () => {
if (!isDesktop) return;
desktopRecentRoutes.value = readDesktopRecentRoutes().filter((item) =>
desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
);
desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) =>
desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
);
@@ -1084,7 +1069,6 @@ watch(() => route.path, () => {
if (isDesktop) {
const current = currentDesktopRoutePreference.value;
if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
refreshDesktopRoutePreferences();
}
}
@@ -1145,10 +1129,7 @@ onMounted(async () => {
}, 1000);
if (isDesktop) {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
const current = currentDesktopRoutePreference.value;
if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
}
clearLegacyDesktopRouteHistoryPreference();
refreshDesktopRoutePreferences();
desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined);
}
-1
View File
@@ -736,7 +736,6 @@ export const TEXT = {
treeTitle: "TMF 目录",
detailTitle: "归档状态",
noNodeSelected: "未选择目录",
selectNodeHint: "请选择左侧目录查看文件",
emptyDocuments: "当前目录暂无文件",
rootNode: "根目录",
actions: {
@@ -1,13 +1,11 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
DESKTOP_FAVORITE_ROUTES_KEY,
DESKTOP_RECENT_ROUTES_KEY,
DESKTOP_THEME_KEY,
applyDesktopThemePreference,
clearLegacyDesktopRouteHistoryPreference,
readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
readDesktopThemePreference,
recordDesktopRecentRoute,
setDesktopThemePreference,
toggleDesktopFavoriteRoute,
} from "./desktopUiPreferences";
@@ -32,17 +30,17 @@ describe("desktop UI preferences", () => {
document.documentElement.style.colorScheme = "";
});
it("stores only route metadata for recent desktop routes", () => {
recordDesktopRecentRoute({ path: "/subjects", title: "受试者", group: "当前项目" });
it("stores only route metadata for desktop favorites", () => {
toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者", group: "当前项目" });
expect(readDesktopRecentRoutes()).toEqual([
expect(readDesktopFavoriteRoutes()).toEqual([
expect.objectContaining({
path: "/subjects",
title: "受试者",
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", () => {
@@ -55,6 +53,21 @@ describe("desktop UI preferences", () => {
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", () => {
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_THEME_KEY = "ctms_desktop_theme";
export const DESKTOP_THEME_CHANGED_EVENT = "ctms:desktop-theme-changed";
@@ -12,10 +11,10 @@ export interface DesktopRoutePreference {
updatedAt: string;
}
const MAX_RECENT_ROUTES = 8;
const MAX_FAVORITE_ROUTES = 12;
const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "light";
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";
@@ -80,19 +79,11 @@ export const setDesktopThemePreference = (theme: DesktopThemePreference): Deskto
return nextTheme;
};
export const readDesktopRecentRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_RECENT_ROUTES_KEY);
export const readDesktopFavoriteRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY);
export const recordDesktopRecentRoute = (route: Pick<DesktopRoutePreference, "path" | "title" | "group">): DesktopRoutePreference[] => {
const item = sanitizeRoutePreference({ ...route, updatedAt: new Date().toISOString() });
if (!item) return readDesktopRecentRoutes();
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 clearLegacyDesktopRouteHistoryPreference = () => {
if (!isStorageAvailable()) return;
window.localStorage.removeItem(LEGACY_DESKTOP_ROUTE_HISTORY_KEY);
};
export const isDesktopFavoriteRoute = (path: string): boolean =>
+1 -3
View File
@@ -10,15 +10,13 @@ export {
} from "./desktopServerConfig";
export {
DESKTOP_FAVORITE_ROUTES_KEY,
DESKTOP_RECENT_ROUTES_KEY,
DESKTOP_THEME_CHANGED_EVENT,
DESKTOP_THEME_KEY,
applyDesktopThemePreference,
clearLegacyDesktopRouteHistoryPreference,
isDesktopFavoriteRoute,
readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
readDesktopThemePreference,
recordDesktopRecentRoute,
setDesktopThemePreference,
toggleDesktopFavoriteRoute,
type DesktopRoutePreference,
@@ -8,14 +8,32 @@ vi.mock("../router", () => ({
}));
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", () => ({
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", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
runtimeMocks.isTauriRuntime.mockReturnValue(false);
vi.useFakeTimers();
setActivePinia(createPinia());
const storage = (() => {
@@ -82,6 +100,24 @@ describe("session manager idle logout", () => {
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 () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
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(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 { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { isTauriRuntime } from "../runtime";
import { getToken, setToken } from "../utils/auth";
import { extendToken } from "../api/authClient";
import { parseJwtExp } from "./jwt";
@@ -24,11 +25,17 @@ let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null
let initialized = false;
const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null;
const shouldEnforceIdleTimeout = () => !isTauriRuntime();
const getTimeoutAt = (session: ReturnType<typeof useSessionStore>) =>
session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000;
const reconcileSessionState = (now: number = Date.now()) => {
const session = useSessionStore();
if (!shouldEnforceIdleTimeout()) {
session.clearTimeoutWarning();
return true;
}
if (now >= getTimeoutAt(session)) {
void forceLogout(LOGOUT_REASON_TIMEOUT);
return false;
@@ -70,6 +77,10 @@ const scheduleIdleCheck = () => {
window.clearTimeout(idleTimer);
}
const session = useSessionStore();
if (!shouldEnforceIdleTimeout()) {
session.clearTimeoutWarning();
return;
}
const now = Date.now();
const timeoutAt = getTimeoutAt(session);
const warningAt = timeoutAt - TIMEOUT_WARNING_SECONDS * 1000;
@@ -224,7 +235,7 @@ export const startTokenKeepAlive = () => {
const remaining = expAt - Date.now();
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
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");
}
}, 10000);
+335 -4
View File
@@ -654,10 +654,34 @@ body {
font-size: 13px;
}
.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.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 {
gap: 10px;
padding: 0;
.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,
@@ -675,6 +699,25 @@ body {
border: 1px solid #d9e2ec;
border-radius: 6px !important;
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;
}
@@ -700,7 +743,7 @@ body {
.desktop-workbench .desktop-route-shell .hero-top,
.desktop-workbench .desktop-route-shell .hero-content,
.desktop-workbench .desktop-route-shell .hero-body {
margin-bottom: 10px !important;
margin-bottom: 8px !important;
}
.desktop-workbench .desktop-route-shell .hero-title,
@@ -737,6 +780,26 @@ body {
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-right,
.desktop-workbench .desktop-route-shell .ctms-page-actions,
@@ -795,6 +858,49 @@ body {
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 {
height: 34px;
padding: 8px 10px;
@@ -817,6 +923,19 @@ body {
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 {
margin-bottom: 0;
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 .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 .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 {
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 {
border-bottom-color: #26364a !important;
background: #111827;
@@ -885,3 +1011,208 @@ body {
.el-overlay {
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>
<div class="page ctms-page-shell page--flush medical-consult-page">
<div class="page-bg-dots"></div>
<div class="page ctms-page-shell page--flush medical-consult-page" :class="{ 'medical-consult-page--desktop': isDesktop }">
<div v-if="!isDesktop" class="page-bg-dots"></div>
<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">
<el-autocomplete
v-model="keyword"
@@ -19,7 +23,13 @@
<el-icon><Search /></el-icon>
</template>
</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>
</section>
@@ -34,7 +44,7 @@
</el-col>
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
<div class="faq-main unified-shell">
<div class="list-toolbar">
<div v-if="!isDesktop" class="list-toolbar">
<PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<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 FaqList from "../components/FaqList.vue";
import FaqItemForm from "../components/FaqItemForm.vue";
import { isTauriRuntime } from "../runtime";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import PermissionAction from "../components/PermissionAction.vue";
@@ -87,6 +98,7 @@ import { TEXT } from "../locales";
const auth = useAuthStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const categories = ref<any[]>([]);
const faqs = ref<any[]>([]);
@@ -104,6 +116,14 @@ const { can } = usePermission();
const canReadCategories = computed(() => can("faq.category.read"));
const canUpdateFaq = computed(() => can("faq.update"));
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 () => {
try {
@@ -247,7 +267,7 @@ onMounted(async () => {
color: #0f172a;
font-size: 32px;
font-weight: 900;
letter-spacing: -0.02em;
letter-spacing: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -361,6 +381,228 @@ onMounted(async () => {
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) {
.hero-tools {
flex-wrap: wrap;
@@ -376,5 +618,18 @@ onMounted(async () => {
.faq-main {
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>
@@ -65,12 +65,33 @@ describe("DocumentList permissions", () => {
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();
expect(source).toContain('@row-contextmenu="openDocumentContextMenu"');
expect(source).toContain("documentContextMenu");
expect(source).toContain('class="document-context-menu"');
expect(source).not.toContain('class="preview-actions"');
expect(source).toContain("@click=\"openSelectedDocument\"");
expect(source).toContain("@click=\"openSelectedDocumentEditor\"");
expect(source).toContain("@click=\"deleteSelectedDocument\"");
+47 -23
View File
@@ -120,22 +120,6 @@
<dt>{{ TEXT.modules.fileVersionManagement.columns.updatedAt }}</dt>
<dd>{{ displayDateTime(selectedDocument.updated_at) }}</dd>
</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>
<div v-else class="preview-empty">
<span>选择一行查看文档摘要</span>
@@ -620,6 +604,31 @@ watch(
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 {
display: flex;
width: 100%;
@@ -705,6 +714,15 @@ watch(
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 {
min-width: 0;
min-height: 0;
@@ -716,11 +734,24 @@ watch(
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 {
min-width: 0;
outline: none;
}
.document-page--desktop .document-table-pane {
min-height: 0;
overflow: hidden;
background: #ffffff;
}
.document-workbench.is-desktop .document-table-pane {
border-right: 1px solid #e3e9f1;
}
@@ -782,13 +813,6 @@ watch(
font-weight: 650;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty {
display: flex;
min-height: 180px;
@@ -35,4 +35,19 @@ describe("ContractFees.vue", () => {
expect(source).not.toContain('router.push("/fees/contracts/new")');
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>
<div class="page ctms-page-shell page--flush">
<div class="overview">
<div class="page ctms-page-shell page--flush" :class="{ 'fee-contracts-page--desktop': isDesktop }">
<div class="overview fee-overview">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
@@ -53,7 +53,7 @@
<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">
<el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item">
@@ -208,9 +208,11 @@ import StateError from "../../components/StateError.vue";
import KpiCard from "../../components/KpiCard.vue";
import ContractFeeEditorDrawer from "./ContractFeeEditorDrawer.vue";
import { TEXT } from "../../locales";
import { isTauriRuntime } from "../../runtime";
const router = useRouter();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const { can } = usePermission();
const canCreate = computed(() => can("fees.contract.create"));
const canUpdate = computed(() => can("fees.contract.update"));
@@ -522,6 +524,76 @@ onMounted(async () => {
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>
@@ -110,6 +110,7 @@ describe("DrugShipments project permissions", () => {
expect(source).toContain('@row-contextmenu="openShipmentContextMenu"');
expect(source).toContain("shipmentContextMenu");
expect(source).toContain('class="shipment-context-menu"');
expect(source).toContain('class="preview-actions"');
expect(source).toContain('@click="openSelectedShipment"');
expect(source).toContain('@click="openSelectedShipmentEditor"');
expect(source).toContain('@click="removeSelectedShipment"');
+41 -3
View File
@@ -164,8 +164,12 @@
<dd>{{ selectedShipment.remark || TEXT.common.fallback }}</dd>
</dl>
<div class="preview-actions">
<el-button size="small" @click="openSelectedShipment">打开详情</el-button>
<el-button v-if="canUpdate" size="small" :disabled="isInactiveSite(selectedShipment.center_id)" @click="openSelectedShipmentEditor">
<el-button
v-if="canUpdate"
size="small"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="openSelectedShipmentEditor"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
@@ -818,12 +822,26 @@ onMounted(async () => {
background: transparent;
}
/* 桌面端:撑满父容器高度 */
.shipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 桌面端:page-inner 撑满 .page */
.shipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Page Header ==================== */
.create-btn {
height: 34px;
@@ -903,6 +921,16 @@ onMounted(async () => {
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 {
min-width: 0;
min-height: 0;
@@ -911,7 +939,9 @@ onMounted(async () => {
.shipment-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 316px;
min-height: min(590px, calc(100vh - 186px));
/* 桌面端:不限定固定高度,由父容器 flex 撑满 */
flex: 1;
min-height: 0;
}
.shipment-table-pane {
@@ -919,6 +949,14 @@ onMounted(async () => {
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 {
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);");
});
});
+380 -240
View File
@@ -5,43 +5,41 @@
<div class="etmf-toolbar">
<div class="etmf-toolbar-main">
<div class="etmf-filter-item">
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable class="filter-select-comp" @change="loadNodeDocuments">
<template #prefix>
<el-icon><OfficeBuilding /></el-icon>
</template>
<el-option :label="TEXT.common.labels.allSites" value="" />
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</div>
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable size="small" class="filter-select-comp" @change="loadNodeDocuments">
<template #prefix>
<el-icon><OfficeBuilding /></el-icon>
</template>
<el-option :label="TEXT.common.labels.allSites" value="" />
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</div>
<div class="etmf-filter-item">
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable class="filter-select-comp">
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable size="small" class="filter-select-comp">
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</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">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
<el-button :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button>
<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-button size="small" :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button>
<el-button v-if="canCreate && selectedNode" size="small" type="primary" @click="openDocumentDialog">
<el-icon class="el-icon--left"><DocumentAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</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>
<section class="unified-section section--flush etmf-workspace">
@@ -49,7 +47,6 @@
<div class="panel-heading panel-heading--compact">
<div>
<div class="panel-title">{{ TEXT.modules.etmf.treeTitle }}</div>
<div class="panel-subtitle">{{ totalNodeCount }} 个目录节点</div>
</div>
</div>
<div v-if="!filteredTree.length && !treeLoading" class="etmf-empty-block etmf-empty-block--tree">
@@ -86,69 +83,30 @@
<main class="etmf-document-panel">
<div class="panel-heading">
<div>
<div class="panel-heading-left">
<div class="panel-title">{{ selectedNode?.name || TEXT.modules.etmf.noNodeSelected }}</div>
<div class="panel-subtitle">
{{ selectedNode ? `${selectedNode.code} · ${scopeLabel(selectedNode.scope_type)}` : TEXT.modules.etmf.selectNodeHint }}
</div>
<span v-if="selectedNode" class="panel-subtitle">{{ selectedNode.code }} · {{ scopeLabel(selectedNode.scope_type) }}</span>
</div>
<div v-if="selectedNode" class="document-panel-meta">
<span>{{ selectedNode.document_count }} 份文件</span>
<span>{{ selectedNode.effective_document_count }} 生效版本</span>
<el-tag effect="plain" :type="statusType(selectedNode.status)">
<span>{{ selectedNode.effective_document_count }} 生效</span>
<el-tag size="small" effect="plain" :type="statusType(selectedNode.status)">
{{ statusLabel(selectedNode.status) }}
</el-tag>
</div>
</div>
<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 />
<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>
<section v-if="selectedNode" class="document-status-summary">
<div class="status-summary-head">
<span class="status-summary-label">{{ TEXT.modules.etmf.detailTitle }}</span>
<el-tag size="small" effect="plain" :type="statusType(selectedNode.status)">
{{ statusLabel(selectedNode.status) }}
</el-tag>
</div>
<span class="status-summary-note" :class="`status-summary-note--${selectedNode.status.toLowerCase()}`">
{{ statusDescription(selectedNode.status) }}
</span>
<dl class="status-summary-list">
<div>
<dt>{{ TEXT.modules.etmf.fields.scope }}</dt>
<dd>{{ scopeLabel(selectedNode.scope_type) }}</dd>
@@ -166,18 +124,44 @@
<dd>{{ selectedNode.effective_document_count }}</dd>
</div>
</dl>
<div class="node-status-note" :class="`node-status-note--${selectedNode.status.toLowerCase()}`">
{{ statusDescription(selectedNode.status) }}
</div>
</template>
<div v-else class="etmf-empty-block etmf-empty-block--detail">
<div class="empty-icon">
<el-icon><CircleCheck /></el-icon>
</div>
<div class="empty-title">等待选择目录</div>
<div class="empty-desc">{{ TEXT.modules.etmf.selectNodeHint }}</div>
</div>
</aside>
</section>
<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>
</template>
</el-table>
</main>
</section>
</div>
@@ -522,151 +506,233 @@ onMounted(load);
</script>
<style scoped>
.etmf-action-bar {
display: grid;
gap: 12px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid var(--ctms-border-light);
/* ==================== 高度链:让工作区撑满页面剩余空间 ==================== */
/* .page 本身占满路由容器给它的全部高度 */
.page {
display: flex;
flex-direction: column;
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 {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
gap: 10px;
width: 100%;
min-width: 0;
}
.etmf-toolbar-main,
.etmf-toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.etmf-toolbar-actions {
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.etmf-filter-item {
width: 240px;
min-width: 180px;
width: 180px;
min-width: 140px;
}
.etmf-filter-item :deep(.el-select) {
width: 100%;
}
.etmf-status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 10px;
/* 内联状态徽章条 */
.etmf-status-inline {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
justify-content: center;
min-width: 0;
overflow: hidden;
padding: 0 8px;
}
.etmf-status-card {
min-height: 64px;
display: grid;
align-content: center;
gap: 2px;
padding: 10px 14px;
border: 1px solid #e4eaf2;
.etmf-status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 14px 5px 10px;
border-radius: 8px;
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;
font-weight: 750;
color: #172033;
letter-spacing: -0.03em;
min-width: 1ch;
text-align: right;
}
.status-card-label {
font-size: 12px;
color: #68758a;
.status-badge-label {
font-size: 11px;
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 {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px);
min-height: calc(100vh - 260px);
grid-template-columns: 280px minmax(0, 1fr);
min-height: 0;
flex: 1;
border-top: 0;
background: #fff;
overflow: hidden;
}
.etmf-tree-panel,
.etmf-document-panel,
.etmf-node-detail {
.etmf-document-panel {
min-width: 0;
padding: 16px;
min-height: 0;
padding: 12px 14px;
overflow-y: auto;
}
.etmf-tree-panel {
border-right: 1px solid var(--ctms-border-light);
background: linear-gradient(180deg, #fbfcfe 0%, #f7f9fc 100%);
border-right: 1px solid #e4e8ef;
background: linear-gradient(180deg, #f9fbff 0%, #f4f7fd 100%);
}
.etmf-document-panel {
border-right: 1px solid var(--ctms-border-light);
background: #fff;
overflow-x: hidden;
}
.panel-heading {
min-height: 48px;
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
gap: 10px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(79, 126, 207, 0.08);
}
.panel-heading--compact {
min-height: 38px;
margin-bottom: 10px;
margin-bottom: 8px;
}
.panel-heading-left {
display: flex;
align-items: baseline;
gap: 0;
min-width: 0;
overflow: hidden;
}
.panel-title {
font-size: 15px;
font-size: 13px;
font-weight: 700;
color: var(--ctms-text-main);
letter-spacing: -0.01em;
}
.panel-subtitle {
margin-top: 4px;
font-size: 12px;
font-size: 11px;
color: var(--ctms-text-secondary);
font-weight: 500;
margin-left: 6px;
}
.etmf-tree {
--el-tree-node-hover-bg-color: #eef4ff;
--el-tree-node-hover-bg-color: rgba(79, 126, 207, 0.07);
background: transparent;
padding-top: 2px;
}
.etmf-tree :deep(.el-tree-node__content) {
height: 34px;
border-radius: 7px;
margin: 2px 0;
height: 30px;
border-radius: 6px;
margin: 1px 0;
transition: background 0.15s ease;
}
.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;
}
@@ -674,16 +740,21 @@ onMounted(load);
width: 100%;
min-width: 0;
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;
gap: 8px;
padding-right: 8px;
gap: 6px;
padding-right: 6px;
}
.tree-node-code {
font-size: 12px;
font-weight: 700;
color: #315f9f;
font-size: 10px;
font-weight: 800;
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 {
@@ -691,159 +762,228 @@ onMounted(load);
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ctms-text-main);
font-size: 12px;
}
.tree-node-count {
height: 20px;
min-width: 22px;
height: 18px;
min-width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #edf2f7;
background: linear-gradient(135deg, #e8edf5, #dce4f0);
color: #526174;
font-size: 12px;
font-weight: 650;
font-size: 10px;
font-weight: 700;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.document-panel-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 8px;
font-size: 12px;
gap: 6px;
font-size: 11px;
font-weight: 500;
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 {
width: 100%;
}
.node-meta {
display: grid;
gap: 12px;
margin: 16px 0;
/* 文档表格行悬停效果 */
.etmf-document-table :deep(.el-table__body tr:hover > td) {
background: rgba(79, 126, 207, 0.04) !important;
cursor: pointer;
}
.node-meta div {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: 10px;
/* 表格头部紧凑化 */
.etmf-document-table :deep(th.el-table__cell) {
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
color: #526174;
background: #f8fafc;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.node-meta dt {
color: var(--ctms-text-secondary);
}
.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;
.etmf-document-table :deep(td.el-table__cell) {
padding: 8px 12px;
font-size: 13px;
line-height: 1.5;
color: var(--ctms-text-main);
}
.node-status-note--effective {
border-left-color: #2f8f5b;
/* 隐藏表格底部分隔线(el-table inner-wrapper 的 ::before 伪元素) */
.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 {
border-left-color: #c58b20;
.etmf-document-table :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.etmf-empty-block {
min-height: 168px;
min-height: 120px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 22px;
padding: 20px;
text-align: center;
color: #7b8797;
}
.etmf-empty-block--tree {
min-height: 280px;
border: 1px dashed #d8e0eb;
border-radius: 8px;
background: rgba(255, 255, 255, 0.68);
min-height: 200px;
border-radius: 10px;
background: linear-gradient(135deg, #f4f7fd 0%, #eef3fb 100%);
}
.etmf-empty-block--documents {
min-height: 420px;
}
.etmf-empty-block--detail {
min-height: 280px;
min-height: 300px;
}
.empty-icon {
width: 44px;
height: 44px;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: #eef3f9;
color: #6b7c91;
font-size: 22px;
background: linear-gradient(135deg, #e8f0fc, #dce8f8);
color: #4f7ecf;
font-size: 18px;
box-shadow: 0 3px 8px rgba(79, 126, 207, 0.15);
}
.empty-title {
font-size: 15px;
font-size: 13px;
font-weight: 700;
color: #2f3a4c;
color: #2a3550;
letter-spacing: -0.01em;
}
.empty-desc {
max-width: 320px;
font-size: 13px;
line-height: 1.55;
max-width: 280px;
font-size: 12px;
line-height: 1.5;
color: #7b8797;
}
@media (max-width: 1180px) {
@media (max-width: 900px) {
.etmf-toolbar {
align-items: stretch;
flex-direction: column;
flex-wrap: wrap;
}
.etmf-toolbar-actions {
justify-content: flex-start;
.etmf-status-inline {
display: none;
}
.etmf-workspace {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
grid-template-columns: 240px minmax(0, 1fr);
}
.etmf-node-detail {
grid-column: 1 / -1;
border-top: 1px solid var(--ctms-border-light);
.status-summary-list {
display: none;
}
.document-status-summary {
flex-wrap: wrap;
}
}
@media (max-width: 760px) {
.etmf-status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (max-width: 700px) {
.etmf-toolbar {
flex-direction: column;
align-items: stretch;
}
.etmf-toolbar-main {
flex-direction: column;
align-items: stretch;
flex-wrap: wrap;
}
.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)"');
});
});
+474 -80
View File
@@ -1,5 +1,5 @@
<template>
<div class="page">
<div class="page" :class="{ 'equipment-page--desktop': isDesktop }">
<div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card">
@@ -24,59 +24,118 @@
</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="rows"
class="equipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="equipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="name" label="设备名称" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.specModel || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.unit || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="brand" label="品牌" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.brand || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column label="是否需要校准" show-overflow-tooltip>
<template #default="{ row }">
<span :class="['status-pill', row.needCalibration ? 'status-pill--yes' : 'status-pill--no']">
{{ row.needCalibration ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button>
<el-button v-if="canDelete" link type="danger" size="small" @click.stop="removeRow(row)">删除</el-button>
<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
v-loading="loading"
:data="rows"
class="equipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="equipmentRowClass"
@row-click="handleEquipmentRowClick"
@row-dblclick="openEquipmentDetail"
@row-contextmenu="openEquipmentContextMenu"
>
<el-table-column prop="name" label="设备名称" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.specModel || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.unit || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="brand" label="品牌" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.brand || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column label="是否需要校准" show-overflow-tooltip>
<template #default="{ row }">
<span :class="['status-pill', row.needCalibration ? 'status-pill--yes' : 'status-pill--no']">
{{ row.needCalibration ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" label="操作" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button>
<el-button v-if="canDelete" link type="danger" size="small" @click.stop="removeRow(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<span>暂无设备数据</span>
</div>
</template>
</el-table>
</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>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div v-else class="preview-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<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>
<span>选择一行查看详情摘要</span>
</div>
</template>
</el-table>
</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>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
@@ -174,7 +233,7 @@
</template>
<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 { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
@@ -192,6 +251,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales";
import { isTauriRuntime } from "../../runtime";
interface EquipmentRow {
id: string;
@@ -209,12 +269,16 @@ type FormModel = Omit<EquipmentRow, "id">;
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const isDesktop = isTauriRuntime();
const filters = reactive({ name: "" });
const rows = ref<EquipmentRow[]>([]);
const loading = ref(false);
const saving = ref(false);
const drawerVisible = ref(false);
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 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 canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:delete"]));
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> = {
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
@@ -326,11 +391,76 @@ const openEdit = (row: EquipmentRow) => {
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;
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 () => {
@@ -405,10 +535,25 @@ watch(
filters.name = "";
loadRows();
drawerVisible.value = false;
selectedEquipmentId.value = "";
},
{ 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(
() => form.needCalibration,
(need) => {
@@ -416,6 +561,14 @@ watch(
if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30;
}
);
onMounted(() => {
if (isDesktop) document.addEventListener("click", closeEquipmentContextMenu);
});
onBeforeUnmount(() => {
if (isDesktop) document.removeEventListener("click", closeEquipmentContextMenu);
});
</script>
<style scoped>
@@ -427,20 +580,35 @@ watch(
background: transparent;
}
/* 桌面端:撑满父容器高度 */
.equipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 桌面端:page-inner 撑满 .page */
.equipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Table Toolbar ==================== */
.table-card-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid rgba(79, 126, 207, 0.1);
gap: 12px;
background: linear-gradient(135deg, #f8faff 0%, #f2f6ff 100%);
}
.toolbar-filters {
@@ -466,16 +634,30 @@ watch(
.filter-label {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
font-weight: 700;
color: #7a8ca8;
text-transform: uppercase;
letter-spacing: 0.03em;
letter-spacing: 0.04em;
}
.filter-input {
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 {
display: flex;
gap: 8px;
@@ -484,21 +666,35 @@ watch(
}
.filter-btn {
border-radius: 6px;
border-radius: 8px;
}
.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-weight: 500;
color: #a3a3a3;
font-weight: 700;
color: #4f7ecf;
white-space: nowrap;
letter-spacing: 0.01em;
}
.create-btn {
height: 34px;
border-radius: 8px;
padding: 0 16px;
font-weight: 500;
padding: 0 18px;
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 ==================== */
@@ -511,6 +707,58 @@ watch(
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 {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
@@ -543,6 +791,10 @@ watch(
transition: background 0.1s ease;
}
.equipment-table :deep(.el-table__body tr.row-selected > td) {
background: #edf5ff !important;
}
/* ==================== Cell Helpers ==================== */
.cell-nowrap {
white-space: nowrap;
@@ -573,21 +825,151 @@ watch(
.status-pill {
display: inline-flex;
align-items: center;
padding: 2px 12px;
font-size: 12px;
padding: 2px 10px;
font-size: 11px;
font-weight: 700;
border-radius: 20px;
line-height: 1.6;
line-height: 1.7;
letter-spacing: 0.02em;
}
.status-pill--yes {
background: #dcfce7;
color: #16a34a;
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
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 {
background: #f5f5f5;
color: #737373;
background: #f1f5f9;
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 ==================== */
@@ -597,30 +979,33 @@ watch(
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
gap: 12px;
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #f5f5f5;
color: #c4c4c4;
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #e8f0fc, #dce8f8);
color: #4f7ecf;
box-shadow: 0 4px 12px rgba(79, 126, 207, 0.15);
}
.table-empty span {
font-size: 13px;
font-weight: 500;
color: #a3a3a3;
font-weight: 600;
color: #8aa0bd;
}
/* ==================== Drawer Editor ==================== */
:deep(.equipment-editor-drawer > .el-drawer__header) {
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) {
@@ -634,10 +1019,10 @@ watch(
.editor-title {
font-size: 18px;
font-weight: 700;
font-weight: 800;
color: #0a0a0a;
line-height: 1.2;
letter-spacing: -0.01em;
letter-spacing: -0.02em;
}
.equipment-form {
@@ -746,6 +1131,15 @@ watch(
/* ==================== Responsive ==================== */
@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--3 {
grid-template-columns: 1fr;
@@ -462,6 +462,32 @@ watch(
.page {
display: flex;
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 {
@@ -689,4 +715,5 @@ watch(
font-weight: 500;
letter-spacing: 0.02em;
}
</style>
+652 -58
View File
@@ -1,72 +1,147 @@
<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 class="overview-container">
<section class="overview-card">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--progress">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</span>
<div class="card-title">中心整体进度</div>
</div>
<div class="card-header-right">
<el-button size="small" @click="loadOverview" class="refresh-btn">
<template #icon>
<el-icon><Refresh /></el-icon>
</template>
刷新
</el-button>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span>
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
<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>
<StateLoading v-if="loading" :rows="6" />
<div v-else-if="centers.length === 0" class="overview-empty-panel">
<div class="overview-empty-content">
<div class="overview-empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<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 class="overview-empty-title">中心整体进度暂未生成</div>
<div class="overview-empty-desc">当前项目未配置中心或尚未形成可展示的中心进度数据</div>
</div>
</div>
<div v-else class="progress-list">
<CenterProgressRow
v-for="(center, index) in centers"
:key="center.center_id || center.center_name || index"
:center="center"
/>
</div>
</section>
<section class="overview-card">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--enrollment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</span>
<div>
<div class="card-title">入组进度</div>
<div class="card-subtitle">{{ enrollmentSummary }}</div>
<div class="overview-workbench">
<section class="overview-card overview-card--progress">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--progress">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</span>
<div class="card-title">中心整体进度</div>
</div>
<div class="card-header-right">
<el-button size="small" @click="loadOverview" class="refresh-btn">
<template #icon>
<el-icon><Refresh /></el-icon>
</template>
刷新
</el-button>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span>
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
</div>
</div>
</div>
<el-radio-group v-model="chartMode" size="small" class="mode-switch">
<el-radio-button label="center">按中心</el-radio-button>
<el-radio-button label="month">按月份</el-radio-button>
</el-radio-group>
</div>
<EnrollmentBarChart
:mode="chartMode"
:items="chartItems"
:loading="loading"
:empty-text="chartEmptyText"
/>
</section>
<StateLoading v-if="loading" :rows="6" />
<div v-else-if="centers.length === 0" class="overview-empty-panel">
<div class="overview-empty-content">
<div class="overview-empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<div class="overview-empty-title">中心整体进度暂未生成</div>
<div class="overview-empty-desc">当前项目未配置中心或尚未形成可展示的中心进度数据</div>
</div>
</div>
<div v-else class="progress-list">
<CenterProgressRow
v-for="(center, index) in centers"
:key="center.center_id || center.center_name || index"
:center="center"
/>
</div>
</section>
<section class="overview-card overview-card--enrollment">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--enrollment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</span>
<div>
<div class="card-title">入组进度</div>
<div class="card-subtitle">{{ enrollmentSummary }}</div>
</div>
</div>
<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="month">按月份</el-radio-button>
</el-radio-group>
</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
v-else
:mode="chartMode"
:items="chartItems"
:loading="loading"
:empty-text="chartEmptyText"
/>
</section>
</div>
</div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
@@ -80,18 +155,127 @@ import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
import { fetchProjectOverview } from "../../api/overview";
import { fetchSites } from "../../api/sites";
import { isTauriRuntime } from "../../runtime";
import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue";
import CenterProgressRow from "./project-overview/CenterProgressRow.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 isDesktop = isTauriRuntime();
const loading = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center");
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[] =>
siteList.map((site: any) => ({
@@ -218,6 +402,12 @@ watch(
gap: 10px;
}
.overview-workbench {
display: flex;
flex-direction: column;
gap: 10px;
}
.card-header-right {
display: flex;
align-items: center;
@@ -389,6 +579,401 @@ watch(
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) {
.overview-container {
padding: 8px;
@@ -406,5 +991,14 @@ watch(
.card-header-right {
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>
@@ -5,15 +5,17 @@ import { resolve } from "node:path";
const readSubjectManagementSource = () => readFileSync(resolve(__dirname, "./SubjectManagement.vue"), "utf8");
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();
expect(source).toContain("isSystemAdmin");
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("canUseApiPermission");
expect(source).toContain('canUseApiPermission("subjects:create")');
expect(source).toContain('canUseApiPermission("subjects:update")');
expect(source).toContain('canUseApiPermission("subjects:delete")');
expect(source).toContain('v-if="canCreateSubject"');
expect(source).toContain('v-if="canUpdateSubject"');
expect(source).toContain('v-if="canDeleteSubject"');
});
});
@@ -24,6 +26,8 @@ describe("SubjectManagement drawer editor", () => {
expect(source).toContain("SubjectEditorDrawer");
expect(source).toContain("subjectDrawerVisible");
expect(source).toContain("editingSubjectId");
expect(source).toContain(':subject-id="editingSubjectId || undefined"');
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", () => {
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("@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("if (!isDesktop || !row?.id) return;");
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('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>
<div class="page">
<div class="page" :class="{ 'subject-page--desktop': isDesktop }">
<div class="table-card">
<div class="table-card-toolbar">
<div class="toolbar-filters">
@@ -24,7 +24,6 @@
</div>
</div>
<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-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
@@ -32,8 +31,13 @@
</div>
</div>
<div class="subject-workbench">
<div class="subject-table-pane" tabindex="0" @keydown.enter.prevent="openSelectedSubject">
<div class="subject-workbench" :class="{ 'is-desktop': isDesktop }">
<div
class="subject-table-pane"
ref="subjectTablePaneRef"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedSubject"
>
<el-table
:data="pagedItems"
v-loading="loading"
@@ -41,13 +45,11 @@
class="subject-table"
:row-class-name="subjectRowClass"
highlight-current-row
@selection-change="onSelectionChange"
@row-click="selectSubject"
@row-click="handleSubjectRowClick"
@row-dblclick="openSubjectDetail"
@row-contextmenu="openSubjectContextMenu"
table-layout="fixed"
>
<el-table-column type="selection" width="42" />
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope">
<div class="subject-info-cell">
@@ -73,7 +75,7 @@
<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>
</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">
<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)">
@@ -104,7 +106,7 @@
</div>
</div>
<aside class="subject-preview-pane">
<aside v-if="isDesktop" class="subject-preview-pane">
<template v-if="selectedSubject">
<div class="preview-head">
<div>
@@ -130,6 +132,26 @@
{{ badge.label }}
</span>
</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>
<div v-else class="preview-empty">
<div class="empty-icon">
@@ -142,7 +164,7 @@
</div>
<div
v-if="contextMenu.visible"
v-if="isDesktop && contextMenu.visible && selectedSubject"
class="subject-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
@@ -154,13 +176,17 @@
type="button"
class="danger"
:disabled="!selectedSubject || isInactiveSite(selectedSubject.site_id)"
@click="selectedSubject && remove(selectedSubject)"
@click="removeSelectedSubject"
>
删除
</button>
</div>
<SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" />
<SubjectEditorDrawer
v-model="subjectDrawerVisible"
:subject-id="editingSubjectId || undefined"
@success="handleSubjectEditorSuccess"
/>
</div>
</template>
@@ -179,15 +205,18 @@ import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { isTauriRuntime } from "../../runtime";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const loading = ref(false);
const subjectDrawerVisible = ref(false);
const editingSubjectId = ref("");
const subjectTablePaneRef = ref<HTMLElement | null>(null);
const items = ref<any[]>([]);
const selectedSubjectId = ref("");
const selectedRows = ref<any[]>([]);
const contextMenu = ref({ visible: false, x: 0, y: 0 });
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const siteMap = ref<Record<string, string>>({});
@@ -215,6 +244,7 @@ const canUseApiPermission = (operationKey: string) => {
return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
};
const canCreateSubject = computed(() => canUseApiPermission("subjects:create"));
const canUpdateSubject = computed(() => canUseApiPermission("subjects:update"));
const canDeleteSubject = computed(() => canUseApiPermission("subjects:delete"));
const loadSites = async () => {
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 handleSubjectEditorSuccess = () => { load(); };
const selectedSubject = computed(() => items.value.find((item) => item.id === selectedSubjectId.value) || null);
const selectSubject = (row: any) => {
contextMenu.value.visible = false;
closeSubjectContextMenu();
if (!row?.id) return;
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) => {
if (!row?.id) return;
@@ -268,21 +310,19 @@ const openSelectedSubject = () => {
contextMenu.value.visible = false;
if (selectedSubject.value?.id) goDetail(selectedSubject.value.id);
};
const onSelectionChange = (rows: any[]) => {
selectedRows.value = rows;
};
const openSubjectContextMenu = (row: any, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
if (!row?.id) return;
selectedSubjectId.value = row.id;
selectSubject(row);
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 152)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 122)),
};
};
const closeSubjectContextMenu = () => {
contextMenu.value.visible = false;
if (!contextMenu.value.visible) return;
contextMenu.value = { visible: false, x: 0, y: 0 };
};
const copySelectedSubjectNo = async () => {
contextMenu.value.visible = false;
@@ -291,6 +331,28 @@ const copySelectedSubjectNo = async () => {
await navigator.clipboard?.writeText(subjectNo);
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) => {
contextMenu.value.visible = false;
const studyId = study.currentStudy?.id;
@@ -349,6 +411,7 @@ watch(() => filteredItems.value.length, (total) => {
});
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
watch(pagedItems, (rows) => {
if (!isDesktop) return;
if (!rows.length) {
selectedSubjectId.value = "";
return;
@@ -359,7 +422,7 @@ watch(pagedItems, (rows) => {
});
onMounted(async () => {
document.addEventListener("click", closeSubjectContextMenu);
if (isDesktop) document.addEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
loadSites();
load();
@@ -369,7 +432,7 @@ onMounted(async () => {
});
onBeforeUnmount(() => {
document.removeEventListener("click", closeSubjectContextMenu);
if (isDesktop) document.removeEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup?.();
});
</script>
@@ -383,25 +446,53 @@ onBeforeUnmount(() => {
background: #ffffff;
}
.subject-page--desktop {
height: 100%;
min-height: 0;
}
.table-card {
background: #ffffff;
overflow: hidden;
}
.subject-page--desktop .table-card {
flex: 1;
display: flex;
min-height: 0;
flex-direction: column;
}
.subject-workbench {
min-width: 0;
min-height: 0;
}
.subject-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
min-height: 520px;
flex: 1;
min-height: 0;
}
.subject-table-pane {
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid #edf1f7;
min-height: 0;
outline: none;
}
.subject-workbench.is-desktop .subject-table-pane {
border-right: 1px solid #edf1f7;
}
.subject-preview-pane {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
padding: 16px;
background: #fbfcff;
}
@@ -431,13 +522,6 @@ onBeforeUnmount(() => {
flex-shrink: 0;
}
.selection-count {
color: #64748b;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.filter-item {
display: flex;
flex-direction: column;
@@ -473,6 +557,8 @@ onBeforeUnmount(() => {
/* ==================== Table ==================== */
.subject-table {
flex: 1;
min-height: 0;
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
}
@@ -546,7 +632,7 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 10px 12px;
margin: 16px 0 0;
margin: 0;
}
.preview-list dt {
@@ -566,10 +652,17 @@ onBeforeUnmount(() => {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty {
flex: 1;
min-height: 360px;
display: flex;
flex-direction: column;
@@ -651,6 +744,7 @@ onBeforeUnmount(() => {
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: auto;
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
}
@@ -680,8 +774,8 @@ onBeforeUnmount(() => {
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.subject-workbench { grid-template-columns: 1fr; }
.subject-table-pane { border-right: 0; }
.subject-workbench.is-desktop { grid-template-columns: 1fr; }
.subject-workbench.is-desktop .subject-table-pane { border-right: 0; }
.subject-preview-pane { display: none; }
.toolbar-filters { flex-direction: column; align-items: stretch; }
.filter-input, .filter-select { width: 100%; }
@@ -1,5 +1,5 @@
<template>
<div class="enrollment-chart">
<div class="enrollment-chart" :class="{ 'enrollment-chart--compact': compact }">
<StateLoading v-if="loading" :rows="5" />
<div v-else-if="items.length === 0" class="chart-empty-shell">
<div class="chart-empty-body">
@@ -9,7 +9,7 @@
</div>
<div v-else class="chart-body">
<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">
<defs>
<linearGradient :id="gradientTargetId" x1="0" y1="0" x2="0" y2="1">
@@ -105,23 +105,40 @@ const props = withDefaults(
items: EnrollmentBarItem[];
loading?: boolean;
emptyText?: string;
compact?: boolean;
}>(),
{
loading: false,
emptyText: "暂无入组数据",
compact: false,
}
);
const compact = computed(() => props.compact);
const showTarget = computed(() => props.mode === "center");
const chartWidth = 960;
const chartHeight = 260;
const axisPadding = {
top: 28,
right: 32,
bottom: 48,
left: 56,
};
const baseChartWidth = computed(() => (compact.value ? 560 : 960));
const chartHeight = computed(() => (compact.value ? 240 : 260));
const axisPadding = computed(() => ({
top: compact.value ? 24 : 28,
right: compact.value ? 24 : 32,
bottom: compact.value ? 42 : 48,
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 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 plotWidth = computed(() => chartWidth - axisPadding.left - axisPadding.right);
const plotHeight = computed(() => chartHeight - axisPadding.top - axisPadding.bottom);
const axisLeft = axisPadding.left;
const axisRight = chartWidth - axisPadding.right;
const axisTop = axisPadding.top;
const axisBottom = chartHeight - axisPadding.bottom;
const labelY = axisBottom + 20;
const valueGap = 10;
const barRadius = 6;
const plotWidth = computed(() => chartWidth.value - axisPadding.value.left - axisPadding.value.right);
const plotHeight = computed(() => chartHeight.value - axisPadding.value.top - axisPadding.value.bottom);
const axisLeft = computed(() => axisPadding.value.left);
const axisRight = computed(() => chartWidth.value - axisPadding.value.right);
const axisTop = computed(() => axisPadding.value.top);
const axisBottom = computed(() => chartHeight.value - axisPadding.value.bottom);
const labelY = computed(() => axisBottom.value + (compact.value ? 18 : 20));
const valueGap = computed(() => (compact.value ? 8 : 10));
const barRadius = computed(() => (compact.value ? 5 : 6));
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 barHeight = (value: number) => {
if (value <= 0) return 0;
return (value / axisMax.value) * plotHeight.value;
};
const barTop = (value: number) => axisTop + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const barTop = (value: number) => axisTop.value + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop.value + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const valueY = (item: EnrollmentBarItem) => {
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) => {
if (label.length <= 8) return label;
return `${label.slice(0, 7)}...`;
const limit = compact.value ? 7 : 8;
if (label.length <= limit) return label;
return `${label.slice(0, limit - 1)}...`;
};
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;
background: linear-gradient(180deg, #fafbfd 0%, #f6f8fb 100%);
position: relative;
aspect-ratio: 960 / 260;
width: 100%;
aspect-ratio: var(--chart-aspect);
min-height: 200px;
}
@@ -297,6 +316,33 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
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) {
.bar-label {
font-size: 11px;