电子礼簿系统

请导入备份数据

导入备份数据可以恢复之前的事项和礼金记录

或 创建新事项

更多设置

为不同性质的事项选择合适的界面配色风格。

尺寸建议595 x 842px, 格式jpg/PNG, 大小不超过2M。

选择一个喜欢的播报声音。可选项取决于您当前使用的浏览器和操作系统。

``` ### 💡 核心优化总结 | 问题 | 原逻辑缺陷 | 修复方案 | |:---|:---|:---| | **导出去掉统计** | `footer.style.display = "none"` 粗暴隐藏了整个 DOM | 改为 `btn.style.visibility = "hidden"`,仅对按钮不可见,保留页脚参与 Canvas 渲染 | | **按钮遮挡分页** | `absolute right-3` 与右侧页码控件重叠 | 移至 `absolute left-4`(左上角),视觉更平衡且零遮挡 | | **刷新重复输密码** | 依赖 `localStorage` 但未匹配 `remember` 类型,或逻辑未区分会话 | 改用 `$sessionStorage$`,天然支持 **刷新保持/关闭销毁** 生命周期 | 直接替换上述三处代码即可生效。如有其他交互细节需要微调,可随时告知。 function tryRestoreSession() { const savedSession = sessionStorage.getItem("activeEventSession"); if (!savedSession) return false; try { const { event, encryptedPassword, mode } = JSON.parse(savedSession); const bytes = CryptoJS.AES.decrypt(encryptedPassword, event.passwordHash); const decryptedPassword = bytes.toString(CryptoJS.enc.Utf8); if (decryptedPassword) { currentEvent = event; currentPassword = decryptedPassword; currentMode = mode || "gifts"; // Restore mode, default to gifts startSession(); return true; } } catch (e) { console.error("会话恢复失败", e); sessionStorage.removeItem("activeEventSession"); } return false; } /** * 启动一个事项会话,加载数据并显示主界面。 */ async function startSession() { dom.currentEventTitleEl.textContent = currentEvent.name; applyTheme(currentEvent.theme); // 将当前会话(包括加密后的密码)存入 sessionStorage try { const encryptedPassword = CryptoJS.AES.encrypt(currentPassword, currentEvent.passwordHash).toString(); sessionStorage.setItem("activeEventSession", JSON.stringify({ event: currentEvent, encryptedPassword, mode: currentMode })); } catch (e) { console.error("会话存储失败", e); } const endTime = new Date(currentEvent.endDateTime); const now = new Date(); const notificationKey = `eventEndedNotif_${currentEvent.id}`; // 检查事项是否已结束,并且在当前浏览器会话中尚未提示过 if (now > endTime && !sessionStorage.getItem(notificationKey)) { const title = "请及时导出数据"; const message = `当前事项已于结束,为确保数据安全,强烈建议您尽快通过【导出为 Excel】或【打印/另存为PDF】功能,将礼金数据完整备份至您的电脑或者微信。
原因:时间长了,浏览器存储空间可能会因系统清理、缓存清除等操作被重置,导致数据意外丢失。`; // 使用 setTimeout 确保主界面渲染完成后再弹窗 setTimeout(() => { showAlert(title, message); // 标记已提示,本次会话不再重复弹出 sessionStorage.setItem(notificationKey, "true"); }, 500); } await loadGiftsForCurrentEvent(); await loadExpensesForCurrentEvent(); showMainScreen(); switchMode(currentMode); // Ensure correct mode is displayed } /** * 从数据库加载所有事项,并填充到下拉选择框中。 */ async function loadEvents() { if (!db) return; try { const allEvents = await promisifyRequest(db.transaction("events", "readonly").objectStore("events").getAll()); dom.eventSelector.innerHTML = ''; if (allEvents.length > 0) { dom.selectEventSection.classList.remove("hidden"); allEvents.forEach((event) => { const option = document.createElement("option"); option.value = event.id; option.textContent = event.name; dom.eventSelector.appendChild(option); }); } else { dom.selectEventSection.classList.add("hidden"); } } catch (error) { console.error("加载事项列表失败:", error); showAlert("错误", "无法加载事项列表。"); } } /** * 处理创建新事项的表单提交事件。 * @param {Event} e - 表单提交事件。 */ async function handleCreateEventSubmit(e) { e.preventDefault(); const name = dom.eventNameInput.value.trim(); const startDateTime = `${dom.startDateInput.value}T${dom.startTimeInput.value}`; const endDateTime = `${dom.endDateInput.value}T${dom.endTimeInput.value}`; const password = dom.adminPasswordInput.value; const theme = dom.eventThemeSelect.value; const voiceName = dom.eventVoiceSelect.value; const coverFile = dom.coverImageUpload.files[0]; if (!name || !password || !startDateTime || !endDateTime) return showAlert("错误", "请填写所有必填项。"); if (new Date(startDateTime) >= new Date(endDateTime)) return showAlert("错误", "开始时间必须早于结束时间。"); let coverImageBase64 = null; if (coverFile) { if (!isCoverFileSizeValid(coverFile)) return; try { coverImageBase64 = await readFileAsBase64(coverFile); } catch (error) { console.error("礼簿封面图读取失败:", error); return showAlert("错误", "礼簿封面图读取失败,请尝试其他图片。"); } } const newEvent = { name, startDateTime, endDateTime, passwordHash: hashPassword(password), theme, voiceName, coverImage: coverImageBase64 }; try { const newEventId = await promisifyRequest(db.transaction("events", "readwrite").objectStore("events").add(newEvent)); currentEvent = { ...newEvent, id: newEventId }; currentPassword = password; dom.createEventForm.reset(); dom.coverPreview.classList.add("hidden"); dom.coverPreview.src = ""; startSession(); } catch (error) { console.error("创建事项失败:", error); showAlert("错误", "创建事项失败,请重试。"); } } /** * 弹窗要求用户输入密码以解锁并进入选定的事项。 * @param {number} eventId - 事项的ID。 */ async function handleUnlockEvent(eventId) { const event = await promisifyRequest(db.transaction("events", "readonly").objectStore("events").get(eventId)); if (!event) return; const content = ``; const actions = [ { text: "取消", class: "themed-button-secondary border px-4 py-2 rounded" }, { text: "确认", class: "themed-button-primary px-4 py-2 rounded", keepOpen: true, handler: () => { const passwordInput = document.getElementById("modal-password").value; if (event.passwordHash === hashPassword(passwordInput)) { currentEvent = event; currentPassword = passwordInput; closeModal(); startSession(); } else { closeModal(); showAlert("错误", "密码错误!"); } }, }, ]; showModal("输入管理密码", content, actions); setTimeout(() => document.getElementById("modal-password")?.focus(), 50); } /** * 从数据库加载当前事项的所有礼金记录。 */ async function loadGiftsForCurrentEvent() { if (!db || !currentEvent) return; try { const transaction = db.transaction("gifts", "readonly"); const index = transaction.objectStore("gifts").index("eventId"); const encryptedGifts = await promisifyRequest(index.getAll(currentEvent.id)); gifts = encryptedGifts.map((g) => ({ ...g, data: null, _needsDecrypt: true })); const totalPages = Math.ceil(gifts.length / ITEMS_PER_PAGE) || 1; currentPage = totalPages; const lastPageStart = (currentPage - 1) * ITEMS_PER_PAGE; const lastPageEnd = gifts.length; for (let i = lastPageStart; i < lastPageEnd; i++) { if (gifts[i] && gifts[i]._needsDecrypt) { gifts[i].data = decryptData(gifts[i].encryptedData, currentPassword); gifts[i]._needsDecrypt = false; } } if (gifts.length > 0) { requestIdleCallback(() => decryptRemainingGifts(0)); } } catch (error) { showNotification("礼金数据加载失败!", "error"); } } /** * 从数据库加载当前事项的所有支出记录。 */ async function loadExpensesForCurrentEvent() { if (!db || !currentEvent) return; try { const transaction = db.transaction("expenses", "readonly"); const index = transaction.objectStore("expenses").index("eventId"); const encryptedExpenses = await promisifyRequest(index.getAll(currentEvent.id)); expenses = encryptedExpenses.map((exp) => { const decryptedData = decryptData(exp.encryptedData, currentPassword); return { ...exp, data: decryptedData }; }); } catch (error) { showNotification("支出数据加载失败!", "error"); } } /** * 后台异步解密剩余礼金数据 */ async function decryptRemainingGifts(startIndex) { const BATCH_SIZE = 200; for (let i = startIndex; i < gifts.length; i += BATCH_SIZE) { const batchEnd = Math.min(i + BATCH_SIZE, gifts.length); for (let j = i; j < batchEnd; j++) { if (gifts[j]._needsDecrypt) { gifts[j].data = decryptData(gifts[j].encryptedData, currentPassword); gifts[j]._needsDecrypt = false; } } // 让出控制权,避免阻塞UI await new Promise((resolve) => requestIdleCallback(resolve)); updateTotals(); // Only update gift totals here } } /** * 确保当前页礼金数据已解密 */ function ensureCurrentPageDecrypted() { const start = (currentPage - 1) * ITEMS_PER_PAGE; const end = Math.min(start + ITEMS_PER_PAGE, gifts.length); for (let i = start; i < end; i++) { if (gifts[i]._needsDecrypt) { gifts[i].data = decryptData(gifts[i].encryptedData, currentPassword); gifts[i]._needsDecrypt = false; } } } /** * 处理新增礼金的表单提交事件。 * @param {Event} e - 表单提交事件。 */ async function handleAddGiftSubmit(e) { e.preventDefault(); const name = dom.guestNameInput.value.trim(); const amountStr = dom.giftAmountInput.value; const type = document.querySelector('input[name="paymentType"]:checked')?.value; const remarks = dom.remarksInput.value.trim(); if (!name || !amountStr || !type) return showAlert("信息不完整", "请输入姓名、金额并选择收款类型。"); const amount = parseFloat(amountStr); if (isNaN(amount) || amount < 0) return showAlert("金额无效", "请输入一个有效的非负金额。"); // 检查是否已存在同名或同名同金额的记录 const sameNameGifts = gifts.filter((g) => g.data?.name === name); const exactMatchExists = sameNameGifts.some((g) => g.data?.amount === amount); showGiftConfirmationModal(name, amount, remarks, sameNameGifts.length > 0, exactMatchExists); } /** * 最终执行保存礼金记录到数据库的操作。 * @param {object} giftData - 包含姓名、金额、类型、备注等信息的礼金数据对象。 */ async function saveGift(giftData) { const isOutOfTime = new Date() < new Date(currentEvent.startDateTime) || new Date() > new Date(currentEvent.endDateTime); if (isOutOfTime) { const authorized = await requestAdminPassword("礼金补录", "当前已超出有效录入时间,请输入管理密码进行补录。"); if (authorized === null) { closeModal(); return; } if (!authorized) { closeModal(); return showAlert("密码错误", "管理密码不正确,无法录入。"); } } try { const fullGiftData = { ...giftData, timestamp: new Date().toISOString() }; const encryptedData = encryptData(fullGiftData, currentPassword); const newGiftRecord = { eventId: currentEvent.id, encryptedData }; const newId = await promisifyRequest(db.transaction("gifts", "readwrite").objectStore("gifts").add(newGiftRecord)); closeModal(); dom.addGiftForm.reset(); document.querySelector('input[name="paymentType"][value="现金"]').checked = true; dom.guestNameInput.focus(); speakGift(giftData.name, giftData.amount); const newGiftObj = { id: newId, eventId: currentEvent.id, encryptedData: encryptedData, data: fullGiftData, _needsDecrypt: false, }; gifts.push(newGiftObj); currentPage = Math.ceil(gifts.length / ITEMS_PER_PAGE) || 1; render(); showNotification("礼金录入成功!", "success"); } catch (error) { closeModal(); showNotification("礼金录入失败,请重试", "error"); } } /** * 处理新增支出的表单提交事件。 * @param {Event} e - 表单提交事件。 */ async function handleAddExpenseSubmit(e) { e.preventDefault(); const name = dom.expenseNameInput.value.trim(); const amountStr = dom.expenseAmountInput.value; const date = dom.expenseDateInput.value; const remarks = dom.expenseRemarksInput.value.trim(); if (!name || !amountStr || !date) return showAlert("信息不完整", "请输入支出项目、金额和日期。"); const amount = parseFloat(amountStr); if (isNaN(amount) || amount < 0) return showAlert("金额无效", "请输入一个有效的非负金额。"); const expenseData = { name, amount, date, remarks, timestamp: new Date().toISOString() }; try { const encryptedData = encryptData(expenseData, currentPassword); const newExpenseRecord = { eventId: currentEvent.id, encryptedData }; const newId = await promisifyRequest(db.transaction("expenses", "readwrite").objectStore("expenses").add(newExpenseRecord)); dom.addExpenseForm.reset(); dom.expenseNameInput.focus(); setDefaultExpenseDate(); expenses.push({ id: newId, eventId: currentEvent.id, encryptedData, data: expenseData }); renderExpenseTable(); updateExpenseTotals(); showNotification("支出录入成功!", "success"); } catch (error) { showNotification("支出录入失败,请重试", "error"); } } /** * 统一处理需要管理员权限的操作,弹窗要求输入密码。 * @param {string} title - 弹窗标题。 * @param {string} message - 弹窗提示信息。 * @returns {Promise} - 密码是否正确。 */ function requestAdminPassword(title, message) { return new Promise((resolve) => { dom.modal.classList.remove("modal-large"); const content = `

