上一篇讓診所學會開處方之後,我自己又玩了好幾輪。
測完、看處方、笑一下,然後按 F5。
畫面回到首頁,一切歸零。
剛剛那張處方籤、那個「心態 -5」的診斷,全部消失,好像什麼事都沒發生過。
這讓我有點心虛。
因為我們做的是「診所」。
診所最基本的東西是什麼?
病歷本。
你去看牙醫,醫生不會每次都問你「請問你是第一次來嗎?」。但我們的診所現在就是這樣,每個人每次進來都是初診的狀態。
所以今天不加新的視覺效果,也不加新題目,只做一件事:
讓瀏覽器記住使用者的看診紀錄。
而且不用後端、不用資料庫、不用登入。這個專題從第一天就說好要純前端,所以今天要請出 localStorage。
localStorage 可以想成瀏覽器幫每個網站準備的一個小置物櫃。
用法簡單到有點不可思議:
// 存
localStorage.setItem("名字", "值");
// 取
localStorage.getItem("名字");
// 刪
localStorage.removeItem("名字");
你關掉分頁、重新整理、甚至關掉瀏覽器,東西還是會在裡面。
但它有一個很大的限制,我一開始就踩到了:
它只能存字串。
而我們想存的是一份病歷,裡面有時間、六個維度的分數、最弱的維度,明明是個物件。
如果直接塞進去,會變成這樣:
localStorage.setItem("record", { "日常": 5 });
// 取出來的結果是:
// "[object Object]"
沒錯,存進去的是一個「看不懂的東西」。
解法是兩個很好用的搭檔:
JSON.stringify(物件) // 物件 → 字串(存的時候用)
JSON.parse(字串) // 字串 → 物件(取的時候用)
存之前先壓成字串,取出來再還原。這樣一整包病歷就可以順利進出倉庫。
另外,我想存的不是「一筆」而是「很多筆」,所以整個倉庫裡只放一個 key,裡面放的是一個陣列,每次看診就把新病歷塞到最前面。
今天一樣抽離其他介面,只留下病歷本的邏輯。我加了一顆「模擬一次看診」的按鈕,隨機產生一組分數,這樣不用每次都真的從頭測驗,就能連續測試存檔功能:
<!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: 30px;
border-radius: 20px;
box-shadow: 0 12px 30px rgba(0,0,0,0.15);
width: 100%;
max-width: 500px;
text-align: center;
}
h2 { color: #1e88e5; margin-bottom: 5px; }
h3 { color: #263238; text-align: left; margin: 25px 0 10px; font-size: 1rem; }
.subtitle { color: #546e7a; font-size: 0.9rem; margin-bottom: 20px; }
.btn {
background-color: #1e88e5;
color: white;
border: none;
border-radius: 50px;
padding: 12px 30px;
font-size: 1rem;
cursor: pointer;
width: 100%;
transition: 0.2s;
}
.btn:hover { background-color: #1565c0; transform: translateY(-2px); }
.clear-btn { background-color: #90a4ae; margin-top: 20px; font-size: 0.9rem; padding: 10px; }
.clear-btn:hover { background-color: #d32f2f; }
.latest-box {
background: #e3f2fd;
border-radius: 14px;
padding: 16px;
margin-top: 15px;
font-weight: bold;
color: #1e88e5;
font-size: 0.95rem;
}
#history-list { list-style: none; padding: 0; margin: 0; text-align: left; }
#history-list li {
background: #fdfdfd;
border: 2px dashed #90caf9;
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 10px;
font-size: 0.85rem;
color: #263238;
line-height: 1.6;
}
#history-list .time { color: #546e7a; font-size: 0.8rem; }
#history-list .weak { color: #d32f2f; font-weight: bold; }
.empty { color: #90a4ae; font-size: 0.9rem; text-align: center; padding: 10px; }
</style>
</head>
<body>
<div class="card">
<h2>🗂️ 賽博診所病歷本</h2>
<p class="subtitle">重新整理也不會消失的看診紀錄</p>
<button class="btn" onclick="simulateVisit()">🩺 模擬一次看診</button>
<div class="latest-box" id="latest">還沒有看診紀錄,先掛個號吧。</div>
<h3>📜 歷史病歷(最多保留 5 筆)</h3>
<ul id="history-list"></ul>
<button class="btn clear-btn" onclick="clearHistory()">清空病歷</button>
</div>
<script>
const STORAGE_KEY = "cyberClinicHistory";
const MAX_RECORDS = 5;
const dimensions = ["日常", "效率", "社交", "心態", "放鬆", "職涯"];
// 讀取病歷:取出字串 → 還原成陣列
function loadHistory() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (e) {
// 讀不到或資料壞掉,就當作沒有病歷
return [];
}
}
// 儲存病歷:陣列 → 壓成字串再存
function saveHistory(list) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
} catch (e) {
console.warn("病歷存檔失敗:", e);
}
}
// 找出分數最低的維度(Day 14 的老朋友)
function getWeakest(scores) {
return Object.entries(scores).reduce((min, cur) => {
return cur[1] < min[1] ? cur : min;
})[0];
}
// 模擬一次看診:隨機產生分數,然後存進病歷本
function simulateVisit() {
const scores = {};
dimensions.forEach(dim => {
scores[dim] = Math.floor(Math.random() * 15) - 5; // -5 ~ 9
});
const record = {
time: new Date().toLocaleString("zh-TW", { hour12: false }),
scores: scores,
weakest: getWeakest(scores)
};
const history = loadHistory();
history.unshift(record); // 新的放最前面
saveHistory(history.slice(0, MAX_RECORDS)); // 只留最近 5 筆
render();
}
function clearHistory() {
try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
render();
}
// 把病歷畫到畫面上
function render() {
const history = loadHistory();
const latest = document.getElementById("latest");
const list = document.getElementById("history-list");
if (history.length === 0) {
latest.innerText = "還沒有看診紀錄,先掛個號吧。";
list.innerHTML = '<li class="empty">病歷本是空的</li>';
return;
}
latest.innerText = `最近一次看診:最需要留意的是「${history[0].weakest}」`;
list.innerHTML = history.map(rec => {
const detail = Object.entries(rec.scores)
.map(([dim, val]) => `${dim} ${val}`)
.join(" ");
return `
<li>
<div class="time">🕒 ${rec.time}</div>
<div>缺乏:<span class="weak">${rec.weakest}</span></div>
<div>${detail}</div>
</li>`;
}).join("");
}
// 一打開頁面就先把舊病歷讀出來,這就是「回診」的感覺
window.onload = render;
</script>
</body>
</html>
貼到本機存成 .html 打開,多按幾次「模擬一次看診」,然後按 F5 重新整理,或是整個關掉再打開。
病歷還在。
1. 為什麼 getItem 要包 try...catch?
一開始我沒包,覺得「不就讀個字串,能出什麼事?」
後來查過才知道會產生兩種情況:
localStorage,一碰就丟錯誤。JSON.parse 會直接爆掉。沒包 try...catch 的話,整個頁面的 JavaScript 就會在這裡停住,使用者看到的是一片空白。包起來之後,最糟就是「當作沒有病歷」,診所至少還能正常營業。
2. 為什麼要用 slice(0, MAX_RECORDS) 限制筆數?
localStorage 的容量大約只有 5MB 左右,而且每次都是整包讀出來、整包寫回去。如果不設上限,理論上會一直長大。診所只需要看最近幾次的狀態變化,留 5 筆綽綽有餘。
3. 怎麼確認東西真的存進去了?
打開開發者工具(F12),找到 Application(應用程式)分頁,左邊選 Local Storage,就能看到剛剛的 cyberClinicHistory 躺在裡面,內容就是那一長串 JSON 字串。第一次看到的時候有種「原來你就住在這裡」的感覺。
4. 它記的是「這台瀏覽器」,不是「這個人」。
換一台電腦、換一個瀏覽器,甚至用同個瀏覽器的無痕視窗,病歷都不會跟過去。這也是為什麼專題一開始的 Email 欄位是選填:我們沒有後端,也就沒辦法真的把一個人的紀錄跨裝置串起來。這個限制我覺得反而要在文章裡誠實講清楚。
今天寫起來最意外的是:localStorage 本身其實不難,整個 API 記三個方法就夠了。真正花時間的反而是「要存什麼、怎麼存」。
一開始我想每一項分數各存一個 key,localStorage.setItem("日常", 5)、localStorage.setItem("效率", 8)……存了兩行就發現不對,這樣「第二次看診」要放哪裡?總不能叫 日常2、效率2 吧。
後來改成「一個 key,裡面放一個陣列,每筆病歷是一個物件」,整個邏輯突然乾淨很多。新病歷 unshift 進去,超過上限就 slice 掉,讀取時 JSON.parse 一次拿到全部。
這讓我又想起 Day 11 的教訓:資料結構決定後面好不好寫。只是這次不是分數的結構,而是「歷史紀錄」的結構。
還有一個小小的感想。
以前我覺得「存資料」是後端的工作,前端頂多負責畫畫面。但做到今天才發現,只靠瀏覽器就能讓一個網頁「有記憶」,對使用者的感受差很多。同樣是一個測驗,能記住你上次結果的,看起來就像個真的產品,不像一次性的小玩具。
今天我們用 localStorage 配上 JSON.stringify 與 JSON.parse,幫診所準備了一本重新整理也不會消失的病歷本,每次看診都會被記錄下來,最多保留最近五筆。
寫完之後我把病歷本打開來重新看了一遍,發現一件更尷尬的事。
結果頁最上面,其實從 Day 1 就一直放著這一行:
今日綜合腦內電池耗損率
0%
那個 0% 是我當初隨手寫死的佔位符,後來忙著做計分、雷達圖、處方籤,一路忘了回來處理它。
現在病歷本都做出來了,裡面存的六個維度分數,卻沒有一個地方真的告訴使用者:你今天到底耗損了幾 %。
這個坑不能再拖了。
所以明天 Day 16,我們要回頭把那個假的 0% 換掉,讓題庫自己算出「最慘」跟「最爽」的分數範圍,把使用者的總分換算成一個貨真價實的耗損率,順便把這個數字也一起存進病歷本裡。
明天,讓那顆電池終於開始動。我們明天見!