用 Node.js + SQLite + 原生前端写一个本地情绪急救 Web App:情绪降落伞 Mood Parachute

摘要

最近做了一个很小但很有意思的本地 Web 应用:情绪降落伞 Mood Parachute

它不是传统意义上的效率工具,也不是一个复杂的心理咨询系统,而是一个偏"情绪急救"的桌面 Web App:当用户心情低落、负面情绪反复打转时,通过"写下并粉碎""随机抽取高光回忆""完成一个极简小游戏""播放短音效"等轻量交互,帮助用户快速从负面反刍里切出来。

技术上,这个项目刻意保持轻量:

  • 后端:Node.js + Express
  • 数据库:SQLite 单文件数据库
  • 前端:原生 HTML/CSS/JavaScript
  • 不使用 React/Vue
  • 不依赖外部 CDN
  • 本地运行,本地存储

本文会从项目目标、整体架构、数据库设计、后端 API、前端交互、动画实现、图片/音效维护、测试与踩坑几个角度,完整拆解这个小应用的实现思路。

C:\Users\86182\Desktop\情绪降落伞

项目定位:不是大道理,是一个"数字庇护所"

这个应用的核心目标不是"讲道理",而是做一个 3 分钟内可用的情绪缓冲器。

它包含四个核心模块:

  1. 负能量粉碎机:用户写下烦心事,点击"粉碎",文字飞入黑洞并写入数据库。
  2. 一键充电博物馆:随机抽取用户保存过的高光卡片。
  3. 心流断路器:一个极简排序小游戏,让用户重新获得一点可控感。
  4. 高光收集器:保存文字、照片、音效等正向素材。

后来又增强了两个体验点:

  • 点击高光照片可以放大查看。
  • 一键充电时可以随机播放用户维护的音效。

这类小工具的关键不是功能多,而是交互要足够直接、反馈要足够及时。

项目结构

当前项目结构大致如下:

text 复制代码
情绪降落伞/
├─ server.js
├─ mood_parachute.db
├─ package.json
├─ README.md
├─ public/
│  ├─ index.html
│  ├─ style.css
│  └─ app.js
└─ tests/
   └─ server.test.js

几个文件的职责很清楚:

  • server.js:Express 服务、SQLite 初始化、API 路由、数据校验。
  • public/index.html:页面结构。
  • public/style.css:暗色复古界面、毛玻璃卡片、粉碎动画、照片放大层、胜利特效。
  • public/app.js:所有前端交互,包括请求 API、读图片/音频文件、播放音效、小游戏逻辑。
  • tests/server.test.js:后端核心逻辑测试。

项目没有做复杂分层,原因也很直接:这是一个本地小应用,保持单文件后端更容易运行和理解。

数据库设计

SQLite 使用单文件数据库 mood_parachute.db。后端启动时会自动创建三张表:

js 复制代码
const SCHEMA_SQL = [
  `CREATE TABLE IF NOT EXISTS bad_emotions (
    id INTEGER PRIMARY KEY,
    content TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  )`,
  `CREATE TABLE IF NOT EXISTS highlights (
    id INTEGER PRIMARY KEY,
    type TEXT NOT NULL,
    content TEXT NOT NULL,
    image_path TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  )`,
  `CREATE TABLE IF NOT EXISTS sounds (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    data_url TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  )`,
];

三张表分别对应三类数据:

表名 作用
bad_emotions 保存被用户"粉碎"的负面情绪
highlights 保存用户的高光卡片,可带照片
sounds 保存用户维护的音效

这里有一个取舍:图片和音频都以 Base64 Data URL 形式存入 SQLite。

这样做的优点是:

  • 不需要额外文件上传目录。
  • 不需要处理静态资源路径。
  • 数据库一个文件就能迁移。

缺点是:

  • Base64 会让体积变大。
  • 不适合大量图片或大音频。

所以项目里限制了图片和音效大小:

  • 图片建议 4MB 以内。
  • 音效建议 3MB 以内。

对于本地、小规模、个人工具来说,这个方案足够简单可靠。