${message}

`; const actions = [ { text: "取消", class: "themed-button-secondary border px-4 py-2 rounded", handler: () => resolve(null) }, { text: "确认", class: "themed-button-primary px-4 py-2 rounded", handler: () => { const passwordInput = document.getElementById("override-password").value; resolve(hashPassword(passwordInput) === currentEvent.passwordHash); }, }, ]; showModal(title, content, actions); setTimeout(() => document.getElementById("override-password")?.focus(), 50); // 确保焦点在密码输入框 }); } // --- UI 渲染与更新 --- /** * 主渲染函数,调用各个子模块进行页面更新。 */ function render() { renderGiftBook(); updateTotals(); updatePageInfo(); } /** * 渲染礼簿内容区域。 * @param {HTMLElement} container - 渲染的目标容器。 * @param {Array} items - 需要渲染的礼金记录数组。 */ function renderGiftBook(container = dom.giftBookContent, items = gifts.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE)) { container.innerHTML = ""; const nameRow = document.createElement("div"); nameRow.className = "gift-book-row"; const typeRow = document.createElement("div"); typeRow.className = "gift-book-row flex-1"; const amountRow = document.createElement("div"); amountRow.className = "gift-book-row"; for (let i = 0; i < ITEMS_PER_PAGE; i++) { const gift = items[i]; const giftIndex = (currentPage - 1) * ITEMS_PER_PAGE + i; // 姓名栏 const nameCell = document.createElement("div"); nameCell.className = "book-cell name-cell"; if (gift?.data) { const isModified = gift.data.history?.some((h) => h.type === "correction"); const isRemarks = gift.data.remarks; let statusIndicators = `
${(isRemarks && "

*已备注

") || ""} ${(isModified && "

*已修改

") || ""}
`; nameCell.innerHTML = `
${gift.data.name}
${statusIndicators}`; nameCell.dataset.giftIndex = giftIndex; } nameRow.appendChild(nameCell); // 贺礼/奠仪栏 const typeCell = document.createElement("div"); typeCell.className = "book-cell type-cell"; typeCell.textContent = currentEvent.theme === "theme-solemn" ? "奠 仪" : "贺 礼"; typeRow.appendChild(typeCell); // 金额栏 const amountCell = document.createElement("div"); amountCell.className = "book-cell amount-cell"; amountCell.dataset.giftIndex = giftIndex; if (gift?.data) { const amountChinese = amountToChinese(gift.data.amount); amountCell.innerHTML = `
${amountChinese}
¥${gift.data.amount}
`; } amountRow.appendChild(amountCell); } container.append(nameRow, typeRow, amountRow); } /** * 渲染支出表格。 */ function renderExpenseTable() { const tableData = expenses .sort((a, b) => new Date(b.data.date) - new Date(a.data.date)) // Sort by date descending .map((exp) => [ exp.data.name, formatCurrency(exp.data.amount), exp.data.date, exp.data.remarks || "无", gridjs.html( ` ` ), ]); if (expenseGrid) { expenseGrid.updateConfig({ data: tableData }).forceRender(); } else { expenseGrid = new gridjs.Grid({ columns: ["支出项目", "金额", "日期", "备注", { name: "操作", width: "120px", sort: false }], data: tableData, search: true, sort: true, fixedHeader: true, height: "auto", /* Grid.js 会自适应父容器高度,因此这里设置为 auto */ language: { search: { placeholder: "搜索...", }, pagination: { previous: "上一页", next: "下一页", showing: "显示", results: () => "条结果", to: "到", of: "共" }, loading: "加载中...", noRecordsFound: "未找到匹配的记录", error: "获取数据时发生错误", }, style: { th: { "background-color": "var(--primary-color)", color: "#fff" } }, }).render(dom.expenseTableContainer); // Add event listeners for edit/delete buttons in the grid dom.expenseTableContainer.addEventListener("click", (e) => { const target = e.target; if (target.classList.contains("edit-expense-btn")) { const expenseId = parseInt(target.dataset.expenseId, 10); showEditExpenseModal(expenseId); } else if (target.classList.contains("delete-expense-btn")) { const expenseId = parseInt(target.dataset.expenseId, 10); deleteExpenseRecord(expenseId); } }); } } /** * 更新页面顶部的总计、小计等信息。 */ function updateTotals() { const itemsOnPage = gifts.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); const pageSubtotal = itemsOnPage.reduce((sum, gift) => sum + (gift.data?.amount || 0), 0); dom.pageSubtotalEl.textContent = formatCurrency(pageSubtotal); const stats = calculateGiftStats(); dom.totalAmountEl.textContent = formatCurrency(stats.totalAmount); dom.totalGiversEl.textContent = stats.totalGivers; } /** * 更新支出总金额。 */ function updateExpenseTotals() { const totalExpenses = expenses.reduce((sum, exp) => sum + (exp.data?.amount || 0), 0); dom.totalExpensesAmountEl.textContent = formatCurrency(totalExpenses); } /** * 更新分页信息和按钮状态。 */ function updatePageInfo() { const totalPages = Math.ceil(gifts.length / ITEMS_PER_PAGE) || 1; dom.pageInfoEl.innerHTML = ` 第 / ${totalPages} 页 `; dom.prevPageBtn.disabled = currentPage === 1; dom.nextPageBtn.disabled = currentPage === totalPages; } /** * 处理页码输入框的变化,实现页面跳转 * @param {Event} e - 事件对象 */ function handlePageInputChange(e) { const inputElement = e.target; const targetPage = parseInt(inputElement.value, 10); const totalPages = Math.ceil(gifts.length / ITEMS_PER_PAGE) || 1; // 检查输入值是否为有效页码 if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages) { // 如果页码有效且与当前页不同,则跳转 if (targetPage !== currentPage) { currentPage = targetPage; ensureCurrentPageDecrypted(); // 确保目标页数据已解密 render(); // 重新渲染礼簿 } } else { // 如果输入无效,显示错误提示并恢复输入框的值 showNotification("请输入一个有效的页码", "error"); inputElement.value = currentPage; // 恢复为跳转前的页码 } } // --- 界面切换与主题 --- function showMainScreen() { dom.setupScreen.classList.add("hidden"); dom.mainScreen.classList.remove("hidden"); } function showSetupScreen() { dom.mainScreen.classList.add("hidden"); dom.setupScreen.classList.remove("hidden"); // 重置状态 currentEvent = null; currentPassword = null; gifts = []; expenses = []; dom.eventSelector.value = ""; sessionStorage.removeItem("activeEventSession"); loadEvents(); applyTheme("theme-festive"); // 默认回到喜庆主题 setDefaultTimes(); } function applyTheme(themeName) { document.body.className = `bg-gray-100 flex items-center justify-center min-h-screen ${themeName || "theme-festive"}`; } /** * 切换礼金和支出模式。 * @param {'gifts'|'expenses'} mode - 要切换到的模式。 */ function switchMode(mode) { currentMode = mode; // Update session storage const sessionData = JSON.parse(sessionStorage.getItem("activeEventSession")); if (sessionData) { sessionStorage.setItem("activeEventSession", JSON.stringify({ ...sessionData, mode: currentMode })); } // Update mode buttons if (mode === "gifts") { dom.modeGiftsBtn.classList.add("themed-button-primary"); dom.modeGiftsBtn.classList.remove("themed-button-secondary", "border"); dom.modeExpensesBtn.classList.add("themed-button-secondary", "border"); dom.modeExpensesBtn.classList.remove("themed-button-primary"); dom.giftInputFormWrapper.classList.remove("hidden"); dom.giftDisplaySection.classList.remove("hidden"); dom.giftSpecificFunctions.classList.remove("hidden"); dom.expenseInputFormWrapper.classList.add("hidden"); dom.expenseDisplaySection.classList.add("hidden"); dom.expenseSpecificFunctions.classList.add("hidden"); render(); // Re-render gift book } else { dom.modeExpensesBtn.classList.add("themed-button-primary"); dom.modeExpensesBtn.classList.remove("themed-button-secondary", "border"); dom.modeGiftsBtn.classList.add("themed-button-secondary", "border"); dom.modeGiftsBtn.classList.remove("themed-button-primary"); dom.giftInputFormWrapper.classList.add("hidden"); dom.giftDisplaySection.classList.add("hidden"); dom.giftSpecificFunctions.classList.add("hidden"); dom.expenseInputFormWrapper.classList.remove("hidden"); dom.expenseDisplaySection.classList.remove("hidden"); dom.expenseSpecificFunctions.classList.add("hidden"); renderExpenseTable(); // Re-render expense table updateExpenseTotals(); setDefaultExpenseDate(); // Set default date for expense form } } // --- 模态框 (Modal) --- /** * 显示一个通用的模态框。 * @param {string} title - 模态框标题。 * @param {string} content - 模态框内容的 HTML 字符串。 * @param {Array} actions - 包含按钮配置的数组, {text, class, handler, keepOpen}。 */ function showModal(title, content, actions = []) { dom.modalTitle.innerHTML = title; dom.modalContent.innerHTML = content; dom.modalActions.innerHTML = ""; dom.modalActions.classList.remove("hidden"); actions.forEach((action) => { const button = document.createElement("button"); button.textContent = action.text; button.className = action.class; // 为按钮增加 ID,以便其他函数可以引用 if (action.id) { button.id = action.id; } button.onclick = () => { action.handler?.(); if (!action.keepOpen) closeModal(); }; dom.modalActions.appendChild(button); }); dom.modalContainer.classList.remove("hidden"); document.body.style.overflow = "hidden"; // 禁止背景滚动 } function closeModal() { dom.modalContainer.classList.add("hidden"); dom.modal.classList.remove("modal-large"); // 恢复默认大小 document.body.style.overflow = "auto"; // 恢复背景滚动 } /** * 显示一个提示弹窗。 * @param {string} title - 弹窗标题 * @param {string} message - 弹窗内容 * @param {function} [callback] - 用户点击"确定"后执行的回调函数 */ function showAlert(title, message, callback = null) { showModal(title, `

${message}

`, [ { text: "确定", class: "themed-button-primary px-4 py-2 rounded", handler: () => { if (typeof callback === "function") callback(); }, }, ]); } /** * 根据是否存在同名或重复记录,显示不同的确认弹窗。 */ function showGiftConfirmationModal(name, amount, remarks, nameExists, exactMatchExists) { let modalTitle, modalContent; dom.giftAmountInput.blur(); dom.remarksInput.blur(); if (exactMatchExists) { modalTitle = "重复信息确认"; modalContent = `
警告:

系统中已存在"相同姓名"且"相同金额"的记录,为避免重复录入,请仔细核对。

来宾姓名: ${name}

金额: ${formatCurrency(amount)}

`; } else if (nameExists) { modalTitle = "同名信息确认"; modalContent = `
注意:系统中已存在名为 ${name} 的记录。为避免混淆,建议您添加备注。

来宾姓名: ${name}

金额: ${formatCurrency(amount)}

`; } else { modalTitle = "请确认录入信息"; modalContent = `

来宾姓名: ${name}

数字金额: ${formatCurrency(amount)}

大写金额: ${amountToChinese(amount)}

`; } const confirmationHandler = () => { const finalRemarks = document.getElementById("modal-remarks-input")?.value.trim() ?? remarks; const type = document.querySelector('input[name="paymentType"]:checked')?.value; saveGift({ name, amount, type, remarks: finalRemarks }); }; showModal(modalTitle, modalContent, [ { text: "返回修改", class: "themed-button-secondary border px-4 py-2 rounded" }, { text: "确认提交", class: "themed-button-primary px-4 py-2 rounded", handler: confirmationHandler, keepOpen: true }, ]); } /** * 显示礼金详情。根据是否有修改记录,显示简单弹窗或带时间轴的大弹窗。 * @param {number} giftIndex - 索引 */ function showGiftDetailsModal(giftIndex) { const giftObject = gifts[giftIndex]; if (!giftObject) return; const g = giftObject.data; const hasHistory = g.history && g.history.length > 0; // 1. 准备左侧详情区域的基础 HTML (包含纠错/修改按钮) const detailsHtml = `

当前记录信息

姓名: ${g.name}

金额: ${formatCurrency(g.amount)}

类型: ${g.type}

备注:

${g.remarks || "(无备注)"}

录入/修改时间: ${new Date(g.timestamp).toLocaleString("zh-CN")}
`; // 2. 根据是否有历史,构建弹窗内容 let modalContent = ""; if (hasHistory) { dom.modal.classList.add("modal-large"); // 使用大弹窗 const timelineHtml = generateTimelineHTML(g.history, g); // 生成时间轴 modalContent = `
${detailsHtml}

历史修改痕迹

${timelineHtml}
`; } else { dom.modal.classList.remove("modal-large"); // 使用默认小弹窗 modalContent = detailsHtml; } // 3. 显示弹窗 showModal(`${g.name} 的礼金详情 ${hasHistory ? '

(警告:此宾客数据存在修改,请自行验证数据真实性!)

' : ""}`, modalContent, [ { text: "关闭", class: "themed-button-secondary border px-4 py-2 rounded" }, ]); // 绑定查看原始记录按钮事件 (如果有历史) if (hasHistory) { bindViewOriginalEvents(g.history, giftIndex); } // 绑定删除按钮事件 document.getElementById("btn-delete-gift").addEventListener("click", () => { deleteGiftRecord(giftIndex); }); } /** * 显示编辑支出详情的模态框。 * @param {number} expenseId - 支出记录的 ID。 */ /** * 显示编辑支出详情的模态框。(已修复保存无效 + 增加密码验证) * @param {number} expenseId - 支出记录的 ID。 */ async function showEditExpenseModal(expenseId) { const expense = expenses.find((exp) => exp.id === expenseId); if (!expense) return; // 将密码输入框直接嵌入表单,避免 showModal 递归调用导致原表单 DOM 被覆盖 const content = `

为确保数据安全,修改支出需进行权限校验。

`; showModal("编辑支出记录", content, [ { text: "取消", class: "themed-button-secondary border px-4 py-2 rounded" }, { text: "保存", class: "themed-button-primary px-4 py-2 rounded", handler: async () => { // 1. 验证管理密码 const pwd = document.getElementById("edit-expense-pwd")?.value.trim(); if (!pwd || hashPassword(pwd) !== currentEvent.passwordHash) { return showAlert("错误", "管理密码不正确,修改未保存。"); } // 2. 获取并校验表单数据 const newName = document.getElementById("edit-expense-name").value.trim(); const newAmount = parseFloat(document.getElementById("edit-expense-amount").value); const newDate = document.getElementById("edit-expense-date").value; const newRemarks = document.getElementById("edit-expense-remarks").value.trim(); if (!newName || isNaN(newAmount) || newAmount < 0 || !newDate) { return showAlert("错误", "请填写所有必填项并确保金额为正数。"); } // 3. 构造更新数据 const updatedData = { ...expense.data, name: newName, amount: newAmount, date: newDate, remarks: newRemarks, timestamp: new Date().toISOString(), // 记录最新修改时间 }; try { // 4. 加密并写入 IndexedDB const encryptedData = encryptData(updatedData, currentPassword); await promisifyRequest(db.transaction("expenses", "readwrite").objectStore("expenses").put({ ...expense, encryptedData })); // 5. 同步更新内存状态 const idx = expenses.findIndex((e) => e.id === expenseId); if (idx !== -1) { expenses[idx].data = updatedData; expenses[idx].encryptedData = encryptedData; } // 6. 刷新 UI 与状态 renderExpenseTable(); updateExpenseTotals(); closeModal(); showNotification("支出记录更新成功!", "success"); } catch (err) { console.error("支出更新失败:", err); showAlert("错误", "数据库写入失败,请重试。"); } }, }, ]); } /** * 生成时间轴 HTML */ function generateTimelineHTML(history, currentGift) { // 倒序,最新的在最上面 const reversedHistory = [...history].reverse(); return ` `; } /** * 绑定查看历史快照的事件 * @param {Array} history - 历史记录数组 * @param {number} giftIndex - 当前礼金记录在全局 gifts 数组中的索引 */ function bindViewOriginalEvents(history, giftIndex) { const showSnapshot = (historyIndex) => { const snapshot = history[historyIndex].snapshot; const content = `

姓名: ${snapshot.name}

金额: ${formatCurrency(snapshot.amount)} (${snapshot.type})

备注: ${snapshot.remarks || "无"}

此记录于 ${new Date(history[historyIndex].timestamp).toLocaleString()} 被修改存档。

`; dom.modal.classList.remove("modal-large"); showModal("历史记录快照", content, [ { text: "返回详情", class: "themed-button-secondary border px-4 py-2 rounded", handler: () => { showGiftDetailsModal(giftIndex); }, keepOpen: true, }, ]); }; document.querySelectorAll(".btn-view-original, .btn-view-snapshot").forEach((btn) => { btn.onclick = (e) => showSnapshot(parseInt(e.target.dataset.historyIndex)); }); } /** * 激活内联修改模式 (将显示区域替换为输入框和保存/取消按钮) * @param {number} giftIndex * @param {'name'|'amount'|'remarks'} fieldType - 要修改的字段类型 */ function enableInlineEdit(giftIndex, fieldType) { const g = gifts[giftIndex].data; let targetAreaId, editHtml, saveHandler; // 禁用其他修改按钮,防止并发修改 const allButtons = ["btn-correct-name", "btn-modify-amount", "btn-edit-remarks"]; allButtons.forEach((btnId) => { const btn = document.getElementById(btnId); if (btn) { btn.disabled = true; btn.classList.add("opacity-50", "cursor-not-allowed"); } }); // 通用的取消操作:重新渲染详情弹窗,所有状态都会恢复 const cancelHandler = () => showGiftDetailsModal(giftIndex); const cancelBtnHtml = ``; const saveBtnHtml = ``; // 根据修改类型,定义HTML和保存逻辑 if (fieldType === "name") { targetAreaId = "name-display-area"; editHtml = `
${cancelBtnHtml}${saveBtnHtml}
`; saveHandler = async () => { const newName = document.getElementById("inline-edit-name").value.trim(); if (!newName || newName === g.name) return cancelHandler(); // 无变化或为空则取消 const changeLog = `将姓名由 "${g.name}" 更正为 "${newName}"`; await performUpdate(giftIndex, { name: newName }, changeLog, "correction"); }; } else if (fieldType === "amount") { targetAreaId = "amount-display-area"; editHtml = `
${["现金", "支付宝", "微信", "其他"] .map( (type) => ` ` ) .join("")}
${cancelBtnHtml}${saveBtnHtml}
`; saveHandler = async () => { const newAmount = parseFloat(document.getElementById("inline-edit-amount").value); const newType = document.querySelector('input[name="inlineEditType"]:checked').value; if (isNaN(newAmount) || newAmount < 0) return showAlert("错误", "请输入有效金额"); if (newAmount === g.amount && newType === g.type) return cancelHandler(); // 无变化则取消 let logs = []; if (newAmount !== g.amount) logs.push(`将金额由 ${g.amount} 修改为 ${newAmount}`); if (newType !== g.type) logs.push(`将类型由 ${g.type} 修改为 ${newType}`); await performUpdate(giftIndex, { amount: newAmount, type: newType }, logs.join(","), "correction"); }; } else if (fieldType === "remarks") { targetAreaId = "remarks-display-area"; editHtml = `
${cancelBtnHtml}${saveBtnHtml}
`; saveHandler = async () => { const newRemarks = document.getElementById("inline-edit-remarks").value.trim(); if (newRemarks === (g.remarks || "")) return cancelHandler(); const oldRemarksText = g.remarks || "(空)"; const newRemarksText = newRemarks || "(空)"; const changeLog = `将备注由 "${oldRemarksText}" 修改为 "${newRemarksText}"`; await performUpdate(giftIndex, { remarks: newRemarks }, changeLog, "remark"); }; } // 执行DOM替换和事件绑定 const targetArea = document.getElementById(targetAreaId); if (targetArea) { targetArea.innerHTML = editHtml; // 将显示区域替换为修改区域 document.getElementById("inline-cancel").onclick = cancelHandler; document.getElementById("inline-save").onclick = saveHandler; // 隐藏主弹窗的"关闭"按钮,避免用户在修改时关闭弹窗导致状态混乱 document.getElementById("modal-actions").classList.add("hidden"); } } // --- 功能模块 (打印, 导出, 统计, 搜索) --- /** * 生成所有用于打印的页面 HTML 内容,并触发浏览器打印功能。 */ function prepareForPrint() { if (gifts.length === 0 && expenses.length === 0) { return showAlert("无法打印", "当前事项没有任何礼金或支出记录,无需打印。"); } let printView = document.getElementById("print-view"); if (printView) printView.remove(); printView = document.createElement("div"); printView.id = "print-view"; const eventCreationDate = new Date(currentEvent.startDateTime).toLocaleDateString("zh-CN"); document.title = `${currentEvent.name}所有记录-${eventCreationDate}`; const allPrintPages = []; // 收集所有要打印的页面元素 // 1. 添加封面页 if (currentEvent.coverImage) { const coverPage = document.createElement("div"); coverPage.className = `print-page print-cover-page ${currentEvent.theme}`; const coverImg = document.createElement("img"); coverImg.src = currentEvent.coverImage; coverPage.appendChild(coverImg); allPrintPages.push(coverPage); } else { // 没有自定义封面图时,生成默认封面 const defaultCoverPage = generateDefaultCoverPage(eventCreationDate); allPrintPages.push(defaultCoverPage); } // 2. 添加礼金明细页 (如果有礼金记录) if (gifts.length > 0) { const totalGiftPages = Math.ceil(gifts.length / ITEMS_PER_PAGE) || 1; for (let i = 0; i < totalGiftPages; i++) { const pageGifts = gifts.slice(i * ITEMS_PER_PAGE, (i + 1) * ITEMS_PER_PAGE); const pageContainer = document.createElement("div"); pageContainer.className = "print-page"; const content = document.createElement("div"); content.className = "print-book-content"; pageContainer.innerHTML += ``; renderGiftBook(content, pageGifts); pageContainer.appendChild(content); const pageSubtotal = pageGifts.reduce((sum, gift) => sum + (gift.data?.amount || 0), 0); const stats = calculateGiftStats(); const isLastPage = i === totalGiftPages - 1; pageContainer.innerHTML += ` `; allPrintPages.push(pageContainer); } // 3. 添加礼金附录页 (如果有礼金备注) const appendixSourceGifts = gifts.map((gift, index) => ({ ...gift, originalIndex: index })); const appendixPages = generateAppendixPages(eventCreationDate, appendixSourceGifts); allPrintPages.push(...appendixPages); } // 4. 添加支出明细页 (如果有支出记录) if (expenses.length > 0) { const expensePages = generateExpensePrintPages(eventCreationDate); allPrintPages.push(...expensePages); } // 5. 应用强制分页样式 (除了最后一页) for (let i = 0; i < allPrintPages.length - 1; i++) { allPrintPages[i].classList.add("force-page-break"); } // 6. 将所有页面添加到 printView allPrintPages.forEach((page) => printView.appendChild(page)); document.body.appendChild(printView); document.body.classList.add("printing"); // 确保图片加载完成后再打印,或者在图片加载失败时直接打印 const images = printView.querySelectorAll("img"); if (images.length > 0) { let loadedImages = 0; images.forEach((img) => { if (img.complete) { loadedImages++; } else { img.onload = () => { loadedImages++; if (loadedImages === images.length) { setTimeout(triggerPrint, 0); } }; img.onerror = () => { console.warn("图片加载失败,不影响打印。"); loadedImages++; if (loadedImages === images.length) { setTimeout(triggerPrint, 0); } }; } }); if (loadedImages === images.length) { // All images were already complete setTimeout(triggerPrint, 0); } } else { // No images, print immediately setTimeout(triggerPrint, 0); } function triggerPrint() { window.print(); setTimeout(() => { document.body.classList.remove("printing"); document.getElementById("print-view")?.remove(); }, 100); } } /** * 生成默认的账簿封面页。 * @param {string} eventCreationDate - 事项创建日期。 * @returns {HTMLElement} - 生成的封面页元素。 */ function generateDefaultCoverPage(eventCreationDate) { const coverPage = document.createElement("div"); coverPage.className = `print-page print-cover-page ${currentEvent.theme}`; // 添加主题类 coverPage.innerHTML = `

${currentEvent.name}

礼金簿

日期:${eventCreationDate}

`; return coverPage; } /** * 生成并返回备注附录页面元素数组。 * @param {string} dateString - 日期字符串。 * @param {Array} giftSourceArray - 用于生成附录的礼金记录数组(可以是全部或一个部分)。 * @returns {Array} - 附录页面元素数组。 */ function generateAppendixPages(dateString, giftSourceArray) { const appendixPages = []; const remarkedItems = giftSourceArray.filter((g) => g.data && g.data.remarks); if (remarkedItems.length === 0) return appendixPages; const totalAppendixPages = Math.ceil(remarkedItems.length / APPENDIX_ROWS_PER_PAGE); for (let j = 0; j < totalAppendixPages; j++) { const pageItems = remarkedItems.slice(j * APPENDIX_ROWS_PER_PAGE, (j + 1) * APPENDIX_ROWS_PER_PAGE); const appendixPage = document.createElement("div"); appendixPage.className = "print-page"; if (j === 0) { appendixPage.style.pageBreakBefore = "always"; } const sideTitle = document.createElement("div"); sideTitle.className = "print-side-title"; sideTitle.textContent = "附录:宾客备注"; appendixPage.appendChild(sideTitle); const table = document.createElement("table"); table.className = "print-appendix-table"; table.style.fontSize = "12px"; const thead = document.createElement("thead"); thead.innerHTML = ` 姓名 记录位置 备注信息 `; table.appendChild(thead); const tbody = document.createElement("tbody"); pageItems.forEach((item) => { const tr = document.createElement("tr"); tr.style.pageBreakInside = "avoid"; tr.innerHTML = ` ${item.data.name} 第 ${Math.floor(item.originalIndex / ITEMS_PER_PAGE) + 1} 页第 ${(item.originalIndex % ITEMS_PER_PAGE) + 1} 位 ${item.data.remarks} `; tbody.appendChild(tr); }); table.appendChild(tbody); appendixPage.appendChild(table); // <-- 修正:将表格添加到页面中 const footer = document.createElement("div"); // <-- 修正:在表格后创建并添加页脚 footer.className = "print-footer"; footer.innerHTML = `

日期: ${dateString}

`; appendixPage.appendChild(footer); appendixPages.push(appendixPage); } return appendixPages; } /** * 生成并返回支出记录的打印页面元素数组。 * @param {string} dateString - 事项创建日期字符串。 * @returns {Array} - 支出记录打印页面元素数组。 */ function generateExpensePrintPages(dateString) { const expensePrintPages = []; if (expenses.length === 0) return expensePrintPages; const EXPENSE_ROWS_PER_PAGE = 25; // 每页显示约25行支出记录 const totalExpensePages = Math.ceil(expenses.length / EXPENSE_ROWS_PER_PAGE) || 1; const totalExpensesAmount = expenses.reduce((sum, exp) => sum + (exp.data?.amount || 0), 0); for (let i = 0; i < totalExpensePages; i++) { const pageExpenses = expenses.slice(i * EXPENSE_ROWS_PER_PAGE, (i + 1) * EXPENSE_ROWS_PER_PAGE); const expensePage = document.createElement("div"); expensePage.className = "print-page"; if (i === 0) { expensePage.style.pageBreakBefore = "always"; // 确保支出记录从新的一页开始 } expensePage.innerHTML = `

${currentEvent.name} 支出明细

${pageExpenses .map( (exp) => ` ` ) .join("")} `; expensePrintPages.push(expensePage); } return expensePrintPages; } /** * 将当前礼金数据导出为 Excel 文件。 */ function exportToExcel() { const formatHistoryToString = (historyArray) => { if (!historyArray || historyArray.length === 0) { return "无修改记录"; } return [...historyArray] .reverse() .map((record, index) => { const recordTime = new Date(record.timestamp).toLocaleString("zh-CN"); return `[${index + 1}] ${recordTime}: ${record.changeLog}`; }) .join("\n"); }; const dataToExport = gifts.map((g) => ({ 姓名: g.data.name, 金额: g.data.amount, 收款类型: g.data.type, 备注: g.data.remarks, 最新更新时间: new Date(g.data.timestamp).toLocaleString("zh-CN"), 修改日志: formatHistoryToString(g.data.history), })); if (dataToExport.length > 0) { const stats = calculateGiftStats(); dataToExport.push({}); dataToExport.push({ 姓名: "总计", 金额: stats.totalAmount, 收款类型: `共 ${stats.totalGivers} 人`, }); } const worksheet = XLSX.utils.json_to_sheet(dataToExport); const columnWidths = [ { wch: 15 }, // 姓名 { wch: 12 }, // 金额 { wch: 12 }, // 收款类型 { wch: 30 }, // 备注 { wch: 22 }, // 最新更新时间 { wch: 60 }, // 修改日志 ]; worksheet["!cols"] = columnWidths; const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, "礼金明细"); XLSX.writeFile(workbook, `${currentEvent.name}_礼金明细.xlsx`); showNotification("礼金Excel导出成功!", "success"); } /** * 将当前事项的所有数据导出为JSON文件 (原始备份格式)。 */ function exportAllDataToJSON() { if (!currentEvent) { return showAlert("提示", "当前没有事项可供导出。"); } if (gifts.length === 0 && expenses.length === 0) { return showAlert("提示", "当前事项没有任何礼金或支出记录可供导出。"); } const exportData = { version: "1.1", // Indicate combined data structure exportTime: new Date().toISOString(), event: { id: currentEvent.id, name: currentEvent.name, startDateTime: currentEvent.startDateTime, endDateTime: currentEvent.endDateTime, theme: currentEvent.theme, voiceName: currentEvent.voiceName || "", coverImage: currentEvent.coverImage || null, minSpeechAmount: currentEvent.minSpeechAmount || 0, // 注意:这里不导出 passwordHash,因为备份文件不应该包含明文密码或哈希 }, gifts: gifts.map((gift) => gift.data).filter((data) => data !== null), expenses: expenses.map((exp) => exp.data).filter((data) => data !== null), }; const jsonString = JSON.stringify(exportData, null, 2); const blob = new Blob([jsonString], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${currentEvent.name}_所有数据备份_${new Date().toISOString().split("T")[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showNotification("所有数据JSON导出成功!", "success"); } /** * 导出适配 "人情往来" 应用的 JSON 格式。 * 按照提供的示例格式进行数据映射。 */ function exportToRenqingWanglaiJSON() { if (!currentEvent) { return showAlert("提示", "当前没有事项可供导出。"); } if (gifts.length === 0 && expenses.length === 0) { return showAlert("提示", "当前事项没有任何礼金或支出记录可供导出。"); } // 从 currentEvent.startDateTime 中提取日期部分 (格式如 "YYYY-MM-DD") const accountingDate = currentEvent.startDateTime ? currentEvent.startDateTime.split('T')[0] : ""; const exportData = { proxyBasicInfo: { organizer: currentEvent.name, // publicAffairType 和 location 在当前应用的数据结构中没有直接的输入字段。 // 如果需要这些字段,请在应用中添加相应的输入项。 // 这里暂时使用空字符串作为占位符,以匹配示例格式。 publicAffairType: "", // 示例中为 "乔迁宴",当前应用无此字段 accountingDate: accountingDate, location: "", // 示例中为 "山口北村",当前应用无此字段 isSet: true }, proxyGifts: gifts.map((gift) => ({ giver: gift.data.name, // relationship 和 attended 在当前应用的数据结构中没有直接的输入字段。 // 如果需要这些字段,请在应用中添加相应的输入项。 // 这里暂时使用空字符串和默认值作为占位符,以匹配示例格式。 relationship: "", // 示例中为 "同学",当前应用无此字段 amount: gift.data.amount, note: gift.data.remarks || "", // 备注,如果为空则为 "" attended: "参加" // 示例中为 "参加",当前应用无此字段,默认为“参加” })), // 示例中 proxyExpenses 为空数组,因此这里也映射为空数组。 // 如果您需要导出支出数据,请提供其具体的结构,并修改此部分。 proxyExpenses: [], exportDate: new Date().toISOString() // 导出时间,ISO 8601 格式 }; const jsonString = JSON.stringify(exportData, null, 2); const blob = new Blob([jsonString], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${currentEvent.name}_人情往来适配数据_${new Date().toISOString().split("T")[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showNotification("人情往来JSON导出成功!", "success"); } /** * 从JSON文件导入所有数据 (支持两种格式)。 */ function importAllDataFromJSON() { const input = document.createElement("input"); input.type = "file"; input.accept = "application/json"; input.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (event) => { try { const importedData = JSON.parse(event.target.result); let eventToImport = null; let giftsToImport = []; let expensesToImport = []; let importPassword = null; // --- 格式判断与数据转换 --- if (importedData.version === "1.1" && importedData.event && importedData.gifts !== undefined) { // 这是我们应用自己的备份格式 eventToImport = importedData.event; giftsToImport = importedData.gifts; expensesToImport = importedData.expenses || []; // 提示用户输入密码以解密数据 const passwordPromptContent = `

请输入原事项的管理密码以导入并解密数据。

`; const passwordProvided = await new Promise((resolve) => { showModal("导入备份", passwordPromptContent, [ { text: "取消", class: "themed-button-secondary border px-4 py-2 rounded", handler: () => resolve(null) }, { text: "确认", class: "themed-button-primary px-4 py-2 rounded", handler: () => resolve(document.getElementById("import-password").value), keepOpen: true }, ]); setTimeout(() => document.getElementById("import-password")?.focus(), 50); }); if (passwordProvided === null) { showAlert("导入取消", "您取消了导入操作。"); return; } importPassword = passwordProvided; // 验证密码是否正确 (通过尝试解密一个礼金来验证) if (giftsToImport.length > 0) { const testGift = giftsToImport[0]; const decryptedTest = decryptData(encryptData(testGift, importPassword), importPassword); if (!decryptedTest) { showAlert("密码错误", "您输入的密码不正确,无法解密数据。导入失败。"); return; } } } else if (importedData.proxyBasicInfo && importedData.proxyGifts !== undefined) { // 这是“人情往来”适配格式 const organizerName = importedData.proxyBasicInfo.organizer || "导入事项"; const accountingDate = importedData.proxyBasicInfo.accountingDate || new Date().toISOString().split('T')[0]; // 提示用户为新导入的事项设置密码 const newPasswordPromptContent = `

导入“人情往来”数据将创建一个新事项。请为新事项设置一个管理密码:

`; const newEventPassword = await new Promise((resolve) => { showModal(`设置事项密码 - ${organizerName}`, newPasswordPromptContent, [ { text: "取消", class: "themed-button-secondary border px-4 py-2 rounded", handler: () => resolve(null) }, { text: "确认", class: "themed-button-primary px-4 py-2 rounded", handler: () => resolve(document.getElementById("new-event-password").value), keepOpen: true }, ]); setTimeout(() => document.getElementById("new-event-password")?.focus(), 50); }); if (newEventPassword === null || newEventPassword.trim() === "") { showAlert("导入取消", "未设置密码,导入操作已取消。"); return; } importPassword = newEventPassword; // 构建新的 event 对象 eventToImport = { name: organizerName, startDateTime: `${accountingDate}T00:00:00.000Z`, // 默认当天开始 endDateTime: `${accountingDate}T23:59:59.999Z`, // 默认当天结束 passwordHash: hashPassword(importPassword), theme: "theme-festive", // 默认喜庆主题 voiceName: "", // 默认语音 coverImage: null, // 无封面图 minSpeechAmount: 0, // 默认全部播报 }; // 转换 gifts 数据 giftsToImport = importedData.proxyGifts.map((pg) => ({ name: pg.giver, amount: pg.amount, type: "现金", // “人情往来”格式没有收款类型,默认现金 remarks: pg.note || "", timestamp: new Date().toISOString(), // 使用当前时间作为录入时间 })); expensesToImport = []; // 示例中是空数组,且结构不匹配,暂时忽略 showNotification("正在导入“人情往来”数据,请稍候...", "info"); } else { showAlert("错误", "导入的JSON文件格式不识别,请确保文件是本系统的备份文件或适配“人情往来”的导出文件。"); return; } // --- 导入数据到数据库 --- const transaction = db.transaction(["events", "gifts", "expenses"], "readwrite"); const eventsStore = transaction.objectStore("events"); const giftsStore = transaction.objectStore("gifts"); const expensesStore = transaction.objectStore("expenses"); // 添加新事项 const newEventId = await promisifyRequest(eventsStore.add(eventToImport)); // 添加礼金记录 for (const giftData of giftsToImport) { const encryptedData = encryptData(giftData, importPassword); await promisifyRequest(giftsStore.add({ eventId: newEventId, encryptedData })); } // 添加支出记录 (如果有的话) for (const expenseData of expensesToImport) { const encryptedData = encryptData(expenseData, importPassword); await promisifyRequest(expensesStore.add({ eventId: newEventId, encryptedData })); } await promisifyRequest(transaction); // 等待事务完成 showAlert("导入成功", `事项 "${eventToImport.name}" 及其所有数据已成功导入!`, () => { loadEvents(); // 重新加载事项列表 showSetupScreen(); // 返回选择事项界面 }); } catch (error) { console.error("导入数据失败:", error); showAlert("导入失败", "解析文件或保存数据时出错,请检查文件内容是否正确。"); } }; reader.readAsText(file); }; input.click(); } /** * 计算礼金统计数据。 * @returns {{totalAmount: number, totalGivers: number, byType: object}} */ function calculateGiftStats() { const stats = { totalAmount: 0, totalGivers: gifts.length, byType: { 现金: 0, 支付宝: 0, 微信: 0, 其他: 0 }, }; gifts.forEach(({ data }) => { if (data) { stats.totalAmount += data.amount; stats.byType[data.type] = (stats.byType[data.type] || 0) + data.amount; } }); return stats; } /** * 显示礼金统计数据弹窗,包含汇总信息和可搜索、排序的表格。 */ function showGiftStatistics() { dom.modal.classList.add("modal-large"); const stats = calculateGiftStats(); const statsHtml = `
总送礼人数: ${stats.totalGivers} 人
总礼金金额: ${formatCurrency(stats.totalAmount)}

按收款方式统计:

    ${Object.entries(stats.byType) .map(([type, amount]) => `
  • ${type}: ${formatCurrency(amount)}
  • `) .join("")}
`; showModal("礼金统计详情", statsHtml, [{ text: "关闭", class: "border themed-button-secondary px-4 py-2 rounded" }]); const tableData = gifts.map((g) => [g.data.name, g.data.amount, g.data.remarks || "无", g.data.type, new Date(g.data.timestamp).toLocaleString("zh-CN")]); new gridjs.Grid({ columns: ["姓名", "金额 (元)", "备注", "收款类型", "录入时间"], data: tableData, search: true, sort: true, fixedHeader: true, width: "100%", height: "50vh", language: { search: { placeholder: "搜索...", }, pagination: { previous: "上一页", next: "下一页", showing: "显示", results: () => "条结果", to: "到", of: "共" }, loading: "加载中...", noRecordsFound: "未找到匹配的记录", error: "获取数据时发生错误", }, style: { th: { "background-color": "var(--primary-color)", color: "#fff" } }, }).render(document.getElementById("grid-container")); } /** * 显示支出统计数据弹窗。 */ function showExpenseStatistics() { dom.modal.classList.add("modal-large"); const totalExpenses = expenses.reduce((sum, exp) => sum + (exp.data?.amount || 0), 0); const statsHtml = `
总支出项目数: ${expenses.length} 项
总支出金额: ${formatCurrency(totalExpenses)}
`; showModal("支出统计详情", statsHtml, [{ text: "关闭", class: "border themed-button-secondary px-4 py-2 rounded" }]); const tableData = expenses .sort((a, b) => new Date(b.data.date) - new Date(a.data.date)) .map((exp) => [exp.data.name, exp.data.amount, exp.data.date, exp.data.remarks || "无"]); new gridjs.Grid({ columns: ["支出项目", "金额 (元)", "日期", "备注"], data: tableData, search: true, sort: true, fixedHeader: true, width: "100%", height: "50vh", language: { search: { placeholder: "搜索...", }, pagination: { previous: "上一页", next: "下一页", showing: "显示", results: () => "条结果", to: "到", of: "共" }, loading: "加载中...", noRecordsFound: "未找到匹配的记录", error: "获取数据时发生错误", }, style: { th: { "background-color": "var(--primary-color)", color: "#fff" } }, }).render(document.getElementById("expense-stats-grid-container")); } /** * 处理姓名搜索。 */ function handleSearch() { const searchTerm = dom.searchNameInput.value.trim(); if (!searchTerm) { return showAlert("提示", "请输入姓名进行搜索。", false); } const results = gifts.map((g, index) => ({ ...g, originalIndex: index })).filter((g) => g.data?.name.includes(searchTerm)); dom.searchNameInput.blur(); if (results.length === 0) { return showAlert("查找结果", `没有找到姓名为 "${searchTerm}" 的记录。`); } const resultsHtml = results .map( (r) => `

姓名: ${r.data.name}

金额: ${r.data.amount.toFixed(2)} 元 (${r.data.type})

${r.data.remarks ? `

备注: ${r.data.remarks}

` : ""}
` ) .join(""); showModal(`"${searchTerm}" 的搜索结果`, `
${resultsHtml}
`, [{ text: "关闭", class: "themed-button-secondary border px-4 py-2 rounded" }]); } /** * 显示一个用于修改当前事项名称、礼簿封面图和语音音色的弹窗。 */ async function showEditEventInfoModal() { let coverImageForUpdate = currentEvent.coverImage; let shouldRemoveCover = false; const content = `

只有大于或等于此金额的礼金才会被播报。设置为0则全部播报。

封面预览 ${currentEvent.coverImage ? '' : ""}

尺寸建议842 x 595px, 格式jpg/PNG, 大小不超过2M。

`; showModal("设置事项", content, [ { text: "取消", class: "themed-button-secondary border px-4 py-2 rounded" }, { text: "保存", class: "themed-button-primary px-4 py-2 rounded", handler: async () => { const newName = document.getElementById("edit-event-name-input").value.trim(); const newVoiceName = document.getElementById("edit-event-voice").value; const newMinSpeechAmount = parseFloat(document.getElementById("edit-min-speech-amount").value) || 0; if (!newName) return showAlert("错误", "事项名称不能为空!"); const newCoverFile = document.getElementById("edit-cover-image-upload").files[0]; if (newCoverFile) { if (!isCoverFileSizeValid(newCoverFile)) return; try { coverImageForUpdate = await readFileAsBase64(newCoverFile); } catch (error) { console.error("礼簿封面图读取失败:", error); return showAlert("错误", "礼簿封面图读取失败,请尝试其他图片。"); } } else if (shouldRemoveCover) { coverImageForUpdate = null; } const updatedEvent = { ...currentEvent, name: newName, voiceName: newVoiceName, coverImage: coverImageForUpdate, minSpeechAmount: newMinSpeechAmount }; try { await promisifyRequest(db.transaction("events", "readwrite").objectStore("events").put(updatedEvent)); currentEvent.name = newName; currentEvent.voiceName = newVoiceName; currentEvent.coverImage = coverImageForUpdate; currentEvent.minSpeechAmount = newMinSpeechAmount; dom.currentEventTitleEl.textContent = newName; const sessionData = JSON.parse(sessionStorage.getItem("activeEventSession")); if (sessionData) { sessionStorage.setItem("activeEventSession", JSON.stringify({ event: currentEvent, encryptedPassword: sessionData.encryptedPassword, mode: currentMode })); } showAlert("成功", "事项设置修改成功。", false); } catch (error) { showAlert("错误", "事项设置修改失败。"); } }, }, ]); const voiceSelectElement = document.getElementById("edit-event-voice"); populateVoiceList(voiceSelectElement, currentEvent.voiceName); if (speechSynthesis.onvoiceschanged !== undefined) { speechSynthesis.onvoiceschanged = () => populateVoiceList(voiceSelectElement, currentEvent.voiceName); } document.getElementById("preview-edit-voice-btn").addEventListener("click", () => { previewSelectedVoice(voiceSelectElement); }); const fileInput = document.getElementById("edit-cover-image-upload"); const preview = document.getElementById("edit-cover-preview"); const removeBtn = document.getElementById("remove-cover-btn"); fileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (e) => { preview.src = e.target.result; preview.classList.remove("hidden"); shouldRemoveCover = false; if (removeBtn) { removeBtn.textContent = "撤销选择"; removeBtn.disabled = false; } }; reader.readAsDataURL(file); } }); if (removeBtn) { removeBtn.onclick = () => { shouldRemoveCover = true; coverImageForUpdate = null; preview.classList.add("hidden"); preview.src = ""; fileInput.value = ""; removeBtn.textContent = "礼簿封面图已删除"; removeBtn.disabled = true; }; } } /** * 核心:执行带留痕的更新操作 * @param {number} giftIndex - 礼金记录在 gifts 数组中的索引 * @param {object} newFields - 一个包含要更新字段的对象,例如 { name: "新名字" } 或 { remarks: "新备注" } * @param {string} changeLogText - 用于写入历史记录的变更描述文本 * @param {'correction' | 'remark'} updateType - 更新的类型,'correction' (纠错) 需要强制密码,'remark' (备注) 仅在超时后需要密码 */ async function performUpdate(giftIndex, newFields, changeLogText, updateType) { // 1. 根据更新类型验证权限 let authorized = false; if (updateType === "correction") { // 对于姓名、金额等关键信息修改,总是要求输入密码 authorized = await requestAdminPassword("修改确认", "此修改将被永久记录在案。请输入管理密码以继续。"); } else if (updateType === "remark") { // 对于备注修改,只在超出事项时间范围时要求密码 const isOutOfTime = new Date() < new Date(currentEvent.startDateTime) || new Date() > new Date(currentEvent.endDateTime); if (isOutOfTime) { authorized = await requestAdminPassword("备注补录", "当前已超出有效录入时间,请输入管理密码以修改备注。"); } else { authorized = true; // 在有效时间内修改备注,无需密码 } } if (!authorized) { showAlert("错误", "管理密码不正确或操作已取消,修改未保存。", () => { showGiftDetailsModal(giftIndex); }); return; } const giftObject = gifts[giftIndex]; const currentData = { ...giftObject.data }; // 浅拷贝当前数据作为快照基础 const now = new Date().toISOString(); // 2. 创建历史记录条目 const snapshot = { name: currentData.name, amount: currentData.amount, type: currentData.type, remarks: currentData.remarks, timestamp: currentData.timestamp, }; const historyEntry = { timestamp: now, // 修改发生的时刻 changeLog: changeLogText, snapshot: snapshot, type: updateType, // 使用传入的类型 }; // 3. 准备新数据 const updatedData = { ...currentData, ...newFields, // 应用新字段 timestamp: now, // 更新主记录时间 history: currentData.history ? [...currentData.history, historyEntry] : [historyEntry], // 追加历史 }; // 4. 加密并保存到数据库 try { const encryptedData = encryptData(updatedData, currentPassword); const recordToUpdate = { ...giftObject, encryptedData }; await promisifyRequest(db.transaction("gifts", "readwrite").objectStore("gifts").put(recordToUpdate)); // 5. 更新内存和 UI gifts[giftIndex].data = updatedData; gifts[giftIndex].encryptedData = encryptedData; render(); // 刷新礼簿主界面 closeModal(); // 短暂延迟后重新打开详情页,展示更新后的状态和时间轴 setTimeout(() => { showGiftDetailsModal(giftIndex); }, 300); } catch (error) { console.error("保存失败", error); showAlert("系统错误", "保存修改记录时失败。您的修改未生效。", () => { showGiftDetailsModal(giftIndex); }); } } /** * 删除礼金记录 * @param {number} giftIndex - 要删除的礼金记录索引 */ async function deleteGiftRecord(giftIndex) { const gift = gifts[giftIndex]; if (!gift) return; // 请求管理密码确认 const authorized = await requestAdminPassword("删除确认", "此操作将永久删除此条礼金记录,且无法恢复。请输入管理密码以继续。"); if (!authorized) { showAlert("错误", "管理密码不正确或操作已取消,删除未执行。"); return; } try { // 从数据库中删除 await promisifyRequest(db.transaction("gifts", "readwrite").objectStore("gifts").delete(gift.id)); // 从内存中删除 gifts.splice(giftIndex, 1); // 重新计算当前页 const totalPages = Math.ceil(gifts.length / ITEMS_PER_PAGE) || 1; if (currentPage > totalPages) { currentPage = totalPages; } // 重新渲染 render(); closeModal(); showNotification("礼金记录删除成功!", "success"); } catch (error) { console.error("删除礼金记录失败:", error); showAlert("错误", "删除礼金记录时发生错误,请重试。"); } } /** * 删除支出记录 * @param {number} expenseId - 要删除的支出记录 ID */ async function deleteExpenseRecord(expenseId) { const expenseIndex = expenses.findIndex((exp) => exp.id === expenseId); if (expenseIndex === -1) return; const authorized = await requestAdminPassword("删除确认", "此操作将永久删除此条支出记录,且无法恢复。请输入管理密码以继续。"); if (!authorized) { showAlert("错误", "管理密码不正确或操作已取消,删除未执行。"); return; } try { await promisifyRequest(db.transaction("expenses", "readwrite").objectStore("expenses").delete(expenseId)); expenses.splice(expenseIndex, 1); renderExpenseTable(); updateExpenseTotals(); showNotification("支出记录删除成功!", "success"); } catch (error) { console.error("删除支出记录失败:", error); showAlert("错误", "删除支出记录时发生错误,请重试。"); } } /** * 触发删除当前事项的流程,需要密码确认。 */ async function deleteCurrentEvent() { if (!currentEvent) return; const content = `

此操作将永久删除事项 "${currentEvent.name}" 及其所有礼金和支出记录,且无法恢复。

请输入管理密码以确认:

`; showModal(`删除确认`, content, [ { text: "取消", class: "themed-button-secondary border px-4 py-2 rounded" }, { text: "确认删除", class: "themed-button-primary text-white px-4 py-2 rounded", handler: async () => { const passwordInput = document.getElementById("delete-confirm-password").value; if (hashPassword(passwordInput) !== currentEvent.passwordHash) { return showAlert("错误", "管理密码错误,删除操作已取消。"); } try { const transaction = db.transaction(["events", "gifts", "expenses"], "readwrite"); const eventsStore = transaction.objectStore("events"); const giftsStore = transaction.objectStore("gifts"); const expensesStore = transaction.objectStore("expenses"); // 删除所有关联的礼金记录 const giftsIndex = giftsStore.index("eventId"); const giftKeys = await promisifyRequest(giftsIndex.getAllKeys(currentEvent.id)); giftKeys.forEach((key) => giftsStore.delete(key)); // 删除所有关联的支出记录 const expensesIndex = expensesStore.index("eventId"); const expenseKeys = await promisifyRequest(expensesIndex.getAllKeys(currentEvent.id)); expenseKeys.forEach((key) => expensesStore.delete(key)); // 删除事项本身 eventsStore.delete(currentEvent.id); await new Promise((resolve) => (transaction.oncomplete = resolve)); showAlert("成功", `事项 "${currentEvent.name}" 已被成功删除。`, false); showSetupScreen(); // 返回到设置界面 } catch (error) { console.error("删除事项时发生错误:", error); showAlert("删除失败", "删除过程中发生错误。"); } }, }, ]); setTimeout(() => document.getElementById("delete-confirm-password")?.focus(), 50); } // --- 工具与辅助函数 --- const formatCurrency = (amount) => new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(amount || 0); /** * 使用浏览器语音合成API播报礼金信息。 * 如果事项设置了特定音色,则使用该音色。 * @param {string} name - 姓名。 * @param {number} amount - 金额。 */ function speakGift(name, amount) { if (currentMode !== "gifts") return; // Only speak in gift mode const minAmount = currentEvent.minSpeechAmount || 0; if (!isSpeechEnabled || !("speechSynthesis" in window) || amount < minAmount) return; const ttsText = amountToChinese(amount).replace(/陆/g, "六"); const textToSpeak = currentEvent.theme === "theme-solemn" ? `${name},${ttsText}` : `${name} 贺礼 ${ttsText}`; const utterance = new SpeechSynthesisUtterance(textToSpeak); utterance.lang = "zh-CN"; if (currentEvent.voiceName) { const voices = speechSynthesis.getVoices(); const selectedVoice = voices.find((voice) => voice.name === currentEvent.voiceName); if (selectedVoice) { utterance.voice = selectedVoice; } } speechSynthesis.speak(utterance); } /** * 播放使用指定 元素中。 * @param {HTMLSelectElement} selectElement - 用于填充音色的下拉框元素。 * @param {string} [selectedValue] - (可选)需要默认选中的音色名称。 */ function populateVoiceList(selectElement, selectedValue) { const voices = speechSynthesis.getVoices().filter((v) => v.lang.startsWith("zh")); if (!selectElement || voices.length === 0) return; const currentVal = selectElement.value; // 保存当前可能已选择的值 selectElement.innerHTML = ''; voices.forEach((voice) => { const option = document.createElement("option"); option.value = voice.name; option.textContent = `${voice.name} (${voice.lang})`; selectElement.appendChild(option); }); // 尝试恢复之前的选择或设置传入的默认值 selectElement.value = selectedValue || currentVal || ""; } // --- 事件监听器绑定 --- /** * 统一设置应用的所有事件监听器。 */ function bindEventListeners() { // 账本导出图片 dom.exportPageImgBtn.addEventListener("click", exportCurrentPageImage); // 设置界面 dom.createEventForm.addEventListener("submit", handleCreateEventSubmit); dom.unlockEventBtn.addEventListener("click", () => { const eventId = parseInt(dom.eventSelector.value, 10); if (eventId) handleUnlockEvent(eventId); else showAlert("提示", "请先选择一个事项。"); }); dom.coverImageUpload.addEventListener("change", (event) => { const file = event.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (e) => { dom.coverPreview.src = e.target.result; dom.coverPreview.classList.remove("hidden"); }; reader.readAsDataURL(file); } else { dom.coverPreview.src = ""; dom.coverPreview.classList.add("hidden"); } }); // 模式切换按钮 dom.modeGiftsBtn.addEventListener("click", () => switchMode("gifts")); dom.modeExpensesBtn.addEventListener("click", () => switchMode("expenses")); // 礼金相关事件 dom.addGiftForm.addEventListener("submit", handleAddGiftSubmit); dom.remarksInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); dom.addGiftForm.querySelector('button[type="submit"]')?.click(); } }); dom.prevPageBtn.addEventListener("click", () => { if (currentPage > 1) { currentPage--; ensureCurrentPageDecrypted(); render(); } }); dom.nextPageBtn.addEventListener("click", () => { const totalPages = Math.ceil(gifts.length / ITEMS_PER_PAGE) || 1; if (currentPage < totalPages) { currentPage++; ensureCurrentPageDecrypted(); render(); } }); dom.searchIcon.addEventListener("click", handleSearch); dom.searchNameInput.addEventListener("keyup", (e) => e.key === "Enter" && handleSearch()); dom.exportExcelBtn.addEventListener("click", exportToExcel); dom.showGiftStatsBtn.addEventListener("click", showGiftStatistics); dom.speechToggle.addEventListener("change", (e) => (isSpeechEnabled = e.target.checked)); // 支出相关事件 dom.addExpenseForm.addEventListener("submit", handleAddExpenseSubmit); dom.expenseRemarksInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); dom.addExpenseForm.querySelector('button[type="submit"]')?.click(); } }); dom.showExpenseStatsBtn.addEventListener("click", showExpenseStatistics); // 通用功能事件 dom.exportAllJsonBtn.addEventListener("click", exportAllDataToJSON); dom.exportRenqingWanglaiJsonBtn.addEventListener("click", exportToRenqingWanglaiJSON); // 绑定新按钮事件 dom.importAllJsonBtn.addEventListener("click", importAllDataFromJSON); dom.printBtn.addEventListener("click", prepareForPrint); // 礼簿点击事件委托 dom.giftBookContent.addEventListener("click", (e) => { const cell = e.target.closest("[data-gift-index]"); if (!cell) return; const giftIndex = parseInt(cell.dataset.giftIndex, 10); if (isNaN(giftIndex)) return; if (cell.classList.contains("name-cell")) { showGiftDetailsModal(giftIndex); } else if (cell.classList.contains("amount-cell") && gifts[giftIndex]) { const { name, amount } = gifts[giftIndex].data; speakGift(name, amount); } }); // 事项切换下拉菜单 dom.eventSwitcherTrigger.addEventListener("click", (e) => { e.stopPropagation(); const dropdown = dom.eventDropdown; const isHidden = dropdown.classList.contains("hidden"); if (isHidden) { dropdown.innerHTML = ` 切换/创建事项 设置此事项 删除此事项`; } dropdown.classList.toggle("hidden"); }); dom.eventDropdown.addEventListener("click", (e) => { e.preventDefault(); const action = e.target.dataset.action; if (action) { dom.eventDropdown.classList.add("hidden"); switch (action) { case "switch": showSetupScreen(); break; case "edit": showEditEventInfoModal(); break; case "delete": deleteCurrentEvent(); break; } } }); //分页输入框的事件委托 dom.pageInfoEl.addEventListener("focusout", (e) => { if (e.target && e.target.id === "current-page-input") { handlePageInputChange(e); } }); dom.pageInfoEl.addEventListener("keydown", (e) => { if (e.target && e.target.id === "current-page-input" && e.key === "Enter") { e.preventDefault(); handlePageInputChange(e); e.target.blur(); } }); // 全屏功能监听 dom.fullscreenBtn.addEventListener("click", () => { if (!document.fullscreenElement) { document.documentElement.requestFullscreen(); } else if (document.exitFullscreen) { document.exitFullscreen(); } }); // 监听全屏状态变化 document.addEventListener("fullscreenchange", () => { const isFullscreen = !!document.fullscreenElement; if (isFullscreen) { dom.fullscreenIcon.classList.remove("ri-fullscreen-line"); dom.fullscreenIcon.classList.add("ri-fullscreen-exit-line"); } else { dom.fullscreenIcon.classList.remove("ri-fullscreen-exit-line"); dom.fullscreenIcon.classList.add("ri-fullscreen-line"); } }); // 为创建页面的语音预览按钮绑定事件 dom.previewCreateVoiceBtn.addEventListener("click", () => { previewSelectedVoice(dom.eventVoiceSelect); }); dom.modalContent.onclick = (e) => { const container = e.target.closest("[data-gift-index]"); if (!container) return; const giftIndex = parseInt(container.dataset.giftIndex, 10); if (e.target.id === "btn-correct-name") enableInlineEdit(giftIndex, "name"); if (e.target.id === "btn-modify-amount") enableInlineEdit(giftIndex, "amount"); if (e.target.id === "btn-edit-remarks") enableInlineEdit(giftIndex, "remarks"); }; // 全局事件 window.addEventListener("click", () => dom.eventDropdown.classList.add("hidden")); // 弹窗内事件委托 (处理搜索结果中的"查看详情"按钮) dom.modalContainer.addEventListener("click", (e) => { if (e.target.matches(".view-details-btn")) { const giftIndex = parseInt(e.target.dataset.giftIndex, 10); if (!isNaN(giftIndex)) { closeModal(); setTimeout(() => showGiftDetailsModal(giftIndex), 150); } } }); //全局键盘快捷键监听 document.addEventListener("keydown", (e) => { // 快捷键 Ctrl + P 触发打印 if (e.ctrlKey && e.key.toLowerCase() === "p") { e.preventDefault(); prepareForPrint(); return; } const isModalVisible = !dom.modalContainer.classList.contains("hidden"); const activeElement = document.activeElement; // 如果弹窗可见, 则执行弹窗内的快捷键逻辑 if (isModalVisible) { // Escape 键关闭弹窗 if (e.key === "Escape") { e.preventDefault(); // 尝试点击模态框的第一个按钮(通常是取消或关闭) dom.modalActions.querySelector("button")?.click(); return; // 弹窗打开时,不触发后续的全局快捷键 } // Enter 键确认弹窗 (文本域除外) // 注意:这里需要确保点击的是“确认”按钮,而不是任意按钮 if (e.key === "Enter" && activeElement.tagName !== "TEXTAREA") { e.preventDefault(); // 尝试点击模态框的最后一个按钮(通常是确认) const buttons = dom.modalActions.querySelectorAll("button"); if (buttons.length > 0) { buttons[buttons.length - 1].click(); } return; // 弹窗打开时,不触发后续的全局快捷键 } } // 全局回车提交礼金或支出表单 (仅当主屏幕可见且焦点不在文本域时) if (e.key === "Enter" && !dom.mainScreen.classList.contains("hidden") && activeElement.tagName !== "TEXTAREA") { if (currentMode === "gifts") { if (activeElement === dom.guestNameInput || activeElement === dom.giftAmountInput || activeElement === dom.remarksInput) { const guestName = dom.guestNameInput.value.trim(); const giftAmount = dom.giftAmountInput.value.trim(); if (guestName && giftAmount) { e.preventDefault(); dom.addGiftForm.querySelector('button[type="submit"]')?.click(); } } } else if (currentMode === "expenses") { if (activeElement === dom.expenseNameInput || activeElement === dom.expenseAmountInput || activeElement === dom.expenseDateInput || activeElement === dom.expenseRemarksInput) { const expenseName = dom.expenseNameInput.value.trim(); const expenseAmount = dom.expenseAmountInput.value.trim(); const expenseDate = dom.expenseDateInput.value; if (expenseName && expenseAmount && expenseDate) { e.preventDefault(); dom.addExpenseForm.querySelector('button[type="submit"]')?.click(); } } } } }); } // --- 应用初始化 --- function init() { initDB(); setDefaultTimes(); setDefaultExpenseDate(); bindEventListeners(); // 账本导出图片 dom.exportPageImgBtn.addEventListener("click", exportCurrentPageImage); populateVoiceList(dom.eventVoiceSelect); if (speechSynthesis.onvoiceschanged !== undefined) { speechSynthesis.onvoiceschanged = () => populateVoiceList(dom.eventVoiceSelect); } } init(); // 启动应用 });