From 1f2036be3e388f4c9a65f41bf919cf1662584eab Mon Sep 17 00:00:00 2001 From: love19870821 <59876241+love19870821@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:14:33 +0800 Subject: [PATCH] Guard storage access for delivery --- index.html | 114 ++++++++++++++ script.js | 293 +++++++++++++++++++++++++++++++++++ styles.css | 444 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 851 insertions(+) create mode 100644 index.html create mode 100644 script.js create mode 100644 styles.css diff --git a/index.html b/index.html new file mode 100644 index 0000000..1e28985 --- /dev/null +++ b/index.html @@ -0,0 +1,114 @@ + + + + + + 語音記帳工具 | MiMi Voice Ledger + + + + + + +
+
+
+

✨ 智能語音記帳 ✨

+

MiMi Voice Ledger

+

跟著說一句,收入支出自動整理,記帳也可以很可愛。

+
+
+
+ 本月結餘 + $0 +
+
+
+
+
+
+

收入

+ $0 +
+
+

支出

+ $0 +
+
+
+
+ +
+
+
+ +
+
+
+

即時辨識

+

準備好說:"早餐 80 元"

+
+ + + +
+

+
+
+ +
+
+

今日記錄

+
+ + + +
+
+
+ + + + +
+
+
+ +
+

甜甜圈分析

+
+
+ + + + +
+ 支出佔比 + 0% +
+
+
+

今天的支出還很乖,繼續保持!

+
    +
  • 可說出「收入」或「支出」關鍵字
  • +
  • 說完立即加入記帳清單
  • +
  • 所有資料都保存在你的裝置
  • +
