📊 小说写作中的时间轴管理:基于 Vue 3 的事序图技术实现
💡 本文深入探讨了在小说写作软件中实现类似甘特图的事序图功能的技术方案,包括时间轴可视化、事件管理、拖拽交互等核心功能的完整实现,为开发者提供一套完整的时间轴管理解决方案。
📋 目录
🎯 项目背景
在小说创作过程中,作者需要管理复杂的时间线和事件序列。传统的时间线管理工具往往过于复杂,不适合小说创作场景。因此,我们在 51mazi 中设计了一个专门的事序图功能,类似甘特图但更贴近小说创作需求。
📊 事序图功能展示
可视化事序图管理 - 直观展示事件时间轴和进度
✨ 核心功能特性
- 🕐 时间轴可视化: 基于时间单元格的可视化事件展示
- 📝 事件管理: 支持创建、编辑、删除事件,包含简介、详情、进度等信息
- 🖱️ 拖拽调整: 直观的拖拽操作调整事件时间位置
- 📈 进度跟踪: 事件进度条显示,支持0-100%进度管理
- 📚 多事序图: 支持创建多个独立的事序图,满足不同章节或故事线需求
- 💾 数据持久化: 本地文件存储,确保数据安全
🏗️ 技术架构设计
核心技术栈
- Vue 3.5.13: 渐进式 JavaScript 框架
- Element Plus 2.10.1: 企业级 UI 组件库
- Electron 35.0.3: 跨平台桌面应用框架
系统架构设计
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 渲染进程 │ │ 主进程 │ │ 文件系统 │
│ (Vue 3) │◄──►│ (Node.js) │◄──►│ (JSON存储) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
📁 组件结构设计
// src/renderer/src/views/EventsSequence.vue
<template>
<LayoutTool :title="`事序图 - ${bookName}`">
<!-- 事序图列表 -->
<div class="sequence-charts">
<div v-for="chart in sequenceCharts" :key="chart.id" class="sequence-chart">
<!-- 甘特图表格 -->
<div class="gantt-table">
<!-- 表格头部 -->
<div class="table-header">...</div>
<!-- 表格主体 -->
<div class="table-body">
<div class="table-left">...</div>
<div class="table-right">...</div>
</div>
</div>
</div>
</div>
</LayoutTool>
</template>
🔧 核心功能实现
1. 数据结构设计
// 事序图数据结构
const sequenceCharts = ref([
{
id: 'chart-1',
title: '第一章事件',
cellCount: 100,
events: [
{
id: 'event-1',
index: 1,
introduction: '事件简介',
detail: '事件详细描述',
progress: 50,
startTime: 1,
endTime: 5,
color: '#409EFF'
}
]
}
])
2. 时间轴渲染实现
// 时间轴单元格渲染
<div class="right-header">
<div v-for="i in chart.cellCount || 100" :key="i" class="time-cell">
{{ i }}
</div>
</div>
// 背景网格渲染
<div class="grid-background">
<div
v-for="rowIndex in chart.events.length"
:key="`row-${rowIndex}`"
class="grid-row"
>
<div
v-for="colIndex in chart.cellCount || 100"
:key="`cell-${rowIndex}-${colIndex}`"
class="grid-cell"
></div>
</div>
</div>
3. 事件条样式计算
// 事件条样式计算
const getEventBarStyle = (event) => {
if (!event.startTime || !event.endTime) return {}
const left = (event.startTime - 1) * 40 // 40px 单元格宽度
const width = (event.endTime - event.startTime + 1) * 40
return {
left: `${left}px`,
width: `${width}px`,
backgroundColor: event.color || '#409EFF'
}
}
4. 进度条可视化
// 进度条宽度计算
const getEventProgressWidth = (event) => {
const progress = typeof event.progress === 'number'
? event.progress
: Number(event.progress) || 0
const clamped = Math.max(0, Math.min(100, progress))
return `${clamped}%`
}
/* 进度条样式 */
.event-progress {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.22);
width: 0%;
transition: width 0.3s ease;
pointer-events: none;
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 4px,
rgba(255, 255, 255, 0.1) 4px,
rgba(255, 255, 255, 0.1) 8px
);
animation: progressStripes 1s linear infinite;
}
@keyframes progressStripes {
0% { background-position: 0 0; }
100% { background-position: 8px 8px; }
}
🖱️ 拖拽交互实现
1. 拖拽状态管理
// 拖拽相关状态
const draggingEvent = ref(null)
const dragStartX = ref(0)
const dragStartLeft = ref(0)
const isDragging = ref(false)
const hasMovedWhileMouseDown = ref(false)
const isPointerDown = ref(false)
let clickSuppressTimer = null
2. 拖拽开始处理
const startDrag = (event, eventData) => {
event.preventDefault()
draggingEvent.value = eventData
dragStartX.value = event.clientX
dragStartLeft.value = (eventData.startTime - 1) * 40
hasMovedWhileMouseDown.value = false
isPointerDown.value = true
// 添加全局鼠标事件监听
document.addEventListener('mousemove', handleDrag)
document.addEventListener('mouseup', stopDrag)
// 设置拖拽状态
isDragging.value = true
// 添加拖拽样式
document.body.style.cursor = 'grabbing'
document.body.style.userSelect = 'none'
document.body.classList.add('dragging')
}
3. 拖拽移动处理
const handleDrag = (event) => {
if (!isDragging.value || !draggingEvent.value) return
const deltaX = event.clientX - dragStartX.value
if (Math.abs(deltaX) > 3) {
hasMovedWhileMouseDown.value = true
}
const newLeft = dragStartLeft.value + deltaX
// 计算新的时间位置(基于40px单元格宽度)
const newStartTime = Math.round(newLeft / 40) + 1
// 边界检查
const chart = sequenceCharts.value.find((c) => c.events.includes(draggingEvent.value))
if (chart) {
const eventDuration = draggingEvent.value.endTime - draggingEvent.value.startTime + 1
const maxStartTime = chart.cellCount - eventDuration + 1
// 限制在有效范围内
const clampedStartTime = Math.max(1, Math.min(newStartTime, maxStartTime))
const clampedEndTime = clampedStartTime + eventDuration - 1
// 更新事件时间
draggingEvent.value.startTime = clampedStartTime
draggingEvent.value.endTime = clampedEndTime
}
}
4. 拖拽结束处理
const stopDrag = async () => {
if (!isDragging.value || !draggingEvent.value) return
// 显示拖拽结果
const chart = sequenceCharts.value.find((c) => c.events.includes(draggingEvent.value))
if (chart) {
if (hasMovedWhileMouseDown.value) {
ElMessage.success(
`事件"${draggingEvent.value.introduction}"已移动到时间 ${draggingEvent.value.startTime}-${draggingEvent.value.endTime}`
)
await saveSequenceCharts()
// 拖动结束后,短暂抑制 mouseup 触发编辑
if (clickSuppressTimer) clearTimeout(clickSuppressTimer)
clickSuppressTimer = setTimeout(() => {
hasMovedWhileMouseDown.value = false
clickSuppressTimer = null
}, 120)
}
}
// 清理拖拽状态
isDragging.value = false
draggingEvent.value = null
// 移除全局事件监听
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', stopDrag)
// 恢复样式
document.body.style.cursor = ''
document.body.style.userSelect = ''
document.body.classList.remove('dragging')
isPointerDown.value = false
}
5. 点击与拖拽冲突解决
// mouseup 时决定是否打开弹框(仅未发生拖动)
const onEventBarMouseUp = (chartId, event) => {
if (hasMovedWhileMouseDown.value) {
// 刚刚发生过拖动,不打开
return
}
openEventEditor(chartId, event)
}
💾 数据持久化方案
1. 主进程文件操作
// src/main/index.js
const { ipcMain } = require('electron')
const fs = require('fs').promises
const path = require('path')
// 读取事序图数据
ipcMain.handle('read-sequence-charts', async (event, bookId) => {
try {
const bookDir = path.join(booksDirectory, bookId)
const filePath = path.join(bookDir, 'sequence-charts.json')
// 确保目录存在
await fs.mkdir(bookDir, { recursive: true })
const data = await fs.readFile(filePath, 'utf8')
return JSON.parse(data)
} catch (error) {
console.error('读取事序图数据失败:', error)
return []
}
})
// 写入事序图数据
ipcMain.handle('write-sequence-charts', async (event, bookId, data) => {
try {
const bookDir = path.join(booksDirectory, bookId)
const filePath = path.join(bookDir, 'sequence-charts.json')
// 确保目录存在
await fs.mkdir(bookDir, { recursive: true })
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8')
return { success: true }
} catch (error) {
console.error('保存事序图数据失败:', error)
return { success: false, error: error.message }
}
})
2. 预加载脚本暴露API
// src/preload/index.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electron', {
// 事序图相关API
readSequenceCharts: (bookId) => ipcRenderer.invoke('read-sequence-charts', bookId),
writeSequenceCharts: (bookId, data) => ipcRenderer.invoke('write-sequence-charts', bookId, data)
})
3. 渲染进程数据管理
// 加载事序图数据
const loadSequenceCharts = async () => {
try {
const data = await window.electron.readSequenceCharts(currentBookId.value)
sequenceCharts.value = data || []
} catch (error) {
console.error('加载事序图数据失败:', error)
ElMessage.error('加载事序图数据失败')
}
}
// 保存事序图数据
const saveSequenceCharts = async () => {
try {
const result = await window.electron.writeSequenceCharts(currentBookId.value, sequenceCharts.value)
if (!result.success) {
throw new Error(result.error)
}
} catch (error) {
console.error('保存事序图数据失败:', error)
ElMessage.error('保存事序图数据失败')
}
}
// 监听数据变化自动保存
watch(sequenceCharts, saveSequenceCharts, { deep: true })
⚡ 性能优化策略
1. 虚拟滚动优化
// 对于大量事件的情况,可以考虑虚拟滚动
const visibleEvents = computed(() => {
const start = scrollTop.value / itemHeight
const end = start + visibleCount.value
return sequenceCharts.value.slice(start, end)
})
2. 事件防抖处理
// 拖拽过程中的防抖处理
const debouncedSave = debounce(saveSequenceCharts, 300)
// 在拖拽过程中使用防抖保存
const handleDrag = (event) => {
// ... 拖拽逻辑
debouncedSave()
}
3. 内存优化
// 组件卸载时清理定时器
onUnmounted(() => {
if (clickSuppressTimer) {
clearTimeout(clickSuppressTimer)
}
// 清理全局事件监听
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', stopDrag)
})
🎯 技术亮点总结
1. 创新的交互设计
- 拖拽与点击分离: 通过
mouseup
事件和位移检测,完美解决拖拽与点击的冲突 - 视觉反馈: 拖拽时的视觉状态变化,提升用户体验
- 边界限制: 智能的边界检查,防止事件拖拽到无效位置
2. 高性能渲染
- 分层渲染: 背景网格、事件条分层渲染,提升性能
- CSS动画: 使用CSS动画实现进度条效果,性能更优
- 响应式设计: 基于Vue 3的响应式系统,数据变化自动更新UI
3. 数据持久化
- 本地存储: 基于文件系统的本地存储,数据安全可靠
- 自动保存: 数据变化自动保存,防止数据丢失
- 错误处理: 完善的错误处理机制,提升系统稳定性
🔮 总结与展望
本文详细介绍了在小说写作软件中实现事序图功能的技术方案。通过Vue 3的响应式系统、Element Plus的UI组件、以及精心设计的交互逻辑,我们实现了一个功能完善、用户体验优秀的时间轴管理工具。
技术优势
- ✅ 高性能: 基于Vue 3的响应式系统,渲染性能优秀
- ✅ 易扩展: 模块化设计,易于功能扩展
- ✅ 用户友好: 直观的拖拽交互,操作简单
- ✅ 数据安全: 本地文件存储,数据安全可靠
未来优化方向
- 🔮 实时协作: 支持多用户实时协作编辑
- 🔮 模板系统: 提供预设的事件模板
- 🔮 导出功能: 支持导出为图片或PDF格式
- 🔮 AI辅助: 集成AI功能,智能推荐事件安排
通过这套技术方案,我们为小说创作者提供了一个强大而直观的时间轴管理工具,大大提升了创作效率和体验。
📚 相关链接
- 项目地址: GitHub - 51mazi,给个 Star 哦~
- 技术栈: Electron + Vue 3 + Element Plus
- 关键词: 事序图、甘特图、时间轴管理、拖拽交互、Vue 3
🏷️ 标签
#Vue3
#ElementPlus
#事序图
#甘特图
#时间轴管理
#拖拽交互
#小说写作
#前端开发
💡 如果这篇文章对你有帮助,请给个 ⭐️ 支持一下!