731 lines
23 KiB
HTML
731 lines
23 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
|
<title>录音工具</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 15px;
|
|
}
|
|
|
|
.container {
|
|
width: 100%;
|
|
max-width: 400px;
|
|
background: white;
|
|
border-radius: 16px;
|
|
padding: 20px;
|
|
box-shadow: 0 15px 50px rgba(0,0,0,0.3);
|
|
}
|
|
|
|
h1 {
|
|
text-align: center;
|
|
color: #333;
|
|
margin-bottom: 15px;
|
|
font-size: 22px;
|
|
}
|
|
|
|
.status {
|
|
text-align: center;
|
|
padding: 8px 12px;
|
|
margin-bottom: 10px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
transition: all 0.3s;
|
|
min-height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.status.idle {
|
|
background: #f0f0f0;
|
|
color: #666;
|
|
}
|
|
|
|
.status.recording {
|
|
background: #ffebee;
|
|
color: #c62828;
|
|
animation: pulse 1.5s infinite;
|
|
}
|
|
|
|
.status.playing {
|
|
background: #e3f2fd;
|
|
color: #1565c0;
|
|
}
|
|
|
|
.status.transcribing {
|
|
background: #fff3e0;
|
|
color: #e65100;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.7; }
|
|
}
|
|
|
|
.timer {
|
|
text-align: center;
|
|
font-size: 36px;
|
|
font-weight: 300;
|
|
color: #333;
|
|
margin: 8px 0;
|
|
font-variant-numeric: tabular-nums;
|
|
line-height: 1;
|
|
}
|
|
|
|
.button-row {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
button {
|
|
flex: 1;
|
|
padding: 14px 16px;
|
|
border: none;
|
|
border-radius: 10px;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
button:active:not(:disabled) {
|
|
transform: scale(0.98);
|
|
}
|
|
|
|
.btn-start {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
}
|
|
|
|
.btn-stop {
|
|
background: #ef5350;
|
|
color: white;
|
|
}
|
|
|
|
.btn-play {
|
|
background: #42a5f5;
|
|
color: white;
|
|
flex: 0.6;
|
|
}
|
|
|
|
.progress-info {
|
|
flex: 0.4;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #f5f5f5;
|
|
border-radius: 10px;
|
|
font-size: 14px;
|
|
color: #666;
|
|
}
|
|
|
|
.audio-player {
|
|
margin-top: 12px;
|
|
padding: 10px;
|
|
background: #f5f5f5;
|
|
border-radius: 10px;
|
|
display: none;
|
|
}
|
|
|
|
.audio-player.show {
|
|
display: block;
|
|
}
|
|
|
|
audio {
|
|
width: 100%;
|
|
height: 36px;
|
|
}
|
|
|
|
.icon {
|
|
font-size: 18px;
|
|
}
|
|
|
|
.error {
|
|
background: #ffebee;
|
|
color: #c62828;
|
|
padding: 10px 12px;
|
|
border-radius: 8px;
|
|
margin-bottom: 12px;
|
|
font-size: 13px;
|
|
display: none;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.error.show {
|
|
display: block;
|
|
}
|
|
|
|
/* 转写结果区域 */
|
|
.transcript-box {
|
|
margin-top: 15px;
|
|
padding: 15px;
|
|
background: #f8f9fa;
|
|
border-radius: 12px;
|
|
border-left: 4px solid #667eea;
|
|
display: none;
|
|
}
|
|
|
|
.transcript-box.show {
|
|
display: block;
|
|
}
|
|
|
|
.transcript-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.transcript-title {
|
|
font-weight: 600;
|
|
color: #333;
|
|
font-size: 15px;
|
|
}
|
|
|
|
.transcript-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.btn-small {
|
|
padding: 6px 12px;
|
|
font-size: 12px;
|
|
border-radius: 6px;
|
|
border: none;
|
|
cursor: pointer;
|
|
background: #e3f2fd;
|
|
color: #1565c0;
|
|
}
|
|
|
|
.transcript-content {
|
|
background: white;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
min-height: 80px;
|
|
font-size: 15px;
|
|
line-height: 1.6;
|
|
color: #333;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
}
|
|
|
|
.transcript-content:empty::before {
|
|
content: '转写中...';
|
|
color: #999;
|
|
}
|
|
|
|
/* AI 改写按钮区域 */
|
|
.ai-actions {
|
|
margin-top: 12px;
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.btn-ai {
|
|
padding: 8px 14px;
|
|
font-size: 13px;
|
|
border-radius: 8px;
|
|
border: none;
|
|
cursor: pointer;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
font-weight: 500;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.btn-ai:hover:not(:disabled) {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
|
}
|
|
|
|
.btn-ai:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* AI 改写结果区域 */
|
|
.analysis-box {
|
|
margin-top: 15px;
|
|
padding: 15px;
|
|
background: #f0f7ff;
|
|
border-radius: 12px;
|
|
border-left: 4px solid #42a5f5;
|
|
display: none;
|
|
}
|
|
|
|
.analysis-box.show {
|
|
display: block;
|
|
}
|
|
|
|
.analysis-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.analysis-title {
|
|
font-weight: 600;
|
|
color: #1565c0;
|
|
font-size: 15px;
|
|
}
|
|
|
|
.analysis-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.analysis-type {
|
|
font-size: 12px;
|
|
color: #666;
|
|
background: white;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.analysis-content {
|
|
background: white;
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
min-height: 100px;
|
|
font-size: 14px;
|
|
line-height: 1.7;
|
|
color: #333;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
}
|
|
|
|
.analysis-loading {
|
|
text-align: center;
|
|
color: #666;
|
|
padding: 20px;
|
|
}
|
|
|
|
.analysis-loading::after {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid #667eea;
|
|
border-top-color: transparent;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin-left: 8px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.progress-bar {
|
|
width: 100%;
|
|
height: 4px;
|
|
background: #e0e0e0;
|
|
border-radius: 2px;
|
|
margin-top: 8px;
|
|
overflow: hidden;
|
|
display: none;
|
|
}
|
|
|
|
.progress-bar.show {
|
|
display: block;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #667eea, #764ba2);
|
|
width: 0%;
|
|
transition: width 0.3s;
|
|
border-radius: 2px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>🎙️ 录音工具</h1>
|
|
|
|
<div class="error" id="errorBox"></div>
|
|
|
|
<div class="status idle" id="statusBox">准备录音</div>
|
|
|
|
<div class="timer" id="timer">00:00</div>
|
|
|
|
<!-- 第一行:开始 + 结束 -->
|
|
<div class="button-row">
|
|
<button class="btn-start" id="btnStart" onclick="startRecording()">
|
|
<span class="icon">🔴</span>开始
|
|
</button>
|
|
<button class="btn-stop" id="btnStop" onclick="stopRecording()" disabled>
|
|
<span class="icon">⏹️</span>结束
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 第二行:回放 + 进度 -->
|
|
<div class="button-row">
|
|
<button class="btn-play" id="btnPlay" onclick="playRecording()" disabled>
|
|
<span class="icon">▶️</span>回放
|
|
</button>
|
|
<div class="progress-info" id="playProgress">00:00</div>
|
|
</div>
|
|
|
|
<!-- 转写进度条 -->
|
|
<div class="progress-bar" id="transcribeProgress">
|
|
<div class="progress-fill" id="progressFill"></div>
|
|
</div>
|
|
|
|
<div class="audio-player" id="audioPlayer">
|
|
<audio id="audioElement" controls></audio>
|
|
</div>
|
|
|
|
<!-- 转写结果 -->
|
|
<div class="transcript-box" id="transcriptBox">
|
|
<div class="transcript-header">
|
|
<span class="transcript-title">📝 转写结果</span>
|
|
<div class="transcript-actions">
|
|
<button class="btn-small" onclick="copyTranscript()">复制</button>
|
|
</div>
|
|
</div>
|
|
<div class="transcript-content" id="transcriptContent"></div>
|
|
|
|
<!-- AI 改写按钮 -->
|
|
<div class="ai-actions" id="aiActions" style="display: none;">
|
|
<button class="btn-ai" onclick="analyzeText('daily')">📋 日报总结</button>
|
|
<button class="btn-ai" onclick="analyzeText('weekly')">📊 周报总结</button>
|
|
<button class="btn-ai" onclick="analyzeText('general')">📝 日常总结</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- AI 改写结果 -->
|
|
<div class="analysis-box" id="analysisBox">
|
|
<div class="analysis-header">
|
|
<span class="analysis-title">🤖 AI 改写结果</span>
|
|
<div class="analysis-actions">
|
|
<span class="analysis-type" id="analysisType"></span>
|
|
<button class="btn-small" onclick="copyAnalysis()">复制</button>
|
|
</div>
|
|
</div>
|
|
<div class="analysis-content" id="analysisContent"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let mediaRecorder = null;
|
|
let audioChunks = [];
|
|
let audioBlob = null;
|
|
let audioUrl = null;
|
|
let startTime = null;
|
|
let timerInterval = null;
|
|
let currentRecordingFilename = null;
|
|
|
|
const btnStart = document.getElementById('btnStart');
|
|
const btnStop = document.getElementById('btnStop');
|
|
const btnPlay = document.getElementById('btnPlay');
|
|
const statusBox = document.getElementById('statusBox');
|
|
const timer = document.getElementById('timer');
|
|
const audioPlayer = document.getElementById('audioPlayer');
|
|
const audioElement = document.getElementById('audioElement');
|
|
const playProgress = document.getElementById('playProgress');
|
|
const errorBox = document.getElementById('errorBox');
|
|
const transcriptBox = document.getElementById('transcriptBox');
|
|
const transcriptContent = document.getElementById('transcriptContent');
|
|
const transcribeProgress = document.getElementById('transcribeProgress');
|
|
const progressFill = document.getElementById('progressFill');
|
|
const aiActions = document.getElementById('aiActions');
|
|
const analysisBox = document.getElementById('analysisBox');
|
|
const analysisContent = document.getElementById('analysisContent');
|
|
const analysisType = document.getElementById('analysisType');
|
|
|
|
function showError(msg) {
|
|
errorBox.textContent = msg;
|
|
errorBox.classList.add('show');
|
|
setTimeout(() => errorBox.classList.remove('show'), 5000);
|
|
}
|
|
|
|
function updateStatus(status, text) {
|
|
statusBox.className = 'status ' + status;
|
|
statusBox.textContent = text;
|
|
}
|
|
|
|
function formatTime(seconds) {
|
|
const mins = Math.floor(seconds / 60).toString().padStart(2, '0');
|
|
const secs = (seconds % 60).toString().padStart(2, '0');
|
|
return `${mins}:${secs}`;
|
|
}
|
|
|
|
function updateTimer() {
|
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
timer.textContent = formatTime(elapsed);
|
|
}
|
|
|
|
function updatePlayProgress() {
|
|
if (audioElement.duration) {
|
|
const current = Math.floor(audioElement.currentTime);
|
|
const total = Math.floor(audioElement.duration);
|
|
playProgress.textContent = `${formatTime(current)}/${formatTime(total)}`;
|
|
}
|
|
}
|
|
|
|
audioElement.ontimeupdate = updatePlayProgress;
|
|
audioElement.onloadedmetadata = updatePlayProgress;
|
|
|
|
async function startRecording() {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
sampleRate: 16000
|
|
}
|
|
});
|
|
|
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm')
|
|
? 'audio/webm;codecs=opus'
|
|
: 'audio/mp4';
|
|
|
|
mediaRecorder = new MediaRecorder(stream, { mimeType });
|
|
audioChunks = [];
|
|
|
|
mediaRecorder.ondataavailable = (event) => {
|
|
if (event.data.size > 0) {
|
|
audioChunks.push(event.data);
|
|
}
|
|
};
|
|
|
|
mediaRecorder.onstop = () => {
|
|
audioBlob = new Blob(audioChunks, { type: mimeType });
|
|
audioUrl = URL.createObjectURL(audioBlob);
|
|
|
|
audioElement.src = audioUrl;
|
|
audioPlayer.classList.add('show');
|
|
btnPlay.disabled = false;
|
|
|
|
// 自动开始转写
|
|
transcribeAudio(audioBlob);
|
|
|
|
stream.getTracks().forEach(track => track.stop());
|
|
};
|
|
|
|
mediaRecorder.start(100);
|
|
startTime = Date.now();
|
|
timerInterval = setInterval(updateTimer, 1000);
|
|
|
|
btnStart.disabled = true;
|
|
btnStop.disabled = false;
|
|
btnPlay.disabled = true;
|
|
updateStatus('recording', '🔴 正在录音...');
|
|
audioPlayer.classList.remove('show');
|
|
transcriptBox.classList.remove('show');
|
|
aiActions.style.display = 'none';
|
|
analysisBox.classList.remove('show');
|
|
transcriptContent.textContent = '';
|
|
playProgress.textContent = '00:00';
|
|
|
|
} catch (err) {
|
|
console.error('录音失败:', err);
|
|
showError('无法访问麦克风,请检查权限设置');
|
|
}
|
|
}
|
|
|
|
function stopRecording() {
|
|
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
mediaRecorder.stop();
|
|
clearInterval(timerInterval);
|
|
btnStart.disabled = false;
|
|
btnStop.disabled = true;
|
|
updateStatus('idle', '录音完成,正在转写...');
|
|
}
|
|
}
|
|
|
|
function playRecording() {
|
|
if (audioUrl) {
|
|
audioElement.play();
|
|
updateStatus('playing', '▶️ 正在播放...');
|
|
|
|
audioElement.onended = () => {
|
|
updateStatus('idle', '播放完成');
|
|
};
|
|
}
|
|
}
|
|
|
|
async function transcribeAudio(blob) {
|
|
transcribeProgress.classList.add('show');
|
|
progressFill.style.width = '30%';
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('audio', blob, `recording_${Date.now()}.webm`);
|
|
|
|
progressFill.style.width = '60%';
|
|
|
|
const response = await fetch('/api/transcribe', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
progressFill.style.width = '90%';
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
progressFill.style.width = '100%';
|
|
|
|
setTimeout(() => {
|
|
transcribeProgress.classList.remove('show');
|
|
progressFill.style.width = '0%';
|
|
}, 500);
|
|
|
|
if (result.success) {
|
|
transcriptContent.textContent = result.text;
|
|
transcriptBox.classList.add('show');
|
|
aiActions.style.display = 'flex';
|
|
currentRecordingFilename = result.filename;
|
|
updateStatus('idle', '转写完成');
|
|
} else {
|
|
showError('转写失败: ' + result.error);
|
|
updateStatus('idle', '转写失败');
|
|
}
|
|
} else {
|
|
throw new Error('请求失败');
|
|
}
|
|
} catch (err) {
|
|
console.error('转写错误:', err);
|
|
transcribeProgress.classList.remove('show');
|
|
showError('转写服务暂时不可用');
|
|
updateStatus('idle', '转写失败');
|
|
}
|
|
}
|
|
|
|
function copyTranscript() {
|
|
const text = transcriptContent.textContent;
|
|
if (text) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
const btn = event.target;
|
|
const original = btn.textContent;
|
|
btn.textContent = '已复制!';
|
|
setTimeout(() => btn.textContent = original, 1500);
|
|
});
|
|
}
|
|
}
|
|
|
|
// 复制AI分析结果
|
|
function copyAnalysis() {
|
|
const text = analysisContent.textContent;
|
|
if (text && !text.includes('正在改写中')) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
const btn = event.target;
|
|
const original = btn.textContent;
|
|
btn.textContent = '已复制!';
|
|
setTimeout(() => btn.textContent = original, 1500);
|
|
});
|
|
}
|
|
}
|
|
|
|
// AI 分析文本
|
|
async function analyzeText(type) {
|
|
const text = transcriptContent.textContent;
|
|
if (!text || text.trim().length < 10) {
|
|
showError('转写内容太短,无法分析(至少10个字符)');
|
|
return;
|
|
}
|
|
|
|
const typeNames = {
|
|
'daily': '日报',
|
|
'weekly': '周报',
|
|
'general': '日常总结'
|
|
};
|
|
|
|
// 显示加载状态
|
|
analysisBox.classList.add('show');
|
|
analysisContent.innerHTML = '<div class="analysis-loading">AI 正在改写中</div>';
|
|
analysisType.textContent = typeNames[type];
|
|
|
|
// 禁用按钮
|
|
document.querySelectorAll('.btn-ai').forEach(btn => btn.disabled = true);
|
|
|
|
try {
|
|
const response = await fetch('/api/analyze', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
text: text,
|
|
type: type
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
analysisContent.textContent = result.analysis;
|
|
} else {
|
|
analysisContent.textContent = '改写失败: ' + result.error;
|
|
}
|
|
} else {
|
|
throw new Error('请求失败');
|
|
}
|
|
} catch (err) {
|
|
console.error('AI 分析错误:', err);
|
|
analysisContent.textContent = 'AI 服务暂时不可用,请稍后重试';
|
|
} finally {
|
|
// 启用按钮
|
|
document.querySelectorAll('.btn-ai').forEach(btn => btn.disabled = false);
|
|
}
|
|
}
|
|
|
|
// 页面加载检查
|
|
window.onload = () => {
|
|
const checks = [];
|
|
|
|
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') {
|
|
checks.push('❌ 需要 HTTPS 环境');
|
|
}
|
|
|
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
checks.push('❌ 不支持 getUserMedia');
|
|
}
|
|
|
|
if (!window.MediaRecorder) {
|
|
checks.push('❌ 不支持 MediaRecorder');
|
|
}
|
|
|
|
if (checks.length > 0) {
|
|
let errorMsg = '环境不满足:\n' + checks.join('\n');
|
|
if (/MicroMessenger|QQ/i.test(navigator.userAgent)) {
|
|
errorMsg += '\n\n请在 Safari/Chrome 中打开';
|
|
}
|
|
showError(errorMsg);
|
|
btnStart.disabled = true;
|
|
}
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|