eagleye

Quasar 框架的 `QTimeline` 组件能以时间顺序清晰展示事件流,是企业级应用中用于项目里程碑、操作日志、版本历史等场景的理想选择

Quasar 框架的 `QTimeline` 组件能以时间顺序清晰展示事件流,是企业级应用中用于项目里程碑、操作日志、版本历史等场景的理想选择。下面我会结合组合式 API 和 TypeScript,为你提供一个避免使用 `any` 类型的企业级实用教程。

# Quasar QTimeline 组件企业级实用教程

## 组件概述

`QTimeline` 是 Quasar 框架中用于按时间顺序显示事件列表的组件。它通常是一种图形设计,显示一个长条,并在其旁边标有日期,通常是事件。`QTimeline` 具有三种布局:`dense`(默认)、`comfortable` 和 `loose`。

## 基础用法

以下是一个基本的 `QTimeline` 组件示例:

```html
<template>
<q-timeline color="secondary">
<q-timeline-entry heading>
Timeline Subject
</q-timeline-entry>
<q-timeline-entry
title="Event Title"
subtitle="February 22, 1986"
side="left"
>
<div>
Lorem ipsum dolor sit amet.
</div>
</q-timeline-entry>
</q-timeline>
</template>
```

## 企业级实现

下面是一个完整的企业级 `QTimeline` 组件实现,采用组合式 API、TypeScript,并避免使用 `any` 类型。

```html
<template>
<div class="timeline-container">
<!-- 时间线控件 -->
<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>

<!-- 时间线内容 -->
<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)"
:body="entry.body"
: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>

<!-- 分页控件 -->
<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>

<script setup lang="ts">
import { computed, ref, reactive, onMounted, watch } from 'vue';
import { date, useQuasar } from 'quasar';
import { fetchTimelineData, TimelineEntry, TimelineGroup, TimelineFilter } from './timelineService';

// 类型定义
interface TimelineTag {
label: string;
color?: string;
}

interface TimelineAction {
id: string;
label: string;
icon: string;
color?: string;
handler: (entry: TimelineEntry) => void;
}

interface TimelineAttachment {
id: string;
name: string;
type: string;
url: string;
size?: number;
}

// 扩展基础时间线条目类型
interface ExtendedTimelineEntry extends TimelineEntry {
userName?: string;
avatar?: string;
tags?: TimelineTag[];
actions?: TimelineAction[];
attachments?: TimelineAttachment[];
}

// 组件属性
const props = withDefaults(defineProps<{
initialEntries?: ExtendedTimelineEntry[];
autoLoad?: boolean;
groupByDate?: boolean;
pageSize?: number;
}>(), {
initialEntries: () => [],
autoLoad: true,
groupByDate: true,
pageSize: 20
});

// 事件定义
const emit = defineEmits<{
(e: 'entry-click', entry: ExtendedTimelineEntry): void;
(e: 'action-handler', action: TimelineAction, entry: ExtendedTimelineEntry): void;
(e: 'refresh'): void;
}>();

const $q = useQuasar();

// 响应式状态
const layout = ref<'dense' | 'comfortable' | 'loose'>('dense');
const timelineEntries = ref<ExtendedTimelineEntry[]>(props.initialEntries);
const loading = ref(false);
const searchText = ref('');
const filterType = ref<'all' | 'important' | 'user'>('all');
const pagination = reactive({
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(() => {
return $q.dark.isActive ? 'secondary' : 'primary';
});

const filteredEntries = computed(() => {
let entries = timelineEntries.value;

// 应用搜索过滤
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)
);
}

// 应用类型过滤
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 filteredTimelineGroups = computed(() => {
if (!props.groupByDate) {
return [{
date: '所有事件',
entries: paginatedEntries.value
}];
}

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),
entries
}))
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
});

const paginatedEntries = computed(() => {
const start = (pagination.currentPage - 1) * pagination.pageSize;
const end = start + pagination.pageSize;
return filteredEntries.value.slice(start, end);
});

// 方法
const formatSubtitle = (entry: ExtendedTimelineEntry): string => {
const time = date.formatDate(entry.timestamp, 'HH:mm');
return entry.userName ? `${time} · ${entry.userName}` : time;
};

const formatGroupDate = (dateStr: string): string => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);

const dateObj = new Date(dateStr);

if (date.formatDate(dateObj, 'YYYY-MM-DD') === date.formatDate(today, 'YYYY-MM-DD')) {
return '今天';
} else if (date.formatDate(dateObj, 'YYYY-MM-DD') === date.formatDate(yesterday, 'YYYY-MM-DD')) {
return '昨天';
} else {
return date.formatDate(dateObj, 'YYYY年MM月DD日');
}
};

const getEntryColor = (entry: ExtendedTimelineEntry): string => {
if (entry.color) return entry.color;

// 根据标签确定颜色
const importantTag = entry.tags?.find(tag =>
tag.color === 'negative' || tag.color === 'warning'
);

if (importantTag) return importantTag.color || 'primary';

return '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 data = await fetchTimelineData(filter, {
page: pagination.currentPage,
pageSize: pagination.pageSize
});

timelineEntries.value = data.entries;
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(() => {
if (props.autoLoad && props.initialEntries.length === 0) {
refreshTimeline();
}

// 计算总页数
pagination.totalPages = Math.ceil(filteredEntries.value.length / pagination.pageSize);
});

// 监听过滤条件变化
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>

<style scoped lang="scss">
.timeline-container {
width: 100%;
height: 100%;
}

.timeline-controls {
background-color: transparent;
padding: 8px 0;
}

.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;
}

.timeline-attachments {
border-top: 1px solid #e0e0e0;
padding-top: 8px;
}

.timeline-pagination {
margin-top: 24px;
}

// 响应式调整
@media (max-width: 600px) {
.timeline-controls {
.row {
flex-direction: column;
align-items: stretch;
}

.q-select {
width: 100%;
}
}

.timeline-actions {
flex-direction: column;
align-items: stretch;

.q-btn {
margin-right: 0;
margin-bottom: 4px;
}
}
}

// 修复嵌套时间线线条显示问题
: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>
```

