iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
自我挑戰組

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

Day 28:上線前的三項修補,漏翻的題目、漏翻的資料、斷氣的呼吸

  • 分享至 

  • xImage
  •  

前言

昨天診所做完健檢,報告上列得清清楚楚。不過健檢查的是程式碼的體質,使用者實際走過一遍會遇到什麼,單子上看不出來。所以做完三張檢驗單之後,我換成使用者的身分,從頭把整間診所玩了一輪,挑出三個一定要在上線前處理掉的問題:

  • 漏翻的題目:切到英文、日文、韓文之後,測驗的題目、選項、抽到的籤詩,全部還是中文
  • 漏翻的資料:處方、飄過去的紅色氣泡、發洩牆上的三句示範留言,同樣停在中文
  • 斷氣的呼吸:呼吸過場的字吐了兩口氣之後就不再變了,圓圈還在那邊一縮一放

三個問題各有各的原因,今天分成三段來講,最後用同一個示範頁把它們串起來。部署的事,留給明天~


第一段:題目、選項、籤詩,全部換成四語物件

症狀

Day 19 做完語言切換、Day 23 補完動態圖例之後,我一直以為多語系已經收尾了。這次實際玩一遍才發現,最明顯的破綻出在測驗本身:整個畫面的按鈕、標題都在換語言,題目卻原封不動地停在中文。

原因

前面處理的都是介面上的「說明文字」,題庫不一樣,它是資料本身。quizData 裡的題目從 Day 10 開始就是一串中文字串,語言表根本沒有機會接上它們。

做法

把每一段文字,從單純的字串換成一個「四語物件」:

const L = (zh, en, ja, ko) => ({ zh, en, ja, ko });

q: L("早上鬧鐘響了,但你昨天熬夜趕專題,現在的你會:",
     "Your alarm goes off, but you stayed up late on your project. You:",
     "目覚ましが鳴ったけど、昨夜は課題で徹夜…。あなたは:",
     "알람이 울렸지만 어젯밤 과제로 밤을 새웠다. 당신은:")

顯示的時候,用一個小函式 t() 依照目前語言取出對應的那一句:

const t = (obj) => (obj && (obj[lang] || obj.zh)) || "";

這一段有三個地方要特別處理:

  • 選項前面的 A、B、C 不再寫在文字裡。 以前選項是 "A. 直接按掉……" 整串寫死,現在文字只放內容本身,字母由程式依照順序補上。四種語言共用同一套字母,也不用在四個版本裡各寫一次。
  • 抽籤結果直接顯示選項裡對應語言的那一句。 籤詩不再是寫死的中文,而是從選項物件裡,用 t() 取出當下語言的版本。語言是在進入測驗之前選好的,測驗畫面上沒有語言按鈕,作答途中不會被切換,所以顯示的當下取對語言就夠了。
  • 分數不跟語言走。 每個選項背後的 scores 不管哪種語言都一模一樣,所以不論用哪個語言作答,診斷結果會是同一份。

第二段:藏在資料裡的其他文字,一起接上

症狀

題目修好之後,我繼續往後玩,發現同樣的問題還藏在三個地方:診斷結果的處方、惡言消滅器的詞庫、發洩牆預設的三句話。

做法

三個地方的處理方式,跟題庫是同一套:

  • 處方:prescriptionDB 的每一筆,從字串換成四語物件,顯示時用 t() 取出對應語言。
  • 惡言消滅器:詞庫換成四語物件,氣泡產生的當下,用 t() 取出目前語言的詞。氣泡只會出現在測驗畫面,而那裡沒有語言按鈕,所以不用特地追蹤已經飄在半空中的氣泡。
  • 發洩牆:預設的三句換成四語物件,使用者自己輸入的留言則是單純的字串。牆上的每一筆資料,顯示前先判斷型別,物件就翻譯,字串就原樣顯示。

另外,語言切換的函式也要多做一件事:依照目前停留在哪個畫面,決定要重畫哪一塊。 在呼吸過場要立刻刷新呼吸的字,在結果頁要重畫圖例、處方跟發洩牆。測驗畫面則不需要,因為那裡不放語言按鈕,語言在進入測驗之前就已經決定了。這是 Day 20 學到的「查表」邏輯,再套用一次。


第三段:呼吸只吐兩口就沒氣

症狀

呼吸過場的圓圈會一直縮放下去,圓圈中間的文字卻只變了幾次:吸氣、吐氣、吸氣、吐氣、吸氣,然後停在那裡,圓圈還在動,字已經不動了。

原因

我回頭翻計時器的寫法:

if (step >= 4) clearInterval(interval);

