iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
自我挑戰組

賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁系列 第 25 篇

Day 25:開一面牆,讓大家一起吐苦水!

  • 分享至 

  • xImage
  •  

前言

昨天把戳章落地的動畫調完之後,整個結果頁看起來已經相當完整。雷達圖、處方籤、電池耗損率、戳章,一路排下來,像一份真的有在運作的診斷報告。

我自己反覆測試了幾次,問題又發生了:每次測完,我都是自己一個人看完結果,笑一笑,然後關掉分頁。

這跟原本想做的事情,好像有點偏了。

一開始規劃這個專題的時候,我心裡想的其實是「大家一起紓壓」,不是「我一個人蓋章自爽」。壓力這種東西本來就是群體現象,看到別人也在為了同一件鳥事崩潰,往往比自己一個人硬扛更有共鳴感。

所以今天要加的功能,跟雷達圖、處方籤這些都不太一樣,不是分析使用者的狀態,而是讓使用者可以把今天的怨言丟出來,公開讓大家看。

一面匿名發洩牆。輸入一句話,按下送出,這句話就會出現在牆上,跟其他人丟出來的怨言放在一起。


核心觀念:資料存在陣列裡,畫面交給兩種模式輪流詮釋

這次的資料結構很單純,就是一個陣列,每個元素是一句話:

let complaints = [
    "教授說報告延期,結果延到明天早上八點",
    "咖啡喝到第三杯,手已經開始抖了"
];

送出一句新的怨言,就是陣列加一筆:

complaints.unshift(newText);

重點不在資料怎麼存,而是同一包資料,可以用完全不同的方式呈現在畫面上。今天我準備了兩種模式:

條列牆模式,把陣列裡的每一句話,照順序排成一張一張的卡片,整整齊齊往下疊,像一面真的留言牆,方便從頭到尾逐條看過去。

氣泡漂浮模式,把每一句話變成一顆從底部往上飄的文字氣泡,飄過螢幕中間再消失,比較像情緒隨機湧出來又散去的感覺,沿用的是 Day 9 做「惡言消滅器」時那套 @keyframes floatRandom 的邏輯,只是這次飄的不是固定詞庫,而是使用者自己打的句子。

兩種模式切換的時候,資料完全不用動,只是把 complaints 這個陣列,交給不同的渲染函式去處理:

function render() {
    if (currentMode === "wall") renderWallMode();
    else renderBubbleMode();
}

這跟之前雷達圖以及長條圖的關係有點像,同一份分數,可以畫成六角形,也可以畫成一排長條。資料跟畫面本來就該是分開的兩件事,今天只是又找到一個印證這句話的例子。


動手寫 Code:匿名發洩牆

今天的示範把兩種模式都做出來,上方一顆切換鈕可以互相切換:

<!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,讓這份唬爛的診斷書也能離開這個網頁,傳到親朋好友的手機上!

明天,讓這份病歷正式出院,去外面的世界透透氣!


上一篇
Day 24:惡搞病歷戳章動畫:用 CSS 3D 變形與落下特效模擬真實診所
下一篇
Day 26:出院通知書產生器,一份病歷四種語氣
系列文
賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言