项目总览界面UI美化与入组数据统计修复

- 重构总览页面布局:卡片化设计、顶部标题栏、图标装饰
- 美化进度时间线:hover高亮、呼吸动画、渐变连接线
- 重写入组图表:去除边框、虚线网格、渐变柱子、柔和背景
- 修复入组统计:同时匹配enrollment_date和ENROLLED状态
- 移除adapter中hasEnrollmentStage过滤,显示真实入组数

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-20 16:53:36 +08:00
parent 5b97ea32ab
commit 6d682103f3
7 changed files with 323 additions and 202 deletions
+27 -20
View File
@@ -3,7 +3,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Dict, List, Tuple from typing import Dict, List, Tuple
from sqlalchemy import func, select, and_ from sqlalchemy import Date, cast, func, select, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.site import Site from app.models.site import Site
@@ -86,20 +86,18 @@ async def get_project_overview(db: AsyncSession, study_id: uuid.UUID) -> Dict:
startup_status = "NOT_STARTED" startup_status = "NOT_STARTED"
startup_completed = kickoff.get("kickoff_date") if contract_status == "COMPLETED" else None startup_completed = kickoff.get("kickoff_date") if contract_status == "COMPLETED" else None
# 入组状态 - 依赖启动会完成 # 入组状态
enrollment = enrollment_data.get(site_id_str, {"actual": 0}) enrollment = enrollment_data.get(site_id_str, {"actual": 0})
enrollment_actual = enrollment["actual"] enrollment_actual = enrollment["actual"]
enrollment_target = site.enrollment_target or 0 enrollment_target = site.enrollment_target or 0
if startup_status == "COMPLETED": if enrollment_actual >= enrollment_target and enrollment_target > 0:
if enrollment_actual >= enrollment_target and enrollment_target > 0: enrollment_status = "COMPLETED"
enrollment_status = "COMPLETED" elif enrollment_actual > 0:
elif enrollment_actual > 0: enrollment_status = "IN_PROGRESS"
enrollment_status = "IN_PROGRESS" elif startup_status == "COMPLETED":
else: enrollment_status = "NOT_STARTED"
enrollment_status = "NOT_STARTED"
else: else:
# 启动会未完成,入组强制为未开始
enrollment_status = "NOT_STARTED" enrollment_status = "NOT_STARTED"
# 检查状态 - 依赖入组完成 # 检查状态 - 依赖入组完成
@@ -265,35 +263,44 @@ async def _get_enrollment_data(db: AsyncSession, study_id: uuid.UUID, site_ids:
and_( and_(
Subject.study_id == study_id, Subject.study_id == study_id,
Subject.site_id.in_(site_ids), Subject.site_id.in_(site_ids),
Subject.enrollment_date.isnot(None) or_(
Subject.enrollment_date.isnot(None),
Subject.status == "ENROLLED"
)
) )
).group_by(Subject.site_id) ).group_by(Subject.site_id)
result = await db.execute(stmt) result = await db.execute(stmt)
rows = result.all() rows = result.all()
data = {} data = {}
for row in rows: for row in rows:
data[str(row.site_id)] = {"actual": row.count} data[str(row.site_id)] = {"actual": row.count}
return data return data
async def _get_monthly_enrollment(db: AsyncSession, study_id: uuid.UUID) -> List[Dict]: async def _get_monthly_enrollment(db: AsyncSession, study_id: uuid.UUID) -> List[Dict]:
"""获取月度入组统计""" """获取月度入组统计"""
month_col = func.to_char(Subject.enrollment_date, 'YYYY-MM').label("month") month_col = func.to_char(
func.coalesce(Subject.enrollment_date, func.cast(Subject.created_at, Date)),
'YYYY-MM'
).label("month")
stmt = select( stmt = select(
month_col, month_col,
func.count(Subject.id).label("count") func.count(Subject.id).label("count")
).where( ).where(
and_( and_(
Subject.study_id == study_id, Subject.study_id == study_id,
Subject.enrollment_date.isnot(None) or_(
Subject.enrollment_date.isnot(None),
Subject.status == "ENROLLED"
)
) )
).group_by(month_col).order_by(month_col) ).group_by(month_col).order_by(month_col)
result = await db.execute(stmt) result = await db.execute(stmt)
rows = result.all() rows = result.all()
return [{"month": row.month, "count": row.count} for row in rows] return [{"month": row.month, "count": row.count} for row in rows]
+13 -12
View File
@@ -7099,30 +7099,31 @@ onBeforeUnmount(() => {
.setup-content-step-nav { .setup-content-step-nav {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
} }
.setup-content-step-nav :deep(.el-button.is-circle) { .setup-content-step-nav :deep(.el-button.is-circle) {
width: 28px; width: 32px;
height: 28px; height: 32px;
padding: 0; padding: 0;
background: transparent !important; background: #f1f5f9 !important;
border: 1px solid #e2e8f0 !important; border: 1.5px solid #cbd5e1 !important;
color: #64748b !important; color: #475569 !important;
font-size: 14px;
transition: all 0.2s; transition: all 0.2s;
} }
.setup-content-step-nav :deep(.el-button.is-circle:hover) { .setup-content-step-nav :deep(.el-button.is-circle:hover) {
background: #f8fafc !important; background: #e2e8f0 !important;
border-color: #cbd5e1 !important; border-color: #94a3b8 !important;
color: #3b82f6 !important; color: #1e40af !important;
} }
.setup-content-step-nav :deep(.el-button.is-circle.is-disabled), .setup-content-step-nav :deep(.el-button.is-circle.is-disabled),
.setup-content-step-nav :deep(.el-button.is-circle.is-disabled:hover) { .setup-content-step-nav :deep(.el-button.is-circle.is-disabled:hover) {
background: transparent !important; background: #f8fafc !important;
border-color: #f1f5f9 !important; border-color: #e2e8f0 !important;
color: #e2e8f0 !important; color: #cbd5e1 !important;
cursor: not-allowed; cursor: not-allowed;
} }
+167 -39
View File
@@ -1,14 +1,31 @@
<template> <template>
<div class="page ctms-page-shell page--flush"> <div class="page ctms-page-shell page--flush">
<div v-if="study.currentStudy" class="page-body"> <div v-if="study.currentStudy" class="page-body">
<div class="unified-shell ctms-table-card"> <div class="overview-container">
<section class="unified-section"> <div class="overview-top-bar">
<div class="overview-meta-inline"> <div class="overview-top-left">
<div class="updated-at">更新{{ updatedAtLabel }}</div> <span class="overview-top-icon">
<el-button size="small" @click="loadOverview">刷新</el-button> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
</span>
<span class="overview-top-title">项目总览</span>
</div> </div>
<div class="overview-top-right">
<span class="overview-updated-label">更新于 {{ updatedAtLabel }}</span>
<el-button size="small" @click="loadOverview" class="refresh-btn">
<template #icon>
<el-icon><Refresh /></el-icon>
</template>
刷新
</el-button>
</div>
</div>
<section class="overview-card">
<div class="card-header"> <div class="card-header">
<div> <div class="card-header-left">
<span class="card-icon card-icon--progress">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</span>
<div class="card-title">中心整体进度</div> <div class="card-title">中心整体进度</div>
</div> </div>
<div class="progress-legend"> <div class="progress-legend">
@@ -21,6 +38,9 @@
<StateLoading v-if="loading" :rows="6" /> <StateLoading v-if="loading" :rows="6" />
<div v-else-if="centers.length === 0" class="overview-empty-panel"> <div v-else-if="centers.length === 0" class="overview-empty-panel">
<div class="overview-empty-content"> <div class="overview-empty-content">
<div class="overview-empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<div class="overview-empty-title">中心整体进度暂未生成</div> <div class="overview-empty-title">中心整体进度暂未生成</div>
<div class="overview-empty-desc">当前项目未配置中心或尚未形成可展示的中心进度数据</div> <div class="overview-empty-desc">当前项目未配置中心或尚未形成可展示的中心进度数据</div>
</div> </div>
@@ -34,12 +54,15 @@
</div> </div>
</section> </section>
<section class="unified-section"> <section class="overview-card">
<div class="card-header"> <div class="card-header">
<div> <div class="card-header-left">
<div class="card-title">入组进度</div> <span class="card-icon card-icon--enrollment">
<div class="card-subtitle"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
{{ enrollmentSummary }} </span>
<div>
<div class="card-title">入组进度</div>
<div class="card-subtitle">{{ enrollmentSummary }}</div>
</div> </div>
</div> </div>
<el-radio-group v-model="chartMode" size="small" class="mode-switch"> <el-radio-group v-model="chartMode" size="small" class="mode-switch">
@@ -62,6 +85,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { Refresh } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { displayDateTime } from "../../utils/display"; import { displayDateTime } from "../../utils/display";
@@ -197,25 +221,78 @@ watch(
.page { .page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0;
} }
.page-body { .page-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0;
} }
.updated-at { .overview-container {
white-space: nowrap; padding: 24px 28px 32px;
} display: flex;
flex-direction: column;
.overview-meta-inline { gap: 20px;
}
.overview-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.overview-top-left {
display: flex; display: flex;
justify-content: flex-end;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
margin-bottom: 10px; }
.overview-top-icon {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-hover));
color: #ffffff;
}
.overview-top-title {
font-size: 18px;
font-weight: 700;
color: var(--ctms-text-main);
letter-spacing: -0.01em;
}
.overview-top-right {
display: flex;
align-items: center;
gap: 12px;
}
.overview-updated-label {
font-size: 13px;
color: var(--ctms-text-secondary);
}
.refresh-btn {
border-radius: 8px;
}
.overview-card {
background: var(--ctms-bg-card);
border: 1px solid var(--ctms-border-color);
border-radius: var(--ctms-radius-lg);
padding: 20px 24px;
transition: var(--ctms-transition);
box-shadow: var(--ctms-shadow-sm);
}
.overview-card:hover {
box-shadow: var(--ctms-shadow);
border-color: var(--ctms-border-color-hover);
} }
.card-header { .card-header {
@@ -224,24 +301,51 @@ watch(
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 16px;
}
.card-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
flex-shrink: 0;
}
.card-icon--progress {
background: rgba(63, 143, 107, 0.1);
color: var(--ctms-success);
}
.card-icon--enrollment {
background: rgba(63, 93, 117, 0.1);
color: var(--ctms-primary);
} }
.card-title { .card-title {
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
color: #0f2345; color: var(--ctms-text-main);
} }
.card-subtitle { .card-subtitle {
font-size: 12px; font-size: 12px;
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
margin-top: 4px; margin-top: 2px;
} }
.progress-legend { .progress-legend {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 14px;
font-size: 12px; font-size: 12px;
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
} }
@@ -282,7 +386,7 @@ watch(
.progress-list { .progress-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 0;
} }
.overview-empty-panel { .overview-empty-panel {
@@ -290,11 +394,9 @@ watch(
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 6px; padding: 32px 24px;
padding: 24px; border-radius: 12px;
border-radius: 18px; background: linear-gradient(180deg, #f8fafc, #f1f5f9);
background:
linear-gradient(180deg, rgba(246, 249, 253, 0.96), rgba(251, 253, 255, 0.98));
} }
.overview-empty-content { .overview-empty-content {
@@ -302,25 +404,51 @@ watch(
text-align: center; text-align: center;
} }
.overview-empty-icon {
margin-bottom: 12px;
color: var(--ctms-text-disabled);
}
.overview-empty-title { .overview-empty-title {
font-size: 18px; font-size: 16px;
font-weight: 700; font-weight: 600;
letter-spacing: 0.01em; color: var(--ctms-text-regular);
color: #243a5a;
} }
.overview-empty-desc { .overview-empty-desc {
margin-top: 10px; margin-top: 8px;
font-size: 14px; font-size: 13px;
line-height: 1.7; line-height: 1.7;
color: #8a97ab; color: var(--ctms-text-secondary);
} }
.mode-switch :deep(.el-radio-button__inner) { .mode-switch :deep(.el-radio-button__inner) {
padding: 4px 12px; padding: 5px 14px;
border-radius: 6px;
} }
@media (max-width: 1024px) { .mode-switch :deep(.el-radio-group) {
border-radius: 8px;
}
@media (max-width: 768px) {
.overview-container {
padding: 16px;
}
.overview-card {
padding: 16px;
}
.card-header {
flex-direction: column;
align-items: flex-start;
}
.overview-top-bar {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
} }
</style> </style>
@@ -56,12 +56,14 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
display: grid; display: grid;
grid-template-columns: 180px 1fr; grid-template-columns: 180px 1fr;
gap: 16px; gap: 16px;
padding: 14px 0; padding: 14px 16px;
border-bottom: 1px dashed var(--ctms-border-color); border-radius: 10px;
margin: 2px 0;
transition: background-color 0.2s ease;
} }
.center-row:last-child { .center-row:hover {
border-bottom: none; background-color: var(--ctms-neutral-100);
} }
.center-meta { .center-meta {
@@ -87,9 +89,10 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
.enrollment-label { .enrollment-label {
padding: 2px 8px; padding: 2px 8px;
border-radius: 999px; border-radius: 999px;
background-color: var(--ctms-bg-muted); background-color: rgba(63, 93, 117, 0.08);
color: var(--ctms-text-secondary); color: var(--ctms-primary);
font-weight: 600; font-weight: 600;
font-size: 11px;
} }
.center-timeline { .center-timeline {
@@ -130,10 +133,10 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
position: absolute; position: absolute;
right: -2px; right: -2px;
top: 50%; top: 50%;
width: 6px; width: 5px;
height: 6px; height: 5px;
border-top: 2px solid var(--ctms-border-color); border-top: 1.5px solid var(--ctms-border-color);
border-right: 2px solid var(--ctms-border-color); border-right: 1.5px solid var(--ctms-border-color);
transform: translateY(-50%) rotate(45deg); transform: translateY(-50%) rotate(45deg);
} }
@@ -142,7 +145,7 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
} }
.connector-in_progress { .connector-in_progress {
background-color: var(--ctms-primary); background: linear-gradient(90deg, var(--ctms-primary), rgba(63, 93, 117, 0.3));
} }
.connector-blocked { .connector-blocked {
@@ -177,7 +180,8 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
} }
.row-inactive { .row-inactive {
background-color: #fff7d6; background-color: rgba(255, 247, 214, 0.5);
border: 1px dashed rgba(197, 139, 42, 0.3);
} }
.row-inactive .center-name { .row-inactive .center-name {
@@ -186,6 +190,6 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
.row-inactive .stage-connector, .row-inactive .stage-connector,
.row-inactive .center-timeline { .row-inactive .center-timeline {
opacity: 0.6; opacity: 0.5;
} }
</style> </style>
@@ -12,78 +12,68 @@
<div class="chart-plot"> <div class="chart-plot">
<svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet"> <svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
<defs> <defs>
<linearGradient :id="gradientId" x1="0" y1="0" x2="0" y2="1"> <linearGradient :id="gradientTargetId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f1f5f9" /> <stop offset="0%" stop-color="#e8edf3" />
<stop offset="100%" stop-color="#e2e8f0" /> <stop offset="100%" stop-color="#f1f5f9" />
</linearGradient>
<linearGradient :id="gradientActualId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#4a7c9b" />
<stop offset="100%" stop-color="#3f5d75" />
</linearGradient> </linearGradient>
<filter :id="shadowId" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#3f5d75" flood-opacity="0.18" />
</filter>
</defs> </defs>
<rect <!-- Horizontal grid lines -->
class="chart-frame" <g class="chart-grid">
x="0.5"
:y="frameTop"
:width="chartWidth - 1"
:height="frameHeight"
rx="12"
/>
<g class="chart-axis">
<line <line
class="axis-line" v-for="tick in yTicks"
:key="`grid-${tick.value}`"
class="grid-line"
:x1="axisLeft" :x1="axisLeft"
:y1="axisTop" :y1="tickY(tick.value)"
:x2="axisLeft"
:y2="axisBottom"
/>
<line
class="axis-line"
:x1="axisLeft"
:y1="axisBottom"
:x2="axisRight" :x2="axisRight"
:y2="axisBottom" :y2="tickY(tick.value)"
/> />
<g v-for="tick in yTicks" :key="`${tick.value}-${tick.minor ? 'm' : 'M'}`" class="axis-tick"> </g>
<text :x="axisLeft - 12" :y="tickY(tick.value)" class="tick-label"> <!-- Y axis labels -->
<g class="chart-axis">
<g v-for="tick in yTicks" :key="`tick-${tick.value}`" class="axis-tick">
<text :x="axisLeft - 14" :y="tickY(tick.value)" class="tick-label">
{{ formatNumber(tick.value) }} {{ formatNumber(tick.value) }}
</text> </text>
<line
class="tick-line"
:class="{ minor: tick.minor }"
:x1="axisLeft - 8"
:y1="tickY(tick.value)"
:x2="axisLeft"
:y2="tickY(tick.value)"
/>
</g> </g>
</g> </g>
<!-- Bars -->
<g class="chart-bars"> <g class="chart-bars">
<g v-for="(item, index) in items" :key="item.key" class="bar-group"> <g v-for="(item, index) in items" :key="item.key" class="bar-group">
<title>{{ item.label }}</title> <title>{{ item.label }}: {{ item.actual }}{{ showTarget ? ` / ${item.target}` : '' }}</title>
<!-- Target bar (background) -->
<rect <rect
v-if="showTarget" v-if="showTarget && (item.target || 0) > 0"
class="bar-target" class="bar-target"
:x="barLeft(index)" :x="barLeft(index)"
:y="barTop(item.target || 0)" :y="barTop(item.target || 0)"
:width="barWidth" :width="barWidth"
:height="barHeight(item.target || 0)" :height="barHeight(item.target || 0)"
rx="8" :rx="barRadius"
:fill="`url(#${gradientId})`" :fill="`url(#${gradientTargetId})`"
/> />
<!-- Actual bar -->
<rect <rect
v-if="item.actual > 0"
class="bar-actual" class="bar-actual"
:x="barLeft(index)" :x="barLeft(index)"
:y="barTop(item.actual)" :y="barTop(item.actual)"
:width="barWidth" :width="barWidth"
:height="barHeight(item.actual)" :height="barHeight(item.actual)"
rx="8" :rx="barRadius"
:filter="`url(#${shadowId})`" :fill="`url(#${gradientActualId})`"
/> />
<!-- Value label -->
<text :x="barCenter(index)" :y="valueY(item)" class="bar-value"> <text :x="barCenter(index)" :y="valueY(item)" class="bar-value">
<tspan class="bar-value-actual">{{ formatNumber(item.actual) }}</tspan> <tspan class="bar-value-actual">{{ formatNumber(item.actual) }}</tspan>
<tspan v-if="showTarget" class="bar-value-divider" dx="4">/</tspan> <tspan v-if="showTarget" class="bar-value-divider" dx="3">/</tspan>
<tspan v-if="showTarget" class="bar-value-target" dx="4">{{ formatNumber(item.target || 0) }}</tspan> <tspan v-if="showTarget" class="bar-value-target" dx="3">{{ formatNumber(item.target || 0) }}</tspan>
</text> </text>
<!-- X axis label -->
<text :x="barCenter(index)" :y="labelY" class="bar-label"> <text :x="barCenter(index)" :y="labelY" class="bar-label">
{{ truncateLabel(item.label) }} {{ truncateLabel(item.label) }}
</text> </text>
@@ -125,16 +115,16 @@ const props = withDefaults(
const showTarget = computed(() => props.mode === "center"); const showTarget = computed(() => props.mode === "center");
const chartWidth = 960; const chartWidth = 960;
const chartHeight = 300; const chartHeight = 260;
const axisPadding = { const axisPadding = {
top: 24, top: 28,
right: 24, right: 32,
bottom: 52, bottom: 48,
left: 72, left: 56,
}; };
const gradientId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`; const gradientTargetId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`;
const shadowId = `enroll-shadow-${Math.random().toString(36).slice(2, 8)}`; const gradientActualId = `enroll-actual-${Math.random().toString(36).slice(2, 8)}`;
const maxValue = computed(() => { const maxValue = computed(() => {
if (!props.items.length) return 1; if (!props.items.length) return 1;
@@ -146,7 +136,7 @@ const maxValue = computed(() => {
const yTicks = computed(() => { const yTicks = computed(() => {
const max = maxValue.value; const max = maxValue.value;
const step = max <= 12 ? 1 : Math.max(1, Math.ceil(max / 6)); const step = max <= 5 ? 1 : max <= 12 ? 2 : Math.max(1, Math.ceil(max / 5));
const top = Math.max(1, Math.ceil(max / step) * step); const top = Math.max(1, Math.ceil(max / step) * step);
const ticks: Array<{ value: number; minor: boolean }> = []; const ticks: Array<{ value: number; minor: boolean }> = [];
for (let value = top; value >= 0; value -= step) { for (let value = top; value >= 0; value -= step) {
@@ -162,12 +152,12 @@ const axisLeft = axisPadding.left;
const axisRight = chartWidth - axisPadding.right; const axisRight = chartWidth - axisPadding.right;
const axisTop = axisPadding.top; const axisTop = axisPadding.top;
const axisBottom = chartHeight - axisPadding.bottom; const axisBottom = chartHeight - axisPadding.bottom;
const labelY = axisBottom + 18; const labelY = axisBottom + 20;
const valueGap = 8; const valueGap = 10;
const valuePadding = 8; const barRadius = 6;
const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value)); const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value));
const barWidth = computed(() => Math.min(48, bandWidth.value * 0.6)); const barWidth = computed(() => Math.min(52, bandWidth.value * 0.5));
const barCenter = (index: number) => axisLeft + bandWidth.value * index + bandWidth.value / 2; const barCenter = (index: number) => axisLeft + bandWidth.value * index + bandWidth.value / 2;
const barLeft = (index: number) => barCenter(index) - barWidth.value / 2; const barLeft = (index: number) => barCenter(index) - barWidth.value / 2;
@@ -183,20 +173,6 @@ const valueY = (item: EnrollmentBarItem) => {
return barTop(anchor) - valueGap; return barTop(anchor) - valueGap;
}; };
const minValueY = computed(() => {
if (!props.items.length) return axisTop;
return Math.min(
...props.items.map((item) => {
const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return barTop(anchor) - valueGap;
})
);
});
const extraTop = computed(() => Math.max(0, valuePadding - minValueY.value));
const frameTop = computed(() => 0.5 - extraTop.value);
const frameHeight = computed(() => chartHeight - 1 + extraTop.value);
const truncateLabel = (label: string) => { const truncateLabel = (label: string) => {
if (label.length <= 8) return label; if (label.length <= 8) return label;
return `${label.slice(0, 7)}...`; return `${label.slice(0, 7)}...`;
@@ -211,15 +187,14 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
} }
.chart-empty-shell { .chart-empty-shell {
min-height: 176px; min-height: 160px;
border-radius: 18px; border-radius: 12px;
padding: 22px; padding: 22px;
background: background: linear-gradient(180deg, #f8fafc, #f1f5f9);
linear-gradient(180deg, rgba(246, 249, 253, 0.96), rgba(251, 253, 255, 0.98));
} }
.chart-empty-body { .chart-empty-body {
min-height: 118px; min-height: 100px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -228,37 +203,33 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
} }
.chart-empty-title { .chart-empty-title {
font-size: 18px; font-size: 16px;
font-weight: 700; font-weight: 600;
color: #243a5a; color: var(--ctms-text-regular);
letter-spacing: 0.01em;
} }
.chart-empty-desc { .chart-empty-desc {
margin-top: 10px; margin-top: 8px;
font-size: 14px; font-size: 13px;
line-height: 1.7; line-height: 1.7;
color: #8a97ab; color: var(--ctms-text-secondary);
} }
.chart-body { .chart-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px;
} }
.chart-scroll { .chart-scroll {
overflow: hidden; overflow: hidden;
padding-bottom: 8px;
} }
.chart-plot { .chart-plot {
border-radius: 12px; border-radius: 10px;
padding: 0; background: linear-gradient(180deg, #fafbfd 0%, #f6f8fb 100%);
background-color: transparent;
position: relative; position: relative;
aspect-ratio: 16 / 5; aspect-ratio: 960 / 260;
min-height: 220px; min-height: 200px;
} }
.chart-svg { .chart-svg {
@@ -268,32 +239,31 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
overflow: visible; overflow: visible;
} }
.chart-frame { .grid-line {
fill: #ffffff; stroke: #e8ecf1;
stroke: #000000;
stroke-width: 1px;
}
.axis-line,
.tick-line {
stroke: #000000;
stroke-width: 1px; stroke-width: 1px;
stroke-dasharray: 4 3;
} }
.tick-label { .tick-label {
font-size: 11px; font-size: 11px;
fill: #000000; fill: var(--ctms-text-disabled);
dominant-baseline: middle; dominant-baseline: middle;
text-anchor: end; text-anchor: end;
font-weight: 500;
} }
.bar-target { .bar-target {
stroke: #e2e8f0; stroke: #dce3ec;
stroke-width: 1px; stroke-width: 1px;
} }
.bar-actual { .bar-actual {
fill: var(--ctms-primary); transition: opacity 0.2s ease;
}
.bar-group:hover .bar-actual {
opacity: 0.85;
} }
.bar-value { .bar-value {
@@ -304,14 +274,19 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
} }
.bar-value-actual { .bar-value-actual {
fill: var(--ctms-text-main); fill: var(--ctms-primary);
font-weight: 600; font-weight: 700;
font-size: 13px;
} }
.bar-value-divider, .bar-value-divider {
.bar-value-target {
fill: var(--ctms-text-disabled); fill: var(--ctms-text-disabled);
font-weight: 600; font-weight: 400;
}
.bar-value-target {
fill: var(--ctms-text-secondary);
font-weight: 500;
} }
.bar-label { .bar-label {
@@ -319,12 +294,7 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
fill: var(--ctms-text-regular); fill: var(--ctms-text-regular);
text-anchor: middle; text-anchor: middle;
dominant-baseline: hanging; dominant-baseline: hanging;
} font-weight: 500;
.chart-footnote {
font-size: 12px;
color: var(--ctms-text-secondary);
text-align: right;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -42,7 +42,7 @@ const statusClass = computed(() => `stage-${props.status.toLowerCase()}`);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 6px; gap: 5px;
min-width: 64px; min-width: 64px;
} }
@@ -52,7 +52,7 @@ const statusClass = computed(() => `stage-${props.status.toLowerCase()}`);
border-radius: 50%; border-radius: 50%;
border: 2px solid var(--ctms-border-color); border: 2px solid var(--ctms-border-color);
background-color: #ffffff; background-color: #ffffff;
transition: var(--ctms-transition); transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
} }
.stage-label { .stage-label {
@@ -60,24 +60,35 @@ const statusClass = computed(() => `stage-${props.status.toLowerCase()}`);
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
transition: color 0.2s ease;
} }
.stage-date { .stage-date {
font-size: 11px; font-size: 10px;
color: var(--ctms-text-disabled); color: var(--ctms-text-disabled);
text-align: center; text-align: center;
white-space: nowrap; white-space: nowrap;
background: rgba(63, 143, 107, 0.06);
padding: 1px 6px;
border-radius: 4px;
} }
.stage-completed .stage-dot { .stage-completed .stage-dot {
background-color: var(--ctms-success); background-color: var(--ctms-success);
border-color: var(--ctms-success); border-color: var(--ctms-success);
box-shadow: 0 2px 6px rgba(63, 143, 107, 0.3);
} }
.stage-in_progress .stage-dot { .stage-in_progress .stage-dot {
border-color: var(--ctms-primary); border-color: var(--ctms-primary);
box-shadow: 0 0 0 3px rgba(63, 93, 117, 0.18); box-shadow: 0 0 0 3px rgba(63, 93, 117, 0.15);
background-color: #ffffff; background-color: #ffffff;
animation: pulse-ring 2s ease-in-out infinite;
}
@keyframes pulse-ring {
0%, 100% { box-shadow: 0 0 0 3px rgba(63, 93, 117, 0.15); }
50% { box-shadow: 0 0 0 5px rgba(63, 93, 117, 0.08); }
} }
.stage-not_started .stage-dot { .stage-not_started .stage-dot {
@@ -87,7 +98,8 @@ const statusClass = computed(() => `stage-${props.status.toLowerCase()}`);
.stage-blocked .stage-dot { .stage-blocked .stage-dot {
border-color: var(--ctms-danger); border-color: var(--ctms-danger);
background-color: rgba(194, 75, 75, 0.12); background-color: rgba(194, 75, 75, 0.1);
box-shadow: 0 2px 6px rgba(194, 75, 75, 0.2);
} }
.stage-completed .stage-label { .stage-completed .stage-label {
@@ -111,7 +111,6 @@ export const adaptProjectOverview = (raw: unknown): ProjectOverviewViewModel =>
const centers = centersRaw.map((center) => { const centers = centersRaw.map((center) => {
const enrollmentStatus = normalizeStatus((center as any).enrollment_status); const enrollmentStatus = normalizeStatus((center as any).enrollment_status);
const enrollmentActualRaw = toNumber((center as any).enrollment_actual); const enrollmentActualRaw = toNumber((center as any).enrollment_actual);
const hasEnrollmentStage = enrollmentStatus === "IN_PROGRESS" || enrollmentStatus === "COMPLETED";
return { return {
center_id: toString((center as any).center_id || (center as any).id), center_id: toString((center as any).center_id || (center as any).id),
center_name: toString((center as any).center_name || (center as any).name), center_name: toString((center as any).center_name || (center as any).name),
@@ -123,7 +122,7 @@ export const adaptProjectOverview = (raw: unknown): ProjectOverviewViewModel =>
inspection_status: normalizeStatus((center as any).inspection_status), inspection_status: normalizeStatus((center as any).inspection_status),
closeout_status: normalizeStatus((center as any).closeout_status), closeout_status: normalizeStatus((center as any).closeout_status),
enrollment_target: toNumber((center as any).enrollment_target), enrollment_target: toNumber((center as any).enrollment_target),
enrollment_actual: hasEnrollmentStage ? enrollmentActualRaw : 0, enrollment_actual: enrollmentActualRaw,
institution_initiation_completed_at: toString((center as any).institution_initiation_completed_at), institution_initiation_completed_at: toString((center as any).institution_initiation_completed_at),
ethics_completed_at: toString((center as any).ethics_completed_at), ethics_completed_at: toString((center as any).ethics_completed_at),
contract_sign_completed_at: toString((center as any).contract_sign_completed_at), contract_sign_completed_at: toString((center as any).contract_sign_completed_at),