标签: Vue 3, TypeScript, Tauri, Pinia, Naive UI, Tailwind CSS, Tiptap, 前端架构
分类: 前端开发
难度: 进阶


一、引言

iNovel 是一款基于 Tauri 2 框架构建的跨平台桌面小说写作工具,其前端采用 Vue 3 + TypeScript + Vite 8 技术栈,结合 Naive UI 组件库和 Tailwind CSS 原子化 CSS 框架,构建了一套完整的写作工作流。本文将深入剖析 iNovel 前端的技术架构、核心设计模式、状态管理方案以及编辑器实现细节,为读者提供一套可参考的桌面应用前端开发范式。

技术栈一览

技术 版本 用途
Vue 3 3.5.x 前端框架,Composition API
TypeScript 6.0.x 类型安全
Vite 8.x 构建工具
Pinia 3.x 状态管理
Naive UI 2.44.x UI 组件库
Tailwind CSS 4.x 原子化 CSS
Tiptap 3.23.x 富文本编辑器
Vue Router 4.x 路由管理
vue-i18n 11.x 国际化
@vueuse/core 13.x 组合式工具函数

二、项目架构总览

2.1 目录结构

src/
├── assets/              # 静态资源(图片、字体等)
├── components/          # 可复用 Vue 组件
│   ├── MarkdownEditor.vue       # 核心编辑器组件
│   ├── SmartSymbolsExtension.ts # 智能符号扩展
│   ├── MentionExtension.ts      # @提及扩展
│   ├── SensitiveHighlightPlugin.ts # 敏感词高亮插件
│   ├── TemplateSelector.vue     # 模板选择器
│   └── ...
├── composables/         # 组合式函数(逻辑复用)
│   ├── useEditor.ts             # 编辑器核心逻辑
│   ├── useTheme.ts              # 主题切换
│   ├── useWordCount.ts          # 字数统计
│   ├── useTextBeautify.ts       # 文本美化
│   ├── useEditorLayout.ts       # 编辑器布局
│   ├── useGlobalShortcuts.ts    # 全局快捷键
│   └── ...
├── stores/              # Pinia 状态管理
│   ├── editor.ts                # 编辑器状态
│   ├── project.ts               # 项目状态
│   └── ...
├── i18n/                # 国际化配置
│   ├── index.ts                 # i18n 初始化
│   └── composables/             # i18n 组合式函数
├── locales/             # 语言文件
│   ├── zh-CN.ts                 # 简体中文
│   ├── en-US.ts                 # 英文
│   └── zh-TW.ts                 # 繁体中文
├── router/              # 路由配置
├── config/              # 应用配置
├── services/            # 前端服务层
├── views/               # 页面级组件
│   ├── WelcomePage.vue          # 欢迎页
│   ├── EditorPage.vue           # 编辑器主页
│   ├── SettingsPage.vue         # 全局设置
│   ├── WorldbuildingPage.vue    # 世界观构建
│   └── ...
├── App.vue              # 根组件
├── main.ts              # 应用入口
└── style.css            # 全局样式

2.2 架构分层

┌──────────────────────────────────────────────────────┐
│                    Views(页面层)                    │
│  WelcomePage / EditorPage / SettingsPage / ...       │
├──────────────────────────────────────────────────────┤
│                  Components(组件层)                 │
│  MarkdownEditor / TemplateSelector / ...             │
├──────────────────────────────────────────────────────┤
│                 Composables(逻辑层)                 │
│  useEditor / useTheme / useWordCount / ...           │
├──────────────────────────────────────────────────────┤
│                   Stores(状态层)                    │
│  useProjectStore / useEditorStore / ...              │
├──────────────────────────────────────────────────────┤
│                  Services(服务层)                   │
│  invoke() → Tauri Commands                           │
└──────────────────────────────────────────────────────┘

三、核心设计模式

3.1 Composition API + Composables 模式

iNovel 全面采用 Vue 3 的 Composition API,通过 Composables(组合式函数) 实现逻辑复用。这是项目中最核心的设计模式。