后端架构:Express + Promise 封装 SQLite

后端入口是 server.js

启动逻辑很简单:

js 复制代码
function startServer() {
  const sqlite3 = require("sqlite3").verbose();
  const db = openDatabase(sqlite3);
  initDatabase(db);
  const app = createApp(db);

  app.listen(PORT, () => {
    console.log(`Mood Parachute 已启动: http://localhost:${PORT}`);
  });
}

为了让 SQLite 的回调 API 更适合 async/await,项目封装了三个工具函数:

js 复制代码
function run(db, sql, params = []) {
  return new Promise((resolve, reject) => {
    db.run(sql, params, function onRun(error) {
      if (error) {
        reject(error);
        return;
      }
      resolve({ id: this.lastID, changes: this.changes });
    });
  });
}

function get(db, sql, params = []) {
  return new Promise((resolve, reject) => {
    db.get(sql, params, (error, row) => {
      if (error) {
        reject(error);
        return;
      }
      resolve(row);
    });
  });
}

function all(db, sql, params = []) {
  return new Promise((resolve, reject) => {
    db.all(sql, params, (error, rows) => {
      if (error) {
        reject(error);
        return;
      }
      resolve(rows);
    });
  });
}

这样 API 路由就可以写得比较干净。

例如保存负面情绪:

js 复制代码
app.post("/api/emotions", async (req, res) => {
  try {
    const content = validateEmotionContent(req.body && req.body.content);
    const result = await run(db, "INSERT INTO bad_emotions (content) VALUES (?)", [content]);
    res.status(201).json({ ok: true, id: result.id, message: "已粉碎并封存" });
  } catch (error) {
    res.status(400).json({ ok: false, error: error.message });
  }
});

这里的重点是:先校验,再入库

validateEmotionContent 会处理空字符串和超长内容:

js 复制代码
function validateEmotionContent(content) {
  const clean = String(content || "").trim();
  if (!clean) {
    throw new Error("请输入要粉碎的内容");
  }
  if (clean.length > 1200) {
    throw new Error("内容太长了,先切成一小块来粉碎");
  }
  return clean;
}

这种校验虽然简单,但对本地应用依然重要。前端可以做一次校验,后端也必须兜底。

一键充电:随机抽卡与避免重复

"一键充电"功能对应接口:

text 复制代码
GET /api/highlights/random

前端会带上上一张卡片 ID:

js 复制代码
const query = lastHighlightId ? `?exclude_id=${encodeURIComponent(lastHighlightId)}` : "";
const data = await requestJson(`/api/highlights/random${query}`);

后端根据 exclude_id 拼出不同 SQL:

js 复制代码
function buildRandomHighlightSql(excludeId) {
  const id = Number(excludeId);
  if (Number.isInteger(id) && id > 0) {
    return {
      sql: "SELECT * FROM highlights WHERE content NOT GLOB '*????*' AND id != ? ORDER BY RANDOM() LIMIT 1",
      params: [id],
    };
  }

  return {
    sql: "SELECT * FROM highlights WHERE content NOT GLOB '*????*' ORDER BY RANDOM() LIMIT 1",
    params: [],
  };
}

这样连续点击时,就尽量不会抽到上一张。

如果数据库里没有其他高光,则使用兜底卡片:

js 复制代码
function pickFallbackHighlight(excludeId) {
  const candidates = FALLBACK_HIGHLIGHTS.filter((highlight) => highlight.id !== excludeId);
  const pool = candidates.length ? candidates : FALLBACK_HIGHLIGHTS;
  return pool[Math.floor(Math.random() * pool.length)];
}

这个细节很小,但体验差异明显。用户连续点击"一键充电"时,如果一直看到同一张,会觉得按钮"没反应"。加入排重后,即使数据少,也能更频繁地看到变化。

乱码过滤:一次真实踩坑

项目开发过程中遇到过一个很实际的问题:用 PowerShell 发中文 JSON 做接口验证时,如果编码处理不当,中文可能会被写成 ???????

为了避免"一键充电"抽出这种坏数据,后端加了一个不可读内容判断:

