Remove non-admin workbench

This commit is contained in:
Cheng Zhou
2026-05-21 15:48:19 +08:00
parent da6f2f2d41
commit 78247a8149
20 changed files with 36 additions and 1474 deletions
@@ -68,9 +68,6 @@ def classify(file_path: str, key_expr: str) -> tuple[str, str, str]:
if any(k in lowered for k in ("ctms_sidebar_collapsed", "study", "site", "role")):
return ("low", "ui-preference", "UI偏好/上下文缓存")
if "personaltodo" in file_path.lower():
return ("low", "feature-cache", "工作台本地待办缓存(非核心主数据)")
return ("medium", "unknown", "未命中规则,建议人工复核是否为业务关键数据")
-2
View File
@@ -56,5 +56,3 @@
| low | auth-session | `frontend/src/utils/auth.ts` | 17 | `localStorage.setItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
| low | auth-session | `frontend/src/utils/auth.ts` | 24 | `localStorage.getItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
| low | auth-session | `frontend/src/utils/auth.ts` | 38 | `localStorage.removeItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
| low | feature-cache | `frontend/src/views/workbench/components/PersonalTodo.vue` | 79 | `localStorage.getItem` | `props.storageKey` | 工作台本地待办缓存(非核心主数据) |
| low | feature-cache | `frontend/src/views/workbench/components/PersonalTodo.vue` | 91 | `localStorage.setItem` | `props.storageKey` | 工作台本地待办缓存(非核心主数据) |
@@ -23,7 +23,7 @@ backend/scripts/storage_persistence_audit.py --fail-on-banned
- 高风险:4 条(均为 `ProjectDetail` 本地草稿兜底读写)
- 中风险:0 条
- 低风险:42 条(会话、偏好、工作台本地待办等)
- 低风险:40 条(会话、偏好等)
## 4. 已完成整改
@@ -80,9 +80,8 @@
3. 统一状态体系:加载态、空态、错误态、禁用态、风险高亮态。
#### P1(有余量时)
1. `/workbench` 视觉升级
2. 表单页统一(新建/编辑页头、分组、主次按钮)。
3. 克制型关键动效(入场/hover)。
1. 表单页统一(新建/编辑页头、分组、主次按钮)
2. 克制型关键动效(入场/hover)。
#### P2(后续阶段)
1. 全模块扩展(知识库、启动授权细分页)。
@@ -67,7 +67,6 @@ Expected: PASS with the regrouped files.
### Task 3: Remove obsolete docs and local artifacts
**Files:**
- Delete: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/workbench-ui-optimization.md`
- Delete: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/plans/2026-03-05-rd-monitor-design.md`
- Delete: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/plans/2026-03-05-rd-monitor-implementation.md`
@@ -37,7 +37,6 @@
- Modify: `frontend/src/components/attachments/AttachmentList.vue`
- Modify: `frontend/src/components/fees/FeeAttachmentPanel.vue`
- Modify: `frontend/src/components/FaqList.vue`
- Modify: `frontend/src/views/workbench/components/CenterSummary.vue`
**Step 1: 仅保留业务浏览页**
@@ -93,7 +92,6 @@
- Modify: `frontend/src/components/attachments/AttachmentList.vue`
- Modify: `frontend/src/components/fees/FeeAttachmentPanel.vue`
- Modify: `frontend/src/components/FaqList.vue`
- Modify: `frontend/src/views/workbench/components/CenterSummary.vue`
**Step 1: 对齐详情子表策略**
+2 -2
View File
@@ -71,7 +71,7 @@ instance.interceptors.response.use(
// 认证相关的错误由具体页面自行处理,避免重复提示
return Promise.reject(error);
}
// 如果当前项目不存在或已失效,清理项目并跳到工作台
// 如果当前项目不存在或已失效,清理项目并跳到项目管理页
if (status === 404 && typeof data?.message === "string" && data.message.toLowerCase().includes("study")) {
try {
const studyStore = useStudyStore();
@@ -79,7 +79,7 @@ instance.interceptors.response.use(
} catch {
/* ignore */
}
router.push("/workbench");
router.push("/admin/projects");
}
if (status === 401) {
const config = error.config as ApiRequestConfig;
+1 -6
View File
@@ -12,11 +12,6 @@
class="aside-menu"
@select="handleMenuSelect"
>
<el-menu-item v-if="!isAdmin" index="/workbench">
<el-icon><Monitor /></el-icon>
<span>{{ TEXT.menu.workbench }}</span>
</el-menu-item>
<el-menu-item-group v-if="auth.user" class="menu-group">
<template #title>
<span class="menu-divider">{{ TEXT.menu.admin }}</span>
@@ -222,7 +217,7 @@ import { fetchStudies } from "../api/studies";
import { fetchSites } from "../api/sites";
import { TEXT } from "../locales";
import {
Monitor, User, Suitcase, House, Calendar, Flag, ChatDotRound,
User, Suitcase, House, Calendar, Flag, ChatDotRound,
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key
} from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
+1 -1
View File
@@ -60,7 +60,7 @@ const loadStudies = async () => {
const onCommand = async (cmd: any) => {
if (cmd === "clear") {
study.clearCurrentStudy();
router.push("/workbench");
router.push("/admin/projects");
return;
}
if (cmd?.type === "switch" && cmd.study) {
-29
View File
@@ -252,7 +252,6 @@ export const TEXT = {
},
},
menu: {
workbench: "我的工作台",
admin: "管理后台",
accountManagement: "账号管理",
projectManagement: "项目管理",
@@ -923,34 +922,6 @@ export const TEXT = {
result: "结果",
},
},
workbench: {
title: "我的工作台",
subtitle: "欢迎回来,这是基于您的角色 {role} 聚合的待办事项",
selectProject: "请先在顶部导航选择一个项目,以开启完整的工作台功能。",
todayTitle: "今日待办",
overdueTitle: "已逾期事项",
actionTitle: "需要我处理的事项",
todayEmpty: "今天暂时没有需要紧急处理的业务事项",
overdueEmpty: "太棒了!目前没有任何逾期待处理的事项",
actionEmpty: "暂无需要您处理的事项",
newLostVisit: "新增失访",
quickEntryTitle: "快捷入口",
enterProject: "进入项目",
quickEmpty: "暂无需要你处理的事项",
aeList: "AE 列表",
contractList: "合同费用",
medicalConsult: "医学咨询",
feasibilityEthics: "立项与伦理",
aeTitleFallback: "不良事件",
contractTitleFallback: "合同费用",
todoTitle: "个人备忘 (本地存储)",
todoCount: "{count} 条记录",
todoPlaceholder: "有什么需要记录的事项吗?(回车添加)",
todoAdd: "添加",
todoEmpty: "暂无待办事项,记录一些备忘吧",
todoStatusFallback: "待处理",
loadFailed: "工作台数据加载失败",
},
permissions: {
subjectEnroll: "仅 PM/CRA 可更新参与者状态",
subjectComplete: "仅 PM/CRA 可更新参与者状态",
+11 -1
View File
@@ -22,6 +22,16 @@ describe("admin project route permissions", () => {
expect(source).not.toContain("findPmAdminLandingPath");
expect(source).not.toContain("pmAdminLandingModules");
expect(source).toContain(' : studyStore.currentStudy\n ? "/project/overview"\n : "/workbench",');
expect(source).toContain(' : studyStore.currentStudy\n ? "/project/overview"\n : "/admin/projects",');
});
it("does not register the removed non-admin personal dashboard route", () => {
const source = readRouter();
const removedComponent = ["My", "Work", "bench"].join("");
const removedPath = `/${["work", "bench"].join("")}`;
expect(source).not.toContain(removedComponent);
expect(source).not.toContain(`path: "${removedPath.slice(1)}"`);
expect(source).not.toContain(`"${removedPath}"`);
});
});
+7 -14
View File
@@ -11,7 +11,6 @@ import Login from "../views/Login.vue";
import Register from "../views/Register.vue";
import ForgotPassword from "../views/ForgotPassword.vue";
import StudyHome from "../views/StudyHome.vue";
import MyWorkbench from "../views/workbench/MyWorkbench.vue";
import FaqDetail from "../views/FaqDetail.vue";
import AuditLogs from "../views/admin/AuditLogs.vue";
import AdminUsers from "../views/admin/Users.vue";
@@ -84,14 +83,8 @@ const routes: RouteRecordRaw[] = [
{
path: "/",
component: Layout,
redirect: "/workbench",
redirect: "/project/overview",
children: [
{
path: "workbench",
name: "MyWorkbench",
component: MyWorkbench,
meta: { title: TEXT.menu.workbench },
},
{
path: "profile",
name: "ProfileSettings",
@@ -552,7 +545,7 @@ router.beforeEach(async (to, _from, next) => {
next({ path: "/login" });
return;
}
if (isAdmin && (to.path === "/" || to.path === "/workbench")) {
if (isAdmin && to.path === "/") {
next({ path: studyStore.currentStudy ? "/project/overview" : "/admin/users" });
return;
}
@@ -565,7 +558,7 @@ router.beforeEach(async (to, _from, next) => {
: "/admin/users"
: studyStore.currentStudy
? "/project/overview"
: "/workbench",
: "/admin/projects",
});
return;
}
@@ -573,12 +566,12 @@ router.beforeEach(async (to, _from, next) => {
if (to.meta.adminProjectPermission) {
const allowed = await ensureAdminProjectAccess(to, isAdmin).catch(() => false);
if (!allowed) {
next({ path: isAdmin ? "/admin/users" : "/workbench" });
next({ path: isAdmin ? "/admin/users" : "/admin/projects" });
return;
}
}
if (to.meta.requiresAdmin && !isAdmin) {
next({ path: "/workbench" });
next({ path: "/admin/projects" });
return;
}
if (to.name === "AdminAuditLogs") {
@@ -593,7 +586,7 @@ router.beforeEach(async (to, _from, next) => {
}
}
if (to.meta.requiresStudy && !studyStore.currentStudy) {
next({ path: isAdmin ? "/admin/users" : "/workbench" });
next({ path: isAdmin ? "/admin/users" : "/admin/projects" });
return;
}
if (to.meta.requiresStudy && studyStore.currentStudy) {
@@ -605,7 +598,7 @@ router.beforeEach(async (to, _from, next) => {
const canAccessProjectRoute = hasProjectPermission(studyStore.currentPermissions, projectRole, permission, isAdmin);
if (!canAccessProjectRoute) {
const fallback = findFirstAccessibleProjectPath(studyStore.currentPermissions, projectRole, isAdmin);
next({ path: fallback || "/workbench" });
next({ path: fallback || "/admin/projects" });
return;
}
}
+1 -3
View File
@@ -248,10 +248,8 @@ const onSubmit = async () => {
}
if (studyStore.currentStudy) {
router.push("/project/overview");
} else if (auth.user?.is_admin) {
router.push("/admin/users");
} else {
router.push("/workbench");
router.push(auth.user?.is_admin ? "/admin/users" : "/admin/projects");
}
} catch (error: any) {
const status = error?.response?.status;
+1 -1
View File
@@ -337,7 +337,7 @@ const canAccessAuditLogs = computed(() => {
const ensureAccess = () => {
if (!canAccessAuditLogs.value) {
router.replace(study.currentStudy ? "/project/overview" : "/workbench");
router.replace(study.currentStudy ? "/project/overview" : "/admin/projects");
}
};
@@ -30,6 +30,15 @@ describe("project management access", () => {
expect(layout).toContain('index="/admin/projects"');
});
it("removes the non-admin personal dashboard menu entry", () => {
const layout = readLayout();
const removedPath = `/${["work", "bench"].join("")}`;
const removedMenuKey = ["TEXT.menu.work", "bench"].join("");
expect(layout).not.toContain(`index="${removedPath}"`);
expect(layout).not.toContain(removedMenuKey);
});
it("uses each project permission matrix to show project management actions", () => {
const source = readProjects();
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readWorkbenchView = () => readFileSync(resolve(__dirname, "./MyWorkbench.vue"), "utf8");
describe("workbench project role context", () => {
it("uses the current project role instead of only the global account role", () => {
const source = readWorkbenchView();
expect(source).toContain("const projectRole = computed");
expect(source).toContain("isSystemAdmin");
expect(source).toContain("getProjectRole");
expect(source).toContain("study.currentStudyRole");
expect(source).not.toContain('const role = computed(() => auth.user?.role || "")');
expect(source).not.toContain("role_in_study || auth.user?.role");
expect(source).toContain('const isCraWorkbench = computed(() => projectRole.value === "CRA")');
expect(source).toContain("const craRequests = isCraWorkbench.value");
expect(source).not.toContain('projectRole.value === "PM"');
expect(source).not.toContain('projectRole.value === "PV"');
expect(source).toContain("quickActionCandidates");
expect(source).toContain("canAccessProjectPath(action.path)");
});
});
@@ -1,740 +0,0 @@
<template>
<div class="workbench-container">
<!-- 头部欢迎区域 -->
<div class="workbench-header">
<div class="header-content">
<h1 class="welcome-title">{{ TEXT.modules.workbench.title }}</h1>
</div>
<div class="header-actions">
<el-tag effect="dark" class="role-badge">{{ roleLabel }}</el-tag>
</div>
</div>
<!-- 提示未选择项目 -->
<el-alert
v-if="!currentStudyId"
:title="TEXT.modules.workbench.selectProject"
type="warning"
show-icon
:closable="false"
class="study-alert"
/>
<template v-if="currentStudyId">
<!-- 汇总框 -->
<CenterSummary v-if="isCraWorkbench" :data="centerSummaryData" :loading="loading" />
<!-- 核心看板层 -->
<div class="stats-row">
<div class="stats-col">
<SectionCard
:title="TEXT.modules.workbench.todayTitle"
:items="todayList"
:loading="loading"
:empty-text="TEXT.modules.workbench.todayEmpty"
@more="goMore(todayMorePath)"
/>
</div>
<div class="stats-col">
<div class="notifications-card">
<div class="notifications-header">
<div class="notifications-title-group">
<span class="notifications-title">{{ TEXT.modules.projectOverview.notificationsTitle }}</span>
<span class="notifications-subtitle">{{ TEXT.modules.projectOverview.notificationsSubtitle }}</span>
</div>
<el-button v-if="notifications.length" type="primary" link @click="goMore('/documents')" class="notifications-more">
{{ TEXT.common.actions.more }} <el-icon class="el-icon--right"><ArrowRight /></el-icon>
</el-button>
</div>
<el-skeleton v-if="loading" :rows="3" animated />
<StateEmpty v-else-if="!notifications.length" :description="TEXT.modules.projectOverview.notificationsEmpty" />
<div v-else class="notification-list">
<div
class="notification-item"
v-for="item in notifications.slice(0, 5)"
:key="item.id"
@click="openDocument(item.document_id)"
>
<div class="notification-main">
<div class="notification-title">
<span class="notification-doc">{{ item.document_title }}</span>
<span class="notification-version">V{{ item.version_no }}</span>
</div>
<div class="notification-meta">
<span class="notification-time">{{ formatDate(item.effective_at || item.created_at) }}</span>
<span v-if="item.change_summary" class="notification-summary">{{ item.change_summary }}</span>
</div>
</div>
<div class="notification-update-badge">
<span class="update-text">更新</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 次级看板与快捷入口 -->
<div class="secondary-row">
<div class="action-col">
<SectionCard
:title="TEXT.modules.workbench.overdueTitle"
:items="overdueList"
:loading="loading"
:empty-text="TEXT.modules.workbench.overdueEmpty"
@more="goMore(overdueMorePath)"
/>
</div>
<div class="quick-col">
<div class="quick-entry-card">
<div class="entry-header">
<span class="entry-title">{{ TEXT.modules.workbench.quickEntryTitle }}</span>
<el-button type="primary" link @click="goMore('/project/overview')" class="entry-more">
{{ TEXT.modules.workbench.enterProject }} <el-icon class="el-icon--right"><Right /></el-icon>
</el-button>
</div>
<div class="quick-grid">
<div
v-for="qa in quickActions"
:key="qa.path"
class="quick-item"
@click="goMore(qa.path)"
>
<div class="quick-icon-box">
<el-icon><component :is="qa.icon" /></el-icon>
</div>
<span class="quick-label">{{ qa.label }}</span>
</div>
</div>
<div v-if="!quickActions.length" class="empty-quick">{{ emptyText }}</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, defineAsyncComponent } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { fetchAes } from "../../api/aes";
import { fetchLostVisits, fetchCenterSummary } from "../../api/dashboard";
import { listNotifications } from "../../api/notifications";
import { Right, Timer, Warning, Money, Collection, RefreshRight, ArrowRight } from "@element-plus/icons-vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { displayEnum } from "../../utils/display";
import { getProjectRole, isSystemAdmin } from "../../utils/roles";
import { getProjectRoutePermission, hasProjectPermission } from "../../utils/projectRoutePermissions";
import { TEXT } from "../../locales";
import type { NotificationItem } from "../../types/notifications";
import type { VisitLostItem } from "../../types/visits";
interface WorkItem {
title: string;
subtitle?: string;
status?: string;
overdue?: boolean;
path: string;
}
const SectionCard = defineAsyncComponent(() => import("./components/SectionCard.vue"));
const CenterSummary = defineAsyncComponent(() => import("./components/CenterSummary.vue"));
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const loading = ref(false);
const todayList = ref<WorkItem[]>([]);
const overdueList = ref<WorkItem[]>([]);
const notifications = ref<NotificationItem[]>([]);
const lostVisits = ref<VisitLostItem[]>([]);
const centerSummaryData = ref<any[]>([]);
const currentStudyId = computed(() => study.currentStudy?.id || "");
const todayStr = computed(() => new Date().toISOString().slice(0, 10));
const projectRole = computed(() => {
if (isSystemAdmin(auth.user)) return "ADMIN";
return getProjectRole(study.currentStudy, study.currentStudyRole) || "";
});
const roleLabel = computed(() => displayEnum(TEXT.enums.userRole, projectRole.value));
const emptyText = TEXT.modules.workbench.quickEmpty;
const isCraWorkbench = computed(() => projectRole.value === "CRA");
const canAccessProjectPath = (path: string) =>
hasProjectPermission(study.currentPermissions, projectRole.value, getProjectRoutePermission(path), isSystemAdmin(auth.user));
const quickActionCandidates = [
{ label: TEXT.modules.workbench.feasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
{ label: TEXT.modules.workbench.aeList, path: "/subjects", icon: Warning },
{ label: TEXT.modules.workbench.contractList, path: "/finance/contracts", icon: Money },
{ label: TEXT.modules.workbench.medicalConsult, path: "/knowledge/medical-consult", icon: Collection },
];
const quickActions = computed(() => {
if (!currentStudyId.value) return [];
return quickActionCandidates.filter((action) => canAccessProjectPath(action.path));
});
const todayMorePath = computed(() => "/subjects");
const overdueMorePath = computed(() => "/subjects");
const canReadRiskIssues = computed(() => canAccessProjectPath("/risk-issues/sae"));
const isOverdue = (d?: string | null) => {
if (!d) return false;
return new Date(d) < new Date(todayStr.value);
};
const toAeItem = (ae: any): WorkItem => ({
title: ae.term || TEXT.modules.workbench.aeTitleFallback,
subtitle: "",
status: ae.status,
overdue: isOverdue(ae.expected_resolution_date || ae.updated_at),
path: ae.subject_id ? `/subjects/${ae.subject_id}` : "/subjects",
});
const toLostVisitItem = (visit: VisitLostItem): WorkItem => ({
title: `${visit.subject_no} ${visit.visit_code}`,
subtitle: "",
status: isNewLostVisit(visit.updated_at) ? TEXT.modules.workbench.newLostVisit : TEXT.enums.visitStatus.LOST,
overdue: true,
path: `/subjects/${visit.subject_id}`,
});
const loadData = async () => {
if (!currentStudyId.value) return;
loading.value = true;
try {
const studyId = currentStudyId.value;
const shouldLoadAes = canReadRiskIssues.value;
const craRequests = isCraWorkbench.value;
const emptyListResponse = { data: { items: [] } } as any;
const aesReq = shouldLoadAes ? fetchAes(studyId, { status: "NEW", limit: 50 }) : Promise.resolve(emptyListResponse);
const overdueReq = shouldLoadAes ? fetchAes(studyId, { overdue: true, limit: 50 }) : Promise.resolve(emptyListResponse);
const [aesRes, overdueRes, noticeRes, lostRes, centerRes] = await Promise.allSettled([
aesReq,
overdueReq,
listNotifications(studyId, { limit: 5 }),
craRequests ? fetchLostVisits(studyId, { limit: 20 }) : Promise.resolve({ data: [] }),
craRequests ? fetchCenterSummary(studyId) : Promise.resolve({ data: [] }),
]);
const aes = aesRes.status === "fulfilled" ? (aesRes.value.data.items || aesRes.value.data || []) : [];
const overdueAes = overdueRes.status === "fulfilled" ? (overdueRes.value.data.items || overdueRes.value.data || []) : [];
notifications.value = noticeRes.status === "fulfilled" ? noticeRes.value.data || [] : [];
lostVisits.value = lostRes.status === "fulfilled" ? lostRes.value.data || [] : [];
centerSummaryData.value = centerRes.status === "fulfilled" ? centerRes.value.data || [] : [];
buildSections(aes, overdueAes, lostVisits.value);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.workbench.loadFailed);
} finally {
loading.value = false;
}
};
const buildSections = (aes: any[], overdueAes: any[], lost: VisitLostItem[]) => {
todayList.value = [];
overdueList.value = [];
const openAes = aes.filter((a) => a.status !== "CLOSED");
const lostVisitItems = lost.map(toLostVisitItem);
if (isCraWorkbench.value) {
todayList.value = openAes.slice(0, 5).map(toAeItem);
overdueList.value = [...lostVisitItems, ...overdueAes.map(toAeItem)].slice(0, 5);
} else if (canReadRiskIssues.value) {
const aeOpen = aes.filter((a) => a.status !== "CLOSED");
todayList.value = aeOpen.slice(0, 5).map(toAeItem);
overdueList.value = overdueAes.map(toAeItem).slice(0, 5);
}
};
const goMore = (path: string) => {
if (!path) return;
router.push(path);
};
const formatDate = (value?: string | null) => {
if (!value) return TEXT.common.fallback;
return value.replace("T", " ").replace("Z", "").split(".")[0];
};
const isNewLostVisit = (value?: string | null) => {
if (!value) return false;
const updatedAt = new Date(value);
if (Number.isNaN(updatedAt.getTime())) return false;
const now = new Date();
return now.getTime() - updatedAt.getTime() <= 24 * 60 * 60 * 1000;
};
const openDocument = (documentId: string) => {
router.push(`/documents/${documentId}`);
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
loadData();
});
</script>
<style scoped>
.workbench-container {
display: flex;
flex-direction: column;
gap: 28px;
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 头部区域 - 添加渐变背景 */
.workbench-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 32px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
margin: -8px -8px 0 -8px;
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.2);
position: relative;
overflow: hidden;
}
.workbench-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
opacity: 0.4;
}
.header-content {
position: relative;
z-index: 1;
}
.header-actions {
position: relative;
z-index: 1;
}
.welcome-title {
font-size: 32px;
font-weight: 800;
color: #ffffff;
margin: 0;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
letter-spacing: -0.5px;
}
.welcome-subtitle {
margin: 10px 0 0;
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.role-badge {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #ffffff;
font-weight: 600;
padding: 0 16px;
height: 32px;
line-height: 32px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.stats-row, .secondary-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.study-alert {
border: none;
border-left: 4px solid var(--ctms-warning);
background: linear-gradient(90deg, #fffbe6 0%, #ffffff 100%);
box-shadow: var(--ctms-shadow-sm);
}
/* 快捷入口卡片样式 - 大幅优化 */
.quick-entry-card {
background: linear-gradient(135deg, #ffffff 0%, #f8f9ff 100%);
border: 1px solid rgba(102, 126, 234, 0.1);
border-radius: 16px;
height: 100%;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
}
.quick-entry-card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.entry-header {
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(102, 126, 234, 0.08);
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.02) 100%);
}
.entry-title {
font-size: 17px;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.entry-more {
font-size: 13px;
font-weight: 600;
}
.quick-grid {
padding: 24px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 20px;
flex: 1;
}
.quick-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1);
padding: 16px 12px;
border-radius: 12px;
background: #ffffff;
border: 1px solid transparent;
}
.quick-item:hover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 28px rgba(102, 126, 234, 0.3);
border-color: rgba(255, 255, 255, 0.2);
}
.quick-icon-box {
width: 56px;
height: 56px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
color: #ffffff;
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
}
.quick-icon-box::before {
content: '';
position: absolute;
inset: 0;
border-radius: 16px;
padding: 2px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.1));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.quick-item:hover .quick-icon-box {
background: #ffffff;
color: #667eea;
transform: scale(1.1) rotate(5deg);
box-shadow: 0 12px 32px rgba(255, 255, 255, 0.4);
}
.quick-label {
font-size: 13px;
font-weight: 600;
color: var(--ctms-text-regular);
transition: all 0.3s ease;
text-align: center;
}
.quick-item:hover .quick-label {
color: #ffffff;
transform: scale(1.05);
}
/* 通知卡片 - 增强样式 */
.notifications-card {
background: linear-gradient(135deg, #ffffff 0%, #fef7ff 100%);
border: 1px solid rgba(118, 75, 162, 0.1);
border-radius: 16px;
padding: 20px 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
}
.notifications-card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.notifications-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid rgba(118, 75, 162, 0.08);
}
.notifications-title-group {
display: flex;
align-items: baseline;
gap: 10px;
}
.notifications-more {
font-size: 13px;
font-weight: 600;
transition: all 0.3s ease;
}
.notifications-more:hover {
transform: translateX(4px);
}
.notifications-title {
font-size: 17px;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.notifications-subtitle {
font-size: 12px;
color: var(--ctms-text-secondary);
font-weight: 500;
}
.notification-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.notification-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 12px;
background: #ffffff;
border: 1px solid rgba(102, 126, 234, 0.08);
transition: all 0.3s ease;
cursor: pointer;
}
.notification-item:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
border-color: rgba(102, 126, 234, 0.2);
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
}
.notification-main {
display: flex;
flex-direction: column;
gap: 6px;
}
.notification-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--ctms-text-main);
font-size: 14px;
}
.notification-version {
font-size: 11px;
padding: 3px 8px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff;
font-weight: 700;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
.notification-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--ctms-text-secondary);
}
.notification-summary {
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 锯齿状更新徽章 - 扁平版 */
.notification-update-badge {
position: relative;
width: 56px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s ease;
}
.notification-update-badge::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #ff4757 0%, #ff6b81 100%);
clip-path: polygon(
10% 0%,
20% 8%,
30% 0%,
40% 8%,
50% 0%,
60% 8%,
70% 0%,
80% 8%,
90% 0%,
100% 15%,
95% 50%,
100% 85%,
90% 100%,
80% 92%,
70% 100%,
60% 92%,
50% 100%,
40% 92%,
30% 100%,
20% 92%,
10% 100%,
0% 85%,
5% 50%,
0% 15%
);
transition: all 0.3s ease;
}
/* 透明内层 - 镂空效果 */
.notification-update-badge::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: calc(100% - 4px);
height: calc(100% - 4px);
background: #ffffff;
clip-path: polygon(
10% 0%,
20% 10%,
30% 0%,
40% 10%,
50% 0%,
60% 10%,
70% 0%,
80% 10%,
90% 0%,
100% 18%,
94% 50%,
100% 82%,
90% 100%,
80% 90%,
70% 100%,
60% 90%,
50% 100%,
40% 90%,
30% 100%,
20% 90%,
10% 100%,
0% 82%,
6% 50%,
0% 18%
);
transition: all 0.3s ease;
}
.update-text {
position: relative;
z-index: 2;
font-size: 11px;
font-weight: 800;
color: #1a1a2e;
letter-spacing: 1px;
}
.notification-item:hover .notification-update-badge {
transform: scale(1.1);
}
.notification-item:hover .notification-update-badge::before {
box-shadow: 0 4px 16px rgba(255, 71, 87, 0.4);
}
.empty-quick {
padding: 48px 24px;
text-align: center;
color: var(--ctms-text-disabled);
font-size: 14px;
}
@media (max-width: 992px) {
.stats-row, .secondary-row {
grid-template-columns: 1fr;
}
.workbench-header {
padding: 24px;
margin: 0;
}
.welcome-title {
font-size: 26px;
}
}
</style>
@@ -1,124 +0,0 @@
<template>
<div class="center-summary-box">
<div class="box-header">
<span class="box-title">各中心进展汇总</span>
</div>
<div class="summary-table-container">
<el-table
:data="data"
style="width: 100%"
v-loading="loading"
empty-text="暂无数据"
class="custom-table center-summary-table"
table-layout="fixed"
>
<el-table-column prop="name" label="管理中心" show-overflow-tooltip>
<template #default="{ row }">
<span class="center-name">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="stage" label="进展阶段">
<template #default="{ row }">
<el-tag :type="getStageType(row.stage_status)" effect="light" size="small" round>
{{ row.stage }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="实际入组例数/计划例数" align="right">
<template #default="{ row }">
<div class="enrollment-ratio">
<span class="actual">{{ row.actual_enrolled }}</span>
<span class="separator">/</span>
<span class="planned">{{ row.planned_enrolled }}</span>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
data: any[];
loading: boolean;
}>();
const getStageType = (status?: string) => {
if (status === "COMPLETED") return "success";
if (status === "IN_PROGRESS") return "warning";
if (status === "BLOCKED") return "danger";
return "info";
};
</script>
<style scoped>
.center-summary-box {
background: #ffffff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
margin-bottom: 24px;
}
.summary-table-container {
overflow: hidden;
}
.center-summary-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.summary-table-container :deep(.el-table th.el-table__cell) {
background: #f7f9fc;
}
.summary-table-container :deep(.el-table td.el-table__cell),
.summary-table-container :deep(.el-table th.el-table__cell) {
padding-left: 0;
padding-right: 0;
}
.summary-table-container :deep(.el-table td.el-table__cell) {
padding-top: 7px;
padding-bottom: 7px;
}
.box-header {
margin-bottom: 16px;
}
.box-title {
font-size: 18px;
font-weight: 700;
color: var(--ctms-text-main, #1a1a2e);
}
.center-name {
font-weight: 600;
color: var(--ctms-text-main, #1a1a2e);
}
.enrollment-ratio {
font-family: 'Roboto Mono', monospace;
font-weight: 600;
}
.actual {
color: var(--ctms-primary, #667eea);
}
.separator {
margin: 0 4px;
color: #ccc;
}
.planned {
color: var(--ctms-text-secondary, #808080);
}
</style>
@@ -1,251 +0,0 @@
<template>
<el-card shadow="never" class="todo-card unified-shell">
<template #header>
<div class="section-header">
<div class="header-left">
<el-icon class="header-icon"><Notebook /></el-icon>
<span class="header-title">{{ TEXT.modules.workbench.todoTitle }}</span>
</div>
<el-tag size="small" effect="plain" type="info" class="count-tag">
{{ TEXT.modules.workbench.todoCount.replace("{count}", String(todos.length)) }}
</el-tag>
</div>
</template>
<div class="todo-body">
<div class="input-section">
<el-input
v-model="input"
:placeholder="TEXT.modules.workbench.todoPlaceholder"
class="todo-input"
@keyup.enter="add"
>
<template #prefix>
<el-icon><EditPen /></el-icon>
</template>
</el-input>
<el-button type="primary" :icon="Plus" @click="add" class="add-btn">
{{ TEXT.modules.workbench.todoAdd }}
</el-button>
</div>
<div v-if="todos.length" class="todo-list">
<transition-group name="todo-list">
<div
v-for="item in todos"
:key="item.id"
class="todo-item"
:class="{ 'is-done': item.done }"
>
<el-checkbox v-model="item.done" @change="save" class="todo-checkbox">
<span class="todo-text">{{ item.text }}</span>
</el-checkbox>
<el-button
link
type="danger"
:icon="Delete"
@click="remove(item.id)"
class="delete-btn"
></el-button>
</div>
</transition-group>
</div>
<div v-else class="empty-state">
<StateEmpty :description="TEXT.modules.workbench.todoEmpty" />
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from "vue";
import { Notebook, EditPen, Plus, Delete } from "@element-plus/icons-vue";
import StateEmpty from "../../../components/StateEmpty.vue";
import { TEXT } from "../../../locales";
const props = defineProps<{ storageKey: string | undefined }>();
interface TodoItem {
id: string;
text: string;
done: boolean;
}
const input = ref("");
const todos = ref<TodoItem[]>([]);
const load = () => {
if (!props.storageKey) return;
const raw = localStorage.getItem(props.storageKey);
if (raw) {
try {
todos.value = JSON.parse(raw) || [];
} catch {
todos.value = [];
}
}
};
const save = () => {
if (!props.storageKey) return;
localStorage.setItem(props.storageKey, JSON.stringify(todos.value));
};
const add = () => {
const text = input.value.trim();
if (!text) return;
todos.value.unshift({
id: crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(),
text,
done: false,
});
input.value = "";
save();
};
const remove = (id: string) => {
todos.value = todos.value.filter((t) => t.id !== id);
save();
};
watch(
() => props.storageKey,
() => load(),
{ immediate: true }
);
onMounted(() => {
load();
});
</script>
<style scoped>
.todo-card {
border: 1px solid var(--ctms-border-color);
border-radius: 8px;
}
:deep(.el-card__header) {
padding: 16px 20px;
border-bottom: 1px solid var(--ctms-border-color);
background-color: #fafafa;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
font-size: 18px;
color: var(--ctms-primary);
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
}
.count-tag {
border-radius: 12px;
}
.todo-body {
padding: 20px;
}
.input-section {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.todo-input :deep(.el-input__wrapper) {
box-shadow: none;
border: 1px solid var(--ctms-border-color);
transition: all 0.3s ease;
}
.todo-input :deep(.el-input__wrapper.is-focus) {
border-color: var(--ctms-primary);
}
.add-btn {
padding-left: 20px;
padding-right: 20px;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: #fafafa;
border: 1px solid var(--ctms-border-color);
border-radius: 6px;
transition: all 0.2s ease;
}
.todo-item:hover {
background-color: #fff;
border-color: var(--ctms-primary-hover);
box-shadow: var(--ctms-shadow-sm);
}
.todo-item.is-done {
opacity: 0.6;
}
.todo-item.is-done .todo-text {
text-decoration: line-through;
color: var(--ctms-text-disabled);
}
.todo-checkbox {
flex: 1;
}
.todo-text {
font-size: 14px;
color: var(--ctms-text-main);
transition: all 0.2s ease;
}
.delete-btn {
opacity: 0;
transition: all 0.2s ease;
}
.todo-item:hover .delete-btn {
opacity: 1;
}
/* 列表动画 */
.todo-list-enter-active,
.todo-list-leave-active {
transition: all 0.3s ease;
}
.todo-list-enter-from,
.todo-list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.empty-state {
padding: 20px 0;
}
</style>
@@ -1,266 +0,0 @@
<template>
<el-card shadow="never" class="section-card unified-shell">
<template #header>
<div class="section-header">
<div class="section-title-wrapper">
<div class="section-icon">
<el-icon><component :is="getIcon()" /></el-icon>
</div>
<span class="section-title">{{ title }}</span>
</div>
<el-button v-if="!loading" type="primary" link @click="$emit('more')" class="more-btn">
{{ TEXT.common.actions.more }} <el-icon class="el-icon--right"><ArrowRight /></el-icon>
</el-button>
</div>
</template>
<div class="section-body">
<StateLoading v-if="loading" :rows="3" />
<div v-else-if="items && items.length" class="list-container">
<div
v-for="item in items.slice(0, 5)"
:key="item.title + item.path"
class="list-item"
@click="go(item.path)"
>
<div class="item-main">
<div class="item-title">{{ item.title }}</div>
<div class="item-subtitle">{{ item.subtitle }}</div>
</div>
<div class="item-meta">
<el-tag
size="small"
:type="item.overdue ? 'danger' : 'info'"
:effect="item.overdue ? 'dark' : 'light'"
class="status-tag"
>
{{ item.status || TEXT.modules.workbench.todoStatusFallback }}
</el-tag>
</div>
</div>
</div>
<div v-else class="empty-state">
<StateEmpty :description="emptyText || TEXT.common.empty.noData" />
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import { ArrowRight, Calendar, Warning, Document } from "@element-plus/icons-vue";
import StateEmpty from "../../../components/StateEmpty.vue";
import StateLoading from "../../../components/StateLoading.vue";
import { TEXT } from "../../../locales";
const props = defineProps<{
title: string;
items: any[];
loading?: boolean;
emptyText?: string;
}>();
const emit = defineEmits(["more"]);
const router = useRouter();
const go = (path: string) => {
if (!path) return;
router.push(path);
};
const getIcon = () => {
// 根据标题返回不同的图标
if (props.title.includes("今日") || props.title.includes("待办")) return Calendar;
if (props.title.includes("逾期")) return Warning;
return Document;
};
</script>
<style scoped>
.section-card {
border: 1px solid rgba(102, 126, 234, 0.12);
border-radius: 16px;
height: 100%;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #ffffff 0%, #fafcff 100%);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
overflow: hidden;
transition: all 0.3s ease;
}
.section-card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
border-color: rgba(102, 126, 234, 0.2);
}
:deep(.el-card__header) {
padding: 20px 24px;
border-bottom: 1px solid rgba(102, 126, 234, 0.08);
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.02) 100%);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-title-wrapper {
display: flex;
align-items: center;
gap: 12px;
}
.section-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
font-size: 18px;
color: #ffffff;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25);
transition: all 0.3s ease;
}
.section-card:hover .section-icon {
transform: rotate(-5deg) scale(1.05);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.35);
}
.section-title {
font-size: 17px;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.more-btn {
font-size: 13px;
color: var(--ctms-primary);
font-weight: 600;
transition: all 0.3s ease;
}
.more-btn:hover {
transform: translateX(4px);
}
.section-body {
flex: 1;
padding: 0;
}
.list-container {
display: flex;
flex-direction: column;
}
.list-item {
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
border-bottom: 1px solid rgba(102, 126, 234, 0.06);
position: relative;
}
.list-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s ease;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.04) 0%, rgba(118, 75, 162, 0.04) 100%);
transform: translateX(6px);
}
.list-item:hover::before {
width: 4px;
}
.item-main {
flex: 1;
overflow: hidden;
margin-right: 16px;
position: relative;
z-index: 1;
}
.item-title {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
margin-bottom: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.3s ease;
}
.list-item:hover .item-title {
color: #667eea;
}
.item-subtitle {
font-size: 12px;
color: var(--ctms-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.item-meta {
position: relative;
z-index: 1;
}
.status-tag {
border-radius: 12px;
font-weight: 600;
font-size: 12px;
padding: 4px 10px;
border: 1px solid transparent;
transition: all 0.3s ease;
}
.status-tag.el-tag--danger {
background: linear-gradient(135deg, rgba(194, 75, 75, 0.12) 0%, rgba(194, 75, 75, 0.08) 100%);
color: var(--ctms-danger);
border-color: rgba(194, 75, 75, 0.2);
}
.status-tag.el-tag--info {
background: linear-gradient(135deg, rgba(100, 116, 139, 0.12) 0%, rgba(100, 116, 139, 0.08) 100%);
color: var(--ctms-info);
border-color: rgba(100, 116, 139, 0.2);
}
.list-item:hover .status-tag {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.empty-state {
padding: 16px 0;
}
</style>