eagleye

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 组件通过组合式 APITypeScript 类型安全企业级功能设计,提供了开箱即用的时间线解决方案。核心优势在于灵活的布局控制、强大的筛选能力和丰富的交互体验,可满足项目管理、操作日志等多种场景需求。通过最佳实践中的性能优化和扩展建议,可进一步提升组件的适用性和稳定性。

 

posted on 2025-09-13 17:43  GoGrid  阅读(11)  评论(0)    收藏  举报

导航