Remove non-admin workbench
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user