js 复制代码
function isUnreadableHighlight(highlight) {
  const content = String(highlight && highlight.content || "").trim();
  if (!content) {
    return true;
  }

  const questionMarks = (content.match(/\?/g) || []).length;
  return questionMarks >= 4 && questionMarks / content.length > 0.6;
}

同时 SQL 层也排除明显异常内容:

sql 复制代码
WHERE content NOT GLOB '*????*'

这是一个典型的本地工具开发细节:不是所有问题都来自复杂架构,很多时候来自脚本、终端、编码和测试数据。

图片上传:不做文件服务器,直接存 Data URL

高光卡片支持上传照片。

前端 HTML:

html 复制代码
<input id="photoInput" name="photo" type="file" accept="image/png,image/jpeg,image/webp,image/gif">

前端读取文件:

js 复制代码
function readFileAsDataUrl(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.addEventListener("load", () => resolve(reader.result));
    reader.addEventListener("error", () => reject(reader.error));
    reader.readAsDataURL(file);
  });
}

选择照片后,会先做前端校验:

js 复制代码
if (!file.type.startsWith("image/")) {
  photoInput.value = "";
  photoPreview.textContent = "只能选择图片文件";
  return;
}

if (file.size > 4 * 1024 * 1024) {
  photoInput.value = "";
  photoPreview.textContent = "照片太大了,请选择 4MB 以内的图片";
  return;
}

然后提交时将 Data URL 放进 image_path

js 复制代码
const payload = Object.fromEntries(formData.entries());
delete payload.photo;
payload.image_path = selectedPhotoDataUrl;

后端再次校验:

js 复制代码
function normalizeImagePath(imagePath) {
  const clean = String(imagePath || "").trim();
  if (!clean) {
    return null;
  }

  if (clean.startsWith("data:")) {
    const isImageData = /^data:image\/(png|jpeg|jpg|webp|gif);base64,[a-z0-9+/=\s]+$/i.test(clean);
    if (!isImageData) {
      throw new Error("只支持图片文件");
    }
    if (clean.length > 5 * 1024 * 1024) {
      throw new Error("图片太大了,请选择 4MB 以内的照片");
    }
  }

  return clean;
}

前后端双重限制,保证不会把非图片内容塞进数据库。

点击照片放大

高光卡片渲染时,如果有图片,会生成一个按钮包裹图片:

js 复制代码
const imageMarkup = highlight.image_path
  ? `<button class="card-photo-button" type="button" data-photo="${escapeAttribute(highlight.image_path)}"><img class="card-photo" src="${escapeAttribute(highlight.image_path)}" alt="高光照片"></button>`
  : "";

点击后打开全屏遮罩:

js 复制代码
highlightCard.addEventListener("click", (event) => {
  const button = event.target.closest(".card-photo-button");
  if (!button) {
    return;
  }

  largePhoto.src = button.dataset.photo;
  photoOverlay.classList.add("show");
  photoOverlay.setAttribute("aria-hidden", "false");
});

关闭方式支持三种:

  • 点击右上角关闭按钮
  • 点击背景
  • Esc
js 复制代码
document.addEventListener("keydown", (event) => {
  if (event.key === "Escape") {
    closeLargePhoto();
  }
});

这个交互很适合"高光回忆"场景:缩略图给提示,放大图给沉浸感。

音效维护:上传、试听、随机播放

音效维护区支持上传短音效。

后端表结构:

sql 复制代码
CREATE TABLE IF NOT EXISTS sounds (
  id INTEGER PRIMARY KEY,
  name TEXT NOT NULL,
  data_url TEXT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)

主要 API:

方法 路径 作用
GET /api/sounds 获取音效列表
GET /api/sounds/random 随机获取一个音效
GET /api/sounds/:id 获取单个音效,用于试听
POST /api/sounds 保存音效
DELETE /api/sounds/:id 删除音效

音频校验逻辑:

