Quasar QTimeline 组件企业级实现文档
Quasar QTimeline 组件企业级实现文档
一、组件概述
QTimeline 是 Quasar 框架中用于按时间顺序展示事件流的组件,支持分组显示、筛选搜索、分页加载等企业级特性。通过组合式 API 与 TypeScript 类型安全设计,可无缝集成到项目里程碑、操作日志、版本历史等场景,提供清晰的时间线可视化效果。
二、核心特性
功能特性 |
描述 |
多视图布局 |
支持dense(紧凑)、comfortable(舒适)、loose(宽松)三种布局,适配不同信息密度需求 |
智能分组 |
按日期自动分组(今天/昨天/具体日期),支持关闭分组显示所有事件 |
高级筛选 |
支持按事件类型(所有/重要/用户事件)、关键词搜索,快速定位目标事件 |
丰富交互 |
每条时间线可包含头像、标签、操作按钮、附件,支持点击、刷新、分页等交互 |
TypeScript 全类型 |
完整类型定义,避免any类型,确保数据结构规范与开发体验 |
响应式设计 |
自适应屏幕尺寸,移动端优化布局(控件堆叠、按钮纵向排列) |
状态反馈 |
加载中、空状态、错误提示等完整状态处理,提升用户体验 |
三、类型定义
3.1 核心类型(types/timeline.ts)
// types/timeline.ts
export interface TimelineEntry {
id: string; // 事件唯一ID(必填)
title: string; // 事件标题(必填)
subtitle?: string; // 事件副标题(可选)
body?: string; // 事件详情内容(可选)
timestamp: Date; // 事件时间戳(必填)
side?: 'left' | 'right'; // 事件显示在时间线左侧/右侧(可选,默认自动)
icon?: string; // 事件图标(Quasar图标名,可选)
color?: string; // 事件颜色(可选,默认primary)
tag?: string; // 事件标签(可选)
}
export interface TimelineGroup {
date: string; // 分组日期(YYYY-MM-DD 或格式化后的文本)
entries: TimelineEntry[]; // 该日期下的事件列表
}
export interface TimelineFilter {
search?: string; // 搜索关键词(可选)
type?: 'all' | 'important' | 'user'; // 事件类型筛选(可选)
dateRange?: { // 日期范围筛选(可选)
start: Date;
end: Date;
};
}
export interface PaginationOptions {
page: number; // 当前页码(必填)
pageSize: number; // 每页条数(必填)
}
export interface TimelineResponse {
entries: TimelineEntry[]; // 事件列表
totalCount: number; // 总事件数
hasMore: boolean; // 是否有更多数据
}
// 扩展类型(组件内部使用)
export interface TimelineTag {
label: string; // 标签文本
color?: string; // 标签颜色(可选)
}
export interface TimelineAction {
id: string; // 操作ID
label: string; // 操作按钮文本
icon: string; // 操作图标
color?: string; // 按钮颜色(可选)
handler: (entry: TimelineEntry) => void; // 操作回调函数
}
export interface TimelineAttachment {
id: string; // 附件ID
name: string; // 附件名称
type: string; // 附件类型(pdf/image/document等)
url: string; // 附件下载地址
size?: number; // 附件大小(字节,可选)
}
export interface ExtendedTimelineEntry extends TimelineEntry {
userName?: string; // 操作用户名(可选)
avatar?: string; // 用户头像URL(可选)
tags?: TimelineTag[]; // 事件标签列表(可选)
actions?: TimelineAction[]; // 事件操作按钮列表(可选)
attachments?: TimelineAttachment[]; // 事件附件列表(可选)
}
四、组件实现
4.1 模板部分(Template)
<template>
<div class="timeline-container">
<!-- 1. 时间线控件区(布局切换、筛选、搜索) -->
<div class="timeline-controls q-mb-md">
<!-- 布局切换按钮组 -->
<q-btn-toggle
v-model="layout"
spread
no-caps
:options="layoutOptions"
color="primary"
class="q-mb-md"
/>
<!-- 筛选与搜索 -->
<div class="row items-center q-gutter-md">
<!-- 类型筛选下拉框 -->
<q-select
v-model="filterType"
:options="filterOptions"
label="过滤类型"
dense
outlined
style="min-width: 150px;"
emit-value
map-options
/>
<!-- 关键词搜索框 -->
<q-input
v-model="searchText"
placeholder="搜索事件..."
dense
outlined
clearable
class="col-grow"
>
<template v-slot:append>
<q-icon name="search" />
</template>
</q-input>
<!-- 刷新按钮 -->
<q-btn
icon="refresh"
color="primary"
round
dense
@click="refreshTimeline"
>
<q-tooltip>刷新时间线</q-tooltip>
</q-btn>
</div>
</div>
<!-- 2. 时间线内容区 -->
<q-timeline
:layout="layout"
:color="timelineColor"
class="q-timeline-custom"
>
<!-- 时间线分组(按日期) -->
<template v-for="(group, groupIndex) in filteredTimelineGroups" :key="groupIndex">
<!-- 分组标题(日期) -->
<q-timeline-entry heading>
{{ group.date }}
</q-timeline-entry>
<!-- 分组内事件列表 -->
<q-timeline-entry
v-for="entry in group.entries"
:key="entry.id"
:title="entry.title"
:subtitle="formatSubtitle(entry)"
:side="entry.side"
:icon="entry.icon"
:color="getEntryColor(entry)"
:tag="entry.tag"
>
<!-- 头像插槽(如有用户头像) -->
<template v-if="entry.avatar" v-slot:avatar>
<q-avatar>
<img :src="entry.avatar" :alt="entry.userName">
<q-tooltip v-if="entry.userName">{{ entry.userName }}</q-tooltip>
</q-avatar>
</template>
<!-- 事件内容区 -->
<div class="timeline-content">
<!-- 事件详情文本 -->
<div v-if="entry.body" class="timeline-body">{{ entry.body }}</div>
<!-- 事件标签(如有) -->
<div v-if="entry.tags && entry.tags.length" class="timeline-tags q-mt-sm">
<q-chip
v-for="(tag, tagIndex) in entry.tags"
:key="tagIndex"
size="sm"
:color="tag.color || 'primary'"
text-color="white"
>
{{ tag.label }}
</q-chip>
</div>
<!-- 事件操作按钮(如有) -->
<div v-if="entry.actions && entry.actions.length" class="timeline-actions q-mt-md">
<q-btn
v-for="(action, actionIndex) in entry.actions"
:key="actionIndex"
:icon="action.icon"
:label="action.label"
size="sm"
:color="action.color || 'primary'"
outline
class="q-mr-xs"
@click="handleAction(action, entry)"
/>
</div>
<!-- 事件附件(如有) -->
<div v-if="entry.attachments && entry.attachments.length" class="timeline-attachments q-mt-md">
<div class="text-caption text-weight-medium q-mb-xs">附件:</div>
<div class="row q-gutter-xs">
<q-btn
v-for="(attachment, attachmentIndex) in entry.attachments"
:key="attachmentIndex"
:icon="getAttachmentIcon(attachment.type)"
:label="attachment.name"
size="sm"
color="grey-6"
flat
@click="openAttachment(attachment)"
>
<q-tooltip>点击下载: {{ attachment.name }}</q-tooltip>
</q-btn>
</div>
</div>
</div>
</q-timeline-entry>
</template>
<!-- 加载状态 -->
<q-timeline-entry v-if="loading" heading>
<div class="text-center">
<q-spinner size="lg" color="primary" />
<div class="q-mt-sm">加载中...</div>
</div>
</q-timeline-entry>
<!-- 空状态 -->
<q-timeline-entry v-else-if="filteredTimelineGroups.length === 0" heading>
<div class="text-center text-grey-6">
<q-icon name="event_busy" size="lg" />
<div class="q-mt-sm">暂无时间线数据</div>
</div>
</q-timeline-entry>
</q-timeline>
<!-- 3. 分页控件 -->
<div v-if="pagination.totalPages > 1" class="timeline-pagination q-mt-md row justify-center">
<q-pagination
v-model="pagination.currentPage"
:max="pagination.totalPages"
:max-pages="6"
direction-links
outline
color="primary"
active-color="white"
active-text-color="primary"
/>
</div>
</div>
</template>
4.2 脚本部分(Script)
<script setup lang="ts">
import { computed, ref, reactive, onMounted, watch } from 'vue';
import { date, useQuasar } from 'quasar';
import { fetchTimelineData } from './services/timelineService';
import type {
TimelineEntry,
TimelineFilter,
PaginationOptions,
ExtendedTimelineEntry
} from './types/timeline';
// 组件属性(Props)
const props = withDefaults(defineProps<{
initialEntries?: ExtendedTimelineEntry[]; // 初始事件列表(默认空)
autoLoad?: boolean; // 是否自动加载数据(默认true)
groupByDate?: boolean; // 是否按日期分组(默认true)
pageSize?: number; // 每页条数(默认20)
}>(), {
initialEntries: () => [],
autoLoad: true,
groupByDate: true,
pageSize: 20
});
// 组件事件(Emits)
const emit = defineEmits<{
(e: 'entry-click', entry: ExtendedTimelineEntry): void; // 事件点击
(e: 'action-handler', action: TimelineAction, entry: ExtendedTimelineEntry): void; // 操作按钮点击
(e: 'refresh'): void; // 刷新事件
}>();
// 依赖与状态初始化
const $q = useQuasar(); // Quasar工具(通知、提示等)
const timelineEntries = ref<ExtendedTimelineEntry[]>(props.initialEntries); // 事件列表
const loading = ref(false); // 加载状态
const searchText = ref(''); // 搜索关键词
const filterType = ref<'all' | 'important' | 'user'>('all'); // 类型筛选
const layout = ref<'dense' | 'comfortable' | 'loose'>('dense'); // 布局模式
const pagination = reactive<PaginationOptions & { totalPages: number }>({
currentPage: 1,
pageSize: props.pageSize,
totalPages: 1
});
// 计算属性:布局切换选项
const layoutOptions = computed(() => [
{ label: '紧凑视图', value: 'dense' },
{ label: '舒适视图', value: 'comfortable' },
{ label: '宽松视图', value: 'loose' }
]);
// 计算属性:类型筛选选项
const filterOptions = computed(() => [
{ label: '所有事件', value: 'all' },
{ label: '重要事件', value: 'important' },
{ label: '用户事件', value: 'user' }
]);
// 计算属性:时间线颜色(适配深色/浅色主题)
const timelineColor = computed(() => $q.dark.isActive ? 'secondary' : 'primary');
// 计算属性:筛选后的事件列表
const filteredEntries = computed(() => {
let entries = [...timelineEntries.value];
// 1. 关键词搜索筛选
if (searchText.value) {
const searchLower = searchText.value.toLowerCase();
entries = entries.filter(entry =>
entry.title.toLowerCase().includes(searchLower) ||
entry.body?.toLowerCase().includes(searchLower) ||
entry.userName?.toLowerCase().includes(searchLower)
);
}
// 2. 类型筛选
if (filterType.value === 'important') {
// 重要事件:包含红色/黄色标签
entries = entries.filter(entry =>
entry.tags?.some(tag => tag.color === 'negative' || tag.color === 'warning')
);
} else if (filterType.value === 'user') {
// 用户事件:包含用户名
entries = entries.filter(entry => !!entry.userName);
}
return entries;
});
// 计算属性:分页后的事件列表
const paginatedEntries = computed(() => {
const start = (pagination.currentPage - 1) * pagination.pageSize;
const end = start + pagination.pageSize;
return filteredEntries.value.slice(start, end);
});
// 计算属性:按日期分组后的时间线数据
const filteredTimelineGroups = computed(() => {
if (!props.groupByDate) {
// 不分组:所有事件合并为一个组
return [{ date: '所有事件', entries: paginatedEntries.value }];
}
// 按日期分组(YYYY-MM-DD 为键)
const groups: Record<string, ExtendedTimelineEntry[]> = {};
paginatedEntries.value.forEach(entry => {
const dateKey = date.formatDate(entry.timestamp, 'YYYY-MM-DD');
if (!groups[dateKey]) groups[dateKey] = [];
groups[dateKey].push(entry);
});
// 转换为分组数组并按日期倒序排序(最新在前)
return Object.entries(groups)
.map(([date, entries]) => ({
date: formatGroupDate(date), // 格式化日期(今天/昨天/YYYY年MM月DD日)
entries
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
});
// 方法:格式化分组日期
const formatGroupDate = (dateStr: string): string => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const targetDate = new Date(dateStr);
if (date.formatDate(targetDate, 'YYYY-MM-DD') === date.formatDate(today, 'YYYY-MM-DD')) {
return '今天';
} else if (date.formatDate(targetDate, 'YYYY-MM-DD') === date.formatDate(yesterday, 'YYYY-MM-DD')) {
return '昨天';
} else {
return date.formatDate(targetDate, 'YYYY年MM月DD日');
}
};
// 方法:格式化事件副标题(时间+用户名)
const formatSubtitle = (entry: ExtendedTimelineEntry): string => {
const time = date.formatDate(entry.timestamp, 'HH:mm'); // 格式化时间(HH:mm)
return entry.userName ? `${time} · ${entry.userName}` : time;
};
// 方法:获取事件颜色(优先事件自身颜色,其次标签颜色)
const getEntryColor = (entry: ExtendedTimelineEntry): string => {
if (entry.color) return entry.color;
// 从标签中提取重要颜色(红色/黄色标签优先)
const importantTag = entry.tags?.find(tag =>
tag.color === 'negative' || tag.color === 'warning'
);
return importantTag?.color || 'primary';
};
// 方法:获取附件图标(根据附件类型)
const getAttachmentIcon = (type: string): string => {
const iconMap: Record<string, string> = {
'pdf': 'picture_as_pdf',
'image': 'image',
'document': 'description',
'spreadsheet': 'table_chart',
'archive': 'folder_zip',
'video': 'videocam',
'audio': 'audiotrack'
};
return iconMap[type] || 'insert_drive_file';
};
// 方法:处理操作按钮点击
const handleAction = (action: TimelineAction, entry: ExtendedTimelineEntry): void => {
try {
action.handler(entry); // 执行操作回调
emit('action-handler', action, entry); // 触发外部事件
} catch (error) {
$q.notify({
type: 'negative',
message: `操作失败: ${error instanceof Error ? error.message : '未知错误'}`
});
}
};
// 方法:打开附件(新窗口)
const openAttachment = (attachment: TimelineAttachment): void => {
window.open(attachment.url, '_blank');
};
// 方法:刷新时间线数据
const refreshTimeline = async (): Promise<void> => {
try {
loading.value = true;
emit('refresh'); // 触发外部刷新事件
// 构建筛选参数
const filter: TimelineFilter = {
search: searchText.value,
type: filterType.value
};
// 调用服务层获取数据
const paginationOpt: PaginationOptions = {
page: pagination.currentPage,
pageSize: pagination.pageSize
};
const data = await fetchTimelineData(filter, paginationOpt);
// 更新事件列表和总页数
timelineEntries.value = data.entries as ExtendedTimelineEntry[];
pagination.totalPages = Math.ceil(data.totalCount / pagination.pageSize);
} catch (error) {
$q.notify({
type: 'negative',
message: `加载失败: ${error instanceof Error ? error.message : '网络错误'}`
});
} finally {
loading.value = false;
}
};
// 生命周期:组件挂载时初始化
onMounted(() => {
// 初始化总页数
pagination.totalPages = Math.ceil(filteredEntries.value.length / pagination.pageSize);
// 自动加载数据(若未提供初始数据且autoLoad为true)
if (props.autoLoad && props.initialEntries.length === 0) {
refreshTimeline();
}
});
// 监听:筛选条件变化时重置页码
watch([searchText, filterType], () => {
pagination.currentPage = 1; // 筛选条件变化,回到第一页
pagination.totalPages = Math.ceil(filteredEntries.value.length / pagination.pageSize);
});
// 暴露方法给父组件(如动态添加/删除事件)
defineExpose({
refresh: refreshTimeline, // 刷新方法
addEntry: (entry: ExtendedTimelineEntry) => { // 添加事件
timelineEntries.value.unshift(entry); // 添加到列表头部
},
removeEntry: (id: string) => { // 删除事件
timelineEntries.value = timelineEntries.value.filter(entry => entry.id !== id);
}
});
</script>
4.3 样式部分(Style)
<style scoped lang="scss">
.timeline-container {
width: 100%;
height: 100%;
}
/* 控件区样式 */
.timeline-controls {
padding: 8px 0;
/* 响应式:移动端控件堆叠 */
@media (max-width: 600px) {
.row {
flex-direction: column;
align-items: stretch;
}
.q-select { width: 100%; }
}
}
/* 时间线内容区样式 */
.timeline-content {
position: relative;
}
.timeline-body {
line-height: 1.6; // 提升文本可读性
white-space: pre-wrap; // 保留换行符
}
.timeline-tags {
display: flex;
flex-wrap: wrap;
gap: 4px; // 标签间距
}
.timeline-actions {
display: flex;
flex-wrap: wrap;
gap: 8px; // 按钮间距
/* 响应式:移动端按钮纵向排列 */
@media (max-width: 600px) {
flex-direction: column;
align-items: stretch;
.q-btn {
margin-right: 0;
margin-bottom: 4px;
}
}
}
.timeline-attachments {
border-top: 1px solid #e0e0e0; // 分隔线
padding-top: 8px;
}
/* 分页控件样式 */
.timeline-pagination {
margin-top: 24px;
}
/* 修复时间线最后一条事件的线条显示问题 */
:deep(.q-timeline__entry:last-child .q-timeline__dot:after) {
content: '' !important;
}
:deep(.q-timeline__entry:last-child > .q-timeline__dot:after) {
content: none !important;
}
</style>
五、服务层代码(services/timelineService.ts)
处理时间线数据的获取、筛选与分页逻辑:
// services/timelineService.ts
import type {
TimelineEntry,
TimelineFilter,
PaginationOptions,
TimelineResponse
} from '../types/timeline';
// 模拟数据获取函数(实际项目中替换为API调用)
export const fetchTimelineData = async (
filter: TimelineFilter,
pagination: PaginationOptions
): Promise<TimelineResponse> => {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500));
// 模拟事件数据
const mockEntries: TimelineEntry[] = [
{
id: '1',
title: '项目启动',
body: '新项目正式启动,团队组建完成',
timestamp: new Date('2024-01-15T09:00:00'),
icon: 'flag',
color: 'positive'
},
{
id: '2',
title: '需求评审完成',
body: '完成了所有核心需求的评审工作',
timestamp: new Date('2024-01-20T14:30:00'),
icon: 'check_circle',
color: 'primary'
},
{
id: '3',
title: '技术方案设计',
body: '完成了系统架构和技术方案设计',
timestamp: new Date('2024-01-25T16:45:00'),
icon: 'design_services',
color: 'info'
},
{
id: '4',
title: '紧急bug修复',
body: '修复了生产环境的关键bug',
timestamp: new Date('2024-01-28T10:15:00'),
icon: 'bug_report',
color: 'negative',
tags: [{ label: '紧急', color: 'negative' }]
}
];
// 应用筛选条件
let filteredEntries = [...mockEntries];
// 关键词搜索筛选
if (filter.search) {
const searchLower = filter.search.toLowerCase();
filteredEntries = filteredEntries.filter(entry =>
entry.title.toLowerCase().includes(searchLower) ||
entry.body?.toLowerCase().includes(searchLower)
);
}
// 类型筛选(重要事件:color为negative/warning)
if (filter.type === 'important') {
filteredEntries = filteredEntries.filter(entry =>
entry.color === 'negative' || entry.color === 'warning'
);
}
// 应用分页
const start = (pagination.page - 1) * pagination.pageSize;
const end = start + pagination.pageSize;
const paginatedEntries = filteredEntries.slice(start, end);
return {
entries: paginatedEntries,
totalCount: filteredEntries.length,
hasMore: end < filteredEntries.length
};
};
六、使用示例
6.1 父组件集成
<template>
<div class="q-pa-md">
<h2 class="text-h4">项目时间线</h2>
<p class="text-grey-8">跟踪项目进度和重要事件</p>
<!-- 引入时间线组件 -->
<timeline-component
:auto-load="true"
:group-by-date="true"
:page-size="10"
@entry-click="handleEntryClick"
@action-handler="handleAction"
@refresh="handleRefresh"
ref="timelineRef"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import TimelineComponent from './components/TimelineComponent.vue';
import type { ExtendedTimelineEntry, TimelineAction } from './types/timeline';
// 组件引用(用于调用内部方法)
const timelineRef = ref<InstanceType<typeof TimelineComponent>>();
// 事件点击处理
const handleEntryClick = (entry: ExtendedTimelineEntry): void => {
console.log('事件点击:', entry);
};
// 操作按钮点击处理
const handleAction = (action: TimelineAction, entry: ExtendedTimelineEntry): void => {
console.log('操作处理:', action, entry);
};
// 刷新事件处理
const handleRefresh = (): void => {
console.log('时间线刷新');
};
// 示例:动态添加事件(如点击按钮触发)
const addNewEntry = (): void => {
if (timelineRef.value) {
timelineRef.value.addEntry({
id: `entry-${Date.now()}`,
title: '新功能上线',
body: '用户管理模块V2版本上线',
timestamp: new Date(),
icon: 'rocket',
color: 'accent',
userName: '管理员',
avatar: 'https://cdn.quasar.dev/img/avatar.png',
tags: [{ label: '上线', color: 'accent' }],
actions: [{
id: 'view',
label: '查看详情',
icon: 'visibility',
handler: (entry) => console.log('查看详情:', entry)
}]
});
}
};
</script>
七、企业级最佳实践
7.1 性能优化
- 虚拟滚动:数据量超过100条时,结合q-virtual-scroll优化长列表渲染性能。
- 数据缓存:缓存已加载的事件数据,避免重复请求(如使用localStorage或 Pinia 状态管理)。
- 懒加载附件:附件图标默认显示,点击后再加载完整附件内容。
- 网络错误:通过try/catch捕获 API 请求异常,使用$q.notify提示用户。
- 数据格式校验:使用 Zod 或 TypeScript 类型守卫验证后端返回数据格式,避免运行时错误。
- 键盘导航:确保所有交互元素(事件、按钮)支持键盘Tab聚焦和Enter触发。
- 屏幕阅读器支持:为时间线分组、事件标题添加aria-label,提升无障碍访问体验。
- 拖拽排序:集成vue-draggable支持手动调整事件顺序(适用于可编辑时间线)。
- 事件编辑:添加双击事件触发编辑弹窗,支持修改事件内容。
- 导出功能:支持将时间线数据导出为 PDF 或 Excel(结合jspdf、xlsx库)。
7.2 错误处理
7.3 可访问性
7.4 扩展建议
八、总结
QTimeline 组件通过组合式 API、TypeScript 类型安全和企业级功能设计,提供了开箱即用的时间线解决方案。核心优势在于灵活的布局控制、强大的筛选能力和丰富的交互体验,可满足项目管理、操作日志等多种场景需求。通过最佳实践中的性能优化和扩展建议,可进一步提升组件的适用性和稳定性。