优化桌面端界面布局
This commit is contained in:
@@ -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 权限请求时继续保持“待授权”,避免错误显示为“已拒绝”。
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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\"");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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);");
|
||||
});
|
||||
});
|
||||
@@ -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-shell(main-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)"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user