js 复制代码
function normalizeSoundPayload(payload) {
  const name = String(payload && payload.name || "").trim();
  const dataUrl = String(payload && payload.data_url || "").trim();

  if (!name) {
    throw new Error("请给音效起个名字");
  }
  if (!dataUrl) {
    throw new Error("请上传一个音效文件");
  }
  if (!/^data:audio\/(mpeg|mp3|wav|ogg|webm|aac|x-wav);base64,[a-z0-9+/=\s]+$/i.test(dataUrl)) {
    throw new Error("只支持音频文件");
  }
  if (dataUrl.length > 4 * 1024 * 1024) {
    throw new Error("音效太大了,请选择 3MB 以内的短音效");
  }

  return { name, data_url: dataUrl };
}

前端播放自定义音效:

js 复制代码
function playAudioDataUrl(dataUrl) {
  return new Promise((resolve) => {
    const audio = new Audio(dataUrl);
    audio.volume = 0.42;
    audio.addEventListener("ended", resolve, { once: true });
    audio.addEventListener("error", resolve, { once: true });
    audio.play().catch(resolve);
  });
}

如果用户没有维护任何音效,则使用 Web Audio API 合成一个短提示音:

js 复制代码
function playSyntheticChargeSound() {
  const AudioContext = window.AudioContext || window.webkitAudioContext;
  if (!AudioContext) {
    return;
  }

  const context = new AudioContext();
  const gain = context.createGain();
  gain.gain.setValueAtTime(0.0001, context.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.12, context.currentTime + 0.02);
  gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.34);
  gain.connect(context.destination);

  [329.63, 415.3, 554.37].forEach((frequency, index) => {
    const oscillator = context.createOscillator();
    oscillator.type = "triangle";
    oscillator.frequency.setValueAtTime(frequency, context.currentTime + index * 0.055);
    oscillator.connect(gain);
    oscillator.start(context.currentTime + index * 0.055);
    oscillator.stop(context.currentTime + 0.34);
  });
}

这个兜底很实用:即使用户没有上传音效,"一键充电"也有即时反馈。

负能量粉碎机:仪式感来自 CSS 动画

负能量粉碎机的重点不是"保存一段文本",而是"让用户感觉这件事被处理了"。

点击粉碎时,前端会:

  1. 读取文本。
  2. 播放粉碎动画。
  3. 调用 /api/emotions 写入数据库。
  4. 清空输入框。
  5. 更新顶部状态提示。

核心函数:

js 复制代码
function playShredAnimation(text) {
  flyingText.textContent = text.slice(0, 160);
  flyingText.classList.remove("active");
  appShell.classList.remove("shake");

  requestAnimationFrame(() => {
    flyingText.classList.add("active");
    appShell.classList.add("shake");
  });

  window.setTimeout(() => {
    flyingText.classList.remove("active");
    appShell.classList.remove("shake");
  }, 980);
}

CSS 中通过 clip-pathscalerotate 和透明度变化模拟文字破碎:

css 复制代码
@keyframes shredText {
  0% {
    opacity: 1;
    clip-path: polygon(0 0, 100% 0, 96% 100%, 4% 100%);
    transform: translate(-50%, -50%) scale(1) rotate(0deg);
  }
  55% {
    opacity: 1;
    clip-path: polygon(0 0, 22% 12%, 36% 0, 58% 16%, 78% 3%, 100% 13%, 93% 100%, 70% 86%, 54% 100%, 30% 82%, 7% 100%);
  }
  100% {
    opacity: 0;
    transform: translate(260px, -118px) scale(0.04) rotate(760deg);
  }
}

同时页面会轻微震动:

css 复制代码
@keyframes shakeScreen {
  0%, 100% { transform: translate(0, 0); }
  20% { transform: translate(-4px, 2px); }
  40% { transform: translate(5px, -2px); }
  60% { transform: translate(-3px, -1px); }
  80% { transform: translate(3px, 2px); }
}

这里的设计思路是:把一个抽象的"我不想再想它了",变成一个可见、可点击、可完成的动作。

心流断路器:极简小游戏

小游戏不是为了挑战用户,而是为了给用户一个很容易完成的小胜利。

当前题目是排序四张牌:

