创作世界的建筑师:小说设计器开发手记
缘起
最近给一位爱写小说的好朋友做了一个小工具 —— 小说设计器。看着他每次写小说时,在各种文档、笔记本、思维导图之间来回切换,记录角色设定、世界观、人物关系等内容,我就在想:为什么不做一个集成化的工具,把这些功能整合在一起呢?
于是,利用周末的时间,我用 SolidJS 开发了这个小说设计器。从最初的简单框架,到现在功能完善的创作工具,前后经历了三次主要迭代。看着好友用得开心,我觉得这个小工具值得分享给更多有同样需求的创作者。
功能特性
1. 项目管理
小说设计器采用项目化管理方式,可以同时管理多部小说作品:
- 创建小说:一键创建新的小说项目
- 搜索功能:快速在多个项目中找到想要的作品
- 项目列表:侧边栏展示所有小说,方便切换
- 基本信息:为每部小说设置书名、封面、简介等基础信息
2. 六大核心模块
小说设计器的详情页提供了六个精心设计的功能模块:
📖 故事背景
一个大文本框,用于描述小说的整体背景、时代背景、地点等核心设定。这是整个小说世界的基石。
👥 人物详情
角色是小说的灵魂,这个模块提供了完整的角色管理功能:
- 基础信息:姓名、年龄、性别
- 性格特点:记录角色的性格特质
- 背景故事:详细记录角色的过往经历
- 详细描述:外貌、习惯、特殊技能等补充信息
卡片式的展示让每个角色一目了然,编辑、删除操作也非常便捷。
🔗 人物关系网
这是我最喜欢的一个功能!支持两种视图模式:
列表视图
- 清晰地列出所有人物关系
- 显示"人物A → 关系 → 人物B"的关系链
- 可以添加详细的关系说明
图谱视图
- 使用 SVG 绘制关系网络图
- 圆形布局,自动排列角色节点
- 箭头连线显示人物关系
- 支持悬停查看详细信息
开发这个功能时,我特别考虑了复杂人物关系的展示问题。当小说中角色众多、关系复杂时,图谱视图能够直观地展现整个人物关系网络,帮助作者理清思路。
🌍 世界设定
小说世界观是吸引读者的重要元素:
- 分类管理:地理、历史、魔法体系、科技等分类
- 标题+描述:每个设定都有清晰的标题和详细说明
- 徽章标识:用颜色标签区分不同类别的设定
⏰ 时间线
帮助作者理清故事发展脉络:
- 时间标记:可以是具体日期,也可以是"第一章"这样的相对时间
- 事件标题:简明扼要的事件名称
- 详细描述:事件的详细内容
- 时间轴展示:视觉化的时间线布局,清晰展现故事发展
📝 章节梗概
写长篇小说最怕的就是写着写着忘了大纲:
- 章节号:第一章、第二章...
- 章节标题:每章的标题
- 内容梗概:提前规划好每章要写什么内容
- 列表展示:所有章节一览无余
3. 数据管理
本地存储
所有数据自动保存到浏览器的 localStorage 中,不用担心刷新页面后数据丢失。
导出/导入
- 导出功能:将所有小说数据导出为 JSON 文件,随时备份
- 导入功能:支持从 JSON 文件导入数据,方便换设备使用
- 数据验证:导入时会验证数据结构,防止格式错误
4. 细节优化
开发过程中,我特别注重用户体验的细节:
ID 生成优化
在第二次迭代(commit 5387119)中,我改进了 ID 生成机制:
let idCounter = 0;
const generateId = () => {
const timestamp = Date.now();
idCounter = (idCounter + 1) % 1000;
return `${timestamp}-${idCounter}`;
};
这样可以避免快速连续创建多个项目时产生重复 ID。
数据验证
添加了完整的数据验证机制,确保导入的数据格式正确:
const validateNovelData = (data: any): data is Novel[] => {
if (!Array.isArray(data)) return false;
return data.every((novel: any) => {
return (
typeof novel === 'object' &&
typeof novel.id === 'string' &&
typeof novel.title === 'string' &&
// ... 更多验证
);
});
};
级联删除
当删除角色时,会自动删除与该角色相关的所有人物关系,保持数据一致性。
响应式设计
使用 DaisyUI 和 TailwindCSS,适配各种屏幕尺寸,在手机上也能流畅使用。
技术实现
技术栈
- SolidJS:轻量级的响应式框架,性能优秀
- TypeScript:完整的类型定义,开发体验更好
- DaisyUI + TailwindCSS:快速构建美观的 UI
- SVG:用于绘制人物关系图谱
核心特性
- 响应式状态管理:使用 SolidJS 的
createSignal管理状态 - 模态框系统:每个功能模块都有独立的编辑模态框
- 视图切换:首页视图和详情视图无缝切换
- 本地持久化:localStorage 自动保存,无需服务器
核心代码解析
下面我将详细讲解小说设计器的核心实现逻辑,帮助大家理解整个系统是如何运作的。
1. 数据结构设计
整个应用的核心是一套完整的 TypeScript 类型定义,这确保了数据的类型安全:
// 小说主体结构
interface Novel {
id: string; // 唯一标识
title: string; // 书名
cover: string; // 封面图片URL
summary: string; // 简介
createdAt: string; // 创建时间
characters: Character[]; // 角色列表
worldSettings: WorldSetting[]; // 世界观设定
background: string; // 故事背景
relationships: Relationship[]; // 人物关系
timeline: TimelineEvent[]; // 时间线
outlines: OutlineItem[]; // 章节梗概
}
// 角色结构
interface Character {
id: string;
name: string;
avatar: string;
age: string;
gender: string;
personality: string;
background: string;
description: string;
}
// 世界观设定
interface WorldSetting {
id: string;
category: string; // 分类:地理、历史、魔法等
title: string;
description: string;
}
// 人物关系
interface Relationship {
id: string;
from: string; // 人物A的名字
to: string; // 人物B的名字
relation: string; // 关系类型
description: string;
}
// 时间线事件
interface TimelineEvent {
id: string;
time: string;
title: string;
description: string;
}
// 章节梗概
interface OutlineItem {
id: string;
chapter: string;
title: string;
content: string;
}
这套数据结构的设计遵循了几个原则:
- 扁平化:避免过深的嵌套,方便数据操作
- 关联性:通过名字关联人物关系,而不是 ID,更直观
- 可扩展:每个实体都有独立的 ID,便于后续扩展
2. 状态管理系统
使用 SolidJS 的 createSignal 来管理所有状态,实现细粒度的响应式更新:
export default function NovelDesigner() {
// ===== 核心数据状态 =====
const [novels, setNovels] = createSignal<Novel[]>([]); // 所有小说列表
const [selectedNovel, setSelectedNovel] = createSignal<Novel | null>(null); // 当前选中的小说
// ===== UI 状态 =====
const [searchQuery, setSearchQuery] = createSignal(''); // 搜索关键词
const [viewMode, setViewMode] = createSignal<ViewMode>('home'); // 视图模式:home | detail
const [activeModal, setActiveModal] = createSignal<ModalType>(null); // 当前打开的模态框
const [relationshipViewMode, setRelationshipViewMode] = createSignal<RelationshipViewMode>('list'); // 关系视图模式
// ===== 编辑状态 =====
const [editingCharacter, setEditingCharacter] = createSignal<Character | null>(null);
const [editingWorld, setEditingWorld] = createSignal<WorldSetting | null>(null);
const [editingRelationship, setEditingRelationship] = createSignal<Relationship | null>(null);
const [editingTimeline, setEditingTimeline] = createSignal<TimelineEvent | null>(null);
const [editingOutline, setEditingOutline] = createSignal<OutlineItem | null>(null);
const [editingNovel, setEditingNovel] = createSignal<Partial<Novel> | null>(null);
// ...
}
为什么这样设计?
- 分离关注点:数据状态、UI状态、编辑状态分开管理,职责清晰
- 单一数据源:所有状态都在组件顶层定义,避免状态散落
- 细粒度更新:SolidJS 只会重新渲染使用了变化状态的部分,性能优秀
3. 唯一 ID 生成器
这是一个看似简单但很重要的功能。在第二版迭代中,我特别优化了这个机制:
// ID 生成计数器,避免快速连续创建时产生重复 ID
let idCounter = 0;
const generateId = () => {
const timestamp = Date.now();
idCounter = (idCounter + 1) % 1000;
return `${timestamp}-${idCounter}`;
};
为什么需要这样做?
如果只用 Date.now() 生成 ID,当用户快速连续点击"添加角色"时(比如双击),可能在同一毫秒内生成多个 ID,导致冲突。加入计数器后:
- 即使在同一毫秒内创建,ID 也会不同
% 1000保证计数器在 0-999 循环,不会无限增长- 组合后的 ID 格式:
1729267200000-0,1729267200000-1...
4. 数据持久化
实现了完整的本地存储方案,数据不会因为刷新页面而丢失:
// 从 localStorage 加载数据
const loadFromStorage = () => {
if (typeof window === 'undefined') return; // SSR 环境检查
try {
const stored = localStorage.getItem('novel-designer-novels');
if (stored) {
const data = JSON.parse(stored);
setNovels(data || []);
}
} catch (e) {
console.error('Failed to load data:', e);
}
};
// 保存到 localStorage
const saveToStorage = () => {
if (typeof window === 'undefined') return;
try {
localStorage.setItem('novel-designer-novels', JSON.stringify(novels()));
} catch (e) {
console.error('Failed to save data:', e);
alert('保存失败,可能是存储空间不足');
}
};
// 初始化时加载数据(使用 setTimeout 避免 SSR 问题)
if (typeof window !== 'undefined') {
setTimeout(loadFromStorage, 0);
}
关键点:
- SSR 兼容:检查
window是否存在,避免服务端渲染报错 - 错误处理:localStorage 可能因为存储空间不足而失败,需要捕获异常
- 延迟加载:使用
setTimeout确保在客户端环境中执行
5. 数据更新的统一模式
所有数据修改都通过 updateSelectedNovel 函数,确保数据一致性:
// 更新选中的小说
const updateSelectedNovel = (updater: (novel: Novel) => Novel) => {
const current = selectedNovel();
if (!current) return;
const updated = updater(current);
setSelectedNovel(updated);
setNovels(novels().map((n) => (n.id === updated.id ? updated : n)));
saveToStorage();
};
这个函数做了三件事:
- 更新选中的小说状态
- 同步更新小说列表
- 自动保存到 localStorage
使用示例:
// 保存角色
const saveCharacter = () => {
const char = editingCharacter();
if (!char) return;
// 验证必填字段
if (!char.name.trim()) {
alert('请输入角色姓名');
return;
}
updateSelectedNovel((novel) => {
const exists = novel.characters.find((c) => c.id === char.id);
return {
...novel,
characters: exists
? novel.characters.map((c) => (c.id === char.id ? char : c))
: [...novel.characters, char],
};
});
setEditingCharacter(null);
};
设计优势:
- 不可变更新:每次返回新对象,而不是修改原对象
- 自动同步:不需要手动调用保存函数
- 类型安全:TypeScript 确保更新函数返回正确类型
6. 级联删除机制
删除角色时,自动删除相关的人物关系:
const deleteCharacter = (id: string) => {
const novel = selectedNovel();
if (!novel) return;
const charToDelete = novel.characters.find((c) => c.id === id);
if (!charToDelete) return;
if (confirm('确定要删除这个角色吗?相关的人物关系也将被删除。')) {
updateSelectedNovel((novel) => ({
...novel,
characters: novel.characters.filter((c) => c.id !== id),
// 同时删除与该角色相关的所有关系
relationships: novel.relationships.filter(
(r) => r.from !== charToDelete.name && r.to !== charToDelete.name
),
}));
}
};
这确保了数据一致性,不会出现"人物关系指向不存在的角色"的情况。
7. 搜索过滤功能
实现了简单但实用的实时搜索:
// 筛选小说
const filteredNovels = () => {
const query = searchQuery().toLowerCase();
if (!query) return novels();
return novels().filter((novel) =>
novel.title.toLowerCase().includes(query)
);
};
这是一个计算信号(Computed Signal),会自动响应 searchQuery 和 novels 的变化。当用户输入搜索关键词时,列表会实时过滤。
8. 数据验证系统
导入数据时,需要验证数据结构是否正确:
// 验证导入的数据结构
const validateNovelData = (data: any): data is Novel[] => {
if (!Array.isArray(data)) return false;
return data.every((novel: any) => {
return (
typeof novel === 'object' &&
typeof novel.id === 'string' &&
typeof novel.title === 'string' &&
typeof novel.createdAt === 'string' &&
Array.isArray(novel.characters) &&
Array.isArray(novel.worldSettings) &&
Array.isArray(novel.relationships) &&
Array.isArray(novel.timeline) &&
Array.isArray(novel.outlines)
);
});
};
// 导入数据
const importData = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target?.result as string);
// 验证数据结构
if (!validateNovelData(data)) {
alert('导入失败,文件格式不正确。请确保导入的是有效的小说数据文件。');
return;
}
if (confirm('导入将覆盖当前所有数据,确定继续吗?')) {
setNovels(data);
saveToStorage();
alert('导入成功!');
}
} catch (err) {
alert('导入失败,文件格式错误');
}
};
reader.readAsText(file);
}
};
input.click();
};
验证的重要性:
- 防止恶意数据注入
- 避免类型错误导致应用崩溃
- 提供友好的错误提示
9. 人物关系图谱的 SVG 绘制
这是最复杂也最有趣的部分!使用纯 SVG 实现关系网络图:
<svg class="absolute inset-0 w-full h-full pointer-events-none">
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="10"
refX="9"
refY="3"
orient="auto"
>
<polygon points="0 0, 10 3, 0 6" fill="currentColor" class="text-base-content/30" />
</marker>
</defs>
<For each={selectedNovel()?.relationships || []}>
{(rel, index) => {
const characters = selectedNovel()?.characters || [];
const fromIndex = characters.findIndex(c => c.name === rel.from);
const toIndex = characters.findIndex(c => c.name === rel.to);
// 计算位置 - 使用圆形布局
const centerX = 400;
const centerY = 300;
const radius = 200;
const totalChars = characters.length || 1;
const fromAngle = (fromIndex / totalChars) * 2 * Math.PI - Math.PI / 2;
const toAngle = (toIndex / totalChars) * 2 * Math.PI - Math.PI / 2;
const x1 = centerX + radius * Math.cos(fromAngle);
const y1 = centerY + radius * Math.sin(fromAngle);
const x2 = centerX + radius * Math.cos(toAngle);
const y2 = centerY + radius * Math.sin(toAngle);
return (
<g>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="currentColor"
class="text-base-content/30"
stroke-width="2"
marker-end="url(#arrowhead)"
/>
{/* 关系标签 */}
<text
x={(x1 + x2) / 2}
y={(y1 + y2) / 2}
text-anchor="middle"
class="fill-primary text-xs font-semibold"
>
{rel.relation}
</text>
</g>
);
}}
</For>
</svg>
{/* 角色节点 */}
<For each={selectedNovel()?.characters || []}>
{(char, index) => {
const totalChars = (selectedNovel()?.characters.length || 1);
const angle = (index() / totalChars) * 2 * Math.PI - Math.PI / 2;
const centerX = 400;
const centerY = 300;
const radius = 200;
const x = centerX + radius * Math.cos(angle) - 50;
const y = centerY + radius * Math.sin(angle) - 50;
return (
<div
class="absolute flex h-24 w-24 flex-col items-center justify-center rounded-full border-4 border-primary bg-base-100 shadow-lg transition-transform hover:scale-110"
style={`left: ${x}px; top: ${y}px;`}
>
<div class="text-center">
<div class="text-sm font-bold line-clamp-2 px-2">{char.name}</div>
<Show when={char.age || char.gender}>
<div class="text-xs opacity-60">
{char.age} {char.gender}
</div>
</Show>
</div>
</div>
);
}}
</For>
实现原理:
-
圆形布局算法:
- 将所有角色平均分布在一个圆周上
- 角度计算:
(index / totalChars) * 2π - π/2 -π/2让第一个角色在正上方,更美观
-
坐标计算:
- X 坐标:
centerX + radius × cos(angle) - Y 坐标:
centerY + radius × sin(angle)
- X 坐标:
-
关系连线:
- 找到两个角色的索引
- 计算它们的坐标
- 用
<line>元素连接 - 添加箭头标记(marker)
-
关系标签:
- 放在连线的中点
- 计算方式:
(x1 + x2) / 2,(y1 + y2) / 2
10. 模态框管理
使用统一的模态框状态管理,避免多个模态框同时打开:
type ModalType = 'background' | 'characters' | 'relationships' | 'worldview' | 'timeline' | 'outline' | null;
const [activeModal, setActiveModal] = createSignal<ModalType>(null);
// 打开模态框
onClick={() => setActiveModal('characters')}
// 关闭模态框
onClick={() => setActiveModal(null)}
// 渲染模态框
<Show when={activeModal() === 'characters'}>
{/* 模态框内容 */}
</Show>
使用 Show 组件而不是 CSS 控制显隐,模态框不显示时完全不渲染,性能更好。
11. 表单验证
在保存数据前,验证必填字段:
const saveCharacter = () => {
const char = editingCharacter();
if (!char) return;
// 验证必填字段
if (!char.name.trim()) {
alert('请输入角色姓名');
return;
}
// 保存逻辑...
};
const saveRelationship = () => {
const rel = editingRelationship();
if (!rel) return;
// 验证必填字段
if (!rel.from.trim() || !rel.to.trim()) {
alert('请输入两个人物名称');
return;
}
if (!rel.relation.trim()) {
alert('请输入关系类型');
return;
}
// 保存逻辑...
};
用户体验考虑:
- 使用
alert虽然简单,但足够直接 - 后续可以改用 Toast 通知,体验更好
.trim()避免用户输入空格就通过验证
12. 导出功能实现
将数据导出为 JSON 文件,供用户备份:
const exportData = () => {
const data = novels();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `novels-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url); // 释放内存
};
实现细节:
JSON.stringify(data, null, 2):格式化 JSON,方便人类阅读URL.createObjectURL:创建临时下载链接URL.revokeObjectURL:下载后释放内存,避免内存泄漏- 文件名包含时间戳,避免覆盖
13. SolidJS 的响应式特性
SolidJS 与 React 的最大区别在于细粒度响应式:
// React 方式(整个组件重新渲染)
const [count, setCount] = useState(0);
return <div>{count}</div>; // count 变化时,整个组件重新执行
// SolidJS 方式(只更新使用了信号的部分)
const [count, setCount] = createSignal(0);
return <div>{count()}</div>; // 只有这个 <div> 会更新
在小说设计器中,当编辑一个角色时:
- React:整个组件树重新渲染
- SolidJS:只有编辑模态框相关的 DOM 更新
这就是为什么即使管理了大量状态,应用依然很流畅。
14. 类型安全的好处
TypeScript 在开发过程中提供了巨大帮助:
// 类型推断和检查
updateSelectedNovel((novel) => {
// novel 的类型是 Novel,编辑器会提供智能提示
return {
...novel,
characters: [...novel.characters, newChar],
// 如果写错字段名,TypeScript 会立即报错
// charactersss: [...] // ❌ 报错
};
});
实际收益:
- 减少了 90% 以上的低级错误
- 重构代码更有信心
- IDE 智能提示大大提高开发效率
开发历程
第一版(commit c2f62a8)
- 搭建基础框架
- 实现六大核心模块
- 完成数据增删改查
- 添加导出/导入功能
代码量:一次性添加了 784 行代码,包含 735 行的 NovelDesigner 组件
第二版(commit 5387119)
- 改进 ID 生成机制
- 添加数据验证
- 优化角色关系管理
- 完善级联删除
改进:124 行新增,22 行删除
第三版(commit 65199b8)
- 添加人物关系视图切换功能
- 实现图谱视图的 SVG 绘制
- 优化关系网络的展示效果
改进:202 行新增,43 行删除
使用体验
朋友用了一段时间后给我反馈说:
"以前写小说,角色多了就容易记混,谁和谁是什么关系、每个角色的背景设定都要翻好几个文档。现在有了这个工具,所有信息集中管理,特别是那个人物关系图谱,太直观了!"
听到这样的反馈,我觉得周末的时间花得很值。
后续计划
虽然目前功能已经比较完善了,但还有一些想法可以继续优化:
- 云端同步:目前数据只存在本地,如果能支持云端同步会更方便
- 协作功能:支持多人协作编辑同一部小说
- AI 辅助:接入 AI 帮助生成角色背景、情节建议等
- 导出格式:支持导出为 Markdown、Word 等格式
- 统计功能:字数统计、角色数量等数据可视化
- 模板系统:提供不同类型小说的模板(玄幻、都市、科幻等)
总结
这个小说设计器从想法到实现,前后花了几个周末的时间。虽然是个小工具,但开发过程中学到了很多东西:
- 用户需求至上:功能设计要紧贴实际使用场景
- 细节决定体验:ID 生成、数据验证这些细节很重要
- 迭代优化:不要一次做完,分步骤优化更好
- 技术选型:SolidJS 的性能和开发体验确实很棒
最重要的是,能用自己的技术帮助朋友解决实际问题,这种感觉很棒。如果你也有爱写小说的朋友,或者你自己就是创作者,欢迎试用这个工具!
如果你有任何建议或想法,欢迎在评论区留言,或者直接提 Issue。让我们一起把这个工具做得更好!