创作世界的建筑师:小说设计器开发手记

创作世界的建筑师:小说设计器开发手记

2025年10月18日
开发日记
前端
solidjs

缘起

最近给一位爱写小说的好朋友做了一个小工具 —— 小说设计器。看着他每次写小说时,在各种文档、笔记本、思维导图之间来回切换,记录角色设定、世界观、人物关系等内容,我就在想:为什么不做一个集成化的工具,把这些功能整合在一起呢?

于是,利用周末的时间,我用 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:用于绘制人物关系图谱

核心特性

  1. 响应式状态管理:使用 SolidJS 的 createSignal 管理状态
  2. 模态框系统:每个功能模块都有独立的编辑模态框
  3. 视图切换:首页视图和详情视图无缝切换
  4. 本地持久化: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();
};

这个函数做了三件事:

  1. 更新选中的小说状态
  2. 同步更新小说列表
  3. 自动保存到 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),会自动响应 searchQuerynovels 的变化。当用户输入搜索关键词时,列表会实时过滤。

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>

实现原理:

  1. 圆形布局算法

    • 将所有角色平均分布在一个圆周上
    • 角度计算:(index / totalChars) * 2π - π/2
    • -π/2 让第一个角色在正上方,更美观
  2. 坐标计算

    • X 坐标:centerX + radius × cos(angle)
    • Y 坐标:centerY + radius × sin(angle)
  3. 关系连线

    • 找到两个角色的索引
    • 计算它们的坐标
    • <line> 元素连接
    • 添加箭头标记(marker)
  4. 关系标签

    • 放在连线的中点
    • 计算方式:(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 行删除

使用体验

朋友用了一段时间后给我反馈说:

"以前写小说,角色多了就容易记混,谁和谁是什么关系、每个角色的背景设定都要翻好几个文档。现在有了这个工具,所有信息集中管理,特别是那个人物关系图谱,太直观了!"

听到这样的反馈,我觉得周末的时间花得很值。

后续计划

虽然目前功能已经比较完善了,但还有一些想法可以继续优化:

  1. 云端同步:目前数据只存在本地,如果能支持云端同步会更方便
  2. 协作功能:支持多人协作编辑同一部小说
  3. AI 辅助:接入 AI 帮助生成角色背景、情节建议等
  4. 导出格式:支持导出为 Markdown、Word 等格式
  5. 统计功能:字数统计、角色数量等数据可视化
  6. 模板系统:提供不同类型小说的模板(玄幻、都市、科幻等)

总结

这个小说设计器从想法到实现,前后花了几个周末的时间。虽然是个小工具,但开发过程中学到了很多东西:

  • 用户需求至上:功能设计要紧贴实际使用场景
  • 细节决定体验:ID 生成、数据验证这些细节很重要
  • 迭代优化:不要一次做完,分步骤优化更好
  • 技术选型:SolidJS 的性能和开发体验确实很棒

最重要的是,能用自己的技术帮助朋友解决实际问题,这种感觉很棒。如果你也有爱写小说的朋友,或者你自己就是创作者,欢迎试用这个工具!


项目地址

在线体验

如果你有任何建议或想法,欢迎在评论区留言,或者直接提 Issue。让我们一起把这个工具做得更好!