js 复制代码
const puzzle = [
  { id: "one", text: "1  收拢注意力" },
  { id: "two", text: "2  找到可控的一小步" },
  { id: "three", text: "3  执行 30 秒" },
  { id: "four", text: "4  领取掌控感" },
];

默认顺序打乱:

js 复制代码
let currentOrder = ["three", "one", "four", "two"];

点击两张牌交换位置:

js 复制代码
function selectOrSwap(id) {
  if (!selectedLine) {
    selectedLine = id;
    renderPuzzle();
    return;
  }

  if (selectedLine === id) {
    selectedLine = null;
    renderPuzzle();
    return;
  }

  const firstIndex = currentOrder.indexOf(selectedLine);
  const secondIndex = currentOrder.indexOf(id);
  currentOrder[firstIndex] = id;
  currentOrder[secondIndex] = selectedLine;
  selectedLine = null;
  renderPuzzle();
}

判断是否完成:

js 复制代码
function isPuzzleSolved() {
  return currentOrder.join(",") === puzzle.map((line) => line.id).join(",");
}

通关后显示全屏像素风提示:

js 复制代码
winOverlay.classList.add("show");
winOverlay.setAttribute("aria-hidden", "false");
setStatus("心流断路器通关");

这类交互不需要复杂逻辑,关键是足够短、足够确定、足够可完成。

前端安全:escapeHtml 与 escapeAttribute

因为卡片内容来自用户输入,所以渲染时必须避免直接插入未处理文本。

项目里用了两个简单函数:

js 复制代码
function escapeHtml(value) {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");
}

function escapeAttribute(value) {
  return escapeHtml(value).replaceAll("`", "&#096;");
}

渲染卡片内容时:

js 复制代码
<p class="card-content">${escapeHtml(highlight.content)}</p>

渲染图片地址时:

js 复制代码
data-photo="${escapeAttribute(highlight.image_path)}"

这不是完整的安全框架,但对于这个本地应用,已经能避免最常见的 HTML 注入问题。

样式设计:暗色复古、毛玻璃和稳定布局

视觉风格上,这个项目没有使用高饱和霓虹,而是采用暗色复古灰、琥珀色、翡翠绿。

CSS 变量:

css 复制代码
:root {
  color-scheme: dark;
  --bg: #171b1f;
  --panel: rgba(42, 48, 53, 0.82);
  --panel-solid: #252b30;
  --ink: #eef1ec;
  --muted: #aeb8b2;
  --line: rgba(238, 241, 236, 0.14);
  --amber: #d6a85f;
  --emerald: #69b98f;
  --danger: #cf775f;
}

所有元素统一 box-sizing

css 复制代码
*,
*::before,
*::after {
  box-sizing: border-box;
}

卡片采用毛玻璃效果:

css 复制代码
.highlight-card {
  min-height: 190px;
  margin-top: 18px;
  padding: 22px;
  border: 1px solid rgba(238, 241, 236, 0.16);
  border-radius: 8px;
  background: linear-gradient(145deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.025));
  box-shadow: inset 0 0 30px rgba(255, 255, 255, 0.035), 0 16px 42px rgba(0, 0, 0, 0.25);
}

布局上大量使用稳定的块级、table-cell 和明确宽度,而不是复杂嵌套布局。原因是这个应用更像桌面工具,不需要过度炫技,稳定更重要。

测试:用 Node 内置 test runner 锁住核心逻辑

项目使用 Node 自带的测试模块,不引入 Jest/Mocha。

测试文件:tests/server.test.js

测试覆盖了:

  • 负面情绪输入校验
  • 高光类型归一化
  • 图片 Data URL 校验
  • 音频 Data URL 校验
  • 随机高光 SQL 是否排除上一张
  • 兜底卡片是否避免重复
  • SQLite 建表语句是否包含必要字段

示例:

js 复制代码
test("normalizeSoundPayload rejects non-audio data URLs", () => {
  assert.throws(() => normalizeSoundPayload({
    name: "bad",
    data_url: "data:image/png;base64,iVBORw0KGgo=",
  }), /只支持音频文件/);
});

