330 lines
14 KiB
HTML
330 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html xmlns:th="http://www.thymeleaf.org">
|
||
<head>
|
||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||
<title>TraceXD - 首页</title>
|
||
<link rel="stylesheet" href="/css/style.css">
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<header class="app-header">
|
||
<span class="header-title">TraceXD</span>
|
||
<nav class="header-nav">
|
||
<a href="/browse" class="header-link">浏览记录</a>
|
||
<a href="#" onclick="logout(); return false;" class="header-logout">退出</a>
|
||
</nav>
|
||
</header>
|
||
|
||
<main class="main-content">
|
||
<!-- 结果展示区 -->
|
||
<div id="result-area" class="result-area"></div>
|
||
|
||
<!-- Toast 通知 -->
|
||
<div id="toast" class="toast hidden"></div>
|
||
|
||
<!-- 最近事项 -->
|
||
<div class="recent-section">
|
||
<h3 class="section-title">最近事项</h3>
|
||
<div id="recent-list" class="recent-list">
|
||
<p class="empty-hint">暂无事项记录,点击右下角麦克风开始录入</p>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- 麦克风按钮 -->
|
||
<button id="mic-btn" class="mic-btn">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mic-icon">
|
||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
||
<line x1="12" y1="19" x2="12" y2="23"/>
|
||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<script src="/js/app.js"></script>
|
||
<script>
|
||
(function() {
|
||
const micBtn = document.getElementById('mic-btn');
|
||
const resultArea = document.getElementById('result-area');
|
||
const toast = document.getElementById('toast');
|
||
const recentList = document.getElementById('recent-list');
|
||
|
||
let isRecording = false;
|
||
let stream = null;
|
||
// Web Audio API WAV recording variables
|
||
let audioCtx = null;
|
||
let mediaStreamSrc = null;
|
||
let procNode = null;
|
||
let pcmChunks = [];
|
||
let recordedSampleRate = 48000;
|
||
|
||
// 时间格式化
|
||
function formatTime(isoString) {
|
||
if (!isoString) return '';
|
||
const d = new Date(isoString);
|
||
const pad = n => String(n).padStart(2, '0');
|
||
return (d.getMonth() + 1) + '-' + d.getDate() + ' ' +
|
||
pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||
}
|
||
|
||
// 显示 Toast
|
||
function showToast(message, type) {
|
||
toast.textContent = message;
|
||
toast.className = 'toast toast-' + (type || 'success') + ' show';
|
||
setTimeout(function() {
|
||
toast.className = 'toast hidden';
|
||
}, 3000);
|
||
}
|
||
|
||
// 展示查询结果
|
||
function showQueryResult(data) {
|
||
var html = '<div class="query-result-card">';
|
||
if (data.queryData && data.queryData.length > 0) {
|
||
html += '<table class="query-table"><thead><tr>' +
|
||
'<th>时间</th><th>人物</th><th>内容</th><th>金额</th>' +
|
||
'</tr></thead><tbody>';
|
||
data.queryData.forEach(function(row) {
|
||
html += '<tr class="' + cardClass(row.category) + '"><td>' + formatTime(row.time) + '</td>' +
|
||
'<td>' + (row.person || '') + '</td>' +
|
||
'<td>' + (row.content || '') + '</td>' +
|
||
'<td>' + (row.amount || '0') + '</td></tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
}
|
||
if (data.reply) {
|
||
html += '<div class="ai-summary">' + data.reply + '</div>';
|
||
}
|
||
html += '</div>';
|
||
resultArea.innerHTML = html;
|
||
}
|
||
|
||
// 展示AI直接回复(其他意图)
|
||
function showAiReply(text) {
|
||
var html = '<div class="query-result-card"><div class="ai-summary">' +
|
||
text + '</div></div>';
|
||
resultArea.innerHTML = html;
|
||
}
|
||
|
||
// 开始录音
|
||
function startRecording() {
|
||
pcmChunks = [];
|
||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||
.then(function(mediaStream) {
|
||
stream = mediaStream;
|
||
|
||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
if (audioCtx.state === 'suspended') {
|
||
audioCtx.resume();
|
||
}
|
||
recordedSampleRate = audioCtx.sampleRate;
|
||
|
||
mediaStreamSrc = audioCtx.createMediaStreamSource(mediaStream);
|
||
|
||
procNode = audioCtx.createScriptProcessor(4096, 1, 1);
|
||
procNode.onaudioprocess = function(event) {
|
||
var channelData = event.inputBuffer.getChannelData(0);
|
||
var buf = new Int16Array(channelData.length);
|
||
for (var i = 0; i < channelData.length; i++) {
|
||
var s = Math.max(-1, Math.min(1, channelData[i]));
|
||
buf[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||
}
|
||
pcmChunks.push(buf);
|
||
};
|
||
|
||
mediaStreamSrc.connect(procNode);
|
||
procNode.connect(audioCtx.destination);
|
||
|
||
isRecording = true;
|
||
micBtn.classList.add('recording');
|
||
})
|
||
.catch(function(err) {
|
||
showToast('无法访问麦克风 ' + err.message, 'error');
|
||
});
|
||
}
|
||
|
||
function stopRecording() {
|
||
if (!isRecording) return;
|
||
isRecording = false;
|
||
micBtn.classList.remove('recording');
|
||
|
||
if (procNode) {
|
||
procNode.disconnect();
|
||
procNode = null;
|
||
}
|
||
if (mediaStreamSrc) {
|
||
mediaStreamSrc.disconnect();
|
||
mediaStreamSrc = null;
|
||
}
|
||
if (audioCtx) {
|
||
audioCtx.close();
|
||
audioCtx = null;
|
||
}
|
||
if (stream) {
|
||
stream.getTracks().forEach(function(t) { t.stop(); });
|
||
stream = null;
|
||
}
|
||
|
||
if (pcmChunks.length === 0) return;
|
||
|
||
var totalLen = pcmChunks.reduce(function(sum, chunk) { return sum + chunk.length; }, 0);
|
||
var allSamples = new Int16Array(totalLen);
|
||
var offset = 0;
|
||
pcmChunks.forEach(function(chunk) {
|
||
allSamples.set(chunk, offset);
|
||
offset += chunk.length;
|
||
});
|
||
|
||
var wavBlob = encodeWav(allSamples, recordedSampleRate);
|
||
sendAudio(wavBlob);
|
||
}
|
||
|
||
function encodeWav(samples, sampleRate) {
|
||
var numChannels = 1;
|
||
var bitsPerSample = 16;
|
||
var bytesPerSample = bitsPerSample / 8;
|
||
var byteRate = sampleRate * numChannels * bytesPerSample;
|
||
var blockAlign = numChannels * bytesPerSample;
|
||
var dataSize = samples.length * bytesPerSample;
|
||
|
||
var buf = new ArrayBuffer(44 + dataSize);
|
||
var dv = new DataView(buf);
|
||
|
||
function writeStr(offset, str) {
|
||
for (var i = 0; i < str.length; i++) {
|
||
dv.setUint8(offset + i, str.charCodeAt(i));
|
||
}
|
||
}
|
||
|
||
writeStr(0, 'RIFF');
|
||
dv.setUint32(4, 36 + dataSize, true);
|
||
writeStr(8, 'WAVE');
|
||
writeStr(12, 'fmt ');
|
||
dv.setUint32(16, 16, true);
|
||
dv.setUint16(20, 1, true);
|
||
dv.setUint16(22, numChannels, true);
|
||
dv.setUint32(24, sampleRate, true);
|
||
dv.setUint32(28, byteRate, true);
|
||
dv.setUint16(32, blockAlign, true);
|
||
dv.setUint16(34, bitsPerSample, true);
|
||
writeStr(36, 'data');
|
||
dv.setUint32(40, dataSize, true);
|
||
|
||
var ptr = 44;
|
||
for (var i = 0; i < samples.length; i++) {
|
||
dv.setInt16(ptr, samples[i], true);
|
||
ptr += 2;
|
||
}
|
||
|
||
return new Blob([buf], { type: 'audio/wav' });
|
||
}
|
||
|
||
function cardClass(category) {
|
||
if (!category) return 'card-default';
|
||
var fixed = {'\u9910\u996e':'card-c1','\u996e\u98df':'card-c1','\u4ea4\u901a':'card-c2','\u51fa\u884c':'card-c2','\u8d2d\u7269':'card-c3','\u5a31\u4e50':'card-c4','\u533b\u7597':'card-c5','\u5065\u5eb7':'card-c5','\u6559\u80b2':'card-c6','\u5b66\u4e60':'card-c6'};
|
||
if (fixed[category]) return fixed[category];
|
||
var hash = 0;
|
||
for (var i = 0; i < category.length; i++) {
|
||
hash = ((hash << 5) - hash) + category.charCodeAt(i);
|
||
hash |= 0;
|
||
}
|
||
return 'card-c' + ((Math.abs(hash) % 12) + 1);
|
||
}
|
||
|
||
function sendAudio(blob) {
|
||
showToast('处理中...', 'info');
|
||
var formData = new FormData();
|
||
formData.append('audio', blob, 'recording.wav');
|
||
|
||
fetch('/api/voice/process', { method: 'POST', body: formData })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(resp) {
|
||
if (resp.code !== 200) {
|
||
showToast(resp.message, 'error');
|
||
return;
|
||
}
|
||
var taskId = resp.data.taskId;
|
||
var es = new EventSource('/api/voice/stream/' + taskId);
|
||
es.onmessage = function(event) {
|
||
var result = JSON.parse(event.data);
|
||
es.close();
|
||
handleVoiceResult(result);
|
||
};
|
||
es.onerror = function() {
|
||
es.close();
|
||
showToast('处理超时,请重试', 'error');
|
||
};
|
||
})
|
||
.catch(function(err) {
|
||
showToast('网络错误: ' + err.message, 'error');
|
||
});
|
||
}
|
||
|
||
function handleVoiceResult(result) {
|
||
if (result.code !== 200) {
|
||
showToast(result.message, 'error');
|
||
return;
|
||
}
|
||
var data = result.data;
|
||
if (data.intent === 'RECORD') {
|
||
if (data.success && data.recordTip) {
|
||
showToast(data.recordTip, 'success');
|
||
loadRecentRecords();
|
||
} else {
|
||
showToast(data.reply || '录入失败', 'error');
|
||
}
|
||
} else if (data.intent === 'QUERY') {
|
||
showQueryResult(data);
|
||
showToast('查询完成', 'info');
|
||
} else {
|
||
showAiReply(data.reply);
|
||
}
|
||
}
|
||
|
||
// 加载最近事项
|
||
function loadRecentRecords() {
|
||
fetch('/api/records?page=1&size=5')
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(resp) {
|
||
if (resp.code !== 200 || !resp.data) return;
|
||
var records = resp.data.records || [];
|
||
if (records.length === 0) {
|
||
recentList.innerHTML = '<p class="empty-hint">暂无事项记录</p>';
|
||
return;
|
||
}
|
||
var html = '';
|
||
records.forEach(function(r) {
|
||
html += '<div class="record-item ' + cardClass(r.category) + '">' +
|
||
'<span class="rec-time">' + formatTime(r.time) + '</span>' +
|
||
'<span class="rec-person">' + (r.person || '我') + '</span>' +
|
||
'<span class="rec-content">' + (r.content || '') + '</span>' +
|
||
'<span class="rec-amount">¥' + (r.amount || '0') + '</span>' +
|
||
'</div>';
|
||
});
|
||
recentList.innerHTML = html;
|
||
})
|
||
.catch(function() {});
|
||
}
|
||
|
||
// 事件绑定
|
||
micBtn.addEventListener('mousedown', startRecording);
|
||
micBtn.addEventListener('mouseup', stopRecording);
|
||
micBtn.addEventListener('mouseleave', stopRecording);
|
||
micBtn.addEventListener('touchstart', function(e) {
|
||
e.preventDefault();
|
||
startRecording();
|
||
});
|
||
micBtn.addEventListener('touchend', function(e) {
|
||
e.preventDefault();
|
||
stopRecording();
|
||
});
|
||
|
||
// 初始化加载最近事项
|
||
loadRecentRecords();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|