3.1.1 编辑器 Composables 示例
// src/composables/useEditor.ts
import { ref, watch, toRef, type Ref } from "vue";
import { useEditor, EditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";

export interface UseEditorOptions {
  modelValue: string | Ref<string>;
  projectId?: number | null | Ref<number | null | undefined>;
  editorMode?: EditorMode | Ref<EditorMode | undefined>;
  smartSymbolsEnabled?: boolean;
  onContentChange?: (html: string) => void;
  onWordCountUpdate?: (count: number) => void;
  onMentionClick?: (id: string) => void;
}

export function useEditorComposable(options: UseEditorOptions) {
  const modelValueRef = toRef(options, "modelValue");
  const projectIdRef = toRef(options, "projectId");
  const editorModeRef = toRef(options, "editorMode");

  // 创建 Tiptap 编辑器实例
  const editor = useEditor({
    content: modelValueRef.value,
    extensions: [
      StarterKit,
      Placeholder.configure({ placeholder: "开始写作..." }),
      // ... 更多扩展
    ],
    onUpdate: ({ editor }) => {
      const html = editor.getHTML();
      options.onContentChange?.(html);
    },
  });

  // 暴露编辑器操作方法
  return {
    editor,
    EditorContent,
    toggleBold: () => editor.value?.chain().focus().toggleBold().run(),
    toggleItalic: () => editor.value?.chain().focus().toggleItalic().run(),
    // ...
  };
}
3.1.2 主题切换 Composables
// src/composables/useTheme.ts
import { computed } from "vue";
import { useDark, useToggle } from "@vueuse/core";
import { darkTheme } from "naive-ui";
import type { GlobalTheme } from "naive-ui";

const isDark = useDark({
  selector: "html",
  attribute: "class",
  valueDark: "dark",
  valueLight: "",
});

const toggleDark = useToggle(isDark);

const theme = computed<GlobalTheme | undefined>(() =>
  isDark.value ? darkTheme : undefined,
);

export function useTheme() {
  return { isDark, toggleDark, theme };
}

3.2 Pinia Store 模式

项目使用 Pinia 进行全局状态管理,采用 Setup Store 语法(与 Composition API 风格一致)。

// src/stores/project.ts
import { defineStore } from "pinia";
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";

export const useProjectStore = defineStore("project", () => {
  // ===== 状态 =====
  const recentProjects = ref<ProjectMeta[]>([]);
  const currentProject = ref<ProjectMeta | null>(null);
  const isLoading = ref(false);
  const error = ref<string | null>(null);

  // ===== 分页状态 =====
  const currentPage = ref(1);
  const totalPages = ref(0);
  const pageSize = ref(5);

  // ===== 操作 =====
  async function fetchRecentProjects(page: number = 1) {
    isLoading.value = true;
    error.value = null;
    try {
      const result = await invoke<PaginatedProjects>("get_recent_projects", {
        page,
        page_size: pageSize.value,
      });
      recentProjects.value = result.items;
      currentPage.value = result.page;
      totalPages.value = result.total_pages;
    } catch (e) {
      error.value = String(e);
    } finally {
      isLoading.value = false;
    }
  }

  async function createProject(params: CreateProjectParams) {
    isLoading.value = true;
    error.value = null;
    try {
      const project = await invoke<ProjectMeta>("create_project", { params });
      recentProjects.value.unshift(project);
      currentProject.value = project;
      return project;
    } catch (e) {
      error.value = String(e);
      return null;
    } finally {
      isLoading.value = false;
    }
  }

  return {
    // 状态
    recentProjects,
    currentProject,
    isLoading,
    error,
    currentPage,
    totalPages,
    pageSize,
    // 操作
    fetchRecentProjects,
    createProject,
    openProject,
    removeProjectFromList,
    updateProject,
  };
});

3.3 组件设计模式

3.3.1 Props 与 Emits 类型定义
<script setup lang="ts">
import { ref, toRef } from "vue";

// 使用 TypeScript 接口定义 Props
const props = defineProps<{
  modelValue: string;
  chapterId: number | null;
  projectId?: number | null;
  volumeWordCount?: number;
  totalWordCount?: number;
  isDark?: boolean;
  editorMode?: EditorMode;
}>();

// 使用 TypeScript 函数签名定义 Emits
const emit = defineEmits<{
  (e: "update:modelValue", value: string): void;
  (e: "requestSave"): void;
  (e: "exitSpecialMode"): void;
  (e: "mention-click", id: string): void;
  (e: "show-history"): void;
  (e: "create-snapshot"): void;
  (e: "word-count-updated", count: number): void;
}>();

// 使用 toRef 将 props 转为响应式引用
const modelValueRef = toRef(props, "modelValue");
const projectIdRef = toRef(props, "projectId");
</script>
3.3.2 组件组合模式
<!-- App.vue - 根组件中的 Provider 嵌套 -->
<template>
  <n-config-provider
    :theme="theme"
    :theme-overrides="themeOverrides"
    :locale="naiveLocale"
    :date-locale="naiveDateLocale"
  >
    <n-loading-bar-provider>
      <n-dialog-provider>
        <n-message-provider>
          <GlobalPasswordOverlay />
          <RouterView />
        </n-message-provider>
      </n-dialog-provider>
    </n-loading-bar-provider>
  </n-config-provider>
</template>

四、富文本编辑器实现

4.1 Tiptap 编辑器架构

iNovel 的核心是 Tiptap 3(基于 ProseMirror)富文本编辑器,支持以下功能:

功能 实现方式
基础编辑 StarterKit 扩展包
占位符 Placeholder 扩展
智能符号 自定义 SmartSymbolsExtension
@提及 自定义 MentionExtension
敏感词高亮 自定义 SensitiveHighlightPlugin
Markdown 导入 marked 库解析
打字机模式 CSS + 编辑器状态控制
专注模式 段落级高亮控制

4.2 自定义 ProseMirror 插件

// 敏感词高亮插件核心逻辑
import { Plugin, PluginKey } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";

export const sensitiveKey = new PluginKey("sensitiveWords");

export function createSensitivePlugin() {
  return new Plugin({
    key: sensitiveKey,
    state: {
      init() {
        return DecorationSet.empty;
      },
      apply(tr, oldSet) {
        // 在文档变更时重新计算高亮
        const matches = findSensitiveMatches(tr.doc);
        return buildDecorations(tr.doc, matches);
      },
    },
    props: {
      decorations(state) {
        return sensitiveKey.getState(state);
      },
    },
  });
}

4.3 Markdown 自动检测与转换

// 检测内容是否为 Markdown 格式
function isMarkdownContent(content: string): boolean {
  if (!content) return false;
  const markdownPatterns = [
    /^#+\s/m, // 标题
    /^\s*[-*+]\s/m, // 无序列表
    /^\s*\d+\.\s/m, // 有序列表
    /```[\s\S]*?```/g, // 代码块
    /\[.+?\]\(.+?\)/g, // 链接
    /\*\*[^*]+\*\*/g, // 加粗
  ];
  const matchCount = markdownPatterns.filter((pattern) =>
    pattern.test(content),
  ).length;
  return matchCount >= 2;
}

// Markdown 转 HTML
function markdownToHtml(markdown: string): string {
  if (!markdown) return "";
  try {
    return marked.parse(markdown) as string;
  } catch (error) {
    console.error("Markdown parsing failed:", error);
    return markdown;
  }
}

五、国际化方案

5.1 多语言架构

iNovel 支持 简体中文、英文、繁体中文 三种语言,采用 vue-i18n 实现。

// src/i18n/index.ts
import { createI18n } from "vue-i18n";
import zhCN from "../locales/zh-CN";
import enUS from "../locales/en-US";
import zhTW from "../locales/zh-TW";

const i18n = createI18n({
  legacy: false,
  locale: getStoredLocale(),
  fallbackLocale: "zh-CN",
  messages: {
    "zh-CN": zhCN,
    "en-US": enUS,
    "zh-TW": zhTW,
  },
  missing: import.meta.env.DEV ? handleMissing : undefined,
});

// 前后端语言同步
export async function initializeLocale(): Promise<void> {
  try {
    const locale = await invoke<string>("get_locale");
    if (isValidLocale(locale)) {
      i18n.global.locale.value = locale;
      localStorage.setItem("inovel_locale", locale);
      document.documentElement.setAttribute("lang", locale);
    }
  } catch (error) {
    console.warn("Failed to load locale from backend:", error);
  }
}

5.2 语言文件结构

// src/locales/zh-CN.ts (示例结构)
export default {
  common: {
    app: { name: "iNovel" },
    actions: {
      save: "保存",
      cancel: "取消",
      confirm: "确认",
    },
  },
  editor: {
    toolbar: {
      bold: "加粗",
      italic: "斜体",
      heading: "标题",
    },
    placeholder: "开始写作...",
  },
  project: {
    create: "创建项目",
    open: "打开项目",
    delete: "删除项目",
  },
};

六、前后端通信

6.1 Tauri invoke 调用模式

import { invoke } from "@tauri-apps/api/core";

// 带类型的命令调用
const result = await invoke<ProjectMeta>("create_project", {
  params: {
    name: "我的小说",
    author: "作者名",
    description: "简介",
    path: "/path/to/project",
  },
});

// 分页查询
const projects = await invoke<PaginatedProjects>("get_recent_projects", {
  page: 1,
  page_size: 10,
});

6.2 错误处理模式

async function fetchData() {
  isLoading.value = true;
  error.value = null;
  try {
    const result = await invoke<DataType>("command_name", params);
    // 处理成功结果
    return result;
  } catch (e) {
    error.value = String(e);
    console.error("操作失败:", e);
    return null;
  } finally {
    isLoading.value = false;
  }
}

七、路由设计

// src/router/index.ts
const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    { path: "/", name: "Welcome", component: WelcomePage },
    {
      path: "/editor/:projectId",
      name: "Editor",
      component: EditorPage,
    },
    {
      path: "/editor/:projectId/worldbuilding",
      name: "Worldbuilding",
      component: WorldbuildingPage,
    },
    { path: "/settings", name: "Settings", component: SettingsPage },
    { path: "/stats", name: "Stats", component: StatsDashboard },
    { path: "/tasks", name: "TaskChecklist", component: TaskChecklistPage },
    { path: "/config", name: "ConfigManager", component: ConfigManagerPage },
    { path: "/templates", name: "UserTemplates", component: UserTemplatesPage },
  ],
});

