tracexd/target/classes/templates/index.html
2026-06-09 16:03:44 +08:00

330 lines
14 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>