项目总览界面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
@@ -12,78 +12,68 @@
<div class="chart-plot">
<svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient :id="gradientId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f1f5f9" />
<stop offset="100%" stop-color="#e2e8f0" />
<linearGradient :id="gradientTargetId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#e8edf3" />
<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>
<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>
<rect
class="chart-frame"
x="0.5"
:y="frameTop"
:width="chartWidth - 1"
:height="frameHeight"
rx="12"
/>
<g class="chart-axis">
<!-- Horizontal grid lines -->
<g class="chart-grid">
<line
class="axis-line"
v-for="tick in yTicks"
:key="`grid-${tick.value}`"
class="grid-line"
:x1="axisLeft"
:y1="axisTop"
:x2="axisLeft"
:y2="axisBottom"
/>
<line
class="axis-line"
:x1="axisLeft"
:y1="axisBottom"
:y1="tickY(tick.value)"
:x2="axisRight"
:y2="axisBottom"
:y2="tickY(tick.value)"
/>
<g v-for="tick in yTicks" :key="`${tick.value}-${tick.minor ? 'm' : 'M'}`" class="axis-tick">
<text :x="axisLeft - 12" :y="tickY(tick.value)" class="tick-label">
</g>
<!-- 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) }}
</text>
<line
class="tick-line"
:class="{ minor: tick.minor }"
:x1="axisLeft - 8"
:y1="tickY(tick.value)"
:x2="axisLeft"
:y2="tickY(tick.value)"
/>
</g>
</g>
<!-- Bars -->
<g class="chart-bars">
<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
v-if="showTarget"
v-if="showTarget && (item.target || 0) > 0"
class="bar-target"
:x="barLeft(index)"
:y="barTop(item.target || 0)"
:width="barWidth"
:height="barHeight(item.target || 0)"
rx="8"
:fill="`url(#${gradientId})`"
:rx="barRadius"
:fill="`url(#${gradientTargetId})`"
/>
<!-- Actual bar -->
<rect
v-if="item.actual > 0"
class="bar-actual"
:x="barLeft(index)"
:y="barTop(item.actual)"
:width="barWidth"
:height="barHeight(item.actual)"
rx="8"
:filter="`url(#${shadowId})`"
:rx="barRadius"
:fill="`url(#${gradientActualId})`"
/>
<!-- Value label -->
<text :x="barCenter(index)" :y="valueY(item)" class="bar-value">
<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-target" dx="4">{{ formatNumber(item.target || 0) }}</tspan>
<tspan v-if="showTarget" class="bar-value-divider" dx="3">/</tspan>
<tspan v-if="showTarget" class="bar-value-target" dx="3">{{ formatNumber(item.target || 0) }}</tspan>
</text>
<!-- X axis label -->
<text :x="barCenter(index)" :y="labelY" class="bar-label">
{{ truncateLabel(item.label) }}
</text>
@@ -125,16 +115,16 @@ const props = withDefaults(
const showTarget = computed(() => props.mode === "center");
const chartWidth = 960;
const chartHeight = 300;
const chartHeight = 260;
const axisPadding = {
top: 24,
right: 24,
bottom: 52,
left: 72,
top: 28,
right: 32,
bottom: 48,
left: 56,
};
const gradientId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`;
const shadowId = `enroll-shadow-${Math.random().toString(36).slice(2, 8)}`;
const gradientTargetId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`;
const gradientActualId = `enroll-actual-${Math.random().toString(36).slice(2, 8)}`;
const maxValue = computed(() => {
if (!props.items.length) return 1;
@@ -146,7 +136,7 @@ const maxValue = computed(() => {
const yTicks = computed(() => {
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 ticks: Array<{ value: number; minor: boolean }> = [];
for (let value = top; value >= 0; value -= step) {
@@ -162,12 +152,12 @@ const axisLeft = axisPadding.left;
const axisRight = chartWidth - axisPadding.right;
const axisTop = axisPadding.top;
const axisBottom = chartHeight - axisPadding.bottom;
const labelY = axisBottom + 18;
const valueGap = 8;
const valuePadding = 8;
const labelY = axisBottom + 20;
const valueGap = 10;
const barRadius = 6;
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 barLeft = (index: number) => barCenter(index) - barWidth.value / 2;
@@ -183,20 +173,6 @@ const valueY = (item: EnrollmentBarItem) => {
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) => {
if (label.length <= 8) return label;
return `${label.slice(0, 7)}...`;
@@ -211,15 +187,14 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
}
.chart-empty-shell {
min-height: 176px;
border-radius: 18px;
min-height: 160px;
border-radius: 12px;
padding: 22px;
background:
linear-gradient(180deg, rgba(246, 249, 253, 0.96), rgba(251, 253, 255, 0.98));
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
}
.chart-empty-body {
min-height: 118px;
min-height: 100px;
display: flex;
flex-direction: column;
align-items: center;
@@ -228,37 +203,33 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
}
.chart-empty-title {
font-size: 18px;
font-weight: 700;
color: #243a5a;
letter-spacing: 0.01em;
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-regular);
}
.chart-empty-desc {
margin-top: 10px;
font-size: 14px;
margin-top: 8px;
font-size: 13px;
line-height: 1.7;
color: #8a97ab;
color: var(--ctms-text-secondary);
}
.chart-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.chart-scroll {
overflow: hidden;
padding-bottom: 8px;
}
.chart-plot {
border-radius: 12px;
padding: 0;
background-color: transparent;
border-radius: 10px;
background: linear-gradient(180deg, #fafbfd 0%, #f6f8fb 100%);
position: relative;
aspect-ratio: 16 / 5;
min-height: 220px;
aspect-ratio: 960 / 260;
min-height: 200px;
}
.chart-svg {
@@ -268,32 +239,31 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
overflow: visible;
}
.chart-frame {
fill: #ffffff;
stroke: #000000;
stroke-width: 1px;
}
.axis-line,
.tick-line {
stroke: #000000;
.grid-line {
stroke: #e8ecf1;
stroke-width: 1px;
stroke-dasharray: 4 3;
}
.tick-label {
font-size: 11px;
fill: #000000;
fill: var(--ctms-text-disabled);
dominant-baseline: middle;
text-anchor: end;
font-weight: 500;
}
.bar-target {
stroke: #e2e8f0;
stroke: #dce3ec;
stroke-width: 1px;
}
.bar-actual {
fill: var(--ctms-primary);
transition: opacity 0.2s ease;
}
.bar-group:hover .bar-actual {
opacity: 0.85;
}
.bar-value {
@@ -304,14 +274,19 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
}
.bar-value-actual {
fill: var(--ctms-text-main);
font-weight: 600;
fill: var(--ctms-primary);
font-weight: 700;
font-size: 13px;
}
.bar-value-divider,
.bar-value-target {
.bar-value-divider {
fill: var(--ctms-text-disabled);
font-weight: 600;
font-weight: 400;
}
.bar-value-target {
fill: var(--ctms-text-secondary);
font-weight: 500;
}
.bar-label {
@@ -319,12 +294,7 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
fill: var(--ctms-text-regular);
text-anchor: middle;
dominant-baseline: hanging;
}
.chart-footnote {
font-size: 12px;
color: var(--ctms-text-secondary);
text-align: right;
font-weight: 500;
}
@media (max-width: 768px) {