Files
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

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>