diff --git a/apps/notes/frontend/src/App.vue b/apps/notes/frontend/src/App.vue index e6b0f6b..aa46993 100644 --- a/apps/notes/frontend/src/App.vue +++ b/apps/notes/frontend/src/App.vue @@ -25,6 +25,17 @@
+ +

{{ uploadErr }}

@@ -121,6 +132,82 @@ const uploading = ref(false) const uploadErr = ref('') let pollTimer = null +// 浏览器内录音(iOS 没法选录音机 App 文件,直接 web record 更顺) +const recState = ref('idle') // 'idle' | 'recording' +const recDuration = ref(0) +let mediaRecorder = null +let recChunks = [] +let recStream = null +let recTimer = null + +async function startRec() { + uploadErr.value = '' + if (!navigator.mediaDevices?.getUserMedia) { + uploadErr.value = '浏览器不支持 mic 录音' + return + } + try { + recStream = await navigator.mediaDevices.getUserMedia({ audio: true }) + } catch (e) { + uploadErr.value = 'mic 权限被拒:' + (e.message || e.name) + return + } + // Safari 偏向 audio/mp4,Chrome/Edge 优先 audio/webm + const tries = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', ''] + let mimeType = '' + for (const t of tries) { + if (!t || (window.MediaRecorder && MediaRecorder.isTypeSupported(t))) { + mimeType = t + break + } + } + recChunks = [] + mediaRecorder = mimeType + ? new MediaRecorder(recStream, { mimeType }) + : new MediaRecorder(recStream) + mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size) recChunks.push(e.data) } + mediaRecorder.onstop = onRecStop + mediaRecorder.start(1000) // 1s chunks 保证 stop 时有数据 + recState.value = 'recording' + recDuration.value = 0 + recTimer = setInterval(() => recDuration.value++, 1000) +} + +function stopRec() { + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + mediaRecorder.stop() + } + if (recTimer) { clearInterval(recTimer); recTimer = null } +} + +async function onRecStop() { + const mimeType = mediaRecorder?.mimeType || 'audio/webm' + const blob = new Blob(recChunks, { type: mimeType }) + if (recStream) { + recStream.getTracks().forEach(t => t.stop()) + recStream = null + } + recState.value = 'idle' + // 生成文件名 + const ext = mimeType.includes('mp4') ? 'm4a' + : mimeType.includes('webm') ? 'webm' + : mimeType.includes('ogg') ? 'ogg' + : 'bin' + const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-') + const file = new File([blob], `录音-${ts}.${ext}`, { type: mimeType }) + if (file.size < 1024) { + uploadErr.value = '录音太短(< 1KB),没保存' + return + } + await doUpload(file) +} + +function fmtSec(s) { + const m = Math.floor(s / 60) + const sec = s % 60 + return m + ':' + (sec < 10 ? '0' : '') + sec +} + async function submitPass() { setPass(passDraft.value.trim()) try { @@ -366,12 +453,11 @@ input, textarea { font-family: inherit; background: transparent; border: none; c .upload-row { padding: 12px; border-bottom: 1px solid var(--border-soft); + display: flex; + gap: 8px; } -.upload-pick { position: relative; display: block; cursor: pointer; } -.upload-pick input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; } -.upload-btn { - display: block; - text-align: center; +.rec-btn { + flex: 1; background: var(--accent-strong); color: #fff; padding: 10px; @@ -380,8 +466,32 @@ input, textarea { font-family: inherit; background: transparent; border: none; c font-size: 13px; transition: background 0.15s; } -.upload-btn:hover { background: var(--accent); } +.rec-btn:hover:not(:disabled) { background: var(--accent); } +.rec-btn.recording { + background: var(--accent-red); + animation: rec-pulse 1.4s ease-in-out infinite; +} +@keyframes rec-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} +.upload-pick { position: relative; display: block; cursor: pointer; flex-shrink: 0; } +.upload-pick input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; } +.upload-btn { + display: block; + text-align: center; + background: var(--bg-elev); + border: 1px solid var(--border); + color: var(--text-dim); + padding: 10px 14px; + border-radius: 6px; + font-weight: 600; + font-size: 13px; + transition: background 0.15s; +} +.upload-btn:hover { background: var(--bg-hover); color: var(--text); } .upload-btn.uploading { background: var(--bg-card); color: var(--text-dim); } +.upload-btn.small { padding: 10px 12px; } .upload-err { color: var(--accent-red); font-size: 12px;