+
+
+
+
+
+ + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..2ae8caf --- /dev/null +++ b/script.js @@ -0,0 +1,293 @@ +const micButton = document.getElementById("micButton"); +const micText = document.getElementById("micText"); +const pulse = document.getElementById("pulse"); +const liveSpeech = document.getElementById("liveSpeech"); +const entryList = document.getElementById("entryList"); +const manualText = document.getElementById("manualText"); +const manualAmount = document.getElementById("manualAmount"); +const manualType = document.getElementById("manualType"); +const addEntry = document.getElementById("addEntry"); +const incomeDisplay = document.getElementById("income"); +const expenseDisplay = document.getElementById("expense"); +const balanceDisplay = document.getElementById("balance"); +const progressBar = document.getElementById("progressBar"); +const donutFill = document.getElementById("donutFill"); +const expenseRatio = document.getElementById("expenseRatio"); +const insightMessage = document.getElementById("insightMessage"); +const speechStatus = document.getElementById("speechStatus"); +const filters = document.querySelectorAll(".filter"); +const chips = document.querySelectorAll(".chip"); + +const currencyFormatter = new Intl.NumberFormat("zh-TW", { + style: "currency", + currency: "TWD", + maximumFractionDigits: 0, +}); + +const isStorageAvailable = () => { + if (typeof localStorage === "undefined") return false; + try { + const testKey = "__voice_ledger_test__"; + localStorage.setItem(testKey, "1"); + localStorage.removeItem(testKey); + return true; + } catch (error) { + return false; + } +}; + +const loadEntries = () => { + if (!isStorageAvailable()) { + if (speechStatus) { + speechStatus.textContent = + "瀏覽器儲存不可用,資料僅會保留在目前頁面。"; + } + return []; + } + try { + const raw = localStorage.getItem("entries"); + return raw ? JSON.parse(raw) : []; + } catch (error) { + return []; + } +}; + +const saveEntries = () => { + if (!isStorageAvailable()) return; + try { + localStorage.setItem("entries", JSON.stringify(entries)); + } catch (error) { + if (speechStatus) { + speechStatus.textContent = + "無法儲存到瀏覽器,資料將只保留在此頁面。"; + } + } +}; + +let entries = loadEntries(); +let activeFilter = "all"; +let recognition; +let listening = false; + +const SpeechRecognition = + window.SpeechRecognition || window.webkitSpeechRecognition; +const isSpeechSupported = Boolean(SpeechRecognition); +const canUseSpeech = isSpeechSupported && window.isSecureContext; + +if (canUseSpeech) { + recognition = new SpeechRecognition(); + recognition.lang = "zh-TW"; + recognition.continuous = false; + recognition.interimResults = true; + + recognition.onstart = () => { + listening = true; + micText.textContent = "正在聆聽"; + pulse.classList.add("active"); + }; + + recognition.onresult = (event) => { + const transcript = Array.from(event.results) + .map((result) => result[0].transcript) + .join(""); + liveSpeech.textContent = transcript || "我有在聽~"; + + if (event.results[0].isFinal) { + const parsed = parseSpeech(transcript); + if (parsed) { + addEntryItem(parsed); + liveSpeech.textContent = `已加入:${parsed.note} ${parsed.amount} 元`; + } else { + liveSpeech.textContent = "還沒抓到金額,再試一次吧!"; + } + } + }; + + recognition.onerror = () => { + liveSpeech.textContent = "語音辨識出錯了,可以改用手動輸入。"; + }; + + recognition.onend = () => { + listening = false; + micText.textContent = "點擊開始"; + pulse.classList.remove("active"); + }; +} else { + micButton.disabled = true; + micText.textContent = "不可用"; + if (!isSpeechSupported) { + liveSpeech.textContent = "此瀏覽器不支援語音辨識,請使用手動輸入。"; + speechStatus.textContent = "可改用下方手動輸入或點選範例。"; + } else { + liveSpeech.textContent = "語音辨識需要 HTTPS 或 localhost 才能使用。"; + speechStatus.textContent = "目前為非安全環境,已切換成手動模式。"; + } +} + +const parseSpeech = (text) => { + if (!text) return null; + const amountMatch = text.match(/(\d[\d,]*)(?=\s*元?)/); + if (!amountMatch) return null; + const amount = Number(amountMatch[1].replace(/,/g, "")); + const isIncome = /收入|進帳|收到|薪水|入帳/.test(text); + const isExpense = /支出|花|買|付款|支付/.test(text); + const type = isIncome && !isExpense ? "income" : "expense"; + + const note = text + .replace(amountMatch[0], "") + .replace(/元/g, "") + .trim(); + + return { + id: Date.now(), + note: note || (type === "income" ? "收入" : "支出"), + amount, + type, + time: new Date().toLocaleTimeString("zh-TW", { + hour: "2-digit", + minute: "2-digit", + }), + }; +}; + +const updateSummary = () => { + const income = entries + .filter((entry) => entry.type === "income") + .reduce((sum, entry) => sum + entry.amount, 0); + const expense = entries + .filter((entry) => entry.type === "expense") + .reduce((sum, entry) => sum + entry.amount, 0); + const balance = income - expense; + + incomeDisplay.textContent = currencyFormatter.format(income); + expenseDisplay.textContent = currencyFormatter.format(expense); + balanceDisplay.textContent = currencyFormatter.format(balance); + + const total = income + expense || 1; + const expensePercent = Math.round((expense / total) * 100); + progressBar.style.width = `${expensePercent}%`; + + const circumference = 289; + const offset = circumference - (expensePercent / 100) * circumference; + donutFill.style.strokeDashoffset = `${offset}`; + expenseRatio.textContent = `${expensePercent}%`; + + if (expensePercent < 35) { + insightMessage.textContent = "今天的支出還很乖,繼續保持!"; + } else if (expensePercent < 70) { + insightMessage.textContent = "支出稍微活躍,記得留點甜甜的。"; + } else { + insightMessage.textContent = "支出超級熱鬧,該給錢包抱抱了。"; + } + + saveEntries(); +}; + +const renderEntries = () => { + entryList.innerHTML = ""; + const filtered = entries.filter((entry) => { + if (activeFilter === "all") return true; + return entry.type === activeFilter; + }); + + filtered + .sort((a, b) => b.id - a.id) + .forEach((entry) => { + const item = document.createElement("div"); + item.className = `entry ${entry.type}`; + item.innerHTML = ` +
+

${entry.note}

+ ${entry.time} +
+
+ ${entry.type === "income" ? "+" : "-"}${currencyFormatter.format( + entry.amount + )} +
+ `; + entryList.appendChild(item); + }); + + if (filtered.length === 0) { + const empty = document.createElement("div"); + empty.className = "entry"; + empty.innerHTML = ` +
+

還沒有記錄

+ 點擊麥克風開始語音記帳 +
+
$0
+ `; + entryList.appendChild(empty); + } +}; + +const addEntryItem = (entry) => { + entries.push(entry); + updateSummary(); + renderEntries(); +}; + +const addManualEntry = () => { + const amountValue = Number(manualAmount.value); + if (!amountValue) { + liveSpeech.textContent = "請先輸入金額再加入記帳!"; + return; + } + const entry = { + id: Date.now(), + note: manualText.value || "手動記帳", + amount: amountValue, + type: manualType.value, + time: new Date().toLocaleTimeString("zh-TW", { + hour: "2-digit", + minute: "2-digit", + }), + }; + manualText.value = ""; + manualAmount.value = ""; + addEntryItem(entry); +}; + +micButton.addEventListener("click", () => { + if (!recognition) return; + if (listening) { + recognition.stop(); + } else { + recognition.start(); + } +}); + +addEntry.addEventListener("click", addManualEntry); + +[manualText, manualAmount].forEach((input) => { + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + addManualEntry(); + } + }); +}); + +filters.forEach((filter) => { + filter.addEventListener("click", () => { + filters.forEach((btn) => btn.classList.remove("active")); + filter.classList.add("active"); + activeFilter = filter.dataset.filter; + renderEntries(); + }); +}); + +chips.forEach((chip) => { + chip.addEventListener("click", () => { + const text = chip.dataset.demo; + liveSpeech.textContent = text; + const parsed = parseSpeech(text); + if (parsed) { + addEntryItem(parsed); + } + }); +}); + +updateSummary(); +renderEntries(); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..992c514 --- /dev/null +++ b/styles.css @@ -0,0 +1,444 @@ +:root { + color-scheme: light; + --bg: #f9f5ff; + --card: #ffffff; + --primary: #8b5cf6; + --secondary: #f472b6; + --accent: #22d3ee; + --text: #2b2b2b; + --muted: #6b7280; + --success: #34d399; + --danger: #fb7185; + --shadow: 0 20px 40px rgba(109, 92, 255, 0.15); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: "Noto Sans TC", sans-serif; +} + +body { + background: radial-gradient(circle at top, #fff, var(--bg)); + color: var(--text); + min-height: 100vh; +} + +.app { + max-width: 1100px; + margin: 0 auto; + padding: 40px 24px 80px; +} + +.hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + flex-wrap: wrap; + margin-bottom: 32px; +} + +.tag { + background: rgba(139, 92, 246, 0.12); + color: var(--primary); + display: inline-block; + padding: 6px 16px; + border-radius: 999px; + font-weight: 600; + margin-bottom: 12px; +} + +h1 { + font-family: "Baloo 2", cursive; + font-size: 48px; + color: var(--primary); + margin-bottom: 12px; +} + +.subtitle { + font-size: 18px; + color: var(--muted); +} + +.hero-card { + background: var(--card); + border-radius: 24px; + padding: 24px; + box-shadow: var(--shadow); + min-width: 280px; + flex: 1; + max-width: 360px; +} + +.total span { + font-size: 14px; + color: var(--muted); +} + +.total strong { + display: block; + font-size: 36px; + color: var(--primary); + margin-top: 4px; +} + +.progress { + height: 10px; + background: #efe8ff; + border-radius: 999px; + margin: 20px 0; + overflow: hidden; +} + +.progress-bar { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--secondary), var(--primary)); + transition: width 0.4s ease; +} + +.summary { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.summary p { + font-size: 14px; + color: var(--muted); +} + +.summary strong { + font-size: 20px; +} + +main { + display: grid; + gap: 24px; +} + +.voice-panel { + background: var(--card); + border-radius: 24px; + padding: 24px; + display: flex; + gap: 24px; + flex-wrap: wrap; + align-items: center; + box-shadow: var(--shadow); +} + +.mic-wrapper { + position: relative; + width: 180px; + height: 180px; + display: grid; + place-items: center; +} + +.mic-button { + width: 140px; + height: 140px; + border-radius: 50%; + background: linear-gradient(145deg, var(--primary), var(--secondary)); + border: none; + color: #fff; + font-size: 18px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + cursor: pointer; + position: relative; + z-index: 2; + box-shadow: 0 18px 30px rgba(139, 92, 246, 0.4); + transition: transform 0.2s ease; +} + +.mic-button:hover { + transform: translateY(-3px) scale(1.02); +} + +.mic-icon { + font-size: 32px; +} + +.pulse { + position: absolute; + width: 160px; + height: 160px; + border-radius: 50%; + background: rgba(139, 92, 246, 0.2); + animation: pulse 1.4s ease infinite; + opacity: 0; +} + +.pulse.active { + opacity: 1; +} + +@keyframes pulse { + 0% { + transform: scale(0.9); + } + 70% { + transform: scale(1.15); + opacity: 0.5; + } + 100% { + transform: scale(1); + opacity: 0; + } +} + +.speech-display { + flex: 1; + min-width: 240px; +} + +.label { + font-size: 14px; + color: var(--muted); + margin-bottom: 8px; +} + +.speech { + font-size: 20px; + font-weight: 600; + margin-bottom: 16px; + color: var(--text); +} + +.chips { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.status { + margin-top: 12px; + font-size: 14px; + color: var(--muted); +} + +.chip { + border: none; + background: rgba(34, 211, 238, 0.18); + color: #0e7490; + padding: 8px 14px; + border-radius: 999px; + cursor: pointer; + font-size: 14px; + transition: background 0.2s ease, transform 0.2s ease; +} + +.chip:hover { + background: rgba(34, 211, 238, 0.3); + transform: translateY(-1px); +} + +.entry-panel, +.insight-panel { + background: var(--card); + border-radius: 24px; + padding: 24px; + box-shadow: var(--shadow); +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 16px; + margin-bottom: 16px; +} + +h2 { + font-size: 24px; + color: var(--primary); +} + +.filters { + display: flex; + gap: 8px; +} + +.filter { + border: none; + background: #f3e8ff; + color: var(--primary); + padding: 6px 14px; + border-radius: 999px; + cursor: pointer; + font-size: 14px; +} + +.filter.active { + background: var(--primary); + color: #fff; +} + +.input-row { + display: grid; + grid-template-columns: 1.2fr 0.6fr 0.5fr 0.4fr; + gap: 12px; + margin-bottom: 20px; +} + +.input-row input, +.input-row select { + border-radius: 12px; + border: 1px solid #e5e7eb; + padding: 10px 12px; + font-size: 14px; +} + +.input-row button { + border: none; + border-radius: 12px; + background: var(--primary); + color: #fff; + font-weight: 600; + cursor: pointer; +} + +.entry-list { + display: grid; + gap: 12px; +} + +.entry { + display: flex; + justify-content: space-between; + align-items: center; + background: #fdf4ff; + border-radius: 18px; + padding: 14px 18px; +} + +.entry.income { + background: #ecfdf3; +} + +.entry .title { + font-weight: 600; +} + +.entry small { + color: var(--muted); +} + +.entry .amount { + font-weight: 700; + font-size: 18px; +} + +.entry .amount.expense { + color: var(--danger); +} + +.entry .amount.income { + color: var(--success); +} + +.insight-card { + display: flex; + gap: 24px; + align-items: center; + flex-wrap: wrap; +} + +.donut { + width: 160px; + height: 160px; + position: relative; +} + +.donut svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.donut-track { + fill: none; + stroke: #f1f5f9; + stroke-width: 14; +} + +.donut-fill { + fill: none; + stroke: var(--secondary); + stroke-width: 14; + stroke-linecap: round; + stroke-dasharray: 289; + stroke-dashoffset: 289; + transition: stroke-dashoffset 0.4s ease; +} + +.donut-center { + position: absolute; + inset: 0; + display: grid; + place-items: center; + text-align: center; + font-size: 14px; +} + +.donut-center strong { + display: block; + font-size: 22px; + color: var(--primary); +} + +.insight-text { + flex: 1; + min-width: 220px; +} + +.insight-text p { + font-size: 18px; + font-weight: 600; + margin-bottom: 12px; +} + +.insight-text ul { + list-style: none; + display: grid; + gap: 8px; + color: var(--muted); + font-size: 14px; +} + +@media (max-width: 820px) { + .hero { + flex-direction: column; + align-items: flex-start; + } + + .hero-card { + max-width: 100%; + } + + .input-row { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 520px) { + h1 { + font-size: 36px; + } + + .voice-panel { + flex-direction: column; + } + + .input-row { + grid-template-columns: 1fr; + } +}