這行是 Day 4 的原始寫法,目的是避免計時器無限跑下去。問題是圓圈的 CSS 動畫寫的是 infinite,永遠不會停。一個八秒後收工,一個永遠不收工,兩個時鐘各走各的。 之後我把整段計時器原封不動搬進語言切換的範例,也沒有發現這個落差。

做法

乾脆不要自己再數秒了。圓圈的動畫本身就有一個時鐘,直接問它「你現在跑到幾毫秒」:

const anim = circle.getAnimations()[0];
const ms = Number(anim.currentTime);
const state = (ms % 4000) < 2000 ? "in" : "out";

動畫週期是四秒,前兩秒圓圈放大,對應吸氣;後兩秒圓圈縮小,對應吐氣。文字用同一個時鐘決定,就不可能跟圓圈對不上。計時器也不再有結束的條件,停留在這個畫面多久都會繼續呼吸,離開畫面時才清掉。


動手寫 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>
    * { box-sizing: border-box; }
    body {
        font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
        background: #e3f2fd;
        color: #263238;
        margin: 0;
        padding: 16px;
        min-height: 100vh;
        min-height: 100dvh;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
    }
    .lang-switch { display: flex; gap: 6px; margin-bottom: 12px; }
    .lang-btn {
        background: #fff; border: 2px solid transparent; border-radius: 50px;
        padding: 6px 14px; font-size: 0.8rem; cursor: pointer; color: #263238;
        touch-action: manipulation;
    }
    .lang-btn.active { background: #1e88e5; color: #fff; }

    .card {
        background: #fff; padding: 24px; border-radius: 20px;
        box-shadow: 0 12px 30px rgba(0,0,0,0.15);
        width: 100%; max-width: 480px; text-align: center;
        position: relative; z-index: 10;
    }
    .hidden { display: none; }
    h1 { color: #1e88e5; font-size: 1.4rem; margin: 0 0 8px; }
    h2 { color: #263238; font-size: 1.1rem; line-height: 1.5; margin: 8px 0 18px; }
    h3 { font-size: 1rem; text-align: left; margin: 18px 0 8px; }
    p { color: #546e7a; font-size: 0.9rem; margin: 0 0 16px; }
    .progress { font-size: 0.85rem; color: #1e88e5; font-weight: bold; }

    .btn {
        background: #1e88e5; color: #fff; border: none; border-radius: 50px;
        padding: 12px 28px; font-size: 1rem; cursor: pointer; width: 100%;
        margin-top: 10px; touch-action: manipulation;
    }
    .btn:hover { background: #1565c0; }
    .option-btn {
        background: #f5f5f5; border: 2px solid transparent; border-radius: 14px;
        padding: 14px; width: 100%; margin-bottom: 10px; text-align: left;
        cursor: pointer; font-size: 0.93rem; color: #263238; touch-action: manipulation;
    }
    .option-btn:hover { border-color: #1e88e5; background: #e3f2fd; }
    .draw-btn {
        background: #d32f2f; color: #fff; border: none; border-radius: 50px;
        padding: 14px 36px; font-size: 1.1rem; font-weight: bold; cursor: pointer;
        width: 100%; box-shadow: 0 4px 15px rgba(211,47,47,0.4); touch-action: manipulation;
    }
    #drawResult { margin-top: 14px; font-weight: bold; color: #d32f2f; min-height: 48px; white-space: pre-line; }

    /* 呼吸圓圈:前 2 秒放大(吸氣),後 2 秒縮小(吐氣) */
    .breathe-circle {
        width: 130px; height: 130px; border-radius: 50%; margin: 30px auto;
        background: #90caf9; color: #fff; font-weight: bold;
        display: flex; align-items: center; justify-content: center; text-align: center;
        animation: breatheAnimation 4s infinite ease-in-out;
    }
    @keyframes breatheAnimation {
        0%   { transform: scale(1);   background: #90caf9; }
        50%  { transform: scale(1.3); background: #42a5f5; }
        100% { transform: scale(1);   background: #90caf9; }
    }

    /* 惡言消滅器 */
    .rage-target {
        position: fixed; background: #ff5252; color: #fff; padding: 6px 12px;
        border-radius: 20px; font-size: 0.85rem; cursor: pointer; z-index: 100;
        box-shadow: 0 4px 10px rgba(0,0,0,0.2); user-select: none;
        animation: floatRandom 8s forwards linear; transition: transform 0.1s, opacity 0.2s;
    }
    @keyframes floatRandom {
        0%   { transform: translateY(105vh); opacity: 0.9; }
        100% { transform: translateY(-10vh) translateX(30px); opacity: 0; }
    }

    /* 結果頁 */
    .dim-row { text-align: left; margin-bottom: 10px; font-size: 0.85rem; }
    .dim-head { display: flex; justify-content: space-between; margin-bottom: 3px; }
    .dim-track { height: 10px; background: #eceff1; border-radius: 5px; overflow: hidden; }
    .dim-fill { height: 100%; background: linear-gradient(90deg, #64b5f6, #1e88e5); border-radius: 5px; }
    .rx {
        text-align: left; border: 2px dashed #90caf9; border-radius: 12px;
        padding: 12px 14px; font-size: 0.88rem; line-height: 1.6; margin-top: 14px;
    }
    .rx b { color: #d32f2f; display: block; margin-bottom: 4px; }

    .wall-input { display: flex; gap: 6px; margin-bottom: 8px; }
    .wall-input input {
        flex: 1; padding: 9px 14px; border-radius: 50px;
        border: 2px solid #e3f2fd; font-size: 0.85rem;
    }
    .wall-input button {
        background: #1e88e5; color: #fff; border: none; border-radius: 50px;
        padding: 9px 16px; cursor: pointer; font-size: 0.85rem;
    }
    #wallList { max-height: 140px; overflow-y: auto; text-align: left; }
    .wall-card {
        background: #fdfdfd; border: 2px dashed #90caf9; border-radius: 10px;
        padding: 8px 12px; margin-bottom: 6px; font-size: 0.83rem;
    }
</style>
</head>
<body>

    <div class="lang-switch" id="langSwitch">
        <button class="lang-btn" data-lang="zh">中文</button>
        <button class="lang-btn" data-lang="en">EN</button>
        <button class="lang-btn" data-lang="ja">日本語</button>
        <button class="lang-btn" data-lang="ko">한국어</button>
    </div>

    <!-- 首頁 -->
    <div id="welcome-screen" class="card">
        <h1 data-i18n="title"></h1>
        <p data-i18n="subtitle"></p>
        <button class="btn" data-i18n="startBtn" onclick="startBreathe()"></button>
    </div>

    <!-- 呼吸過場 -->
    <div id="breathe-screen" class="card hidden">
        <h2 data-i18n="breatheTitle"></h2>
        <div class="breathe-circle" id="breatheCircle"></div>
        <button class="btn" data-i18n="readyBtn" onclick="startQuiz()"></button>
    </div>

    <!-- 測驗 -->
    <div id="quiz-screen" class="card hidden">
        <span class="progress" id="progress"></span>
        <h2 id="question"></h2>
        <div id="options"></div>
        <div id="drawResult"></div>
    </div>

    <!-- 結果 -->
    <div id="result-screen" class="card hidden">
        <h2 data-i18n="resultTitle"></h2>
        <div id="dimList"></div>
        <div class="rx"><b id="rxTitle"></b><span id="rxText"></span></div>

        <h3 data-i18n="wallTitle"></h3>
        <div class="wall-input">
            <input id="wallInput" data-i18n-ph="wallPlaceholder" maxlength="40">
            <button data-i18n="wallSend" onclick="submitComplaint()"></button>
        </div>
        <div id="wallList"></div>

        <button class="btn" data-i18n="againBtn" onclick="restart()"></button>
    </div>

    <script>
        const $ = (id) => document.getElementById(id);
        let lang = "zh";

        // ---------- 第一段:每段文字都是四語物件,顯示時才挑語言 ----------
        const L = (zh, en, ja, ko) => ({ zh, en, ja, ko });
        const t = (obj) => (obj && (obj[lang] || obj.zh)) || "";

        const dimensions = ["日常", "效率", "社交", "心態", "放鬆", "職涯"];
        const dimensionNames = {
            "日常": L("日常", "Daily", "日常", "일상"),
            "效率": L("效率", "Efficiency", "効率", "효율"),
            "社交": L("社交", "Social", "社交", "사교"),
            "心態": L("心態", "Mindset", "メンタル", "멘탈"),
            "放鬆": L("放鬆", "Relax", "リラックス", "휴식"),
            "職涯": L("職涯", "Career", "キャリア", "커리어")
        };

        const UI = {
            title:          L("賽博紓壓診所", "Cyber Stress Clinic", "サイバー・ストレスクリニック", "사이버 스트레스 클리닉"),
            subtitle:       L("結合廟宇抽籤、雷達圖與惡搞處方籤的心靈小工具", "Fortune draws, radar charts, and absurd prescriptions in one app", "おみくじ、レーダーチャート、ふざけた処方箋が詰まった癒しツール", "운세뽑기, 레이더 차트, 엉뚱한 처방전이 담긴 힐링 도구"),
            startBtn:       L("進入紓壓診所", "Enter the Clinic", "クリニックへ入る", "클리닉 입장하기"),
            breatheTitle:   L("🧘 調整呼吸,放鬆心情", "🧘 Take a Breath, Relax", "🧘 呼吸を整えて、心を落ち着けよう", "🧘 숨을 고르고 마음을 편안하게"),
            breathIn:       L("吸氣...", "Breathe in...", "吸って...", "들이쉬고..."),
            breathOut:      L("吐氣...", "Breathe out...", "吐いて...", "내쉬고..."),
            readyBtn:       L("準備好了,開始解碼", "I'm Ready, Let's Start", "準備完了、始めよう", "준비됐어요, 시작할게요"),
            qLabel:         L("Question {n} of {total}", "Question {n} of {total}", "第{n}問 / 全{total}問", "{n} / {total}번째 질문"),
            drawBtn:        L("🎲 搖動籤筒", "🎲 Shake the Stick", "🎲 おみくじを振る", "🎲 운세 통 흔들기"),
            drawResult:     L("鏘鏘!你抽到了:", "Ta-da! You drew:", "ジャーン!引いたのは:", "짜잔! 뽑은 결과:"),
            resultTitle:    L("🏥 診斷完成", "🏥 Diagnosis Complete", "🏥 診断完了", "🏥 진단 완료"),
            weakest:        L("最需要留意:", "Needs attention: ", "要注意:", "주의 필요: "),
            wallTitle:      L("🧱 匿名發洩牆", "🧱 Anonymous Vent Wall", "🧱 匿名ぼやきの壁", "🧱 익명 하소연 벽"),
            wallPlaceholder:L("今天被什麼氣到了?", "What annoyed you today?", "今日は何にイラッとした?", "오늘 뭐 때문에 화났어?"),
            wallSend:       L("丟出去", "Send", "送信", "보내기"),
            againBtn:       L("重新掛號", "Start Over", "もう一度", "다시 시작")
        };

        const quizData = [
            { type: "normal",
              q: L("早上鬧鐘響了,但你昨天熬夜趕專題,現在的你會:", "Your alarm goes off, but you stayed up late on your project. You:", "目覚ましが鳴ったけど、昨夜は課題で徹夜…。あなたは:", "알람이 울렸지만 어젯밤 과제로 밤을 새웠다. 당신은:"),
              options: [
                { t: L("直接按掉,催眠自己教授今天會請假", "Hit snooze and convince yourself the professor will call in sick", "スヌーズを押して、先生が休講になると自分に言い聞かせる", "알람을 끄고 교수님이 휴강하길 스스로 세뇌한다"), scores: { "日常": -2, "放鬆": 3 } },
                { t: L("痛苦地爬起來,靠三杯黑咖啡續命", "Drag yourself up and survive on three black coffees", "苦しみながら起き上がり、ブラックコーヒー3杯で乗り切る", "괴롭게 일어나 블랙커피 세 잔으로 버틴다"), scores: { "效率": 3, "心態": -2 } },
                { t: L("傳訊息跟同學求救「幫點名」", "Text a classmate to cover your attendance", "クラスメイトに「出席お願い」とメッセージを送る", "친구에게 \"대리 출석 부탁해\"라고 문자한다"), scores: { "社交": 2, "心態": 1 } }
              ] },
            { type: "draw",
              q: L("來到廟宇,面對未知的死線,請誠心搖動籤筒抽出今日運勢:", "At the temple, facing a mysterious deadline, shake the fortune stick sincerely to draw today's luck:", "神社で、見えない締切を前に、心を込めておみくじを引こう:", "사원에서 알 수 없는 마감 앞에, 정성껏 운세 통을 흔들어 오늘의 운세를 뽑아보세요:"),
              options: [
                { t: L("【大吉】教授突然宣布延期交件!", "【Great Luck】The professor suddenly extends the deadline!", "【大吉】教授が突然、提出期限を延ばしてくれた!", "【대길】교수님이 갑자기 제출 기한을 연장해 주셨다!"), scores: { "放鬆": 5 } },
                { t: L("【中吉】剛好找到神級開源程式碼,無痛套用。", "【Good Luck】You find perfect open-source code and drop it in painlessly.", "【中吉】神レベルのオープンソースコードを発見、楽々流用できた。", "【중길】완벽한 오픈소스 코드를 발견해 아무 고통 없이 갖다 썼다."), scores: { "效率": 4 } },
                { t: L("【大凶】電腦無預警死機,而且你沒存檔。", "【Terrible Luck】Your computer crashes without warning, and you didn't save.", "【大凶】パソコンが突然フリーズ、しかも保存していない。", "【대흉】컴퓨터가 갑자기 꺼졌는데 저장도 안 했다."), scores: { "心態": -5 } }
              ] },
            { type: "normal",
              q: L("突然多出一個小時空檔,你會怎麼做?", "You suddenly get a free hour. What do you do?", "急に1時間ぽっかり空いた。どうする?", "갑자기 한 시간이 비었다. 무엇을 할까?"),
              options: [
                { t: L("趕快把今天的待辦事項處理掉", "Knock out today's to-do list", "今日のToDoを一気に片付ける", "오늘 할 일을 얼른 처리한다"), scores: { "效率": 3, "日常": 1 } },
                { t: L("躺著滑手機,享受難得的空檔", "Lie down and scroll your phone, enjoying the rare break", "寝転んでスマホをいじり、貴重な空き時間を満喫する", "누워서 폰을 보며 귀한 여유를 즐긴다"), scores: { "放鬆": 3 } },
                { t: L("找朋友聊天", "Chat with a friend", "友達とおしゃべりする", "친구와 수다를 떤다"), scores: { "社交": 3, "心態": 1 } }
              ] },
            { type: "normal",
              q: L("遇到完全不會的問題時,你通常會?", "When you hit a problem you totally can't solve, you usually:", "まったく分からない問題にぶつかったら、たいてい:", "전혀 모르는 문제를 만나면 보통:"),
              options: [
                { t: L("自己研究到搞懂", "Dig into it alone until you get it", "分かるまで自分で調べ抜く", "이해할 때까지 혼자 파고든다"), scores: { "效率": 2, "職涯": 3 } },
                { t: L("先放著,等心情好一點再處理", "Leave it for now and come back when you feel better", "ひとまず放置して、気分が良くなってから取り組む", "일단 두고 기분이 나아지면 다시 한다"), scores: { "放鬆": 2, "心態": 2 } },
                { t: L("找朋友或同學一起討論", "Discuss it with friends or classmates", "友達やクラスメイトと一緒に相談する", "친구나 동기와 함께 의논한다"), scores: { "社交": 2, "職涯": 1 } }
              ] },
            { type: "draw",
              q: L("最後一關,請再次把命運交給賽博籤筒!", "Final round. Entrust your fate to the cyber fortune stick once more!", "最終関門。もう一度、運命をサイバーおみくじに委ねよう!", "마지막 관문. 다시 한번 사이버 운세 통에 운명을 맡겨보세요!"),
              options: [
                { t: L("【神籤】突然獲得神秘力量,今天效率爆表!", "【Divine Luck】A mysterious power surges in. Your efficiency is off the charts today!", "【神籤】謎の力が湧き、今日の効率は爆上がり!", "【신첨】신비한 힘이 솟아 오늘 효율이 폭발한다!"), scores: { "效率": 5, "職涯": 2 } },
                { t: L("【平籤】今天適合好好休息,明天再戰。", "【Plain Luck】Today is for resting. Fight again tomorrow.", "【平籤】今日はしっかり休んで、明日また戦おう。", "【평첨】오늘은 푹 쉬고 내일 다시 싸우자."), scores: { "放鬆": 4, "心態": 2 } },
                { t: L("【凶籤】打開電腦發現昨天的程式全部消失。", "【Bad Luck】You open your laptop and find yesterday's code has vanished.", "【凶籤】パソコンを開くと、昨日のコードが全部消えていた。", "【흉첨】노트북을 켜니 어제 짠 코드가 전부 사라졌다."), scores: { "心態": -5, "日常": -2 } }
              ] }
        ];

        // ---------- 第二段:處方、氣泡詞庫、發洩牆預設留言,同樣換成四語物件 ----------
        const prescriptionDB = {
            "日常": L("今天不用早睡了,反正你也不會睡。去買杯手搖飲,全糖去冰,糖分才是良藥。", "Skip the early bedtime, you won't sleep anyway. Grab a bubble tea, full sugar, no ice. Sugar is the real medicine.", "今日は早寝しなくていい、どうせ眠れない。フルシュガー氷なしのタピオカドリンクを買おう。糖分こそ良薬。", "오늘은 일찍 잘 필요 없다, 어차피 못 잔다. 당 100% 얼음 없는 버블티를 사 마시자. 당분이 곧 약이다."),
            "效率": L("把手機關機兩小時,看著天花板發呆。這也是一種產出,只是產出是「無」。", "Turn off your phone for two hours and stare at the ceiling. It's a kind of output, the output is \"nothing.\"", "スマホの電源を2時間切って、天井をぼんやり眺めよう。これも立派なアウトプット。ただし成果は「無」。", "휴대폰을 두 시간 꺼 두고 천장을 멍하니 바라보자. 이것도 산출물이다, 결과물이 '무'일 뿐."),
            "社交": L("取消今晚所有的局,你需要跟棉被獨處的 me time,誰傳訊息都當作沒看到。", "Cancel every plan tonight. You need me-time alone with your blanket. Pretend you saw no messages.", "今夜の予定は全部キャンセル。必要なのは布団と二人きりのミータイム。誰からのメッセージも見なかったことに。", "오늘 밤 약속은 전부 취소하자. 이불과 단둘이 보내는 me time이 필요하다. 누가 연락해도 못 본 척."),
            "心態": L("對著鏡子大喊三聲「我已經很努力了」,然後去睡覺,明天的事明天再說。", "Shout \"I've already tried so hard\" at the mirror three times, then go to bed. Tomorrow's problems can wait.", "鏡に向かって「私はもう十分がんばった」と3回叫んで寝よう。明日のことは明日考える。", "거울을 보고 \"나 충분히 열심히 했어\"를 세 번 외치고 자자. 내일 일은 내일 생각하자."),
            "放鬆": L("去便利商店走一圈,什麼都不買,純欣賞架上的零食,療癒效果驚人。", "Walk a lap around a convenience store, buy nothing, just admire the snacks. The healing effect is astonishing.", "コンビニを一周して、何も買わず、棚のお菓子を眺めるだけ。癒やし効果は絶大。", "편의점을 한 바퀴 돌며 아무것도 사지 말고 진열된 과자만 구경하자. 힐링 효과가 놀랍다."),
            "職涯": L("打開求職網站逛十分鐘,看看別人的職缺內容,你會突然覺得現在好像還行。", "Browse a job site for ten minutes. Seeing other openings might make your current situation look fine.", "求人サイトを10分眺めてみよう。他の求人を見ると、今の状況も悪くない気がしてくる。", "구직 사이트를 10분 둘러보자. 다른 공고를 보면 지금도 나쁘지 않다는 생각이 든다.")
        };

        const rageWords = [
            L("論文死線", "Thesis deadline", "論文の締切", "논문 마감"),
            L("明天要交報告", "Report due tomorrow", "明日提出のレポート", "내일 제출할 보고서"),
            L("長輩群組訊息", "Family group-chat messages", "親戚グループのメッセージ", "어른들 단톡방 메시지"),
            L("存款餘額不足", "Low bank balance", "残高不足", "잔액 부족"),
            L("專案又改需求", "Requirements changed again", "また仕様変更", "프로젝트 요구사항 또 변경")
        ];

        // 發洩牆:預設三句有四語版本,使用者自己輸入的就是單純的字串
        let complaints = [
            L("教授說報告延期,結果延到明天早上八點", "The professor said the report was postponed, to 8 a.m. tomorrow", "先生がレポート延期と言ったのに、延びたのは明日の朝8時まで", "교수님이 보고서를 연기해 주셨는데 내일 아침 8시로 연기됐다"),
            L("咖啡喝到第三杯,手已經開始抖了", "Three coffees in and my hands are already shaking", "コーヒー3杯目で、もう手が震えてきた", "커피 세 잔째인데 벌써 손이 떨린다"),
            L("同組的人已經三天沒讀訊息了", "My groupmate hasn't read a message in three days", "グループの人が3日間メッセージを既読にしていない", "조원이 사흘째 메시지를 읽지 않고 있다")
        ];

        // ---------- 畫面切換與語言 ----------
        let screen = "welcome";
        function showScreen(name) {
            screen = name;
            ["welcome", "breathe", "quiz", "result"].forEach(s => $(s + "-screen").classList.toggle("hidden", s !== name));
            $("langSwitch").classList.toggle("hidden", name === "quiz");   // 作答途中不顯示語言按鈕
            if (name !== "breathe") stopBreathe();
            if (name === "quiz") startRage(); else stopRage();
        }

        function applyLanguage(l) {
            lang = l;
            document.documentElement.lang = { zh: "zh-Hant", en: "en", ja: "ja", ko: "ko" }[l];
            document.querySelectorAll("[data-i18n]").forEach(el => el.innerText = t(UI[el.dataset.i18n]));
            document.querySelectorAll("[data-i18n-ph]").forEach(el => el.placeholder = t(UI[el.dataset.i18nPh]));
            document.querySelectorAll(".lang-btn").forEach(b => b.classList.toggle("active", b.dataset.lang === l));

            // 有語言按鈕的畫面,依目前停留的位置決定要重畫哪一塊
            if (screen === "result") renderResult();
            if (screen === "breathe") tickBreath();
        }
        document.querySelectorAll(".lang-btn").forEach(b => b.addEventListener("click", () => applyLanguage(b.dataset.lang)));

        // ---------- 第三段:呼吸文字直接讀動畫自己的時鐘 ----------
        let breatheTimer = null;
        let breatheStart = 0;

        function getBreathState() {
            const circle = $("breatheCircle");
            const anim = circle.getAnimations ? circle.getAnimations()[0] : null;
            let ms;
            if (anim && anim.currentTime != null) ms = Number(anim.currentTime);
            else ms = performance.now() - breatheStart;      // 備援:不支援時退回自己計時
            return (ms % 4000) < 2000 ? "in" : "out";        // 前 2 秒放大 = 吸氣,後 2 秒縮小 = 吐氣
        }
        function tickBreath() {
            const text = t(getBreathState() === "in" ? UI.breathIn : UI.breathOut);
            const el = $("breatheCircle");
            if (el.textContent !== text) el.textContent = text;
        }
        function startBreathe() {
            showScreen("breathe");
            breatheStart = performance.now();
            clearInterval(breatheTimer);
            breatheTimer = setInterval(tickBreath, 120);     // 沒有結束條件,離開畫面才清掉
            tickBreath();
        }
        function stopBreathe() { clearInterval(breatheTimer); breatheTimer = null; }

        // ---------- 測驗流程 ----------
        let current = 0, userScores = {}, locked = false;

        function startQuiz() {
            current = 0; locked = false;
            userScores = {};
            dimensions.forEach(d => userScores[d] = 0);
            showScreen("quiz");
            renderQuestion();
        }

        function renderQuestion() {
            const q = quizData[current];
            $("progress").innerText = t(UI.qLabel)
                .replace("{n}", current + 1)
                .replace("{total}", quizData.length);
            $("question").innerText = t(q.q);
            $("drawResult").innerText = "";

            const box = $("options");
            box.innerHTML = "";
            if (q.type === "draw") {
                const btn = document.createElement("button");
                btn.className = "draw-btn";
                btn.innerText = t(UI.drawBtn);
                btn.onclick = () => drawStick(q);
                box.appendChild(btn);
            } else {
                q.options.forEach((opt, i) => {
                    const btn = document.createElement("button");
                    btn.className = "option-btn";
                    btn.innerText = "ABC"[i] + ". " + t(opt.t);   // 字母由程式補上,不寫在文字裡
                    btn.onclick = () => choose(opt.scores);
                    box.appendChild(btn);
                });
            }
        }

        // 抽到的籤詩,直接取目前語言的那一句顯示
        function drawStick(q) {
            if (locked) return;
            locked = true;
            const picked = q.options[Math.floor(Math.random() * q.options.length)];
            $("drawResult").innerText = t(UI.drawResult) + "\n" + t(picked.t);
            setTimeout(() => { locked = false; choose(picked.scores); }, 1800);
        }

        function choose(scores) {
            for (const dim in scores) userScores[dim] += scores[dim];
            current++;
            if (current < quizData.length) renderQuestion();
            else { showScreen("result"); renderResult(); }
        }

        // 每個維度的範圍由程式掃題庫算出來
        function getDimensionRange(dim) {
            let min = 0, max = 0;
            quizData.forEach(q => {
                const c = q.options.map(o => o.scores[dim] || 0);
                min += Math.min(...c);
                max += Math.max(...c);
            });
            return { min, max };
        }
        function toPercent(v, min, max) {
            if (max === min) return 50;
            return Math.max(0, Math.min(100, Math.round((v - min) / (max - min) * 100)));
        }

        // ---------- 結果頁 ----------
        function renderResult() {
            const scored = dimensions.map(dim => {
                const { min, max } = getDimensionRange(dim);
                return { dim, pct: toPercent(userScores[dim], min, max) };
            });
            $("dimList").innerHTML = scored.map(s => `
                <div class="dim-row">
                    <div class="dim-head"><span>${dimensionNames[s.dim][lang]}</span><b>${s.pct}</b></div>
                    <div class="dim-track"><div class="dim-fill" style="width:${s.pct}%"></div></div>
                </div>`).join("");

            // 用歸一化後的分數找最弱維度,比原始分數公平
            const weakest = scored.reduce((m, c) => c.pct < m.pct ? c : m).dim;
            $("rxTitle").innerText = "💊 " + t(UI.weakest) + dimensionNames[weakest][lang];
            $("rxText").innerText = t(prescriptionDB[weakest]);
            renderWall();
        }

        function renderWall() {
            const list = $("wallList");
            list.innerHTML = "";
            complaints.forEach(item => {
                const div = document.createElement("div");
                div.className = "wall-card";
                // 物件 = 預設的四語留言;字串 = 使用者自己輸入的,原樣顯示。用 textContent 避免塞進 HTML
                div.textContent = "💬 " + (typeof item === "string" ? item : t(item));
                list.appendChild(div);
            });
        }
        function submitComplaint() {
            const input = $("wallInput");
            const text = input.value.trim();
            if (!text) return;
            complaints.unshift(text);
            input.value = "";
            renderWall();
        }
        $("wallInput").addEventListener("keydown", e => { if (e.key === "Enter") submitComplaint(); });

        function restart() { showScreen("welcome"); }

        // ---------- 惡言消滅器:氣泡產生的當下,取目前語言的詞 ----------
        let rageTimer = null;
        function spawnRage() {
            const idx = Math.floor(Math.random() * rageWords.length);
            const el = document.createElement("div");
            el.className = "rage-target";
            el.textContent = "🔥 " + t(rageWords[idx]);
            el.style.left = (Math.random() * 70 + 10) + "%";
            el.onclick = () => { el.style.transform = "scale(1.5)"; el.style.opacity = "0"; setTimeout(() => el.remove(), 200); };
            document.body.appendChild(el);
            setTimeout(() => el.remove(), 8000);
        }
        function startRage() { if (!rageTimer) rageTimer = setInterval(spawnRage, 2000); }
        function stopRage() {
            clearInterval(rageTimer); rageTimer = null;
            document.querySelectorAll(".rage-target").forEach(el => el.remove());
        }

        applyLanguage("zh");
    </script>
</body>
</html>

存成 .html 打開,先在首頁選好語言,進到呼吸畫面先放著不動,看字跟著圓圈一吸一吐一直輪下去,這裡也可以隨時切換語言;開始作答之後,語言按鈕會收起來,題目、選項、抽籤結果、飄過去的氣泡都用你選好的語言顯示;答完之後在結果頁再切一次語言,處方跟發洩牆也會跟著換。


幾個修補時順手發現的事

  • 使用者留言改用 textContent 顯示
    Day 25 的發洩牆是用 innerHTML 把留言塞進畫面,使用者打什麼,瀏覽器就當成 HTML 解讀。在自己的小專題裡風險不大,但養成這個習慣比較安心,所以今天順手改成 textContent,使用者輸入的任何字元都只會被當成純文字。

  • t() 找不到對應語言時,退回中文
    obj[lang] || obj.zh 這個寫法是留給「某個語言漏翻」的情況。真的漏了,畫面上至少顯示中文,不會出現空白或 undefined。

  • getAnimations() 取到的 currentTime 有可能是 null
    動畫還沒開始、或是元素暫時不在畫面上時就會這樣,所以取值前先判斷,判斷不過就退回用 performance.now() 自己計時。這條備援路線平常用不到,留著是為了不支援這個 API 的瀏覽器。

  • 最弱維度改用歸一化後的分數去找
    Day 14 的處方是用原始分數挑最低的維度,現在每個維度已經有站在同一把尺上的 0 到 100 分,用它來比較比較公平,不會有某個維度只是因為題庫給的分數範圍比較窄,就被誤判成最弱。


開發心得與設計考量

  • 多語系做得到哪裡,要看資料長什麼樣子
    Day 19 的語言表是為了「頁面上寫死的文字」設計的,碰到題庫這種大量資料就不夠用了。今天把每段文字都做成四語物件之後,才算真正搞懂:不論是畫面上的文字、還是資料裡的文字,只要在顯示的那一刻才決定語言,就能隨時切換。

  • 內容跟裝飾要分開放
    選項前面的 A、B、C 本來是跟文字黏在一起的,換成四種語言就得抄四次。拆開之後,文字只負責內容,字母由程式依序號補上,之後想改成 1、2、3 也只要動一個地方。這跟前面反覆出現的「資料跟呈現分開」是同一個道理,只是這次輪到一個小小的字母。

  • 把兩個時鐘合併成一個,比校準兩個時鐘容易
    呼吸的問題,我原本想過把計時器的間隔調得更精準,後來發現再準也是兩個時鐘,遲早會漂移。直接讀動畫自己的時間,是整個問題最乾脆的解法,因為根本沒有第二個時鐘可以對不上。


今日結語與明日預告

今天分三段處理了上線前最後三個破綻:把題目、選項、籤詩全部換成四語物件;處方、氣泡詞庫、發洩牆預設留言一併接上,在呼吸過場與結果頁切換語言,畫面都會立刻跟著換;呼吸過場的文字改成直接讀動畫的時鐘,不會再中途斷氣。

這次的修補完成之後,診所裡能想到的洞,我手上的清單都劃掉了。

剩下的一件事,從 Day 1 就寫在專案初衷裡:點進網址就能用。

明天 Day 29,我們要替診所掛上真正的門牌,把整個專題推上 GitHub Pages,換成一串任何人都能點開的網址。

明天掛牌開業!!


上一篇
Day 27:院內自主健檢,重構、相容、效能一次掃完
系列文
賽博紓壓診所:從零打造純前端心理測驗與互動紓壓網頁 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言