## 类型定义文件

创建一个 `types/timeline.ts` 文件来定义 TypeScript 类型:

```typescript
// types/timeline.ts
export interface TimelineEntry {
id: string;
title: string;
subtitle?: string;
body?: string;
timestamp: Date;
side?: 'left' | 'right';
icon?: string;
color?: string;
tag?: string;
}

export interface TimelineGroup {
date: string;
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;
}
```

## 服务层代码

创建一个 `timelineService.ts` 文件来处理数据获取:

```typescript
// services/timelineService.ts
import { TimelineEntry, TimelineFilter, TimelineResponse, PaginationOptions } from '../types/timeline';

// 模拟数据获取函数
export const fetchTimelineData = async (
filter: TimelineFilter,
pagination: PaginationOptions
): Promise<TimelineResponse> => {
// 在实际应用中,这里会是API调用
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'
}
];

// 应用过滤
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)
);
}

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
};
};
```

## 使用示例

```html
<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: '这是一个新添加的时间线事件',
timestamp: new Date(),
icon: 'add_circle',
color: 'primary'
});
}
};
</script>
```

## 企业级最佳实践

1. **性能优化**:对于大型时间线数据,使用虚拟滚动或分页加载以提高性能。
2. **错误处理**:实现完整的错误处理机制,包括网络错误、数据格式错误等。
3. **可访问性**:确保时间线组件对屏幕阅读器和键盘导航友好。
4. **主题支持**:支持 light/dark 主题,并根据用户偏好自动切换。
5. **国际化**:支持多语言,包括日期格式、标签文本等。
6. **单元测试**:为组件编写完整的单元测试,确保业务逻辑正确性。

这个实现提供了完整的企业级 QTimeline 组件,具有以下特点:

1. **组合式 API**:使用 Vue 3 的组合式 API 组织代码
2. **TypeScript 支持**:完整的类型定义,避免使用 any 类型
3. **响应式设计**:自适应不同屏幕尺寸
4. **丰富的功能**:支持搜索、过滤、分页、操作等
5. **企业级特性**:支持错误处理、加载状态、空状态等
6. **可扩展性**:易于扩展新的时间线条目类型和操作

您可以根据实际项目需求进一步扩展这个组件,例如添加拖拽排序、条目编辑、导出功能等。

posted on 2025-09-04 21:19  GoGrid  阅读(9)  评论(0)    收藏  举报

导航