chore: 初始提交 - 移除所有硬编码凭据,统一从环境变量读取
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "语音日程工具的前端鉴权配置",
|
||||
"enable_auth_comment": "true:启用鉴权;false:关闭鉴权并放开访问",
|
||||
"enable_auth": true,
|
||||
"max_age_days_comment": "鉴权 Cookie 有效期(天),范围建议 1~365",
|
||||
"max_age_days": 30,
|
||||
"cookieName_comment": "鉴权 Cookie 名称,建议保持默认",
|
||||
"cookieName": "calendar_voice_gate",
|
||||
"requireGuid_comment": "true:入口必须带 guid 参数;false:允许仅凭 token 或会话访问",
|
||||
"requireGuid": false,
|
||||
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
|
||||
"guidParam": "guid",
|
||||
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换",
|
||||
"guidValue": "ASDF9-0ASDKFS-KI393-9JJDSFHE-KASDKF"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>语记日历 - Voice Calendar Note</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="voice-section">
|
||||
<textarea id="voice-text" placeholder="点击“语音识别”开始说话,内容将显示在这里..."></textarea>
|
||||
<div class="row-layout">
|
||||
<button id="btn-record" class="btn btn-primary btn-large">
|
||||
<span class="icon">🎤</span> <span class="text">语音识别</span>
|
||||
</button>
|
||||
<button id="btn-process" class="btn btn-secondary btn-large">
|
||||
<span class="icon">⬇️</span> 整理至表单
|
||||
</button>
|
||||
</div>
|
||||
<div id="recording-status" class="hidden">
|
||||
<span class="pulse">●</span> 正在识别...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-group vertical">
|
||||
<label for="event-title">标题</label>
|
||||
<input type="text" id="event-title" placeholder="自动提取的核心主题">
|
||||
</div>
|
||||
|
||||
<div class="form-group vertical">
|
||||
<label for="event-content">内容</label>
|
||||
<textarea id="event-content" placeholder="自动提取的正文内容"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group horizontal">
|
||||
<label for="event-start">开始时间</label>
|
||||
<div class="input-group">
|
||||
<input type="datetime-local" id="event-start">
|
||||
<button type="button" class="btn-now" data-target="event-start">🈺</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group horizontal">
|
||||
<label for="event-end">结束时间</label>
|
||||
<div class="input-group">
|
||||
<input type="datetime-local" id="event-end">
|
||||
<button type="button" class="btn-now" data-target="event-end">🈺</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group horizontal">
|
||||
<label for="event-remind">提醒时间</label>
|
||||
<div class="input-group">
|
||||
<input type="datetime-local" id="event-remind">
|
||||
<button type="button" class="btn-now" data-target="event-remind">🈺</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-actions">
|
||||
<button id="btn-save" class="btn btn-success btn-large">保存</button>
|
||||
<button id="btn-close" class="btn btn-danger btn-large">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading-overlay" class="hidden">
|
||||
<div class="modal-content">
|
||||
<div class="spinner" id="modal-spinner"></div>
|
||||
<div id="modal-logs" class="modal-logs"></div>
|
||||
<button id="btn-modal-close" class="btn btn-secondary hidden" style="margin-top: 20px;">关闭窗口</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,294 @@
|
||||
:root {
|
||||
--primary-color: #3b82f6;
|
||||
--secondary-color: #64748b;
|
||||
--success-color: #22c55e;
|
||||
--danger-color: #ef4444;
|
||||
--bg-color: #f8fafc;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #1e293b;
|
||||
--border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
line-height: 1.5;
|
||||
padding: 16px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-bottom: 80px; /* Space for footer */
|
||||
}
|
||||
|
||||
header h1 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Voice Section */
|
||||
.voice-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
resize: none;
|
||||
background: var(--card-bg);
|
||||
min-height: 100px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.row-layout {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-layout .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
padding: 12px 20px;
|
||||
min-height: 48px; /* Ensure touch target size */
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: white;
|
||||
color: var(--secondary-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 6px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
#recording-status {
|
||||
color: var(--danger-color);
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Form Section */
|
||||
.form-section {
|
||||
background: var(--card-bg);
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.form-group.vertical {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.form-group.horizontal {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group.horizontal label {
|
||||
flex-shrink: 0;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.form-group.horizontal input {
|
||||
/* flex: 1; Removed this because it's now inside input-group */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-now {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 18px; /* Icon size */
|
||||
cursor: pointer;
|
||||
background: white;
|
||||
padding: 0 10px;
|
||||
height: 42px; /* Match standard input height (16px font + 8px*2 padding + 2px border) */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-now:active {
|
||||
background-color: #f1f5f9;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 14px;
|
||||
color: var(--secondary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
background: #fdfdfd;
|
||||
}
|
||||
|
||||
/* Modal Content */
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.modal-logs {
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-log-item {
|
||||
margin-bottom: 8px;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-log-item::before {
|
||||
content: "•";
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.modal-log-item.success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.modal-log-item.success::before {
|
||||
content: "✓";
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
/* Footer Actions */
|
||||
.footer-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Loading Overlay */
|
||||
#loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border-color);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 12px;
|
||||
color: var(--secondary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
Reference in New Issue
Block a user