摘要
最近做了一个很小但很有意思的本地 Web 应用:情绪降落伞 Mood Parachute。
它不是传统意义上的效率工具,也不是一个复杂的心理咨询系统,而是一个偏"情绪急救"的桌面 Web App:当用户心情低落、负面情绪反复打转时,通过"写下并粉碎""随机抽取高光回忆""完成一个极简小游戏""播放短音效"等轻量交互,帮助用户快速从负面反刍里切出来。
技术上,这个项目刻意保持轻量:
- 后端:Node.js + Express
- 数据库:SQLite 单文件数据库
- 前端:原生 HTML/CSS/JavaScript
- 不使用 React/Vue
- 不依赖外部 CDN
- 本地运行,本地存储
本文会从项目目标、整体架构、数据库设计、后端 API、前端交互、动画实现、图片/音效维护、测试与踩坑几个角度,完整拆解这个小应用的实现思路。
C:\Users\86182\Desktop\情绪降落伞

项目定位:不是大道理,是一个"数字庇护所"
这个应用的核心目标不是"讲道理",而是做一个 3 分钟内可用的情绪缓冲器。
它包含四个核心模块:
- 负能量粉碎机:用户写下烦心事,点击"粉碎",文字飞入黑洞并写入数据库。
- 一键充电博物馆:随机抽取用户保存过的高光卡片。
- 心流断路器:一个极简排序小游戏,让用户重新获得一点可控感。
- 高光收集器:保存文字、照片、音效等正向素材。
后来又增强了两个体验点:
- 点击高光照片可以放大查看。
- 一键充电时可以随机播放用户维护的音效。
这类小工具的关键不是功能多,而是交互要足够直接、反馈要足够及时。
项目结构
当前项目结构大致如下:
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 动画
负能量粉碎机的重点不是"保存一段文本",而是"让用户感觉这件事被处理了"。
点击粉碎时,前端会:
- 读取文本。
- 播放粉碎动画。
- 调用
/api/emotions写入数据库。 - 清空输入框。
- 更新顶部状态提示。
核心函数:
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-path、scale、rotate 和透明度变化模拟文字破碎:
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("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function escapeAttribute(value) {
return escapeHtml(value).replaceAll("`", "`");
}
渲染卡片内容时:
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. 为什么要避免连续抽到同一张?
按钮反馈不仅是"请求成功",还包括"用户感知到变化"。
如果连续点击没有视觉变化,用户会以为功能坏了。排重策略不复杂,但体验收益很高。
可以继续优化的方向
这个项目还可以继续扩展:
- 高光卡片支持编辑和删除。
- 增加高光搜索和分类筛选。
- 图片压缩后再入库,降低 SQLite 体积。
- 音效支持排序、启用/禁用。
- 负面情绪支持自动定期清理。
- 增加导入/导出数据功能。
- 做成 Electron/Tauri 桌面应用。
- 为前端交互补 Playwright 测试。
不过目前版本已经完成了一个完整闭环:记录、抽取、互动、上传、播放、维护。
总结
"情绪降落伞"这个项目技术上并不重,但它很适合练习一种能力:把抽象体验翻译成具体交互。
它的后端是很朴素的 Express + SQLite,前端是原生 HTML/CSS/JS,但通过动画、随机卡片、照片放大、短音效和小游戏,组合出了一个有仪式感的小工具。
很多时候,应用的价值不一定来自复杂技术栈,而来自对使用场景的理解:
- 用户低落时,不需要复杂菜单。
- 用户焦虑时,不需要长篇解释。
- 用户反复内耗时,需要一个可完成的小动作。
这也是这个项目最有意思的地方:代码很轻,但交互有情绪。
如果你也想做一个本地小工具,可以从这个项目的思路开始:先确定一个非常具体的使用瞬间,再用最简单的技术栈,把这个瞬间做扎实。