// State let isRecording = false; let mediaRecorder = null; let audioChunks = []; let audioContext = null; let processor = null; let input = null; const clearUrlQuery = () => { try { const cleanUrl = `${window.location.origin}${window.location.pathname}${window.location.hash || ''}`; window.history.replaceState({}, '', cleanUrl); } catch {} }; // UI Elements const els = { voiceText: document.getElementById('voice-text'), btnRecord: document.getElementById('btn-record'), status: document.getElementById('recording-status'), btnProcess: document.getElementById('btn-process'), title: document.getElementById('event-title'), content: document.getElementById('event-content'), start: document.getElementById('event-start'), end: document.getElementById('event-end'), remind: document.getElementById('event-remind'), btnSave: document.getElementById('btn-save'), btnClose: document.getElementById('btn-close'), overlay: document.getElementById('loading-overlay') }; // --- Recording Logic --- async function startRecording() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // Aliyun requires 16000Hz Sample Rate audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 }); input = audioContext.createMediaStreamSource(stream); // Create a ScriptProcessorNode processor = audioContext.createScriptProcessor(4096, 1, 1); audioChunks = []; // Store raw PCM data (Float32) processor.onaudioprocess = (e) => { if (!isRecording) return; const inputData = e.inputBuffer.getChannelData(0); // Clone data because input buffer is reused audioChunks.push(new Float32Array(inputData)); }; input.connect(processor); processor.connect(audioContext.destination); isRecording = true; updateRecordUI(true); } catch (err) { console.error('Microphone access error:', err); alert('无法访问麦克风,请检查权限设置'); } } function stopRecording() { if (!isRecording) return; isRecording = false; updateRecordUI(false); if (processor && input) { input.disconnect(); processor.disconnect(); } if (audioContext) { audioContext.close(); } // Process audio data processAudio(); } function updateRecordUI(recording) { if (recording) { els.btnRecord.innerHTML = '⏹️ 停止录音'; els.btnRecord.classList.replace('btn-primary', 'btn-danger'); els.status.classList.remove('hidden'); } else { els.btnRecord.innerHTML = '🎤 语音识别'; els.btnRecord.classList.replace('btn-danger', 'btn-primary'); els.status.classList.add('hidden'); } } // Convert Float32 PCM to 16-bit PCM function floatTo16BitPCM(output, offset, input) { for (let i = 0; i < input.length; i++, offset += 2) { let s = Math.max(-1, Math.min(1, input[i])); s = s < 0 ? s * 0x8000 : s * 0x7FFF; output.setInt16(offset, s, true); } } async function processAudio() { showLoading(true, '正在识别语音...'); try { // Flatten chunks const length = audioChunks.reduce((acc, chunk) => acc + chunk.length, 0); const buffer = new Float32Array(length); let offset = 0; for (const chunk of audioChunks) { buffer.set(chunk, offset); offset += chunk.length; } // Create DataView for 16-bit PCM const pcmBuffer = new ArrayBuffer(length * 2); const view = new DataView(pcmBuffer); floatTo16BitPCM(view, 0, buffer); // Send to backend const blob = new Blob([view], { type: 'application/octet-stream' }); const formData = new FormData(); formData.append('audio', blob, 'recording.pcm'); const res = await fetch('/api/calendar_voice/recognize', { method: 'POST', body: formData }); const data = await res.json(); if (data.ok) { const currentText = els.voiceText.value; // Append if there is existing text els.voiceText.value = currentText ? (currentText + '\n' + data.text) : data.text; } else { alert('识别失败: ' + (data.error || '未知错误')); } } catch (e) { console.error('Processing error:', e); alert('上传或处理失败'); } finally { showLoading(false); } } // --- Text Processing Logic --- function parseRelativeDate(text) { const now = new Date(); // Reset time to 00:00:00 for calculation const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); let targetDate = null; // 1. Keywords: 今天, 明天, 后天, 前天 if (text.includes('今天')) targetDate = today; else if (text.includes('明天')) targetDate = new Date(today.getTime() + 86400000); else if (text.includes('后天') || text.includes('过两天')) targetDate = new Date(today.getTime() + 86400000 * 2); else if (text.includes('前天')) targetDate = new Date(today.getTime() - 86400000 * 2); // 2. Weekdays: 周X, 星期X, 下周X // Map: 一->1, ... 六->6, 日/天->7 const weekMap = { '一':1, '二':2, '三':3, '四':4, '五':5, '六':6, '日':7, '天':7 }; const weekMatch = text.match(/(下)?(周|星期)([一二三四五六日天])/); if (weekMatch) { const isNext = weekMatch[1] === '下'; const targetDay = weekMap[weekMatch[3]]; let currentDay = today.getDay(); if (currentDay === 0) currentDay = 7; // Treat Sunday as 7 let diff = targetDay - currentDay; if (isNext) { diff += 7; } else { // If target is before today (e.g. Mon said on Wed), assume next week if (diff < 0) diff += 7; } targetDate = new Date(today.getTime() + diff * 86400000); } if (targetDate) { const y = targetDate.getFullYear(); const m = String(targetDate.getMonth() + 1).padStart(2, '0'); const d = String(targetDate.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } return null; } async function parseTextToForm() { const text = els.voiceText.value.trim(); if (!text) return; // Show loading state const originalBtnText = els.btnProcess.innerHTML; els.btnProcess.disabled = true; els.btnProcess.innerHTML = '🤖 正在智能分析...'; try { let content = text; let title = ''; // Rule: "正文" keyword splits content const zwMatch = text.match(/正文(.*?)$/s); if (zwMatch) { content = zwMatch[1].trim(); } // AI Summary try { const res = await fetch('/api/calendar_voice/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: content }) }); const data = await res.json(); if (data.ok && data.title) { title = data.title; } else { // Fallback if API fails title = content.split('\n')[0].substring(0, 30); } } catch (e) { console.error('Summary API error:', e); title = content.split('\n')[0].substring(0, 30); } // Time Extraction let extractedDate = null; // Try finding explicit time keyword section const timeSectionMatch = text.match(/时间(.*?)([,,。]|$)/); const timeText = timeSectionMatch ? timeSectionMatch[1] : text; // 1. Relative Date extractedDate = parseRelativeDate(timeText); // 2. If not found, try absolute date if (!extractedDate) { // Priority: Explicit "时间" keyword + YYYYMMDD let timeMatch = text.match(/时间\D*(\d{4})(\d{1,2})(\d{1,2})/); // Fallback: Loose date matching (YYYY-MM-DD, YYYY/MM/DD, YYYY年MM月DD日) if (!timeMatch) { timeMatch = text.match(/(\d{4})[.\-/年](\d{1,2})[.\-/月](\d{1,2})/); } if (timeMatch) { const y = timeMatch[1]; const m = timeMatch[2].padStart(2, '0'); const d = timeMatch[3].padStart(2, '0'); extractedDate = `${y}-${m}-${d}`; } } // Fill Form els.title.value = title; els.content.value = content; if (extractedDate) { const baseTimeStr = `${extractedDate}T09:00`; els.start.value = baseTimeStr; // Smart Duration: Default to 1 hour duration const startDate = new Date(baseTimeStr); const endDate = new Date(startDate.getTime() + 60 * 60 * 1000); // +1 hour els.end.value = formatDateTimeLocal(endDate); els.remind.value = baseTimeStr; } else { // 没有识别出任何日期:默认今天 09:00 开始,明天 09:00 结束,提醒为今天 09:00 const now = new Date(); const start = new Date(now); start.setHours(9, 0, 0, 0); const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); // +1 day els.start.value = formatDateTimeLocal(start); els.end.value = formatDateTimeLocal(end); els.remind.value = formatDateTimeLocal(start); } } catch (err) { console.error('Parse error:', err); alert('处理出错,请重试'); } finally { els.btnProcess.disabled = false; els.btnProcess.innerHTML = originalBtnText; } } // --- Save Logic --- async function saveEvent() { const payload = { title: els.title.value, content: els.content.value, start: els.start.value, end: els.end.value, remindAt: els.remind.value, completed: false, deleted: false }; if (!payload.title) { alert('请输入标题'); return; } if (!payload.start) { alert('请输入开始时间'); return; } // Adjust format if (payload.start.length === 16) payload.start += ':00'; if (payload.end && payload.end.length === 16) payload.end += ':00'; if (payload.remindAt && payload.remindAt.length === 16) payload.remindAt += ':00'; showLoading(true); addLog('开始处理请求...'); try { // Step 1: Validation await new Promise(r => setTimeout(r, 500)); addLog('正在验证数据完整性...'); // Step 2: Save to DB await new Promise(r => setTimeout(r, 500)); addLog('正在提交数据到本地数据库...'); const res = await fetch('/api/calendar/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); let data = null; try { data = await res.json(); } catch (e) { addLog('服务器返回的不是有效 JSON,状态码: ' + res.status, 'error'); throw e; } if (data && data.ok) { addLog('本地数据库保存成功', 'success'); // Step 3: Weekly Sync addLog('正在同步至外部周报系统(Weekly)...'); // Simulate sync time (in reality this might happen in backend async) await new Promise(r => setTimeout(r, 1500)); addLog('外部系统同步完成', 'success'); addLog('所有操作已完成 ✅', 'success'); document.getElementById('modal-spinner').classList.add('hidden'); document.getElementById('btn-modal-close').classList.remove('hidden'); clearForm(); } else { const errMsg = String((data && data.error) || '未知错误'); addLog('保存失败: ' + errMsg, 'error'); try { addLog('服务器返回详情: ' + JSON.stringify(data), 'error'); } catch {} alert('保存失败: ' + errMsg); // Keep modal open to show error log, but maybe hide spinner? document.getElementById('modal-spinner').classList.add('hidden'); document.getElementById('btn-modal-close').classList.remove('hidden'); } } catch (e) { console.error(e); const msg = e && e.message ? String(e.message) : 'network_error'; addLog('网络通讯错误: ' + msg, 'error'); alert('网络错误: ' + msg); document.getElementById('modal-spinner').classList.add('hidden'); document.getElementById('btn-modal-close').classList.remove('hidden'); } } function clearForm() { els.voiceText.value = ''; localStorage.removeItem('voice_draft'); // Clear draft els.title.value = ''; els.content.value = ''; els.start.value = ''; els.end.value = ''; els.remind.value = ''; } // --- Helpers --- function roundToQuarter(d) { const q = Math.round(d.getMinutes() / 15) * 15; d.setMinutes(Math.min(45, Math.max(0, q)), 0, 0); return d; } function formatDateTimeLocal(d) { const pad = (n) => String(n).padStart(2, '0'); const yyyy = d.getFullYear(); const MM = pad(d.getMonth() + 1); const dd = pad(d.getDate()); const hh = pad(d.getHours()); const mm = pad(d.getMinutes()); return `${yyyy}-${MM}-${dd}T${hh}:${mm}`; } function showLoading(show, text = '加载中...') { const overlay = document.getElementById('loading-overlay'); const spinner = document.getElementById('modal-spinner'); const btnClose = document.getElementById('btn-modal-close'); const logs = document.getElementById('modal-logs'); if (show) { overlay.classList.remove('hidden'); spinner.classList.remove('hidden'); btnClose.classList.add('hidden'); logs.innerHTML = ''; // Clear logs } else { overlay.classList.add('hidden'); } } function addLog(text, type = 'normal') { const logs = document.getElementById('modal-logs'); const item = document.createElement('div'); item.className = 'modal-log-item'; if (type === 'success') item.classList.add('success'); if (type === 'error') item.style.color = 'var(--danger-color)'; item.textContent = text; logs.appendChild(item); logs.scrollTop = logs.scrollHeight; } document.getElementById('btn-modal-close').addEventListener('click', () => { showLoading(false); }); // --- Event Listeners --- els.btnRecord.addEventListener('click', () => { if (isRecording) { stopRecording(); } else { startRecording(); } }); els.btnProcess.addEventListener('click', parseTextToForm); els.btnSave.addEventListener('click', saveEvent); els.btnClose.addEventListener('click', clearForm); // Auto-save draft els.voiceText.addEventListener('input', () => { localStorage.setItem('voice_draft', els.voiceText.value); }); // Load draft on startup window.addEventListener('DOMContentLoaded', () => { clearUrlQuery(); const draft = localStorage.getItem('voice_draft'); if (draft) { els.voiceText.value = draft; } }); // "Now" buttons logic document.querySelectorAll('.btn-now').forEach(btn => { btn.addEventListener('click', (e) => { const targetId = e.currentTarget.dataset.target; const input = document.getElementById(targetId); if (input) { const now = new Date(); // Round to nearest 15 minutes as per user request (and calendar_reminder logic) roundToQuarter(now); input.value = formatDateTimeLocal(now); } }); });