Files

481 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = '<span class="icon">⏹️</span> <span class="text">停止录音</span>';
els.btnRecord.classList.replace('btn-primary', 'btn-danger');
els.status.classList.remove('hidden');
} else {
els.btnRecord.innerHTML = '<span class="icon">🎤</span> <span class="text">语音识别</span>';
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 = '<span class="icon">🤖</span> 正在智能分析...';
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);
}
});
});