[{"content":"一句话：OC Ferry 是个开源小工具，作用是把 OpenCode 的免费模型接进 WorkBuddy 用。项目地址 HRuiCcc/RuiC-oc-ferry，MIT 协议。\n一、它的原理 WorkBuddy 支持填自定义模型（写在 ~/.workbuddy/models.json），填进去就走你自己的配额。OpenCode 那边有一批免费模型额度。看着挺简单 —— 在 WorkBuddy 里加个接口地址不就完了？\n不行，卡在一个地方：\nOpenCode 的免费额度，只认从 OpenCode 自己内部发出的请求。\n你把这个接口地址抄出来、用别的工具去调，服务端会发现\u0026quot;来源不对\u0026quot;，直接拒绝 —— 官方原话就是 opencode's free tier can only be used from within opencode。\n所以 OC Ferry 的做法很直接：它不抄接口，而是在你电脑上放一份隔离的 OpenCode 运行时，让请求真的从 OpenCode 里面发出去。\n1 WorkBuddy → 本机 127.0.0.1:41980 → 适配器 → 隔离的 OpenCode → 免费模型 围绕这条链路，它干三件事：\n挑 —— 把 OpenCode 里真正免费的模型选出来。判断标准是价格字段（输入/输出/缓存读写）全为 0，不是名字里有没有 free 测 —— 挨个测它能不能给 WorkBuddy 干活。重点是能不能产生工具调用（Agent 场景的命门）；只回文本的也不算失败，会降级成「可用 · 仅对话」并把工具关掉 接 —— 把测通的写进你的 models.json，再起一个只监听本机的转发口，供 WorkBuddy 调用 二、可行性分析 技术可行性：成立 ✅ 关键就在\u0026quot;隔离运行时\u0026quot;这一层 —— 如果它是直接转发密钥，这个方案从第一天就不成立。工程细节上也做得比较实在：检测 3 路并发（一轮约 60 秒），判定结果按模型缓存（成功留 6 小时），所以重启基本是秒开，不会每次都全量重测。\n安全可行性：比预期周全 ✅ 网络面：只监听 127.0.0.1:41980，所有请求要带 Bearer 密钥，带 Origin 头的直接 403 —— 这个口不给浏览器用 权限面：模型只能吐一段 JSON 信封表达\u0026quot;我想做什么\u0026quot;，真正的执行权始终在 WorkBuddy 手里。任何本机动作都会被拒绝或转交成外部工具调用 —— 模型永远不会自己动手 写配置：改 models.json 前先备份、加锁、原子替换；只动它自己标记过的条目，你手写的一条都不碰 退出清理：从托盘退出时，把自己导入的条目摘干净 后两条是我最看重的 —— 这决定了它是\u0026quot;能放心常驻的小工具\u0026quot;，而不是\u0026quot;会污染你配置的黑盒\u0026quot;。\n使用可行性：分平台 ⚠️ 装完即用，不需要另外装 Node.js（打包后用的是 Electron 自带的 Node） Windows 版安装时会写入开机自启（--hidden，开机只常驻托盘、不弹窗），等于\u0026quot;一直连着\u0026quot; 关窗口 ≠ 退出：关掉窗口它继续在托盘跑 要留意：官方 Release 目前只发布了 macOS（Apple 芯片） 的安装包；想在 Windows 上用，得走非官方 fork Chemingitch/RuiC-oc-ferry-windows，或者自己 build 风险与边界 ❌ 作者在 README 里专门写了一节「诚实交代」，照搬几条关键的：\n免费模型和额度由上游决定，随时可能变 检测通过只代表\u0026quot;这一次能按协议产生动作\u0026quot;，不代表完整工作流都验证过 启动后会自动发检测请求，会消耗少量免费额度 界面上显示的耗时是完整请求用时，不是首字延迟；剩余额度上游不提供，查不到 系统代理只支持手动 HTTP/HTTPS，不支持 PAC / SOCKS macOS 包未做 Apple 公证，首次打开可能要右键「打开」；若提示\u0026quot;已损坏\u0026quot;，先核对 sha256，再执行 xattr -dr com.apple.quarantine \u0026quot;/Applications/OC Ferry.app\u0026quot; 三、值不值得用 值得一试，但别当主力。\n结论 适合 想给 WorkBuddy 加一条免费备用通道的人；手上已经有 OpenCode；macOS 用户（体验最顺） 不适合 指望它当生产力主力的人（免费池不稳）；Windows 且完全不想折腾的人 心态 当成体验通道 / 应急通道，而不是日常主力模型 三条红线：别拿它跑重要工作流；别把额度当成无限；模型有权限边界，重要操作自己盯一眼。\n四、怎么用 其实就点两下。\n安装包打开后，首次启动会自动扫描免费模型 + 逐个检测 + 自动导入一次。之后想重新来一遍：\n点 「检测全部」 —— 重新扫描并检测模型 点 「导入 WorkBuddy」 —— 把结果写进配置 完事。WorkBuddy 的模型列表里就会多出一批 OC · 名字 的模型，选中即用。\n上图左边是跑通的日志（ready at http://127.0.0.1:41980/v1; 12 free models; probing 12），右边是导入后 WorkBuddy 里的模型列表 —— 多出一批带 OC · 前缀的免费模型，选中即用。\n几个注意点：\n要用就让它常驻 —— 关窗口它继续在托盘跑，但从托盘退出会把导入的条目摘掉（保留你手写的） 只想重测某一个：进它的详情，点「只重测这一个」 主题切换：工具栏右上角，系统 / 浅色 / 深色三档，选择会记住 下载\n主仓库：https://github.com/HRuiCcc/RuiC-oc-ferry Windows 非官方 fork：https://github.com/Chemingitch/RuiC-oc-ferry-windows 五、这 10 款模型都是什么来头 装完之后，你的模型列表里会多出一批 OC · 名字 —— 名字全都眼生。我把本机检测通过的那 10 款整理成了一张表，出厂时间、Artificial Analysis 智能指数、实测能力一起摆出来：\nOC Ferry 免费模型池 · 出厂时间与智能能力评估 10 款可用模型 · 按综合档位排序 · AA 指数为 Artificial Analysis 现行口径（榜首约 58 分） · † = 隐身模型，身份为社区猜测 模型 / 部署规格厂商出厂时间AA 指数智能能力评估档位 Muse Spark 1.31M ctx · 131K 出 · 图/视频 · 工具 ✕ Meta2026-09-02 48 全场最高分。编码与超长上下文极强（512K–1M 档 98.1 分）；但 Zen 免费档工具协议不兼容，本机已按「仅对话」导入，适合纯对话与写作 S · 仅对话 Step 5 Preview1M ctx · 65K 出 · 视觉 · 工具 ✓ 阶跃星辰2026-09-20 44 国产开源前三。DeepSWE 67.7、FrontierFinance 66.4（全球第二）；600B/27B MoE；10-15 权重开源；零数据保留。综合最强可用款 S MiMo-V2.6-Flash200K ctx · 32K 出 · 图/视/音 · 工具 ✓ 小米2026-09-22 38 MIT 开源全模态。DeepSWE 67.9、CyberGym 95.1；RL 训练成本 85 万美元全公开；标称 1M、Zen 部署 200K。均衡主力 A Space Bunny †1M ctx · 512K 出 · 图 · 工具 ✓ 隐身2026-09 下旬 未收录 社区实测 15/15 任务全过，三款隐身中最快、单任务成本最低；512K 超大输出；零保留。智能体任务强力备选，身份悬空是唯一风险 A Nemotron 3 Ultra1M ctx · 128K 出 · 纯文本 · 工具 ✓ NVIDIA2026-06-04 23 550B/55B 开放旗舰，发布时为美国最强开放权重（旧口径 47.7）；1M 纯文本，长文档/长日志输入专家，通用智能一般 B · 长文本 Ling 3.1 Flash262K ctx · 32K 出 · 纯文本 · 工具 ✓ 蚂蚁 InclusionAI2026-09-30 未收录 560B/25B 新一代 flash；官方自报 FrontierSWE 75.16、GDPVal Elo 1673（未经第三方复现）；本池上下文最短，长任务受限 B Big Pickle †200K ctx · 32K 出 · 纯文本 · 工具 ✓ 隐身2025-10 未收录 唯一横跨 2026 三个免费批次的老将；实测 12/15，最难任务三次超时；200K 上下文，已显老态，建议让位于新模型 C Nemotron 3.5 Lightning262K ctx · 262K 出 · 纯文本 · 工具 ✓ NVIDIA2026-08-11 13 Ultra 的 30B 蒸馏速度版（约 670 tok/s）；定位常驻 agent 执行层，轻任务/高并发专用，复杂推理弱 C · 速度 LongCat 2.5 Preview1M ctx · 131K 出 · 图 · 工具 ✓ 美团2026-09-26 未收录 「1.6T 参数」实为 2.0 旧数，官方未公布 2.5 参数；未开源、官方文档图文口径自相矛盾；零保留。整体待第三方验证 C · 待验证 Cat Jam †262K ctx · 131K 出 · 纯文本 · 工具 ✓ 未知2026-10 新现 无资料 10 月刚进池的隐身模型，连官方免费名单都未列入，零公开资料；规格不俗（5 档推理可调），建议小额试用观察 未评 数据政策：零保留 = Space Bunny / LongCat / Step 5；免费期可能用于训练 = Big Pickle / MiMo / Muse Spark / Ling；Nemotron 两款跑 NVIDIA 试用端点，勿提交机密数据。免费池限时轮换：Exo Free 刚因 410 下线，名单每周可能变化。 规格列来自本机 OC Ferry 目录实测（2026-10-13），与厂商标称可能不同；「官方自报」为厂商发布会数字，非第三方复现。 一句话总结：能当主力的只有 Step 5，MiMo 均衡，Muse Spark 分数最高但只能纯对话，其余按需取用。\n小结 一个 120 星、10 月 10 号才创建的小项目，但把\u0026quot;能不能用\u0026quot;这件事想得比很多大项目清楚：用隔离运行时绕过官方限制，用执行权归属守住安全边界，用缓存解决\u0026quot;每次都要重测\u0026quot;的体验问题。\n免费的东西都不稳定，这一点它自己也承认。但作为一条\u0026quot;积分不够时的备用通道\u0026quot;，它的可行性是成立的。\n项目地址：https://github.com/HRuiCcc/RuiC-oc-ferry （MIT） 封面/配图来自项目仓库 docs/screenshots\n","date":"2026-10-13T20:45:00+08:00","image":"https://linsk27.github.io/p/1013-oc-ferry/oc-ferry-dark_hu_dca28c5ad90f282d.png","permalink":"https://linsk27.github.io/p/1013-oc-ferry/","title":"【workbuddy】+OC Ferry 免费模型使用！！！"},{"content":"起因很简单：笔记写了三年，多到自己都不想翻。\n一、痛点：笔记越多，越像垃圾场 我攒了一堆 Markdown 笔记 —— Typora 写的、从自己 Hugo 博客导出的、还有各处抄下来存档的。文件名五花八门，图片散在五个不同的图床，同一件事在两篇笔记里说法还不一样。\n量不算大，但\u0026quot;能直接用\u0026quot;的没几份：\n找不到 —— 想查某个知识点，不知道该翻哪一篇 不放心 —— 笔记是几年前写的，里面的 API 可能早就废弃了 不成体系 —— 知识点是散的，面试要用的时候串不起来 我试过纯手动整理，结论是：整理笔记这件事会持续消耗意志力，而意志力是会用完的。\n二、为什么是 Obsidian + WorkBuddy 分工原则一句话：\n素材只读，wiki 由 AI 写，我只负责\u0026quot;给素材\u0026quot;和\u0026quot;提问\u0026quot;。\nObsidian 负责\u0026quot;装\u0026quot;：纯本地 Markdown 文件夹、[[双链]]、关系图谱、全文搜索。数据在自己硬盘上，不怕平台跑路。 WorkBuddy 负责\u0026quot;干\u0026quot;：能读写本地文件、跑脚本、抓网页、读 PDF，关键是 —— 你说人话，它干活。 两者之间靠一个 skill 粘起来，这也是整篇文章的主角。\n三、核心：一个叫 Karpathy LLM Wiki 的 skill 整套玩法的发动机，是 WorkBuddy 里的一个 skill —— Karpathy LLM Wiki。\n它来自 Andrej Karpathy 提出的「LLM Wiki」思路：不要 RAG，要\u0026quot;增量编译\u0026quot;。\n传统做法（RAG）是你每问一次，就去素材库里检索一遍；而它反着来 —— 你每丢进来一份素材，它就读一遍、提炼要点，然后写进 wiki，顺手更新相关页面、标注和新结论矛盾的地方，把零散的信息缝合成一张网。\n所以这个 wiki 是一个会复利的产物：交叉引用早就连好了，矛盾被标出来了，综述反映的是你读过的全部内容。素材越多、问得越多，它就越厚、越准。\n它把内容分成三层，边界很清楚：\n层 谁维护 说明 素材 raw/ 你 只许放，不许改 —— 唯一的事实来源 wiki/ AI 摘要、概念页、综述、对比…… 你只读不写 WIKI-SCHEMA.md 你和 AI 共同演进 约定目录、分类、页面格式 一句话概括分工：你负责喂素材、提问题；AI 负责写页面、查矛盾。\n四、日常只有三个指令 整套工作流，你只需要记住三个动词：\n指令 你说的话 它做的事 入库 Ingest \u0026ldquo;把这篇素材入库\u0026rdquo; 读素材 → 写摘要页 → 更新目录 → 顺带更新相关页面 → 记一条日志 提问 Query \u0026ldquo;XX 这个知识，我库里是怎么说的？\u0026rdquo; 先读目录定位，再综合出答案并给出引用；好答案会落盘成新页面 体检 Lint \u0026ldquo;给知识库做一次体检\u0026rdquo; 找矛盾、过时断言、孤儿页、缺失的双链和内容缺口 就这三句。剩下的它自己会做。\n五、它长什么样、能干嘛 上图就是它跑起来的样子 —— 左边是文件树，中间是笔记的属性（frontmatter 可视化），右边是关系图谱。具体价值有这么几个：\n知识沉淀：每读一份素材，wiki 就更厚一点。它是资产而不是消耗品 —— 你今天读的东西，半年后还能被检索、被引用、被缝合进新的结论里。 在 WorkBuddy 里直接检索：不用自己翻文件，直接问就行（\u0026ldquo;我之前整理过的那几个坑是哪些？\u0026quot;），它会读相关页面、给出带引用的答案；如果这个回答有价值，还能让它落盘成一个新页面，而不是沉在聊天记录里。 双链 + 图谱：页面之间用 [[双链]] 连起来，Obsidian 的图谱会把这些连接画成一张网 —— 你能一眼看出哪些知识点是孤立的、哪些是核心枢纽。 体检兜底：内容一多就容易自己打架。定期跑一次体检，矛盾、过时、断链、缺口都会列出来。 随时能搬走：说到底就是一堆 .md 文件，可以用 git 管版本，不绑定任何平台。 六、怎么开始 第一步：装 Obsidian 官网直接下载，Windows / macOS / Linux / 手机端都有，免费：\n下载地址：https://obsidian.md/download\n先装好，但先别急着建仓库 —— 下一步会告诉你仓库放哪。\n第二步：让 WorkBuddy 帮你把仓库建好 在 WorkBuddy 里启用 Karpathy LLM Wiki skill（其他能装 skill、能读写本地文件的 agent 同理），然后说一句：\n\u0026ldquo;帮我建一个个人知识库。\u0026rdquo;\n它会自动把整套结构和约定建出来：\n1 2 3 4 5 6 7 wiki-knowledge/ ├── raw/ # 原始素材（只读） │ └── assets/ # 本地化的图片 ├── wiki/ # AI 维护的页面 │ ├── index.md # 内容目录 │ └── log.md # 操作日志 └── WIKI-SCHEMA.md # 约定：目录 / 分类 / 页面格式 注意：这一步你不用手动建任何文件夹、也不用自己写配置文件 —— 那都是 skill 的活。你只需要说一句话。\n第三步：把仓库导进 Obsidian 打开 Obsidian，选 「打开文件夹作为仓库」，把它刚建好的那个目录选进去。完成，界面就和上面那张图一样了。\n第四步：日常就三个指令 把新素材丢进 raw/，说\u0026quot;入库\u0026rdquo;；有问题就问；想保持整洁就说\u0026quot;体检\u0026quot;。就这三句。\n第五步：进阶 —— 让它自己跑 真正把\u0026quot;意志力\u0026quot;这件事彻底解决掉的，是定时任务。在 WorkBuddy 里给这个知识库挂上两个定时任务：\n定时摄入：比如每周一次，自动把 raw/ 里还没处理的素材入库 定时体检：比如每周一次，自动跑一遍 lint，把矛盾、孤儿页、内容缺口整理好发给你 之后就可以躺平了：素材随手丢，AI 每周自己整理、自己查错。\n最后，安利一下 成本几乎为零：Obsidian 免费，数据全在你本地，没有订阅、没有额度。 不绑定平台：产物就是一堆 Markdown，哪天不想用了，打包带走。 越用越值钱：它是个复利资产 —— 不像聊天记录，问完就沉底了；这里的每一次沉淀都留在库里，等着被下一次引用。 谁都适合：笔记多但烂在手里的、天天读论文的、做竞品调研的、准备面试要串知识点的，都能直接用。 一句话总结：你负责喂养和提问，AI 负责整理和查错。\n参考自：Andrej Karpathy 的 LLM Wiki（gist）、WorkBuddy「Karpathy LLM Wiki」skill、个人知识库实操记录（2026-10）\n","date":"2026-10-09T15:54:34+08:00","image":"https://linsk27.github.io/p/1009-workbuddy-obsidian/obsidian-vault_hu_ff5a15e65cd96235.png","permalink":"https://linsk27.github.io/p/1009-workbuddy-obsidian/","title":"WorkBuddy + Obsidian：用 AI 维护一个会自己长大的知识库"},{"content":"这是一篇还在更新的 Node.js 学习笔记，记录我学到的知识点和练习代码。目前从 Buffer、文件读写和 HTTP 学到了 Express，后面会边学边补。\nBuffer（缓冲器） 概念 一个类似于Array的对象，即表示一段固定长度的内存空间，用于处理二进制数据 特点 Buffer大小固定且无法调整 性能较好，直接对计算机内存进行操作 每个元素的大小为1字节（byte），即8个bit 创建方式 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 //1. alloc let buf = Buffer.alloc(10) //2.allocUnsafe,不会对原内存清空，包含旧数据 // 不需要归零操作，因此速度快于 alloc let buf_2 = Buffer.allocUnsafe(10) //3.from let buf_3 = Buffer.from(\u0026#39;hello\u0026#39;) let buf_4 = Buffer.from([166, 233, 145, 764, 103, 221]) console.log(buf_4) // \u0026lt;Buffer a6 e9 91 fc 67 dd\u0026gt; // ps：Buffer.from(\u0026#39;hello\u0026#39;) 创建时，会使用UTF-8编码算法，把字符串转为一串字节； // console.log打印buffer时，只是把Buffer内部每个字节的值，用16进制格式展示出来； // Buffer底层存储的是字节（本质二进制数据）。 buffer 的元素转换 1 2 3 4 5 6 7 // buffer与字符串的转换 let buf_4 = Buffer.from([105, 108, 111, 118, 101, 121, 111, 117]) console.log(buf_4.toString()) //iloveyou ，通过 utf--8,即toString(\u0026#39;utf-8\u0026#39;) // 字节2进制转换 let buf = Buffer.from(\u0026#39;hello\u0026#39;) console.log(buf[0].toString(2)) //01101000 注意 溢出 因为8位数二进制最多为255\n1 2 let buf = Buffer.from(\u0026#39;hello\u0026#39;) buf[0] = 361 // 舍弃高位数字 0001 0110 1001 =\u0026gt; 0110 1001 中文 一个中文utf-8中占3个字节\nfs模块 * 概念与使用 fs模块可以实现与硬盘的交互，例如\n文件的创建、删除、重命名、移动 文件内容的写入读取 文件夹的相关操作 文件写入 换行得用\\r\\n\nwriteFile 异步写入，即单开线程，主线程继续走 1 2 3 4 5 6 7 8 9 10 11 12 // 1.导入 fs 模块 const fs = require(\u0026#39;fs\u0026#39;) //2. 写入文件 fs.writeFile(\u0026#39;./座右铭.txt\u0026#39;, \u0026#39;三人行，则必有我师焉\u0026#39;, err =\u0026gt; { // err 写入失败：错误对象 写入成功：null if (err) { console.log(\u0026#39;写入失败\u0026#39;) } else { console.log(\u0026#39;写入成功\u0026#39;) } }) writeFileSync 同步写入，主线程停滞，等同步写入完再继续执行 1 fs.writeFileSync(\u0026#39;./data.txt\u0026#39;, \u0026#39;text\u0026#39;) 第三个参数{flag:\u0026lsquo;a\u0026rsquo;} 实现和追加写入一样的效果 1 2 3 4 5 6 7 8 9 10 11 12 // 1.导入 fs 模块 const fs = require(\u0026#39;fs\u0026#39;) //2. 写入文件 fs.writeFile(\u0026#39;./座右铭.txt\u0026#39;, \u0026#39;三人行，则必有我师焉\u0026#39;, { flag: \u0026#39;a\u0026#39; }, err =\u0026gt; { // err 写入失败：错误对象 写入成功：null if (err) { console.log(\u0026#39;写入失败\u0026#39;) } else { console.log(\u0026#39;写入成功\u0026#39;) } }) appendFile 异步追加 appendFileSync 同步追加 1 fs.appendFileSync(\u0026#39;./座右铭.txt\u0026#39;, \u0026#39;\\r\\n温故而知新，可以为师矣\u0026#39;) 流式写入 * 程序打开一个文件是需要消耗资源的，流式写入可以减少打开关闭文件的次数。\n流式写入方式适用于大文件写入或者频繁写入的场景，writeFile适合于写入频率较低的场景 如\n方案 1【分片上传，多请求并发】：前端slice切文件，并发发起多个请求上传分片；后端接收每一片，流式写入临时分片。优点：断网只重传失败分片，支持断点续传；缺点：请求多，要做并发控制。\n方案 2【单请求 body 流式上传】：前端把文件作为请求 body 的流，单个 HTTP 请求源源不断推送数据，后端同步流式写入磁盘。缺点：一旦网络中断，本次上传全部作废，无法断点续传。\n1 2 3 4 5 6 7 8 9 10 11 const fs = require(\u0026#39;fs\u0026#39;) //创建写入流对象 const ws = fs.createWriteStream(\u0026#39;./观书有感.txt\u0026#39;) ws.write(\u0026#39;半亩方糖一件开1\\r\\n\u0026#39;) ws.write(\u0026#39;半亩方糖一件开2\\r\\n\u0026#39;) ws.write(\u0026#39;半亩方糖一件开3\\r\\n\u0026#39;) ws.write(\u0026#39;半亩方糖一件开4\u0026#39;) //关闭通道 ws.close() 文件写入场景 下载文件 安装软件 程序日志、如git 视频录制 需持久化保存数据的时候，就应该想到文件写入\n文件读取 异步和同步读取 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const fs = require(\u0026#39;fs\u0026#39;) //异步读取 fs.readFile(\u0026#39;./观书有感.txt\u0026#39;, (err, data) =\u0026gt; { if (err) { console.log(\u0026#39;读取失败\u0026#39;) return } console.log(data) //输出为buffer，因此要转字符串 console.log(data.toString()) }) // 同步读取 let dataSync = fs.readFileSync(\u0026#39;./观书有感.txt\u0026#39;) console.log(dataSync.toString()) 读取文件应用场景 电脑开机 程序运行 编辑器打开文件 查看图片 播放视频、音乐 git查看日志，git log 上传文件，（读取文件，然后传到服务端） 查看聊天记录 流式读取 1 2 3 4 5 6 7 8 9 10 11 12 const fs = require(\u0026#39;fs\u0026#39;) // 创建读取流对象 const rs = fs.createReadStream(\u0026#39;../资料/笑看风云.mp4\u0026#39;) //绑定data事件 chunk 块儿 大块儿 rs.on(\u0026#39;data\u0026#39;, chunk =\u0026gt; { console.log(chunk) console.log(chunk.length) // 66536 字节 =\u0026gt; 64KB }) //end 可选事件 rs.on(\u0026#39;end\u0026#39;, () =\u0026gt; { console.log(\u0026#39;读取完成\u0026#39;) }) 文件复制案例* 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 const fs = require(\u0026#39;fs\u0026#39;) const process = require(\u0026#39;process\u0026#39;) //方式1 readFile // 读取文件内容 let data = fs.readFileSync(\u0026#39;../资料/笑看风云.mp4\u0026#39;) // 写入文件 fs.writeFileSync(\u0026#39;../资料/笑看风云复制.mp4\u0026#39;, data) //检查内存使用量 rss字段 console.log(process.memoryUsage()) //110710784字节 105MB // 方式2 流式操作 // 创建写入流对象 const ws = fs.createWriteStream(\u0026#39;../资料/笑看风云流式复制.mp4\u0026#39;) // 创建读取流对象 const rs = fs.createReadStream(\u0026#39;../资料/笑看风云.mp4\u0026#39;) // // 为读取流绑定data事件 // rs.on(\u0026#39;data\u0026#39;, chunk =\u0026gt; { // ws.write(chunk) // }) // rs.on(\u0026#39;end\u0026#39;, () =\u0026gt; { // //检查内存使用量 rss字段 // console.log(process.memoryUsage()) //43106304 41MB // }) // 更好的写法，自动处理背压，因为纯手写没处理背压。读得比写得快，数据块排队堆内存，rss 升高 rs.pipe(ws) 方式2性能更好，比如MP4资源为78MB，则方式1内存资源占用78M，而理想状态方式2只占64KB，为什么理想，是因为读取速度比写入速度快，因此会大于64KB，但是也优于方式1 文件的重命名和移动 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const fs = require(\u0026#39;fs\u0026#39;) // 调用 rename 方法 fs.rename(\u0026#39;./座右铭.txt\u0026#39;, \u0026#39;./论语.txt\u0026#39;, err =\u0026gt; { if (err) { console.log(\u0026#39;重命名失败\u0026#39;) return } console.log(\u0026#39;重命名成功\u0026#39;) }) // 文件的移动 fs.rename(\u0026#39;./座右铭.txt\u0026#39;, \u0026#39;../资料/移动文件.txt\u0026#39;, err =\u0026gt; { if (err) { console.log(\u0026#39;移动失败\u0026#39;) return } console.log(\u0026#39;移动成功\u0026#39;) }) 文件删除 一样有同步函数，较简单\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const fs = require(\u0026#39;fs\u0026#39;) // 调用unlink方法 unlinkSync fs.unlink(\u0026#39;./观书有感.txt\u0026#39;, err =\u0026gt; { if (err) { console.log(\u0026#39;删除失败\u0026#39;) return } console.log(\u0026#39;删除成功\u0026#39;) }) // 调用rm方法 14.4 rmSync fs.rm(\u0026#39;../资料/移动文件.txt\u0026#39;, err =\u0026gt; { if (err) { console.log(\u0026#39;删除失败\u0026#39;) return } console.log(\u0026#39;删除成功\u0026#39;) }) 文件夹操作 参数1path路径，参数2options选型配置（可选），回调函数\n创建文件 1 2 3 4 5 6 7 8 9 const fs = require(\u0026#39;fs\u0026#39;) fs.mkdir(\u0026#39;./html\u0026#39;, err =\u0026gt; { if (err) { console.log(\u0026#39;创建失败\u0026#39;) return } console.log(\u0026#39;创建成功\u0026#39;) }) 递归创建 1 2 3 4 5 6 7 8 //递归创建 fs.mkdir(\u0026#39;./a/b/c\u0026#39;, { recursive: true }, err =\u0026gt; { if (err) { console.log(\u0026#39;创建失败\u0026#39;) return } console.log(\u0026#39;创建成功\u0026#39;) }) 读取文件夹 1 2 3 4 5 6 7 fs.readdir(\u0026#39;../资料\u0026#39;, (err, data) =\u0026gt; { if (err) { console.log(\u0026#39;读取失败\u0026#39;) return } console.log(data) }) 删除文件夹 1 2 3 4 5 6 7 fs.rmdir(\u0026#39;./html\u0026#39;, (err, data) =\u0026gt; { if (err) { console.log(\u0026#39;读取失败\u0026#39;) return } console.log(data) }) 递归删除 不推荐使用 1 2 3 4 5 6 7 // fs.rmdir(\u0026#39;./a\u0026#39;, { recursive: true }, err =\u0026gt; { // if (err) { // console.log(\u0026#39;删除失败\u0026#39;) // return // } // console.log(\u0026#39;删除成功\u0026#39;) // }) 推荐使用 1 2 3 4 5 6 7 8 //递归删除 fs.rm(\u0026#39;./a\u0026#39;, { recursive: true }, err =\u0026gt; { if (err) { console.log(\u0026#39;删除失败\u0026#39;) return } console.log(\u0026#39;删除成功\u0026#39;) }) 查看资源信息 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const fs = require(\u0026#39;fs\u0026#39;) //stat 方法 status 缩写 状态 fs.stat(\u0026#39;../资料/笑看风云.mp4\u0026#39;, (err, data) =\u0026gt; { if (err) { console.log(\u0026#39;查看失败\u0026#39;) return } console.log(data) // isFile console.log(\u0026#39;是否为文件\u0026#39;, data.isFile()) // 目标资源是不是一个文件夹 console.log(\u0026#39;是否为文件夹：\u0026#39;, data.isDirectory()) }) // 常用信息size，最后访问时间atime，文件创建时间birthtimeMs，最后修改时间mtime，最后一次修改文件状态的时间ctime fs路径 1 2 3 4 5 6 7 8 9 10 11 12 13 14 const fs = require(\u0026#39;fs\u0026#39;) // 相对路径的bug // 脚本执行的时候，默认参照命令行执行时的所在目录，如： // 以下命令PS D:\\代码学习\\后端\\node.js后端\u0026gt; node ./代码/11-fs路径.js，执行结果html与代码是同级目录 fs.writeFileSync(\u0026#39;./index.html\u0026#39;, \u0026#39;love\u0026#39;) // 绝对路径\u0026#39;全局变量\u0026#39;,本文件的所在目录的绝对路径\u0026#39;__dirname\u0026#39; // 本文件的绝对路径\u0026#39;__filename\u0026#39; console.log(__dirname) //D:\\代码学习\\后端\\node.js后端\\代码 const path = __dirname + \u0026#39;/index.html\u0026#39; fs.writeFileSync(path, \u0026#39;love\u0026#39;) 批量重命名练习 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 const fs = require(\u0026#39;fs\u0026#39;) const path = require(\u0026#39;path\u0026#39;) const basePath = __dirname const files = fs.readdirSync(basePath) let fileList = [] try { files.forEach(item =\u0026gt; { let data = item.split(\u0026#39;-\u0026#39;) let [numStr, ...rest] = data let num = Number(numStr) // 不是【数字-xxx】格式，直接跳过 if (isNaN(num)) return let name = rest.join(\u0026#39;-\u0026#39;) fileList.push({ num, name, item }) }) // 按数字从小到大排序，对象捆绑，不会错位 fileList.sort((a, b) =\u0026gt; a.num - b.num) // 计算最大序号的位数，用来补0 const maxNum = fileList[fileList.length - 1].num const maxLen = String(maxNum).length // 生成所有新文件名 const result = fileList.map(obj =\u0026gt; { const padded = String(obj.num).padStart(maxLen, \u0026#39;0\u0026#39;) return `${padded}-${obj.name}` }) // ==========【试运行预览模式】只打印，不修改任何文件！！========== result.forEach((item, index) =\u0026gt; { console.log(`预览：${fileList[index].item} → ${item}`) }) // ==========【真正重命名代码，预览确认无误后，再取消下面注释！】========== result.forEach((item, index) =\u0026gt; { const oldPath = path.join(basePath, fileList[index].item) const newPath = path.join(basePath, item) fs.renameSync(oldPath, newPath) console.log(`已重命名：${fileList[index].item} → ${item}`) }) } catch (err) { console.log(\u0026#39;出现错误：\u0026#39;) console.log(err) } path模块 path.resolve 解决路径问题的规范（常用） 调试环境：当前 js 文件所在目录（基准路径）：D:\\代码学习\\后端\\node.js后端\\express框架\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const path = require(\u0026#39;path\u0026#39;) console.log(path.resolve(__dirname, \u0026#39;./index.html\u0026#39;)) // D:\\代码学习\\后端\\node.js后端\\express框架\\index.html console.log(path.resolve(__dirname, \u0026#39;index.html\u0026#39;)) // D:\\代码学习\\后端\\node.js后端\\express框架\\index.html // ✅ 上面两者输出结果一致，自动转为系统标准分隔符 Windows为 \\ // ⚠️ 重点坑：第二个参数开头带 /，代表绝对路径，直接抛弃前面所有内容 console.log(path.resolve(__dirname, \u0026#39;/index.html\u0026#39;)) // D:\\index.html // ⚠️ 只要任意片段以 / 开头，该片段左侧全部作废，继续向右拼接剩下片段 console.log(path.resolve(__dirname, \u0026#39;/index.html\u0026#39;, \u0026#39;./test\u0026#39;)) // D:\\index.html\\test path.json 和 path.resolve区别 1 2 3 4 5 6 7 8 9 10 11 12 13 const path = require(\u0026#39;path\u0026#39;) console.log(path.join(__dirname, \u0026#39;./index.html\u0026#39;)) // D:\\代码学习\\后端\\node.js后端\\express框架\\index.html console.log(path.join(__dirname, \u0026#39;index.html\u0026#39;)) // D:\\代码学习\\后端\\node.js后端\\express框架\\index.html // ✅ join不会跳到磁盘根，忽略片段开头的 / console.log(path.join(__dirname, \u0026#39;/index.html\u0026#39;)) // D:\\代码学习\\后端\\node.js后端\\express框架\\index.html console.log(path.join(__dirname, \u0026#39;/index.html\u0026#39;, \u0026#39;./test\u0026#39;)) // D:\\代码学习\\后端\\node.js后端\\express框架\\index.html\\test sep 分隔符，获取当前操作系统的路径分隔符 1 2 3 const path = require(\u0026#39;path\u0026#39;) console.log(path.sep) // window 为 ‘\\’ linux‘/’ parse方法 解析路径并且返回对象 1 2 const path = require(\u0026#39;path\u0026#39;) console.log(path.parse(__filename)) basename路径基础名称，dirname目录名，extname扩展名 3和4都是字符串处理函数，不经过磁盘扫描\n注意 同步函数没有回调参数,如需捕获错误需通过try{}catch{} HTTP模块 获取请求行和请求头信息 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const http = require(\u0026#39;http\u0026#39;) const server = http.createServer((request, response) =\u0026gt; { // 获取请求的方法 console.log(request.method) //GET // 获取请求的 url console.log(request.url) //只包含url中的路径与查询参数 // 获取http 协议的版本号 console.log(request.httpVersionp) // 获取http的请求头 console.log(request.headers.host) response.end(\u0026#39;http\u0026#39;) // 设置响应体 }) server.listen(9000, () =\u0026gt; { console.log(\u0026#39;服务已经启动\u0026#39;) }) 获取请求体信息 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34; /\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;IE=edge\u0026#34; /\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34; /\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;form action=\u0026#34;http://127.0.0.1:9000\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; name=\u0026#34;username\u0026#34; /\u0026gt; \u0026lt;input type=\u0026#34;text\u0026#34; name=\u0026#34;password\u0026#34; /\u0026gt; \u0026lt;input type=\u0026#34;submit\u0026#34; value=\u0026#34;提交\u0026#34; /\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // 响应上方html的post请求 const http = require(\u0026#39;http\u0026#39;) const server = http.createServer((request, response) =\u0026gt; { let body = \u0026#39;\u0026#39; // 绑定data事件 request.on(\u0026#39;data\u0026#39;, chunk =\u0026gt; { console.log(chunk) body += chunk }) // 绑定 end 事件 request.on(\u0026#39;end\u0026#39;, () =\u0026gt; { console.log(body) // 响应 response.end(\u0026#39;Hello HTTP\u0026#39;) }) }) server.listen(9000, () =\u0026gt; { console.log(\u0026#39;服务已经启动\u0026#39;) }) 获取url的请求参数和查询字符串 1 2 3 4 5 6 7 8 9 10 const http = require(\u0026#39;http\u0026#39;) const server = http.createServer((request, response) =\u0026gt; { let url = new URL(request.url, \u0026#39;http://127.0.0.1\u0026#39;) console.log(\u0026#39;pathname输出路径:\u0026#39;, url.pathname) // 访问 /search?keyword=nodejs 输出：/search console.log(url.searchParams.get(\u0026#39;keyword\u0026#39;)) // 访问 /search?keyword=nodejs 输出：nodejs response.end(\u0026#39;url new\u0026#39;) }) server.listen(9000, () =\u0026gt; { console.log(\u0026#39;服务已经启动\u0026#39;) // 服务启动成功打印：服务已经启动 }) http请求练习 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 const http = require(\u0026#39;http\u0026#39;) const server = http.createServer((request, response) =\u0026gt; { // 设置响应头 response.setHeader(\u0026#39;Content-Type\u0026#39;, \u0026#39;text/plain;charset=utf-8\u0026#39;) // 获取url参数 let url = new URL(request.url, \u0026#39;http://127.0.0.1\u0026#39;) const { method } = request const { pathname } = url console.log(method, pathname) if (method !== \u0026#39;GET\u0026#39;) response.end(\u0026#39;请求方式错误\u0026#39;) // 设置响应体 if (pathname == \u0026#39;/login\u0026#39;) { response.end(\u0026#39;登录页面\u0026#39;) // 设置响应体 } else if (pathname == \u0026#39;/reg\u0026#39;) { response.end(\u0026#39;注册页面\u0026#39;) // 设置响应体 } else response.end(\u0026#39;页面未开发\u0026#39;) // 设置响应体 }) server.listen(9000, () =\u0026gt; { console.log(\u0026#39;服务已经启动\u0026#39;) }) 设置http响应报文 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 const http = require(\u0026#39;http\u0026#39;) const server = http.createServer((request, response) =\u0026gt; { // 设置响应状态码 response.statusCode = 200 // 默认状态码为200 // 响应状态码描述 response.statusMessage = \u0026#39;i love u\u0026#39; // 用地较少 // 响应头设置 response.setHeader(\u0026#39;Content-Type\u0026#39;, \u0026#39;text/plain;charset=utf-8\u0026#39;) response.setHeader(\u0026#39;Server\u0026#39;, \u0026#39;node.js\u0026#39;) response.setHeader(\u0026#39;MyHeader\u0026#39;, \u0026#39;linda\u0026#39;) // 设置同名响应头 response.setHeader(\u0026#39;test\u0026#39;, [\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;, \u0026#39;c\u0026#39;]) // 可多次调用的响应体,用了write可不写end response.write(\u0026#39;love\u0026#39;) response.write(\u0026#39;love\u0026#39;) response.write(\u0026#39;love\u0026#39;) response.write(\u0026#39;love\u0026#39;) response.end(\u0026#39;响应报文\u0026#39;) }) server.listen(9000, () =\u0026gt; { console.log(\u0026#39;服务已经启动\u0026#39;) }) 响应报文练习 实现四行三列的表格，点击项对应变色\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 \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34; /\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;IE=edge\u0026#34; /\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34; /\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;style\u0026gt; td { padding: 20px; } table tr:nth-child(odd) { background: rgb(201, 255, 170); } table tr:nth-child(even) { background: rgb(119, 119, 136); } table, td { border-collapse: collapse; } \u0026lt;/style\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;table border=\u0026#34;1\u0026#34;\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;td\u0026gt;test1\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;test1\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;test1\u0026lt;/td\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;td\u0026gt;test1\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;test1\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;test1\u0026lt;/td\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;td\u0026gt;test1\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;test1\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;test1\u0026lt;/td\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;tr\u0026gt; \u0026lt;td\u0026gt;test1\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;test1\u0026lt;/td\u0026gt; \u0026lt;td\u0026gt;test1\u0026lt;/td\u0026gt; \u0026lt;/tr\u0026gt; \u0026lt;/table\u0026gt; \u0026lt;script\u0026gt; // 获取所有td let tds = document.querySelectorAll(\u0026#39;td\u0026#39;) // 点击事件 tds.forEach(item =\u0026gt; { item.onclick = function () { this.style.backgroundColor = \u0026#39;#631\u0026#39; } }) \u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 1 2 3 4 5 6 7 8 9 10 11 12 13 const http = require(\u0026#39;http\u0026#39;) const fs = require(\u0026#39;fs\u0026#39;) const server = http.createServer((request, response) =\u0026gt; { const data = fs.readFileSync(__dirname + \u0026#39;/06-响应报文练习.html\u0026#39;) //end 可读取buffer也可读取字符串 response.end(data) }) server.listen(9000, () =\u0026gt; { console.log(\u0026#39;服务已经启动\u0026#39;) }) 响应练习扩展 如果进行目录及js，css，html拆分，考虑浏览器渲染原理以及响应体的资源请求路由没有判断，一直返回html文件，导致js和css无法导入到html（可从浏览器控制台查看网络接口的访问），因此需要优化\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 const http = require(\u0026#39;http\u0026#39;) const fs = require(\u0026#39;fs\u0026#39;) const server = http.createServer((request, response) =\u0026gt; { const html = fs.readFileSync(__dirname + \u0026#39;/07-响应报文练习.html\u0026#39;) // 需要区分请求,和06不一样了，不然js，css走9000端口返回的都是这个 html // response.end(html) // ❌ const css = fs.readFileSync(__dirname + \u0026#39;/index.css\u0026#39;) const js = fs.readFileSync(__dirname + \u0026#39;/index.js\u0026#39;) // 实例化url对象 let url = new URL(request.url, \u0026#39;http://127.0.0.1\u0026#39;) const { pathname } = url // 输出路径 console.log(\u0026#39;pathname输出路径:\u0026#39;, pathname) if (pathname == \u0026#39;/\u0026#39;) response.end(html) else if (pathname == \u0026#39;/index.css\u0026#39;) response.end(css) else if (pathname == \u0026#39;/index.js\u0026#39;) response.end(js) else response(\u0026#39;404\u0026#39;) }) server.listen(9000, () =\u0026gt; { console.log(\u0026#39;服务已经启动\u0026#39;) }) 当文件有很多的时候，就要再次优化，见后文\n静态资源服务 静态资源的封装导入 目录结构： page属于静态资源目录\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const http = require(\u0026#39;http\u0026#39;) const fs = require(\u0026#39;fs\u0026#39;) const server = http.createServer((request, response) =\u0026gt; { // 实例化url对象 let url = new URL(request.url, \u0026#39;http://127.0.0.1\u0026#39;) const { pathname } = url //拼接文件路径 let filePath = __dirname + \u0026#39;/page\u0026#39; + pathname // 读取文件 fs 异步 API fs.readFile(filePath, (err, data) =\u0026gt; { if (err) { response.statusCode = 500 response.end(\u0026#39;文件读取失败\u0026#39;) return } //响应文件内容 response.end(data) }) }) server.listen(9000, () =\u0026gt; { console.log(\u0026#39;服务已经启动\u0026#39;) }) 实现静态资源配置，静态资源有变化不需要和往常一样重复操作导入\n静态资源的url引用 url、src等，常用\u0026lt;link rel=\u0026quot;stylesheet\u0026quot; href=\u0026quot;/css/index.css\u0026quot; /\u0026gt;，\u0026rsquo;./\u0026lsquo;的话会有隐藏bug，后文模板引擎部分会讲，直接用\u0026rsquo;/\u0026lsquo;可以防止域名或者端口发生变化，从而直接拼接。\nmime类型 response.setHeader('ContentType','xxx),\u0026lsquo;xxx\u0026rsquo;指的就是文件类型的判断，但是通常浏览器已经有这个嗅觉了\n手动判断也更规范，可以用path.exthname()来判断后缀从而匹配\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const filePath = path.join(__dirname, \u0026#39;/css/index.css\u0026#39;) const extWithDot = path.extname(filePath) // \u0026#39;.css\u0026#39; const ext = extWithDot.slice(1).toLowerCase() // \u0026#39;css\u0026#39; // mime映射表 const mime = { html: \u0026#39;text/html;charset=utf-8\u0026#39;, css: \u0026#39;text/css;charset=utf-8\u0026#39;, js: \u0026#39;application/javascript;charset=utf-8\u0026#39;, png: \u0026#39;image/png\u0026#39;, jpg: \u0026#39;image/jpeg\u0026#39; } const contentType = mime[ext] || \u0026#39;application/octet-stream\u0026#39; console.log(contentType) // text/css;charset=utf-8 err错误处理 通过读取err打印出来的code类型去进行状态码反馈，如404、403等，详情可见官网的error的错误类型\nCommonJS规范模块化 暴露数据及文件导入 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const duanzi = function () { console.log(\u0026#39;我是个段子\u0026#39;) } const lezi = function () { console.log(\u0026#39;我是个乐子\u0026#39;) } // exports.duanzi = duanzi // exports.lezi = lezi // 直接用这种比较好，因为exports不能用直接=value的方式，而module.exports = \u0026#39;111\u0026#39;可以 // 因为require返回结果是module中的exports的值而不是exports。因此当exports = 521的时候拿不到结果 // exports.duanzi = duanzi可以是因为属于对象的引用数据（地址），modules同样可以访问到 exports = modules.exports = {} module.exports = { duanzi, lezi } 1 2 3 4 5 6 7 8 9 10 11 const modules = require(\u0026#39;./01-暴露的方式\u0026#39;) console.log(modules) console.log(modules.duanzi()) console.log(modules.lezi()) log: { duanzi: [Function: duanzi], lezi: [Function: lezi] } 我是个段子 undefined 我是个乐子 undefined ps：\n不加文件后缀也会直接匹配，如果同名会从js优先匹配 自定义后缀文件如.abc文件，默认为js文件 路径推荐使用相对路径\u0026rsquo;./\u0026rsquo; 导入文件夹 会检查是否有package.json文件是否存在，并且检查其main属性的指向文件。如果存在则导入，不存在则报错。 如package.json文件不存在，则默认检查文件夹下的index.js或者index.json文件，没有则报错 1 2 3 { \u0026#34;main\u0026#34;: \u0026#34;./app.js\u0026#34; } require的导入原理 将相对路径转为绝对路径，定位目标文件 缓存检测 读取文件代码 包裹为一个函数，然后执行 缓存结果 返回module.exports的值 包管理工具 express框架 创建express项目：npm init =\u0026gt; npm i express\n路由 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 // 导入express const express = require(\u0026#39;express\u0026#39;) // 创建应用对象 const app = express() // 创建路由 app.get(\u0026#39;/\u0026#39;, (req, res) =\u0026gt; { res.end(\u0026#39;hello express\u0026#39;) }) app.get(\u0026#39;/home\u0026#39;, (req, res) =\u0026gt; { res.end(\u0026#39;home\u0026#39;) }) app.post(\u0026#39;/login\u0026#39;, (req, res) =\u0026gt; { res.end(\u0026#39;login login\u0026#39;) }) // 所有请求都可跳转 app.all(\u0026#39;/test\u0026#39;, (req, res) =\u0026gt; { res.end(\u0026#39;test,all request is ok\u0026#39;) }) // 通配符 404响应，兜底路由规则 app.all(\u0026#39;*\u0026#39;, (req, res) =\u0026gt; { res.end(\u0026#39;404 Not Found\u0026#39;) }) app.listen(3000, () =\u0026gt; { console.log(\u0026#39;express服务已启动，端口3000正在监听中...\u0026#39;) }) 获取请求参数 兼容原生，大同小异\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 // 导入express const express = require(\u0026#39;express\u0026#39;) // 创建应用对象 const app = express() // 创建路由 app.get(\u0026#39;/\u0026#39;, (req, res) =\u0026gt; { // 原生操作 console.log(\u0026#39;[ req.url ] \u0026gt;\u0026#39;, req.url) console.log(\u0026#39;[ req.method ] \u0026gt;\u0026#39;, req.method) console.log(\u0026#39;[ req.httpVersion ] \u0026gt;\u0026#39;, req.httpVersion) console.log(\u0026#39;[ req.headers ] \u0026gt;\u0026#39;, req.headers) // express 操作 console.log(\u0026#39;[ req.path ] \u0026gt;\u0026#39;, req.path) console.log(\u0026#39;[ req.query ] \u0026gt;\u0026#39;, req.query) // 获取IP console.log(\u0026#39;[ req.ip ] \u0026gt;\u0026#39;, req.ip) // 获取请求头 console.log(\u0026#39;[ req.host ] \u0026gt;\u0026#39;, req.host) res.end(\u0026#39;hello express\u0026#39;) }) app.listen(3000, () =\u0026gt; { console.log(\u0026#39;express服务已启动，端口3000正在监听中...\u0026#39;) }) log:---------------------- [ req.url ] \u0026gt; /?a=100\u0026amp;b=200 [ req.method ] \u0026gt; GET [ req.httpVersion ] \u0026gt; 1.1 [ req.headers ] \u0026gt; { host: \u0026#39;127.0.0.1:3000\u0026#39;, connection: \u0026#39;keep-alive\u0026#39;, \u0026#39;cache-control\u0026#39;: \u0026#39;max-age=0\u0026#39;, \u0026#39;sec-ch-ua\u0026#39;: \u0026#39;\u0026#34;Chromium\u0026#34;;v=\u0026#34;154\u0026#34;, \u0026#34;Google Chrome\u0026#34;;v=\u0026#34;154\u0026#34;, \u0026#34;Not A(Brand\u0026#34;;v=\u0026#34;99\u0026#34;\u0026#39;, // ...省略 } [ req.path ] \u0026gt; / [ req.query ] \u0026gt; [Object: null prototype] { a: \u0026#39;100\u0026#39;, b: \u0026#39;200\u0026#39; } [ req.ip ] \u0026gt; ::ffff:127.0.0.1 [ req.host ] \u0026gt; 127.0.0.1:3000 获取路由参数 （params） 示例：京东商城的商品访问，路由路径皆为各个商品的id.html，因此通过动态路由匹配，获取对应路由信息，从而匹配对应的商品资源\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 导入express const express = require(\u0026#39;express\u0026#39;) // 创建应用对象 const app = express() // 创建路由 // app.get(\u0026#39;/1000031.html\u0026#39;, (req, res) =\u0026gt; { // res.setHeader(\u0026#39;content-type\u0026#39;, \u0026#39;text/html;charset=utf-8\u0026#39;) // res.end(\u0026#39;商品详情\u0026#39;) // }) // 但是商品有成千上万，因此要做动态路由的匹配 app.get(\u0026#39;/:id.html\u0026#39;, (req, res) =\u0026gt; { // 获取url 路由参数,字段名字\u0026#39;.id\u0026#39;需要和路由保持一致 console.log(\u0026#39;[ req.params.id ] \u0026gt;\u0026#39;, req.params.id) res.setHeader(\u0026#39;content-type\u0026#39;, \u0026#39;text/html;charset=utf-8\u0026#39;) res.end(\u0026#39;商品详情\u0026#39;) }) app.listen(3000, () =\u0026gt; { console.log(\u0026#39;express服务已启动，端口3000正在监听中...\u0026#39;) }) 路由参数练习 require('./xxx.json')：一次性加载、不会实时更新，适合项目固定配置（服务启动后json不再改动）。 fs读取 + JSON.parse：每次读取都拿最新文件内容，适合运行中会修改的json数据。 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 // 导入express const express = require(\u0026#39;express\u0026#39;) const { singers } = require(\u0026#39;./05-singers.json\u0026#39;)\t// 为何不用fs见上文 // 创建应用对象 const app = express() // 创建路由 app.get(\u0026#39;/singers/:id.html\u0026#39;, (req, res) =\u0026gt; { let result = singers.find(item =\u0026gt; { if (item.id == req.params.id) { return true } }) console.log(\u0026#39;[ result ] \u0026gt;\u0026#39;, result) if (!result) { res.end(\u0026#39;404 没有找到对应歌手\u0026#39;) return } res.end(` \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34; /\u0026gt; \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;IE=edge\u0026#34; /\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34; /\u0026gt; \u0026lt;title\u0026gt;Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h2\u0026gt;${result.singer_name}\u0026lt;/好\u0026gt; \u0026lt;br\u0026gt; \u0026lt;img src=\u0026#34;${result.singer_pic}\u0026#34;/\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; `) }) app.listen(3000, () =\u0026gt; { console.log(\u0026#39;express服务已启动，端口3000正在监听中...\u0026#39;) }) 设置响应 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 // 导入express const express = require(\u0026#39;express\u0026#39;) // 创建应用对象 const app = express() // 创建路由 app.get(\u0026#39;/response\u0026#39;, (req, res) =\u0026gt; { // 原生响应 // res.statusCode = 200 // 默认状态码为200 // res.statusMessage = \u0026#39;i love u\u0026#39; // res.setHeader(\u0026#39;Content-Type\u0026#39;, \u0026#39;text/plain;charset=utf-8\u0026#39;) // res.write(\u0026#39;love\u0026#39;) // res.end(\u0026#39;响应报文\u0026#39;) // express响应 // res.status(500) // res.set(\u0026#39;aaa\u0026#39;, \u0026#39;bbb\u0026#39;) // res.send(\u0026#39;你好 Express\u0026#39;) // express的send方法内置utf-8配置 // 连贯设置 // res.status(500).set(\u0026#39;abc\u0026#39;, \u0026#39;def\u0026#39;).send(\u0026#39;这都是 OK 的\u0026#39;) }) app.listen(3000, () =\u0026gt; { console.log(\u0026#39;express服务已启动，端口3000正在监听中...\u0026#39;) }) 其他响应 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 导入express const express = require(\u0026#39;express\u0026#39;) // 创建应用对象 const app = express() // 创建路由 app.get(\u0026#39;/other\u0026#39;, (req, res) =\u0026gt; { // 跳转响应 // res.redirect(\u0026#39;http://atguigu.com\u0026#39;) // 下载响应 // res.download(__dirname + \u0026#39;/package.json\u0026#39;) // JSON响应 // res.json({ // name: \u0026#39;linda\u0026#39;, // slogon: \u0026#39;我想要工作\u0026#39; // }) //响应文件内容 // res.sendFile(__dirname + \u0026#39;/02-form.html\u0026#39;) }) app.listen(3000, () =\u0026gt; { console.log(\u0026#39;express服务已启动，端口3000正在监听中...\u0026#39;) }) express中间件 概念 本质是一个回调函数 参数：和路由函数类似，（req，res） 作用 封装公共操作，简化代码 类型 全局中间件 路由中间件 全局中间件 常用场景： 访问日志打印（如下例子）\n全局错误捕获\nCORS跨域处理（设置响应头，放行跨域请求）\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 /** * 记录url和ip */ // 导入express const express = require(\u0026#39;express\u0026#39;) const fs = require(\u0026#39;fs\u0026#39;) const path = require(\u0026#39;path\u0026#39;) // 创建应用对象 const app = express() // 声明中间件函数 function recordMiddleware(req, res, next) { // 获取url和ip let { url, ip } = req console.log(\u0026#39;[ url,ip ] \u0026gt;\u0026#39;, url, ip) // 将信息保存于文件中 // fs.appendFileSync(\u0026#39;./08-access.log\u0026#39;, `${url} ${ip}\\r\\n`) // ❌！！！这里写的时候路径直接用了\u0026#39;./\u0026#39;，回顾上文这里文件创建会取决于cmd的执行目录 fs.appendFileSync(path.resolve(__dirname, \u0026#39;08-access.log\u0026#39;), `${url} ${ip}\\r\\n`) // 调用next，必须加 next() } // 全局中间件挂载 app.use(recordMiddleware) // 创建路由 app.get(\u0026#39;/home\u0026#39;, (req, res) =\u0026gt; { res.send(\u0026#39;前台首页\u0026#39;) }) app.get(\u0026#39;/admin\u0026#39;, (req, res) =\u0026gt; { res.send(\u0026#39;后台首页\u0026#39;) }) // Express5 无法使用之前的app.all(\u0026#39;*\u0026#39;,()=\u0026gt;{})来做404。 app.use((req, res) =\u0026gt; { res.status(404).send(\u0026#39;404 页面不存在\u0026#39;) }) app.listen(3000, () =\u0026gt; { console.log(\u0026#39;express服务已启动，端口3000正在监听中...\u0026#39;) }) 路由中间件 常用场景：页面检验登录信息 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 /** * 针对/admin，/setting的请求，要求url携带code=521参数，如未携带显示暗号错误 */ // 导入express const express = require(\u0026#39;express\u0026#39;) // 创建应用对象 const app = express() // 创建路由 app.get(\u0026#39;/home\u0026#39;, (req, res) =\u0026gt; { res.send(\u0026#39;主页\u0026#39;) }) // 创建路由中间件 let checkCodeMiddleware = (req, res, next) =\u0026gt; { if (req.query.code === \u0026#39;521\u0026#39;) { next() } else { res.send(\u0026#39;暗号错误\u0026#39;) } } app.get(\u0026#39;/setting\u0026#39;, checkCodeMiddleware, (req, res) =\u0026gt; { res.send(\u0026#39;后台设置\u0026#39;) }) app.get(\u0026#39;/admin\u0026#39;, checkCodeMiddleware, (req, res) =\u0026gt; { res.send(\u0026#39;后台首页\u0026#39;) }) // Express5 无法使用之前的app.all(\u0026#39;*\u0026#39;,()=\u0026gt;{})来做404。 app.use((req, res) =\u0026gt; { res.status(404).send(\u0026#39;404 页面不存在\u0026#39;) }) app.listen(3000, () =\u0026gt; { console.log(\u0026#39;express服务已启动，端口3000正在监听中...\u0026#39;) }) 静态资源中间件 区别于“08-静态资源服务”栏目的内容，这里更加便捷了 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 导入express const express = require(\u0026#39;express\u0026#39;) const path = require(\u0026#39;path\u0026#39;) // 创建应用对象 const app = express() // 静态资源中间件设置 app.use(express.static(path.resolve(__dirname, \u0026#39;./public\u0026#39;))) // 创建路由 app.get(\u0026#39;/\u0026#39;, (req, res) =\u0026gt; { res.send(\u0026#39;我才是真的表格！！！\u0026#39;) }) // Express5 无法使用之前的app.all(\u0026#39;*\u0026#39;,()=\u0026gt;{})来做404。 app.use((req, res) =\u0026gt; { res.status(404).send(\u0026#39;404 页面不存在\u0026#39;) }) app.listen(3000, () =\u0026gt; { console.log(\u0026#39;express服务已启动，端口3000正在监听中...\u0026#39;) }) 注意点 静态资源匹配下，如果根路由没有返回，则会自动匹配public下的index.html文件 如果静态资源匹配和路由匹配相同，谁先响应匹配谁，看同步函数的代码顺序 获取请求体数据 使用中间件body-parser 1 npm i body-parser 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 /** * 搭建HTTP服务 * * GET /login 登录页 * POST /login 获取表单中的用户名和密码 */ const express = require(\u0026#39;express\u0026#39;) const path = require(\u0026#39;path\u0026#39;) // 导入中间件 const bodyParser = require(\u0026#39;body-parser\u0026#39;) // 创建实例 const app = express() // 解析JSON格式的请求体中间件 const jsonParser = bodyParser.json() // 解析querystring格式的请求体中间件 const urlencodeedParser = bodyParser.urlencoded({ extended: false }) app.get(\u0026#39;/login\u0026#39;, (req, res) =\u0026gt; { res.sendFile(path.resolve(__dirname, \u0026#39;./11-form.html\u0026#39;)) }) app.post(\u0026#39;/login\u0026#39;, urlencodeedParser, (req, res) =\u0026gt; { console.log(\u0026#39;[ req.body ] \u0026gt;\u0026#39;, req.body) res.send(\u0026#39;获取表单响应\u0026#39;) }) app.listen(3000, () =\u0026gt; { console.log(\u0026#39;在监听3000端口\u0026#39;) }) 防盗链 防止外部网站盗用我们的网站资源 如请求头的referer字段，显示了请求域名是多少，判断为外来域名，因此图片不返回响应 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 const express = require(\u0026#39;express\u0026#39;) const path = require(\u0026#39;path\u0026#39;) const app = express() // 防盗链中间件 app.use((req, res, next) =\u0026gt; { // 检测请求头中的referer 是否为127.0.0.1 let referer = req.get(\u0026#39;referer\u0026#39;) console.log(\u0026#39;[ referer ] \u0026gt;\u0026#39;, referer) if (referer) { const url = new URL(referer) let hostname = url.hostname if (hostname !== \u0026#39;127.0.0.1\u0026#39;) { res.status(404).send(\u0026#39;\u0026lt;h1\u0026gt;404 NOT FOUND\u0026lt;/h1\u0026gt;\u0026#39;) return } } next() }) app.use(express.static(path.resolve(__dirname, \u0026#39;./public\u0026#39;))) app.get(\u0026#39;/\u0026#39;, (req, res) =\u0026gt; { res.send(\u0026#39;Hello World!\u0026#39;) }) app.listen(3000, () =\u0026gt; { console.log(\u0026#39;Server is running on port 3000\u0026#39;) }) 此处防盗链防止了127.0.0.1以外的域名访问静态资源，如果想只防盗图片的访问的话，则需要再做一层校验\n路由模块化 *（开发范式） 路由的模块化更容易维护，如中间件和相同类似的路由维护。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // ./routes/homeRouter.js // 导入express const express = require(\u0026#39;express\u0026#39;) // 创建路由对象 const router = express.Router() // 创建路由规则 router.get(\u0026#39;/home\u0026#39;, (req, res) =\u0026gt; { res.send(\u0026#39;主页\u0026#39;) }) router.get(\u0026#39;/home/search\u0026#39;, (req, res) =\u0026gt; { res.send(\u0026#39;路由搜索\u0026#39;) }) // 暴露router module.exports = router 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 /** * 针对/admin，/setting的请求，要求url携带code=521参数，如未携带显示暗号错误 */ // 导入express const express = require(\u0026#39;express\u0026#39;) const homeRouter = require(\u0026#39;./routes/homeRouter\u0026#39;) const adminRouter = require(\u0026#39;./routes/adminRouter\u0026#39;) // 创建应用对象 const app = express() // 路由导入 app.use(homeRouter) app.use(adminRouter) app.listen(3000, () =\u0026gt; { console.log(\u0026#39;express服务已启动，端口3000正在监听中...\u0026#39;) }) 路由导入模板时可增加路由前缀app.use('home',homeRouter)，此时homeRouter.js的文件可变化为 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // ./routes/homeRouter.js // 导入express const express = require(\u0026#39;express\u0026#39;) // 创建路由对象 const router = express.Router() // 创建路由规则 router.get(\u0026#39;/\u0026#39;, (req, res) =\u0026gt; { res.send(\u0026#39;主页\u0026#39;) }) router.get(\u0026#39;/search\u0026#39;, (req, res) =\u0026gt; { res.send(\u0026#39;路由搜索\u0026#39;) }) // 暴露router module.exports = router ejs模板引擎(了解即可) EJS：嵌入式JS模板引擎，Express服务端渲染SSR，类比Java Thymeleaf / Python Jinja2 学习目标：看懂基础语法，理解SSR思想，新项目基本不用\n安装 1 npm i express ejs Express配置 1 2 3 4 5 const express = require(\u0026#39;express\u0026#39;) const app = express() app.set(\u0026#39;view engine\u0026#39;,\u0026#39;ejs\u0026#39;) // 设置模板引擎 app.set(\u0026#39;views\u0026#39;,\u0026#39;./views\u0026#39;) // 模板存放目录，默认views app.listen(3000,()=\u0026gt;console.log(\u0026#39;服务启动\u0026#39;)) 目录：项目/views/xxx.ejs\n路由渲染 1 2 3 4 app.get(\u0026#39;/\u0026#39;,(req,res)=\u0026gt;{ // res.render(模板名, 传给模板的数据) res.render(\u0026#39;index\u0026#39;,{name:\u0026#39;小明\u0026#39;,arr:[\u0026#39;A\u0026#39;,\u0026#39;B\u0026#39;]}) }) EJS标记 标记 作用 \u0026lt;% js代码 %\u0026gt; 执行JS，不输出（if/for） \u0026lt;%= 变量 %\u0026gt; 输出变量，自动转义HTML，防XSS ✅推荐 \u0026lt;%- 变量 %\u0026gt; 原样输出HTML标签，不安全慎用 \u0026lt;%- include('part/header') %\u0026gt; 引入公共模板片段 模板示例 views/index.ejs 1 2 3 4 5 6 7 8 9 \u0026lt;p\u0026gt;名字：\u0026lt;%= name %\u0026gt;\u0026lt;/p\u0026gt; \u0026lt;% if(name){ %\u0026gt; \u0026lt;p\u0026gt;有用户名\u0026lt;/p\u0026gt; \u0026lt;% } %\u0026gt; \u0026lt;ul\u0026gt; \u0026lt;% for(let item of arr){ %\u0026gt; \u0026lt;li\u0026gt;\u0026lt;%= item %\u0026gt;\u0026lt;/li\u0026gt; \u0026lt;% } %\u0026gt; \u0026lt;/ul\u0026gt; 要点总结 模板JS运行在服务器，不是浏览器，即SSR res.render 自动读取ejs，拼接完整HTML返回浏览器 适用：老项目；现代开发优先前后端分离，SSR用Next/Nuxt \u0026lt;%- include() 复用页面头部底部 我发现res.sendFile和res.render都可以响应html文件，区别就是render属于服务端渲染，后续可加参数进行模板传值，而sendFile是读取磁盘上的静态资源文件 express-generator工具，快速搭建express框架项目 不需要再重复一点一点敲，直接在脚手架基础上开发即可 1 2 3 4 5 6 7 8 //方式1. npx express-generator //方式2. npm install -g express-generator //创建方式 express -e 15-generator //即可创建出该命名项目，-e表示支持ejs 处理文件上传 npm i formidable,这里使用的是npm install formidable@2版本，目前是3 基于express-geanrator搭建框架，在index.js做文件上传路由操作\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 var express = require(\u0026#39;express\u0026#39;) var router = express.Router() const formidable = require(\u0026#39;formidable\u0026#39;) /* GET home page. */ router.get(\u0026#39;/\u0026#39;, function (req, res, next) { res.render(\u0026#39;index\u0026#39;, { title: \u0026#39;Express\u0026#39; }) }) //显示网页的表单 router.get(\u0026#39;/portrait\u0026#39;, function (req, res) { res.render(\u0026#39;portrait\u0026#39;) }) router.post(\u0026#39;/portrait\u0026#39;, function (req, res) { //创建form对象 const form = formidable({ multiples: true, // 设置上传文件的保存目录 uploadDir: __dirname + \u0026#39;/../public/images\u0026#39;, // 保存文件后缀 keepExtensions: true }) // 解析请求报文 form.parse(req, (err, fields, files) =\u0026gt; { if (err) { next(err) return } // console.log(\u0026#39;[ fields ] \u0026gt;\u0026#39;, fields) // 保存非文件数据的字段 {username:\u0026#39;abc\u0026#39;} // console.log(\u0026#39;[ files ] \u0026gt;\u0026#39;, files) // 保存文件数据的字段 // 服务器保存该图片的访问url // /images/bzu57fhd4mc7t2x16kx294qz5.jpg let url = \u0026#39;/images/\u0026#39; + files.portrait.newFilename // 将来将此数据保存在数据库中 res.send(url) }) }) module.exports = router 对应的portrait.ejs\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 文件上传 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34; /\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34; /\u0026gt; \u0026lt;title\u0026gt;文件上传\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;!-- enctype=\u0026#34;multipart/form-data\u0026#34; 文件上传的必须属性 --\u0026gt; \u0026lt;form action=\u0026#34;/portrait\u0026#34; method=\u0026#34;post\u0026#34; enctype=\u0026#34;multipart/form-data\u0026#34;\u0026gt; 用户名 \u0026lt;input type=\u0026#34;text\u0026#34; name=\u0026#34;username\u0026#34; /\u0026gt; 头像 \u0026lt;input type=\u0026#34;file\u0026#34; name=\u0026#34;portrait\u0026#34; /\u0026gt; \u0026lt;button type=\u0026#34;submit\u0026#34;\u0026gt;上传\u0026lt;/button\u0026gt; \u0026lt;/form\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 唯一id标识npm包 uuid 1 2 const { v4: uuidv4 } = require(\u0026#39;uuid\u0026#39;) const id = uuidv4() // 示例：b3a2f078-901c-4221-b978-27218ac34123 nanoid 1 2 const { nanoid } = require(\u0026#39;nanoid\u0026#39;) const id = nanoid(10) // 10位短字符串 ","date":"2026-09-23T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/nodejs-fullstack-notes/","title":"Node.js 全栈学习笔记（持续更新）"},{"content":"我最近在看 NandhaKishorM/laya。它经常和 Jev 放在一起讨论，原因很简单：两者都不是用来聊天的，而是让模型回答一些程序能直接使用的问题。\n比如收到一封邮件，程序真正关心的可能只有三件事：\n应该交给账单组还是技术组？ 用户有没有明确要求退款？ 这件事要不要马上处理？ 如果让普通大模型回答，通常要让它写一段话，再从话里解析 JSON。Laya 的做法是把问题和答案范围先写清楚，模型直接返回选项、分数和概率。后面的分流、审核、调用哪个模型，仍由你的代码负责。\n本文根据 Laya 仓库 42626c3 提交整理，包版本为 0.3.4。没有在本机下载权重，也没有把仓库里的 Jev 跑分当成同条件实测。\n先跑一个小例子 Laya 的调用始终围绕两个东西：要判断的 state，以及问题 questions。\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 import laya agent = laya.load( \u0026#34;convaiinnovations/laya\u0026#34;, subfolder=\u0026#34;multilingual\u0026#34;, ) result = agent.predict( {\u0026#34;message\u0026#34;: \u0026#34;同一笔订单扣了两次钱，请今天帮我退款。\u0026#34;}, { \u0026#34;team\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;choice\u0026#34;, \u0026#34;instructions\u0026#34;: \u0026#34;Which team should handle this message?\u0026#34;, \u0026#34;criteria\u0026#34;: { \u0026#34;billing\u0026#34;: \u0026#34;charges, invoices, refunds\u0026#34;, \u0026#34;technical\u0026#34;: \u0026#34;bugs and outages\u0026#34;, \u0026#34;sales\u0026#34;: \u0026#34;pricing and purchases\u0026#34;, \u0026#34;other\u0026#34;: \u0026#34;none of the above\u0026#34;, }, }, \u0026#34;refund_requested\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;noul\u0026#34;, \u0026#34;instructions\u0026#34;: \u0026#34;Does the message explicitly request a refund?\u0026#34;, }, }, ) print(result[\u0026#34;answers\u0026#34;][\u0026#34;team\u0026#34;][\u0026#34;choice\u0026#34;]) print(result[\u0026#34;answers\u0026#34;][\u0026#34;refund_requested\u0026#34;][\u0026#34;noul\u0026#34;]) choice 是从几个选项里选一个，noul 是判断一个命题为真的概率，另外还有按等级评分的 score。一批问题可以一起提交，但它们互相看不到答案；如果第二个问题依赖第一个结果，就由业务代码再发起下一步。\n这里有个容易误会的地方：score 的结果是等级索引的加权平均，不是测量出来的真实数值。比如三个等级的概率是 [0.1, 0.3, 0.6]，分数是 1.5，它只能说明结果更靠近高等级，不能直接解释成“75% 紧急”或“还剩几小时”。\n它在模型里做了什么 Laya 的核心代码在 laya/agent.py 和 laya/common.py。\n调用时，程序会把问题说明、候选答案和 state 拼成一条输入。每个候选答案前面留一个标记，双向 Transformer 读完整条序列后，scorer 只在这些标记位置上打分。最后再把分数转成概率。\n大致可以画成：\n1 2 3 4 5 6 7 问题说明 + 候选项 + state ↓ Encoder ↓ 给每个候选项打分 ↓ 概率和结果 它不生成自然语言，所以没有“生成一段 JSON，再尝试解析”的步骤。这样确实让接口更稳定，但不代表判断就一定正确。它仍然可能把一封退款邮件分到技术组，只是返回的格式不会突然变成一段解释文字。\n一次调用不等于只算一次正文 代码会为每个问题各自拼一条序列，然后把这些序列组成 batch，一次送进模型。\n所以，同一封邮件问十个问题，并不是“正文只编码一次，十个问题免费复用”；实际是十条输入一起算，只是批处理比十次完全独立调用更高效。仓库在 T4 上测到多语言模型单问题约 32.8ms，十个问题约 72.3ms。这个数字是作者的测试环境，不能直接当成你电脑上的保证。\nRouter 到底路由什么 Laya 里有两个容易混淆的名字：\nRouter：在 Laya 自己的几个 checkpoint 之间选择； router_questions()：一组“请求属于什么领域、难度如何”的问题模板。 Router 不会替你选择 GPT、Claude 或 Codex。它现在主要在三个 checkpoint 之间选：\nlaya：英语，ModernBERT-large； laya-multilingual：多语言，mmBERT-base； laya-typed-decisions：针对四类业务流程微调的版本。 它的语言判断不是再调用一个模型。laya/lang.py 会统计 Unicode 字符，先判断文字系统；如果是拉丁文字，再用停用词和变音符号猜测部分语言。检测到中文、日文、阿拉伯文等，就会倾向多语言 checkpoint。\n这一步很有必要。仓库的测试里，英文模型遇到非英语文字时可能“很自信地答错”，所以不能等模型输出一个低置信度再补救，应该先决定用哪个模型。\n默认的 Router() 还会懒加载模型，并用 LRU 控制内存。如果中英文请求交替到来，模型可能反复卸载和加载；服务端通常应该预加载需要的 checkpoint。typed-decisions 也不是默认自动选择的，除非你显式指定，或者开启代码里那几个固定问题 ID 的识别。\nconfidence 不是“正确率” Laya 的 choice 和 score 会返回 probabilities 和 confidence。代码里的 confidence 是根据概率分布的熵算出来的：概率越集中，数值越高；几个选项差不多，数值越低。\n它不是最高选项的概率，也不是“这次答对的概率”。二选一给出 [0.9, 0.1] 时，按 Laya 的公式，confidence 约为 0.53，不会直接返回 0.9。noul 又是另一种形式，直接返回“为真”的概率。\n因此，不能把所有问题都套一个 0.85 阈值。退款、删数据、改权限这类操作，应该用自己的验证集确定门槛，低置信度进入人工或大模型复核。仓库也明确提到基础模型存在过度自信，需要重新做 temperature calibration。\n另外，代码里有个 act_head，会返回 act_probability。当前实现只是把这个值放在结果里，并没有替你执行动作，也不能因为字段名字像“是否行动”就把它当成生产开关。\nLaya 和 Jev，区别不只是“一个开源一个闭源” 两者的接口思路很像：给 state 和 questions，支持 choice、score、noul，一次请求回答多个问题。这也是它们经常被放在一起比较的原因。\n但使用时差别很明显：\n项目 Laya Jev 运行方式 下载 Hugging Face 权重，本地 Python/PyTorch 推理 调用 TypeSafe 托管 API 能否看实现 编码器、打分头、训练 notebook 都在仓库里 官方没有公开可逐层核对的模型实现 适配业务 改问题，也可以自己微调 checkpoint 主要通过 state、instructions、criteria 适配，官方不提供客户级 LoRA 上下文 常见配置为 512 或 1024 token，问题和正文共用预算 官方文档给出 64k 请求上限，state 还有单独限制 Choice 选项 默认选项预算较紧，仓库建议控制在约 20 个以内 API 最多 255 个选项 运维 自己负责权重、显存、并发、冷启动 不维护模型，但要处理 API key、网络、限流和版本 成本 自托管没有调用费，但要承担机器成本 按输入 token 计费，输出 token 不单独计费 Jev 的具体上限和接口可以看 官方 Models 文档 与 API 文档。Laya 的限制主要来自 build_sequence()：它会先给问题和候选项分配一段固定预算，剩下的才给正文。选项太多时，每个标签被压缩得很短，分类自然容易掉点。\nJev 的上下文更长，但也不是万能的。官方已经列出它在算术、日期比较、多步间接推理、无关长文本和对抗性内容上的问题。两者都有一个共同原则：能用代码准确算的事情，就不要交给模型。\n跑分应该怎样看 Laya 仓库里有和 Jev 的对比表，但研究说明也写了限制：Jev 的数字来自第三方公开结果，Laya 没有在同一环境调用 Jev，因此不是严格的 A/B 测试。\n还有两个容易被忽略的细节：\n第一，typed-decisions 的 0.766 来自专门微调过的 laya-typed-decisions，不是默认基础模型的零样本成绩。仓库里基础模型在这组任务上只有约 0.34，低于多数类基线。\n第二，准确率和概率质量不是一回事。Laya 微调版在某组测试中的最终选项准确率高于 Jev 引用值，但软分布匹配反而更低。要拿概率做自动放行，就不能只看“选中了谁”，还要看概率是否经过校准。\n所以，文章里可以说“Laya 在部分公开任务上有竞争力”，不能简单写成“全面超过 Jev”。\n它适合放在什么位置 我觉得最实际的接法有三种：\n入口分流：先把请求分成账单、技术、销售，再交给对应流程。 RAG 过滤：检索拿到候选段落后，先判断哪些段落真的相关，再送给生成模型。向量库、引用和最终回答仍由你的系统负责。 Agent 闸门：判断是否需要工具、是否有风险、是否必须人工确认。 如果想用它来节省 token，关键不是“让 Laya 替代大模型”，而是让一些请求根本不用走最贵的路径。前提是 Laya 本地运行足够便宜，而且路由结果能改变后续流程。每次请求都先远程调用一次 Laya，再调用大模型，收益可能就不明显了。\n和 Codex 配合也一样：Codex 可以帮你写接入代码，但 Laya 不会自动接管 Codex 的内部模型选择。你需要自己提供 Python 服务、HTTP 接口或 MCP 工具，把 Laya 的结果交给调度层。\n最后怎么评价它 Laya 更像一个可以自己部署、自己微调的判断模型底座，不是完整的 Agent 平台，也不是 GPT 的替代品。它最适合固定问题、有限选项、请求量较大，而且你愿意准备数据和验证集的场景。\nJev 更像开箱即用的托管服务：不用管权重和显存，输入空间也更宽，但结果依赖远程 API，模型版本和成本由服务方控制。\n如果只是想试一下 System One 这种“只返回判断、不生成文字”的思路，Laya 很适合读源码。真正接入生产环境前，我会先拿自己的请求测试三件事：截断会不会影响结果、低置信度能不能真的筛出风险、模型冷启动和常驻内存是否能接受。\n先把一个具体判断测清楚，再决定要不要把它放进整套 Agent。\n","date":"2026-09-21T18:30:00+08:00","image":"https://linsk27.github.io/img/projects/laya-devto-cover.png","permalink":"https://linsk27.github.io/projects/laya/","title":"Laya：把“该怎么处理”先判断清楚"},{"content":"准备技术面试时，最容易做的事是继续收藏资料，最难的事却是把一道题讲清楚。答案看过不少，轮到自己开口，常常只剩几个关键词；隔几天再看，又像第一次见。\n我做“面试边注”，就是想把刷题从“看过答案”往前推一步：先自己组织，再看结论和原理；写下回答后，可以让 AI 面试官指出哪里说对了、下一步最该补什么；如果某个地方还是绕，也可以围绕当前题继续问 AI。\n目前平台整理了 14 个公开题库、50 个章节和 762 道题，覆盖前端、Java 后端、AI 应用与求职专项。游客不用注册就能读题、搜索、使用练习模式、AI 面试官评分和 AI 助手；个人学习记录需要登录后保存。\n直接进入面试边注\n先选方向，不必从第一题硬背 题库按学习方向组织，而不是把几百道题塞进一个长目录：\n方向 当前题量 主要内容 前端开发 300 JavaScript、Vue、React、浏览器、工程化与 TypeScript 后端开发 234 Java 基础、Spring、数据库、缓存、网络与部署 AI 应用开发 75 RAG、Agent、MCP、Tool Calling、流式交互与安全 求职专项 153 简历项目追问、真实面经与公司专项准备 题库中心可以按方向筛选，也可以按名称或技术关键词搜索题库；进入具体题库后，再搜索题目或按章节跳转。登录账号还能接着上次进度继续学习。首页也会直接放出真实题目，第一次访问不需要先理解整套产品。\n一道题，不只放一整页答案 我希望题解先解决“面试时怎么开口”，再展开完整原理。因此每道题会尽量按同一条阅读顺序拆开：\n速答：先给可以直接复述的结论。 术语：把容易卡住的关键词换成白话。 原理：说明它为什么这样工作，而不是只给定义。 实战：用代码或具体场景把概念落下来。 追问与避坑：继续准备面试官可能往下问的内容。 来源：保留可以回去核对的资料入口。 遇到流程型问题时，题解会配站内图解。例如 HashMap 的 put 不只写“数组、链表、红黑树”，而是把定位桶、处理冲突、插入节点和扩容的先后关系画出来。\n社区面经主要用来确认“真实面试里问过什么”。正式答案会重新整理，并尽量用官方文档和较稳定的技术资料交叉核对；无法公开查看正文的登录墙页面，不会被当成公开证据。\n从“看懂”变成“能说出来” 只读答案很容易产生一种错觉：每句话都认识，于是以为自己已经会了。练习模式会先把答案收起来，让我自己组织一遍，再提交回答、对照题解并做一次自评。\n写完以后，让 AI 面试官评一次 新版本在这一步加了 AI 评分。写下答案并提交后，可以点“开始 AI 评分”，看看自己的回答在技术正确性、原理与因果、关键点覆盖、场景边界与取舍、表达结构这五项上分别说到了什么。\n截图里只写了“重叠分块”。这个方向没有错，但还没解释它解决什么问题、会增加什么代价，以及什么时候该选多大的重叠，所以系统给出的重点不是一句“答错了”，而是告诉我下一次回答最先应该补哪一层。\n分数也不是让模型随手报一个数字。模型先判断五项表现分别处在哪个等级，服务端再按 30 / 25 / 20 / 15 / 10 的固定权重换算总分；如果回答里出现跑题、核心概念说反、虚构机制等严重问题，还会触发扣分或总分上限。评分按回答表达的意思判断，不要求照抄标准答案，也不会单纯因为字少就机械扣分。\n这项评分只用于当次练习，回答、分数和评语都不会保存到账号，也不代表真实面试结果。即使评分暂时失败，标准答案仍然可以正常查看。\nAI 评分看的是这一次回答；后面的“不会、模糊、掌握”自评只决定下一次什么时候复习。三种选择分别对应 1、3、7 天后，再配合“未开始、学习中、需复习、已掌握”四种状态，形成一条比较简单的复习线。\n登录后，收藏、总结、高亮、批注、掌握状态和复习安排会保存到个人账号。这样批注不只是写在某一页旁边，而是能和题目、进度一起带到下一次学习。\nAI 助手只围绕当前题帮忙 AI 助手不是另开一个没有上下文的聊天窗口。打开某道题后，页面会把当前题目的必要内容和这次对话一起交给服务端；可以让它换一种说法、补一个项目场景，或者继续追问面试表达。\n这条链路可以简化成：\n当前题目 + 本次对话 → 同源 AI 代理 → OpenAI-compatible 模型 → SSE 流式返回 → 页面逐段显示\n浏览器不会直接拿模型密钥。服务端负责附带题目上下文、限制并发与超时，并把上游的流式或一次性响应统一交给前端。AI 对话目前不会保存到账号，切换题目后也会清空；它更像阅读时的临时辅导，不是另一套长期知识库。\n这里容易混淆：RAG、Agent 是题库会讲的技术主题，不是这套 AI 助手的实现。当前助手没有接向量库或全题库检索，只携带当前题和本次对话。回答仍可能出错，重要内容要回到题解与来源核对。\n游客能直接用，账号只负责个人记录 我把公开阅读和个人数据分开处理：\n游客可以浏览全部公开题库、搜索、切题、练习、切换阅读版式，并体验 AI 面试官评分和 AI 助手，但不会写入个人学习记录。 学习账号在游客功能之上保存进度、收藏、批注和复习计划，并支持跨设备继续。 内容编辑可以维护题库、归档恢复内容并预览 Markdown 导入；管理员还可以管理账号与邀请、处理反馈申请，并创建和下载 SQLite 备份。 服务端的 SQLite 是登录用户学习状态的最终记录。网络不稳时，浏览器会用 IndexedDB 按账号暂存尚未确认的修改，连接恢复后再同步，避免丢进度或把记录写错账号。\n题库内容怎样进入页面 题库不是直接写死在 React 组件里。项目把 Markdown 和 JavaScript 内容源经过生成与检查，再导出为公开索引、按题库拆分的内容快照和 SQLite 种子。\n内容源 → 生成与质量检查 → 题库索引 + 分片快照 → SQLite\n页面第一次进入只读取题库索引，真正打开某个题库时才加载对应分片。实时接口暂时不可用时，游客阅读还能退回到静态快照；这类回退只保证公开内容可读，账号、批注和复习记录仍然不可用。\n内容检查会关注题目结构、来源链接、首屏是否讲清“结论、为什么、怎么用”，以及代码和站内图解是否与题目匹配。当前基线中，735 道题带有可核验来源，507 道带代码示例，48 道带站内图解。\n实现上怎样分工 部分 负责什么 React + TypeScript 首页、题库中心、阅读器、练习模式、AI 评分面板、批注和 AI 对话 Express 同源 API、公开题库、账号、学习状态、内容管理，以及 AI 对话与评分入口 SQLite 题库、账号、会话、个人学习记录与审计日志 内容管线 生成题库、检查结构与来源，并导出按需加载的公开快照 ECS + systemd 运行单个 Node 服务和持久化数据库 Cloudflare Tunnel 把只监听本机回环地址的服务接入 HTTPS 公网入口 正式站运行在阿里云 ECS 上，Express 同时提供 API 和构建后的前端资源。数据库放在独立持久目录，发布前先做检查与备份，再切换版本并验证健康状态。Vercel 保留公开题库的静态版本，但它不是一套独立的账号与数据后端。\n现在的边界 公共注册目前关闭，普通学习账号需要邀请或由管理员创建；如果只是读题和练习，游客模式已经够用。\n题量和质量指标是当前版本的快照，后续内容更新后会变化。社区来源只能说明某类问题在面试中出现过，不能替代技术事实本身；AI 对话与评分也只负责辅助理解和复盘，不替代来源核验。\n现在我更愿意把面试边注看成一张可以持续修改的学习桌面：题库负责给出范围，题解负责讲清楚，练习和复习负责把内容留下来，AI 用来换一种讲法，也用来检查自己到底有没有说清楚。\n平台入口：interview.linsk27.dpdns.org/app\n","date":"2026-08-31T17:14:52+08:00","image":"https://linsk27.github.io/img/projects/interview-margin-home.png","permalink":"https://linsk27.github.io/projects/interview-margin/","title":"面试边注：别急着背答案，先把这道题讲明白"},{"content":"竞见是一个面向品牌和包装设计团队的淘宝竞品调研工具。输入品类、价格范围和样本量后，它会整理品牌资料，采集淘宝商品的图片、价格和规格线索，生成一份包含品牌定位、产品结构、包装视觉、价格带、竞品比较和设计建议的 PPTX。报告完成后，还能继续追问，并从回答中的引用回到原报告或原始网页核对。\n本文对应 2026 年 9 月 1 日最新开发版本。界面截图使用脱敏演示数据；淘宝、搜索和模型服务需要在实际运行环境中连接。\n从一份研究简报开始 用户先填写主题、品类、目标市场、价格范围和关注点，再选择快速、标准、深度或自定义样本量。提交前，页面会先检查后端服务和淘宝登录状态。同一次创建请求重试时会沿用同一个请求键，避免网络抖动造成重复任务。\n研究简报只收集会影响调研结果的条件，样本量可以按任务时间调整。\n一次完整调研大致经过这条链路：\n研究简报 → 品类理解 → 品牌核验 → 分品牌研究 → 淘宝商品采集 → 洞察与质检 → HTML 预览 + 一份 PPTX → 研究问答\n品牌资料主要来自公开网页；商品图、价格、规格和销量线索尽量从同一张淘宝商品卡获取，避免把几个页面的信息拼成一个并不存在的商品。采集结果还会经过品类匹配、图片可用性、重复内容和版面检查。开启淘宝采集后，如果一张可用商品图都没有，任务会失败，不会拿普通网络图片填空。\n淘宝登录过期或出现验证码时，任务会进入等待状态，由用户在共享 Chrome 中完成登录或验证。系统不会绕过平台验证，也不会把等待人工操作误报成程序失败。\n最后得到的是什么 竞见只交付一份正式 PPTX，同时提供同内容的 HTML 预览。报告不是简单罗列搜索结果，而是按研究顺序组织：\n开头说明研究对象、样本范围和对标结论； 按品牌整理定位、用户、产品线、价格层级与视觉语言； 保留商品样本、页面截图和来源入口； 最后汇总跨品牌差异、市场判断与设计建议。 报告开头先交代研究对象，再给出行业、竞争和品牌发展层面的结论。\n中间页保留商品样本及图片来源，再把样本整理为产品架构和价格层级。\nHTML 预览和 PPTX 使用同一份报告内容，网页上看到的页序和下载文件保持一致。PPTX 的页面以整页画面为主，适合直接展示和分发，并不是由大量可逐项编辑的文本框拼成的模板；当前也不生成 PDF。\n生成后，系统最多进行三轮检查和修复，处理空图、内容溢出与版面异常。能定位到具体商品图的问题会单独补采；检查未通过，就不会把报告标记为完成。\n长任务为什么能停下来再继续 调研会连续调用搜索、模型、浏览器和渲染程序，不能依赖一次网页请求从头跑到尾。竞见把任务状态和最终报告保存在研究主库中，并在品类理解、品牌核验、单品牌研究、洞察整理和淘宝采集等阶段留下检查点。\n浏览器页面关闭后，后台任务仍可继续。程序重启时，运行中的任务会转为“已中断、可恢复”。用户点击恢复后，检查点可用时会从兼容阶段继续；检查点不可用时仍沿用原请求，但流程会从头执行。\n只有最终报告成功写入数据库，任务才会标记为完成，因此不会出现“状态已经完成，报告却没有保存”的情况。\nRAG 不是把整份 PPT 塞给模型 报告问答分成两步：先把完成的报告整理成可检索证据，再让模型只根据找到的证据回答。它只查询已经完成的报告，不会在问答时重新联网采集淘宝。\n报告怎样变成可检索的资料 完成的任务和报告先保存在 research.sqlite3。同步知识库时，系统从这里读取报告，再按报告结构建立检索索引。报告原文由主库统一保存，检索索引即使损坏，也可以重新生成。\n竞见不会按固定字数随意截断，而是沿用报告本身的业务结构：\n一块调研概况； 每个品牌一块品牌概览； 每个商品一块商品信息； 每条市场洞察和设计建议各自成块。 例如，一块品牌资料大致是这样：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 section: 品牌 · 卫龙 content: 品牌: 卫龙 品类: 辣条、小面筋、大面筋 定位: 以香辣配置、面筋口感和便携零食为核心 用户: 偏好香辣、筋道口感及怀旧零食的消费者 主色: 红、金 产品架构: 小面筋系列、大面筋系列、大刀辣条系列 包装策略: 稳定品牌区、插画资产、系列色区分…… 货架观察: 大面筋、小面筋名称辨识度高 设计洞察: 突出面筋形态与辣味联想 机会: 强化规格分层与型号识别 价格带: ¥3.5～¥12.09 metadata: {\u0026#34;brand\u0026#34;: \u0026#34;卫龙\u0026#34;, \u0026#34;kind\u0026#34;: \u0026#34;brand\u0026#34;} 标题、品类、章节、metadata 和正文会组成同一份检索文本，关键词索引和向量索引都使用它。系统通过内容指纹判断报告是否真的变化，只重建变化项；外部向量全部生成并校验后，才在一次短事务里替换旧索引。中途失败时，旧索引仍能使用。\n先找对报告，再从报告里找证据 一次内容提问的链路可以缩成：\n问题 → 确定报告范围 → 关键词检索 + 向量检索 → 合并排序 → 取出证据 → 带引用回答\n关键词检索适合查品牌名、型号和具体术语；向量检索用来寻找说法不同、意思相近的内容。两路结果通过 RRF 合并，必要时还可以让模型再排一次。精排默认关闭；没有开启或执行失败时，仍会使用原来的检索结果。\n这里没有 Milvus、FAISS 或其他独立向量库。向量直接存在 SQLite 的知识块记录中，查询时由 Python 计算余弦相似度。未配置外部 Embedding 时，系统使用本地 384 维 feature-hash；它做的是文字特征匹配，不是神经语义模型。配置 Ark Embedding 后，系统才会加入语义向量。外部向量不可用或签名不一致时，查询会降级为 FTS5。\n不同问题也不会强行走同一条路：\n问题 处理方式 “现在有多少份报告？” 直接查询研究主库，不靠检索结果猜数量。 “最新报告的包装策略是什么？” 先由主库确定最新任务，再只检索这份报告。 “比较最近两份报告” 先在已同步的报告目录中限定范围，再尽量让每份报告都有证据进入候选。 没有找到可靠证据 直接说明证据不足，不让模型凭聊天记忆补答案。 多报告比较依赖已经同步的知识目录；如果索引落后，应该先同步，而不是把旧报告当成最新结果。\n回答必须带回本次证据 候选知识块会标成 [S1]、[S2]，来源先发给前端，回答再以流式文字显示。生成结束后，服务端检查回答是否至少引用了一条来源，以及引用编号是否真的存在于本轮候选中。检查失败，这轮回答不会被当成完成结果。\n这能阻止模型编造一个不存在的 [S99]，但不能代替人工判断。界面仍会保留报告入口、章节和原始网页链接，方便读者自己核对。\n最新版还可以直接复制用户问题和已经结束的回答；仍在流式生成的半截回答不会显示复制按钮。\n问答页同时显示已同步报告数、证据块数量、检索方式、回答引用和来源卡片。\n连续追问怎样不跑题 会话、消息、部分流式回答、引用快照和滚动摘要都写入独立的会话库。系统组合最近对话、尚未归纳的衔接内容、与当前问题相关的较早问答和滚动摘要，而不是无限追加整段历史。\n“那它的价格呢？”这类追问会继承上一轮问题和来源报告的范围；出现“换一个品牌”之类的明确转向时，则不再沿用旧范围。会话记忆只帮助理解指代，不能代替报告证据。\n三层数据各管一件事 数据层 保存什么 为什么分开 研究主库 任务、请求、最终报告与校验值 是事实来源；任务完成与报告写入保持一致。 知识索引库 业务知识块、FTS5 文本和向量 可以从主库重建，索引失败不会破坏报告。 会话库 会话、消息状态、引用和摘要 保留断线前的部分结果，并支持安全重试。 前端用 React、TypeScript 和 TanStack Query 管理工作台状态；FastAPI 负责任务、流式事件和知识接口；共享 Chrome 配合 Python RPA worker 与 Playwright/CDP 完成淘宝页面采集和人工验证；HTML 分页内容再渲染为 PPTX。当前设计面向一台 Windows 工作站上的个人或小团队，不是多租户、分布式爬虫平台。\n现在的边界 淘宝采集依赖有效登录态、可交互桌面和人工处理平台验证，页面结构或风控变化会影响成功率。 商品截图和价格是研究样本，不代表整个市场；报告结论仍要结合采集范围阅读。 使用外部向量模型时，待索引的知识块和查询文本会发送给对应服务；回答模型会收到召回证据和有限的会话上下文，因此并非所有处理都在本机完成。 引用校验确认的是“引用编号来自本轮证据”，不是逐句事实审计。 正式交付物是一份 PPTX；HTML 用于预览，知识索引用于继续查询。 项目代码见 linsk27/jingjian-taobao-agent。本文按最新开发分支 feat/sqlite-primary-storage @ fd30cd9 的实现整理。\n","date":"2026-08-26T04:34:00+08:00","image":"https://linsk27.github.io/img/projects/jingjian-research-center-latest.png","permalink":"https://linsk27.github.io/projects/jingjian/","title":"竞见：从淘宝采集到可追溯的竞品报告"},{"content":"现在打开 Signal Atlas，看到的已经不是最早那版项目卡片页，而是一台可以进入的 3D 工作站。外层场景来自一个 MIT 开源作品集，我保留了许可证和署名；显示器里的复古桌面、窗口系统、博客与八股文入口，以及两层页面之间的交互，是我按自己的内容重新实现的。\n这张图是 v0.25.7 生产版本的真实画面，不再使用旧版 Atlas 面板截图。现在的桌面只保留 Hugo 博客、八股炼丹炉、GitHub 和 QQ 这些真实入口，之前用来占位置的 My Showcase 和伪应用已经删除。\n从“看起来很酷”到“真的能用” 最初做 3D 主页，想法很简单：普通项目卡片太像简历，我更想让访问者走到一台电脑前，再从电脑里打开我的内容。但真正开始接博客以后，问题就不再是“模型好不好看”，而是网站能不能顺利打开、显示器里的链接能不能点、滚轮会不会带着外层相机一起转。\nSignal Atlas 现在由两种渲染技术协作，并为内嵌页增加一层按显示器几何定位的覆盖层。\nWebGL 负责房间、电脑、光影与 CRT 效果；CSS3DRenderer 负责把可交互的桌面 DOM 放到显示器的位置。父页 Portal 又是独立于 3D 渲染器的真实 DOM，Vercel rewrite 只负责取回上游页面，不直接参与画面合成。网页没有被截成一张纹理，所以聚焦显示器后仍然可以点击、滚动和打开窗口。代价是每次相机移动、窗口缩放或发生遮挡，都要重新保证几层几何对得上。\n八股文内嵌页为什么最难处理 八股炼丹炉本身在另一个服务上。直接在浏览器打开时，它只需要面对一个正常视口；如果继续把它嵌套在 3D 桌面的 iframe 里，尺寸、裁切和跨域链路都会更复杂。\n当前版没有在桌面里继续嵌套八股页。桌面只通过 postMessage 把窗口位置发给父页面，父页面再结合 CSS3D 锚点计算屏幕四角，用固定定位的 DOM Portal 承载真实 iframe，并裁切到显示器边界。窗口移动、缩放或页面恢复时会重新校正几何；单次加载超过 18 秒会进入失败处理，随后最多重试 2 次。Vercel 的同源 rewrite 负责把请求转到八股文实际服务。\n这也解释了它为什么仍不可能做到百分之百稳定：如果八股文源站或 Cloudflare Tunnel 本身离线，外层工作站无法凭空把服务变可用。现在能做的是正确重试、恢复布局，并把“上游不可用”和“页面溢出”分开处理。\n第一次进入为什么比普通博客重 3D 场景要加载模型、贴图、脚本和媒体，第一次访问天然比纯 HTML 博客更贵。v0.25.7 做了几件比较实际的事：启动阶段只等待 8 个关键资源；CRT 视频改为 preload=\u0026quot;none\u0026quot;，启动音和环境音只预读元数据，两段很短的鼠标音保留预加载；HTML 每次重新校验，而带版本号的 JS、CSS 和静态素材走长期缓存。浏览器从 BFCache 返回时也尽量恢复现场，不再完整重启一次。\n这不会让 3D 首页变得和文字页一样轻，但能避免“为了等一段暂时用不到的视频，整个入口一直卡在 BIOS”。\n哪些是开源基础，哪些是我做的 这部分我想写清楚，避免把“基于开源”说成“全部原创”。\n当前生产外层基于 Henry Heffernan 的 portfolio-website，使用其 MIT 许可的工作站模型、贴图、CRT 媒体和镜头表现，完整归因保存在仓库的 reference/NOTICE.md。 上游另一个内层桌面仓库没有明确许可证，所以我没有复制它。显示器中的 linsk27 桌面、窗口管理器、应用入口和内容是重新实现的。 仓库也保留了我自己用 Three.js primitives、Blender 脚本、manifest 和 Vue runtime 做的原创 Atlas 实验路线，但它不是当前生产首页的那套外观。 参考版目前使用的 Windows 7 Harmony 壁纸不在项目 MIT 许可证范围内；如果要再分发或做二开，应先替换成有合适许可的自有素材。 所以更准确的说法是：它不是从零原创整套 3D 场景，也不是只换了名字的 fork，而是在有明确许可证的外层上重做内容系统、桌面交互和生产稳定性。\n开发过程中我会借助 AI 做代码检索、问题定位、重构建议和回归检查，但场景取舍、许可边界、功能验收和最终部署由我自己负责。尤其是这种同时涉及 WebGL、DOM 和外部服务的页面，AI 给出的“看起来合理”不等于浏览器里真的能用，最后还是要靠真实窗口、不同视口和失败场景逐项验证。\n现在可以从哪里看 生产站点：linsk27.dpdns.org GitHub 仓库：linsk-labs/signal-atlas 项目总览：404_@林达 Projects 目前我更愿意把 Signal Atlas 称为一个持续迭代的作品集实验室，而不是适合所有人的主页模板。它有意保留 3D 的重量和仪式感，同时也必须接受一个现实：越接近真正可用的桌面，越需要在性能、嵌入稳定性和授权边界上做扎实的工程工作。\n","date":"2026-07-08T00:00:00Z","image":"https://linsk27.github.io/img/projects/signal-atlas-reference-edition-desk-v0.25.7.png","permalink":"https://linsk27.github.io/projects/signal-atlas/","title":"Signal Atlas：把真实网页放进 Three.js 工作站"},{"content":"我做 Local AI Ops，不是为了让 AI 替我重启服务器，而是因为自己的域名、ECS、轻量服务器、OSS、续费信息和 SSH 记录散在太多地方。真正出问题时，常常先花十分钟找“这台机器到底在哪”，排故反而只用了五分钟。\n截图使用的是本地空数据实例，所以能看到真实页面结构，但没有云账号、IP、域名、AccessKey 或监控结果。\n先把确定的数据链跑通 这个项目是我围绕自己的运维需求立项和开发的，不是给某个现成面板换皮。FastAPI、React、Celery 这些开源框架负责基础能力，阿里云官方 SDK 负责读取云端资源；我主要做的是资产模型、检查流程、告警状态和本地安全边界。\n整体数据流没有刻意做复杂，人工请求、定时巡检和诊断各走自己的路径。\n用户明确触发资产同步后，FastAPI 会在当前请求中通过 RAM AccessKey 调用阿里云 OpenAPI，把 ECS、轻量应用服务器、OSS、域名和 DNS 转成统一的本地资产。PostgreSQL 保存资产、手工补充的运维资料、检查结果、告警和诊断；Redis、Celery Beat 与 Worker 只负责每分钟筛选并执行已经启用且到期的检查，不承担手动资产同步和诊断。\n一次健康检查大致会这样走：Celery Beat 判断哪些检查到期，Worker 执行 HTTP、TCP、SSH、CloudMonitor 或云助手只读命令，把结果写回数据库，再根据连续失败次数更新告警。这里的关键是先拿到可重复验证的事实，而不是先问模型“服务器为什么挂了”。\nAI 只放在最后一层 用户针对某条告警发起诊断时，系统会整理当前资产、告警内容和最近几次检查结果，先脱敏，再交给 OpenAI-compatible 接口生成摘要、可能原因和排查步骤。如果没有配置模型，或者接口调用失败，就回退到本地规则建议。\n我刻意保留了几条边界：\n模型只能给建议，不能自动执行修复。 模型建议只展示、不自动执行；真正进入 SSH 或云助手执行器的命令才受严格的只读白名单限制。 发送给模型前要去掉 AccessKey、密码、私钥和其他敏感字段。 AI 不是监控数据的事实来源，HTTP 状态、端口和云指标才是。 这样做没有“全自动 AI 运维”听起来厉害，却更适合个人环境。一条看似正确的重启命令，如果落到错误实例上，损失会比少点几下控制台大得多。\n为什么坚持本地优先 默认部署用 Docker Compose 拉起 React、FastAPI、PostgreSQL、Redis 和 Celery。数据库与 Redis 不需要直接暴露到公网，管理入口也定位在可信局域网。AccessKey、SSH 密码、私钥、面板密码和模型 Key 会以 AES-GCM 加密保存，但这不等于“绝对安全”——主密钥仍然要由部署者自己妥善保管。\nLocal AI Ops 也不打算替代阿里云控制台。云厂商控制台负责完整配置能力，这个工作台更像我的运维索引：哪些资源属于哪个项目、什么时候到期、最近是否可达、出问题时先看什么，都能在一个地方接起来。\nAI 也参与开发，但不参与拍脑袋 开发时我会用 AI 编程工具辅助拆需求、补样板代码、梳理接口和测试边界。不过资产结构、安全规则、告警流程和真实云账号验证必须由我自己确定。涉及凭据和自动化时，我更相信“代码里能看到的限制”，而不是一句提示词承诺。\n目前它仍是个人和小团队可用的 MVP，不是公网多租户 SaaS。v0.3.0 已经补上资产关系图、续费中心和纯本地数据库知识库；下一步更重要的是把手动同步、检查和诊断真正移入 Worker，并完善备份恢复、定时资产同步和操作审计。\n项目入口 GitHub 仓库：linsk-labs/local-ai-ops 项目总览：404_@林达 Projects GitHub 项目索引：404_@林达 GitHub 项目索引 ","date":"2026-06-23T00:00:00Z","image":"https://linsk27.github.io/img/projects/local-ai-ops-empty-dashboard.png","permalink":"https://linsk27.github.io/projects/local-ai-ops/","title":"Local AI Ops：先把运维资料收回来，再谈 AI"},{"content":"这一篇不是“我开发了一个免费域名平台”，而是一次真实配置记录：我怎样通过 DigitalPlat 领取 linsk27.dpdns.org，再把 DNS 控制权交给 Cloudflare。控制台按钮以后可能变化，但 DNS 委派的原理不会跟着换皮肤。\n免费域名适合学习、测试、个人项目展示和临时服务入口。真正重要的生产项目，还是建议使用自己购买并长期持有的独立域名，方便迁移和品牌沉淀。\n先理解三个角色 刚接触域名时，最容易把“注册成功”“DNS 生效”和“网站能打开”当成同一件事。实际上这条链路里有三个角色：\nDigitalPlat 控制父级 dpdns.org，负责把 linsk27.dpdns.org 这个子域委派出去。 Cloudflare 接受委派，成为这个子域的权威 DNS，回答 A、AAAA 或 CNAME 记录。 Vercel、GitHub Pages 或自己的服务器 才是真正返回网页的源站。 浏览器访问时，递归 DNS 会先沿着父区委派找到 Cloudflare nameserver，再从 Cloudflare 拿到 DNS 答案。DNS 查询结束后，浏览器才会另起 HTTPS 请求：开启橙云时先连接 Cloudflare Edge，再由代理转到源站；DNS only 时则直接连接源站。这个分支不是浏览器临时选择的，而是 Cloudflare 记录的代理模式决定权威 DNS 返回 Edge 地址，还是源站地址或 CNAME 目标。可以把它理解成：DigitalPlat 决定“去问谁”，Cloudflare 的权威 DNS 回答“先连哪里”，源站最终负责“返回什么”。\n截至 2026 年 8 月 28 日，本站的实测链路是 Cloudflare 权威 DNS 使用 DNS-only 记录，浏览器随后直连 Vercel；www 子域当时没有记录。这个结果只描述本站当前配置，不代表所有 DigitalPlat 域名都应该选择 DNS-only。\n因此，注册成功但没有 DNS 记录，网站仍然打不开；DNS 已经正确，但托管平台没有绑定自定义域名，也一样会报错。\n准备工作 开始之前需要准备三样东西：\n一个 DigitalPlat Domains 账号 一个 Cloudflare 账号 一个想注册的域名前缀，例如 linsk27 本文示例最终得到的完整域名是：\n1 linsk27.dpdns.org 如果你想用自己的名字，可以把 linsk27 换成其他前缀。前缀尽量使用小写字母、数字和短横线，避免使用商标、敏感词或容易被误判滥用的词。\n第一步：在 DigitalPlat 里检查域名 登录 DigitalPlat Domains 后，进入左侧的 Register 页面。\n在注册页面里填写：\n1 2 Domain: linsk27 Suffix: .dpdns.org 然后勾选服务条款，点击检查可用性。\n如果页面提示可用，就可以进入下一步。.dpdns.org 通常适合免费测试使用；如果选择其他后缀，可能会遇到 slot、订阅或 key 的限制，以页面实际提示为准。\n第二步：先去 Cloudflare 添加站点 DigitalPlat 在注册时会要求填写外部 nameserver，所以需要先在 Cloudflare 里添加这个域名。\n进入 Cloudflare 控制台，点击 Add a site，输入完整域名：\n1 linsk27.dpdns.org 套餐选择 Free plan 即可。添加完成后，Cloudflare 会分配两个 nameserver，格式一般类似：\n1 2 gerardo.ns.cloudflare.com sue.ns.cloudflare.com 注意：这里的 nameserver 每个账号或域名可能不同，实际使用时要复制 Cloudflare 页面上给你的两个地址。\n第三步：回 DigitalPlat 填写 nameserver 回到 DigitalPlat 的注册弹窗，把 Cloudflare 给出的两个 nameserver 填进去：\n1 2 NS1: gerardo.ns.cloudflare.com NS2: sue.ns.cloudflare.com 其他 NS 输入框可以留空。不要填写 ns1.digitalplat.net、ns2.digitalplat.net 或类似 DigitalPlat 自己的 nameserver，因为当前注册流程要求使用外部 nameserver。\nDelegation mode 保持：\n1 External nameservers only 然后选择 Use Free Slot，点击 Register。\n注册成功后，DigitalPlat 的域名列表里应该能看到这个域名。进入管理页后，可以看到注册日期、到期时间和当前 nameserver。\n第四步：等待 Cloudflare 检测生效 回到 Cloudflare 的域名 Overview 页面，会看到类似提示：\n1 Waiting for your registrar to propagate your new nameservers 这是正常状态。点击 Check nameservers now 可以手动触发检查。\n通常几分钟到几小时内会生效，最长可能需要 24 小时。只要 DigitalPlat 里已经保存了 Cloudflare 的两个 nameserver，就耐心等待即可。\n也可以用命令检查 NS 是否已经切换：\n1 nslookup -type=ns linsk27.dpdns.org 如果结果里出现 Cloudflare 的 nameserver，说明 NS 委派已经生效。\n第五步：在 Cloudflare 添加 DNS 解析 Cloudflare 激活后，进入左侧 DNS 页面，添加解析记录。\n如果你有服务器 IP，可以添加 A 记录：\n1 2 3 4 Type: A Name: @ IPv4 address: 你的服务器 IP Proxy status: Proxied 或 DNS only 如果你想让 www 也能访问，可以再添加一条 CNAME：\n1 2 3 Type: CNAME Name: www Target: linsk27.dpdns.org 如果你要绑定 GitHub Pages，可以使用 CNAME 指向：\n1 2 3 Type: CNAME Name: @ Target: linsk27.github.io Cloudflare 允许在根域使用 CNAME，是因为它会做 CNAME flattening；这不是所有 DNS 服务都支持的通用写法。GitHub Pages 侧也需要在仓库设置里绑定自定义域名，否则只做 DNS 解析还不够。\n记录保存后，可以分别检查 DNS 和 HTTP：\n1 2 nslookup linsk27.dpdns.org curl -I https://linsk27.dpdns.org 前一个命令确认域名最终解析到了哪里，后一个命令确认 HTTPS 请求是否真的收到源站响应。把这两层分开看，排错会快很多。\n第六步：配置 SSL/TLS Cloudflare 激活后，进入 SSL/TLS 页面。\n如果你只是用 GitHub Pages、Vercel、Netlify 这类静态托管，通常保持 Cloudflare 默认设置即可。\n如果你自己有服务器，建议：\n有 HTTPS 证书：使用 Full 或 Full (strict) 没有 HTTPS 证书：先配置证书，不建议长期使用 Flexible Flexible 看似方便，但它只加密用户到 Cloudflare 的连接，Cloudflare 到源站之间仍可能是 HTTP，不适合长期使用。\n常见问题 1. DigitalPlat 注册时报 403 先检查 nameserver 是否只填了 Cloudflare 给你的两个地址。不要把 ns3.digitalplat.net 或其他 DigitalPlat hosted DNS 填进去。\n推荐配置：\n1 2 3 NS1: Cloudflare 给你的第一个 nameserver NS2: Cloudflare 给你的第二个 nameserver NS3-NS8: 留空 如果仍然 403，可以刷新页面、重新登录后再提交一次。\n2. Cloudflare 一直显示等待 nameserver 确认 DigitalPlat 的域名管理页里已经保存了 Cloudflare 的 nameserver。\n如果保存没问题，那就是 DNS 委派还在传播。可以过几分钟再点 Check nameservers now，或者等待更久。\n3. 域名注册成功但打不开网站 注册成功只代表域名属于你的账号，还需要在 Cloudflare 里添加 DNS 记录。\n至少需要一条 A 记录或 CNAME 记录，否则浏览器不知道这个域名要指向哪里。\n4. 是否需要开启 WHOIS Privacy 建议开启。免费域名注册时通常会填写姓名、邮箱、地址、电话等 WHOIS 信息，开启隐私保护可以减少个人信息暴露。\n在 DigitalPlat 的域名管理页里找到 WHOIS Privacy，按页面提示启用即可。\n这次配置和开源项目是什么关系 DigitalPlat 公开了 FreeDomain 文档仓库；官方公开实现可以在 Domain-OSS 看到，它是一个使用 Flask、SQLAlchemy 等组件的 AGPL-3.0 项目。这里说的是公开仓库，不代表线上服务一定运行着仓库的最新 commit。\n我在整理这次排障过程时用 AI 辅助归纳过检查顺序，但文章里的控制台步骤、nameserver、DNS 结果和最终访问状态都以实际操作为准。AI 很适合解释报错可能在哪一层，不能替代 nslookup、托管平台状态和浏览器请求这些真实证据。\n总结 整个流程可以压缩成三步：\n在 Cloudflare 添加完整域名，拿到两个 nameserver 在 DigitalPlat 注册免费域名，并把 Cloudflare nameserver 填进去 等 Cloudflare 激活后，添加 A 记录或 CNAME 记录 完成后，linsk27.dpdns.org 就可以作为个人项目、测试服务或博客入口使用。以后遇到打不开，也按“NS 委派 → DNS 记录 → 源站绑定 → TLS/HTTP”这个顺序逐层检查，不需要在几个控制台之间来回碰运气。\n","date":"2026-06-03T12:00:00+08:00","image":"https://linsk27.github.io/p/0603-freedomain-cloudflare-dns/digitalplat-nameserver_hu_415871645c49a03f.png","permalink":"https://linsk27.github.io/p/0603-freedomain-cloudflare-dns/","title":"DigitalPlat 免费域名实操：先弄懂 DNS 委派，再配 Cloudflare"},{"content":"最初我只想解决一个很笨、但每天都会遇到的问题：隔几天重新打开项目时，我已经忘了它用什么命令启动、端口在哪、Python 环境是哪一个、上次又为什么失败。Dev Cockpit 就是从这个问题长出来的。\n它不是 IDE，也不打算替代 VS Code、Docker Desktop 或 Conda。它负责回答的是更靠前的几件事：这个目录是什么项目、应该怎么启动、现在到底有没有运行、端口属于谁、失败信息在哪里。\n扫描一个项目时，程序在看什么 把一个目录交给 Dev Cockpit 后，核心扫描器会读取常见标记文件、包管理配置和 Git 状态，识别 Node、Python、Java、PHP、Ruby、.NET、Go、Rust 与 Docker 项目。接着再从 package scripts、常见入口和技术栈约定中推断启动命令与候选端口，并结合进程归属和 HTTP 探测确认实际运行状态。\n这听起来像“判断文件存不存在”，但麻烦通常出在冲突上。例如同一个目录里既有 Python 虚拟环境，也有 VS Code 解释器设置和 Conda 声明。当前策略会按手工绑定、编辑器配置、项目虚拟环境、Conda 声明等优先级解析，并把为什么选中这个解释器一起展示，而不是静默猜一个路径。\n看到 3000 端口，不等于所有项目都在线 早期最容易写错的逻辑，是只要发现某个端口在监听，就把对应项目标成“运行中”。如果电脑上同时开了几个前端项目，这种判断很快就会串台。\nDev Cockpit 会把状态拆开看：\n是否由工作台自己启动并持有进程； 外部监听端口能否归属到当前项目或明确命令； HTTP 地址是否真的可达； 最近一次启动是否失败； 端口是否只是别的程序留下的占用。 也就是说，“进程存在”“端口被占用”和“这个项目能访问”不是一件事。状态判断尽量依赖本地可验证信号，而不是让模型猜。\n同一份项目事实，按入口逐层补全 为了避免 CLI、浏览器和桌面版各自猜一遍项目，代码按职责拆成基础扫描模型、机器运行证据补全和界面映射三层。\npackages/core 负责扫描规则和 Project 基础字段，CLI 的 scan、doctor 与文本上下文可以直接消费这一层。Local Server 再合并托管进程、PID 与端口归属、HTTP 探测和最近日志，生成带机器现场的 Project；Vue 从本地 API 读取它，并在视图层映射 managed-running、detected-online、stale、failed 和 idle 等展示状态。Electron 启动的是同一个本地 Server，再加载同一套 Web 界面。\n因此真正复用的是“如何扫描、字段代表什么、哪些事实已经验证”，而不是三个入口共享一台状态机。界面文案怎么改，不应该反过来改变端口归属或项目是否可达的事实；以后补一种技术栈，也不需要在三个入口各写一遍扫描规则。\n资源雷达是后来长出来的功能 我平时还会收集 GitHub 仓库、Demo、工具、Skills 和教程，所以后来加了资源雷达：把链接或文本整理成带来源、摘要、分类、标签和评分的卡片，再用筛选与星云图复盘最近关注的方向。\n资源处理大致是“本地规则优先，可选 AI 补充”：先解析 GitHub 或网页元数据，再用规则生成基础分类；配置模型后，AI 可以帮忙整理标题、标签和摘要，最后仍要经过预览才写入本地 JSON。它不会因为看见一个仓库就自动安装 Skill，也不会上传整个源码目录。\n这也是旧文章最容易让人误解的地方：资源雷达很直观，截图也好看，但它只是 Dev Cockpit 的一个模块，项目核心仍然是恢复本地开发现场。\nAI 在哪里有用，哪里没资格下结论 工作台可以把项目路径、技术栈、启动命令、端口、Git 状态和最近错误整理成一段可复制的上下文，方便交给 AI 编程工具；只有用户明确点击时，才会写入 PROJECT_CONTEXT.md 或 AGENTS.md。\n我也会用 AI 辅助梳理跨技术栈识别规则、重构和补测试。但模型不是运行状态的事实来源：端口归属、HTTP 可达性和 Python 环境解析，仍由本地确定性代码判断。这条边界比给聊天框换一个更聪明的模型重要。\n试用入口 安装 Node.js 后可以直接运行：\n1 npx local-dev-cockpit 默认会在本机打开 http://localhost:8787。项目数据保存在本地应用数据目录，定位仍是个人开发工作台，而不是需要注册账号的云服务。\nGitHub 仓库：linsk-labs/local-dev-cockpit 项目总览：404_@林达 Projects ","date":"2026-05-19T00:00:00Z","image":"https://linsk27.github.io/img/projects/dev-cockpit-resource-radar-cards.png","permalink":"https://linsk27.github.io/projects/dev-cockpit/","title":"Dev Cockpit：隔几天回来，也知道项目怎么跑"},{"content":"AI 让写 Pull Request 变快了，但没有让 review 变便宜。很多 PR 最大的问题不是代码一定错，而是没有复现、没有测试、没有截图，也没解释为什么要改权限。ProofPR 先检查这些“应该随改动一起出现的证据”，再把 PR 交给人看。\n先说清楚：ProofPR 不是 AI code reviewer，运行时也不调用大模型。它不猜代码是不是 AI 写的，不理解你的业务逻辑，更不会替维护者判断实现是否正确。它做的是一套可解释、可重复的初筛。\n一个 PR 是怎样被处理的 在本地 CLI 中，ProofPR 从 Git 读取 unified diff；在 GitHub Action 中，它会读取 PR diff、标题、正文和仓库里的 .proofpr.yml。之后的数据处理也是确定性的。\ndiff 会先被解析成带路径和新增行位置的 change set，PR 标题与正文则单独提取测试、复现、截图和风险说明；.proofpr.yml 不进入 diff parser，而是在配置校验后控制规则、Evidence Contract、preset 与阈值。三类输入只在确定性规则引擎里汇合。\n规则会关注改动规模、敏感路径、测试缺失、描述过薄、疑似 secret、依赖版本与 lockfile、GitHub Actions 权限、pull_request_target、不可信 checkout 和 MCP 凭据等信号。最后分别计算风险分与证据分，并给出四种 gate：ready、review-carefully、needs-evidence 或 block-merge。\n这不是在说 block-merge 的 PR 一定有漏洞，而是在提醒维护者：现有证据不足，或者风险已经高到不该直接合并。\nEvidence Contract 比“一套规则管所有仓库”更实用 不同目录需要的证据不一样。改 UI 时，截图很有用；改 workflow 时，更应该说明权限为什么增加；改可复现 bug 时，验证步骤比一张绿勾更重要。\nProofPR 的 Evidence Contract 可以按路径声明要求，例如：\n1 2 3 4 5 6 evidence: contracts: - id: ui-screenshot paths: [\u0026#34;src/ui/**\u0026#34;] requires: [\u0026#34;screenshot\u0026#34;, \u0026#34;verification\u0026#34;] severity: medium 这样维护者检查的不是“正文够不够长”，而是这类改动有没有带上真正有用的材料。规则命中后也能看到具体原因，不需要猜评分从哪里来。\n为什么故意不用 LLM 这里不用模型不是因为 AI 没价值，而是 PR gate 更看重稳定和可追溯：\n不需要 API Key，也不会把 diff 发送给第三方模型服务； 相同输入得到相同结果，方便复现 benchmark； 每条发现都能回到具体规则和证据；适用时还会标出文件与新增行； 维护者可以在配置里明确调整阈值，而不是修改提示词碰运气。 ProofPR 也不会查询完整漏洞数据库。依赖漏洞仍然更适合交给 OSV、Dependabot 等专用工具；它关注的是“这个依赖改动有没有说清楚、有没有锁定、生命周期和来源是否可疑”。\n代码是怎么拆的 项目使用 pnpm monorepo。@proof-pr/core 负责 TypeScript 规则引擎、配置校验和报告模型；CLI 用 Commander 读取本地 Git；GitHub Action 使用 GitHub Actions Toolkit 获取 PR 上下文并发布结果。Zod、YAML 和 Picomatch 负责配置与路径匹配，Vitest 与 benchmark 用来锁定规则行为。\n当前仓库的 benchmark 覆盖 22 个固定案例。这个数字本身不代表工具已经“识别所有风险”，它的价值是：新增或修改规则时，能立刻知道旧案例有没有被破坏。\nProofPR 也是我围绕“先补审查证据”这个问题自行设计的项目，不是某个 reviewer 仓库的换皮。开发和测试过程中，我会借助 AI 做规则梳理、重构和文档辅助；但发布后的扫描器不调用模型，阈值、评分和输出都留在可查看、可复现的代码里。\n它适不适合你的仓库 如果仓库经常接收外部贡献，或者团队已经觉得 PR review 被重复检查拖慢，它会比较有用。若只是一个人写、一个人合并的小项目，维护这套 gate 可能反而比手动看两眼更重。工具存在的意义不是给每个 PR 多加一盏红灯，而是把有限的 review 时间留给真正需要人判断的部分。\nGitHub 仓库：linsk-labs/proof-pr 项目总览：404_@林达 Projects ","date":"2026-05-09T00:00:00Z","image":"https://linsk27.github.io/img/projects/proofpr-github-action-pr-review-report.png","permalink":"https://linsk27.github.io/projects/proofpr/","title":"ProofPR：AI 让写 PR 变快，但 review 没变便宜"},{"content":"图片上传通常不只是把文件传到接口：用户还需要在提交前看到预览图，前端需要拦住错误格式和过大的文件，替换图片或离开页面时也要释放浏览器临时创建的地址。\n下面用 el-upload 做一个单图上传的基础版本。\n先在选择文件时做校验 把校验抽成一个函数，既可以在选择文件时调用，也可以在真正请求前再执行一次。前端校验主要是避免用户走到无效的提交步骤。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import { ElMessage } from \u0026#39;element-plus\u0026#39; import type { UploadRawFile } from \u0026#39;element-plus\u0026#39; const validateImage = (rawFile: UploadRawFile) =\u0026gt; { const allowedTypes = [\u0026#39;image/jpeg\u0026#39;, \u0026#39;image/png\u0026#39;, \u0026#39;image/webp\u0026#39;] const isImage = allowedTypes.includes(rawFile.type) const isLt2M = rawFile.size / 1024 / 1024 \u0026lt; 2 if (!isImage) { ElMessage.error(\u0026#39;请上传 JPG、PNG 或 WebP 图片\u0026#39;) return false } if (!isLt2M) { ElMessage.error(\u0026#39;图片大小不能超过 2MB\u0026#39;) return false } return true } 前端校验只是体验层。后端仍然要校验文件类型、大小和实际内容，不能只相信文件扩展名。\n用 Object URL 做本地预览 URL.createObjectURL 可以在不上传文件的情况下生成浏览器可访问的临时地址。替换图片时先释放旧地址，避免长时间操作后积累无用的内存引用。选择无效文件时，同时清空上传列表。\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 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import { onBeforeUnmount, ref } from \u0026#39;vue\u0026#39; import type { UploadFile, UploadRawFile, UploadUserFile } from \u0026#39;element-plus\u0026#39; const previewUrl = ref(\u0026#39;\u0026#39;) const fileList = ref\u0026lt;UploadUserFile[]\u0026gt;([]) let objectUrl = \u0026#39;\u0026#39; const revokePreviewUrl = () =\u0026gt; { if (!objectUrl) return URL.revokeObjectURL(objectUrl) objectUrl = \u0026#39;\u0026#39; } const handleChange = (uploadFile: UploadFile) =\u0026gt; { const file = uploadFile.raw if (!file) return if (!validateImage(file)) { fileList.value = [] previewUrl.value = \u0026#39;\u0026#39; return } revokePreviewUrl() objectUrl = URL.createObjectURL(file) previewUrl.value = objectUrl } onBeforeUnmount(revokePreviewUrl) \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;el-upload :auto-upload=\u0026#34;false\u0026#34; :limit=\u0026#34;1\u0026#34; v-model:file-list=\u0026#34;fileList\u0026#34; accept=\u0026#34;image/jpeg,image/png,image/webp\u0026#34; :on-change=\u0026#34;handleChange\u0026#34; \u0026gt; \u0026lt;el-button type=\u0026#34;primary\u0026#34;\u0026gt;选择图片\u0026lt;/el-button\u0026gt; \u0026lt;/el-upload\u0026gt; \u0026lt;img v-if=\u0026#34;previewUrl\u0026#34; :src=\u0026#34;previewUrl\u0026#34; alt=\u0026#34;上传图片预览\u0026#34; class=\u0026#34;preview\u0026#34; /\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;style scoped\u0026gt; .preview { display: block; width: 160px; height: 160px; margin-top: 16px; object-fit: cover; border-radius: 8px; } \u0026lt;/style\u0026gt; 如果页面只需要展示已有的远程头像，直接赋值接口返回的 URL 即可；只有本地刚选择的文件才需要 createObjectURL。\n用 FormData 提交文件 当表单同时包含文本和文件时，使用 FormData。浏览器会自动补充 multipart/form-data 的边界，因此不要手动给请求头设置 Content-Type。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const submitAvatar = async (file: File) =\u0026gt; { const formData = new FormData() formData.append(\u0026#39;avatar\u0026#39;, file) formData.append(\u0026#39;nickname\u0026#39;, \u0026#39;林达\u0026#39;) const response = await fetch(\u0026#39;/api/profile/avatar\u0026#39;, { method: \u0026#39;POST\u0026#39;, body: formData, }) if (!response.ok) { throw new Error(\u0026#39;上传失败，请稍后重试\u0026#39;) } return response.json() } 接口成功后，应使用服务端返回的正式图片地址替换本地预览地址。这样刷新页面后，用户仍然能看到已保存的图片。\n容易忽略的细节 用 accept 限制文件选择器，但不要把它当成安全校验。 单图上传要设置 limit=\u0026quot;1\u0026quot;，替换时同步清理旧预览。 预览图建议设置固定宽高和 object-fit: cover，避免不同尺寸图片撑坏布局。 上传过程中禁用重复提交，接口失败时给出明确提示。 小结 一个稳定的图片上传流程，至少包括选择前校验、选择后预览、提交时使用 FormData、成功后替换正式 URL，以及离开页面时释放临时地址。把这些细节补齐后，头像、封面图和后台图片字段都可以复用同一套思路。\n","date":"2026-03-18T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/vue3-%E5%9B%BE%E7%89%87%E4%B8%8A%E4%BC%A0%E4%B8%8E%E6%9C%AC%E5%9C%B0%E9%A2%84%E8%A7%88%E7%9A%84%E5%B8%B8%E8%A7%81%E5%86%99%E6%B3%95/","title":"Vue3 图片上传与本地预览的常见写法"},{"content":"AI-Vue3-python-flask-Blog 是一个 Vue 3、Flask、MySQL 和 AI 能力组合的全栈博客系统。它覆盖前端页面、后端接口、数据库持久化、内容管理和 AI 能力接入，适合作为课程设计、毕业设计、全栈项目练习和二次开发基础。\n适合谁 需要 Vue 3 + Flask + MySQL 全栈项目参考的学生或初级开发者。 正在准备课程设计、毕业设计或项目答辩的人。 想学习前后端分离、接口设计、内容管理和数据库持久化的人。 想在传统博客系统里加入 AI 能力的人。 项目价值 这个项目的核心价值是完整。它不是只有一个前端页面，也不是只有接口示例，而是把页面、接口、数据和内容管理串成一个可继续扩展的系统。对于搜索 Vue Flask full stack project、Vue3 Flask MySQL blog system、AI blog system、graduation project 或 course design 的人，它能提供更直接的参考。\n技术方向 Vue 3 Flask Python MySQL Element Plus 前后端分离 内容管理 AI blog system 项目入口 GitHub 仓库：linsk27/AI-Vue3-python-flask-Blog 线上演示：ai-vue3-python-flask-blog.vercel.app 项目总览：linsk27 Projects 如果这个全栈项目对你有参考价值，欢迎在 GitHub 仓库点 Star。Star 是我继续完善文档、部署说明和二次开发示例的主要信号。\n","date":"2026-03-10T00:00:00Z","image":"https://linsk27.github.io/img/projects/ai-vue-flask-blog-home-page.png","permalink":"https://linsk27.github.io/projects/ai-vue-flask-blog/","title":"AI Vue Flask Blog 全栈系统"},{"content":"列表页是后台项目最常见的页面之一。表格、分页、筛选、加载状态一起出现后，问题往往不是“表格能不能显示”，而是换页后序号不连续、重复请求、页码没有重置，或者前端排序和服务端分页混在一起。\n跨页连续序号怎么算 el-table-column 的 type=\u0026quot;index\u0026quot; 默认会从 1 开始重新编号。要显示跨页连续序号，可以给它传一个函数：\n1 \u0026lt;el-table-column type=\u0026#34;index\u0026#34; label=\u0026#34;序号\u0026#34; :index=\u0026#34;getIndex\u0026#34; width=\u0026#34;72\u0026#34; /\u0026gt; 1 2 3 const getIndex = (index: number) =\u0026gt; { return (currentPage.value - 1) * pageSize.value + index + 1 } 例如当前是第 3 页、每页 20 条，当前页第一行的 index 是 0，最终序号就是 41。\n一个可复用的分页列表 下面的例子把页码、每页数量、总数和加载状态放在一起管理。筛选条件变化后回到第 1 页，再重新请求数据。\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 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import { onMounted, ref } from \u0026#39;vue\u0026#39; interface User { id: number name: string status: \u0026#39;enabled\u0026#39; | \u0026#39;disabled\u0026#39; } const rows = ref\u0026lt;User[]\u0026gt;([]) const total = ref(0) const currentPage = ref(1) const pageSize = ref(20) const keyword = ref(\u0026#39;\u0026#39;) const loading = ref(false) const getIndex = (index: number) =\u0026gt; { return (currentPage.value - 1) * pageSize.value + index + 1 } const loadUsers = async () =\u0026gt; { loading.value = true try { const params = new URLSearchParams({ page: String(currentPage.value), pageSize: String(pageSize.value), keyword: keyword.value, }) const response = await fetch(`/api/users?${params}`) const data = await response.json() rows.value = data.list total.value = data.total } finally { loading.value = false } } const search = () =\u0026gt; { currentPage.value = 1 loadUsers() } const changePageSize = () =\u0026gt; { currentPage.value = 1 loadUsers() } onMounted(loadUsers) \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;el-input v-model=\u0026#34;keyword\u0026#34; clearable placeholder=\u0026#34;按用户名搜索\u0026#34; @keyup.enter=\u0026#34;search\u0026#34; /\u0026gt; \u0026lt;el-table v-loading=\u0026#34;loading\u0026#34; :data=\u0026#34;rows\u0026#34; row-key=\u0026#34;id\u0026#34;\u0026gt; \u0026lt;el-table-column type=\u0026#34;index\u0026#34; label=\u0026#34;序号\u0026#34; :index=\u0026#34;getIndex\u0026#34; width=\u0026#34;72\u0026#34; /\u0026gt; \u0026lt;el-table-column prop=\u0026#34;name\u0026#34; label=\u0026#34;用户名\u0026#34; min-width=\u0026#34;160\u0026#34; /\u0026gt; \u0026lt;el-table-column prop=\u0026#34;status\u0026#34; label=\u0026#34;状态\u0026#34; width=\u0026#34;120\u0026#34; /\u0026gt; \u0026lt;/el-table\u0026gt; \u0026lt;el-pagination v-model:current-page=\u0026#34;currentPage\u0026#34; v-model:page-size=\u0026#34;pageSize\u0026#34; :page-sizes=\u0026#34;[10, 20, 50, 100]\u0026#34; :total=\u0026#34;total\u0026#34; background layout=\u0026#34;total, sizes, prev, pager, next\u0026#34; @current-change=\u0026#34;loadUsers\u0026#34; @size-change=\u0026#34;changePageSize\u0026#34; /\u0026gt; \u0026lt;/template\u0026gt; loading 要在 finally 中关闭。无论请求成功还是失败，页面都不会一直停在加载状态。\n筛选后为什么要重置页码 用户本来在第 8 页，搜索条件一变，新的结果可能只有 1 页。如果继续带着第 8 页请求，列表就会空掉。因此所有会改变结果集的筛选项，都应该先执行：\n1 2 currentPage.value = 1 loadUsers() 服务端分页时，排序也交给服务端 如果表格数据来自分页接口，前端只拿到当前页的 20 条数据。此时直接在前端对这 20 条排序，得到的并不是全量排序结果。\n可以监听 sort-change，把字段名和顺序传回接口：\n1 2 3 \u0026lt;el-table @sort-change=\u0026#34;handleSortChange\u0026#34;\u0026gt; \u0026lt;el-table-column prop=\u0026#34;createdAt\u0026#34; label=\u0026#34;创建时间\u0026#34; sortable=\u0026#34;custom\u0026#34; /\u0026gt; \u0026lt;/el-table\u0026gt; 1 2 3 4 5 6 7 8 9 10 11 12 const sortBy = ref(\u0026#39;createdAt\u0026#39;) const sortOrder = ref\u0026lt;\u0026#39;ascending\u0026#39; | \u0026#39;descending\u0026#39; | \u0026#39;\u0026#39;\u0026gt;(\u0026#39;descending\u0026#39;) const handleSortChange = ({ prop, order }: { prop: string order: \u0026#39;ascending\u0026#39; | \u0026#39;descending\u0026#39; | null }) =\u0026gt; { sortBy.value = prop sortOrder.value = order ?? \u0026#39;\u0026#39; currentPage.value = 1 loadUsers() } 小结 表格页真正需要统一管理的是请求参数和页面状态：页码、每页数量、筛选条件、排序条件、总数与加载状态。把这些状态放在同一处后，分页和表格才不会各自运行、互相打架。\n","date":"2026-02-07T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/element-plus-%E8%A1%A8%E6%A0%BC%E5%88%86%E9%A1%B5%E5%BA%8F%E5%8F%B7%E4%B8%8E%E8%AF%B7%E6%B1%82%E7%8A%B6%E6%80%81%E6%95%B4%E7%90%86/","title":"Element Plus 表格分页、序号与请求状态整理"},{"content":"\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;div class=\u0026#34;compliance-bar\u0026#34; v-for=\u0026#34;item in averageScore\u0026#34; :key=\u0026#34;item.name\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;bar-label\u0026#34;\u0026gt; {{ item.name }} \u0026lt;div\u0026gt; \u0026lt;span :style=\u0026#34;`color:${item.color}`\u0026#34;\u0026gt;{{ item.value + `${item.base === \u0026#39;/ 100%\u0026#39; ? \u0026#39;%\u0026#39; : \u0026#39;\u0026#39;}` }}\u0026lt;/span\u0026gt; {{ item.base }} \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div class=\u0026#34;bar-bg\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;bar-fill\u0026#34; :style=\u0026#34;{ width: item.base === \u0026#39;/ 100%\u0026#39; ? item.value + \u0026#39;%\u0026#39; : item.value / 5 * 100 + \u0026#39;%\u0026#39;, backgroundColor: item.color }\u0026#34;\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; 1 2 3 4 5 6 const averageScore = [ { name: \u0026#34;Qualitative Assessment (QA)\u0026#34;, value: 2.4, base: \u0026#39;/ 5\u0026#39;, color: \u0026#34;#ee6262\u0026#34; }, { name: \u0026#34;Facility Proximity (FP)\u0026#34;, value: 78.6, base: \u0026#39;/ 100%\u0026#39;, color: \u0026#34;#ffe2a4\u0026#34; }, { name: \u0026#34;Infrastructure Completion (IC)\u0026#34;, value: 91.97, base: \u0026#39;/ 100%\u0026#39;, color: \u0026#34;#b8afff\u0026#34; } ]; 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 .compliance-bar { @include MarginBottom(20); .bar-label { display: flex; @include hHeight(17); font-family: PingFang SC; @include FontSize(12); text-transform: capitalize; @include MarginBottom(7); justify-content: space-between; color: #D3D3D4; } .bar-bg { width: 100%; @include hHeight(8); background: #39404a; .bar-fill { height: 100%; @include BorderRadius(4); } } } ","date":"2025-06-03T01:05:52+08:00","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/0603-%E6%89%8B%E6%92%B8%E5%A5%BD%E7%9C%8B%E6%9F%B1%E7%8A%B6%E5%9B%BE%E7%9A%84%E8%AE%B0%E5%BD%95/","title":"手撸好看柱状图的记录"},{"content":"Docker 笔记：常用命令整理 镜像管理 查看镜像：docker images 拉取镜像：docker pull xxx 删除镜像：docker rmi xxx 容器管理 查看容器：docker ps / docker ps -a 进入容器：docker exec -it 容器名 bash 停止容器：docker stop 容器名 删除容器：docker rm 容器名 docker-compose 常用 启动：docker-compose up -d 停止：docker-compose down 查看：docker-compose ps 拉镜像：docker-compose pull 常用组合示例 docker run -d -p 8080:80 nginx docker exec -it my-container bash docker-compose up \u0026ndash;build 清理空间 docker system prune -a 我的fre123部署流程 以阿里云为例 容器部署 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 回到 home 目录，确保路径正确 cd ~ # 克隆项目 git clone https://github.com/fre123-com/fre123-nav.git # 进入 deploy 目录（docker-compose.yml 文件所在） cd fre123-nav/deploy # 拉取镜像 sudo docker-compose pull # 启动服务 sudo docker-compose up -d 成功部署 1 2 3 4 5 6 7 [root@iZbp15al0g417943ohz4h1Z deploy]# docker-compose ps WARN[0000] /root/fre123-nav/deploy/docker-compose.yaml: version is obsolete NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS fre123-mongo mongo:3.6 \u0026#34;docker-entrypoint.s…\u0026#34; fre123-mongo 10 seconds ago Up 7 seconds 0.0.0.0:27077-\u0026gt;27017/tcp, :::27077-\u0026gt;27017/tcp fre123-nav-admin fre123dev/fre123-nav-admin:v0.1.0 \u0026#34;/bin/bash nginx_sta…\u0026#34; fre123-nav-admin 10 seconds ago Up 7 seconds 0.0.0.0:3001-\u0026gt;80/tcp, :::3001-\u0026gt;80/tcp fre123-nav-api fre123dev/fre123-nav-api:v0.1.0 \u0026#34;pipenv run api pro\u0026#34; fre123-nav-api 10 seconds ago Up 6 seconds 0.0.0.0:8765-\u0026gt;8765/tcp, :::8765-\u0026gt;8765/tcp fre123-nav-web fre123dev/fre123-nav-web:v0.1.0 \u0026#34;/bin/sh start.sh\u0026#34; fre123-nav-web 10 seconds ago Up 7 seconds 0.0.0.0:3000-\u0026gt;3000/tcp, :::3000-\u0026gt;3000/tcp 下一步：浏览器访问 你可以现在在浏览器访问以下地址：\n1 2 http://\u0026lt;你的阿里云服务器公网IP\u0026gt;:3000 # 主站前端 http://\u0026lt;你的阿里云服务器公网IP\u0026gt;:3001 # 管理后台 注意：确保端口已在阿里云安全组开放 如果访问不了，可能是安全组没放行这些端口： 3000（前端） 3001（管理后台） 8765（后端 API，如果你需要访问） 27077（MongoDB 端口，通常不建议暴露） 你可以登录 阿里云控制台 → 安全组 找到对应实例的安全组，手动添加规则（其他服务器同理） 服务器防火墙设置 除了要在阿里云开放安全组，你还需要设置服务器本地防火墙开放，具体操作步骤可查看初步接触Nginx的文章 🐳 Nuxt 博客项目从 Windows 上传到阿里云服务器并用 Docker 部署上线教程 本教程适用于：在 Windows 本地开发 Nuxt 3 项目，在阿里云服务器（Linux）上通过 Docker 打包、运行并对外提供服务。\n前置条件 本地开发环境（Windows） 上已有一个可正常构建的 Nuxt 3 项目。 阿里云服务器（推荐 CentOS 或 Ubuntu）已安装 Docker（无需本地安装 Docker）。 拥有服务器的 SSH 登录权限（FinalShell、XShell、PuTTY 等工具）。 （可选）已注册 Docker Hub 账号，用于推送镜像分享给他人。 将项目上传到阿里云服务器 方法一：FinalShell 右键上传 打开 FinalShell，连接到你的阿里云服务器。 在左侧文件管理器中定位到本地项目文件夹。 右键选中项目根目录，选择 \u0026ldquo;上传到服务器\u0026rdquo;，目标路径可选 /root/nuxt-blog。 方法二：命令行 scp 1 2 # 在 Windows PowerShell 或 Git Bash 中运行： scp -r C:/path/to/your/nuxt-blog root@\u0026lt;服务器IP\u0026gt;:/root/nuxt-blog 在服务器上编写 Dockerfile SSH 登录服务器：\n1 ssh root@\u0026lt;服务器IP\u0026gt; 进入项目目录：\n1 cd /root/nuxt-blog 创建并编辑 Dockerfile（适用于Nuxt3）：\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 # ---------- 构建阶段 ---------- FROM node:18 AS builder # 设置工作目录 WORKDIR /app # 增加 Node.js 可用内存（防止内存不足） ENV NODE_OPTIONS=\u0026#34;--max-old-space-size=3072\u0026#34; # 拷贝依赖配置并安装 COPY package.json yarn.lock ./ RUN yarn install # 拷贝全部项目源码 COPY . . # 避免 nuxt 权限问题 RUN chmod +x node_modules/.bin/nuxt # 构建项目 RUN node_modules/.bin/nuxt build # ---------- 运行阶段 ---------- FROM node:18 AS runner # 设置工作目录 WORKDIR /app # 仅复制必要文件，减小镜像体积 COPY --from=builder /app/.output .output COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./ # Nuxt 默认端口 EXPOSE 3000 # 启动服务 CMD [\u0026#34;node\u0026#34;, \u0026#34;.output/server/index.mjs\u0026#34;] 构建 Docker 镜像 在项目根目录执行：\n1 2 # \u0026lt;用户名\u0026gt; 换成你的 Docker Hub 用户名或任意标识 docker build -t \u0026lt;用户名\u0026gt;/nuxt-blog:v1.0 . -t：为镜像打标签，格式为 仓库名:版本。 .：表示当前目录的 Dockerfile。 运行 Docker 容器 1 2 3 4 docker run -d \\ -p 3000:3000 \\ --name nuxt-blog-container \\ \u0026lt;用户名\u0026gt;/nuxt-blog:v1.0 -d：后台运行容器。 -p 3000:3000：映射宿主机 3000 端口到容器 3000 端口。 --name：指定容器名称，方便管理。 测试访问：打开浏览器，访问 http://\u0026lt;服务器IP\u0026gt;:3000，能正常看到 Nuxt 页面即部署成功。\n（可选）推送镜像到 Docker Hub 登录：\n1 docker login 推送：\n1 docker push \u0026lt;用户名\u0026gt;/nuxt-blog:v1.0 之后其他人只需：\n1 2 docker pull \u0026lt;用户名\u0026gt;/nuxt-blog:v1.0 docker run -d -p 3000:3000 \u0026lt;用户名\u0026gt;/nuxt-blog:v1.0 即可一键运行。\n（可选）用 Nginx 反向代理统一入口 若你希望通过域名（如 blog.linsou.asia）访问，并隐藏端口，可在服务器上安装并配置 Nginx：\n安装 Nginx：\n1 2 sudo yum install -y nginx # CentOS # 或 sudo apt install -y nginx # Ubuntu 新建配置 /etc/nginx/conf.d/nuxt-blog.conf：\n1 2 3 4 5 6 7 8 9 10 server { listen 80; server_name blog.linsou.asia; location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } 校验并重载：\n1 2 sudo nginx -t sudo systemctl reload nginx 访问 http://blog.linsou.asia 即可直达 Nuxt 应用。\n恭喜你！🎉 通过本教程，你已掌握从 Windows 传输项目、在 Linux 服务器上打包 Docker 镜像并部署 Nuxt 应用的全流程。\n","date":"2025-06-03T00:58:55+08:00","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/0603-%E5%88%9D%E6%AD%A5%E6%8E%A5%E8%A7%A6docker/","title":"初步接触Docker"},{"content":"后台项目里，新增和编辑弹窗大多离不开 el-form。表单校验看起来简单，真正容易出问题的地方通常是 model、prop、rules 三者没有对齐，或者弹窗复用后遗留了上一次的校验状态。\n最小可用的表单结构 el-form 的 :model 指向表单数据；每个 el-form-item 的 prop 必须与数据字段、规则字段一致。下面的 name 和 email 三处都对应同一个字段名。\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 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import { reactive, ref } from \u0026#39;vue\u0026#39; import type { FormInstance, FormRules } from \u0026#39;element-plus\u0026#39; interface UserForm { name: string email: string } const formRef = ref\u0026lt;FormInstance\u0026gt;() const form = reactive\u0026lt;UserForm\u0026gt;({ name: \u0026#39;\u0026#39;, email: \u0026#39;\u0026#39;, }) const rules: FormRules\u0026lt;UserForm\u0026gt; = { name: [ { required: true, message: \u0026#39;请输入用户名\u0026#39;, trigger: \u0026#39;blur\u0026#39; }, { min: 2, max: 20, message: \u0026#39;长度为 2 到 20 个字符\u0026#39;, trigger: \u0026#39;blur\u0026#39; }, ], email: [ { required: true, message: \u0026#39;请输入邮箱\u0026#39;, trigger: \u0026#39;blur\u0026#39; }, { type: \u0026#39;email\u0026#39;, message: \u0026#39;邮箱格式不正确\u0026#39;, trigger: [\u0026#39;blur\u0026#39;, \u0026#39;change\u0026#39;] }, ], } \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;el-form ref=\u0026#34;formRef\u0026#34; :model=\u0026#34;form\u0026#34; :rules=\u0026#34;rules\u0026#34; label-width=\u0026#34;80px\u0026#34;\u0026gt; \u0026lt;el-form-item label=\u0026#34;用户名\u0026#34; prop=\u0026#34;name\u0026#34;\u0026gt; \u0026lt;el-input v-model=\u0026#34;form.name\u0026#34; placeholder=\u0026#34;请输入用户名\u0026#34; /\u0026gt; \u0026lt;/el-form-item\u0026gt; \u0026lt;el-form-item label=\u0026#34;邮箱\u0026#34; prop=\u0026#34;email\u0026#34;\u0026gt; \u0026lt;el-input v-model=\u0026#34;form.email\u0026#34; placeholder=\u0026#34;name@example.com\u0026#34; /\u0026gt; \u0026lt;/el-form-item\u0026gt; \u0026lt;/el-form\u0026gt; \u0026lt;/template\u0026gt; 如果 prop=\u0026quot;userName\u0026quot;，而 form 写的是 name，规则不会生效。这是排查校验问题时最先该看的地方。\n提交前统一校验 不要只依赖输入框失焦时的提示。提交按钮仍然要执行一次 validate，确认所有字段都通过后再调接口。\n1 2 3 4 5 6 7 const submit = async () =\u0026gt; { const valid = await formRef.value?.validate().catch(() =\u0026gt; false) if (!valid) return await saveUser({ ...form }) } validate 校验失败时会抛出异常，所以这里用 catch 转成 false。通过后再复制一份数据提交，可以避免接口函数直接修改响应式对象。\n编辑弹窗要清掉旧提示 一个弹窗既用于新增又用于编辑时，切换数据后经常会看到上一位用户留下的红色提示。可以在弹窗打开后清掉校验状态。\n1 2 3 4 5 6 7 8 9 10 11 12 13 import { nextTick } from \u0026#39;vue\u0026#39; const openCreate = async () =\u0026gt; { Object.assign(form, { name: \u0026#39;\u0026#39;, email: \u0026#39;\u0026#39; }) await nextTick() formRef.value?.clearValidate() } const openEdit = async (user: UserForm) =\u0026gt; { Object.assign(form, user) await nextTick() formRef.value?.clearValidate() } 如果表单初始值始终不变，也可以使用 formRef.value?.resetFields()。它会把数据还原为组件初始化时的值，更适合单纯的“清空新增表单”。\n只校验一个字段 分步表单或者发送验证码前，往往只需要校验邮箱或手机号。这时用 validateField，不必校验整张表单。\n1 2 3 4 5 6 const sendCode = async () =\u0026gt; { const valid = await formRef.value?.validateField(\u0026#39;email\u0026#39;).catch(() =\u0026gt; false) if (!valid) return await requestEmailCode(form.email) } 自定义规则适合什么场景 确认密码、日期范围、业务编号唯一性等情况，内置规则不够用时可以增加 validator。校验函数只负责给出结果，不要在里面请求大量无关数据。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const passwordForm = reactive({ password: \u0026#39;\u0026#39;, confirmPassword: \u0026#39;\u0026#39;, }) const validateConfirmPassword = ( _rule: unknown, value: string, callback: (error?: Error) =\u0026gt; void, ) =\u0026gt; { if (value !== passwordForm.password) { callback(new Error(\u0026#39;两次输入的密码不一致\u0026#39;)) return } callback() } 小结 我现在处理 Element Plus 表单时，先保证字段名完全对齐，再把提交校验、弹窗清理和局部校验补齐。这样新增、编辑、分步操作放在同一个页面里，表单状态也不会越用越乱。\n","date":"2025-05-06T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/element-plus-%E8%A1%A8%E5%8D%95%E6%A0%A1%E9%AA%8C%E5%B8%B8%E8%A7%81%E5%86%99%E6%B3%95/","title":"Element Plus 表单校验常见写法"},{"content":" 初步接触 Nginx 什么是 Nginx？ Nginx（engine x） 是一个高性能的 HTTP 和反向代理服务器，同时也是一个 IMAP/POP3/SMTP 邮件代理服务器。\n由 Igor Sysoev 编写，最初用于俄罗斯访问量最大的网站之一。如今，它广泛应用于各类网站的：\n静态资源服务（如 HTML、CSS、JS、图片等） 反向代理（把请求转发到后端服务） 负载均衡（分发请求以提升性能与可用性） HTTPS / SSL 支持 动态请求的缓存与压缩处理 为什么使用 Nginx？ Nginx 被广泛采用的原因包括：\n🚀 高性能：可以支撑成千上万个并发连接 ⚙️ 资源占用小：内存占用远低于 Apache 🔁 反向代理 + 负载均衡：非常适合部署在微服务架构中 🔒 支持 HTTPS / SSL 🔧 模块化配置：配置灵活，易于扩展 安装 Nginx 1. 安装依赖 1 2 3 4 yum install gcc gcc-c++ yum install -y pcre pcre-devel yum install -y zlib zlib-devel yum install -y openssl openssl-devel 2. 下载并解压 Nginx 1 2 3 4 cd /usr/local wget http://nginx.org/download/nginx-1.22.1.tar.gz tar -zxvf nginx-1.22.1.tar.gz cd nginx-1.22.1 3. 编译安装 1 2 3 ./configure --with-http_ssl_module # 添加 SSL 模块支持 make # 编译 make install # 安装 🚀 常用命令整理 在 /usr/local/nginx/sbin 目录下运行：\n1 2 3 ./nginx # 启动 Nginx ./nginx -s reload # 热重载配置 ./nginx -s stop # 停止 Nginx 检查是否启动成功 1 ps -ef | grep nginx 输出示例：\n1 2 root 706455 1 0 12:34 ? nginx: master process ./nginx nobody 706456 ... 0 12:34 ? nginx: worker process 判断启动状态：\n✔️ 正常：出现 master process 和 worker process ❌ 异常：只有 master 没有 worker，说明配置有误 ⚠️ 多个进程：说明重复启动了，建议先执行 pkill nginx 配置与测试 📄 配置文件示例（nginx.conf） 路径：/usr/local/nginx/conf/nginx.conf\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 # 定义工作进程数量，通常设置为 CPU 核心数，这里示例用1 worker_processes 1; events { # 每个工作进程允许的最大连接数（含客户端和服务器端连接） worker_connections 1024; } http { # 引入 MIME 类型映射文件，用来识别文件扩展名对应的内容类型 include mime.types; # 默认的 MIME 类型，文件类型未匹配时使用 default_type application/octet-stream; # 启用高效文件传输方式，减少系统调用次数 sendfile on; # 连接保持活动的超时时间（秒），超过此时间关闭连接 keepalive_timeout 65; server { # 监听端口8080，HTTP服务默认端口是80，这里改为8080示例 listen 8080; # 服务器名称，支持域名或IP，这里用localhost作为本机测试 server_name localhost; location / { # 网站根目录，Nginx 会从这里查找文件 root /usr/local/nginx/html/dist; # 默认首页文件，访问目录时默认加载的文件 index index.html index.htm; } # 定义错误页面，当出现500、502、503、504错误时，跳转到该页面 error_page 500 502 503 504 /50x.html; } } 测试是否成功 1 curl http://\u0026lt;你的公网IP\u0026gt;:8080 如果返回页面内容，说明 Nginx 成功响应 否则检查防火墙 / 端口是否开放 ⚠️ 注意事项 监听端口为 8080（非默认 80），需特别注意防火墙设置 如果你使用的是 阿里云服务器，请在控制台： 找到安全组 → 编辑入方向规则 → 开放 8080 端口 即使云服务器厂商（如阿里云、腾讯云等）的“安全组”已经开放端口，如果 服务器操作系统内部 还有防火墙规则没有放行对应端口，也会导致无法访问。\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 systemctl status firewalld # 若正在运行，执行查看端口开放情况 firewall-cmd --list-all # 若未开放指定端口，可执行 firewall-cmd --permanent --add-port=8080/tcp firewall-cmd --reload # 再次执行查看端口开放情况 firewall-cmd --list-all ##实操 [root@iZbp15al0g417943ohz4h1Z ~]# firewall-cmd --list-all public (active) target: default icmp-block-inversion: no interfaces: eth0 sources: services: cockpit dhcpv6-client ssh ports: 20/tcp 21/tcp 22/tcp 80/tcp 443/tcp 8888/tcp 39000-40000/tcp 9999/tcp 8080/tcp # 没有8989端口 protocols: forward: no masquerade: no forward-ports: source-ports: icmp-blocks: rich rules: [root@iZbp15al0g417943ohz4h1Z ~]# firewall-cmd --permanent --add-port=8989/tcp success [root@iZbp15al0g417943ohz4h1Z ~]# firewall-cmd --reload ^[[Asuccess [root@iZbp15al0g417943ohz4h1Z ~]# firewall-cmd --list-all public (active) target: default icmp-block-inversion: no interfaces: eth0 sources: services: cockpit dhcpv6-client ssh ports: 20/tcp 21/tcp 22/tcp 80/tcp 443/tcp 8888/tcp 39000-40000/tcp 9999/tcp 8080/tcp 8989/tcp protocols: forward: no masquerade: no forward-ports: source-ports: icmp-blocks: rich rules: 若你更喜欢默认 HTTP 端口 80，可将 listen 8080 改为 listen 80 Nginx 配置 lindablog.xyz 域名启用 HTTPS 一、准备条件 部署项目文件位置：/usr/local/nginx/html/dist 阿里云申请 SSL 凭证档案（我申请的是免费证书3月一换）： jnalindablog.xyz.pem（凭证） jnalindablog.xyz.key（私钥） 二、建立 SSL 凭证目录并上传ssl凭证档案 1 mkdir -p /usr/local/nginx/ssl 三、撰写 Nginx HTTPS 配置档 在 /usr/local/nginx/conf/nginx.conf 中加入：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 worker_processes 1; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; # 加入這一行：引用你自定義的 server 配置檔案 include /usr/local/nginx/conf/conf.d/*.conf; } 在 /usr/local/nginx/conf/conf.d/lindablog.conf 中加入：\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 server { # 监听 443 端口并启用 SSL/TLS，提供 HTTPS 服务 listen 443 ssl; # 服务器域名，配置成你的网站域名 server_name lindablog.xyz; # SSL 证书文件路径，公钥证书 ssl_certificate /usr/local/nginx/ssl/jnalindablog.xyz.pem; # SSL 私钥文件路径，和证书匹配的私钥 ssl_certificate_key /usr/local/nginx/ssl/jnalindablog.xyz.key; # 允许使用的 TLS 协议版本，推荐启用 1.2 和 1.3 ssl_protocols TLSv1.2 TLSv1.3; # 加密套件，排除掉弱加密算法，确保连接安全 ssl_ciphers HIGH:!aNULL:!MD5; # 网站根目录，存放前端静态文件 root /usr/local/nginx/html/dist; # 默认首页文件，访问根路径时默认加载 index index.html; location / { # try_files 尝试访问请求的 URI，如果不存在，则返回 index.html # 主要用于 SPA（单页应用）支持路由刷新 try_files $uri $uri/ /index.html; } } server { # 监听 HTTP 的 80 端口，用于重定向所有请求到 HTTPS listen 80; # 服务器域名 server_name lindablog.xyz; # 所有访问 80 端口的请求，都重定向（301永久重定向）到对应的 HTTPS 地址 return 301 https://$host$request_uri; } 四、测试与重载 Nginx 1 2 /usr/local/nginx/sbin/nginx -t /usr/local/nginx/sbin/nginx -s reload 五、验证 HTTPS 启用 浏览器访问 https://lindablog.xyz，网址列显示锁头符号 🔒 点击锁头查看凭证细节或者右键检查找到隐私与安全 使用线上工具检查： SSL Labs 测试 SSL Shopper 检查 备注 .pem 与 .crt 可通用于 Nginx ","date":"2025-04-23T12:25:57+08:00","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/0423-%E5%88%9D%E6%AD%A5%E6%8E%A5%E8%A7%A6nginx/","title":"初步接触Nginx"},{"content":"组件通信没有唯一标准答案，关键是看数据属于谁、要经过几层、是否要跨页面共享。日常 Vue3 项目里，父子组件优先用 props 和 emit；层级较深但作用范围有限时用 provide/inject；真正跨页面的公共状态再交给 Pinia。\n父组件传给子组件：props 用户信息由父组件管理，子组件只负责展示时，直接传 props 最合适。数据流是从上到下的，阅读起来也最直接。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 \u0026lt;!-- UserCard.vue --\u0026gt; \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; interface User { id: number name: string role: string } defineProps\u0026lt;{ user: User }\u0026gt;() \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;article\u0026gt; \u0026lt;strong\u0026gt;{{ user.name }}\u0026lt;/strong\u0026gt; \u0026lt;span\u0026gt;{{ user.role }}\u0026lt;/span\u0026gt; \u0026lt;/article\u0026gt; \u0026lt;/template\u0026gt; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;!-- UserList.vue --\u0026gt; \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import UserCard from \u0026#39;./UserCard.vue\u0026#39; const currentUser = { id: 1, name: \u0026#39;林达\u0026#39;, role: \u0026#39;管理员\u0026#39;, } \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;UserCard :user=\u0026#34;currentUser\u0026#34; /\u0026gt; \u0026lt;/template\u0026gt; 子组件不要直接修改 props。这会破坏单向数据流，也容易在控制台出现警告。\n子组件通知父组件：emit 删除、保存、切换状态这类动作，由子组件触发、父组件决定怎么处理，适合用事件回传。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;!-- UserCard.vue --\u0026gt; \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; const emit = defineEmits\u0026lt;{ remove: [id: number] }\u0026gt;() const props = defineProps\u0026lt;{ id: number; name: string }\u0026gt;() \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;button type=\u0026#34;button\u0026#34; @click=\u0026#34;emit(\u0026#39;remove\u0026#39;, props.id)\u0026#34;\u0026gt; 删除 {{ props.name }} \u0026lt;/button\u0026gt; \u0026lt;/template\u0026gt; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 \u0026lt;!-- UserList.vue --\u0026gt; \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import { ref } from \u0026#39;vue\u0026#39; const users = ref([ { id: 1, name: \u0026#39;林达\u0026#39; }, { id: 2, name: \u0026#39;小王\u0026#39; }, ]) const removeUser = (id: number) =\u0026gt; { users.value = users.value.filter((user) =\u0026gt; user.id !== id) } \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;UserCard v-for=\u0026#34;user in users\u0026#34; :key=\u0026#34;user.id\u0026#34; v-bind=\u0026#34;user\u0026#34; @remove=\u0026#34;removeUser\u0026#34; /\u0026gt; \u0026lt;/template\u0026gt; 事件名最好描述行为，例如 save、remove、change-status。不要用 callback 这种看不出意图的名字。\n需要双向绑定时：v-model 表单组件最常见。Vue3 的 v-model 本质上是一个 modelValue 属性和一个 update:modelValue 事件。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;!-- SearchInput.vue --\u0026gt; \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; const props = defineProps\u0026lt;{ modelValue: string }\u0026gt;() const emit = defineEmits\u0026lt;{ \u0026#39;update:modelValue\u0026#39;: [value: string] }\u0026gt;() \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;input :value=\u0026#34;props.modelValue\u0026#34; placeholder=\u0026#34;搜索用户\u0026#34; @input=\u0026#34;emit(\u0026#39;update:modelValue\u0026#39;, ($event.target as HTMLInputElement).value)\u0026#34; /\u0026gt; \u0026lt;/template\u0026gt; 父组件就可以像使用原生输入框一样使用它：\n1 \u0026lt;SearchInput v-model=\u0026#34;keyword\u0026#34; /\u0026gt; 跨多层但只在局部使用：provide / inject 主题配置、表单上下文、页面级权限等数据，经过多层组件逐级传递会很麻烦。这时可以由页面根组件提供，再由后代组件取用。\n1 2 3 4 5 // 页面根组件 import { provide, ref } from \u0026#39;vue\u0026#39; const readonly = ref(false) provide(\u0026#39;formReadonly\u0026#39;, readonly) 1 2 3 4 // 深层子组件 import { inject, type Ref } from \u0026#39;vue\u0026#39; const readonly = inject\u0026lt;Ref\u0026lt;boolean\u0026gt;\u0026gt;(\u0026#39;formReadonly\u0026#39;) 这适合“局部共享”，不建议把所有数据都塞进去。过多的注入会让数据来源变得不清楚。\n跨页面共享：Pinia 登录用户、权限、全局主题、购物车这类状态需要多个页面共用，才值得放进 Pinia。页面临时的弹窗开关、单个表单字段仍然留在组件内更轻。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { defineStore } from \u0026#39;pinia\u0026#39; export const useUserStore = defineStore(\u0026#39;user\u0026#39;, { state: () =\u0026gt; ({ name: \u0026#39;\u0026#39;, token: \u0026#39;\u0026#39;, }), actions: { setUser(name: string, token: string) { this.name = name this.token = token }, }, }) 选择建议 一层父子：props + emit。 表单值双向同步：v-model。 多层组件的局部上下文：provide/inject。 跨路由、跨模块的共享状态：Pinia。 先从最简单的方案开始。通信方式越重，维护成本越高；数据范围越小，代码通常越容易整理。\n","date":"2025-04-17T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/vue3-%E7%BB%84%E4%BB%B6%E9%80%9A%E4%BF%A1%E5%B8%B8%E8%A7%81%E6%96%B9%E5%BC%8F%E6%95%B4%E7%90%86/","title":"Vue3 组件通信常见方式整理"},{"content":"事件循环是前端异步代码绕不开的基础。只要页面里用了 Promise、async/await、定时器或接口请求，就会遇到执行顺序的问题。\n先给一个实用结论：当前同步代码执行完后，JavaScript 会优先清空微任务队列，再进入下一轮任务去执行定时器等宏任务。setTimeout(fn, 0) 不是立刻执行，它只是把 fn 放进后续任务队列。\n同步、微任务、宏任务分别是什么 同步代码会直接进入调用栈，按从上到下的顺序运行。\n微任务常见来源是 Promise.then、catch、finally、queueMicrotask，以及 async/await 中 await 后面继续执行的部分。\n宏任务常见来源是 setTimeout、setInterval、用户交互事件和部分 I/O 回调。不同运行环境对任务来源的细节有区别，但浏览器开发中先掌握这套顺序已经足够解决大多数问题。\n看一个最常见的输出顺序 把下面代码复制到浏览器控制台：\n1 2 3 4 5 6 7 8 9 10 11 console.log(\u0026#39;1：同步开始\u0026#39;) setTimeout(() =\u0026gt; { console.log(\u0026#39;4：定时器任务\u0026#39;) }, 0) Promise.resolve().then(() =\u0026gt; { console.log(\u0026#39;3：Promise 微任务\u0026#39;) }) console.log(\u0026#39;2：同步结束\u0026#39;) 输出顺序是：\n1 2 3 4 1：同步开始 2：同步结束 3：Promise 微任务 4：定时器任务 原因是两条 console.log 都是同步代码，会先执行。Promise.then 进入微任务队列，setTimeout 回调进入后续任务队列。调用栈清空后，浏览器先执行微任务，最后才轮到定时器回调。\n多个微任务按进入顺序执行 微任务不会互相抢占，而是按加入队列的先后顺序依次执行。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 console.log(\u0026#39;A\u0026#39;) Promise.resolve().then(() =\u0026gt; { console.log(\u0026#39;C\u0026#39;) }) queueMicrotask(() =\u0026gt; { console.log(\u0026#39;D\u0026#39;) }) setTimeout(() =\u0026gt; { console.log(\u0026#39;E\u0026#39;) }, 0) console.log(\u0026#39;B\u0026#39;) 输出顺序是 A -\u0026gt; B -\u0026gt; C -\u0026gt; D -\u0026gt; E。这里 Promise.then 比 queueMicrotask 先注册，所以它也先执行；两个都完成后，才会执行定时器回调。\nasync / await 为什么看起来像异步暂停 async 函数在遇到 await 前仍然是同步执行的。await 后面的代码会被放入微任务队列。\n1 2 3 4 5 6 7 8 9 10 async function printOrder() { console.log(\u0026#39;1：进入 async 函数\u0026#39;) await Promise.resolve() console.log(\u0026#39;3：await 后继续执行\u0026#39;) } printOrder() console.log(\u0026#39;2：外层同步代码\u0026#39;) 输出是：\n1 2 3 1：进入 async 函数 2：外层同步代码 3：await 后继续执行 所以 await 并不会阻塞整个页面，它只暂停当前 async 函数。其他同步代码仍然会先走完。\n在 Vue 中什么时候用 nextTick Vue 修改响应式数据后，不会每改一个值就立刻更新 DOM，而是会把更新合并。需要拿到更新后的真实 DOM 时，用 nextTick 比 setTimeout 更准确。\n1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import { nextTick, ref } from \u0026#39;vue\u0026#39; const visible = ref(false) const openDialog = async () =\u0026gt; { visible.value = true await nextTick() document.querySelector\u0026lt;HTMLInputElement\u0026gt;(\u0026#39;#search-input\u0026#39;)?.focus() } \u0026lt;/script\u0026gt; nextTick 的语义是“等 Vue 完成本轮 DOM 更新后再执行”，而不是单纯延迟一会儿。它更适合处理弹窗打开后聚焦、列表更新后滚动到底部这类需求。\n一个容易忽略的问题 微任务不是越多越好。每一轮宏任务结束后，浏览器会先清空当前微任务队列。若在微任务里不断创建新的微任务，渲染和用户交互就可能被推迟。\n1 2 3 4 5 const runForever = () =\u0026gt; { queueMicrotask(runForever) } runForever() 这类代码会让浏览器一直处理微任务，页面可能失去响应。实际业务里，耗时计算应该拆分处理，或者交给 Web Worker，避免堵住主线程。\n小结 理解事件循环后，排查异步顺序可以按这个步骤走：先找同步代码，再找 Promise 和 await 产生的微任务，最后看 setTimeout 等后续任务。大部分“为什么这个日志先出来”“为什么 DOM 还没更新”的问题，都会变得清楚很多。\n","date":"2025-04-12T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/javascript-%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF%E5%88%9D%E6%AD%A5%E7%90%86%E8%A7%A3%E5%90%8C%E6%AD%A5%E5%BE%AE%E4%BB%BB%E5%8A%A1%E5%AE%8F%E4%BB%BB%E5%8A%A1/","title":"JavaScript 事件循环初步理解：同步、微任务、宏任务"},{"content":"echarts-learn 是一个 Vue、Nuxt 和 ECharts 数据可视化练习项目，用来展示多图表 dashboard 的页面组织方式。它覆盖柱状图、雷达图、关系图、环形图、词云和数据总览，适合用来参考 ECharts data visualization、Vue chart project 和 dashboard UI 的组合方式。\n适合谁 想参考 Vue 和 ECharts 如何组合成数据大屏的人。 正在做 dashboard UI、数据看板、监控页面或图表交互练习的人。 需要柱状图、雷达图、关系图、环形图和词云组合示例的人。 想看 Nuxt 项目如何组织组件和页面入口的人。 项目价值 这个项目的重点不是单个图表，而是把多个可视化组件放在同一个页面里，形成一个完整的数据看板。对搜索 Vue ECharts dashboard、ECharts data visualization、Nuxt dashboard 或 chart interaction demo 的人来说，这个页面可以直接说明项目的展示效果和代码入口。\n截图来自本地运行项目并使用同结构演示数据生成。这样可以避免远程示例数据接口不可访问时只截到加载态，同时保留项目真实的页面布局和组件结构。\n技术方向 Vue Nuxt ECharts 数据可视化 dashboard UI 图表组件拆分 数据看板页面组织 项目入口 GitHub 仓库：linsk27/echarts-learn 项目总览：linsk27 Projects 如果这个 ECharts 数据可视化项目对你有参考价值，欢迎在 GitHub 仓库点 Star。Star 是我继续补充图表说明、运行文档和可复用示例的主要信号。\n","date":"2025-04-09T00:00:00Z","image":"https://linsk27.github.io/img/projects/echarts-learn-dashboard.png","permalink":"https://linsk27.github.io/projects/echarts-learn/","title":"echarts-learn 数据可视化练习"},{"content":"什么是Echarts ECharts（全称：Enterprise Charts）是由百度开源的一个基于 JavaScript 的可视化图表库，专门用于构建交互性强、表现丰富的数据可视化图表。\n它能做什么？ ECharts 提供了多种常用和高级图表类型，适用于数据分析、仪表盘、报表系统、可视化大屏等场景，例如：\n折线图（line） 柱状图（bar） 饼图（pie） 散点图（scatter） 地图（map，支持中国地图和世界地图） 仪表盘（gauge） 热力图（heatmap） 自定义图形（graph、tree、sunburst 等） 它的核心特点： 特性 说明 高性能 基于 Canvas 渲染，适合大数据量可视化 响应式 能适应不同屏幕尺寸，自适应缩放 交互性强 支持 tooltip、点击事件、缩放、图例控制等 主题丰富 提供多种主题，也可自定义 开源免费 使用 Apache-2.0 协议，商业项目也可免费用 安装方式（前端项目）： 如果你用的是 Nuxt、Vue、React 等框架，常见引入方式有两种：\nnpm/yarn 安装： 1 2 3 yarn add echarts # 或者 npm install echarts 直接在 HTML 中引用（CDN）： 1 \u0026lt;script src=\u0026#34;https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 其他类似图表库 虽然 ECharts 很强大，但近几年市面上也涌现了不少其它优秀的图表库，例如：\nAntV/G2 系列：蚂蚁金服出品，视觉效果更现代，支持更多数据关系分析，且生态体系逐步完善。\nD3.js：功能灵活，适合自定义程度非常高的可视化开发，不过上手难度和开发成本较高。\nHighcharts、Chart.js：Highcharts 商业授权较为严格，而 Chart.js 则适合中小型数据量和基本交互需求。\n如何写一个图表 图表步骤 初始化 echarts 实例\n1 2 3 4 let myChart = null onMounted(()=\u0026gt;{ myChart = echarts.init(target.value) }) 构建 option 配置对象\n1 2 const options = { } 通过 实例.setOption(option) 挂载到对应盒子上\n1 myChart.setOption(options) 图表配置 柱形图 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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 const options = { // X轴展示数据 xAxis:{ show: false, // x方向作为值展示 type: \u0026#39;value\u0026#39;, // 设置最大值的配置 max: function (value) { // 动态最大值设置为所有数据中的最大值1.2倍，防止样式问题 return parseInt(value.max * 1.2) } }, // y轴展示数据 yAxis:{ // 作为列名展示 type: \u0026#39;category\u0026#39;, // 列名数组，默认从下到上 data: props.data.regions?.map((item) =\u0026gt; item.name), // 反向展示 inverse: true, // 不展示线 axisLine: { show: false }, // 不展示刻度 axisTick: { show: false }, // 展示列名并且label颜色设置 axisLabel: { color: \u0026#39;#9eb1cd\u0026#39; }, }, // 图表绘制的位置，对应上下左右 grid:{ top: 0, right: 0, left: 0, bottom: 0, // 标签包含进去 containLabel: true }, // 核心配置 series:[ { // 柱形图 type: \u0026#39;bar\u0026#39;, // 展示的数据 data: props.data.regions?.map((item) =\u0026gt; ({ name: item.name, value: item.value })), // 柱状条背景开关 showBackground: true, // 柱状条背景色 backGroundStyle: { color: \u0026#39;rgba(180, 180, 180, 0.2)\u0026#39; }, // 每个轴的样式 itemStyle: { color: \u0026#39;#5d98CE\u0026#39;, barBorderRadius: 10, shadowColor: \u0026#39;rgba(0,0,0,0.3)\u0026#39;, shadowBlur: 5, }, // 柱子宽度 barWidth: 12, // 字体的显示 label: { show: true, position: \u0026#39;right\u0026#39;, textStyle: { color: \u0026#39;#fff\u0026#39;, } // 为数据加上百分比，c代表数据 formatter:\u0026#39;{c}%\u0026#39; } } ], } 雷达图 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 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 const option = { // 雷达坐标系配置 radar: { // 坐标系（外部）名字配置 name: { textStyle: { color: \u0026#39;#05D5FF\u0026#39;, fontSize: 14 } }, // 雷达图形状 shape: \u0026#39;polygon\u0026#39;, center: [\u0026#39;50%\u0026#39;, \u0026#39;50%\u0026#39;], radius: \u0026#39;80%\u0026#39;, // 起始位置(开始角度) startAngle: 120, // 轴线配置 axisLine: { lineStyle: { color: \u0026#39;rgba(5,213,255,0.8)\u0026#39; } }, // 网格线，为true才能形成闭环 splitLine: { show: true, lineStyle: { width: 1, color: \u0026#39;rgba(5,213,255,0.8)\u0026#39; } }, // 指示器,就是name部分的坐标系（外部）名字 indicator: props.data.risks.map((item) =\u0026gt; ({ name: item.name, max: 100 })), // 拆分区域，false网格内颜色区分更明显 splitArea: { show: false } }, // 坐标极点 polar: { center: [\u0026#39;50%\u0026#39;, \u0026#39;50%\u0026#39;], radius: \u0026#39;0%\u0026#39; }, // 坐标角度 angleAxis: { min: 0, // 分割线分割间隔 interval: 5, // 刻度显示是否为逆时针增长, clockwise: false }, // 径向轴 radiusAxis: { min: 0, interval: 20, splitLine: { show: false } }, // 图表核心配置 series: [ { type: \u0026#39;radar\u0026#39;, // 指定形状 symbol: \u0026#39;circle\u0026#39;, // 内部数据拐角大小 symbolSize: 10, itemStyle: { normal: { color: \u0026#39;#05D5FF\u0026#39; } }, areaStyle: { normal: { color: \u0026#39;#05D5FF\u0026#39;, opacity: 0.5 } }, lineStyle: { width: 2, color: \u0026#39;#\u0026#39; }, label: { normal: { // 内部数据文本 show: true, color: \u0026#39;#fff\u0026#39; } }, data: [ { value: props.data.risks.map((item) =\u0026gt; item.value) } ] } ] } 环形图 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 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 const getSeriesData = () =\u0026gt; { const series = [] props.data.abnormals.forEach((item, index) =\u0026gt; { // 上层 series.push({ name: item.name, type: \u0026#39;pie\u0026#39;, // 逆时针排列 clockWise: false, // 取消悬浮动画 hoverAnimation: false, // 实现半径依次递减 radius: [73 - index * 15 + \u0026#39;%\u0026#39;, 68 - index * 15 + \u0026#39;%\u0026#39;], center: [\u0026#39;50%\u0026#39;, \u0026#39;50%\u0026#39;], label: { show: false }, data: [ { value: item.value, name: item.name }, { value: 1000, itemStyle: { color: \u0026#39;rgba(0,0,0,0)\u0026#39;, borderWidth: 0, }, tooltip: { show: false }, hoverAnimation: false } ] }) // 底层 series.push({ name: item.name, type: \u0026#39;pie\u0026#39;, // 不希望收到任何事件 silent: true, z: 1, // 逆时针排列 clockWise: false, // 取消悬浮动画 hoverAnimation: false, // 实现半径依次递减 radius: [73 - index * 15 + \u0026#39;%\u0026#39;, 68 - index * 15 + \u0026#39;%\u0026#39;], center: [\u0026#39;50%\u0026#39;, \u0026#39;50%\u0026#39;], label: { show: false }, data: [ { value: 7.5, name: item.name, itemStyle: { color: \u0026#39;rgba(3,31,62)\u0026#39;, borderWidth: 0, }, tooltip: { show: false, }, hoverAnimation: false }, { value: 2.5, itemStyle: { color: \u0026#39;rgba(0,0,0,0)\u0026#39;, borderWidth: 0, }, tooltip: { show: false, }, hoverAnimation: false } ] }) }); return series } const option = { // 图例配置 legend: { show: true, // 图例颜色块形状 icon: \u0026#39;circle\u0026#39;, top: \u0026#39;8%\u0026#39;, left: \u0026#39;52%\u0026#39;, // 图例数据文本 data: props.data.abnormals.map((item) =\u0026gt; item.name), // 负数以列展示 width: -5, // 色块宽高 itemWidth: 10, itemHeight: 10, // 图例间距 itemGap: 6, textStyle: { fontSize: 12, lineHeight: 5, color: \u0026#39;#fff\u0026#39; } }, // 悬浮提示层 tooltip: { show: true, // 触发器 trigger: \u0026#39;item\u0026#39;, // 展示内容,a代表系列名，b代表数据名，c代表数据值,d代表百分比比例 formatter: \u0026#39;{a}\u0026lt;br\u0026gt;{b}:{c}({d}%)\u0026#39; }, yAxis: [ { type: \u0026#39;category\u0026#39;, // 反向展示 inverse: true, axisLine: { show: false } } ], xAxis: [ { show: false } ], // 由于饼图过多，通过方法进行配置 series: getSeriesData() } 关系图 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 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 const option = { xAxis: { show: false, type: \u0026#39;value\u0026#39; }, yAxis: { show: false, type: \u0026#39;value\u0026#39; }, series: [ { type: \u0026#39;graph\u0026#39;, // 表示不需要任何布局类型 layout: \u0026#39;none\u0026#39;, // 该系列需要使用的坐标系：“二维直角坐标系” coordinateSystem: \u0026#39;cartesian2d\u0026#39;, // 节点大小 symbolSize: 26, z: 3, // 边界的萧条标签文字 edgeLabel: { // 默认 normal: { show: true, color: \u0026#39;#fff\u0026#39;, testStyle: { fontSize: 14 }, // 文本模拟内容 formatter: function (params) { return params.data.speed } } }, label: { normal: { show: true, position: \u0026#39;bottom\u0026#39;, color: \u0026#39;#5E5E5E\u0026#39; } }, // 边两端的标记类型,数据流动图标类型 edgeSymbol: [\u0026#39;none\u0026#39;, \u0026#39;arrow\u0026#39;], edgeSymbolSize: 8, data: props.data.relations.map(item =\u0026gt; { if (item.id !== 0) { return { name: item.name, category: 0, active: true, speed: `${item.speed}kb/s`, value: item.value, } } else { return { name: item.name, value: item.value, symbolSize: 100, itemStyle: { color: { colorStops: [ { offset: 0, color: \u0026#39;#157eff\u0026#39; }, { offset: 1, color: \u0026#39;#35c2ff\u0026#39; }, ] } }, label: { normal: { fontSize: 14 } } } } }), // 节点间的数据关系 links: props.data.relations.map((item, index) =\u0026gt; ({ source: item.source, target: item.target, speed: `${item.speed}kb/s`, lineStyle: { normal: { color: \u0026#39;#12b5d0\u0026#39;, // 曲线率 curveness: 0.2 } }, label: { show: true, position: \u0026#39;middle\u0026#39;, // 是否对文字进行偏移 offset: [10, 0] } })) }, { type: \u0026#39;lines\u0026#39;, // 该系列需要使用的坐标系：“二维直角坐标系” coordinateSystem: \u0026#39;cartesian2d\u0026#39;, z: 1, // 线条的特效配置 effect: { show: true, smooth: false, trailLength: 0, symbol: \u0026#39;arrow\u0026#39;, color: \u0026#39;rgba(55,155,255,0.6)\u0026#39;, symbolSize: 12 }, lineStyle: { normal: { curveness: 0.2 } }, data: [ [{ coord: [0, 300] }, { coord: [50, 200] }], [{ coord: [0, 100] }, { coord: [50, 200] }], [{ coord: [50, 200] }, { coord: [100, 100] }], [{ coord: [50, 200] }, { coord: [100, 300] }] ] } ] } 词云图 需要安装依赖后导入import(\u0026quot;echarts-wordcloud\u0026quot;)\n注意：借助浏览器的插件在Nuxt框架下需要判断让它在客户端导入，不然会报错！\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const option ={ series: [ { type: \u0026#39;wordCloud\u0026#39;, // 文字大小范围 sizeRange: [8, 46], // 文字旋转 rotationRange: [0, 0], gridSize: 0, layoutAnimation: true, textStyle: { color: randomRGB }, // 高亮字体 emphasis: { textStyle: { fontWeight: \u0026#39;bold\u0026#39;, color: \u0026#39;#000\u0026#39; } }, data: props.data.datas } ] } 封装composable用于操作echarts生成 按需加载插件，节省初始包体积。 避免 SSR 报错（import 仅在客户端执行）。 onMounted：监听 resize 事件，onBeforeUnmount：解除 resize 监听，释放图表实例 销毁 ECharts 实例：释放资源，避免内存不断积累。 正确时机：组件即将卸载时执行，最适合做 cleanup。 markRaw 处理 chartInstance，避免将 ECharts 实例做响应式包裹，提升性能，避免不必要的跟踪。 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 60 61 62 63 // 导入 echarts 主库 import * as echarts from \u0026#34;echarts\u0026#34; import { markRaw } from \u0026#39;vue\u0026#39; // 添加这句 // 声明 process.client（为了让 TypeScript 不报错） // 在 Nuxt 中，process.client 表示是否处于客户端运行环境 declare const process: { client: boolean; } // 导出一个 composable，用于封装 echarts 的初始化和操作逻辑 export const useEcharts = () =\u0026gt; { // 图表容器的 DOM 引用（挂载到页面上的 \u0026lt;div ref=\u0026#34;chartRef\u0026#34;\u0026gt;） const chartRef = ref\u0026lt;HTMLElement | null\u0026gt;(null) // 图表实例对象（echarts.init 的返回值） let chartInstance: echarts.ECharts | null = null // 初始化图表，支持普通图表或词云（wordcloud） const initChart = async (type?: \u0026#34;wordcloud\u0026#34;) =\u0026gt; { if (!chartRef.value) return // 容器还没挂载，跳过 // 如果是词云图表，并且是在客户端环境下，动态加载 echarts-wordcloud 插件 if (type === \u0026#34;wordcloud\u0026#34; \u0026amp;\u0026amp; process.client) { await import(\u0026#34;echarts-wordcloud\u0026#34;) } // 初始化 echarts 实例，绑定到 DOM 容器上, 使用 markRaw 避免响应式干扰 chartInstance = markRaw(echarts.init(chartRef.value)) } // 设置图表的配置项（option） const setOptions = (options: any) =\u0026gt; { chartInstance?.setOption(options) } // 响应窗口大小变化，重置图表大小 const resize = () =\u0026gt; { chartInstance?.resize() } // 组件挂载时绑定 resize 事件监听 onMounted(() =\u0026gt; { if (process.client) { window.addEventListener(\u0026#34;resize\u0026#34;, resize) } }) // 组件销毁前解绑事件监听，并销毁 echarts 实例释放资源 onBeforeUnmount(() =\u0026gt; { if (process.client) { window.removeEventListener(\u0026#34;resize\u0026#34;, resize) chartInstance?.dispose() } }) // 返回方法和 DOM 引用，供组件内使用 return { chartRef, // DOM 引用，组件中通过 ref=\u0026#34;chartRef\u0026#34; 绑定 initChart, // 初始化图表方法 setOptions, // 设置图表配置项方法 resize // 手动触发图表 resize } } 用nuxt3写echarts开发注意事项 SSR渲染问题 图表组件依赖 DOM 和浏览器环境，不能 SSR，用 \u0026lt;ClientOnly\u0026gt; 包裹图表部分，完美规避服务端渲染的问题。 ","date":"2025-04-08T17:44:12+08:00","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/0408-%E5%88%9D%E6%AD%A5%E6%8E%A5%E8%A7%A6%E5%8F%AF%E8%A7%86%E5%8C%96echarts/","title":"初步接触可视化Echarts"},{"content":"关键概念 导入功能的提升 Components 文件夹组件自动导入 在 Nuxt 中，你可以在 components/ 目录中创建这些组件，它们将自动在整个应用程序中可用，无需显式地导入。 Auto-imports 自动导入(响应式 API、生命周期) 1 2 3 4 5 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; /* ref() and computed() are auto-imported */ const count = ref(1) const double = computed(() =\u0026gt; count.value * 2) \u0026lt;/script\u0026gt; Built-in Auto-imports 内置自动导入 1 2 3 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; /* useFetch() is auto-imported */ const {(data, refresh, status)} = await useFetch(\u0026#39;/api/hello\u0026#39;) \u0026lt;/script\u0026gt; Vue.js 路由 1. 通过pages/每个组件生成路由 1 2 3 4 5 -| pages/ ---| about.vue ---| index.vue ---| posts/ -----| [id].vue 2. \u0026lt;NuxtLink\u0026gt;标签进行路由跳转 1 2 3 4 5 6 7 8 9 10 11 \u0026lt;template\u0026gt; \u0026lt;header\u0026gt; \u0026lt;nav\u0026gt; \u0026lt;ul\u0026gt; \u0026lt;li\u0026gt;\u0026lt;NuxtLink to=\u0026#34;/about\u0026#34;\u0026gt;About\u0026lt;/NuxtLink\u0026gt;\u0026lt;/li\u0026gt; \u0026lt;li\u0026gt;\u0026lt;NuxtLink to=\u0026#34;/posts/1\u0026#34;\u0026gt;Post 1\u0026lt;/NuxtLink\u0026gt;\u0026lt;/li\u0026gt; \u0026lt;li\u0026gt;\u0026lt;NuxtLink to=\u0026#34;/posts/2\u0026#34;\u0026gt;Post 2\u0026lt;/NuxtLink\u0026gt;\u0026lt;/li\u0026gt; \u0026lt;/ul\u0026gt; \u0026lt;/nav\u0026gt; \u0026lt;/header\u0026gt; \u0026lt;/template\u0026gt; 3. Route Parameters 路由参数 1 2 3 4 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; const route = useRoute() // When accessing /posts/1, route.params.id will be 1 console.log(route.params.id) \u0026lt;/script\u0026gt; 4. Route Middleware 路由中间件 导航到特定路由之前提取要运行的代码 路由中间件在 Nuxt 应用程序的 Vue 部分运行。尽管名称相似，但它们与服务器中间件完全不同，后者在应用程序的 Nitro 服务器部分运行。 1 2 3 4 5 6 export default defineNuxtRouteMiddleware((to, from) =\u0026gt; { // isAuthenticated() is an example method verifying if a user is authenticated if (isAuthenticated() === false) { return navigateTo(\u0026#34;/login\u0026#34;); } }); 1 2 3 4 5 6 7 8 9 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; definePageMeta({ middleware: \u0026#34;auth\u0026#34;, }); \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;h1\u0026gt;Welcome to your dashboard\u0026lt;/h1\u0026gt; \u0026lt;/template\u0026gt; 5. Route Validation 路由验证 Nuxt 通过你想要验证的每个页面中 definePageMeta（）中的 validate 属性提供路由验证。 validate 属性接受路由作为参数。您可以返回一个布尔值，以确定这是否是要使用此页面呈现的有效路由。如果返回 false，但找不到其他匹配项，则会导致 404 错误。您也可以直接返回带有 statusCode/statusMessage 的对象，以立即响应错误（不会检查其他匹配项）。 1 2 3 4 5 6 7 8 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; definePageMeta({ validate: async (route) =\u0026gt; { // Check if the id is made up of digits return typeof route.params.id === \u0026#39;string\u0026#39; \u0026amp;\u0026amp; /^\\d+$/.test(route.params.id) } }) \u0026lt;/script\u0026gt; Rendering Modes 渲染模式 浏览器和服务器都可以解释 JavaScript 代码，以将 Vue.js 组件转换为 HTML 元素。此步骤称为 渲染 。 默认情况下，Nuxt 使用通用渲染来提供更好的用户体验、性能并优化搜索引擎索引，但您可以在一行配置中切换渲染模式。 关于服务端渲染和客户端渲染的一个bug，使用setInterval计时器,服务端报错500 1 2 3 setInterval(() =\u0026gt; { request() }, 3000); 1. 问题分析 当你在 Nuxt 的组件或页面中直接使用 setInterval 时，这段代码会在服务端渲染阶段被执行。然而，setInterval 是浏览器环境中的 API，在服务端（Node.js）环境中会报错，提示类似以下内容：\n1 ReferenceError: setInterval is not defined 2. 解决方案 使用 process.client 判断是否在客户端运行\nnuxt 提供了 process.client 变量，用于判断当前代码是否在浏览器环境中运行。你可以通过这个变量确保 setInterval 只在客户端执行。\n1 2 3 4 5 6 7 8 9 10 11 // 确保 setInterval 只在客户端运行 if (process.client) { const intervalId = setInterval(() =\u0026gt; { loadData() }, 3000) // 组件销毁时清除计时器，防止内存泄漏 onUnmounted(() =\u0026gt; { clearInterval(intervalId) }) } 将计时器逻辑放在 mounted 生命周期钩子中\n在 Vue.js 中，mounted 钩子只会在客户端渲染完成后执行，因此可以将计时器逻辑放在这里。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export default { mounted() { this.interval = setInterval(() =\u0026gt; { this.loadData(); }, 3000); }, beforeDestroy() { // 清除计时器，防止内存泄漏 clearInterval(this.interval); }, methods: { loadData() { console.log(\u0026#39;加载数据...\u0026#39;); } } }; 什么是服务器渲染的，什么是客户端渲染的？ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; const counter = ref(0); // executes in server and client environments const handleClick = () =\u0026gt; { counter.value++; // executes only in a client environment }; \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;div\u0026gt; \u0026lt;p\u0026gt;Count: {{ counter }}\u0026lt;/p\u0026gt; \u0026lt;button @click=\u0026#34;handleClick\u0026#34;\u0026gt;Increment\u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; 在初始请求中，计数器 ref 在服务器中初始化，因为它呈现在 \u0026lt;p\u0026gt;标记内。handleClick 的内容在此处永远不会执行。在浏览器中激活期间，counter ref 会重新初始化。handleClick 最终将自身绑定到按钮;因此，可以合理地推断 handleClick 的主体将始终在浏览器环境中运行。\nUniversal Rendering 通用渲染 原理 当浏览器请求启用了通用渲染的 URL 时，Nuxt 会在服务器环境中运行 JavaScript （Vue.js） 代码，并将完全渲染的 HTML 页面返回给浏览器。如果页面是提前生成的，Nuxt 也可以从缓存中返回完全渲染的 HTML 页面。用户会立即获得应用程序的全部初始内容，这与客户端渲染相反。 下载 HTML 文档后，浏览器会解释此内容，Vue.js 将控制该文档。曾经在服务器上运行的相同 JavaScript 代码再次在客户端（浏览器）上运行，现在通过将其侦听器绑定到 HTML 来启用交互性（因此是通用渲染）。 效果 通用渲染允许 Nuxt 应用程序提供快速的页面加载时间，同时保留客户端渲染的优势。此外，由于内容已经存在于 HTML 文档中，因此爬网程序可以毫无开销地为其编制索引。 通用渲染用途广泛，几乎可以适应任何用例，特别适用于任何面向内容的网站：博客、营销网站、投资组合、电子商务网站和市场。 1. 服务器端渲染（SSR） 概念:\n在 SSR 模式下，页面的 HTML 内容是在服务器端动态生成的，然后发送到客户端。客户端接收到 HTML 后，再通过 JavaScript 进行进一步的交互。 SSR 是 Nuxt 3 的默认渲染模式，适合需要 SEO 优化或需要快速首屏加载的应用。 好处\n性能：用户可以立即访问页面的内容，因为浏览器显示静态内容的速度比 JavaScript 生成的内容快得多。同时，Nuxt 在水合过程中保留了 Web 应用程序的交互性。 搜索引擎优化：通用渲染将页面的整个 HTML 内容作为经典服务器应用程序交付给浏览器。Web 爬虫可以直接为页面的内容编制索引，这使得 Universal rendering 成为您想要快速编制索引的任何内容的绝佳选择。 缺点\n开发约束：服务器和浏览器环境不提供相同的 API，因此编写可以在两端无缝运行的代码可能很棘手。幸运的是，Nuxt 提供了指南和特定变量来帮助您确定一段代码的执行位置。 成本：需要运行服务器才能动态呈现页面。这会增加每月成本，就像任何传统服务器一样。但是，由于浏览器接管了客户端导航的通用渲染，服务器调用大大减少了。利用 edge-side 渲染可以降低成本。 2. 静态站点生成（Static Site Generation, SSG） 在 SSG 模式下，页面的 HTML 是在构建时预先生成的，并作为静态文件部署到服务器。这些静态页面可以在没有服务器端逻辑的情况下直接被客户端加载。 SSG 适合内容不经常更新的网站，如博客或文档网站。 3. 单页应用（Single Page Application, SPA） 在 SPA 模式下，页面的 HTML 是在客户端动态生成的，服务器只提供一个初始的 HTML 模板和必要的 JavaScript 文件。 SPA 适合对交互性要求较高的应用，但可能对 SEO 不太友好。 Client-Side Rendering 客户端渲染 相较于通用渲染相反，不做过多叙述\n好处 开发速度：不考虑服务器的兼容性 便宜： 不考虑服务器基础设施成本 离线： 在 Internet 不可用时很好地保持工作 缺点 性能: 需等待浏览器下载、解析、运行js，响应长，影响用户体验 搜索引擎优化: 索引和更新通过客户端渲染交付的内容需要更多时间。不利于引擎爬虫。 您可以在 nuxt.config.ts中使用 Nuxt 启用仅客户端渲染： 1 2 3 export default defineNuxtConfig({ ssr: false }) :warning: 如果你确实使用 ssr： false，你还应该放置一个 HTML 文件， ~/app/spa-loading-template.html 其中包含一些你想用来渲染加载屏幕的 HTML，该屏幕将一直呈现，直到你的应用程序被激活。\nDeploying a Static Client-Rendered App部署静态客户端呈现的应用程序 Hybrid Rendering 混合渲染 Route Rules 路由规则 Edge-Side Rendering 边侧渲染 Nuxt的Server层服务 通过defineEventHandler()定义接口 server文件夹下定义接 名字channel.get.ts，默认get接口 1 2 3 4 5 6 7 // server // - /api // - channel.get.ts import channel from \u0026#34;~/database/channel\u0026#34; //channel 是database定义的默认数据 export default defineEventHandler(()=\u0026gt;{ return channel }) 使用useFetch()调用接口 1 2 3 // 无需导入 // 解构-重命名 const {data:channelList} =await useFetch(\u0026#39;/api/channel\u0026#39;) 我写过的项目的写法的记录 1 2 3 4 5 6 7 8 9 10 11 12 13 import { API_HOT_CATEGORY_LIST } from \u0026#39;~/api/category\u0026#39; import { nodeFetch } from \u0026#39;~/server/utils/handle\u0026#39; export default defineEventHandler(async (event) =\u0026gt; { const body = await readBody(event) const data = await nodeFetch(API_HOT_CATEGORY_LIST, { body, nitroEvent: event }) return { data, status: 0, info: \u0026#39;ok\u0026#39;, } }) ","date":"2025-04-06T19:18:28+08:00","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/0406-%E5%88%9D%E6%AD%A5%E6%8E%A5%E8%A7%A6nuxt3/","title":"初步接触nuxt3"},{"content":"feidu 是一个 Nuxt 3、Vue、Tailwind CSS 和 ECharts 组成的智慧社区数据大屏项目。它把社区管理、安防监控、CIM 平台、能源检测和节能分析集中在一个可视化界面里，适合展示智慧社区、园区管理、安防运维和能源数据看板类项目的前端实现方式。\n适合谁 想参考智慧社区、智慧园区或数据大屏界面实现的人。 想学习 Nuxt 3、Vue、Tailwind CSS 和 ECharts 组合方式的人。 需要安防监控、CIM 平台、能源检测、节能分析等可视化页面结构的人。 想看真实线上演示，而不是只看静态截图的前端学习者。 项目价值 feidu 的价值在于它不是单个图表练习，而是把多种业务模块组织成一个完整大屏：左侧展示安防概况、摄像头监控和报警信息，中间展示城市 / 社区 CIM 场景，右侧展示舒适度、能耗、系统能效和机组运行状态。底部还提供社区管理、安保监控、CIM 平台、能源检测和节能分析等模块切换入口。\n如果你搜索的是 smart community dashboard、Vue ECharts dashboard、Nuxt 3 data visualization、security monitoring dashboard、energy monitoring dashboard 或 CIM platform UI，这个项目可以作为一个可运行的前端大屏参考。\n技术方向 Nuxt 3 Vue Tailwind CSS ECharts Element Plus 数据可视化大屏 智慧社区界面 安防监控看板 能源监测与节能分析 CIM 平台前端展示 项目入口 GitHub 仓库：linsk27/feidu 线上演示：feidu-murex.vercel.app 项目总览：linsk27 Projects 如果这个智慧社区数据大屏对你有参考价值，欢迎在 GitHub 仓库点 Star。Star 是我继续完善项目文档、模块说明和线上演示体验的主要信号。\n","date":"2025-03-30T00:00:00Z","image":"https://linsk27.github.io/img/projects/feidu-smart-community-dashboard.jpg","permalink":"https://linsk27.github.io/projects/feidu/","title":"feidu 智慧社区数据大屏"},{"content":"初学Promise对象，异步基础 推荐实践代码，自己动手调试，更容易理解原理 基础 三个状态（重要） pendding 等待状态 fullfilled 完成状态 rejected 失败状态 状态流向以及回调函数resolve和reject Promise状态流向只有两种，而且状态一旦改变，则不会再变\n调用reolve函数，则 pendding =\u0026gt; fullfilled 调用rejecte函数，则pendding =\u0026gt; rejected 只接收第一个参数调用后改变状态，其他的忽略 1 2 3 4 5 6 7 const promise = new Promise((resolve, reject) =\u0026gt; { resolve(1); reject(2); // 被忽略 }) console.log(promise) // PromiseState : fullfilled // PromiseResult: 1 消费者 .then、.catch 最重要最基础的一个就是 .then\n个人见解就是then就是对Promise对象的一个总结反馈，判断对象的状态并且执行fullfilled后的函数\n.then 的第一个参数是一个函数，该函数将在 promise resolved 且接收到结果后执行。\n.then 的第二个参数也是一个函数，该函数将在 promise rejected 且接收到 error 信息后执行。\n下面例子，可自己通过注释选择resolve还是reject\n1 2 3 4 5 6 7 8 9 10 let promise = new Promise(function(resolve, reject) { // setTimeout(() =\u0026gt; resolve(\u0026#34;done!\u0026#34;), 1000); setTimeout(() =\u0026gt; reject(new Error(\u0026#34;Whoops!\u0026#34;)), 1000); }); // reject 运行 .then 中的第二个函数 promise.then( result =\u0026gt; alert(result), // 不运行 error =\u0026gt; alert(error) // 1 秒后显示 \u0026#34;Error: Whoops!\u0026#34; ); 如果我们只对某个状态的情况感兴趣，那么我们可以只为 .then 提供一个函数参数：\n1 2 3 4 5 6 7 let promise = new Promise(resolve =\u0026gt; { setTimeout(() =\u0026gt; resolve(\u0026#34;done!\u0026#34;), 1000); // setTimeout(() =\u0026gt; reject(\u0026#34;fail!\u0026#34;), 1000); }); promise.then(alert); // 1 秒后显示 \u0026#34;done!\u0026#34; // promise.catch(alert); // 1 秒后显示 \u0026#34;fail!\u0026#34; 实际情况成功的情况较为常见，并且.then和.catch也可以通过链式写法\n1 2 3 4 5 new Promise((resolve, reject) =\u0026gt; { resolve(42); }).then(value =\u0026gt; { console.log(value); // 输出: 42 }); Promise.API await+async Promise 通过链式调用（如 .then() 和 .catch()）管理异步操作的成功或失败； async 修饰的函数总会返回一个 Promise，无论返回的是普通值还是 Promise 本身； await await语句之后的代码处于微队列，用于暂停 async 函数的执行，等待后面的 Promise 解析完成，然后直接获得解析结果，从而让异步代码写起来更像同步代码，避免了冗长的 .then() 链。 await 等待的不一定是promise对象，也有可能为await 1; ==\u0026gt; await Promise.resolve(1)\nPromise.all 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Promise.all([ // 每个数组对象都是**异步微任务**，因此1和2瞬间输出 // 对象5 需要等待 对象4 await执行完毕后执行，即3秒后输出3 new Promise(resolve =\u0026gt; setTimeout(() =\u0026gt; resolve(4), 4000)), // 对象0，value4 console.log(1), // 对象1，undefined（不是promise对象） new Promise(resolve =\u0026gt; setTimeout(() =\u0026gt; resolve(2), 2000)), // 对象2，value2 console.log(2), // 对象3，undefined await new Promise(resolve =\u0026gt; setTimeout(() =\u0026gt; resolve(3), 3000)), // 对象4，value3 console.log(3), // 对象5，undefined ]).then((res) =\u0026gt; { console.log(res); }) // 4秒后所有异步任务执行完毕，输出 [ 0:1, 1:undefined, 2:2, 3:undefined, 4:3, 5:undefined ] 全部完成才会运行.then，如果有某一个失败，则不执行.then\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 Promise.allSettled([ new Promise(resolve =\u0026gt; setTimeout(() =\u0026gt; resolve(4), 4000)), console.log(1), new Promise((resolve,reject) =\u0026gt; setTimeout(() =\u0026gt; reject(2), 2000)), // 这里使用reject console.log(2), await new Promise(resolve =\u0026gt; setTimeout(() =\u0026gt; resolve(3), 3000)), console.log(3), ]).then((res) =\u0026gt; { console.log(res); }) [ 0:1, 1:undefined, 2:2, 3:undefined, 4:3, 5:undefined ] Promise.allsettled 如果任意的 promise reject，则 Promise.all 整个将会 reject。当我们需要 所有 结果都成功时，它对这种“全有或全无”的情况很有用\nPromise.allSettled 等待所有的 promise 都被 settle，无论结果如何。结果数组会是这样的：\n对成功的响应，结果数组对应元素的内容为 {status:\u0026quot;fulfilled\u0026quot;, value:result}， 对出现 error 的响应，结果数组对应元素的内容为 {status:\u0026quot;rejected\u0026quot;, reason:error}。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 Promise.allsettled([ new Promise(resolve =\u0026gt; setTimeout(() =\u0026gt; resolve(4), 4000)), console.log(1), new Promise((resolve,reject) =\u0026gt; setTimeout(() =\u0026gt; reject(2), 2000)), // 这里使用reject console.log(2), await new Promise(resolve =\u0026gt; setTimeout(() =\u0026gt; resolve(3), 3000)), console.log(3), ]).then((res) =\u0026gt; { console.log(res); }) // 控制台输出 [ 0:{status:fullfiled,value:4}, 1:{status:fullfiled,value:undefined}, 2:{status:rejected,reson:2}, 3:{status:fullfiled,value:undefined}, 4:{status:fullfiled,value:3}, 5:{status:fullfiled,value:undefined}, ] 参考文献 js小红书\nECMAScript 6 入门\n一道让我失眠的 Promise 面试题\n","date":"2025-03-27T22:33:37+08:00","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/0327-%E5%88%9D%E5%AD%A6promise%E5%AF%B9%E8%B1%A1/","title":"初学Promise对象"},{"content":"bilibili-nuxt3 是一个 Nuxt 3、Vue、TypeScript 和 Vant 移动端项目实战，围绕视频列表、频道导航、视频详情、分页加载、服务端渲染和 SEO 信息配置展开。它适合作为 Nuxt 3 project、Vue mobile app、Nuxt SSR SEO 和前端移动端页面结构的参考。\n适合谁 想参考 Nuxt 3、Vue 和 TypeScript 移动端项目结构的人。 正在学习 SSR、SEO meta、文件路由和首屏数据获取的人。 需要 Vant UI、频道导航、视频列表和详情页示例的人。 想做移动端前端项目实战、课程项目或简历作品的人。 项目价值 这个项目的价值在于它把 Nuxt 的服务端渲染、SEO 信息配置、文件路由、移动端适配和数据交互放在一个完整示例里。相比普通组件练习，它更接近真实移动端内容应用，适合用来观察首页信息流、频道 tab、视频卡片和详情页之间的组织方式。\n项目预览图来自仓库 README。历史线上预览链接当前连接不稳定，因此这个项目页优先保留稳定的截图、技术说明和 GitHub 仓库入口，后续演示站恢复后再补充新的在线入口。\n技术方向 Nuxt 3 Vue TypeScript Vant UI SSR SEO meta 移动端 vw 适配 视频列表与详情页 项目入口 GitHub 仓库：linsk27/bilibili-nuxt3 项目总览：linsk27 Projects 如果这个 Nuxt 3 移动端项目对你有参考价值，欢迎在 GitHub 仓库点 Star。Star 是我继续完善预览、运行文档和项目说明的主要信号。\n","date":"2025-03-23T00:00:00Z","image":"https://linsk27.github.io/img/projects/bilibili-nuxt3-mobile-preview.jpg","permalink":"https://linsk27.github.io/projects/bilibili-nuxt3/","title":"bilibili-nuxt3 Nuxt3 移动端项目"},{"content":"XMLHttpRequest、Axios 与 Fetch 在 Vue3 中的深度解析 一、XMLHttpRequest (XHR) 核心特性 历史地位：Web 2.0时代的核心技术，实现AJAX异步请求 兼容性：支持IE6+等老旧浏览器 缺陷：基于回调函数，易产生回调地狱 基础示例 1 2 3 4 5 6 7 8 9 10 11 12 13 const xhr = new XMLHttpRequest(); // 1. 初始化请求（方法、URL、异步标志） xhr.open(\u0026#39;GET\u0026#39;, \u0026#39;/api/data\u0026#39;, true); // 2. 监听状态变化事件 xhr.onreadystatechange = function() { // 3. 判断请求完成且状态码200 if (xhr.readyState === 4 \u0026amp;\u0026amp; xhr.status === 200) { // 4. 手动解析JSON响应 console.log(JSON.parse(xhr.responseText)); } }; // 5. 发送请求 xhr.send(); 代码解析 open()方法： 第三个参数true表示异步请求 readyState状态（标识XMLHttpRequest对象处于什么状态）说明： 0: 未初始化 1: 已建立连接 2: 请求已接收 3: 处理中 4: 完成 手动处理JSON：需通过JSON.parse()转换响应文本 错误处理缺失：未处理4xx/5xx状态码 GET和POST请求数据区别 使用Get请求时,参数在URL中显示,而使用Post方式,则放在send里面 使用Get请求发送数据量小,Post请求发送数据量大 使用Get请求安全性低，会被缓存，而Post请求反之 关于第一点区别，详情见下面两张图：\n参考文献：Ajax原理一篇就够了\n二、Fetch API 核心特性 现代标准：基于Promise的链式调用 简洁语法：支持async/await 注意点：默认不携带cookie 基础示例 1 2 3 4 5 6 7 8 fetch(\u0026#39;/api/data\u0026#39;) .then(response =\u0026gt; { // 手动处理非2xx状态码 if (!response.ok) throw new Error(\u0026#39;HTTP error\u0026#39;); return response.json(); // 自动解析JSON }) .then(data =\u0026gt; console.log(data)) .catch(error =\u0026gt; console.error(\u0026#39;Error:\u0026#39;, error)); 代码解析 response.ok：自动判断2xx状态码 .json()方法：返回Promise对象，自动解析JSON 错误处理： .catch()捕获网络错误 需手动处理业务错误（如404） Vue3整合示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { ref, onMounted } from \u0026#39;vue\u0026#39;; export default { setup() { const data = ref(null); onMounted(async () =\u0026gt; { try { // 使用async/await简化代码 const response = await fetch(\u0026#39;/api/data\u0026#39;); data.value = await response.json(); } catch (error) { console.error(\u0026#39;Fetch Error:\u0026#39;, error); } }); return { data }; } }; 三、Axios 核心特性 功能丰富：拦截器、自动JSON转换 跨平台：支持浏览器和Node.js 生态集成：与Vue3深度适配 基础示例 1 2 3 4 5 6 axios.get(\u0026#39;/api/data\u0026#39;) .then(response =\u0026gt; console.log(response.data)) .catch(error =\u0026gt; { // 统一错误处理 console.error(error.response?.data || error.message); }); 高级用法：创建实例 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 // src/api/index.js import axios from \u0026#39;axios\u0026#39;; const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_URL, timeout: 5000, headers: {\u0026#39;X-Custom-Header\u0026#39;: \u0026#39;foobar\u0026#39;} }); // 请求拦截器 apiClient.interceptors.request.use(config =\u0026gt; { const token = localStorage.getItem(\u0026#39;token\u0026#39;); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); // 响应拦截器 apiClient.interceptors.response.use( response =\u0026gt; response.data, error =\u0026gt; { if (error.response.status === 401) { // 统一处理未授权错误 router.push(\u0026#39;/login\u0026#39;); } return Promise.reject(error); } ); export default apiClient; 四、Vue3最佳实践 1. 组合式API封装 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import { ref } from \u0026#39;vue\u0026#39;; export function useFetch(url) { const data = ref(null); const loading = ref(false); const error = ref(null); const fetchData = async () =\u0026gt; { try { loading.value = true; const response = await fetch(url); data.value = await response.json(); } catch (err) { error.value = err.message; } finally { loading.value = false; } }; return { data, loading, error, fetchData }; } 2. Pinia状态管理 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // stores/posts.js import { defineStore } from \u0026#39;pinia\u0026#39;; import axios from \u0026#39;axios\u0026#39;; export const usePostStore = defineStore(\u0026#39;posts\u0026#39;, { state: () =\u0026gt; ({ posts: [], loading: false, error: null }), actions: { async fetchPosts() { this.loading = true; try { const res = await axios.get(\u0026#39;/api/posts\u0026#39;); this.posts = res.data; } catch (error) { this.error = error.message; } finally { this.loading = false; } } } }); 五、技术选型对比表 特性 XMLHttpRequest Fetch Axios 语法复杂度 ⭐️（回调地狱） ⭐️⭐️⭐️（Promise） ⭐️⭐️⭐️⭐️（链式调用） 浏览器兼容性 IE6+ IE10+ IE11+ 自动JSON转换 ❌ ✅ ✅ 拦截器支持 ❌ ❌ ✅ 取消请求 手动实现 ✅（AbortController） ✅ 默认携带Cookie ❌ ❌ ✅ 六、Vue3项目推荐方案 小型项目：fetch + 组合式API封装 中大型项目：Axios实例 + Pinia状态管理 SSR项目：Nuxt3的useFetch组合式函数 TypeScript项目：Axios + 类型守卫 提示：在Vue3.2+版本中，推荐使用\u0026lt;script setup\u0026gt;语法配合组合式API进行开发，可获得更好的类型推导支持。\n","date":"2025-03-22T12:01:56+08:00","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/0322-%E4%B8%89%E7%A7%8D%E6%8E%A5%E5%8F%A3%E8%B0%83%E7%94%A8/","title":"XMLHttpRequest、Axios 与 Fetch 在 Vue3 中的深度解析"},{"content":"链表环检测算法（LeetCode 141） 我的实现方法：哈希表检测法 原始思路 通过哈希表记录已访问过的节点，当遍历到已存在的节点时，说明存在环\n初始代码 1 2 3 4 5 6 7 8 9 var hasCycle = function (head) { let seen = new Map() while(head){ seen.set(head, true); head = head.next; if(seen.get(head)) return true } return false }; 代码优化建议 数据结构优化：Map → Set 更适合存储唯一值 逻辑顺序调整：先检查再移动指针更严谨 优化后代码 1 2 3 4 5 6 7 8 9 var hasCycle = function(head) { const seen = new Set(); while (head) { if (seen.has(head)) return true; // 先检查当前节点 seen.add(head); // 再记录访问状态 head = head.next; // 最后移动指针 } return false; }; 问题分析 原始代码存在两个潜在风险：\n检查的是head.next而非当前节点 移动指针后可能跳过最后一个节点的检查 扩展方法：快慢指针法（Floyd判圈算法） 核心思想 使用速度不同的双指针：\n快指针每次走两步 慢指针每次走一步 若存在环则必然相遇 代码实现 1 2 3 4 5 6 7 8 9 var hasCycle = function(head) { let slow = head, fast = head; while (fast \u0026amp;\u0026amp; fast.next) { slow = slow.next; fast = fast.next.next; if (slow === fast) return true; } return false; }; 对比分析 方法 时间复杂度 空间复杂度 适用场景 哈希表检测法 O(n) O(n) 需要记录访问路径的场景 快慢指针法 O(n) O(1) 空间敏感型场景 ","date":"2025-03-21T23:56:52+08:00","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/0321-%E9%93%BE%E8%A1%A8%E5%BE%AA%E7%8E%AF-%E5%93%88%E5%B8%8C%E6%96%B9%E6%B3%95/","title":"链表循环--哈希方法"},{"content":"Webpack 初步接触 一、什么是 Webpack？ Webpack 是一个 静态模块打包工具，用于将多个模块、文件和资源进行打包，以便在浏览器中高效加载和运行。\n核心功能： 模块打包\n整合 JS、CSS、图片等资源，提升代码可维护性和复用性。 依赖管理\n自动解析模块依赖关系，确保正确加载顺序，降低手动管理复杂度。 代码转换与优化\n通过 Loaders 转换代码（如 ES6 → ES5），并优化输出体积。 开发支持\n支持 HMR（热更新）、Source Maps 调试、代码分割等，提升开发效率。 生态系统\n丰富的插件（Plugins）和加载器（Loaders）扩展功能。 二、Webpack 构建流程 构建过程是串行的，主要分为以下阶段：\n初始化参数\n合并配置文件与命令行参数，生成最终配置。 启动编译\n初始化 Compiler 对象，加载插件并调用 run 方法。 确定入口\n根据 entry 配置定位所有入口文件。 编译模块\n递归处理模块依赖： 调用 Loaders 转换文件内容（如编译 Sass → CSS） 解析模块依赖关系，直至所有模块处理完成。 输出资源\n将模块组合为 Chunk，生成最终文件（如 JS/CSS 文件）。 写入文件系统\n根据 output 配置输出文件到指定路径。 插件机制 Webpack 在构建生命周期广播事件（如 compile、emit），插件通过监听事件调用 API 修改输出结果。\n三、常见 Loaders Loader 功能描述 raw-loader 导入文件原始内容（如文本文件）。 file-loader 将文件输出到指定目录，返回 URL 路径。 url-loader 小于阈值的文件转为 Base64，否则调用 file-loader。 babel-loader 转换 ES6+ 代码为向后兼容的 JavaScript。 ts-loader 编译 TypeScript 为 JavaScript。 sass-loader 编译 Sass/SCSS 为 CSS。 css-loader 解析 CSS 文件中的 @import 和 url()。 style-loader 将 CSS 注入到 DOM 的 \u0026lt;style\u0026gt; 标签中。 eslint-loader 静态检查 JavaScript 代码规范。 vue-loader 处理 Vue 单文件组件（.vue）。 四、常见 Plugins Plugin 功能描述 DefinePlugin 定义全局常量（如环境变量）。 HtmlWebpackPlugin 自动生成 HTML 文件并注入资源。 MiniCssExtractPlugin 将 CSS 提取为独立文件。 UglifyJsPlugin 压缩 JavaScript 代码。 CleanWebpackPlugin 构建前清理输出目录。 WebpackBundleAnalyzer 可视化分析打包体积。 SpeedMeasurePlugin 统计各 Loader/Plugin 的构建耗时。 五、Loader 与 Plugin 的区别 特性 Loader Plugin 核心功能 转换特定类型文件（如编译 Sass）。 扩展 Webpack 功能（如代码压缩、资源注入）。 使用场景 处理单个文件的转换逻辑。 监听构建事件，执行全局操作。 配置方式 在 module.rules 中定义，按文件类型匹配。 在 plugins 数组中实例化，可传入参数。 实现原理 基于函数链式调用处理文件内容。 基于 Tapable 事件流，在生命周期钩子中干预构建。 ","date":"2025-03-21T11:49:00+08:00","image":"https://linsk27.github.io/p/0321-webpack%E5%88%9D%E6%AD%A5%E6%8E%A5%E8%A7%A6/webpack_hu_8086468ee5554a2a.png","permalink":"https://linsk27.github.io/p/0321-webpack%E5%88%9D%E6%AD%A5%E6%8E%A5%E8%A7%A6/","title":"Webpack初步接触"},{"content":"\n低于O(n^2)的写法，使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 /** *原理： * 1. 遍历nums *\t2. 通过hash.has()搜索哈希表内的对应值 *\t3. 没有对应值则存入哈希表，继续下一个数据搜索哈希表内对应值 *\t4. 搜索到对应值，终止循环 * 例子：nums:[2,8,11,15,3,7] target:9 */ var twoSum = function (nums, target) { let hash = new Map() for (let i = 0; i \u0026lt; nums.length; i++) { // 首次哈希表为空，搜索不到数据 // 如果哈希表内搜索不到nums[i]对应相加数据为target的答案，则先存入哈希表，让下个循环的nums[i]进行搜索 if (!hash.has(target - nums[i])) hash.set(nums[i],i) // 哈希表搜索到答案则直接返回 else return [hash.get(target-nums[i]), i] } }; let hash = new Map(); 是 JavaScript 中使用 Map 对象创建一个哈希表（Hash Table）的写法。Map 是 ES6 引入的一种数据结构，用于存储键值对（key-value pairs），类似于普通对象（{}），但功能更强大且灵活。\n1. Map 的核心特性 特性 描述 键的类型任意 键可以是任何数据类型（对象、函数、基本类型等），而普通对象的键只能是字符串或 Symbol。 有序性 键值对的插入顺序会被保留，遍历时按插入顺序返回。 可直接获取大小 通过 hash.size 直接获取键值对数量，而普通对象需要 Object.keys(obj).length。 高性能增删查 在频繁增删键值对时，性能通常优于普通对象。 2. 基本用法 初始化与操作 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 创建一个空 Map let hash = new Map(); // 添加键值对：使用 set(key, value) hash.set(\u0026#34;name\u0026#34;, \u0026#34;Alice\u0026#34;); // 键是字符串 hash.set(42, \u0026#34;Answer\u0026#34;); // 键是数字 hash.set({}, \u0026#34;empty object\u0026#34;); // 键是对象 // 获取值：使用 get(key) console.log(hash.get(\u0026#34;name\u0026#34;)); // 输出 \u0026#34;Alice\u0026#34; // 检查键是否存在：使用 has(key) console.log(hash.has(42)); // true // 删除键值对：使用 delete(key) hash.delete(42); // 清空所有键值对：使用 clear() hash.clear(); 遍历 Map 1 2 3 4 5 6 7 8 9 10 11 // 使用 for...of 遍历键值对 for (const [key, value] of hash) { console.log(key, value); } // 获取所有键：keys() console.log(hash.keys()); // 输出所有键的迭代器 // 获取所有值：values() console.log(hash.values()); // 输出所有值的迭代器 // 获取所有键值对：entries() console.log(hash.entries()); 3. 与普通对象 {} 的区别 场景 普通对象 {} Map 键的类型 仅支持字符串或 Symbol 支持任意类型（包括对象、函数等） 顺序保证 ES6 后保持插入顺序（但不绝对可靠） 严格按插入顺序遍历 大小获取 需手动计算 Object.keys(obj).length 直接通过 map.size 性能 频繁增删时性能较差 频繁增删时性能更好 继承原型链属性 可能意外继承原型链上的属性 不会继承原型链属性 4. 使用场景 需要键为复杂类型（如对象、函数）时：\n1 2 const funcKey = () =\u0026gt; {}; hash.set(funcKey, \u0026#34;Function as key\u0026#34;); 需要保持插入顺序：\n1 2 3 4 const orderedMap = new Map(); orderedMap.set(\u0026#34;second\u0026#34;, 2); orderedMap.set(\u0026#34;first\u0026#34;, 1); console.log([...orderedMap.keys()]); // [\u0026#34;second\u0026#34;, \u0026#34;first\u0026#34;] 需要高效增删操作（如缓存、临时数据存储）：\n1 2 3 const cache = new Map(); cache.set(\u0026#34;user:123\u0026#34;, { name: \u0026#34;Alice\u0026#34; }); cache.delete(\u0026#34;user:123\u0026#34;); // 快速删除 ","date":"2025-03-16T23:10:13+08:00","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/%E4%B8%A4%E6%95%B0%E4%B9%8B%E5%92%8C--%E5%93%88%E5%B8%8C%E6%96%B9%E6%B3%95/","title":"两数之和--哈希方法"},{"content":"SEO本质 解决两个匹配问题：\n内容匹配：让网站内容精准对应搜索者的真实需求（比如用户搜「如何修复冰箱不制冷」，你的网页必须提供具体解决方案，而非只介绍冰箱品牌） 技术匹配：让搜索引擎能快速理解并推荐你的内容（比如用HTML标签明确标注文章主题、确保手机端加载速度低于2秒） 优化SEO的底层逻辑 不优化的结果 优化后的效果 对应技术手段 用户搜不到你（关键词缺失） 目标关键词进入前3页 关键词研究工具（如Google Keyword Planner） 搜索引擎误判内容（技术缺陷） 爬虫高效抓取内容 优化robots.txt文件、XML网站地图 用户点开就离开（体验差） 停留时间提升50%+ 内容分段落+配图、添加目录导航 被算法判定为低质内容 获得「优质内容」权重加分 增加专业参考文献、作者资质说明 外链数量为0 获得行业权威网站推荐 制作可被引用的研究报告/工具 必须优化SEO的数学证明 设某关键词月搜索量1000次：\n第1名点击率 ≈ 35% → 350次访问/月 第6名点击率 ≈ 4.5% → 45次访问/月 排名提升5位，流量增长678% 流量价值计算（假设转化率2%，客单价$100）：\n第1名月收益：350×2%×$100 = $700 第6名月收益：45×2%×$100 = $90 SEO带来的边际收益差：$610/月 现代SEO的三个死亡红线 关键词堆砌（密度\u0026gt;2%即可能被惩罚） 忽视用户实际停留时间（Google已用屏幕停留时长替代跳出率作为核心指标） 忽略结构化数据（没有Schema标记的网页，点击率平均低37%） ","date":"2025-03-15T13:29:13+08:00","image":"https://linsk27.github.io/p/0315-%E4%BB%80%E4%B9%88%E6%98%AFseo/SEO_hu_9b3966ef617d4c47.jpeg","permalink":"https://linsk27.github.io/p/0315-%E4%BB%80%E4%B9%88%E6%98%AFseo/","title":"什么是SEO？"},{"content":"\u0026lt;link rel=\u0026quot;canonical\u0026quot; href=\u0026quot;xxxx/t\u0026quot;\u0026gt;\n主要目的：解决 重复内容（Duplicate Content） 问题，并明确告知搜索引擎哪个是页面的“规范版本”（Canonical URL）。\n1. 什么是Canonical标签？ 作用：当同一内容（页面）可通过多个不同URL访问时（例如带参数的URL、分页、移动端/PC端不同URL等），搜索引擎可能认为这些是重复内容，导致权重分散。通过 rel=\u0026quot;canonical\u0026quot; 标签，可以指定一个 规范URL，告诉搜索引擎“这个URL是主要版本，请优先收录和排名”。\n语法：\n1 \u0026lt;link rel=\u0026#34;canonical\u0026#34; href=\u0026#34;https://example.com/规范URL\u0026#34; /\u0026gt; 2. 为什么需要添加Canonical标签？ 场景举例 URL参数导致的重复内容\n例如：\nhttps://example.com/product?id=1（排序参数）\nhttps://example.com/product?id=1\u0026amp;sort=price 搜索引擎可能认为这是两个不同页面，但实际内容相同。通过设置规范URL为 https://example.com/product?id=1，可集中权重。 分页内容\n例如：\nhttps://example.com/blog?page=1\nhttps://example.com/blog?page=2 可以在分页页面中设置规范URL指向主页面（如 https://example.com/blog），或为每个分页指定各自的规范URL。 移动端与桌面端不同URL\n例如：\nhttps://m.example.com/page（移动端）\nhttps://www.example.com/page（桌面端） 可以在移动端页面添加规范标签指向桌面端URL，或反之（根据SEO策略决定）。 3. 如何正确使用Canonical标签？ 规范URL必须是可访问的：确保 href 中的URL是真实存在的、200状态码的页面。 绝对URL优先：建议使用完整URL（如 https://example.com/t），而非相对路径（/t）。 避免链轮问题：多个页面不能互相指向对方作为规范URL（例如A→B，B→A），会导致搜索引擎无法识别。 分页处理： 如果分页内容独立，每个分页应指定自己的规范URL。 如果希望集中权重到主页面，所有分页的规范URL指向主页面。 4. 与301重定向的区别 301重定向：将用户和搜索引擎永久跳转到新URL，旧URL不再存在。 适用场景：页面迁移、URL结构变更。 Canonical标签：仅提示搜索引擎规范URL，用户仍可访问原URL。 适用场景：内容相同或高度相似的多个URL需要共存（如参数化URL）。 5. 常见错误 指向非规范域名：例如将 https://example.com/t 的规范URL设为 https://otherdomain.com/t，可能导致权重流失。 忽略动态参数：若规范URL带参数，需确保参数不影响内容（如 ?utm_source=... 可保留，但排序参数需排除）。 在非HTML页面使用：如PDF、图片等非HTML文件中添加无效。 6. 验证是否生效 使用 Google Search Console 的“URL检查”工具，查看Google是否识别了规范URL。\n通过浏览器开发者工具查看页面源代码，确认标签正确添加。\n点击Ctrl+U\n7. Nuxt3举例实现方法 优化前 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 步骤1:获取本地地址或.env路径(可用其他方法实现) const requestURL = useRequestURL().href??\u0026#39;\u0026#39; // 动态设置 SEO Meta 标签 useHead({ title: fit[0].seo_setting.title??\u0026#34;\u0026#34;, // 标题 meta: [ { name: \u0026#39;description\u0026#39;, content: fit[0].seo_setting.desc??\u0026#34;\u0026#34; }, // 描述 { name: \u0026#39;keywords\u0026#39;, content: fit[0].seo_setting.keyword??\u0026#39;\u0026#39; }, // 关键词 // 社交媒体优化（OpenGraph） { property: \u0026#39;og:title\u0026#39;, content: fit[0].seo_setting.title??\u0026#39;\u0026#39; }, { property: \u0026#39;og:description\u0026#39;, content: fit[0].seo_setting.desc??\u0026#39;\u0026#39; } ], link:[ {rel:\u0026#39;canonical\u0026#39;,href:requestURL} ] }); 潜在问题 useRequestURL().href的使用风险： 在 SSR（服务器端渲染） 时可能无法正确获取完整 URL（例如在静态导出或反向代理场景下）。如果 href 为空字符串，生成的 canonical 标签会变成 \u0026lt;link rel=\u0026quot;canonical\u0026quot; href=\u0026quot;\u0026quot;\u0026gt;，这会被搜索引擎视为无效或错误的规范 URL，导致 SEO 权重流失\n动态数据为空的兜底逻辑 fit[0].seo_setting.title??\u0026quot;\u0026quot; 依赖外部数据（如 API 响应），如果 fit 数组为空或字段缺失，会导致 title、description 等关键标签为空字符串，搜索引擎会认为页面信息不完整，影响排名。\n代码健壮性改进建议 确保 fit[0].seo_setting 存在且字段完整，避免渲染时崩溃.\n优化后 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 获取本地地址 const requestURL = process.server ? useRequestURL().href // SSR 环境：直接获取完整 URL : window.location.href; // 客户端：使用浏览器 URL // 动态设置 SEO Meta 标签 useHead({ title: fit[0].seo_setting.title??\u0026#34;默认标题\u0026#34;, // 标题 meta: [ { name: \u0026#39;description\u0026#39;, content: fit[0].seo_setting.desc??\u0026#34;默认描述\u0026#34; }, // 描述 { name: \u0026#39;keywords\u0026#39;, content: fit[0].seo_setting.keyword??\u0026#39;默认关键词\u0026#39; }, // 关键词 // 社交媒体优化（OpenGraph） { property: \u0026#39;og:title\u0026#39;, content: fit[0].seo_setting.title??\u0026#39;默认描述\u0026#39; }, { property: \u0026#39;og:description\u0026#39;, content: fit[0].seo_setting.desc??\u0026#39;默认描述\u0026#39; } ], link:[ {rel:\u0026#39;canonical\u0026#39;,href:requestURL} ] }); 代码整体对 SEO 的价值 核心作用 ：\n通过动态设置元标签和规范链接，解决 、关键词匹配 、权重集中 、跨平台展示三大 SEO 核心问题。\n直接收益 ：\n提升搜索排名（通过标题/描述优化）、减少重复内容惩罚（通过 canonical)、增强社交传播效果（通过 OpenGraph社交媒体优化）。\n","date":"2025-03-14T13:29:13+08:00","image":"https://linsk27.github.io/p/seo%E4%BC%98%E5%8C%96%E6%89%8B%E6%AE%B5---canonical/SEO_hu_9b3966ef617d4c47.jpeg","permalink":"https://linsk27.github.io/p/seo%E4%BC%98%E5%8C%96%E6%89%8B%E6%AE%B5---canonical/","title":"SEO优化手段---Canonical"},{"content":"el-table中sort的常见方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const sort = async (newIndex: number, oldIndex: number) =\u0026gt; { if (newIndex === oldIndex) return; const newData = [...tableData.value.rows]; const [movedItem] = newData.splice(oldIndex, 1); newData.splice(newIndex, 0, movedItem); newData.forEach((item, index) =\u0026gt; { item.index = index; }); // Vue3 响应式更新方式 tableData.value = { ...tableData.value, rows: [...newData] // 强制替换整个数组触发更新 }; sortData.value.tag_groups = newData.map(item =\u0026gt; ({ tag_group_id: item.tag_group_id, index: item.index })); isData .value += 1; // 每次拖拽后更新 key } ","date":"2025-03-08T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/el-table%E4%B8%ADsort%E6%8B%96%E6%8B%BD%E6%8E%92%E5%BA%8F%E7%9A%84%E5%B8%B8%E8%A7%81%E6%96%B9%E6%B3%95/","title":"el-table中sort(拖拽排序)的常见方法"},{"content":"模板部分 1 2 3 4 5 6 7 8 9 10 11 \u0026lt;template\u0026gt; \u0026lt;!--断层--\u0026gt; \u0026lt;div class=\u0026#34;tooltip-box\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;tooltip-arrow\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; 点击复制链接 \u0026lt;/div\u0026gt; \u0026lt;!--伪类写法--\u0026gt; \u0026lt;div class=\u0026#34;copy-tooltip-box\u0026#34;\u0026gt; 点击复制链接 \u0026lt;/div\u0026gt; \u0026lt;/template\u0026gt; Css处理部分 断层写法例子 1 2 3 4 5 6 7 8 9 10 11 .tooltip-arrow { position: absolute; /* 👉 基于父容器（.tooltip-box）定位 */ bottom: -10px; /* 👉 位于提示框下方 10px 处（负值向上移动） */ left: 50%; /* 👉 水平居中 */ transform: translateX(-50%); /* 👉 微调水平位置 */ width: 0; /* 👉 宽度为 0，通过边框生成三角形 */ height: 0; /* 👉 高度为 0 */ border-left: 8px solid transparent; /* 👉 左侧透明边框 */ border-right: 8px solid transparent; /* 👉 右侧透明边框 */ border-top: 10px solid white; /* 👉 顶部实色边框，形成箭头 */ } 使用伪类防止断层写法 推荐方法，防止黑色透明箭头和盒子交接处出现断层深色线条 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 // 正常的样例盒子 .copy-tooltip-box { bottom: calc(100% + 14px); position: absolute; left: 50%; transform: translateX(-50%); background-color: #323233D9; /* 黑色主题 */ width: 88px; height: 28px; color: #fff; } // 伪类实现方法 .copy-tooltip-box:after { content: \u0026#34;\u0026#34;; position: absolute; top: 100%; /* 箭头位于提示框底部 */ left: 50%; transform: translateX(-50%); width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; border-top: 10px solid #323233D9; } ","date":"2025-03-07T00:00:00Z","image":"https://linsk27.github.io/p/%E6%82%AC%E6%B5%AE%E5%87%BA%E7%8E%B0%E7%AE%AD%E5%A4%B4%E7%9B%92%E5%AD%90%E7%9A%84%E5%86%99%E6%B3%95/hover_hu_d506caf463524705.png","permalink":"https://linsk27.github.io/p/%E6%82%AC%E6%B5%AE%E5%87%BA%E7%8E%B0%E7%AE%AD%E5%A4%B4%E7%9B%92%E5%AD%90%E7%9A%84%E5%86%99%E6%B3%95/","title":"悬浮出现箭头盒子的写法"},{"content":"推送失败，gitee仓库缺少公钥 1 git push origin lsk_dev 1 2 3 4 5 6 7 8 9 10 11 The authenticity of host \u0026#39;gitee.com (180.76.198.77)\u0026#39; can\u0026#39;t be established. ED25519 key fingerprint is SHA256:+ULzij2u99B9eWYFTw1Q4ErYG/aepHLbu96PAUCoV88. This key is not known by any other names. Are you sure you want to continue connecting (yes/no/[fingerprint])? y Please type \u0026#39;yes\u0026#39;, \u0026#39;no\u0026#39; or the fingerprint: yes Warning: Permanently added \u0026#39;gitee.com\u0026#39; (ED25519) to the list of known hosts. git@gitee.com: Permission denied (publickey). fatal: Could not read from remote repository. Please make sure you have the correct access rights and the repository exists. 检查与自己gitee仓库连接失败，没有显示hellow 1 ssh -T git@gitee.com 1 git@gitee.com: Permission denied (publickey). 进行公钥配置 1 ssh-keygen -t rsa -C \u0026#34;14062626+linsk27@user.noreply.gitee.com\u0026#34; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Generating public/private rsa key pair. Enter file in which to save the key (C:\\Users\\lsk69/.ssh/id_rsa): Enter passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved in C:\\Users\\lsk69/.ssh/id_rsa Your public key has been saved in C:\\Users\\lsk69/.ssh/id_rsa.pub The key fingerprint is: SHA256:SOV+Ur+E321Pw+9NL77mSgZm6V8lc+W3LJi3rN5qXG8 14062626+linsk27@user.noreply.gitee.com The key\u0026#39;s randomart image is: +---[RSA 3072]----+ | . | | o | | . . . .| | . o . + ..| | . S B o o =| | * +o+o*o| | oo*o+==| | *+.=E*| | o+=O=+*| +----[SHA256]-----+ 获取公钥失败 1 ssh-agent -s 1 2 3 4 5 6 7 8 unable to start ssh-agent service, error :1058 \u0026gt; Fix Error **unable to start ssh-agent service, error: 1058(xxxx)** \u0026gt; \u0026gt; 1. win + R, Go To `services.msc` \u0026gt; \u0026gt; 2. Find And Check Is `OpenSSH Authentication Agent` Service Running \u0026gt; 再次运行公钥获取成功，连接成功 1 2 3 ssh-agent -s ssh -T git@gitee.com 1 Hi linsk27(@linsk27)! You\u0026#39;ve successfully authenticated, but GITEE.COM does not provide shell access. 复制公钥数据到gitee仓库的公钥（设置） 把ssh密钥添加到码云\n打开C盘–\u0026gt;用户–\u0026gt;你的用户名–\u0026gt;找到.ssh文件夹。找到id_rsa.pub（日过有多个用最新的那个），用记事本打开，复制整个文本粘贴到gitee（点头像，进入gitee设置面板，SSH设置，将复制的文本粘贴到公钥，标题会自动生成，然后点击添加，根据提示输入密码就可以了。）\n重新推送成功 1 git push origin lsk_dev 1 2 3 4 5 6 7 8 9 10 11 12 Enumerating objects: 16, done. Counting objects: 100% (16/16), done. Delta compression using up to 16 threads Compressing objects: 100% (15/15), done. Writing objects: 100% (16/16), 2.19 KiB | 2.19 MiB/s, done. Total 16 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0) remote: Powered by GITEE.COM [1.1.5] remote: Set trace flag 824c4db5 remote: Create a pull request for \u0026#39;lsk_dev\u0026#39; on Gitee by visiting: remote: https://gitee.com/larrry/competition-system/pull/new/larrry:lsk_dev...larrry:main To gitee.com:larrry/competition-system.git * [new branch] lsk_dev -\u0026gt; lsk_dev ","date":"2024-11-20T00:00:00Z","image":"https://linsk27.github.io/p/gitee%E4%BB%93%E5%BA%93%E7%9A%84%E7%AC%AC%E4%B8%80%E6%AC%A1%E6%8E%A8%E9%80%81%E9%81%87%E8%A7%81%E7%9A%84%E9%94%99%E8%AF%AF/error.svg","permalink":"https://linsk27.github.io/p/gitee%E4%BB%93%E5%BA%93%E7%9A%84%E7%AC%AC%E4%B8%80%E6%AC%A1%E6%8E%A8%E9%80%81%E9%81%87%E8%A7%81%E7%9A%84%E9%94%99%E8%AF%AF/","title":"gitee仓库的第一次推送遇见的错误"},{"content":"刚开始写 Vue3 时，这三个 API 很容易混着用。它们都和响应式数据有关，但处理的问题其实不同：computed 用来推导数据，watch 用来处理明确的变化，watchEffect 适合自动收集依赖的副作用。\n先记住这个选择方式 页面上需要一个由已有数据计算出来的新值，用 computed。 某个值变化后需要请求接口、保存草稿或同步第三方状态，用 watch。 一开始不确定依赖有哪些，并且希望代码立即执行一次，用 watchEffect。 不要为了“监听数据”而一律使用 watch。如果只是把数量和单价相乘得到金额，直接计算更清晰。\n用 computed 推导页面数据 下面这个小计只依赖数量和单价。它没有请求接口，也没有修改外部状态，因此最适合用 computed。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;script setup lang=\u0026#34;ts\u0026#34;\u0026gt; import { computed, ref } from \u0026#39;vue\u0026#39; const count = ref(2) const unitPrice = ref(39.9) const totalPrice = computed(() =\u0026gt; { return Number((count.value * unitPrice.value).toFixed(2)) }) \u0026lt;/script\u0026gt; \u0026lt;template\u0026gt; \u0026lt;p\u0026gt;商品数量：{{ count }}\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;订单小计：{{ totalPrice }} 元\u0026lt;/p\u0026gt; \u0026lt;/template\u0026gt; computed 会缓存结果。只有 count 或 unitPrice 改变时，totalPrice 才会重新计算。模板里可以直接使用 totalPrice，不需要 .value。\n用 watch 处理可控的副作用 搜索关键词变化后请求列表，是典型的 watch 场景。它的好处是监听来源明确，也能在新请求开始前清理旧请求。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import { ref, watch } from \u0026#39;vue\u0026#39; const keyword = ref(\u0026#39;\u0026#39;) const list = ref\u0026lt;string[]\u0026gt;([]) watch(keyword, async (value, _oldValue, onCleanup) =\u0026gt; { const controller = new AbortController() onCleanup(() =\u0026gt; controller.abort()) try { const response = await fetch( `/api/users?keyword=${encodeURIComponent(value)}`, { signal: controller.signal }, ) if (!response.ok) return list.value = await response.json() } catch (error) { if ((error as Error).name !== \u0026#39;AbortError\u0026#39;) throw error } }) 用户连续输入时，前一次请求可能比后一次更晚返回。上面的 AbortController 会在关键词再次变化时取消旧请求，避免旧结果覆盖新结果。\n如果页面打开时也要先加载一次，可以传入第三个配置参数：\n1 watch(keyword, loadUsers, { immediate: true }) 用 watchEffect 快速同步依赖 watchEffect 会立即运行，并在执行过程中自动收集它读到的响应式数据。比如把编辑状态保存到本地草稿：\n1 2 3 4 5 6 7 8 9 10 import { reactive, watchEffect } from \u0026#39;vue\u0026#39; const draft = reactive({ title: \u0026#39;\u0026#39;, content: \u0026#39;\u0026#39;, }) watchEffect(() =\u0026gt; { localStorage.setItem(\u0026#39;article-draft\u0026#39;, JSON.stringify(draft)) }) 这里不需要手动写 [draft.title, draft.content]。不过依赖来源越隐蔽，后期越难排查，因此涉及请求、路由跳转或复杂业务时，我更倾向于用 watch 写清监听对象。\n常见误区 computed 不适合写异步请求。它应该保持纯粹，只返回由现有状态推导出的结果。\nwatchEffect 不适合替代所有 watch。它会自动追踪执行期间访问过的响应式数据，代码稍复杂时可能会触发比预期更多的更新。\n监听对象时也要注意层级。watch(user, callback) 监听 reactive 对象会触发深层变化；如果只关心用户名，就写成 watch(() =\u0026gt; user.name, callback)，范围更准确。\n小结 把三者分开后，代码会更好读：页面展示值交给 computed，明确的业务变化交给 watch，简单的自动同步交给 watchEffect。这也是我在 Vue3 项目里判断响应式逻辑该放在哪里的第一步。\n","date":"2024-11-16T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/vue3-%E4%B8%AD-watchwatcheffectcomputed-%E6%80%8E%E4%B9%88%E9%80%89/","title":"Vue3 中 watch、watchEffect、computed 怎么选"},{"content":"工具合集 sunshine是一个提供给moonlight进行串流的工具\nmoonlight是一个可以方便的将Windows电脑画面传输到各主流操作系统的客户端软件上的工具\n虚拟显示器可以实现屏幕扩展的工具。在这里可以与串流实现配合。\n链接：https://pan.baidu.com/s/1_zhprVgFAOsemp3jEvslsA 提取码：lrx1\n实现屏幕复制 安装与配置sunshine 点击sunshine的.exe文件进行运行安装（安装任何东西尽量不安装到系统盘）,然后右击电脑屏幕右下角的sunshine图标，点击open sunshine进入sunshine的管理界面，具体界面类似点2的图。\n先点击齿轮图形（设置），把页面设置为中文简体方便操作，记得点击保存，然后点击apply（应用），此时会自动重启sunshine程序，如果没有启动则右键右下角的sunshine图标，点击Restart。\n同第2点开设置后，点击network板块，根据图片设置UPnP为“启用”，设置ip地址族为“IPv4+IPv6”，这个对后面串流有用。最后把公网加密模式设置为“禁用”，点击保存并且应用，重启sunshine步骤。 安装与配置moonlight 该演示为安卓端，pc端大同小异，同样可看下面教程\n在上面给的网盘内直接安装或者谷歌商店安装moonlight,进入moonlight界面 点击齿轮图形（设置），可以根据自己设备的分辨率进行设置，其中码率对远程串流会有较大影响，个人认为局域网连接可以直接拉满 设备通常打开moonlight后，在相同局域网下，设备会显示同个局域网下的电脑设备，如第一点的图所示。此时可以平板直接点击电脑设备进行连接，然后会出现ping码，通常电脑会出现ping码弹窗，没有的话打开sunshine控制台，点击ping选项进行输入。通常第一次连接之后，只要平板不删除电脑设备，就不会再要求输入ping码。 如果没有显示电脑设备，则可在电脑按下Win+R,输入cmd打开终端，然后输入ipconfig来获取局域网的ipv4或ipv6的地址（我这个网络没有ipv6地址）。然后选择字段为IPv4地址或IPv6 地址的字样才有效。ipv6对远程串流帮助较大。\n然后点击平板端的moonlight，点击加号图标，直接输入ipv4或者ipv6地址，即可显示电脑设备。\n这个时候你的平板设备应该就成功复制了电脑屏幕，并且几乎只有100ms的延迟！ 实现屏幕扩展 配置与安装ParsecVDisplay（虚拟显示器） 在网盘内或者自行选择下载虚拟显示器软件，打开后如图。 点击ADD DISPLAY,添加虚拟显示器，随后点击显示器查看属性。但是通常会有bug，比如显示\\\\.\\DISPLAY1,说明添加与原屏幕冲突，需要再添加一次，然后可以点击这个1的屏幕然后进行删除。 有正确的显示器之后，点击显示器查看显示器参数，如图为\\\\.\\DISPLAY11。同时，对该虚拟显示器的刷新率和帧率参数进行配置。最后点击CUSTOM按键，输入这些参数。 最后打开sunshine控制台，点击齿轮设置，打开video选项，输入显示器的名称\\\\.\\DISPLAY11，点击保存并且应用，然后平板进行moonlight重连，这个时候，就会扩展成功！ 打开Win+I打开电脑设置，进入“显示”模块，对平板显示器进行位置调动，方便扩展。 注意！！！不断连不上可查看此处 当时搞完这个扩展显示器，后面我出现很多次连不上的现象\n失败原因： sunshine设置了video的显示器名称后，就只会抓取该名称的显示器进行显示，如果没有生成该虚拟显示器，则其他设备的moonlight就无法链接到电脑设备\n解决方法： 方式1：把sunshine的video选项里的video清空，这样就会默认复制屏幕。\n方式2：打开虚拟显示器并且创建，保持sunshine的video设置和虚拟显示器的名称设置相同即可！！\n实现远程串流 ","date":"2024-11-02T00:00:00Z","image":"https://linsk27.github.io/p/moonlight-sunshine%E5%AE%9E%E7%8E%B0%E6%89%A9%E5%B1%95%E5%B1%8F%E5%92%8C%E8%BF%9C%E7%A8%8B%E4%B8%B2%E6%B5%81/moonlight_hu_42561a355a539026.jpg","permalink":"https://linsk27.github.io/p/moonlight-sunshine%E5%AE%9E%E7%8E%B0%E6%89%A9%E5%B1%95%E5%B1%8F%E5%92%8C%E8%BF%9C%E7%A8%8B%E4%B8%B2%E6%B5%81/","title":"moonlight+sunshine实现扩展屏和远程串流"},{"content":"什么是防抖（Debounce）？ 防抖 （多次触发 只执行最后一次） 原理：在高频率触发某个响应事件时，在规定时间间隔内如果没有再次触发，则属于最后一次完成交互，触发相对应的方法\n常用：在用户输入搜索词的过程中，每一次输入都会触发 input 事件，但显然没必要每次都发送请求，只有当用户停止输入后，系统才发送一次最终的请求\n手搓代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 1.封装防抖函数 function debounce(fn, time) { // 4.创建一个标记用来存放定时器的返回值 let timeout = null; return function () { // 5.每当用户触发input事件 把前一个 setTimeout 清楚掉 clearTimeout(timeout); // 6.然后又创建一个新的 setTimeout, 这样就能保证输入字符后等待的间隔内 还有字符输入的话，就不会执行 setTimeout里面的内容 timeout = setTimeout(() =\u0026gt; { // 7.这里进行防抖的内容 fn(); }, time); }; } // 2.获取inpt元素 var inp = document.getElementById(\u0026#34;inp\u0026#34;); // 8. 测试防抖临时使用的函数 function sayHi() { console.log(\u0026#34;防抖成功\u0026#34;); } // 3.给inp绑定input事件 调用封装的防抖函数 传入要执行的内容与间隔事件 inp.addEventListener(\u0026#34;input\u0026#34;, debounce(sayHi, 5000)); 什么是节流（Throttle）？ 节流 （时间间隔内 只允许执行一次） 原理：在高频率触发某个有响应方法的事件时，第一次触发响应事件，计时器开始计时，规定时间内继续交互不触发事件\n常用：鼠标滚轮滑动触发下拉刷新、王者的英雄技能刷新\n手搓代码：\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 // 1.封装节流函数 function throttle(fn, time) { //3. 通过闭包保存一个 \u0026#34;节流阀\u0026#34; 默认为false let temp = false; return function () { //8.触发事件被调用 判断\u0026#34;节流阀\u0026#34; 是否为true 如果为true就直接trurn出去不做任何操作 if (temp) { return; } else { //4. 如果节流阀为false 立即将节流阀设置为true temp = true; //节流阀设置为true //5. 开启定时器 setTimeout(() =\u0026gt; { //6. 将外部传入的函数的执行放在setTimeout中 fn.apply(this, arguments); //7. 最后在setTimeout执行完毕后再把标记\u0026#39;节流阀\u0026#39;为false(关键) 表示可以执行下一次循环了。当定时器没有执行的时候标记永远是true，在开头被return掉 temp = false; }, time); } }; } function sayHi(e) { // 打印当前 document 的宽高 console.log(e.target.innerWidth, e.target.innerHeight); } // 2.绑定事件，绑定时就调用节流函数 // 敲黑板！！！ 这里是重点 绑定是就要调用一下封装的节流函数 触发事件是触发封装函数内部的函数 window.addEventListener(\u0026#34;resize\u0026#34;, throttle(sayHi, 2000)); 高效方式实现防抖和节流 使用 Loadash 库实现防抖和节流 lodash 是一个留下的 JavaScript 实用工具库，提供了高效的防抖和节流函数 使用步骤 安装 lodash 1 npm install lodash 使用防抖和节流 1 2 3 4 5 6 7 8 9 10 import debounce from \u0026#34;lodash/debounce\u0026#34;; import throttle from \u0026#34;lodash/throttle\u0026#34;; // 防抖示例 const debouncedSearch = debounce(handleSearch, 300); searchInput.addEventListener(\u0026#34;input\u0026#34;, debouncedSearch); // 节流示例 const throttledScroll = throttle(handleScroll, 200); window.addEventListener(\u0026#34;scroll\u0026#34;, throttledScroll); 设置合适的时间间隔 防抖和节流的核心在于事件频率控制，因此根据实际场景合理设置时间参数非常关键。过长的延迟可能导致响应不及时，而过短的间隔可能会影响性能提升效果。 通常，防抖的延迟在200ms到500ms之间，节流的时间间隔在100ms到300ms之间。\n我写过的 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import _ from \u0026#39;lodash\u0026#39; const loading=ref() // 防抖搜索（核心逻辑） const handleSearch = _.debounce(async (query) =\u0026gt; { if (!query) return loading.value = true try { let res; // 接口调用 res = await props.api({ name: query}) } finally { loading.value = false } }, 300) ","date":"2024-10-26T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/%E4%BB%80%E4%B9%88%E6%98%AF%E9%98%B2%E6%8A%96%E5%92%8C%E8%8A%82%E6%B5%81%E4%BB%80%E4%B9%88%E6%97%B6%E5%80%99%E5%8E%BB%E7%94%A8/","title":"什么是防抖和节流？什么时候去用？"},{"content":"📌 程序员成长的底层逻辑 大学自学常见误区：过度依赖视频教程 → 知识碎片化 + 学习低效 + 上水课坐牢!!!!\n一、计算机基础筑基 1.1 网络协议 📖《图解 TCP/IP》- TCP/IP协议族可视化解析 📖《图解 HTTP》- HTTP协议运作机制图解 🔗 MDN HTTP 文档（权威技术参考——官方文档） 1.2 算法与数据结构 📖《算法图解》- 算法思维可视化入门 🏆 LeetCode 新手村（每日一题保持手感） 1.3 数据库基础 🐬 MySQL 极简教程（CRUD快速上手——老胡的周刊） 🎯 SQLZoo 实战（在线SQL练习平台） 1.4 Linux 入门 🐧 中科大 Linux 101（中文实验室手册） 💻 Linux 命令速查表（PDF可打印版） 二、前端学习黄金路径 2.1 核心三件套 技术栈 学习重点 推荐资源 HTML5 语义化标签 + 表单验证 MDN HTML 权威指南 CSS3 Flex/Grid布局 + 动画 CSS Tricks 终极指南 ES6+ 异步编程 + DOM操作 现代 JavaScript 教程 ECMAScript 6 入门 2.2 高效学习法 盒子模型思维：所有元素皆盒子（margin/padding/border） 渐进式实战：每日1个小项目 → 每周1个综合案例 调试技巧：Chrome DevTools 断点调试 + 移动端模拟 2.3 精品资源库 🎮 交互学习： ▶ Flexbox 青蛙游戏（弹性布局通关） ▶ Grid 花园（网格布局实战）\n📚 文档体系： ▶ ECMAScript 6 入门（阮一峰经典） ▶ 前端九部知识体系（中文渐进教程）\n🚀 项目实战： ▶ 50 Projects in 50 Days（项目思维训练） ▶ Vue.js 挑战（框架预热）\n在线编辑器:https://plnkr.co/edit\n三、开发者效率工具箱 3.1 必备神器 🖋️ Markdown：语法五分钟精通 💻 VS Code： 1 2 3 4 5 6 7 8 // 推荐插件配置 { \u0026#34;必备插件\u0026#34;: [ \u0026#34;Prettier - 代码格式化\u0026#34;, \u0026#34;Live Server - 实时预览\u0026#34;, \u0026#34;GitLens - 代码历史追溯\u0026#34; ] } 3.2 Git 版本控制 🎯 核心工作流： git clone [url] 克隆仓库 git checkout -b feature 创建分支 git push origin feature 推送更改 📚 Git 飞行规则（故障应急手册） 四、避坑指南（Bonus） 盲目追求框架（先扎实原生JS） 重复造轮子（学会阅读源码） 忽视代码规范（ESLint + Prettier） 立即开启你的高效学习之旅吧！\n","date":"2024-10-20T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/%E5%85%A5%E9%97%A8%E5%89%8D%E7%AB%AF%E5%A6%82%E4%BD%95%E5%B0%91%E8%B5%B0%E5%BC%AF%E8%B7%AF/","title":"入门前端？如何少走弯路！！！"},{"content":"修改站点背景颜色 在\u0026quot;assets/scss/variables.scss\u0026quot;中找到 1 --body-background: #f6f6f6; 或在\u0026quot;assets/scss/customs.scss\u0026quot;中找到 1 --body-background: #f6f6f6; 参考自：Hugo | Hugo-stack-theme 主题魔改版\n友链的添加 在\u0026quot;content/page/links\u0026quot;的 index.md 中有详细解释，图标可通过图床或者本地渲染，模仿格式即可，或可直接把文件内容换为 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 --- title: 友情链接 links: - title: 爱吃鱼的小林 - linsk description: 一个大三程序员,博客讲述了hugo中许多美化stack的主题的教程、前端领域知识以及其他 website: https://linsk27.github.io/ image: fish.png menu: main: weight: -50 params: icon: link comments: false --- 显示文章更新时间 手动添加更新时间 hugo 默认位置为 archetypes/default.md 或者主题下目录下 xx 主题/archetypes/posts.md，加lastmod字段，在创建文章模板里添加以下一行，创建时会以主题目录下的模板来创建。 1 lastmod: { { .Date } } 添加lastmod，有个好处就是可自由修改这个字段的时间。 在 \u0026ldquo;config.toml/yaml/json/\u0026rdquo; 中写明调整这里顺序即可 1 frontmatter: lastmod = [\u0026#34;:git\u0026#34;, \u0026#34;lastmod\u0026#34;, \u0026#34;:fileModTime\u0026#34;, \u0026#34;:defalut\u0026#34;] 自动添加更新时间 记得在\u0026quot;config.toml/yaml/json\u0026quot;开启 gitinfo 1 2 #获取git信息 enableGitInfo = true #设为true 这样就提交代码时，就会去读取 git 时间，来更新文章的更新时间! 但是后面我发现了一个问题：每次重新 git push 之后所有文件最近更新时间 mtime 都变成推送的时刻，这是 git 的一个部署配置问题,因此我搜索了许多博客，终于在hugo github action|vecel 部署后文章更新时间异常修复这篇文章找到了解决方法，总的来说：\n在 \u0026ldquo;.github/workflows/xx.yml\u0026rdquo;,新增以下配置，主要是quotePath，默认情况下，文件名包含中文时，git 会使用引号吧文件名括起来，这会导致 action 中无法读取:GitInfo 变量，所以要设置Disable quotePath 1 2 3 4 5 6 - name: Git Configuration run: | git config --global core.quotePath false git config --global core.autocrlf false git config --global core.safecrlf true git config --global core.ignorecase false 使用checkout的话 fetch-depth 需要设为 0，depth默认是为 1，默认只拉取分支最近一次 commit，可能会导致一些文章的 GitInfo 变量无法获取，设为 0 代表拉去所有分支所有提交。 1 2 3 uses: actions/checkout@v4 with: fetch-depth: 0 #设为0 以下是我的最终的 yml 配置文件,大家可以参考\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 name: deploy # 代码提交到main分支时触发github action on: push: branches: - main jobs: deploy: runs-on: ubuntu-latest env: TZ: Asia/Shanghai # 添加正确的时区 steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Git Configuration run: | git config --global core.quotePath false git config --global core.autocrlf false git config --global core.safecrlf true git config --global core.ignorecase false - name: Setup Hugo uses: peaceiris/actions-hugo@v3 with: hugo-version: \u0026#34;latest\u0026#34; extended: true - name: Build Web run: hugo -D - name: Deploy Web uses: peaceiris/actions-gh-pages@v4 with: PERSONAL_TOKEN: ${{ secrets.TOKEN }} EXTERNAL_REPOSITORY: linsk27/linsk27.github.io PUBLISH_BRANCH: main PUBLISH_DIR: ./public commit_message: auto deploy 更新时间显示在卡片 stack 主题的文章更新时间在文章底部 若想在文章开头就显示更新时间，修改 \u0026ldquo;layouts/partials/article/components/detail.html\u0026rdquo;，在指定位置引入以下代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 \u0026lt;div class=\u0026#34;article-details\u0026#34;\u0026gt; ... \u0026lt;footer class=\u0026#34;article-time\u0026#34;\u0026gt; ... \u0026lt;!-- 更新时间 --\u0026gt; {{- if ne .Lastmod .Date -}} \u0026lt;div class=\u0026#34;article-lastmod\u0026#34;\u0026gt; {{ partial \u0026#34;helper/icon\u0026#34; \u0026#34;clock\u0026#34; }} \u0026lt;time\u0026gt; {{ .Lastmod.Format ( or .Site.Params.dateFormat.lastUpdated \u0026#34;Jan 02, 2006 15:04 MST\u0026#34; ) }} \u0026lt;/time\u0026gt; \u0026lt;/div\u0026gt; {{- end -}} .... \u0026lt;/footer\u0026gt; ... \u0026lt;/div\u0026gt; 这样就会文章开头显示修改时间 显示文章字数统计 在\u0026quot;config.toml/yaml/json\u0026quot;的.params.article中，添加 1 wordCount: true #打开字数统计 注意的是默认 WordCount 是不统计中文字数的，你需要在\u0026quot;config.toml/yaml/json\u0026quot;中添加 1 hasCJKLanguage: true 在\u0026quot;layouts/partials/article/components/details.html\u0026quot;中的 \u0026lt;footer class=\u0026quot;article-time\u0026quot;\u0026gt;内，打开本地 hugo 静态调试，按照位置添加 1 2 \u0026lt;i class=\u0026#34;fa fa-regular fa-message\u0026#34; style=\u0026#34;color: #ff8e2d;\u0026#34;\u0026gt;\u0026lt;/i\u0026gt; 本文有 {{ .WordCount }} 个字 效果如图 修改分类标签的颜色 在\u0026quot;content/categories\u0026quot;创建与分类同名的文件夹，并且在文件夹中创建\u0026quot;_index.md\u0026quot;文件并添加下面的内容,文件内会有一个 Test 的例子，可以模仿 Test 文件夹里面的内容 1 2 3 4 5 6 7 8 ---- title: 这里写分类名称 description: 分类简介，不需要可以删了 image: \u0026#34;categories.png\u0026#34; // 分类的题图 style: background: \u0026#34;#80aba9\u0026#34; //分类标签底色 color: \u0026#34;#fff\u0026#34; ---- 背景脚本放置位置 dev\\themes\\hugo-theme-stack\\layouts\\partials\\footer ","date":"2024-10-19T00:00:00Z","image":"https://linsk27.github.io/p/hugo%E4%B8%BB%E9%A2%98%E7%BE%8E%E5%8C%96%E6%95%99%E7%A8%8B/hugo_hu_3919b6bec45f946e.jpg","permalink":"https://linsk27.github.io/p/hugo%E4%B8%BB%E9%A2%98%E7%BE%8E%E5%8C%96%E6%95%99%E7%A8%8B/","title":"【Hugo】主题美化教程"},{"content":" nexus 下载安装 nuxes 下载链接\n链接：https://pan.baidu.com/s/1M-cotx78rZfoPAqLzMquGA 提取码：ogh0 1.点击.exe 文件进行安装后显示 2.任务栏透明（可以都去实现）\n方式一：安装 translucenTtb​ 插件\n方式二：在设置设置任务栏自动隐藏图标\n将桌面的图标都隐藏\n进行 nexus 配置（不做过多的图片解释，可以自己一边调试一边研究，找出自己最爱的风格） 1.点击 nexus 任务栏的 nexus 打开软件\n2.点击位置将停靠设置在底部，并且根据自己的喜好进行偏移参数调整后点击应用\n3.点击“行为”,打开 dock 透明度，将 dock 透明度拉满\n4.点击“效果”，把悬浮之类的效果都可以设置为弹跳，同时设置水波纹效果等\n5. 点击“一般”，勾选隐藏系统任务栏(可勾可不勾)，勾选右下角操作栏消失\n图标的添加 1.右键 nuxes 的自带图标，将没用的图标删除\n2.右键 nexes 的自带 win 图标，点击创建 dock 项目\n3.点击查找，获取对应需要的软件 exe 文件后会自动完善信息，点击确定即可\n4.配置完成\n添加音频条和时钟组件 日后出更新以及教程噢~~ 恭喜你完成本文教程，最终配置效果如封面图，可以通过 wallpaper 找个动态壁纸，制作属于自己好看的桌面！ ","date":"2024-10-15T00:00:00Z","image":"https://linsk27.github.io/p/nexus%E6%A1%8C%E9%9D%A2%E7%BE%8E%E5%8C%96%E6%95%99%E7%A8%8B/nexus6_hu_838df2597758851e.png","permalink":"https://linsk27.github.io/p/nexus%E6%A1%8C%E9%9D%A2%E7%BE%8E%E5%8C%96%E6%95%99%E7%A8%8B/","title":"【Nexus】桌面美化教程"},{"content":"数组去重方法 1. 通过 filter 过滤函数 JavaScript 的 Array.prototype.filter() 方法可以创建一个新数组，其包含通过所提供函数实现的测试的所有元素。利用这个特性，我们可以对数组进行去重。\n1 2 3 4 5 const array = [1, 2, 2, 3, 4, 4, 5]; const uniqueArray = array.filter((item, index, self) =\u0026gt; { return self.indexOf(item) === index; }); console.log(uniqueArray); // 输出: [1, 2, 3, 4, 5] 2. 通过 new Set() 集合进行去重 Set 是 ES6 引入的一种新的数据结构，它类似于数组，但是成员的值都是唯一的，没有重复的值。我们可以利用这个特性进行数组去重。\n1 2 3 const array = [1, 2, 2, 3, 4, 4, 5]; const uniqueArray = [...new Set(array)]; console.log(uniqueArray); // 输出: [1, 2, 3, 4, 5] 3. for 循环遍历判断 虽然 filter 和 Set 是更现代且效率更高的方法，但了解如何使用 for 循环进行去重也很有用。\n1 2 3 4 5 6 7 8 const array = [1, 2, 2, 3, 4, 4, 5]; const uniqueArray = []; for (let i = 0; i \u0026lt; array.length; i++) { if (uniqueArray.indexOf(array[i]) === -1) { uniqueArray.push(array[i]); } } console.log(uniqueArray); // 输出: [1, 2, 3, 4, 5] ","date":"2024-10-14T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/js%E4%B8%AD%E6%95%B0%E7%BB%84%E5%8E%BB%E7%9A%84%E5%B8%B8%E8%A7%84%E5%8E%BB%E9%87%8D%E6%96%B9%E6%B3%95/","title":"js中数组去的常规去重方法"},{"content":" 该文章图片从网上摘录 对linux的初步准备 1.下载最新版ubuntu和UltraISo、DiskGenius工具 ubuntu下载链接：(https://cn.ubuntu.com/download/desktop)\nUltraISo、DiskGenius下载链接：（https://pan.baidu.com/s/17AwjX2AO7E5fHjYWQmINPQ）提取码：7777\n2.进行u盘刻录 插入自备u盘，注！！！u盘进行录入时会覆盖所有u盘数据\n(1).解压并打开ULtralSo，输入注册名【Guanjiu】，输入注册码【A06C-83A7-701D-6CFC】，点击【确定】\n(2).点击【文件】\n(3).点击【打开】\n(4).选中打开下载好的ubuntu.iso\n(5).点击【写入硬盘映像】\n(6).点击【写入】,选择确定继续操作\n(7).u盘刻录成功\n进行磁盘分区 1.解压并且打开DiskGenius分盘工具 （本人由于磁盘管理器无法分盘所以使用了第三方分盘工具）\n(1).鼠标右击一个磁盘（容量大的）,选择【新建分区】\n(2).所分配的新分区不少于20g，点击【开始】\n(3).点击【是】\n(4).点击【完成】\n开始安装系统 1.系统安装（由于安装时没有进行截图，这里的图片摘录自网上 (1).重启电脑，并且在开机画面出现后按本机电脑的【快捷键】（各品牌电脑的启动选择不一样，我的电脑是华硕，点击【ESC】按键）进入启动选择界面\n(2).点击UEFI选项即选择自己的u盘介质\n(3).进入后我这里选择的是第一项【尝试或者安装ubantu】，然后进入安装界面\n(4).选择中文并且安装Ubuntu\n(5).选择chinese后，点击【继续】\n(6).点击【继续】\n(7).选择【其他选项】\n(8).到这个时候会有两种情况，\n第一种是“这台计算机似乎没有安装操作系统”，此时说明为单系统刷机，点击【继续】即可，本文讲的是双系统安装\n第二种是双系统安装，此时会进入分盘界面，选择空闲磁盘进行四次分区\n(9)分区完成后点击【现在安装】，点击【继续】\n(10)输入用户名密码即可开始等待安装\n(11)安装完成后重启\n2.完成安装 (1)重启后，每次开机时会出现三个选项，点击ubuntu进入ubuntu系统，点击win回到原系统\n(2)输入密码进入用户界面\n","date":"2024-10-14T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/linux%E7%9A%84%E5%AE%89%E8%A3%85ubuntu/","title":"Linux的安装（Ubuntu）"},{"content":"一 使用方法 userList数据循环在列表，下拉框显示的值为userList里面的label字段的值，实际值为对应label的uuid值，如下面例子中输出选中小王的值应该为01\n1 2 3 4 5 6 7 8 9 10 \u0026lt;template\u0026gt; \u0026lt;el-select v-model=\u0026#34;form\u0026#34;\u0026gt; \u0026lt;el-option v-for=\u0026#34;item in userList\u0026#34; :key=\u0026#34;item.uuid\u0026#34; :label=\u0026#34;item.username\u0026#34; :value=\u0026#34;item.uuid\u0026#34; /\u0026gt; \u0026lt;/el-select\u0026gt; \u0026lt;/template\u0026gt; 1 2 3 4 5 const userList = ref([{ username:\u0026#39;小王\u0026#39;, uuid:\u0026#39;01\u0026#39; }]) const form = ref\u0026lt;any\u0026gt;() 二 传值问题（匹配功能） 当我们在子组件进行表单操作的时候，需要从父组件往子组件传相关操作数据的值，若传入form的值是小王，则form在下拉框中显示的值为小王,实际值也为小王，若传入的值为01,则下拉框中显示的值为小王，实际值依旧为01\n","date":"2024-10-13T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/el-select%E6%A0%87%E7%AD%BE%E5%AD%98%E5%9C%A8%E7%9A%84%E6%95%B0%E6%8D%AE%E5%8C%B9%E9%85%8D%E9%97%AE%E9%A2%98/","title":"el-select标签存在的数据匹配问题"},{"content":"[vue/no-use-v-if-with-v-for] 这个错误信息是由 ESLint 的 Vue.js 插件 eslint-plugin-vue 产生的。它指出在你的 Vue.js 模板中，v-if 和 v-for 指令被不当地一起使用在了同一个元素上。\n为什么会产生这个错误？ 在 Vue.js 中，当 v-if 和 v-for 同时用在同一个元素上时，Vue 会先处理 v-for，然后处理 v-if。这意味着即使 v-if 最终会过滤掉一些元素，v-for 仍然会遍历整个列表，这可能会导致不必要的计算开销，特别是在处理大型列表时。\n如何修复这个错误？ 根据 ESLint 的建议，你应该将 v-if 移动到包含 v-for 的父元素（或“包装元素”）上。这样可以确保 v-if 的条件判断只执行一次，而不是对 v-for 生成的每个元素都执行。\n错误的示例： 1 2 3 \u0026lt;div v-for=\u0026#34;item in items\u0026#34; v-if=\u0026#34;item.active\u0026#34; :key=\u0026#34;item.id\u0026#34;\u0026gt; {{ item.name }} \u0026lt;/div\u0026gt; 在这个例子中，v-if 会在每次迭代时都执行，检查 item.active 是否为真。\n正确的示例： 1 2 3 4 5 \u0026lt;div v-if=\u0026#34;hasActiveItems\u0026#34;\u0026gt; \u0026lt;div v-for=\u0026#34;item in activeItems\u0026#34; :key=\u0026#34;item.id\u0026#34;\u0026gt; {{ item.name }} \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; 在这个修正后的例子中，首先通过一个计算属性 hasActiveItems 或在 data、computed 或 methods 中定义的逻辑来检查是否有活跃的项。如果有，则使用 v-for 来渲染这些活跃的项。这里的 activeItems 应该是一个只包含活跃项的数组。\n总结 通过将 v-if 移动到 v-for 的外部，你可以优化你的 Vue 组件的性能，避免不必要的渲染和计算。这也是 Vue.js 官方文档和 ESLint 插件推荐的最佳实践。\n","date":"2024-10-12T00:00:00Z","image":"https://linsk27.github.io/img/default-category-cover_hu_801f689f5bdd1be.png","permalink":"https://linsk27.github.io/p/v-if%E5%92%8Cv-for%E4%B8%BA%E4%BD%95%E4%B8%8D%E8%AF%A5%E5%90%8C%E6%97%B6%E4%BD%9C%E7%94%A8%E4%BA%8E%E4%B8%80%E4%B8%AA%E6%A8%A1%E5%9D%97/","title":"v-if和v-for为何不该同时作用于一个模块？"}]