[{"content":"一、 项目初始化与环境配置 在后端项目根目录（server/）下执行初始化命令：\n1 npx prisma init 执行后，系统会在 server/ 目录下自动生成以下目录和文件：\nprisma/ 目录：存放数据库配置文件。 schema.prisma：Prisma 的核心数据模型与配置文件。 prisma.config.ts：Prisma 的全局配置文件。 .env：环境变量配置文件（用于存放数据库连接串等敏感信息）。 二、 Prisma 全局配置与数据库选型 进入 prisma/schema.prisma 文件，首先配置生成器和数据源：\n1 2 3 4 5 6 7 8 9 10 generator client { provider = \u0026#34;prisma-client-js\u0026#34; output = \u0026#34;../libs/shared/src/generated/prisma\u0026#34; moduleFormat = \u0026#34;cjs\u0026#34; // 显式指定模块格式为 CommonJS } datasource db { provider = \u0026#34;postgresql\u0026#34; url = env(\u0026#34;DATABASE_URL\u0026#34;) } 1. 核心配置项解析 output：指定 Prisma Client 及代码声明文件、类型提示文件的生成目录。本架构将其导出至公共库 libs/shared 中以便复用。 moduleFormat = \u0026quot;cjs\u0026quot; (关键点)： 版本差异说明：在 Prisma 7.0+ 及更高版本中，默认生成的模块格式已调整为 ESM。由于 NestJS 编译打包后的文件遵循 CJS (CommonJS) 规范，直接使用 ESM 会引发模块引入冲突。因此，必须显式将其改回 cjs。\n2. 数据库选型：为什么选择 PostgreSQL 而不是 MySQL？ 原生枚举支持：PostgreSQL 天然支持真正的 enum 类型；而 MySQL 的枚举在底层实质上是一堆字符串，表现力和性能稍逊。 丰富的数据类型：PostgreSQL 原生支持对象（JSON/JSONB）、数组（Array）以及纯粹的布尔值（True/False）；而 MySQL 存储对象和数组时通常需要转换为字符串（或使用特定的 JSON 类型，但生态支持略逊于 PG）。 性能优势：在处理复杂查询、高并发以及大规模数据写入（如本项目的海量单词导入）时，PostgreSQL 具有更强的性能和稳定性。 三、 数据模型设计与业务逻辑详解 1. 用户表 (User) 与 ID 策略对比 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 model User { id String @id @default(cuid()) // 用户ID (cuid方案) name String // 用户名 email String? @unique // 邮箱 (可选，唯一) phone String @unique // 手机号 (唯一) address String? // 地址 (可选) password String // 密码 avatar String? // 头像 (可选) wordNumber Int @default(0) // 累计掌握单词数量 dayNumber Int @default(0) // 累计打卡天数 createdAt DateTime @default(now()) // 创建时间 updatedAt DateTime @updatedAt // 更新时间 (任何字段变更时自动刷新) lastLoginAt DateTime? // 最后登录时间 (可选) // 一对多关联关系 wordBookRecords WordBookRecord[] // 一个用户拥有多个单词学习记录 paymentRecords PaymentRecord[] // 一个用户拥有多个支付记录 courseRecords CourseRecord[] // 一个用户拥有多个课程购买记录 } 💡 装饰器与修饰符说明 ?：代表该字段为可选字段（Nullable），在数据库中允许为 NULL。 @id：声明该字段为表的主键。 @unique：声明唯一约束，该列的值在全表不可重复。 @default(now())：在创建数据时自动填入当前服务器时间。 @updatedAt：自动化钩子，只要该行数据的任何一个字段发生变更，底层会自动更新该时间。 🛡️ 主键自增策略对比 (autoincrement vs uuid vs cuid) autoincrement() (类如 1, 2, 3\u0026hellip;)： 缺点：容易被爬虫根据 ID 规律遍历数据；在分布式多库多表架构下无法保证唯一性。 uuid() (全球唯一哈希)： 缺点：由 MAC 地址、时间戳等生成，由于内部字符无序，导致数据库聚簇索引离散，在高并发写入和排序时性能一般。 cuid() (推荐)： 优点：专门为水平扩展和现代 Web 应用设计的唯一 ID。它基于时间戳和计数器生成，天生支持排序。在保持全球唯一性的同时，避免了索引碎片化，底层读写性能最佳。 2. 单词模块：多对多关系的拆解与索引优化 用户与单词之间是多对多关系（一个用户可以背多个单词，一个单词可以被多个用户背）。这里通过引入中间表 WordBookRecord 将其拆解为两个一对多关系。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 // 单词学习记录（用户-单词 中间表） model WordBookRecord { id String @id @default(cuid()) wordId String // 单词ID userId String // 用户ID isMaster Boolean @default(false) // 是否掌握 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // 建立关联，并设置级联删除 (Cascade)：用户或单词被删时，对应记录自动清除 user User @relation(fields: [userId], references: [id], onDelete: Cascade) word WordBook @relation(fields: [wordId], references: [id], onDelete: Cascade) @@unique([userId, wordId]) // 联合唯一索引：防止同一个用户对同一个单词产生重复记录 } // 单词词库表 model WordBook { id String @id @default(cuid()) word String // 单词文本 phonetic String? // 音标 definition String? // 英文定义 translation String? // 中文翻译 pos String? // 词性 collins String? // 柯林斯星级 oxford String? // 是否牛津核心词汇 tag String? // 标签原始字符串 bnc String? // BNC 英国国家语料库词频 frq String? // FRQ 频率 exchange String? // 时态/复数等变形项 // 考试级别标签（拆分为布尔值，便于多条件组合筛选） zk Boolean? // 中考 gk Boolean? // 高考 cet4 Boolean? // 四级 cet6 Boolean? // 六级 ky Boolean? // 考研 toefl Boolean? // 托福 ielts Boolean? // 雅思 gre Boolean? // GRE createdAt DateTime @default(now()) updatedAt DateTime @updatedAt wordBookRecords WordBookRecord[] // 索引优化 @@index([word]) // 针对单个单词查询优化 @@index([tag]) // 针对按分类标签查询优化 @@index([word, tag]) // 复合索引：应对“查某个分类下的特定单词”的高频联合查询场景 } 💡 索引优化逻辑 在 WordBook 表中添加 @@index。因为后续业务中会有大量基于 word、tag 或两者组合的检索请求。提前建立索引可以避免全表扫描，极大提升查询效率。\n3. 支付与课程模块：四表联查与状态流转 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 // 交易状态枚举 enum TradeStatus { NOT_PAY // 未支付 (系统自定义的初始状态，非支付宝返回) WAIT_BUYER_PAY // 等待买家付款 (支付宝对齐状态) TRADE_CLOSED // 交易关闭 TRADE_SUCCESS // 交易成功 (可退款状态) TRADE_FINISHED // 交易结束 (不可退款状态) } // 支付记录表 model PaymentRecord { id String @id @default(cuid()) userId String // 用户ID tradeNo String? // 支付宝官方交易号 (支付成功后异步回调写入) outTradeNo String @unique // 商户系统的唯一订单号 amount Decimal // 支付金额 (使用 Decimal 保证高精度，避免浮点数精度丢失) subject String // 支付标题 body String // 支付详情描述 tradeStatus TradeStatus @default(NOT_PAY) // 交易状态 sendPayTime DateTime? // 实际支付时间 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) courseRecords CourseRecord[] // 关联的课程记录 @@index([tradeNo]) // 针对支付宝交易号建立索引，方便后续回调与对账查询 } // 课程记录表（用户-课程 购买状态中间表） model CourseRecord { id String @id @default(cuid()) userId String courseId String isPurchased Boolean @default(false) // 是否已成功购买解锁 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt paymentRecordId String? // 关联的支付记录ID (可选，未下单前或异常处理) paymentRecord PaymentRecord? @relation(fields: [paymentRecordId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) @@unique([userId, courseId]) // 联合唯一：一个用户对一门课程只能有一条购买记录 } // 课程基础信息表 model Course { id String @id @default(cuid()) name String // 课程名称 value String // 课程唯一标识/Key description String? // 课程简介 teacher String // 主讲老师 url String // 课程视频/资源播放链接 price Decimal // 课程标准定价 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt courseRecords CourseRecord[] } 🛡️ 核心业务架构设计解析 双订单号设计 (outTradeNo 与 tradeNo) 的必要性：\noutTradeNo 是我方系统生成的订单号；tradeNo 是支付宝侧生成的流水号。 兜底机制：一旦我方系统发生故障、数据库数据受损或订单号失效，只要有支付宝官方的 tradeNo，我们依然可以通过官方渠道为用户办理退款或实施人工对账，这是金融级支付方案的必备容错逻辑。 为何引入 CourseRecord 中间表：\nCourse 表是静态的、公共的，所有人看到的课程内容、老师、价格完全一致。 我们绝不能直接修改 Course 表去记录购买状态。引入 CourseRecord 可以将“用户”与“课程”解耦，用来动态记录特定用户对特定课程的生命周期状态（是否购买、学习进度等）。 支付回调与事务（Transaction）流转链路：\n步骤1：用户选定课程点击下单 $\\rightarrow$ 系统创建 PaymentRecord（状态为 NOT_PAY）和 CourseRecord（isPurchased: false）。 步骤2：用户调起支付宝并完成支付 $\\rightarrow$ 支付宝异步通知（Notify）我方后端接口。 步骤3：后端捕获回调数据，获取 outTradeNo 和 tradeNo。 步骤4（核心）：开启数据库事务。将 PaymentRecord 的状态改为 TRADE_SUCCESS，同时将关联的 CourseRecord 中的 isPurchased 改为 true。事务确保这两步操作要么同时成功，要么同时失败，杜绝“付了钱却没解锁课程”的账实不符问题。 四表级联查询的长处： 由于模型间建立了清晰的关联链（User $\\rightarrow$ PaymentRecord $\\rightarrow$ CourseRecord $\\rightarrow$ Course），使用 Prisma 可以非常轻松地实现四表联查。Prisma 会自动将这种深层嵌套的关系平铺、序列化为规整的 JSON 树状结构直接返回给前端，极大地简化了聚合查询的代码量。\n四、 数据库连接与 NestJS 集成 1. 依赖安装 在后端安装 Prisma、PostgreSQL 驱动及环境变量管理插件：\n1 pnpm add dotenv prisma @prisma/client @prisma/adapter-pg 2. 环境变量配置 (.env) 配置 PostgreSQL 的标准连接串（需提前在数据库软件中创建名为 english 的库）：\n1 DATABASE_URL=\u0026#34;postgresql://postgres:123456@localhost:5432/english\u0026#34; 3. 执行数据库迁移与客户端生成 1 2 3 4 5 # 1. 将 schema 文件的结构同步到物理数据库中，并生成初始化迁移记录 npx prisma migrate dev --name init # 2. 根据最新的 schema 重新生成本地的 Prisma Client 代码和类型提示 npx prisma generate 4. 在 NestJS 中封装 Prisma 服务 在 libs/shared/src/prisma/prisma.service.ts 中编写单例服务，利用 @prisma/adapter-pg 驱动驱动连接：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import { Injectable, OnModuleInit, OnModuleDestroy } from \u0026#39;@nestjs/common\u0026#39;; import { PrismaPg } from \u0026#39;@prisma/adapter-pg\u0026#39;; import { PrismaClient } from \u0026#39;../generated/prisma/client\u0026#39;; import { Pool } from \u0026#39;pg\u0026#39;; import \u0026#39;dotenv/config\u0026#39;; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { constructor() { // 创建 pg 连接池 const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const adapter = new PrismaPg(pool); // 将适配器传入父类构造函数 super({ adapter }); } async onModuleInit() { // 在 NestJS 模块初始化时建立连接 await this.$connect(); } async onModuleDestroy() { // 在应用程序关闭时安全释放连接 await this.$disconnect(); } } 五、 海量数据导入脚本（77万条单词 CSV 离线流式写入） 该脚本用于将大型 ecdict.csv（GBK编码）的数据，通过流式读取、编码转换、批量缓存等策略高效安全地写入到 PostgreSQL 中。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 const fs = require(\u0026#39;fs\u0026#39;); const readline = require(\u0026#39;readline\u0026#39;); const iconv = require(\u0026#39;iconv-lite\u0026#39;); const { Pool } = require(\u0026#39;pg\u0026#39;); const { PrismaClient } = require(\u0026#39;./generated/prisma/client\u0026#39;); const { PrismaPg } = require(\u0026#39;@prisma/adapter-pg\u0026#39;); // 初始化带适配器的 Prisma 实例 const pool = new Pool({ connectionString: \u0026#39;postgres://postgres:123456@localhost:5432/english\u0026#39; }); const adapter = new PrismaPg(pool); const prisma = new PrismaClient({ adapter }); /** * 健壮的 CSV 单行解析函数 * 规避了解析内容中包含英文逗号（如 \u0026#34;hello, world\u0026#34;）导致的错位问题 */ function parseCSVLine(line) { const result = []; let current = \u0026#39;\u0026#39;; let inQuotes = false; for (let i = 0; i \u0026lt; line.length; i++) { const char = line[i]; if (char === \u0026#39;\u0026#34;\u0026#39;) { inQuotes = !inQuotes; // 进入或跳出双引号区域 } else if (char === \u0026#39;,\u0026#39; \u0026amp;\u0026amp; !inQuotes) { result.push(current); current = \u0026#39;\u0026#39;; } else { current += char; } } result.push(current); return result; } /** * 将原始的 tag 字符串（例如 \u0026#34;zk gk cet4\u0026#34;）解析为模型所需的布尔字典对象 */ function parseTagToBoolean(tagValue) { const tags = tagValue ? tagValue.split(\u0026#39; \u0026#39;).filter(t =\u0026gt; t.trim() !== \u0026#39;\u0026#39;) : []; return { zk: tags.includes(\u0026#39;zk\u0026#39;), gk: tags.includes(\u0026#39;gk\u0026#39;), cet4: tags.includes(\u0026#39;cet4\u0026#39;), cet6: tags.includes(\u0026#39;cet6\u0026#39;), ky: tags.includes(\u0026#39;ky\u0026#39;), toefl: tags.includes(\u0026#39;toefl\u0026#39;), ielts: tags.includes(\u0026#39;ielts\u0026#39;), gre: tags.includes(\u0026#39;gre\u0026#39;) }; } /** * 流式读取大型 CSV 并执行分批批量插入 */ async function readLargeCSV(filePath) { let lineCount = 0; let headers = []; let batch = []; const BATCH_SIZE = 2000; // 核心性能调优：每 2000 条数据执行一次批量写入 let totalInserted = 0; console.log(\u0026#39;开始读取CSV文件并插入数据库...\\n\u0026#39;); try { // 创建文件读取流，并通过 iconv-lite 将国标码 (GBK) 动态转码为标准 UTF-8 流 const fileStream = fs.createReadStream(filePath) .pipe(iconv.decodeStream(\u0026#39;gbk\u0026#39;)); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { lineCount++; // 第一行：提取表头 if (lineCount === 1) { headers = line.split(\u0026#39;,\u0026#39;); console.log(\u0026#39;表头字段:\u0026#39;, headers); console.log(\u0026#39;=\u0026#39;.repeat(60)); continue; } // 解析当前行 const values = parseCSVLine(line); const rowData = {}; headers.forEach((header, index) =\u0026gt; { rowData[header] = values[index] || \u0026#39;\u0026#39;; }); // 解析标签为布尔值形式 const booleanFields = parseTagToBoolean(rowData.tag); // 组装符合 Prisma 规范的数据对象 const wordData = { word: rowData.word || \u0026#39;\u0026#39;, phonetic: rowData.phonetic || null, definition: rowData.definition || null, translation: rowData.translation || null, pos: rowData.pos || null, collins: rowData.collins || null, oxford: rowData.oxford || null, tag: rowData.tag || null, bnc: rowData.bnc || null, frq: rowData.frq || null, exchange: rowData.exchange || null, ...booleanFields }; batch.push(wordData); // 触发分批写入 if (batch.length \u0026gt;= BATCH_SIZE) { await prisma.wordBook.createMany({ data: batch, skipDuplicates: true // 核心：遇到主键冲突或唯一性冲突自动跳过，保证底层健壮不崩溃 }); totalInserted += batch.length; console.log(`[进度提示] 已成功写入 ${totalInserted.toLocaleString()} 条单词数据...`); batch = []; // 清空缓存区 } } // 插入尾部剩余的不足 BATCH_SIZE 的数据 if (batch.length \u0026gt; 0) { await prisma.wordBook.createMany({ data: batch, skipDuplicates: true }); totalInserted += batch.length; } console.log(\u0026#39;\\n\u0026#39; + \u0026#39;=\u0026#39;.repeat(60)); console.log(` 报告：导入圆满完成！`); console.log(`总共扫描解析文件行数: ${(lineCount - 1).toLocaleString()} 行`); console.log(`实际成功落库单词总数: ${totalInserted.toLocaleString()} 条`); console.log(\u0026#39;=\u0026#39;.repeat(60)); } catch (error) { console.error(\u0026#39; 致命错误:\u0026#39;, error.message); throw new Error(`读取CSV文件失败: ${error.message}`); } finally { // 断开连接，释放资源池 await prisma.$disconnect(); await pool.end(); } } // 启动执行 readLargeCSV(\u0026#39;ecdict.csv\u0026#39;); Photo by Pawel Czerwinski on Unsplash\n","date":"2026-07-11T00:00:00Z","image":"/p/03%E6%95%B0%E6%8D%AE%E5%BA%93%E6%93%8D%E4%BD%9C/cover.jpg","permalink":"/p/03%E6%95%B0%E6%8D%AE%E5%BA%93%E6%93%8D%E4%BD%9C/","title":"03数据库操作"},{"content":"Vue3 项目初始化与路由配置完整笔记 本笔记在保留全部原始代码的基础上，把「从零搭建一个 Vue3 项目」过程中涉及的每一个知识点都展开做了详细讲解：包括清理脚手架、安装并配置各种依赖、目录结构设计、Header 组件、以及路由 / RouterView 嵌套渲染机制（这是本篇重点）。\n目录 项目清理：清空脚手架默认内容 安装依赖（逐个讲清用途） Tailwind CSS 配置 main.ts 入口配置 TypeScript 类型提示配置 目录结构设计 编写 Header 组件 路由配置 RouterView 与嵌套路由渲染机制（重点） 知识点速查总结 一、项目清理：清空脚手架默认内容 1.1 要做什么 用 create-vue（Vue 官方脚手架）生成的项目里，自带了一堆「示例代码」（欢迎页、示例组件、示例样式）。真正开发前，先把这些「样板 / demo」清掉，得到一个干净的起点。具体操作：\n打开 assets 目录：删除 logo.svg，清空 main.css、base.css 两个文件（是清空内容，不是删文件，后面 base.css 还要写 Tailwind 引入）。 删除 components/ 下面的所有文件（那些是脚手架自带的示例组件，如 HelloWorld.vue、TheWelcome.vue 等）。 router/index.ts：把 routes 数组清空（示例路由不要了，后面重写）。 views/ 下的两个文件删除（HomeView.vue、AboutView.vue 示例页面）。 App.vue：删除所有内容，重新初始化（清成一个空壳，后面重写）。 1.2 为什么要先清理 避免干扰：示例代码里有很多引用关系（App 引 components、router 引 views……），不清理会一直报错、也影响阅读。 明确起点：从空白开始按自己的架构组织代码，思路清晰。 注意先后顺序：因为文件之间有引用，清理时容易出现「A 删了但 B 还在引 A」的报错。合理顺序是从「被引用方」往「引用方」清，或者干脆一次性全清完再重写。比如 App.vue 引了 router，router 引了 views，那清理时把它们一起处理，最后统一重建。 App.vue 重新初始化大致长这样（一个最小空壳）：\n1 2 3 4 5 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;RouterView /\u0026gt; \u0026lt;/template\u0026gt; 只保留一个 \u0026lt;RouterView /\u0026gt;（一级路由出口），其余留白，后面再填充。\n二、安装依赖（逐个讲清用途） 所有依赖都用 pnpm 安装。先说 pnpm，再逐个讲每个包。\n2.1 包管理器 pnpm 包管理器是什么：Node 生态里用来「下载、安装、管理第三方库（npm 包）」的工具。常见的有 npm、yarn、pnpm 三个。 pnpm 的特点： 快：安装速度通常比 npm/yarn 快。 省空间：它用「硬链接 + 全局内容寻址存储」的方式管理依赖——同一个版本的包在你电脑上只存一份，各个项目通过链接指向它，不像 npm 每个项目都复制一份，能省下大量磁盘。 依赖更严格（防幽灵依赖）：pnpm 的 node_modules 结构是「非扁平」的，只有你在 package.json 里明确装过的包才能被 import。npm 的扁平结构会把间接依赖也提到顶层，导致你可能「不小心 import 了没装过的包」（叫「幽灵依赖 / phantom dependency」），换个环境就崩。pnpm 从结构上避免了这个问题。 pnpm add xxx：安装一个包并写进 package.json 的 dependencies。（等价于 npm 的 npm install xxx。） 2.2 tailwindcss + @tailwindcss/vite 1 pnpm add tailwindcss @tailwindcss/vite tailwindcss：一个原子化 / 工具类优先（utility-first） 的 CSS 框架。它不给你封装好的「组件」，而是给你成千上万个「小工具类」（如 flex、p-4、text-center、bg-white），你在 HTML 里直接组合这些类来写样式，几乎不用另写 CSS 文件。 好处：不用给每个元素起 class 名、不用在 HTML 和 CSS 文件间来回跳、样式就近可见、天然响应式、打包时会自动删掉没用到的类（体积小）。 @tailwindcss/vite：Tailwind 官方的 Vite 插件。这是 Tailwind v4 的用法（v4 把和构建工具的集成做成了官方插件，配置比 v3 简单很多，不再需要 tailwind.config.js 和 postcss.config.js 那一套繁琐配置）。 2.3 three + @types/three 1 pnpm add three @types/three three：即 Three.js，基于 WebGL 的 3D 图形库（上一篇笔记里登录页左侧的 3D 模型就是用它做的）。用来在网页里渲染 3D 场景、模型、动画。 @types/three：three 的 TypeScript 类型声明包。 为什么要单独装类型？ 有些库本身是用纯 JavaScript 写的，不自带类型定义。TypeScript 项目里用它们时，编辑器就没有类型提示、也无法做类型检查。社区维护了一个叫 DefinitelyTyped 的仓库，专门为这些库补充类型声明，发布成 @types/xxx 的包。装上 @types/three 后，写 Three.js 代码就有完整的类型提示和检查了。 约定：凡是名字形如 @types/xxx 的，都是「给 xxx 这个库补类型的」，一般装在 devDependencies（只开发时用，打包不需要）。 2.4 axios 1 pnpm add axios axios：最流行的 HTTP 请求库，基于 Promise，浏览器和 Node 都能用。用来向后端发接口请求（GET / POST 等）。 相比浏览器原生的 fetch，axios 的优势：自动把响应转成 JSON、更方便的错误处理、请求/响应拦截器（可统一加 token、统一处理错误）、请求取消、超时设置等。项目里通常会基于 axios 封装一个统一的请求实例（对应目录结构里的 apis/index.ts）。 2.5 @microsoft/fetch-event-source 1 pnpm add @microsoft/fetch-event-source @microsoft/fetch-event-source：微软开源的一个库，用来处理 SSE（Server-Sent Events，服务器发送事件） 这种「服务器持续、单向地往客户端推送数据」的流式场景。 用在哪？ 典型就是 AI 对话的「打字机效果」——你问 AI 一个问题，回答是一个字一个字「流」出来的，而不是等全部生成完才一次性显示。这背后就是服务器用 SSE 把生成的内容一段段推过来。（对应目录里的 AI 聊天页 smart/chat。） 为什么不用浏览器原生的 EventSource？ 原生 EventSource 只支持 GET 请求、不能自定义请求头（没法带 Authorization token）、不能发 POST body。而 AI 对话通常需要 POST 提交问题、还要带鉴权头。@microsoft/fetch-event-source 基于 fetch 实现，支持 POST、自定义 header、更灵活的重连控制，正好补足原生的短板。 2.6 marked 1 pnpm add marked marked：一个把 Markdown 文本转换成 HTML 的解析库，速度快、体积小。 用在哪？ 还是 AI 对话场景。大模型返回的内容通常是 Markdown 格式（带 # 标题、**加粗**、代码块、列表等）。直接显示这些原始符号很难看，用 marked 把 Markdown 转成 HTML 再渲染，就能得到排版漂亮的富文本（标题、代码高亮、列表等）。 安全提醒：把「转换后的 HTML」用 v-html 插入页面时要注意 XSS（跨站脚本攻击） 风险——如果内容里混入了恶意 \u0026lt;script\u0026gt;，可能被执行。生产环境建议配合 DOMPurify 之类的库先对 HTML 消毒过滤后再渲染。\n2.7 element-plus + @element-plus/icons-vue 1 2 pnpm add element-plus pnpm add @element-plus/icons-vue element-plus：面向 Vue 3 的一套桌面端 UI 组件库（Element UI 的 Vue3 版）。提供大量开箱即用的组件：按钮、表单、输入框、弹窗、表格、消息提示等。上一篇登录页用到的 el-form、el-input、el-button、ElMessage 都来自它。用它能快速搭出美观、统一的界面，不用自己从零写组件。 @element-plus/icons-vue：Element Plus 的官方图标库，把图标做成了一个个 Vue 组件（如 HomeFilled、Setting、Star）。用的时候 import 进来当组件渲染。注意它是单独的包，需要单独安装。 2.8 pinia-plugin-persistedstate 1 pnpm add pinia-plugin-persistedstate 背景：Pinia 是 Vue3 的官方状态管理库，存放全局共享状态（如登录用户信息）。但 Pinia 的状态是存在内存里的——页面一刷新，内存清空，状态就没了（比如刷新后「登录用户」又变回未登录）。 pinia-plugin-persistedstate：Pinia 的一个持久化插件。它能自动把指定 store 的状态同步保存到 localStorage（或 sessionStorage），页面刷新后再自动读回来恢复。这样用户信息、token 等就能在刷新后保持，不用重新登录。 用法上：先在 main.ts 里 pinia.use(piniaPluginPersistedstate) 启用插件，然后在需要持久化的 store 里开启 persist: true 即可。 小结这一节：这套依赖组合起来就是一个「AI 英语学习 App」的完整技术栈——Tailwind（样式）+ Element Plus（UI 组件）+ Three.js（3D 展示）+ axios（普通接口）+ fetch-event-source（AI 流式对话）+ marked（渲染 AI 返回的 Markdown）+ Pinia 及持久化插件（状态管理 + 刷新不丢失）。\n三、Tailwind CSS 配置 分三步：安装（上面已装）、配置 Vite 插件、写 CSS 入口。\n3.1 配置 Vite 插件 在 vite.config.ts 里把 Tailwind 的插件加进 plugins 数组：\n1 2 3 4 5 6 7 8 import { defineConfig } from \u0026#39;vite\u0026#39; import tailwindcss from \u0026#39;@tailwindcss/vite\u0026#39; export default defineConfig({ plugins: [ tailwindcss(), ], }) 逐点解释：\nvite.config.ts 是什么：Vite（本项目的构建/开发工具）的配置文件。项目怎么启动、用哪些插件、路径别名、代理等都在这里配。 defineConfig(...)：Vite 提供的一个工具函数。它本身不做特别的事，主要作用是提供类型提示——把配置对象包进去，编辑器就能对里面的字段自动补全和类型检查。 plugins: [...]：Vite 的插件数组。插件用来扩展 Vite 的能力。 tailwindcss()：调用刚装的 @tailwindcss/vite 插件（注意要调用，加括号）。加进去后，Vite 在构建时就会处理 Tailwind 的那些工具类，把你用到的类编译成真正的 CSS。 注意：实际项目里这个数组通常还有 Vue 插件等，比如 plugins: [vue(), tailwindcss()]。这里为了聚焦 Tailwind 只写了一个。 Tailwind v3 vs v4 的差别（顺带一提，避免看老教程混淆）：v3 时代要装 postcss、autoprefixer，还要手写 tailwind.config.js 配 content 路径、写 postcss.config.js。v4（本项目用的） 把这些都简化了——一个官方 Vite 插件搞定，配置极少。所以如果你看到教程里让你建 tailwind.config.js，那是 v3 的做法。\n3.2 CSS 入口文件 在 assets/base.css（之前清空的那个）里写：\n1 2 3 4 5 @import \u0026#39;tailwindcss\u0026#39;; html,body,#app{ @apply h-full w-full; } 逐行解释：\n@import 'tailwindcss';：把 Tailwind 的全部样式引入进来。这是 Tailwind v4 的写法——一行 @import 'tailwindcss' 就把「基础重置样式 + 所有工具类」都引进来了。（v3 时代是写三行 @tailwind base; @tailwind components; @tailwind utilities;，v4 合并成了一行 import。）这一行必须放最前面，后面才能用 Tailwind 的功能。 html,body,#app { ... }：一条普通的 CSS 选择器，同时选中 html、body 和 id=\u0026quot;app\u0026quot; 的元素（#app 就是 Vue 应用挂载的根节点）。 @apply h-full w-full;： @apply 是 Tailwind 的指令，作用是「在 CSS 里套用 Tailwind 的工具类」。它把工具类对应的样式「展开」写进这条 CSS 规则里。 h-full = height: 100%，w-full = width: 100%。 所以这段等价于给 html/body/#app 都设 height: 100%; width: 100%;。 为什么要这么做？ 默认情况下 html、body、#app 的高度是「由内容撑开」，并不占满整个屏幕。想让页面布局（尤其是「满屏高度」的布局，如侧边栏、fixed 定位）正常工作，需要让这三层容器都撑满视口的宽高，形成一条「100% 高度」的传递链。这是做整页布局的经典前置设置。 @apply 的意义：它让你能把「一串常用的工具类」封装进一个语义化的 CSS 类里复用，而不用在 HTML 里重复写一长串。不过日常大多数场景直接在 HTML 里写工具类即可，@apply 用于这种「全局基础样式」或「需要复用的样式组合」。 别忘了在 main.ts 里引入这个 css 文件（见下一节 import '@/assets/base.css'），否则样式不会生效。\n四、main.ts 入口配置 main.ts 是整个 Vue 应用的入口文件——应用从这里启动，所有全局的东西（插件、样式、UI 库）都在这里注册。\n4.1 完整代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import \u0026#39;@/assets/base.css\u0026#39; import { createApp } from \u0026#39;vue\u0026#39; import { createPinia } from \u0026#39;pinia\u0026#39; import App from \u0026#39;./App.vue\u0026#39; import router from \u0026#39;./router\u0026#39; import ElementPlus from \u0026#39;element-plus\u0026#39; import zhCn from \u0026#39;element-plus/es/locale/lang/zh-cn\u0026#39; import \u0026#39;element-plus/dist/index.css\u0026#39; import piniaPluginPersistedstate from \u0026#39;pinia-plugin-persistedstate\u0026#39; const app = createApp(App) const pinia = createPinia() pinia.use(piniaPluginPersistedstate) app.use(pinia) app.use(ElementPlus, { locale: zhCn, }) app.use(router) app.mount(\u0026#39;#app\u0026#39;) 4.2 三类 import 分别是什么 ① 引入 CSS（副作用导入）\n1 2 import \u0026#39;@/assets/base.css\u0026#39; import \u0026#39;element-plus/dist/index.css\u0026#39; 这种「只写路径、不解构任何东西」的 import 叫副作用导入。它不导入具体的变量，只是「执行这个文件」——对 CSS 来说，就是把这份样式加载进项目、让它生效。 @/assets/base.css：我们自己写的 Tailwind 入口（@ 是指向 src 的路径别名）。 element-plus/dist/index.css：Element Plus 组件的样式。必须引入，否则 el-button 等组件会「没有样式、丑陋错乱」。 ② 引入功能函数 / 库\n1 2 3 4 import { createApp } from \u0026#39;vue\u0026#39; // 创建应用实例 import { createPinia } from \u0026#39;pinia\u0026#39; // 创建 pinia 实例 import ElementPlus from \u0026#39;element-plus\u0026#39; // 组件库本体 import piniaPluginPersistedstate from \u0026#39;pinia-plugin-persistedstate\u0026#39; // 持久化插件 ③ 引入本项目模块\n1 2 import App from \u0026#39;./App.vue\u0026#39; // 根组件 import router from \u0026#39;./router\u0026#39; // 路由实例（会自动找 router/index.ts） import router from './router'：注意路径是目录 ./router，没写文件名。这是模块解析的默认规则——引入一个目录时，会自动去找该目录下的 index.ts（或 index.js）。所以它实际引的是 router/index.ts 里 export default 出去的路由实例。 ④ 中文语言包\n1 import zhCn from \u0026#39;element-plus/es/locale/lang/zh-cn\u0026#39; Element Plus 默认是英文（比如分页、日期选择器、表格里的文字都是英文）。这里引入它自带的中文（简体）语言包 zhCn，后面配置给组件库，界面文字就变成中文了。这属于 i18n（国际化，internationalization） 的一部分。 4.3 创建应用与 Pinia 1 2 3 const app = createApp(App) // 用根组件 App 创建 Vue 应用实例 const pinia = createPinia() // 创建 Pinia（状态管理）实例 pinia.use(piniaPluginPersistedstate) // 给 Pinia 挂上持久化插件 createApp(App)：Vue3 的应用创建函数。传入根组件 App，返回一个应用实例 app。之后所有全局配置（装插件、注册全局组件等）都通过这个 app 来做。这是 Vue3 相比 Vue2 的变化——Vue3 是「每个应用一个独立实例」，配置都挂在实例上，不再是全局污染。 createPinia()：创建一个 Pinia 实例（状态管理的「总仓库」）。 pinia.use(piniaPluginPersistedstate)：给这个 Pinia 实例安装持久化插件。装完后，各个 store 才能使用 persist 持久化能力。注意顺序：必须先给 pinia 装插件，再把 pinia 交给 app（下一步）。 4.4 注册插件（app.use）与挂载 1 2 3 4 5 app.use(pinia) // 启用 Pinia app.use(ElementPlus, { locale: zhCn }) // 启用 Element Plus，并设为中文 app.use(router) // 启用路由 app.mount(\u0026#39;#app\u0026#39;) // 把应用挂载到页面 app.use(插件, 选项?)：Vue 的插件安装方法。把一个插件注册到应用上，让它的功能在整个应用中可用。 app.use(pinia)：启用状态管理。 app.use(ElementPlus, { locale: zhCn })：启用 Element Plus，第二个参数是配置项，{ locale: zhCn } 就是把语言设为前面引入的中文包。这样组件内置文字全变中文。 app.use(router)：启用 Vue Router，之后才能用 \u0026lt;RouterView\u0026gt;、\u0026lt;RouterLink\u0026gt;、useRouter() 等。 app.use 支持链式调用（每次返回 app 自身），也可以写成 app.use(pinia).use(ElementPlus,...).use(router)。分开写更清晰。 app.mount('#app')：挂载。把整个 Vue 应用「装」到页面上 id=\u0026quot;app\u0026quot; 的那个 DOM 元素里（就是 index.html 里的 \u0026lt;div id=\u0026quot;app\u0026quot;\u0026gt;\u0026lt;/div\u0026gt;）。执行这一步后，页面才真正渲染出来。mount 必须放最后——所有插件都 use 完了再挂载。 记住这个固定套路：createApp → 创建各种实例/插件 → 一堆 app.use(...) → 最后 app.mount(...)。顺序不能乱，尤其 mount 一定在最后。\n五、TypeScript 类型提示配置 在 tsconfig.app.json 里添加一项配置：\n1 2 3 4 5 6 { \u0026#34;compilerOptions\u0026#34;: { // ...其他配置 \u0026#34;types\u0026#34;: [\u0026#34;element-plus/global\u0026#34;] } } tsconfig.app.json 是什么：TypeScript 的配置文件之一。Vite + Vue 项目通常把 TS 配置拆成几个：tsconfig.json（总入口，引用其他两个）、tsconfig.app.json（管应用源码 src）、tsconfig.node.json（管 vite.config.ts 这类 Node 环境代码）。这里改的是管 src 源码的那个。 \u0026quot;types\u0026quot;: [\u0026quot;element-plus/global\u0026quot;]： 作用：让 Element Plus 的组件（\u0026lt;el-button\u0026gt;、\u0026lt;el-form\u0026gt; 等）在模板里有全局类型提示和检查。加了这个之后，编辑器能识别这些标签、提示它们的属性（props）、发现用错的地方。 为什么需要：Element Plus 组件是全局注册使用的（模板里直接写 \u0026lt;el-xxx\u0026gt;），TypeScript 默认不知道这些全局标签对应什么类型。element-plus/global 这个类型声明就是告诉 TS「这些全局组件长什么样」。 一个坑：types 字段一旦手动指定，TypeScript 就只加载你列出的这些类型包，而不再自动加载 node_modules/@types 下的全部。所以如果项目还依赖别的全局类型（如 node），可能需要一并写进数组：\u0026quot;types\u0026quot;: [\u0026quot;element-plus/global\u0026quot;, \u0026quot;node\u0026quot;]。本例只列了 element-plus/global，按需补充即可。 六、目录结构设计 清理干净后，按功能职责规划 src 目录：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 src/ ├── apis/ # 统一管理后端接口请求 │ └── index.ts # 接口初始化与请求实例配置 ├── assets/ # 静态资源目录（图片、样式、字体等） ├── components/ # 全局通用公共组件 ├── hooks/ # Vue 3 自定义组合式函数（Custom Hooks） ├── layout/ # 页面整体框架布局 │ ├── Content/ # 主体内容区域 │ ├── Header/ # 顶栏组件 │ ├── Profile/ # 用户个人信息 │ └── index.vue # 布局组件入口 ├── router/ # 路由配置目录（定义页面映射关系） ├── stores/ # 全局状态管理目录（如 Pinia / Vuex） ├── views/ # 业务页面组件（具体的路由页面） └── App.vue # 项目的顶级根组件 6.1 为什么要这样分目录 「按职责分层」是工程化项目的基本功。 目录清晰，团队协作、后期维护、找代码都更高效。每个目录的职责边界要明确——一看目录名就知道该放什么、该去哪找。逐个说明：\napis/：接口层。所有和后端通信的请求都集中在这里管理，而不是散落在各个组件里。index.ts 里通常会创建并配置 axios 实例（设置 baseURL、超时、请求/响应拦截器等），再按业务模块拆分成 user.ts、word.ts 等。这样接口地址变了、要统一加 token、要统一处理错误时，只改这一层即可。 assets/：静态资源。图片、全局样式（如 base.css）、字体等。（注意与 public/ 的区别：assets 里的资源会被构建工具处理和打包；public 里的原样输出——上一篇讲过。） components/：全局通用组件。那些跨页面复用、不属于某个具体业务页面的组件放这（如上一篇的登录弹窗 Login）。 hooks/：自定义组合式函数。以 use 开头、封装可复用逻辑的函数（如上一篇的 useLogin）。 layout/：布局层。放「页面的整体框架骨架」，即那些每个页面都共用的结构（顶栏、侧边、内容区外壳）。里面又细分： Header/：顶栏。 Content/：主体内容区（内部藏着第二层路由出口 RouterView，见第九节）。 Profile/：用户信息区。 index.vue：布局入口，把上面这些拼成完整布局。 router/：路由配置。定义「URL ↔ 页面组件」的映射关系。 stores/：状态管理。Pinia 的各个 store（如用户 store）。 views/：业务页面。真正对应各个路由的「页面级组件」（主页、词库页、AI 页等）。它和 components/ 的区别：views 是「页面」（和路由一一对应，是路由的落点）；components 是「零件」（被页面或布局组合使用，不直接对应路由）。 App.vue：根组件，整棵组件树的顶点。 一句话记忆：views 装页面、components 装零件、layout 装骨架、apis 管接口、stores 管状态、hooks 管复用逻辑、router 管跳转映射。职责单一、各就各位。\n七、编写 Header 组件 Header 是顶栏（layout/Header/index.vue），包含 Logo、导航菜单、积分显示、用户头像。\n7.1 完整代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 \u0026lt;template\u0026gt; \u0026lt;header class=\u0026#34;flex items-center h-20 border-b border-gray-200 justify-center sticky top-0 bg-white z-10\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;w-[1200px] mx-auto flex items-center justify-between\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;text-2xl font-bold bg-indigo-700 text-white rounded-[10px] px-2 py-1 w-10 flex items-center justify-center h-10 \u0026#34;\u0026gt; E\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;text-2xl font-bold\u0026#34;\u0026gt;English App\u0026lt;/div\u0026gt; \u0026lt;div @click=\u0026#34;router.push(\u0026#39;/\u0026#39;)\u0026#34; class=\u0026#34;flex items-center gap-2 cursor-pointer rounded-[10px] px-2 py-1 text-gray-500\u0026#34;\u0026gt; \u0026lt;el-icon\u0026gt;\u0026lt;HomeFilled /\u0026gt;\u0026lt;/el-icon\u0026gt; \u0026lt;span\u0026gt;主页\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div @click=\u0026#34;router.push(\u0026#39;/smart/chat\u0026#39;)\u0026#34; class=\u0026#34;flex items-center gap-2 cursor-pointer text-gray-500\u0026#34;\u0026gt; \u0026lt;el-icon\u0026gt;\u0026lt;MagicStick /\u0026gt;\u0026lt;/el-icon\u0026gt; \u0026lt;span\u0026gt;AI\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div @click=\u0026#34;router.push(\u0026#39;/word-book/index\u0026#39;)\u0026#34; class=\u0026#34;flex items-center gap-2 cursor-pointer text-gray-500\u0026#34;\u0026gt; \u0026lt;el-icon\u0026gt;\u0026lt;Notebook /\u0026gt;\u0026lt;/el-icon\u0026gt; \u0026lt;span\u0026gt;词库\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div @click=\u0026#34;router.push(\u0026#39;/courses/index\u0026#39;)\u0026#34; class=\u0026#34;flex items-center gap-2 cursor-pointer text-gray-500\u0026#34;\u0026gt; \u0026lt;el-icon\u0026gt;\u0026lt;Reading /\u0026gt;\u0026lt;/el-icon\u0026gt; \u0026lt;span\u0026gt;课程\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div @click=\u0026#34;router.push(\u0026#39;/setting/index\u0026#39;)\u0026#34; class=\u0026#34;flex items-center gap-2 cursor-pointer text-gray-500\u0026#34;\u0026gt; \u0026lt;el-icon\u0026gt;\u0026lt;Setting /\u0026gt;\u0026lt;/el-icon\u0026gt; \u0026lt;span\u0026gt;设置\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;flex items-center gap-2 bg-blue-200 text-blue-700 rounded-full px-2 py-1\u0026#34;\u0026gt; \u0026lt;el-icon\u0026gt;\u0026lt;Sunny /\u0026gt;\u0026lt;/el-icon\u0026gt; \u0026lt;span class=\u0026#34;font-bold text-sm\u0026#34;\u0026gt;{{ 0 }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;flex items-center gap-2 bg-amber-200 text-amber-700 rounded-full px-2 py-1\u0026#34;\u0026gt; \u0026lt;el-icon\u0026gt;\u0026lt;Star /\u0026gt;\u0026lt;/el-icon\u0026gt; \u0026lt;span class=\u0026#34;font-bold text-sm\u0026#34;\u0026gt;{{ 0 }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;flex items-center gap-2 border-l cursor-pointer border-gray-200 pl-4\u0026#34;\u0026gt; \u0026lt;img class=\u0026#34;w-10 h-10 rounded-full ml-2 mr-2\u0026#34; src=\u0026#34;https://gips3.baidu.com/it/u=3493347002,3356558679\u0026amp;fm=3074\u0026amp;app=3074\u0026amp;f=PNG?w=2048\u0026amp;h=2048\u0026#34; /\u0026gt; \u0026lt;span class=\u0026#34;text-sm font-bold\u0026#34;\u0026gt;{{ \u0026#39;未登录\u0026#39; }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/header\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import { Sunny, Star, HomeFilled, Notebook, MagicStick, Reading, Setting } from \u0026#39;@element-plus/icons-vue\u0026#39; import { useRouter } from \u0026#39;vue-router\u0026#39;; const router = useRouter() \u0026lt;/script\u0026gt; 7.2 脚本部分 1 2 3 import { Sunny, Star, HomeFilled, Notebook, MagicStick, Reading, Setting } from \u0026#39;@element-plus/icons-vue\u0026#39; import { useRouter } from \u0026#39;vue-router\u0026#39;; const router = useRouter() 引入图标：从 @element-plus/icons-vue 按需引入用到的图标组件（HomeFilled 主页、MagicStick 魔法棒代表 AI、Notebook 笔记本代表词库、Reading 阅读代表课程、Setting 设置、Sunny 太阳、Star 星星）。引入后就能在模板里当组件用。 useRouter()：拿到路由器实例，用于编程式导航（在代码里跳转，而不是点链接）。 7.3 \u0026lt;el-icon\u0026gt; 图标容器 1 \u0026lt;el-icon\u0026gt;\u0026lt;HomeFilled /\u0026gt;\u0026lt;/el-icon\u0026gt; el-icon 是什么：Element Plus 提供的图标容器组件。它本身不是具体图标，而是一个「包裹层」，负责给里面的图标统一控制尺寸、颜色、对齐等。 为什么图标要套一层 el-icon：@element-plus/icons-vue 里的图标本质是 SVG 组件。直接用可能大小/颜色不好控制、和文字对齐也不方便。套上 el-icon 后，Element Plus 会帮你处理好这些（默认继承字体大小和颜色、垂直居中对齐文字），用起来更省心、更统一。 用法就是把具体图标组件作为默认插槽放进去：\u0026lt;el-icon\u0026gt;\u0026lt;具体图标 /\u0026gt;\u0026lt;/el-icon\u0026gt;。 7.4 编程式导航 router.push 1 2 \u0026lt;div @click=\u0026#34;router.push(\u0026#39;/\u0026#39;)\u0026#34;\u0026gt;...主页...\u0026lt;/div\u0026gt; \u0026lt;div @click=\u0026#34;router.push(\u0026#39;/smart/chat\u0026#39;)\u0026#34;\u0026gt;...AI...\u0026lt;/div\u0026gt; @click=\u0026quot;...\u0026quot;：Vue 的事件绑定（@click 是 v-on:click 的简写），点击时执行里面的表达式。 router.push('/xxx')：编程式导航——用代码控制页面跳转到指定路由。这里点哪个导航项就 push 到对应路径。 router.push vs \u0026lt;router-link\u0026gt;（两种导航方式）： \u0026lt;router-link to=\u0026quot;/xxx\u0026quot;\u0026gt; 是声明式导航，写在模板里，渲染成 \u0026lt;a\u0026gt; 标签，适合「就是个链接」的场景。 router.push('/xxx') 是编程式导航，写在 JS 里，适合「需要在逻辑中跳转」的场景（如登录成功后跳首页、点击某个 div 跳转）。这里因为导航项是 \u0026lt;div\u0026gt; 而非链接、且是点击触发，所以用 router.push。 router.push 的其他形式：除了传字符串路径，还能传对象 router.push({ name: 'home' })（按路由名跳）、router.push({ path: '/user', query: { id: 1 } })（带查询参数）。相关的还有 router.replace()（替换当前历史记录，不留返回）、router.back()（后退）等。 7.5 关键 Tailwind 类详解 ① \u0026lt;header\u0026gt; 上的类\n1 flex items-center h-20 border-b border-gray-200 justify-center sticky top-0 bg-white z-10 flex：弹性布局。 items-center：交叉轴（垂直方向）居中——让 header 内元素垂直居中。 justify-center：主轴（水平方向）居中——让里面那个 1200px 宽的容器整体水平居中。 h-20：高度 5rem（80px）。Tailwind 的间距单位里 1 = 0.25rem = 4px，所以 20 = 80px。 border-b：只加下边框（bottom border）。border-gray-200：边框颜色浅灰。合起来是顶栏底部一条浅灰分割线。 sticky top-0：粘性定位（重点）。position: sticky + top: 0 的组合意思是：正常情况下 header 跟着页面走，但当页面向下滚动、header 即将滚出视口顶部时，它会「粘」在顶部 top: 0 的位置不动了。效果就是顶栏始终吸附在页面顶部可见（常见的「滚动时导航栏固定」效果）。 bg-white：白色背景（很重要——粘住后如果没背景色，滚动时下面的内容会透过来叠在一起）。 z-10：z-index: 10，层叠层级。保证 header 浮在下方内容之上（滚动时内容不会盖住吸顶的 header）。 sticky vs fixed 的区别：fixed 是「彻底脱离文档流、死死钉在视口某处」；sticky 是「平时正常在文档流里，滚到临界点才吸附」。做吸顶导航用 sticky top-0 更自然（它会占位、不会像 fixed 那样把下面内容顶上去遮住）。\n② 内层容器\n1 \u0026lt;div class=\u0026#34;w-[1200px] mx-auto flex items-center justify-between\u0026#34;\u0026gt; w-[1200px]：固定宽度 1200px（[] 任意值语法）。这是定宽居中布局——内容限制在 1200px 内，两侧留白，大屏上不至于拉得太宽。 mx-auto：margin-left: auto; margin-right: auto，水平居中这个定宽容器（左右外边距自动均分）。 flex items-center justify-between：内部弹性布局、垂直居中；justify-between 让子元素「两端对齐、间隔均分」——Logo 靠左、用户头像靠右，中间导航项均匀分布。 ③ Logo 方块\n1 \u0026lt;div class=\u0026#34;text-2xl font-bold bg-indigo-700 text-white rounded-[10px] px-2 py-1 w-10 h-10 flex items-center justify-center\u0026#34;\u0026gt;E\u0026lt;/div\u0026gt; text-2xl：字号 1.5rem。font-bold：加粗。 bg-indigo-700：靛蓝背景。text-white：白字。 rounded-[10px]：圆角 10px。 px-2 py-1：水平内边距 0.5rem、垂直内边距 0.25rem（p = padding，x/y 分别是水平/垂直）。 w-10 h-10：宽高各 40px，做成一个正方形。 flex items-center justify-center：让里面的字母「E」水平垂直都居中。 ④ 导航项通用类\n1 \u0026lt;div class=\u0026#34;flex items-center gap-2 cursor-pointer text-gray-500\u0026#34;\u0026gt; flex items-center：让图标和文字在一行垂直居中排列。 gap-2：flex/grid 子元素之间的间距 0.5rem（图标和文字之间留点空）。gap 比用 margin 更省心。 cursor-pointer：鼠标悬停时变成「手型」指针，提示这个 div 可以点击（因为它不是原生 \u0026lt;a\u0026gt;/\u0026lt;button\u0026gt;，默认不会有手型，需要手动加）。 text-gray-500：文字灰色。 ⑤ 积分徽标（药丸形）\n1 2 3 \u0026lt;div class=\u0026#34;flex items-center gap-2 bg-blue-200 text-blue-700 rounded-full px-2 py-1\u0026#34;\u0026gt; \u0026lt;el-icon\u0026gt;\u0026lt;Sunny /\u0026gt;\u0026lt;/el-icon\u0026gt; \u0026lt;span class=\u0026#34;font-bold text-sm\u0026#34;\u0026gt;{{ 0 }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; rounded-full：完全圆角（半径极大），把矩形变成「胶囊/药丸」形状。配合内边距形成一个圆润的徽标。 bg-blue-200 text-blue-700：浅蓝底 + 深蓝字（一种协调的配色手法：同色系，背景浅、文字深）。旁边那个用 bg-amber-200 text-amber-700（琥珀/橙色系）同理。 text-sm：小号字。 {{ 0 }}：Vue 的插值语法（双大括号），把里面的表达式的值渲染成文本。这里暂时写死 0（占位），将来会替换成从 store 里取的真实数据（如打卡天数、单词数）。 ⑥ 用户头像区\n1 2 3 4 \u0026lt;div class=\u0026#34;flex items-center gap-2 border-l cursor-pointer border-gray-200 pl-4\u0026#34;\u0026gt; \u0026lt;img class=\u0026#34;w-10 h-10 rounded-full ml-2 mr-2\u0026#34; src=\u0026#34;https://...\u0026#34; /\u0026gt; \u0026lt;span class=\u0026#34;text-sm font-bold\u0026#34;\u0026gt;{{ \u0026#39;未登录\u0026#39; }}\u0026lt;/span\u0026gt; \u0026lt;/div\u0026gt; border-l：只加左边框（left border），border-gray-200 浅灰。pl-4：左内边距 1rem。合起来是在头像区左侧加一条竖分割线并留出间距，把它和前面的导航区分隔开。 \u0026lt;img\u0026gt; 上的 w-10 h-10 rounded-full：40×40 的圆形头像（rounded-full 把方图裁成圆）。ml-2 mr-2：左右外边距。 {{ '未登录' }}：同样是插值，暂时写死「未登录」占位，登录后会换成用户名。 本组件目前是「静态占位」版：积分 {{ 0 }}、用户名 {{ '未登录' }} 都是写死的。后续接入 Pinia 用户 store 后，会把它们换成真实的响应式数据（{{ userStore.user?.dayNumber }}、{{ userStore.user?.name ?? '未登录' }} 之类）。\n八、路由配置 路由采用模块化拆分：每个业务模块（home、word-book……）单独一个路由文件，最后在 router/index.ts 里合并。\n8.1 layout 布局组件（index.vue） 1 2 3 4 5 6 7 8 9 10 \u0026lt;!-- layout/index.vue --\u0026gt; \u0026lt;template\u0026gt; \u0026lt;Header /\u0026gt; \u0026lt;Content /\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import Header from \u0026#34;./Header/index.vue\u0026#34;; import Content from \u0026#34;./Content/index.vue\u0026#34;; \u0026lt;/script\u0026gt; layout 是所有页面共用的「布局外壳」，由 Header（顶栏）和 Content（内容区）组成。 关键：layout 自己不含 RouterView，它只是把 Header 和 Content 拼起来。真正的「二级路由出口」藏在 Content 组件内部（见第九节）。 因为它是每个页面都要的外壳，所以在路由里被设为父路由的 component，各个页面作为它的 children。 8.2 Home 路由（静态引入） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // router/home/index.ts import layout from \u0026#39;@/layout/index.vue\u0026#39; import Home from \u0026#39;@/views/Home/index.vue\u0026#39; export default [ { path: \u0026#39;/\u0026#39;, component: layout, // 父路由用 layout 布局 children: [ { path: \u0026#34;/\u0026#34;, component: Home // 子路由渲染 Home 页面 } ] } ] 这个文件 export default 一个路由记录数组（本模块的路由）。 结构是「一个父路由 + children 子路由」：父路由 path: '/' 用 layout 布局，子路由也是 '/' 渲染 Home。访问 / 时，先渲染 layout，再在其内部渲染 Home。 注意 Home 是「静态引入」：import Home from '@/views/Home/index.vue' 在文件顶部直接 import。这意味着 Home 组件的代码会被打包进首屏的主包、应用一启动就加载。 8.3 WordBook 路由（动态引入 / 懒加载）——重点 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // router/word-book/index.ts import layout from \u0026#39;@/layout/index.vue\u0026#39; export default [ { path: \u0026#39;/word-book\u0026#39;, component: layout, children: [ { path: \u0026#34;index\u0026#34;, component: () =\u0026gt; import(\u0026#39;@/views/WordBook/index.vue\u0026#39;) // 动态引入 } ] } ] 结构和 home 一样（父 layout + 子页面），但子路由的 component 写法不同： Home：component: Home（静态，顶部 import 好的组件）。 WordBook：component: () =\u0026gt; import('@/views/WordBook/index.vue')（动态引入，一个返回 import() 的箭头函数）。 什么是「动态引入 / 路由懒加载」？\n() =\u0026gt; import('...') 是一个函数，里面用了 动态 import()。动态 import 返回一个 Promise，会在被调用时才去异步加载那个模块。 在路由里，component 传这样一个函数，Vue Router 就会等真正访问这个路由时，才去下载对应组件的代码，而不是一开始就全部下载。 构建工具（Vite）看到动态 import，会把这个组件单独拆成一个 JS 文件（chunk），实现代码分割（code splitting）。 为什么 Home 用静态、WordBook 用动态？（原文的关键点）\nHome 是「一进来就展示」的首屏页面。它反正一开始就要用，懒加载没意义（甚至反而多一次请求延迟）。所以直接静态引入，打进主包。 WordBook 不是首屏必需——用户不一定会点词库。如果也打进主包，会白白增大首屏体积、拖慢首屏加载。用动态引入后，词库的代码被单独拆出去，只有用户真的点进词库时才下载。这样能减轻首屏加载负担，首屏更快。 一句话总结策略：首屏必用的页面 → 静态引入（打进主包，秒开）；非首屏、按需访问的页面 → 动态引入懒加载（拆包，用到才下载，减小首屏体积）。这是前端性能优化里非常常用的手段。\n8.4 router/index.ts 汇总 1 2 3 4 5 6 7 8 9 10 11 12 13 import { createRouter, createWebHistory } from \u0026#39;vue-router\u0026#39; import home from \u0026#39;./home/index\u0026#39; import wordBook from \u0026#39;./word-book/index\u0026#39; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ ...home, // 主页 ...wordBook // 单词本 ], }) export default router 8.5 createRouter 与 createWebHistory 1 2 3 4 const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ ... ], }) createRouter(配置)：Vue Router 4（配 Vue3）创建路由实例的函数。配置对象里最核心的两项是 history（路由模式）和 routes（路由表）。 history: createWebHistory(...)：指定路由的历史模式。createWebHistory 表示使用 HTML5 History 模式（下一节详解）。 import.meta.env.BASE_URL： import.meta.env 是 Vite 提供的环境变量入口。BASE_URL 是应用部署的「基础路径」（在 vite 配置的 base 决定，默认是 /）。 把它传给 createWebHistory，是为了让路由知道应用挂在什么根路径下。如果你的应用不是部署在域名根目录（比如部署在 https://xxx.com/app/ 下），改了 base，路由才能正确工作。默认 / 时就是根目录。 8.6 History 模式 vs Hash 模式 Vue Router 有两种主要的历史模式：\ncreateWebHistory（History 模式，本项目用） createWebHashHistory（Hash 模式） URL 形式 https://xxx.com/word-book/index https://xxx.com/#/word-book/index 好看程度 干净、无 # 带 #，略丑 原理 用 HTML5 History API（pushState）改 URL 用 URL 里 # 后面的 hash 部分 服务器配置 需要后端配合：把所有路径都指回 index.html，否则刷新子路由会 404 不需要额外配置（# 后面的内容不会发给服务器） createWebHistory 的注意点：因为 URL 是真实路径（没有 #），用户在 /word-book/index 刷新页面时，浏览器会拿这个路径去请求服务器。而这是个单页应用，服务器上并没有 /word-book/index 这个真实文件，会返回 404。解决办法是在部署时配置服务器（Nginx、Vercel 等），让所有未匹配的路径都返回 index.html，交给前端路由处理。这是 History 模式部署时最常见的坑，务必记住。 Hash 模式没这个问题（# 后面的部分浏览器不会发给服务器），但 URL 带 # 不够美观。现代项目多用 History 模式 + 服务器配置。 8.7 嵌套路由 children 1 2 3 4 5 6 7 { path: \u0026#39;/word-book\u0026#39;, component: layout, children: [ { path: \u0026#39;index\u0026#39;, component: () =\u0026gt; import(\u0026#39;@/views/WordBook/index.vue\u0026#39;) } ] } children 让路由形成一棵树，而不是一份平铺的列表。父路由（layout）下面挂子路由（具体页面）。 路径拼接规则：子路由的 path 不以 / 开头时是「相对路径」，会拼在父路径后面。这里父 path: '/word-book' + 子 path: 'index' → 实际访问路径是 /word-book/index。 如果子路由 path 以 / 开头（像 home 里子路由写的 '/'），则是绝对路径，不拼接父级。（home 里父子都是 '/'，所以最终就是 /。） 为什么要嵌套？ 因为多个页面（Home、WordBook）共享同一个外壳（layout：Header + Content），把 layout 作为父、页面作为 children，就能实现「外壳固定、内容区随路由切换」——这正是第九节要深入讲的 RouterView 嵌套机制。 8.8 展开运算符合并路由 1 2 3 4 routes: [ ...home, // 展开 home 模块导出的数组 ...wordBook // 展开 word-book 模块导出的数组 ] home 和 wordBook 各自 export default 的是数组。 ...（展开运算符） 把数组里的元素「摊平」拼进新数组。[...home, ...wordBook] 就是把两个模块的路由记录合并成一份完整的 routes 列表。 好处：路由模块化。每个业务模块的路由独立在自己的文件里维护，新增一个模块只需：新建该模块路由文件 → 在 index 里 import 并 ... 进 routes。互不干扰、清晰可扩展。想象一下如果所有路由都堆在一个文件里，几十个页面会非常难维护。 九、RouterView 与嵌套路由渲染机制（重点） 这一节把「路由是怎么一层层渲染出来的」彻底讲清楚。理解它，你就明白为什么切换页面时顶栏 Header 一直不动、只有内容区在换。\n9.1 路由表长什么样 1 2 3 router/index.ts ├── home/index.ts → { path: \u0026#39;/\u0026#39;, component: layout, children: [{ path: \u0026#39;/\u0026#39;, component: Home }] } └── word-book/index.ts → { path: \u0026#39;/word-book\u0026#39;, component: layout, children: [{ path: \u0026#39;index\u0026#39;, component: WordBook }] } 两个模块各自导出一个「路由记录数组」，在 router/index.ts 里用展开运算符 ...home、...wordBook 合并成一份完整的 routes 列表，交给 createRouter 生成路由实例。\n关键点：每一条路由记录都可以有 children，形成一棵树，而不是一份平铺列表。layout 是所有页面共用的父级，Home/WordBook 是挂在它下面的子路由。\n9.2 RouterView 是「按层级对应」的插槽 先建立核心概念：\u0026lt;RouterView /\u0026gt; 是一个占位插槽，它的作用是「在这里渲染当前路由匹配到的组件」。而当路由有嵌套（children）时，就需要多层 RouterView——组件树里有几层嵌套的 \u0026lt;RouterView/\u0026gt;，路由匹配到的链路就能渲染几层。\n本项目是两层：\n1 2 3 App.vue ── 第 1 层 \u0026lt;RouterView/\u0026gt; ← 渲染「父路由」匹配到的组件 └─ layout (Header + Content) └─ Content ── 第 2 层 \u0026lt;RouterView/\u0026gt; ← 渲染「子路由」匹配到的组件 特别注意一个容易误解的点：layout 本身不含 RouterView，它只是布局壳（Header + Content）。真正的第二层插槽藏在 Content 组件内部。所以视觉上看起来像是「layout 里有 RouterView」，实际上是它的子组件 Content 代为持有这个二级路由出口。\n也就是说，Content 组件内部大致长这样：\n1 2 3 4 5 6 \u0026lt;!-- layout/Content/index.vue --\u0026gt; \u0026lt;template\u0026gt; \u0026lt;main class=\u0026#34;w-[1200px] mx-auto\u0026#34;\u0026gt; \u0026lt;RouterView /\u0026gt; \u0026lt;!-- 这就是第 2 层路由出口，子页面在这里渲染 --\u0026gt; \u0026lt;/main\u0026gt; \u0026lt;/template\u0026gt; 而根组件 App.vue 里是第一层：\n1 2 3 4 \u0026lt;!-- App.vue --\u0026gt; \u0026lt;template\u0026gt; \u0026lt;RouterView /\u0026gt; \u0026lt;!-- 第 1 层路由出口，父路由（layout）在这里渲染 --\u0026gt; \u0026lt;/template\u0026gt; 层级对应关系（务必记住）：\nApp.vue 里的 RouterView → 渲染匹配链的第 0 层（父路由 layout）。 Content 里的 RouterView → 渲染匹配链的第 1 层（子路由，如 Home / WordBook）。 有几层嵌套路由，就要在对应的组件里放几层 RouterView，一一对应。\n9.3 匹配过程：一次性解析整条链，而不是逐层触发 这是理解嵌套路由最关键的一点：访问一个 URL 时，vue-router 不是「先匹配第一层、渲染出来，再去找第二层该渲染什么」这种「走一步看一步」的方式；而是先把整条匹配链路一次性算出来，再自上而下渲染。\n以访问 /word-book/index 为例，完整过程：\nrouter 遍历路由表，找到 path: '/word-book' 这条记录，前缀匹配上了。 继续在它的 children 里找剩余路径 index，找到 { path: 'index', component: WordBook }。 得到匹配链：[layout, WordBook]。这个数组的顺序，就对应 RouterView 的嵌套层级。 渲染时（自上而下）： 第 1 层 RouterView（在 App.vue）拿链路里的第 0 个 → 渲染 layout。 layout 渲染出 Header + Content。 第 2 层 RouterView（在 Content 里）拿链路里的第 1 个 → 渲染 WordBook。 访问 / 的情况完全一样，只是匹配链是 [layout, Home]。\n重点结论：不存在「先展示 layout、过一会儿才补上 WordBook」的先后顺序——两层是在同一次渲染里一起确定、一起挂载的。router 是先把「这条 URL 要渲染哪几层、分别是什么组件」整条算清楚，再一次性把整棵子树渲染出来。\n为什么强调这点？ 很多人误以为嵌套路由是「父先渲染、触发子再渲染」的链式过程，从而在时序上产生错误理解（比如担心「子路由拿不到父的数据」「会闪一下」）。实际上匹配是一次性解析全链，渲染是同步的整体渲染，心里要有这个正确模型。\n9.4 为什么切页面时 Header 不会闪烁 / 重建 这是嵌套布局最实用的好处，也是原理的直接体现。\n从 / 跳到 /word-book/index 时：\n旧匹配链：[layout, Home] 新匹配链：[layout, WordBook] 对比两条链，第 0 层组件都是同一个 layout（同一个组件定义的引用）。Vue Router 发现「父层组件没变，只是子层变了」，于是只会：\n保留 layout 组件实例不动（连同它里面的 Header 一起，实例不销毁、不重建）。 只把 Content 内部第 2 层 RouterView 渲染的内容从 Home 换成 WordBook。 这就是为什么点击词库按钮，Header 始终存在、只有内容区在切换——本质是同一棵组件树，只重新渲染发生变化的那一层插槽，而不是整个页面推倒重来。\n带来的实际收益：\n性能好：Header（及其内部状态、事件监听、可能的定时器）不会被反复销毁重建。 体验好：顶栏不闪烁、不跳动，滚动位置等也能保持。 状态保持：如果 Header 里有本地状态（如某个下拉菜单的展开状态），切页面时不会丢失。 延伸：如果连子组件也想在切换时「保活」（不销毁、保留状态），可以用 \u0026lt;KeepAlive\u0026gt; 把 RouterView 包起来：\u0026lt;RouterView v-slot=\u0026quot;{ Component }\u0026quot;\u0026gt;\u0026lt;KeepAlive\u0026gt;\u0026lt;component :is=\u0026quot;Component\u0026quot; /\u0026gt;\u0026lt;/KeepAlive\u0026gt;\u0026lt;/RouterView\u0026gt;。这样被切走的页面组件会被缓存而非销毁，再切回来时保持原状。本项目未用，了解即可。\n9.5 一张图总结 1 2 3 4 5 6 7 8 9 10 11 12 13 URL: /word-book/index │ ▼ router 匹配路由表，一次性得到链路: [ layout , WordBook ] │ ▼ App.vue \u0026lt;RouterView/\u0026gt; ①────────► 渲染 layout │ ┌──────┴───────┐ ▼ ▼ \u0026lt;Header/\u0026gt; \u0026lt;Content/\u0026gt; │ \u0026lt;RouterView/\u0026gt; ②───────► 渲染 WordBook ① 对应链路第 0 层，② 对应链路第 1 层，两层同时由一次路由匹配结果驱动。\n换页面（/ → /word-book/index）时，① 处的 layout 不变（Header 稳如泰山），只有 ② 处从 Home 换成 WordBook。 这套「父布局固定 + 子内容随路由切换」的结构，是中后台管理系统、多页应用里最经典、最常用的布局方案。 十、知识点速查总结 依赖与工具 名称 一句话说明 pnpm 快、省磁盘（硬链接共享）、依赖严格（防幽灵依赖）的包管理器 pnpm add xxx 安装包并写入 dependencies tailwindcss 原子化 CSS 框架，HTML 里直接组合工具类写样式 @tailwindcss/vite Tailwind v4 官方 Vite 插件（配置极简） three / @types/three 3D 图形库 / 它的 TS 类型声明包 @types/xxx DefinitelyTyped 为纯 JS 库补充的类型声明 axios Promise 版 HTTP 请求库，支持拦截器 @microsoft/fetch-event-source SSE 流式（支持 POST/自定义头），做 AI 打字机效果 marked Markdown 转 HTML，渲染 AI 返回内容（注意 XSS） element-plus / icons-vue Vue3 UI 组件库 / 官方图标包 pinia-plugin-persistedstate Pinia 状态持久化到 localStorage，刷新不丢 配置相关 知识点 一句话说明 vite.config.ts + defineConfig Vite 配置文件，defineConfig 提供类型提示 plugins: [tailwindcss()] 注册 Vite 插件（记得加括号调用） @import 'tailwindcss' v4 引入 Tailwind（放最前面） @apply 在 CSS 里套用 Tailwind 工具类 html,body,#app { h-full w-full } 撑满视口高度链，整页布局前置设置 副作用导入 import 'xxx.css' 只执行文件（加载样式），不导入变量 import router from './router' 引入目录默认找其 index.ts ElementPlus 中文 引入 zhCn，app.use(ElementPlus,{locale:zhCn}) \u0026quot;types\u0026quot;:[\u0026quot;element-plus/global\u0026quot;] 让 el- 组件有全局类型提示（会覆盖自动加载） Vue 应用启动 知识点 一句话说明 createApp(App) 用根组件创建应用实例 createPinia() + pinia.use(...) 创建状态库并装插件（先装插件再交给 app） app.use(插件, 选项) 注册插件，可链式；第二参为配置 app.mount('#app') 挂载到页面，必须最后执行 启动套路 createApp → 建实例/装插件 → 多个 use → mount 路由核心 知识点 一句话说明 createRouter({history, routes}) 创建路由实例 createWebHistory() History 模式，URL 无 #，部署需服务器回退到 index.html createWebHashHistory() Hash 模式，URL 带 #，无需服务器配置 import.meta.env.BASE_URL Vite 环境变量，应用基础路径 children 嵌套路由 路由成树，父布局 + 子页面 子 path 拼接 不以 / 开头相对拼父级；以 / 开头是绝对路径 ...home 合并路由 展开运算符合并各模块路由，实现模块化 静态引入 component: Home 打进主包，首屏必用页面用它 动态引入 () =\u0026gt; import(...) 懒加载/代码分割，非首屏页面用它减小首屏体积 router.push('/x') 编程式导航（代码里跳转） \u0026lt;router-link to\u0026gt; 声明式导航（模板里的链接） \u0026lt;RouterView /\u0026gt; 路由出口插槽，按层级渲染匹配链对应组件 嵌套渲染机制 一次性解析整条匹配链，自上而下同步渲染 父层不变则复用 切页面时同一 layout 实例保留，只换变化的子层（Header 不闪） \u0026lt;KeepAlive\u0026gt; 缓存被切走的组件、保活状态（延伸） Tailwind 常用类（本篇出现） 类 含义 flex / items-center / justify-center / justify-between 弹性布局与对齐 gap-2 子元素间距 0.5rem h-20 / w-10 / h-10 高 80px / 宽高 40px（1=4px） w-[1200px] / rounded-[10px] 任意值（方括号语法） mx-auto 水平居中定宽容器 sticky top-0 粘性定位，滚动吸顶 z-10 层叠层级 border-b / border-l 下 / 左 单边边框 rounded-full 完全圆角（药丸 / 圆形头像） cursor-pointer 手型指针（给非链接元素加可点提示） bg-blue-200 / text-blue-700 浅底深字同色系配色 px-2 py-1 / pl-4 / ml-2 内边距 / 单向内边距 / 外边距 几个「为什么」的重点回顾 为什么先清理脚手架？ 去掉示例干扰、明确起点；清理注意引用先后顺序。 为什么 three 要另装 @types/three？ 它是纯 JS 库，需要单独的类型声明才有 TS 提示。 为什么 AI 对话用 fetch-event-source 而非原生 EventSource？ 需要 POST + 自定义头 + 鉴权，原生不支持。 为什么先给 pinia 装插件再交给 app？ 插件要在 store 使用前就绪，顺序不能反。 为什么 app.mount 放最后？ 要等所有插件 use 完，应用配置齐全再渲染上屏。 为什么 Home 静态、WordBook 动态引入？ 首屏必用的静态打进主包秒开；非首屏的懒加载减小首屏体积。 为什么 History 模式刷新会 404？ URL 是真实路径会请求服务器，需配置服务器回退到 index.html。 为什么切页面 Header 不闪？ 嵌套匹配链父层 layout 不变，实例复用，只重渲染变化的子层。 ","date":"2026-07-11T00:00:00Z","permalink":"/p/04%E5%89%8D%E7%AB%AF%E5%88%9D%E5%A7%8B%E5%8C%96/","title":"04前端初始化"},{"content":"🌐 全栈前端实战笔记：全局命令面板组件设计 💡 架构常识：由于“全局搜索命令面板”要求在全站任何页面都能通过快捷键唤醒，因此该组件需要放在 src/components/ 下，并在根组件 App.vue 中注册，使其成为常驻的全局组件。\n一、 组件骨架设计与 Tailwind CSS 样式落地 1. 语义化结构 一个标准的命令面板通常由 遮罩层（Overlay） 和 面板主体（Main Panel） 组成：\n1 2 3 4 5 6 7 8 9 10 11 \u0026lt;template\u0026gt; \u0026lt;div class=\u0026#34;fixed inset-0 w-full h-full bg-black opacity-30 blur-sm z-40\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;fixed top-20 left-1/2 -translate-x-1/2 w-1/2 z-50\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;bg-white p-4\u0026#34;\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; class=\u0026#34;w-full focus:outline-none\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;bg-gray-50 max-h-96 overflow-y-auto\u0026#34;\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 📌 代码规范：对于像 input 这样没有子内容的标签，推荐使用单标签闭合形式（例如 \u0026lt;input /\u0026gt;），保持代码简洁。\n2. 核心 Tailwind CSS 类名速查 布局与定位： fixed: 固定定位，使其脱离标准文档流，相对于浏览器窗口定位。 inset-0: 相当于 top: 0; right: 0; bottom: 0; left: 0;，配合 fixed 实现全屏占满。 w-full h-full: 宽高 $100%$。 w-1/2: 面板宽度占父级（或屏幕）的 $50%$。 视觉与修饰： bg-black: 背景颜色设为黑色。 opacity-30: 透明度设为 $30%$，实现标准的半透明遮罩视觉。 blur-sm: 背景轻微高斯模糊，增强视觉景深（隔离背景干扰，迫使用户聚焦当前面板）。 focus:outline-none: 剔除表单输入框在被点击聚焦时出现的浏览器默认黑色边框。 文本截断： line-clamp-2: 多行文本截断。内容如果超出 2 行，会自动截断并在末尾补充省略号（...）。 二、 安全防御（XSS）与剪贴板增强 1. v-html 的应用场景与安全红线 为何使用：字典或翻译数据中通常包含富文本标签（如换行符 \u0026lt;br /\u0026gt; 或加粗 \u0026lt;b\u0026gt;）。如果使用双大括号 {{ }} 会被当成普通文本原样输出，因此必须使用 v-html 指令将其作为真正的纯 HTML 插入标签内。 核心隐患（XSS 攻击）：动态渲染任意 HTML 是极其危险的，恶意攻击者可能会注入 \u0026lt;img src=\u0026quot;x\u0026quot; onerror=\u0026quot;stealLocalStorage()\u0026quot;\u0026gt;。 1 2 3 4 5 6 // ❌ 危险：绝对不要将用户提供的内容或未清洗的内容直接传给 v-html \u0026lt;div v-html=\u0026#34;item.translation\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; // 安全：在交给 v-html 之前，必须使用 DOMPurify 等库进行过滤洗白 import DOMPurify from \u0026#39;dompurify\u0026#39; const cleanHTML = DOMPurify.sanitize(item.translation) 2. 剪贴板复制事件 在单词卡片上绑定点击事件，允许用户快速复制目标文本：\n1 2 3 4 5 6 7 8 9 const copyWord = (word: string) =\u0026gt; { try { // 安全限制：navigator.clipboard 仅在 localhost 或 HTTPS 环境下有效 navigator.clipboard.writeText(word); ElMessage.success(\u0026#39;复制成功\u0026#39;); } catch (error) { ElMessage.error(\u0026#39;复制失败\u0026#39;); } }; 三、 输入防抖（Debounce）的三种技术实现 🔍 概念对齐：防抖指的是在事件被触发 $n$ 秒后再执行回调，如果在这 $n$ 秒内又被触发，则重新计时。这能有效防止用户在疯狂敲击键盘输入时，前端不停向后端发起无效的高频网络请求。\n方案一：侦听器模型（watch 动态追踪） 1 2 3 4 5 6 7 8 9 10 11 12 13 14 let timer: ReturnType\u0026lt;typeof setTimeout\u0026gt; | null = null; // 推荐：第一个参数写成箭头函数，可以精确、动态地追踪 ref 或 Pinia 状态的变化 watch( () =\u0026gt; search.value, (newVal) =\u0026gt; { // 如果 500ms 内旧的定时器还在运转，无情清理它，重新开始计时 if (timer) clearTimeout(timer); timer = setTimeout(() =\u0026gt; { console.log(\u0026#39;发起网络请求：\u0026#39;, newVal); }, 500); } ); 💡 类型进阶：let timer: ReturnType\u0026lt;typeof setTimeout\u0026gt; | null = null 中，ReturnType\u0026lt;typeof setTimeout\u0026gt; 是 TS 的内置泛型工具，能够动态捕获当前宿主环境（浏览器环境为 number，Node 场景下为 Timeout 对象）的定时器 ID 类型。\n方案二：事件监听模型（绑定 @input 原生事件） 事件区别： @input：实时触发。用户每敲一个键、每删一个字或用鼠标右键粘贴文本，输入框内容一变就立马触发。 @change：失去焦点时触发。用户输了一百个字，只要鼠标不点到别处或不按回车，就绝不触发。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 \u0026lt;template\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; @input=\u0026#34;onSearchInput\u0026#34; placeholder=\u0026#34;输入关键词...\u0026#34; /\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; let timer: ReturnType\u0026lt;typeof setTimeout\u0026gt; | null = null; const onSearchInput = (e: Event) =\u0026gt; { if (timer) clearTimeout(timer); // 必须要将 e.target 强类型断言为 HTMLInputElement 才能安全读取到 .value const value = (e.target as HTMLInputElement).value; timer = setTimeout(() =\u0026gt; { console.log(\u0026#39;向后端发起搜索请求：\u0026#39;, value); }, 500); }; \u0026lt;/script\u0026gt; 方案三：底层拦截模型（使用 customRef 封装） ref 是黑盒，数据一变网页立刻无条件刷新；而 customRef 是一个白盒，能够允许我们劫持 get（依赖收集 track()）和 set（派发更新 trigger()）的内部时机，从而实现自带防抖功能的响应式变量。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 import { customRef } from \u0026#39;vue\u0026#39;; let timer: ReturnType\u0026lt;typeof setTimeout\u0026gt; | null = null; const search = customRef((track, trigger) =\u0026gt; { let value = \u0026#39;\u0026#39;; // 闭包存储真实值 return { get() { track(); // 告诉 Vue 追踪这个值的变化 return value; }, set(newValue: string) { value = newValue; // 实时更新内存中的值 if (timer) clearTimeout(timer); // 延迟触发网页更新（把 trigger 关进定时器里） timer = setTimeout(() =\u0026gt; { console.log(\u0026#39;防抖值改变：\u0026#39;, value); getList(); // 请求后端数据 trigger(); // 【核心】500ms 到了，这才通知 Vue 刷新网页 }, 500); } }; }); 🔬 四、 核心原理剖析：防抖代码中的“闭包”双重维度 在防抖的方案一（watch 机制）中，看似简单的几行代码，底层在两个维度上深度依赖了 闭包（Closure） 机制：\n1 2 3 4 5 6 7 // 外层环境（常驻内存） let timer = null; watch(() =\u0026gt; search.value, (newVal) =\u0026gt; { if (timer) clearTimeout(timer); timer = setTimeout(() =\u0026gt; { console.log(newVal) }, 500); // 👈 两个内部函数 }); 维度一：timer 变量的“跨生命周期长寿记忆” watch 的回调函数是一个内部函数，它访问了它外部作用域的 timer 变量。 当单次 watch 执行完毕后，按理说该函数作用域应该被销毁。但因为 Vue 保持着对这个回调函数的引用，这个函数被迫形成了闭包。 它成功让 timer 变量存活了下来。当用户下一次快速输入，第二次触发 watch 回调时，它依然能精确“记住”上一次生成的定时器 ID 从而成功执行 clearTimeout(timer)。 维度二：newVal 的“环境时空冻结” 假设用户在输入框里连续输入了 \u0026quot;V\u0026quot;, \u0026quot;Vu\u0026quot;, \u0026quot;Vue\u0026quot; 三个字母：\nwatch 实际上被连续触发了三次。每次触发，都会在内存中开辟一个独立的作用域，里面存放着当时的 newVal（第一次是 \u0026quot;V\u0026quot;，第二次是 \u0026quot;Vu\u0026quot;，第三次是 \u0026quot;Vue\u0026quot;）。 前两次的调用在运行完 clearTimeout 后就匆匆结束了。 第三次触发时，setTimeout 内部的箭头函数 () =\u0026gt; { console.log(newVal) } 诞生了。它没有立刻执行，而是在等待 500ms。 此时，这个箭头函数向外凝视，将第三次调用产生的空间以及里面的 newVal = \u0026quot;Vue\u0026quot; 打包带走并冻结。 500ms 后，即使 watch 的第三次调用早就执行完毕、其作用域理应被销毁，这个定时器回调函数依然能从自己“打包带走的冻结环境”中，精准无误地抓取并打印出当时的最新值：\u0026quot;Vue\u0026quot;。 五、 全局级高级能力增强 1. 全局快捷键监听与视口锁死 为了实现通过 Ctrl + F 唤醒面板，通过 Esc 关闭面板，需要在全局 window 上绑定键盘事件：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 window.addEventListener(\u0026#39;keydown\u0026#39;, (e: KeyboardEvent) =\u0026gt; { // 唤醒快捷键：Ctrl + F if (e.key === \u0026#39;f\u0026#39; \u0026amp;\u0026amp; e.ctrlKey) { e.preventDefault(); // 关键：阻止浏览器自带的默认网页搜索行为 isShow.value = true; // 体验优化：当弹窗出现时，锁死网页滚动条，防止背景乱滚 document.body.style.overflow = \u0026#39;hidden\u0026#39;; } // 退出快捷键：Escape if (e.key === \u0026#39;Escape\u0026#39;) { isShow.value = false; search.value = \u0026#39;\u0026#39;; // 清空搜索词 document.body.style.overflow = \u0026#39;auto\u0026#39;; // 恢复页面滚动条 } }); 2. 全局组件自动聚焦（自定义插件与指令） 当命令面板弹出时，最人性化的体验是让输入框自动聚焦。我们可以编写一个全局指令插件 v-focus。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // directives/focus.ts import type { App, Plugin } from \u0026#39;vue\u0026#39;; export default { install(app: App) { // 注册全局指令 v-focus app.directive(\u0026#39;focus\u0026#39;, { // 当绑定了 v-focus 的 input 标签被真正塞入 DOM 树瞬间触发 mounted(el: HTMLElement) { el.focus(); // 调用原生 DOM 方法使光标聚焦 } }); } } as Plugin; // 使用 TypeScript 断言，确保其完美符合 Vue 插件的规范 入口文件注册 (main.ts)： 1 2 3 4 5 6 7 8 import { createApp } from \u0026#39;vue\u0026#39;; import App from \u0026#39;./App.vue\u0026#39;; import focusPlugin from \u0026#39;./directives/focus\u0026#39;; // 默认导出可以由我们自定义任意名称 const app = createApp(App); app.use(focusPlugin); // 安装插件，会自动调用插件内部的 install 函数 app.mount(\u0026#39;#app\u0026#39;); 3. \u0026lt;Transition\u0026gt; 优雅过渡动效 原生 v-if 或 v-show 的切换会让组件生硬地闪现或消失。利用 Vue 内置的 \u0026lt;Transition\u0026gt; 可以赋予其丝滑的缩放动画（从小变大，从大变小）。\n1 2 3 \u0026lt;Transition name=\u0026#34;panel\u0026#34;\u0026gt; \u0026lt;div v-if=\u0026#34;isShow\u0026#34; class=\u0026#34;search-panel\u0026#34;\u0026gt; ... \u0026lt;/div\u0026gt; \u0026lt;/Transition\u0026gt; 六个生命周期样式类名： 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 /* 1. 进入（Enter）阶段：由暗到明，由小到大 */ .panel-enter-from { opacity: 0; transform: scale(0.9); /* 动画起始状态：轻微缩小 */ } .panel-enter-active { transition: all 0.3s ease-out; /* 整个过渡动画持续 0.3s */ } .panel-enter-to { opacity: 1; transform: scale(1); } /* 2. 离开（Leave）阶段：由明到暗，由大到小 */ .panel-leave-from { opacity: 1; transform: scale(1); } .panel-leave-active { transition: all 0.2s ease-in; } .panel-leave-to { opacity: 0; transform: scale(0.9); /* 动画结束状态：缩回 0.9 */ } ","date":"2026-07-11T00:00:00Z","permalink":"/p/06%E5%85%A8%E5%B1%80%E7%BB%84%E4%BB%B6%E6%90%9C%E7%B4%A2/","title":"06全局组件搜索"}]