八、最佳实践总结

8.1 代码组织原则

原则 说明
单一职责 每个 Composable 只负责一个功能领域
类型优先 所有接口、Props、Emits 使用 TypeScript 类型定义
逻辑分离 业务逻辑放在 Composables,UI 逻辑放在组件中
状态集中 跨组件共享状态使用 Pinia Store

8.2 性能优化策略

策略 实现
懒加载路由 使用动态 import 实现路由级代码分割
编辑器优化 Tiptap 基于 ProseMirror 的增量更新机制
响应式优化 使用 toRef 而非 ref 包装 props,避免不必要的响应式转换
条件渲染 使用 v-if 而非 v-show 处理不常切换的组件

8.3 常见问题与解决方案

问题 解决方案
编辑器内容与 props 不同步 使用 toRef 将 props 转为 Ref,通过 watch 监听变化
Tauri invoke 类型丢失 使用泛型 invoke<T>() 指定返回类型
暗色模式闪烁 在 HTML 标签上预设 class,通过 useDark 同步状态
i18n 缺失翻译 开发环境启用 missing 回调,生产环境使用 fallback

九、结语

iNovel 的前端架构充分体现了 Vue 3 Composition API 的优势,通过 Composables + Pinia Store + TypeScript 的组合,构建了一套类型安全、逻辑清晰、易于维护的桌面应用前端体系。核心亮点包括:

  1. Composables 逻辑复用:将编辑器、主题、字数统计等功能封装为可复用的组合式函数
  2. Tiptap 深度定制:通过自定义 Extension 和 Plugin 实现智能符号、敏感词高亮等高级功能
  3. 前后端类型安全通信:利用 TypeScript 泛型 + Tauri invoke 实现类型安全的命令调用
  4. 完善的多语言支持:前后端语言状态同步,开发环境翻译缺失检测

这套架构模式不仅适用于小说写作工具,也可为其他基于 Tauri + Vue 3 的桌面应用开发提供参考。


相关阅读: iNovel 项目地址 | Tauri 2 官方文档 | Vue 3 官方文档

Logo

一站式 AI 云服务平台

更多推荐