Remove non-admin workbench
This commit is contained in:
@@ -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")):
|
if any(k in lowered for k in ("ctms_sidebar_collapsed", "study", "site", "role")):
|
||||||
return ("low", "ui-preference", "UI偏好/上下文缓存")
|
return ("low", "ui-preference", "UI偏好/上下文缓存")
|
||||||
|
|
||||||
if "personaltodo" in file_path.lower():
|
|
||||||
return ("low", "feature-cache", "工作台本地待办缓存(非核心主数据)")
|
|
||||||
|
|
||||||
return ("medium", "unknown", "未命中规则,建议人工复核是否为业务关键数据")
|
return ("medium", "unknown", "未命中规则,建议人工复核是否为业务关键数据")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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` | 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` | 24 | `localStorage.getItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) |
|
||||||
| low | auth-session | `frontend/src/utils/auth.ts` | 38 | `localStorage.removeItem` | `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` 本地草稿兜底读写)
|
- 高风险:4 条(均为 `ProjectDetail` 本地草稿兜底读写)
|
||||||
- 中风险:0 条
|
- 中风险:0 条
|
||||||
- 低风险:42 条(会话、偏好、工作台本地待办等)
|
- 低风险:40 条(会话、偏好等)
|
||||||
|
|
||||||
## 4. 已完成整改
|
## 4. 已完成整改
|
||||||
|
|
||||||
|
|||||||
@@ -80,9 +80,8 @@
|
|||||||
3. 统一状态体系:加载态、空态、错误态、禁用态、风险高亮态。
|
3. 统一状态体系:加载态、空态、错误态、禁用态、风险高亮态。
|
||||||
|
|
||||||
#### P1(有余量时)
|
#### P1(有余量时)
|
||||||
1. `/workbench` 视觉升级。
|
1. 表单页统一(新建/编辑页头、分组、主次按钮)。
|
||||||
2. 表单页统一(新建/编辑页头、分组、主次按钮)。
|
2. 克制型关键动效(入场/hover)。
|
||||||
3. 克制型关键动效(入场/hover)。
|
|
||||||
|
|
||||||
#### P2(后续阶段)
|
#### P2(后续阶段)
|
||||||
1. 全模块扩展(知识库、启动授权细分页)。
|
1. 全模块扩展(知识库、启动授权细分页)。
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ Expected: PASS with the regrouped files.
|
|||||||
### Task 3: Remove obsolete docs and local artifacts
|
### Task 3: Remove obsolete docs and local artifacts
|
||||||
|
|
||||||
**Files:**
|
**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-design.md`
|
||||||
- Delete: `/Users/zcc/MyCTMS/ctms-dev/.worktrees/main/docs/plans/2026-03-05-rd-monitor-implementation.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/attachments/AttachmentList.vue`
|
||||||
- Modify: `frontend/src/components/fees/FeeAttachmentPanel.vue`
|
- Modify: `frontend/src/components/fees/FeeAttachmentPanel.vue`
|
||||||
- Modify: `frontend/src/components/FaqList.vue`
|
- Modify: `frontend/src/components/FaqList.vue`
|
||||||
- Modify: `frontend/src/views/workbench/components/CenterSummary.vue`
|
|
||||||
|
|
||||||
**Step 1: 仅保留业务浏览页**
|
**Step 1: 仅保留业务浏览页**
|
||||||
|
|
||||||
@@ -93,7 +92,6 @@
|
|||||||
- Modify: `frontend/src/components/attachments/AttachmentList.vue`
|
- Modify: `frontend/src/components/attachments/AttachmentList.vue`
|
||||||
- Modify: `frontend/src/components/fees/FeeAttachmentPanel.vue`
|
- Modify: `frontend/src/components/fees/FeeAttachmentPanel.vue`
|
||||||
- Modify: `frontend/src/components/FaqList.vue`
|
- Modify: `frontend/src/components/FaqList.vue`
|
||||||
- Modify: `frontend/src/views/workbench/components/CenterSummary.vue`
|
|
||||||
|
|
||||||
**Step 1: 对齐详情子表策略**
|
**Step 1: 对齐详情子表策略**
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ instance.interceptors.response.use(
|
|||||||
// 认证相关的错误由具体页面自行处理,避免重复提示
|
// 认证相关的错误由具体页面自行处理,避免重复提示
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
// 如果当前项目不存在或已失效,清理项目并跳到工作台
|
// 如果当前项目不存在或已失效,清理项目并跳到项目管理页
|
||||||
if (status === 404 && typeof data?.message === "string" && data.message.toLowerCase().includes("study")) {
|
if (status === 404 && typeof data?.message === "string" && data.message.toLowerCase().includes("study")) {
|
||||||
try {
|
try {
|
||||||
const studyStore = useStudyStore();
|
const studyStore = useStudyStore();
|
||||||
@@ -79,7 +79,7 @@ instance.interceptors.response.use(
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
router.push("/workbench");
|
router.push("/admin/projects");
|
||||||
}
|
}
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
const config = error.config as ApiRequestConfig;
|
const config = error.config as ApiRequestConfig;
|
||||||
|
|||||||
@@ -12,11 +12,6 @@
|
|||||||
class="aside-menu"
|
class="aside-menu"
|
||||||
@select="handleMenuSelect"
|
@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">
|
<el-menu-item-group v-if="auth.user" class="menu-group">
|
||||||
<template #title>
|
<template #title>
|
||||||
<span class="menu-divider">{{ TEXT.menu.admin }}</span>
|
<span class="menu-divider">{{ TEXT.menu.admin }}</span>
|
||||||
@@ -222,7 +217,7 @@ import { fetchStudies } from "../api/studies";
|
|||||||
import { fetchSites } from "../api/sites";
|
import { fetchSites } from "../api/sites";
|
||||||
import { TEXT } from "../locales";
|
import { TEXT } from "../locales";
|
||||||
import {
|
import {
|
||||||
Monitor, User, Suitcase, House, Calendar, Flag, ChatDotRound,
|
User, Suitcase, House, Calendar, Flag, ChatDotRound,
|
||||||
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key
|
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key
|
||||||
} from "@element-plus/icons-vue";
|
} from "@element-plus/icons-vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const loadStudies = async () => {
|
|||||||
const onCommand = async (cmd: any) => {
|
const onCommand = async (cmd: any) => {
|
||||||
if (cmd === "clear") {
|
if (cmd === "clear") {
|
||||||
study.clearCurrentStudy();
|
study.clearCurrentStudy();
|
||||||
router.push("/workbench");
|
router.push("/admin/projects");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cmd?.type === "switch" && cmd.study) {
|
if (cmd?.type === "switch" && cmd.study) {
|
||||||
|
|||||||
@@ -252,7 +252,6 @@ export const TEXT = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
workbench: "我的工作台",
|
|
||||||
admin: "管理后台",
|
admin: "管理后台",
|
||||||
accountManagement: "账号管理",
|
accountManagement: "账号管理",
|
||||||
projectManagement: "项目管理",
|
projectManagement: "项目管理",
|
||||||
@@ -923,34 +922,6 @@ export const TEXT = {
|
|||||||
result: "结果",
|
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: {
|
permissions: {
|
||||||
subjectEnroll: "仅 PM/CRA 可更新参与者状态",
|
subjectEnroll: "仅 PM/CRA 可更新参与者状态",
|
||||||
subjectComplete: "仅 PM/CRA 可更新参与者状态",
|
subjectComplete: "仅 PM/CRA 可更新参与者状态",
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ describe("admin project route permissions", () => {
|
|||||||
|
|
||||||
expect(source).not.toContain("findPmAdminLandingPath");
|
expect(source).not.toContain("findPmAdminLandingPath");
|
||||||
expect(source).not.toContain("pmAdminLandingModules");
|
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}"`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import Login from "../views/Login.vue";
|
|||||||
import Register from "../views/Register.vue";
|
import Register from "../views/Register.vue";
|
||||||
import ForgotPassword from "../views/ForgotPassword.vue";
|
import ForgotPassword from "../views/ForgotPassword.vue";
|
||||||
import StudyHome from "../views/StudyHome.vue";
|
import StudyHome from "../views/StudyHome.vue";
|
||||||
import MyWorkbench from "../views/workbench/MyWorkbench.vue";
|
|
||||||
import FaqDetail from "../views/FaqDetail.vue";
|
import FaqDetail from "../views/FaqDetail.vue";
|
||||||
import AuditLogs from "../views/admin/AuditLogs.vue";
|
import AuditLogs from "../views/admin/AuditLogs.vue";
|
||||||
import AdminUsers from "../views/admin/Users.vue";
|
import AdminUsers from "../views/admin/Users.vue";
|
||||||
@@ -84,14 +83,8 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
component: Layout,
|
component: Layout,
|
||||||
redirect: "/workbench",
|
redirect: "/project/overview",
|
||||||
children: [
|
children: [
|
||||||
{
|
|
||||||
path: "workbench",
|
|
||||||
name: "MyWorkbench",
|
|
||||||
component: MyWorkbench,
|
|
||||||
meta: { title: TEXT.menu.workbench },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "profile",
|
path: "profile",
|
||||||
name: "ProfileSettings",
|
name: "ProfileSettings",
|
||||||
@@ -552,7 +545,7 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
next({ path: "/login" });
|
next({ path: "/login" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isAdmin && (to.path === "/" || to.path === "/workbench")) {
|
if (isAdmin && to.path === "/") {
|
||||||
next({ path: studyStore.currentStudy ? "/project/overview" : "/admin/users" });
|
next({ path: studyStore.currentStudy ? "/project/overview" : "/admin/users" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -565,7 +558,7 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
: "/admin/users"
|
: "/admin/users"
|
||||||
: studyStore.currentStudy
|
: studyStore.currentStudy
|
||||||
? "/project/overview"
|
? "/project/overview"
|
||||||
: "/workbench",
|
: "/admin/projects",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -573,12 +566,12 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
if (to.meta.adminProjectPermission) {
|
if (to.meta.adminProjectPermission) {
|
||||||
const allowed = await ensureAdminProjectAccess(to, isAdmin).catch(() => false);
|
const allowed = await ensureAdminProjectAccess(to, isAdmin).catch(() => false);
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
next({ path: isAdmin ? "/admin/users" : "/workbench" });
|
next({ path: isAdmin ? "/admin/users" : "/admin/projects" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (to.meta.requiresAdmin && !isAdmin) {
|
if (to.meta.requiresAdmin && !isAdmin) {
|
||||||
next({ path: "/workbench" });
|
next({ path: "/admin/projects" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (to.name === "AdminAuditLogs") {
|
if (to.name === "AdminAuditLogs") {
|
||||||
@@ -593,7 +586,7 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (to.meta.requiresStudy && !studyStore.currentStudy) {
|
if (to.meta.requiresStudy && !studyStore.currentStudy) {
|
||||||
next({ path: isAdmin ? "/admin/users" : "/workbench" });
|
next({ path: isAdmin ? "/admin/users" : "/admin/projects" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (to.meta.requiresStudy && studyStore.currentStudy) {
|
if (to.meta.requiresStudy && studyStore.currentStudy) {
|
||||||
@@ -605,7 +598,7 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
const canAccessProjectRoute = hasProjectPermission(studyStore.currentPermissions, projectRole, permission, isAdmin);
|
const canAccessProjectRoute = hasProjectPermission(studyStore.currentPermissions, projectRole, permission, isAdmin);
|
||||||
if (!canAccessProjectRoute) {
|
if (!canAccessProjectRoute) {
|
||||||
const fallback = findFirstAccessibleProjectPath(studyStore.currentPermissions, projectRole, isAdmin);
|
const fallback = findFirstAccessibleProjectPath(studyStore.currentPermissions, projectRole, isAdmin);
|
||||||
next({ path: fallback || "/workbench" });
|
next({ path: fallback || "/admin/projects" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,10 +248,8 @@ const onSubmit = async () => {
|
|||||||
}
|
}
|
||||||
if (studyStore.currentStudy) {
|
if (studyStore.currentStudy) {
|
||||||
router.push("/project/overview");
|
router.push("/project/overview");
|
||||||
} else if (auth.user?.is_admin) {
|
|
||||||
router.push("/admin/users");
|
|
||||||
} else {
|
} else {
|
||||||
router.push("/workbench");
|
router.push(auth.user?.is_admin ? "/admin/users" : "/admin/projects");
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const status = error?.response?.status;
|
const status = error?.response?.status;
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ const canAccessAuditLogs = computed(() => {
|
|||||||
|
|
||||||
const ensureAccess = () => {
|
const ensureAccess = () => {
|
||||||
if (!canAccessAuditLogs.value) {
|
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"');
|
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", () => {
|
it("uses each project permission matrix to show project management actions", () => {
|
||||||
const source = readProjects();
|
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>
|
|
||||||
Reference in New Issue
Block a user