<template>
<el-card class="timeline-card">
<div class="card-header">
<h3>患者诊疗事件时间轴</h3>
<div class="header-actions">
<el-select
v-model="timeScale"
size="small"
placeholder="时间尺度"
style="width: 120px"
@change="handleTimeScaleChange"
>
<el-option label="天" value="day" />
<el-option label="周" value="week" />
<el-option label="月" value="month" />
<el-option label="年" value="year" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
size="small"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 240px; margin-left: 8px"
@change="handleDateRangeChange"
/>
<el-select
v-model="eventType"
size="small"
placeholder="事件类型"
style="width: 140px; margin-left: 8px"
@change="handleFilterChange"
>
<el-option label="全部事件" value="all" />
<el-option label="入院/出院" value="admission" />
<el-option label="用药" value="medication" />
<el-option label="检查/检验" value="examination" />
<el-option label="手术/麻醉" value="surgery" />
<el-option label="查房记录" value="rounds" />
</el-select>
<div class="zoom-controls">
<el-button
size="small"
:icon="ZoomOut"
@click="zoomOut"
:disabled="zoomLevel <= 0.5"
/>
<el-slider
v-model="zoomLevel"
:min="0.5"
:max="2"
:step="0.1"
style="width: 100px; margin: 0 8px"
@change="handleZoomChange"
/>
<el-button
size="small"
:icon="ZoomIn"
@click="zoomIn"
:disabled="zoomLevel >= 2"
/>
</div>
</div>
</div>
<div class="timeline-container">
<!-- 时间轴主体 -->
<div class="timeline-main">
<!-- 时间标尺 -->
<div class="timeline-ruler" ref="rulerEl">
<div
v-for="mark in timeMarks"
:key="mark.position"
class="time-mark"
:style="{ left: `${mark.position}%` }"
>
<div class="mark-line"></div>
<div class="mark-label">{{ mark.label }}</div>
</div>
<div
class="time-window"
:style="{ left: `${windowStart}%`, width: `${windowWidth}%` }"
@mousedown="startDrag"
></div>
</div>
<!-- 时间轴内容 -->
<div
class="timeline-content"
ref="timelineEl"
:style="{
transform: `scaleX(${zoomLevel})`,
transformOrigin: 'left center'
}"
>
<div
v-for="(event, index) in filteredEvents"
:key="event.id"
class="timeline-event"
:style="{
left: `${calculateEventPosition(event.date)}%`,
zIndex: expandedEvent === index ? 100 : 1
}"
:class="[
`event-${event.type}`,
{ expanded: expandedEvent === index }
]"
@click="toggleEventExpand(index)"
>
<div class="event-marker">
<el-icon :size="16">
<component :is="eventIcons[event.type]" />
</el-icon>
</div>
<div class="event-card">
<div class="event-header">
<!-- <span class="event-type">{{
eventTypeLabels[event.type]
}}</span> -->
<span class="event-time">{{ formatTime(event.date) }}</span>
</div>
<div class="event-title">{{ event.title }}</div>
<div v-if="expandedEvent === index" class="event-details">
<div
v-for="(value, key) in event.details"
:key="key"
class="detail-item"
>
<span class="detail-label">{{ key }}:</span>
<span class="detail-value">{{ value }}</span>
</div>
<el-button
size="small"
type="primary"
@click.stop="viewRelatedData(event)"
>
查看关联数据
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 指标进展 -->
<div class="indicators-progress" v-if="showIndicators">
<h4 class="section-title">重点指标进展</h4>
<div class="indicator-tabs">
<el-tabs v-model="activeIndicator" type="card">
<el-tab-pane label="化疗用药" name="chemotherapy">
<div class="indicator-content">
<el-timeline>
<el-timeline-item
v-for="(item, index) in chemotherapyData"
:key="index"
:timestamp="item.date"
placement="top"
>
<el-card>
<h4>{{ item.drug }}</h4>
<p>剂量: {{ item.dose }}</p>
<p>周期: 第{{ item.cycle }}周期</p>
<p>反应: {{ item.response }}</p>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
</el-tab-pane>
<el-tab-pane label="靶向用药" name="targeted">
<div class="indicator-content">
<el-timeline>
<el-timeline-item
v-for="(item, index) in targetedTherapyData"
:key="index"
:timestamp="item.date"
placement="top"
>
<el-card>
<h4>{{ item.drug }}</h4>
<p>剂量: {{ item.dose }}</p>
<p>靶点: {{ item.target }}</p>
<p>反应: {{ item.response }}</p>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
<!-- 事件详情对话框 -->
<el-dialog v-model="detailVisible" :title="currentEvent.title" width="60%">
<div class="event-detail-content">
<div class="detail-section">
<h4>基本信息</h4>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">事件类型:</span>
<span class="detail-value">{{
eventTypeLabels[currentEvent.type]
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">发生时间:</span>
<span class="detail-value">{{
formatTime(currentEvent.date)
}}</span>
</div>
<div class="detail-item">
<span class="detail-label">执行医生:</span>
<span class="detail-value">{{
currentEvent.details["执行医生"] || "无记录"
}}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>详细内容</h4>
<div class="detail-full">
<pre>{{ JSON.stringify(currentEvent.details, null, 2) }}</pre>
</div>
</div>
<div class="detail-section" v-if="currentEvent.relatedData">
<h4>关联数据</h4>
<el-table :data="currentEvent.relatedData" border style="width: 100%">
<el-table-column prop="type" label="数据类型" width="120" />
<el-table-column prop="content" label="内容" />
<el-table-column prop="time" label="时间" width="150" />
</el-table>
</div>
</div>
</el-dialog>
</el-card>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { format } from "date-fns";
import {
House,
FirstAidKit,
DocumentChecked,
Operation,
Notebook,
ZoomIn,
ZoomOut
} from "@element-plus/icons-vue";
// 时间轴配置
const timeScale = ref("month");
const dateRange = ref([new Date(2023, 0, 1), new Date(2023, 5, 30)]);
const eventType = ref("all");
const expandedEvent = ref(null);
const detailVisible = ref(false);
const currentEvent = ref({});
const activeIndicator = ref("chemotherapy");
const showIndicators = ref(true);
const zoomLevel = ref(1);
// 事件类型图标
const eventIcons = {
admission: House,
medication: FirstAidKit,
examination: DocumentChecked,
surgery: Operation,
rounds: Notebook
};
// DOM 引用
const rulerEl = ref(null);
const timelineEl = ref(null);
// 模拟数据 - 实际项目中从API获取
const events = ref([
{
id: 1,
type: "admission",
title: "入院治疗",
date: "2023-01-05",
details: {
科室: "心血管内科",
主治医生: "张医生",
入院诊断: "高血压危象",
病情描述: "患者主诉头痛、头晕3天,血压180/110mmHg"
}
},
{
id: 2,
type: "examination",
title: "血常规检查",
date: "2023-01-06",
details: {
检查项目: "血常规",
白细胞计数: "11.2 ×10⁹/L (偏高)",
血红蛋白: "125 g/L",
血小板: "210 ×10⁹/L"
}
},
{
id: 3,
type: "medication",
title: "降压治疗",
date: "2023-01-06",
details: {
药品名称: "硝苯地平",
剂量: "10mg",
频次: "每日2次",
途径: "口服"
}
},
{
id: 4,
type: "rounds",
title: "主治医生查房",
date: "2023-01-07",
details: {
查房医生: "张医生",
血压: "150/95mmHg",
心率: "82次/分",
医嘱: "继续当前治疗,监测血压"
}
},
{
id: 5,
type: "surgery",
title: "心脏导管手术",
date: "2023-01-10",
details: {
手术名称: "冠状动脉造影",
主刀医生: "李医生",
麻醉方式: "局部麻醉",
手术结果: "发现左前降支狭窄70%"
}
},
{
id: 6,
type: "medication",
title: "靶向药物治疗",
date: "2023-01-12",
details: {
药品名称: "阿托伐他汀",
剂量: "20mg",
频次: "每晚1次",
途径: "口服"
}
},
{
id: 7,
type: "admission",
title: "出院",
date: "2023-01-15",
details: {
出院诊断: "1.高血压危象 2.冠心病",
出院医嘱: "继续服药,定期复查",
复诊时间: "2023-02-15"
}
}
]);
const chemotherapyData = ref([
{
date: "2023-01-08",
drug: "环磷酰胺",
dose: "600mg/m²",
cycle: 1,
response: "良好"
},
{
date: "2023-01-22",
drug: "环磷酰胺",
dose: "600mg/m²",
cycle: 2,
response: "轻度恶心"
},
{
date: "2023-02-05",
drug: "环磷酰胺",
dose: "600mg/m²",
cycle: 3,
response: "白细胞降低"
}
]);
const targetedTherapyData = ref([
{
date: "2023-01-12",
drug: "阿托伐他汀",
dose: "20mg",
target: "LDL-C",
response: "良好"
},
{
date: "2023-02-12",
drug: "阿托伐他汀",
dose: "20mg",
target: "LDL-C",
response: "LDL降至2.1mmol/L"
}
]);
// 计算属性
const filteredEvents = computed(() => {
if (eventType.value === "all") return events.value;
return events.value.filter(event => event.type === eventType.value);
});
const timeMarks = computed(() => {
const start = new Date(dateRange.value[0]);
const end = new Date(dateRange.value[1]);
const diff = end - start;
const marks = [];
// 根据时间尺度生成标记
if (timeScale.value === "day") {
for (let i = 0; i <= 10; i++) {
const date = new Date(start.getTime() + (diff * i) / 10);
marks.push({
position: i * 10,
label: format(date, "MM-dd")
});
}
} else if (timeScale.value === "week") {
for (let i = 0; i <= 4; i++) {
const date = new Date(start.getTime() + (diff * i) / 4);
marks.push({
position: i * 25,
label: `第${i + 1}周`
});
}
} else if (timeScale.value === "month") {
const monthDiff =
end.getMonth() -
start.getMonth() +
(end.getFullYear() - start.getFullYear()) * 12;
for (let i = 0; i <= monthDiff; i++) {
const date = new Date(start);
date.setMonth(start.getMonth() + i);
marks.push({
position: (i / monthDiff) * 100,
label: format(date, "yyyy-MM")
});
}
} else {
// year
const yearDiff = end.getFullYear() - start.getFullYear();
for (let i = 0; i <= yearDiff; i++) {
const date = new Date(start);
date.setFullYear(start.getFullYear() + i);
marks.push({
position: (i / yearDiff) * 100,
label: format(date, "yyyy年")
});
}
}
return marks;
});
// 时间窗口控制
const windowStart = ref(0);
const windowWidth = ref(100);
let isDragging = false;
let startX = 0;
let startLeft = 0;
// 方法
function formatTime(dateStr) {
return format(new Date(dateStr), "yyyy-MM-dd HH:mm");
}
function calculateEventPosition(dateStr) {
const date = new Date(dateStr);
const start = new Date(dateRange.value[0]);
const end = new Date(dateRange.value[1]);
if (date < start) return 0;
if (date > end) return 100;
const total = end - start;
const position = ((date - start) / total) * 100;
return Math.min(100, Math.max(0, position));
}
function toggleEventExpand(index) {
expandedEvent.value = expandedEvent.value === index ? null : index;
}
function showEventDetail(event) {
currentEvent.value = event;
detailVisible.value = true;
}
function viewRelatedData(event) {
console.log("View related data for:", event);
// 实际项目中这里获取关联数据
}
function handleTimeScaleChange() {
// 实际项目中这里可能需要重新获取数据
}
function handleDateRangeChange() {
// 实际项目中这里可能需要重新获取数据
}
function handleFilterChange() {
// 实际项目中这里可能需要重新获取数据
}
function startDrag(e) {
isDragging = true;
startX = e.clientX;
startLeft = windowStart.value;
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", stopDrag);
}
function handleDrag(e) {
if (!isDragging) return;
const rulerWidth = rulerEl.value.offsetWidth;
const dx = ((e.clientX - startX) / rulerWidth) * 100;
windowStart.value = Math.max(
0,
Math.min(100 - windowWidth.value, startLeft + dx)
);
}
function stopDrag() {
isDragging = false;
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", stopDrag);
}
// 缩放功能
function zoomIn() {
zoomLevel.value = Math.min(2, zoomLevel.value + 0.1);
updateTimeScaleByZoom();
}
function zoomOut() {
zoomLevel.value = Math.max(0.5, zoomLevel.value - 0.1);
updateTimeScaleByZoom();
}
function handleZoomChange(value) {
zoomLevel.value = value;
updateTimeScaleByZoom();
}
function updateTimeScaleByZoom() {
// 根据缩放级别自动调整时间尺度
if (zoomLevel.value > 1.5) {
timeScale.value = "day";
} else if (zoomLevel.value > 1) {
timeScale.value = "week";
} else {
timeScale.value = "month";
}
}
// 生命周期
onMounted(() => {
// 初始化时间窗口
const now = new Date();
const start = new Date(dateRange.value[0]);
const end = new Date(dateRange.value[1]);
const total = end - start;
const current = now - start;
if (now > start && now < end) {
windowStart.value = (current / total) * 100 - 10;
windowWidth.value = 20;
}
});
</script>
<style scoped>
.timeline-card {
border-radius: 8px;
margin-bottom: 10px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.card-header h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.header-actions {
display: flex;
align-items: center;
}
.zoom-controls {
display: flex;
align-items: center;
margin-left: 16px;
}
.timeline-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.timeline-main {
position: relative;
min-height: 240px;
overflow-x: auto;
}
.timeline-ruler {
position: relative;
height: 40px;
border-bottom: 1px solid #ebeef5;
margin-bottom: 20px;
min-width: 100%;
}
.time-mark {
position: absolute;
bottom: 0;
transform: translateX(-50%);
text-align: center;
}
.mark-line {
height: 10px;
width: 1px;
background-color: #999;
margin: 0 auto;
}
.mark-label {
font-size: 12px;
color: #666;
margin-top: 4px;
}
.time-window {
position: absolute;
height: 100%;
background-color: rgba(64, 158, 255, 0.1);
border-left: 1px solid #409eff;
border-right: 1px solid #409eff;
cursor: move;
}
.timeline-content {
position: relative;
min-height: 180px;
padding-bottom: 20px;
transition: transform 0.3s ease;
transform-origin: left center;
min-width: 100%;
}
.timeline-event {
position: absolute;
bottom: 0;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
transition: all 0.2s;
}
.timeline-event.expanded {
z-index: 100;
}
.event-marker {
width: 24px;
height: 24px;
border-radius: 50%;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: center;
background: white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.event-admission .event-marker {
color: #67c23a;
background-color: #f0f9eb;
}
.event-medication .event-marker {
color: #409eff;
background-color: #ecf5ff;
}
.event-examination .event-marker {
color: #e6a23c;
background-color: #fdf6ec;
}
.event-surgery .event-marker {
color: #f56c6c;
background-color: #fef0f0;
}
.event-rounds .event-marker {
color: #909399;
background-color: #f4f4f5;
}
.event-card {
width: 200px;
padding: 12px;
background: white;
border-radius: 6px;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.1);
transition: all 0.2s;
position: relative;
}
.timeline-event.expanded .event-card {
width: 260px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.event-header {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.event-type {
font-size: 12px;
font-weight: bold;
}
.event-admission .event-type {
color: #67c23a;
}
.event-medication .event-type {
color: #409eff;
}
.event-examination .event-type {
color: #e6a23c;
}
.event-surgery .event-type {
color: #f56c6c;
}
.event-rounds .event-type {
color: #909399;
}
.event-time {
font-size: 11px;
color: #999;
}
.event-title {
font-size: 13px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.event-details {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #ebeef5;
}
.detail-item {
font-size: 12px;
margin-bottom: 6px;
display: flex;
}
.detail-label {
color: #666;
margin-right: 4px;
min-width: 70px;
}
.detail-value {
color: #333;
flex: 1;
}
.indicators-progress {
margin-top: 20px;
}
.section-title {
margin: 0 0 12px 0;
font-size: 14px;
color: #555;
font-weight: bold;
}
.indicator-content {
padding: 12px;
}
.event-detail-content {
padding: 0 16px;
}
.detail-section {
margin-bottom: 20px;
}
.detail-section h4 {
margin: 0 0 12px 0;
color: #555;
font-size: 15px;
padding-bottom: 6px;
border-bottom: 1px solid #ebeef5;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.detail-full {
background: #f8f9fa;
padding: 12px;
border-radius: 4px;
max-height: 300px;
overflow: auto;
}
.detail-full pre {
margin: 0;
font-family: inherit;
white-space: pre-wrap;
font-size: 13px;
line-height: 1.5;
}
@media (max-width: 768px) {
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.header-actions {
width: 100%;
flex-wrap: wrap;
gap: 8px;
}
.header-actions .el-select,
.header-actions .el-date-editor,
.header-actions .zoom-controls {
width: 100% !important;
margin-left: 0 !important;
}
.zoom-controls {
margin-top: 8px;
}
.timeline-main {
min-height: 280px;
}
.timeline-content {
min-height: 220px;
}
.event-card {
width: 160px;
}
.timeline-event.expanded .event-card {
width: 200px;
}
}
</style>
这个组件存在以下问题:
1. 时间轴不能左右拖动
2.放大缩小时间轴时下方描述狂会随之挤压拉伸导致显示异常
3.时间轴最左侧和最右侧的刻度显示不完整