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
+
跟著說一句,收入支出自動整理,記帳也可以很可愛。
+
+
+
+
+
+
+
+
+
即時辨識
+
準備好說:"早餐 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;
+ }
+}