昨天把戳章落地的動畫調完之後,整個結果頁看起來已經相當完整。雷達圖、處方籤、電池耗損率、戳章,一路排下來,像一份真的有在運作的診斷報告。
我自己反覆測試了幾次,問題又發生了:每次測完,我都是自己一個人看完結果,笑一笑,然後關掉分頁。
這跟原本想做的事情,好像有點偏了。
一開始規劃這個專題的時候,我心裡想的其實是「大家一起紓壓」,不是「我一個人蓋章自爽」。壓力這種東西本來就是群體現象,看到別人也在為了同一件鳥事崩潰,往往比自己一個人硬扛更有共鳴感。
所以今天要加的功能,跟雷達圖、處方籤這些都不太一樣,不是分析使用者的狀態,而是讓使用者可以把今天的怨言丟出來,公開讓大家看。
一面匿名發洩牆。輸入一句話,按下送出,這句話就會出現在牆上,跟其他人丟出來的怨言放在一起。
這次的資料結構很單純,就是一個陣列,每個元素是一句話:
let complaints = [
"教授說報告延期,結果延到明天早上八點",
"咖啡喝到第三杯,手已經開始抖了"
];
送出一句新的怨言,就是陣列加一筆:
complaints.unshift(newText);
重點不在資料怎麼存,而是同一包資料,可以用完全不同的方式呈現在畫面上。今天我準備了兩種模式:
條列牆模式,把陣列裡的每一句話,照順序排成一張一張的卡片,整整齊齊往下疊,像一面真的留言牆,方便從頭到尾逐條看過去。
氣泡漂浮模式,把每一句話變成一顆從底部往上飄的文字氣泡,飄過螢幕中間再消失,比較像情緒隨機湧出來又散去的感覺,沿用的是 Day 9 做「惡言消滅器」時那套 @keyframes floatRandom 的邏輯,只是這次飄的不是固定詞庫,而是使用者自己打的句子。
兩種模式切換的時候,資料完全不用動,只是把 complaints 這個陣列,交給不同的渲染函式去處理:
function render() {
if (currentMode === "wall") renderWallMode();
else renderBubbleMode();
}
這跟之前雷達圖以及長條圖的關係有點像,同一份分數,可以畫成六角形,也可以畫成一排長條。資料跟畫面本來就該是分開的兩件事,今天只是又找到一個印證這句話的例子。
今天的示範把兩種模式都做出來,上方一顆切換鈕可以互相切換:
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>賽博紓壓診所:匿名發洩牆</title>
<style>
body {
font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
background: #e3f2fd;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
}
.card {
background: white;
padding: 25px;
border-radius: 20px;
box-shadow: 0 12px 30px rgba(0,0,0,0.15);
width: 100%;
max-width: 480px;
}
h2 { color: #1e88e5; margin: 0 0 15px; text-align: center; }
.mode-switch {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.mode-btn {
flex: 1;
background-color: #f5f5f5;
border: 2px solid transparent;
border-radius: 50px;
padding: 8px;
font-size: 0.85rem;
cursor: pointer;
color: #263238;
transition: 0.2s;
}
.mode-btn.active {
background-color: #1e88e5;
color: white;
border-color: #1e88e5;
}
.input-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.input-row input {
flex: 1;
padding: 10px 14px;
border-radius: 50px;
border: 2px solid #e3f2fd;
font-size: 0.9rem;
}
.input-row input:focus { outline: none; border-color: #1e88e5; }
.send-btn {
background-color: #1e88e5;
color: white;
border: none;
border-radius: 50px;
padding: 10px 20px;
font-size: 0.9rem;
cursor: pointer;
}
.send-btn:hover { background-color: #1565c0; }
/* 條列牆模式 */
#wallView { max-height: 280px; overflow-y: auto; }
.complaint-card {
background: #fdfdfd;
border: 2px dashed #90caf9;
border-radius: 12px;
padding: 10px 14px;
margin-bottom: 8px;
font-size: 0.88rem;
color: #263238;
}
.empty { color: #90a4ae; text-align: center; font-size: 0.85rem; padding: 10px; }
/* 氣泡漂浮模式 */
#bubbleView {
position: relative;
height: 280px;
background: #0d1117;
border-radius: 12px;
overflow: hidden;
}
.bubble {
position: absolute;
bottom: -40px;
background: #ff5252;
color: white;
padding: 6px 14px;
border-radius: 20px;
font-size: 0.8rem;
max-width: 80%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
animation: floatUp linear forwards;
}
@keyframes floatUp {
0% { transform: translateY(0); opacity: 0.9; }
100% { transform: translateY(-320px); opacity: 0; }
}
</style>
</head>
<body>
<div class="card">
<h2>🧱 匿名發洩牆</h2>
<div class="mode-switch">
<button class="mode-btn active" data-mode="wall" onclick="switchMode('wall')">條列牆模式</button>
<button class="mode-btn" data-mode="bubble" onclick="switchMode('bubble')">氣泡漂浮模式</button>
</div>
<div class="input-row">
<input type="text" id="complaintInput" placeholder="今天被什麼氣到了?" maxlength="40">
<button class="send-btn" onclick="submitComplaint()">丟出去</button>
</div>
<div id="wallView"></div>
<div id="bubbleView" class="hidden"></div>
</div>
<script>
// ---------- 今天的重點:資料只存一份,畫面分兩種模式詮釋 ----------
let complaints = [
"教授說報告延期,結果延到明天早上八點",
"咖啡喝到第三杯,手已經開始抖了",
"同組的人已經三天沒讀訊息了"
];
let currentMode = "wall";
let bubbleTimer = null;
function submitComplaint() {
const input = document.getElementById("complaintInput");
const text = input.value.trim();
if (!text) return;
complaints.unshift(text);
input.value = "";
render();
}
document.getElementById("complaintInput").addEventListener("keydown", (e) => {
if (e.key === "Enter") submitComplaint();
});
function switchMode(mode) {
currentMode = mode;
document.querySelectorAll(".mode-btn").forEach(btn => {
btn.classList.toggle("active", btn.dataset.mode === mode);
});
document.getElementById("wallView").classList.toggle("hidden", mode !== "wall");
document.getElementById("bubbleView").classList.toggle("hidden", mode !== "bubble");
if (bubbleTimer) clearInterval(bubbleTimer);
render();
}
function render() {
if (currentMode === "wall") renderWallMode();
else renderBubbleMode();
}
// ---------- 條列牆模式:陣列直接照順序渲染成卡片 ----------
function renderWallMode() {
const wall = document.getElementById("wallView");
if (complaints.length === 0) {
wall.innerHTML = '<div class="empty">目前牆上還沒有人發洩,當第一個吧</div>';
return;
}
wall.innerHTML = complaints
.map(text => `<div class="complaint-card">💬 ${text}</div>`)
.join("");
}
// ---------- 氣泡漂浮模式:定時從陣列裡隨機抓一句,做成飄浮氣泡 ----------
function renderBubbleMode() {
const bubbleView = document.getElementById("bubbleView");
bubbleView.innerHTML = "";
if (complaints.length === 0) return;
spawnBubble();
bubbleTimer = setInterval(spawnBubble, 1800);
}
function spawnBubble() {
if (complaints.length === 0) return;
const text = complaints[Math.floor(Math.random() * complaints.length)];
const bubbleView = document.getElementById("bubbleView");
const el = document.createElement("div");
el.className = "bubble";
el.innerText = text;
el.style.left = (Math.random() * 60 + 5) + "%";
el.style.animationDuration = (Math.random() * 2 + 5) + "s";
bubbleView.appendChild(el);
setTimeout(() => el.remove(), 7500);
}
render();
</script>
</body>
</html>
再存成 .html 打開,先在條列牆模式下送出幾句話,再切到氣泡漂浮模式看同一批怨言用完全不同的方式冒出來,兩種模式讀資料用的是同一個 complaints 陣列,一字不差。
切換模式的時候,要記得清掉舊的 setInterval
氣泡漂浮模式靠 setInterval 不斷產生新氣泡,如果切到條列牆模式再切回來,沒把前一個計時器清掉,畫面上會同時跑著好幾個計時器,氣泡冒出來的速度越疊越快,密密麻麻擠成一團。switchMode() 裡先 clearInterval(bubbleTimer) 這一步,就是在防止這種失控場面。
氣泡的文字用 text-overflow: ellipsis 截斷,長句子照樣能飄
輸入框限制了 40 個字,但中文字一多還是會超出氣泡寬度。靠 white-space: nowrap 加 text-overflow: ellipsis,超過寬度的部分自動變成省略號,氣泡形狀才能維持固定大小,不會因為某句話特別長就撐得歪七扭八。
空陣列時,兩種模式要各自處理「牆上空空的」狀態
條列牆模式直接印一行提示文字;氣泡模式則是讓 spawnBubble() 在陣列為空時直接 return,不然 Math.random() * complaints.length 會抓到不存在的索引,跳出 undefined 飄在畫面上,看起來像是系統在對著空氣罵髒話。
送出的內容完全沒有過濾,正式上線前這塊要補
今天的示範只處理了「資料怎麼存、怎麼用兩種模式呈現」這個核心問題,使用者打了什麼字就直接塞進陣列,沒有做任何過濾或長度以外的限制。真要接進正式的診所,這裡大概會需要擋掉一些不當字眼,或是限制太誇張的重複洗版,這部分我打算留到後面專門處理。
同一份資料,長出兩種完全不同的樣貌
寫完才發現,今天真正的重點不是「做一個留言板」,而是再一次驗證「資料」跟「畫面」分開設計的好處。complaints 這個陣列從頭到尾沒有變過,變的只是渲染函式怎麼詮釋它。雷達圖跟長條圖、固定文字跟動態文字、現在又多了條列牆跟氣泡飄浮,這種「一份資料、多種詮釋」的模式,在這個專題裡反覆出現了好幾次,每次都讓我更確定這個設計思路是正確的!
氣泡漂浮模式,骨架直接借用 Day 9 的飄浮動畫
整段 @keyframes floatUp 跟當初做「惡言消滅器」時幾乎是同一套邏輯,差別只在當初飄的是固定詞庫,今天飄的是使用者自己輸入的內容。能夠直接複用一段之前寫過的動畫,而不用重新想一套,算是今天的輕鬆時刻~
沒有後端,資料存在記憶體裡,重新整理就會歸零
這點跟病歷本不一樣,今天這個發洩牆完全沒接 localStorage,純粹是頁面在記憶體裡存的陣列。重新整理頁面,牆上的留言會直接清空,回到預設的三句示範文字。某種程度上這樣也不壞,發洩完就過去,不需要永久留存,反而更貼近「發洩」這個行為本身的意義。
今天我們用同一個陣列,搭配兩種渲染函式,做出了可以切換顯示方式的匿名發洩牆,讓使用者不再只是自己一個人看完結果就離開,而是能把今天的怨言丟出來,跟其他人的怨言放在一起看。
結果頁、處方籤、電池耗損率、戳章、發洩牆,整間診所看起來越來越像一回事。玩到這裡,我又忍不住想,如果今天測完試、蓋完章、丟完怨言,這一切只停留在這個網頁裡,好像還是有點可惜。
真要說紓壓這件事,很多時候是想把這份「今天真的很累」的感覺,傳給身邊的人看。
所以明天 Day 26,我們要處理分享這件事:用瀏覽器的 Clipboard API,把診斷結果整理成一段文字或連結,一鍵複製,直接貼去 IG、FB、LINE、Threads,讓這份唬爛的診斷書也能離開這個網頁,傳到親朋好友的手機上!
明天,讓這份病歷正式出院,去外面的世界透透氣!