上一篇總結寫到一半,我手癢把語言切換器拿出來重新戳了幾輪,想說順便檢查一下有沒有漏網之魚。
結果抓到兩個問題,但仔細一查才發現事情比我想的嚴重。
第一個,是雷達圖下面那排維度說明文字。測驗做完,畫面上除了那張六角形的圖之外,下面還會列一排:
日常 72 效率 45 社交 90 心態 30 放鬆 88 職涯 50
這排字是測驗結束之後才由 JavaScript 動態組出來、塞進畫面的,跟 Day 19 做的首頁靜態文字完全是兩回事。而且更麻煩的是,「日常」「效率」這些維度名稱,本身就是資料的 key,從 Day 11 開始就是中文字串,直接存在 userScores 物件裡。切到英文模式,這排字照樣原封不動印出一串中文,比雷達圖上畫的標籤還要明顯。
我原本以為只有這排字有問題,結果順著檢查下去,才發現根本不只這一處:抽籤結果的提示文字、電池耗損率的狀態說明(「🟢 電量充足」那幾句)、病歷本列表裡的「缺乏:○○」,全部都是測驗跑到一半或結束後才動態產生的文字,沒有一個接上語言表。之前修 i18n 的時候,我只顧著處理頁面一載入就存在的固定文字,完全漏掉了這整批「之後才冒出來」的內容。
所以今天的範圍要比原本想的大很多,不只是雷達圖:
把所有還沒接上語言系統的動態文字,一次補齊,順便把日夜模式初始狀態又跑掉的老問題,這次真正修乾淨。
Day 20 教會我一件事:動態文字要用「查表」,不能用「一次性填表」。但這次又多了一層麻煩。
之前查表的情境,是 key 固定(像 breathIn、breathOut),不同語言去查對應的翻譯就好。但維度名稱不一樣——「日常」這個字本身,同時是資料的 key,也是要顯示給人看的文字。
如果直接把 userScores 的 key 改成英文,整個計分系統、病歷本存檔格式全部都要跟著動,風險太大。所以正確做法是:保留 userScores 用中文當 key 不動,另外準備一張「維度名稱翻譯表」,顯示的時候用中文 key 去查,查出來的才是使用者看到的文字:
const dimensionNames = {
"日常": { zh: "日常", en: "Daily", ja: "日常", ko: "일상" },
"效率": { zh: "效率", en: "Efficiency", ja: "効率", ko: "효율" },
"社交": { zh: "社交", en: "Social", ja: "社交", ko: "사교" },
"心態": { zh: "心態", en: "Mindset", ja: "メンタル", ko: "멘탈" },
"放鬆": { zh: "放鬆", en: "Relax", ja: "リラックス", ko: "휴식" },
"職涯": { zh: "職涯", en: "Career", ja: "キャリア", ko: "커리어" }
};
渲染的時候,用內部的中文 key 去查表拿顯示文字:
const displayName = dimensionNames[dim][currentLang];
這樣資料結構完全不用動,只是多了一層「內部名字」跟「顯示名字」的轉換。
而所有動態產生的區塊,共通的修法都一樣:不要把渲染邏輯跟語言綁死,而是把渲染包成一個函式,語言切換的時候重新呼叫一次。
function applyLanguage(lang) {
currentLang = lang;
fillStaticText(lang); // 固定文字:一次性填表
renderRadarLegend(); // 動態文字:重新查表渲染
renderStatus();
renderHistory();
}
這樣不管使用者是在測驗途中、結果頁、還是病歷本畫面切換語言,畫面都會用當下的語言重新畫一次,不會有任何一塊卡在舊語言裡。
另一個問題是舊病重犯。之前的寫法大概是這樣:
let isDark = false; // 預設亮色
然後切換按鈕的文字也寫死顯示「亮色」。但如果使用者的系統本身就是暗色模式,瀏覽器會先套用 prefers-color-scheme: dark,頁面背景確實變暗了,可是因為 JavaScript 那邊 isDark 還是寫死的 false,切換按鈕仍然顯示「☀️ 亮色」,兩邊狀態從一開始就是分裂的。
正確做法是:一開始就去問瀏覽器「使用者系統現在是什麼模式」,然後讓 JavaScript 的狀態、畫面的 data-theme、按鈕的文字,三個東西同一時間一起決定,而不是各自寫死。
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
let isDark = prefersDark;
初始化的時候,用這個 isDark 去同步設定畫面跟按鈕,而不是分開寫。
今天的示範把測驗結果頁、電池狀態、日夜模式初始化全部接起來,示範「動態文字查表」跟「初始主題同步」這兩件事:
<!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>
:root {
--bg: #e3f2fd; --card: #ffffff; --primary: #1e88e5; --text: #263238; --muted: #546e7a;
}
[data-theme="dark"] {
--bg: #0d1117; --card: #161b22; --primary: #4ade80; --text: #e6edf3; --muted: #8b949e;
}
body {
font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
background: var(--bg); color: var(--text);
display: flex; justify-content: center; align-items: center;
min-height: 100vh; margin: 0; padding: 20px;
transition: background 0.3s, color 0.3s;
}
.card {
background: var(--card); padding: 25px; border-radius: 20px;
box-shadow: 0 12px 30px rgba(0,0,0,0.2); width: 100%; max-width: 480px;
}
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.lang-switch { display: flex; gap: 6px; }
.lang-btn, .theme-btn {
background: var(--bg); border: 2px solid transparent; border-radius: 50px;
padding: 6px 12px; font-size: 0.8rem; cursor: pointer; color: var(--text); transition: 0.2s;
}
.lang-btn.active { background: var(--primary); color: white; border-color: var(--primary); }
h2 { color: var(--primary); margin: 0 0 15px; text-align: center; }
canvas { display: block; width: 100%; max-width: 300px; height: auto; margin: 0 auto 10px; }
#legend { list-style: none; padding: 0; margin: 0 0 15px; }
#legend li {
display: flex; justify-content: space-between;
padding: 6px 10px; font-size: 0.88rem; border-bottom: 1px dashed var(--muted);
}
#legend b { color: var(--primary); }
.status-box {
background: var(--bg); border-radius: 12px; padding: 12px;
font-size: 0.9rem; font-weight: bold; color: var(--primary); margin-bottom: 10px;
}
.btn {
background: var(--primary); color: white; border: none; border-radius: 50px;
padding: 10px 24px; font-size: 0.9rem; cursor: pointer; width: 100%; margin-top: 6px;
}
</style>
</head>
<body>
<div class="card">
<div class="top-bar">
<div class="lang-switch">
<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>
<button class="theme-btn" id="themeBtn"></button>
</div>
<h2 data-i18n="resultTitle"></h2>
<canvas id="radarChart" width="300" height="300"></canvas>
<!-- 今天的重點:這排是測驗結束後才動態產生的,之前完全沒接上語言系統 -->
<ul id="legend"></ul>
<div class="status-box" id="statusBox"></div>
<button class="btn" onclick="reroll()" data-i18n="rerollBtn"></button>
</div>
<script>
// 維度分數照舊用中文當 key,不動它,純粹拿來當內部資料
let userScores = { "日常": 72, "效率": 45, "社交": 90, "心態": 30, "放鬆": 88, "職涯": 50 };
// 固定文字
const i18n = {
resultTitle: { zh: "🏥 診斷結果", en: "🏥 Diagnosis", ja: "🏥 診断結果", ko: "🏥 진단 결과" },
rerollBtn: { zh: "重新產生一組分數", en: "Reroll scores", ja: "スコアを再生成", ko: "점수 다시 뽑기" },
themeLight: { zh: "☀️ 亮色", en: "☀️ Light", ja: "☀️ ライト", ko: "☀️ 라이트" },
themeDark: { zh: "🌙 暗色", en: "🌙 Dark", ja: "🌙 ダーク", ko: "🌙 다크" }
};
// 維度名稱翻譯表:中文 key → 四種語言的顯示文字
const dimensionNames = {
"日常": { zh: "日常", en: "Daily", ja: "日常", ko: "일상" },
"效率": { zh: "效率", en: "Efficiency", ja: "効率", ko: "효율" },
"社交": { zh: "社交", en: "Social", ja: "社交", ko: "사교" },
"心態": { zh: "心態", en: "Mindset", ja: "メンタル", ko: "멘탈" },
"放鬆": { zh: "放鬆", en: "Relax", ja: "リラックス", ko: "휴식" },
"職涯": { zh: "職涯", en: "Career", ja: "キャリア", ko: "커리어" }
};
let currentLang = "zh";
function fillStaticText(lang) {
document.querySelectorAll("[data-i18n]").forEach(el => {
const entry = i18n[el.dataset.i18n];
if (entry && entry[lang]) el.innerText = entry[lang];
});
}
function renderLegend() {
const list = document.getElementById("legend");
list.innerHTML = Object.entries(userScores).map(([dim, val]) => {
const name = dimensionNames[dim][currentLang];
return `<li><span>${name}</span><b>${val}</b></li>`;
}).join("");
}
function getAvgDrain() {
const values = Object.values(userScores);
const avg = values.reduce((a, b) => a + b, 0) / values.length;
return Math.round(100 - avg);
}
function renderStatus() {
const drain = getAvgDrain();
const level = drain <= 30 ? "statusGood" : drain <= 60 ? "statusMid" : drain <= 85 ? "statusLow" : "statusCritical";
const statusText = {
statusGood: { zh: "🟢 電量充足,今天狀態不錯。", en: "🟢 Battery full, you're doing great.", ja: "🟢 バッテリー十分、今日は調子がいい。", ko: "🟢 배터리 충분, 컨디션 좋아요." },
statusMid: { zh: "🟡 省電模式,別再接新任務了。", en: "🟡 Power saving mode, no new tasks please.", ja: "🟡 省電力モード、新しいタスクはNG。", ko: "🟡 절전 모드, 새 일 받지 마세요." },
statusLow: { zh: "🟠 低電量警告,快去充電。", en: "🟠 Low battery warning, go recharge.", ja: "🟠 バッテリー低下、充電してください。", ko: "🟠 배터리 부족, 충전하세요." },
statusCritical: { zh: "🔴 即將關機,放下手邊的事。", en: "🔴 About to shut down, stop everything.", ja: "🔴 シャットダウン寸前、手を止めて。", ko: "🔴 종료 직전, 하던 일 멈추세요." }
};
document.getElementById("statusBox").innerText = statusText[level][currentLang];
}
function applyLanguage(lang) {
currentLang = lang;
fillStaticText(lang);
renderLegend();
renderStatus();
applyThemeLabel();
document.querySelectorAll(".lang-btn").forEach(b => b.classList.toggle("active", b.dataset.lang === lang));
}
document.querySelectorAll(".lang-btn").forEach(btn => {
btn.addEventListener("click", () => applyLanguage(btn.dataset.lang));
});
function reroll() {
Object.keys(userScores).forEach(dim => userScores[dim] = Math.floor(Math.random() * 101));
renderLegend();
renderStatus();
drawRadar();
}
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
let isDark = prefersDark;
function applyTheme() {
if (isDark) document.documentElement.setAttribute("data-theme", "dark");
else document.documentElement.removeAttribute("data-theme");
}
function applyThemeLabel() {
document.getElementById("themeBtn").innerText =
isDark ? i18n.themeDark[currentLang] : i18n.themeLight[currentLang];
}
document.getElementById("themeBtn").addEventListener("click", () => {
isDark = !isDark;
applyTheme();
applyThemeLabel();
drawRadar();
});
function drawRadar() {
const canvas = document.getElementById("radarChart");
const ctx = canvas.getContext("2d");
const dims = Object.keys(userScores);
const values = Object.values(userScores);
const cx = canvas.width / 2, cy = canvas.height / 2, radius = 100, total = dims.length;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const lineColor = isDark ? "#30363d" : "#cfd8dc";
const fillColor = isDark ? "#4ade80" : "#1e88e5";
ctx.strokeStyle = lineColor;
for (let level = 1; level <= 4; level++) {
ctx.beginPath();
for (let i = 0; i < total; i++) {
const a = -Math.PI / 2 + (Math.PI * 2 * i) / total;
const r = radius * level / 4;
const x = cx + Math.cos(a) * r, y = cy + Math.sin(a) * r;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
}
ctx.beginPath();
values.forEach((v, i) => {
const a = -Math.PI / 2 + (Math.PI * 2 * i) / total;
const r = radius * v / 100;
const x = cx + Math.cos(a) * r, y = cy + Math.sin(a) * r;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.closePath();
ctx.fillStyle = fillColor + "33";
ctx.fill();
ctx.strokeStyle = fillColor;
ctx.lineWidth = 2;
ctx.stroke();
}
applyTheme();
applyLanguage("zh");
drawRadar();
</script>
</body>
</html>
再存成 .html 打開,可以先看一下主題按鈕有沒有跟你電腦系統目前的明暗模式對上,再切幾次語言,雷達圖下面那排維度名稱跟電池狀態說明會立刻跟著換,不會再停在中文。
維度名稱另外做翻譯表,不直接改 userScores 的 keyuserScores 的 key 從 Day 11 就被計分、歸一化、localStorage 存檔一路沿用到現在,改 key 連動範圍太大,而且舊存檔的 key 還是中文,新舊資料會對不起來。讓資料層不動,只在顯示那一層多包一層翻譯最安全。
renderLegend()、renderStatus() 要在 applyLanguage() 裡重新呼叫
這其實就是 Day 20「查表」邏輯的延伸。語言一變,直接重新跑一次渲染,不用額外記什麼特殊狀態,比呼吸過場那個案例單純多了。
prefersDark 只在載入時抓一次,使用者中途改系統設定不會自動跟著變
今天只處理「進站當下對不上」這個最明顯的問題。如果要做到「使用中途系統切成暗色、網頁自動跟著變」,還要加 change 事件監聽,先留到之後有需要再補。
主題按鈕的文字也要走 i18n
原本「☀️ 亮色」「🌙 暗色」是寫死中文,跟雷達圖標籤是同一種病。所以 applyThemeLabel() 也接進語言表,確保不管哪個語言、哪個主題,按鈕上的字都是對的。
「做完」跟「做完整」是兩回事
首頁文字切語言很順利,我就以為 i18n 已經收尾了,其實只處理了頁面一載入就存在的文字,完全沒想到後面陸續加的功能——電池狀態、雷達圖圖例、病歷本列表——每一個都是測驗過程中動態冒出來的,寫的當下竟然沒想到要接回語言系統,實在太粗心大意了T.T
之後的習慣:動態塞文字進畫面,第一件事就先想語言表
只要是會動態產生 DOM 的地方,很容易悄悄漏接 i18n。之後加新功能,我大概會養成「先想這段文字怎麼接語言表,再動手寫」的順序,而不是等到最後才回頭補。
日夜模式的 bug 也是同一種教訓
第一次修的時候只處理了「點擊按鈕之後」的狀態切換,沒處理「頁面剛載入那一瞬間」該是什麼狀態,等於只修了一半。這次把初始化也接上 prefers-color-scheme,系統設定、畫面主題、按鈕文字三個狀態終於真正綁在一起。
今天我們把所有漏接語言系統的動態文字一次補齊,包括雷達圖下方的維度圖例、電池狀態說明、日夜模式切換按鈕的文字,還順手把日夜模式初始狀態跟系統設定對不上的老問題也真的修好,讓整間診所不管用哪種語言、哪種明暗模式打開,看起來都是完整的一致狀態,不會再有露出破綻的半成品。
結果頁現在總算乾淨又完整了,但盯著那張診斷報告看久了,還是有東西可以加。
我想讓這個專題再更有趣一點。
真正的診所,看完診不是應該要蓋個章嗎?
既然都叫「賽博紓壓診所」了,病歷上怎麼能沒有醫生那種用力一蓋、有點歪、還帶點生鏽感的戳章?
所以明天 Day 24,我們要用 CSS 3D 變形,做一個惡搞病歷戳章動畫:讓一枚章從畫面上方重重落下,帶點旋轉跟晃動,蓋在診斷結果上,補上那股真實診所的儀式感。
明天,我要讓每一份病歷都蓋上官方認證的爛章!