UI界面模拟shadcn admin效果

This commit is contained in:
Cheng Zhou
2025-12-25 15:47:47 +08:00
parent 148fa6ad9a
commit a1a4964cd2
25 changed files with 1061 additions and 636 deletions
+56 -46
View File
@@ -1,53 +1,66 @@
<template>
<div class="page" v-if="ae">
<el-card class="mb-12">
<div class="header">
<div>
<h3>AE不良事件</h3>
<p>{{ ae.term }}</p>
</div>
<div class="action-buttons">
<template v-for="action in availableActions" :key="action.key">
<PermissionAction action="ae.close">
<el-button
size="small"
:type="action.danger ? 'danger' : 'primary'"
@click="onAction(action)"
>
{{ action.label }}
</el-button>
</PermissionAction>
</template>
</div>
<div class="ctms-page" v-if="ae">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">不良事件</h1>
</div>
<el-descriptions :column="2" border>
<div class="ctms-page-actions">
<template v-for="action in availableActions" :key="action.key">
<PermissionAction action="ae.close">
<el-button
size="small"
:type="action.danger ? 'danger' : 'primary'"
:link="action.danger"
@click="onAction(action)"
>
{{ action.label }}
</el-button>
</PermissionAction>
</template>
</div>
</div>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">基本信息</div>
</div>
</template>
<el-descriptions :column="2" border class="detail-descriptions">
<el-descriptions-item label="受试者">{{ subjectName(ae.subject_id) }}</el-descriptions-item>
<el-descriptions-item label="严重性">{{ ae.seriousness }}</el-descriptions-item>
<el-descriptions-item label="严重程度">{{ ae.severity }}</el-descriptions-item>
<el-descriptions-item label="发生日期">{{ ae.onset_date }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="stateColor(aeState)">{{ stateLabel(aeState) }}</el-tag>
<el-tag :type="stateColor(aeState)" effect="plain">{{ stateLabel(aeState) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="描述">{{ ae.description }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-tabs>
<el-tab-pane label="评论">
<CommentList :study-id="studyId" entity-type="aes" :entity-id="ae.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList
:study-id="studyId"
entity-type="aes"
:entity-id="ae.id"
:show-uploader="true"
/>
</el-tab-pane>
</el-tabs>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">协作记录</div>
</div>
</template>
<el-tabs class="detail-tabs">
<el-tab-pane label="评论">
<CommentList :study-id="studyId" entity-type="aes" :entity-id="ae.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList
:study-id="studyId"
entity-type="aes"
:entity-id="ae.id"
:show-uploader="true"
/>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
<div v-else class="page">
<el-skeleton rows="4" animated />
<div v-else class="ctms-page">
<StateLoading :rows="4" />
</div>
</template>
@@ -62,6 +75,7 @@ import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
import AttachmentList from "../components/attachments/AttachmentList.vue";
import PermissionAction from "../components/PermissionAction.vue";
import StateLoading from "../components/StateLoading.vue";
import { usePermission } from "../utils/permission";
import { aeMachine, getAvailableActions } from "../state-machine";
import type { ActionConfig } from "../state-machine";
@@ -199,15 +213,11 @@ onMounted(async () => {
</script>
<style scoped>
.page {
padding: 16px;
.detail-descriptions :deep(.el-descriptions__label) {
color: var(--ctms-text-secondary);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.mb-12 {
margin-bottom: 12px;
.detail-tabs {
margin-top: -8px;
}
</style>
+33 -33
View File
@@ -1,21 +1,38 @@
<template>
<div class="page">
<el-card class="mb-12">
<div class="filters">
<el-select v-model="filters.status" placeholder="状态" clearable @change="loadAes">
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
<el-select v-model="filters.seriousness" placeholder="严重性" clearable @change="loadAes">
<el-option v-for="s in seriousnessOptions" :key="s" :label="seriousnessLabel(s)" :value="s" />
</el-select>
<el-switch v-model="filters.overdue" active-text="仅逾期" @change="loadAes" />
<div class="spacer" />
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">不良事件</h1>
</div>
<div class="ctms-page-actions">
<el-button type="primary" v-if="canCreate" @click="showForm = true">新建不良事件</el-button>
</div>
</div>
<el-card class="ctms-filter-card">
<div class="ctms-filter-row">
<div class="ctms-filter-item">
<span class="ctms-filter-label">状态</span>
<el-select v-model="filters.status" placeholder="全部" clearable @change="loadAes">
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">严重性</span>
<el-select v-model="filters.seriousness" placeholder="全部" clearable @change="loadAes">
<el-option v-for="s in seriousnessOptions" :key="s" :label="seriousnessLabel(s)" :value="s" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">仅逾期</span>
<el-switch v-model="filters.overdue" @change="loadAes" />
</div>
<div class="ctms-filter-spacer" />
</div>
</el-card>
<el-card>
<el-table :data="aes" v-loading="loading" style="width: 100%" @row-click="goDetail">
<el-card class="ctms-table-card">
<el-table :data="aes" v-loading="loading" class="ctms-table" style="width: 100%" @row-click="goDetail">
<el-table-column prop="term" label="事件" />
<el-table-column label="受试者" width="200">
<template #default="scope">
@@ -34,12 +51,13 @@
<el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140">
<el-table-column label="操作" width="120" align="right">
<template #default="scope">
<el-button
v-if="showClose(scope.row)"
type="danger"
size="small"
link
@click.stop="onClose(scope.row)"
>
关闭 AE
@@ -48,7 +66,7 @@
</el-table-column>
</el-table>
<el-pagination
class="pagination"
class="ctms-pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
@@ -244,22 +262,4 @@ onMounted(async () => {
</script>
<style scoped>
.page {
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.spacer {
flex: 1;
}
.pagination {
margin-top: 12px;
text-align: right;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
+30 -31
View File
@@ -1,19 +1,36 @@
<template>
<div class="page">
<el-card class="mb-12">
<div class="filters">
<el-select v-model="filters.status" placeholder="状态" clearable @change="loadQueries">
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
<el-switch v-model="filters.overdue" active-text="仅逾期" @change="loadQueries" />
<el-input v-model="filters.assigned_to" placeholder="负责人" style="width: 180px" @change="loadQueries" />
<div class="spacer" />
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">数据问题</h1>
</div>
<div class="ctms-page-actions">
<el-button type="primary" v-if="canEdit" @click="showForm = true">新建数据问题</el-button>
</div>
</div>
<el-card class="ctms-filter-card">
<div class="ctms-filter-row">
<div class="ctms-filter-item">
<span class="ctms-filter-label">状态</span>
<el-select v-model="filters.status" placeholder="全部" clearable @change="loadQueries">
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">负责人</span>
<el-input v-model="filters.assigned_to" placeholder="输入邮箱或姓名" @change="loadQueries" />
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">仅逾期</span>
<el-switch v-model="filters.overdue" @change="loadQueries" />
</div>
<div class="ctms-filter-spacer" />
</div>
</el-card>
<el-card>
<el-table :data="queries" v-loading="loading" style="width: 100%" @row-click="goDetail">
<el-card class="ctms-table-card">
<el-table :data="queries" v-loading="loading" class="ctms-table" style="width: 100%" @row-click="goDetail">
<el-table-column prop="title" label="标题" />
<el-table-column label="受试者">
<template #default="scope">
@@ -36,7 +53,7 @@
<el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140">
<el-table-column label="操作" width="120" align="right">
<template #default="scope">
<el-button
v-if="showResolve(scope.row)"
@@ -50,7 +67,7 @@
</el-table-column>
</el-table>
<el-pagination
class="pagination"
class="ctms-pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
@@ -264,22 +281,4 @@ onMounted(async () => {
</script>
<style scoped>
.page {
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.spacer {
flex: 1;
}
.pagination {
margin-top: 12px;
text-align: right;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
+44 -33
View File
@@ -1,14 +1,21 @@
<template>
<div class="page" v-if="query">
<el-card class="mb-12">
<div class="header">
<div>
<h3>数据问题详情</h3>
<p>{{ query.title }}</p>
</div>
<div class="ctms-page" v-if="query">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">数据问题</h1>
</div>
<div class="ctms-page-actions">
<el-button v-if="canEdit" type="primary" size="small" @click="nextStatus">流转状态</el-button>
</div>
<el-descriptions :column="2" border>
</div>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">基础信息</div>
</div>
</template>
<el-descriptions :column="2" border class="detail-descriptions">
<el-descriptions-item label="受试者">
{{ subjectMap[query.subject_id] || query.subject_id || "-" }}
</el-descriptions-item>
@@ -16,28 +23,35 @@
<el-descriptions-item label="优先级">{{ query.priority }}</el-descriptions-item>
<el-descriptions-item label="截止日期">{{ query.due_date }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="query.is_overdue ? 'danger' : 'info'">{{ query.status }}</el-tag>
<el-tag :type="query.is_overdue ? 'danger' : 'info'" effect="plain">{{ query.status }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="描述">{{ query.description }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-tabs>
<el-tab-pane label="评论">
<CommentList :study-id="studyId" entity-type="data-queries" :entity-id="query.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList
:study-id="studyId"
entity-type="data-queries"
:entity-id="query.id"
:show-uploader="true"
/>
</el-tab-pane>
</el-tabs>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">协作记录</div>
</div>
</template>
<el-tabs class="detail-tabs">
<el-tab-pane label="评论">
<CommentList :study-id="studyId" entity-type="data-queries" :entity-id="query.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList
:study-id="studyId"
entity-type="data-queries"
:entity-id="query.id"
:show-uploader="true"
/>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
<div v-else class="page">
<el-skeleton rows="4" animated />
<div v-else class="ctms-page">
<StateLoading :rows="4" />
</div>
</template>
@@ -52,6 +66,7 @@ import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
import AttachmentList from "../components/attachments/AttachmentList.vue";
import { usePermission } from "../utils/permission";
import StateLoading from "../components/StateLoading.vue";
const route = useRoute();
const study = useStudyStore();
@@ -122,15 +137,11 @@ onMounted(async () => {
</script>
<style scoped>
.page {
padding: 16px;
.detail-descriptions :deep(.el-descriptions__label) {
color: var(--ctms-text-secondary);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.mb-12 {
margin-bottom: 12px;
.detail-tabs {
margin-top: -8px;
}
</style>
+44 -33
View File
@@ -1,16 +1,23 @@
<template>
<div class="page" v-if="issue">
<el-card class="mb-12">
<div class="header">
<div>
<h3>风险与问题</h3>
<p>{{ issue.title }}</p>
</div>
<div class="ctms-page" v-if="issue">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">风险与问题</h1>
</div>
<div class="ctms-page-actions">
<PermissionAction action="issue.close">
<el-button type="danger" size="small" @click="closeIssue">关闭</el-button>
<el-button type="danger" size="small" link @click="closeIssue">关闭</el-button>
</PermissionAction>
</div>
<el-descriptions :column="2" border>
</div>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">基础信息</div>
</div>
</template>
<el-descriptions :column="2" border class="detail-descriptions">
<el-descriptions-item label="类别">{{ issue.category }}</el-descriptions-item>
<el-descriptions-item label="等级">{{ issue.level }}</el-descriptions-item>
<el-descriptions-item label="截止日期">{{ issue.due_date }}</el-descriptions-item>
@@ -19,22 +26,29 @@
</el-descriptions>
</el-card>
<el-tabs>
<el-tab-pane label="评论">
<CommentList :study-id="studyId" entity-type="issues" :entity-id="issue.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList
:study-id="studyId"
entity-type="issues"
:entity-id="issue.id"
:show-uploader="true"
/>
</el-tab-pane>
</el-tabs>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">协作记录</div>
</div>
</template>
<el-tabs class="detail-tabs">
<el-tab-pane label="评论">
<CommentList :study-id="studyId" entity-type="issues" :entity-id="issue.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList
:study-id="studyId"
entity-type="issues"
:entity-id="issue.id"
:show-uploader="true"
/>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
<div v-else class="page">
<el-skeleton rows="4" animated />
<div v-else class="ctms-page">
<StateLoading :rows="4" />
</div>
</template>
@@ -49,6 +63,7 @@ import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission";
import CommentList from "../components/CommentList.vue";
import AttachmentList from "../components/attachments/AttachmentList.vue";
import StateLoading from "../components/StateLoading.vue";
const route = useRoute();
const study = useStudyStore();
@@ -97,15 +112,11 @@ onMounted(async () => {
</script>
<style scoped>
.page {
padding: 16px;
.detail-descriptions :deep(.el-descriptions__label) {
color: var(--ctms-text-secondary);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.mb-12 {
margin-bottom: 12px;
.detail-tabs {
margin-top: -8px;
}
</style>
+37 -35
View File
@@ -1,24 +1,44 @@
<template>
<div class="page">
<el-card class="mb-12">
<div class="filters">
<el-select v-model="filters.status" placeholder="状态" clearable @change="loadIssues">
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
<el-select v-model="filters.level" placeholder="等级" clearable @change="loadIssues">
<el-option v-for="l in levels" :key="l" :label="levelLabel(l)" :value="l" />
</el-select>
<el-select v-model="filters.category" placeholder="类别" clearable @change="loadIssues">
<el-option v-for="c in categories" :key="c" :label="categoryLabel(c)" :value="c" />
</el-select>
<el-switch v-model="filters.overdue" active-text="仅逾期" @change="loadIssues" />
<div class="spacer" />
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">风险与问题</h1>
</div>
<div class="ctms-page-actions">
<el-button type="primary" v-if="canCreate" @click="showForm = true">新建风险/问题</el-button>
</div>
</div>
<el-card class="ctms-filter-card">
<div class="ctms-filter-row">
<div class="ctms-filter-item">
<span class="ctms-filter-label">状态</span>
<el-select v-model="filters.status" placeholder="全部" clearable @change="loadIssues">
<el-option v-for="s in statuses" :key="s" :label="statusLabel(s)" :value="s" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">等级</span>
<el-select v-model="filters.level" placeholder="全部" clearable @change="loadIssues">
<el-option v-for="l in levels" :key="l" :label="levelLabel(l)" :value="l" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">类别</span>
<el-select v-model="filters.category" placeholder="全部" clearable @change="loadIssues">
<el-option v-for="c in categories" :key="c" :label="categoryLabel(c)" :value="c" />
</el-select>
</div>
<div class="ctms-filter-item">
<span class="ctms-filter-label">仅逾期</span>
<el-switch v-model="filters.overdue" @change="loadIssues" />
</div>
<div class="ctms-filter-spacer" />
</div>
</el-card>
<el-card>
<el-table :data="issues" v-loading="loading" style="width: 100%" @row-click="goDetail">
<el-card class="ctms-table-card">
<el-table :data="issues" v-loading="loading" class="ctms-table" style="width: 100%" @row-click="goDetail">
<el-table-column prop="title" label="标题" />
<el-table-column prop="level" label="等级" width="120">
<template #default="scope">
@@ -38,7 +58,7 @@
</el-table-column>
</el-table>
<el-pagination
class="pagination"
class="ctms-pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
@@ -159,22 +179,4 @@ onMounted(async () => {
</script>
<style scoped>
.page {
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.spacer {
flex: 1;
}
.pagination {
margin-top: 12px;
text-align: right;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
+20 -82
View File
@@ -1,12 +1,5 @@
<template>
<div class="login-container">
<!-- 动态背景元素 -->
<div class="bg-animate">
<div class="circle circle-1"></div>
<div class="circle circle-2"></div>
<div class="circle circle-3"></div>
</div>
<div class="login-content">
<div class="login-brand">
<h1 class="brand-title">CTMS</h1>
@@ -145,62 +138,11 @@ const onSubmit = async () => {
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: #f8fafc;
background-color: var(--ctms-bg-base);
position: relative;
overflow: hidden;
}
/* 动态背景 */
.bg-animate {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
.circle {
position: absolute;
border-radius: 50%;
filter: blur(80px);
z-index: 0;
opacity: 0.4;
animation: move 20s infinite alternate;
}
.circle-1 {
width: 500px;
height: 500px;
background: #3b82f6;
top: -100px;
left: -100px;
}
.circle-2 {
width: 400px;
height: 400px;
background: #10b981;
bottom: -50px;
right: -50px;
animation-delay: -5s;
}
.circle-3 {
width: 300px;
height: 300px;
background: #6366f1;
top: 40%;
left: 30%;
animation-delay: -10s;
}
@keyframes move {
from { transform: translate(0, 0) scale(1); }
to { transform: translate(50px, 100px) scale(1.1); }
}
.login-content {
width: 420px;
z-index: 1;
@@ -214,17 +156,15 @@ const onSubmit = async () => {
.brand-title {
font-size: 48px;
font-weight: 800;
background: linear-gradient(135deg, var(--ctms-primary) 0%, #6366f1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
color: var(--ctms-text-main);
letter-spacing: -1px;
margin: 0;
}
.brand-subtitle {
font-size: 14px;
color: #64748b;
color: var(--ctms-text-secondary);
margin-top: 8px;
letter-spacing: 2px;
text-transform: uppercase;
@@ -232,11 +172,10 @@ const onSubmit = async () => {
}
.login-card {
border: 1px solid rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
border-radius: 20px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.1);
border: 1px solid var(--ctms-border-color);
background: #ffffff;
border-radius: 16px;
box-shadow: none;
padding: 12px;
}
@@ -248,34 +187,34 @@ const onSubmit = async () => {
.form-title {
font-size: 24px;
font-weight: 700;
color: #1e293b;
color: var(--ctms-text-main);
margin: 0;
}
.form-desc {
font-size: 14px;
color: #94a3b8;
color: var(--ctms-text-secondary);
margin-top: 8px;
}
.elegant-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #475569;
color: var(--ctms-text-regular);
padding-bottom: 8px;
}
.elegant-form :deep(.el-input__wrapper) {
background-color: #f8fafc;
box-shadow: 0 0 0 1px #e2e8f0 inset;
background-color: #ffffff;
box-shadow: 0 0 0 1px var(--ctms-border-color) inset;
padding: 8px 12px;
border-radius: 10px;
transition: var(--ctms-transition);
}
.elegant-form :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px var(--ctms-primary) inset, 0 0 0 4px rgba(47, 84, 235, 0.1) !important;
background-color: #fff;
box-shadow: 0 0 0 1px var(--ctms-primary) inset !important;
background-color: #ffffff;
}
.form-options {
@@ -298,22 +237,21 @@ const onSubmit = async () => {
font-size: 16px;
font-weight: 600;
border-radius: 12px;
background: linear-gradient(135deg, var(--ctms-primary) 0%, #4f46e5 100%);
background: var(--ctms-primary);
border: none;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
box-shadow: none;
transition: var(--ctms-transition);
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.4);
transform: translateY(-1px);
}
.register-prompt {
margin-top: 24px;
text-align: center;
font-size: 14px;
color: #64748b;
color: var(--ctms-text-secondary);
}
.register-prompt a {
@@ -330,6 +268,6 @@ const onSubmit = async () => {
.login-footer p {
font-size: 12px;
color: #94a3b8;
color: var(--ctms-text-disabled);
}
</style>
+55 -48
View File
@@ -1,13 +1,18 @@
<template>
<div class="page" v-if="milestone">
<el-card class="mb-12">
<div class="header">
<div>
<h3>节点详情</h3>
<p class="subtitle">{{ milestone.name }}</p>
</div>
<div class="ctms-page" v-if="milestone">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">节点详情</h1>
</div>
<el-descriptions :column="2" border>
</div>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">基础信息</div>
</div>
</template>
<el-descriptions :column="2" border class="detail-descriptions">
<el-descriptions-item label="名称">{{ milestone.name || "—" }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ typeLabel(milestone.type) }}</el-descriptions-item>
<el-descriptions-item label="中心">{{ siteName(milestone) }}</el-descriptions-item>
@@ -15,38 +20,45 @@
<el-descriptions-item label="计划日期">{{ displayDate(milestone.planned_date) }}</el-descriptions-item>
<el-descriptions-item label="实际日期">{{ displayDate(milestone.actual_date) }}</el-descriptions-item>
<el-descriptions-item label="当前状态">
<el-tag :type="statusColor(milestone.status)">{{ statusLabel(milestone.status) }}</el-tag>
<el-tag :type="statusColor(milestone.status)" effect="plain">{{ statusLabel(milestone.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ milestone.notes || "—" }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-tabs>
<el-tab-pane label="评论">
<CommentList
v-if="milestone.id"
:study-id="studyId"
entity-type="milestones"
:entity-id="milestone.id"
:can-comment="true"
/>
</el-tab-pane>
<el-tab-pane label="附件">
<div class="tip">
<div>用于上传与本节点相关的证明文件如伦理批件会议纪要等仅用于证明该节点的完成情况</div>
<div class="sub-tip">项目通用文件请在项目详情查看中心级文件请在中心管理查看</div>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">协作记录</div>
</div>
<AttachmentList
v-if="milestone.id"
:study-id="studyId"
entity-type="ethics_node"
:entity-id="milestone.id"
/>
</el-tab-pane>
</el-tabs>
</template>
<el-tabs class="detail-tabs">
<el-tab-pane label="评论">
<CommentList
v-if="milestone.id"
:study-id="studyId"
entity-type="milestones"
:entity-id="milestone.id"
:can-comment="true"
/>
</el-tab-pane>
<el-tab-pane label="附件">
<div class="tip">
<div>用于上传与本节点相关的证明文件如伦理批件会议纪要等仅用于证明该节点的完成情况</div>
<div class="sub-tip">项目通用文件请在项目详情查看中心级文件请在中心管理查看</div>
</div>
<AttachmentList
v-if="milestone.id"
:study-id="studyId"
entity-type="ethics_node"
:entity-id="milestone.id"
/>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
<div v-else class="page">
<el-skeleton rows="4" animated />
<div v-else class="ctms-page">
<StateLoading :rows="4" />
</div>
</template>
@@ -61,6 +73,7 @@ import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
import AttachmentList from "../components/attachments/AttachmentList.vue";
import StateLoading from "../components/StateLoading.vue";
import {
getMilestoneStatusColor,
getMilestoneStatusLabel,
@@ -143,28 +156,22 @@ onMounted(async () => {
</script>
<style scoped>
.page {
padding: 16px;
.detail-descriptions :deep(.el-descriptions__label) {
color: var(--ctms-text-secondary);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.subtitle {
margin: 4px 0 0;
color: #666;
}
.mb-12 {
margin-bottom: 12px;
.detail-tabs {
margin-top: -8px;
}
.tip {
margin-bottom: 8px;
color: #666;
color: var(--ctms-text-regular);
line-height: 1.4;
}
.sub-tip {
color: #888;
color: var(--ctms-text-secondary);
font-size: 12px;
}
</style>
+22 -15
View File
@@ -4,6 +4,7 @@
<div class="home-header">
<div class="header-content">
<h1 class="welcome-title">项目概览</h1>
<p class="page-subtitle">当前项目运行状态与关键指标</p>
<p class="study-info">
<span class="study-name">{{ study.currentStudy.name }}</span>
<span class="study-divider">/</span>
@@ -11,7 +12,7 @@
</p>
</div>
<div class="header-meta">
<el-tag effect="plain" class="role-tag">我的角色: {{ auth.user?.role }}</el-tag>
<el-tag effect="plain" class="role-tag">角色: {{ auth.user?.role }}</el-tag>
</div>
</div>
@@ -83,7 +84,7 @@
</div>
<div v-else class="empty-state-container">
<el-empty description="请先在顶部选择一个当前项目" />
<StateEmpty description="请先在顶部选择一个当前项目" />
</div>
</template>
@@ -95,6 +96,7 @@ import { fetchFinanceSummary, fetchOverdueAesCount, fetchOverdueQueriesCount, fe
import KpiCard from "../components/KpiCard.vue";
import QuickActions from "../components/QuickActions.vue";
import { List, Warning, QuestionFilled, Money } from "@element-plus/icons-vue";
import StateEmpty from "../components/StateEmpty.vue";
const auth = useAuthStore();
const study = useStudyStore();
@@ -181,23 +183,29 @@ watch(
.home-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding-bottom: 8px;
align-items: center;
padding-bottom: 4px;
}
.welcome-title {
font-size: 28px;
font-weight: 700;
font-size: 26px;
font-weight: 600;
color: var(--ctms-text-main);
margin: 0;
}
.page-subtitle {
font-size: 13px;
color: var(--ctms-text-secondary);
margin: 6px 0 0;
}
.study-info {
margin: 8px 0 0;
margin: 6px 0 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-size: 13px;
color: var(--ctms-text-secondary);
}
@@ -206,22 +214,21 @@ watch(
}
.role-tag {
background-color: #fff;
border-color: var(--ctms-primary-hover);
color: var(--ctms-primary);
background-color: #ffffff;
border-color: var(--ctms-border-color);
color: var(--ctms-text-regular);
font-weight: 500;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
gap: 16px;
}
.home-alert {
border: none;
border-left: 4px solid;
border-radius: 4px;
border: 1px solid var(--ctms-border-color);
border-radius: var(--ctms-radius);
}
.quick-nav-section {
+34 -17
View File
@@ -8,11 +8,12 @@
<div class="brand-info">
<h1 class="subject-no">{{ subject.subject_no }}</h1>
<p class="site-info">{{ siteName(subject.site_id) }}</p>
<p class="meta-info">受试者详情与状态管理</p>
</div>
</div>
<div class="subject-status">
<span class="status-label">当前状态</span>
<el-tag :type="stateColor(subject.status)" effect="dark" class="status-tag">
<el-tag :type="stateColor(subject.status)" effect="plain" class="status-tag">
{{ stateLabel(subject.status) }}
</el-tag>
</div>
@@ -22,8 +23,9 @@
<template v-for="action in availableActions" :key="action.key">
<PermissionAction :action="permissionMap[action.key]">
<el-button
:type="action.danger ? 'danger' : 'primary'"
:plain="!action.primary"
:type="action.danger ? 'danger' : (action.primary ? 'primary' : 'default')"
:plain="!action.primary && !action.danger"
:link="action.danger"
size="default"
class="action-btn"
@click="onAction(action)"
@@ -42,6 +44,7 @@
<el-icon><Calendar /></el-icon>
<span>访视记录</span>
</div>
<div class="section-desc">按访视节点维护受试者随访数据</div>
</div>
<div class="section-body">
<VisitTable :visits="visits" :subject-id="subject.id" :can-edit="canVisitEdit" :on-refresh="loadVisits" />
@@ -50,7 +53,7 @@
</div>
<div v-else class="loading-state">
<el-skeleton :rows="5" animated />
<StateLoading :rows="5" />
</div>
</template>
@@ -67,6 +70,7 @@ import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission";
import VisitTable from "../components/VisitTable.vue";
import StateLoading from "../components/StateLoading.vue";
import { getAvailableActions, subjectMachine } from "../state-machine";
import type { ActionConfig } from "../state-machine";
import { statusDict, getDictLabel, getDictColor } from "../dictionaries";
@@ -214,7 +218,7 @@ onMounted(async () => {
background-color: #fff;
border-radius: var(--ctms-radius);
padding: 24px;
box-shadow: var(--ctms-shadow-sm);
border: 1px solid var(--ctms-border-color);
display: flex;
justify-content: space-between;
align-items: flex-start;
@@ -232,31 +236,37 @@ onMounted(async () => {
}
.brand-avatar {
width: 56px;
height: 56px;
background-color: var(--ctms-primary-light);
color: var(--ctms-primary);
border-radius: 12px;
width: 48px;
height: 48px;
background-color: #f1f5f9;
color: var(--ctms-text-main);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 700;
font-size: 20px;
font-weight: 600;
}
.subject-no {
font-size: 24px;
font-size: 22px;
font-weight: 600;
color: var(--ctms-text-main);
margin: 0;
}
.site-info {
font-size: 14px;
font-size: 13px;
color: var(--ctms-text-secondary);
margin-top: 4px;
}
.meta-info {
font-size: 12px;
color: var(--ctms-text-disabled);
margin-top: 4px;
}
.subject-status {
display: flex;
flex-direction: column;
@@ -264,7 +274,7 @@ onMounted(async () => {
}
.status-label {
font-size: 12px;
font-size: 11px;
color: var(--ctms-text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -287,7 +297,7 @@ onMounted(async () => {
.info-section {
background-color: #fff;
border-radius: var(--ctms-radius);
box-shadow: var(--ctms-shadow-sm);
border: 1px solid var(--ctms-border-color);
overflow: hidden;
}
@@ -303,7 +313,13 @@ onMounted(async () => {
gap: 8px;
font-weight: 600;
color: var(--ctms-text-main);
font-size: 16px;
font-size: 15px;
}
.section-desc {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 6px;
}
.section-body {
@@ -314,5 +330,6 @@ onMounted(async () => {
padding: 40px;
background-color: #fff;
border-radius: var(--ctms-radius);
border: 1px solid var(--ctms-border-color);
}
</style>
+13 -24
View File
@@ -68,17 +68,14 @@
</el-table-column>
<el-table-column prop="screening_date" label="筛选日期" width="140" />
<el-table-column prop="enrollment_date" label="入组日期" width="140" />
<el-table-column label="操作" width="100" fixed="right">
<el-table-column label="操作" width="100" fixed="right" align="right">
<template #default>
<el-button link type="primary">详细资料</el-button>
</template>
</el-table-column>
<template #empty>
<div class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<p>暂无符合条件的受试者</p>
</div>
<StateEmpty description="暂无符合条件的受试者" />
</template>
</el-table>
@@ -102,12 +99,13 @@
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Plus, Document } from "@element-plus/icons-vue";
import { Plus } from "@element-plus/icons-vue";
import { fetchSubjects } from "../api/subjects";
import { fetchSites } from "../api/sites";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import SubjectForm from "../components/SubjectForm.vue";
import StateEmpty from "../components/StateEmpty.vue";
const study = useStudyStore();
const auth = useAuthStore();
@@ -228,18 +226,18 @@ const siteName = (id: string) => siteMap.value[id] || id || "-";
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-end;
}
.page-title {
font-size: 24px;
font-size: 22px;
font-weight: 600;
color: var(--ctms-text-main);
margin: 0;
}
.page-subtitle {
font-size: 14px;
font-size: 13px;
color: var(--ctms-text-secondary);
margin-top: 4px;
}
@@ -252,11 +250,12 @@ const siteName = (id: string) => siteMap.value[id] || id || "-";
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 24px;
}
.filter-items {
display: flex;
gap: 24px;
gap: 20px;
}
.filter-item {
@@ -266,8 +265,8 @@ const siteName = (id: string) => siteMap.value[id] || id || "-";
}
.filter-label {
font-size: 13px;
font-weight: 500;
font-size: 12px;
font-weight: 600;
color: var(--ctms-text-regular);
}
@@ -281,25 +280,15 @@ const siteName = (id: string) => siteMap.value[id] || id || "-";
.subject-no {
font-weight: 600;
color: var(--ctms-primary);
color: var(--ctms-text-main);
}
.pagination-container {
margin-top: 24px;
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.empty-state {
padding: 40px 0;
color: var(--ctms-text-disabled);
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
:deep(.el-table__row) {
cursor: pointer;
}
+28 -41
View File
@@ -1,33 +1,38 @@
<template>
<div class="page">
<el-card class="mb-12">
<div class="header">
<div>
<h3>项目详情</h3>
<p class="sub" v-if="project">项目{{ project.code }} - {{ project.name }}</p>
</div>
<div class="actions">
<el-button type="primary" @click="enterProject" :disabled="!project">进入项目</el-button>
<el-button @click="goMembers" :disabled="!project">项目成员配置</el-button>
<el-button @click="goSites" :disabled="!project">中心管理</el-button>
</div>
<div class="ctms-page">
<div class="ctms-page-header">
<div>
<h1 class="ctms-page-title">项目详情</h1>
</div>
</el-card>
<div class="ctms-page-actions">
<el-button type="primary" @click="enterProject" :disabled="!project">进入项目</el-button>
<el-button @click="goMembers" :disabled="!project">项目成员配置</el-button>
<el-button @click="goSites" :disabled="!project">中心管理</el-button>
</div>
</div>
<el-card class="mb-12">
<h4 class="section-title">项目基本信息</h4>
<el-descriptions v-if="project" :column="2" border>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">项目基本信息</div>
</div>
</template>
<el-descriptions v-if="project" :column="2" border class="detail-descriptions">
<el-descriptions-item label="项目编号">{{ project.code }}</el-descriptions-item>
<el-descriptions-item label="项目名称">{{ project.name }}</el-descriptions-item>
<el-descriptions-item label="申办方">{{ project.sponsor || "—" }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(project.status) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ displayDateTime(project.created_at) }}</el-descriptions-item>
</el-descriptions>
<el-skeleton v-else :rows="4" animated />
<StateLoading v-else :rows="4" />
</el-card>
<el-card>
<h4 class="section-title">项目通用文件</h4>
<el-card class="ctms-section-card">
<template #header>
<div>
<div class="ctms-section-title">项目通用文件</div>
</div>
</template>
<AttachmentList
v-if="project"
:study-id="project.id"
@@ -35,7 +40,7 @@
:entity-id="project.id"
:show-uploader="true"
/>
<el-skeleton v-else :rows="3" animated />
<StateLoading v-else :rows="3" />
</el-card>
</div>
</template>
@@ -47,6 +52,7 @@ import { ElMessage } from "element-plus";
import { fetchStudyDetail } from "../../api/studies";
import type { Study } from "../../types/api";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateLoading from "../../components/StateLoading.vue";
import { useStudyStore } from "../../store/study";
import { displayDateTime } from "../../utils/display";
@@ -94,26 +100,7 @@ onMounted(() => {
</script>
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.actions {
display: flex;
gap: 8px;
}
.sub {
color: #666;
margin-top: 4px;
}
.section-title {
margin-bottom: 12px;
}
.mb-12 {
margin-bottom: 12px;
.detail-descriptions :deep(.el-descriptions__label) {
color: var(--ctms-text-secondary);
}
</style>
@@ -51,7 +51,7 @@
</transition-group>
</div>
<div v-else class="empty-state">
<el-empty description="暂无待办事项,记录一些备忘吧" :image-size="60" />
<StateEmpty description="暂无待办事项,记录一些备忘吧" />
</div>
</div>
</el-card>
@@ -60,6 +60,7 @@
<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";
const props = defineProps<{ storageKey: string | undefined }>();
@@ -10,36 +10,33 @@
</template>
<div class="section-body">
<el-skeleton :loading="loading" animated :rows="3">
<template #default>
<div v-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)"
<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"
>
<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 || "待处理" }}
</el-tag>
</div>
</div>
{{ item.status || "待处理" }}
</el-tag>
</div>
<div v-else class="empty-state">
<el-empty :description="emptyText" :image-size="60" />
</div>
</template>
</el-skeleton>
</div>
</div>
<div v-else class="empty-state">
<StateEmpty :description="emptyText || '暂无数据'" />
</div>
</div>
</el-card>
</template>
@@ -47,6 +44,8 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import { ArrowRight } from "@element-plus/icons-vue";
import StateEmpty from "../../../components/StateEmpty.vue";
import StateLoading from "../../../components/StateLoading.vue";
defineProps<{
title: string;
@@ -147,11 +146,6 @@ const go = (path: string) => {
}
.empty-state {
padding: 40px 0;
}
:deep(.el-empty__description) {
margin-top: 8px;
color: var(--ctms-text-disabled);
padding: 8px 0;
}
</style>