运行:

bash 复制代码
npm test

语法检查:

bash 复制代码
node --check server.js
node --check public/app.js

对于一个小型本地应用来说,这种测试方式足够轻量,不会增加太多维护成本。

运行方式

安装依赖:

bash 复制代码
npm i

启动:

bash 复制代码
npm start

浏览器访问:

text 复制代码
http://localhost:3000

如果端口被占用,可以换端口:

powershell 复制代码
$env:PORT=3001
npm start

这个项目的几个设计取舍

1. 为什么不用 React/Vue?

这个项目的交互并不复杂,原生 DOM 已经足够。

不用框架的好处:

  • 启动快。
  • 文件少。
  • 本地部署简单。
  • 不需要构建步骤。

2. 为什么图片和音频直接存 SQLite?

因为这是个人本地工具,目标是"一个文件夹就能跑"。

如果改成生产级应用,更合理的做法是:

  • 图片/音频放本地文件目录或对象存储。
  • SQLite 只存路径。
  • 增加文件清理和引用管理。

但对这个项目来说,Data URL 更简单。

3. 为什么要做音效兜底?

因为音效属于增强体验,不应该成为主流程依赖。

用户没有上传音效时,仍然应该有反馈,所以用 Web Audio API 合成短提示音。

4. 为什么要避免连续抽到同一张?

按钮反馈不仅是"请求成功",还包括"用户感知到变化"。

如果连续点击没有视觉变化,用户会以为功能坏了。排重策略不复杂,但体验收益很高。

可以继续优化的方向

这个项目还可以继续扩展:

  1. 高光卡片支持编辑和删除。
  2. 增加高光搜索和分类筛选。
  3. 图片压缩后再入库,降低 SQLite 体积。
  4. 音效支持排序、启用/禁用。
  5. 负面情绪支持自动定期清理。
  6. 增加导入/导出数据功能。
  7. 做成 Electron/Tauri 桌面应用。
  8. 为前端交互补 Playwright 测试。

不过目前版本已经完成了一个完整闭环:记录、抽取、互动、上传、播放、维护。

总结

"情绪降落伞"这个项目技术上并不重,但它很适合练习一种能力:把抽象体验翻译成具体交互

它的后端是很朴素的 Express + SQLite,前端是原生 HTML/CSS/JS,但通过动画、随机卡片、照片放大、短音效和小游戏,组合出了一个有仪式感的小工具。

很多时候,应用的价值不一定来自复杂技术栈,而来自对使用场景的理解:

  • 用户低落时,不需要复杂菜单。
  • 用户焦虑时,不需要长篇解释。
  • 用户反复内耗时,需要一个可完成的小动作。

这也是这个项目最有意思的地方:代码很轻,但交互有情绪。

如果你也想做一个本地小工具,可以从这个项目的思路开始:先确定一个非常具体的使用瞬间,再用最简单的技术栈,把这个瞬间做扎实。

相关推荐
mN9B2uk171 小时前
在Qt中使用SQLite数据库
数据库·qt·sqlite
樱花的浪漫1 小时前
Typescript、Zod基础
前端·javascript·人工智能·语言模型·自然语言处理·typescript
Bigger2 小时前
记一次坑爹的 Cloudflare Pages 部署:Failed to load module script 是怎么把我的 SPA 搞挂的
前端·ci/cd·浏览器
竹林8182 小时前
监听智能合约事件,我用 wagmi v2 踩了三天坑,终于找到了稳定方案
前端·javascript
星栈2 小时前
Makepad 界面怎么做得更像产品,而不是示例
前端·rust
不好听6132 小时前
Bun vs Node.js:谁才是 TypeScript 的"亲爹"?
typescript·node.js·bun
Momo__2 小时前
SSR 懒水合四件套 — 99%的人不知道 Vue 3.5 藏了这些水合策略
前端·vue.js·性能优化
矩阵科学2 小时前
Langchain.js 实战四:工具的使用
langchain·node.js
riuphan2 小时前
JavaScript 事件循环:单线程异步编程的核心机制
前端·javascript