Trae Assistant
Enhance app with file upload, error handling, and localization
888384b
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>突触流 Synapse Flow | 智能BCI数据平台</title>
<!-- Vue 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- ECharts -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<!-- Marked.js -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
[v-cloak] { display: none; }
.chart-container { height: 300px; width: 100%; }
/* Custom Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
</style>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
900: '#0c4a6e',
}
}
}
}
}
</script>
</head>
<body class="bg-slate-50 text-slate-800 font-sans antialiased">
<div id="app" v-cloak class="flex h-screen overflow-hidden">
<!-- Mobile Sidebar Overlay -->
<div v-if="mobileMenuOpen" class="fixed inset-0 bg-black/50 z-40 md:hidden" @click="mobileMenuOpen = false"></div>
<!-- Sidebar -->
<aside :class="{'translate-x-0': mobileMenuOpen, '-translate-x-full': !mobileMenuOpen}" class="fixed md:static inset-y-0 left-0 z-50 w-64 bg-white border-r border-slate-200 transition-transform duration-300 md:translate-x-0 flex flex-col">
<div class="p-6 border-b border-slate-100 flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-brand-600 flex items-center justify-center text-white text-xl shadow-lg shadow-brand-500/30">
<i class="fa-solid fa-brain"></i>
</div>
<div>
<h1 class="font-bold text-lg tracking-tight text-slate-900">突触流</h1>
<p class="text-xs text-slate-500 font-medium">Synapse Flow Agent</p>
</div>
</div>
<nav class="flex-1 p-4 space-y-1 overflow-y-auto">
<a href="#" @click.prevent="currentView = 'dashboard'" :class="{'bg-brand-50 text-brand-700': currentView === 'dashboard', 'text-slate-600 hover:bg-slate-50': currentView !== 'dashboard'}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors">
<i class="fa-solid fa-chart-line w-5"></i> 实时监控
</a>
<a href="#" @click.prevent="currentView = 'analysis'" :class="{'bg-brand-50 text-brand-700': currentView === 'analysis', 'text-slate-600 hover:bg-slate-50': currentView !== 'analysis'}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors">
<i class="fa-solid fa-microchip w-5"></i> 智能分析
</a>
<a href="#" @click.prevent="currentView = 'history'" :class="{'bg-brand-50 text-brand-700': currentView === 'history', 'text-slate-600 hover:bg-slate-50': currentView !== 'history'}" class="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors">
<i class="fa-solid fa-clock-rotate-left w-5"></i> 历史档案
</a>
</nav>
<div class="p-4 border-t border-slate-100">
<div class="bg-slate-900 rounded-xl p-4 text-white">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-slate-300">设备状态</span>
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium bg-green-500/20 text-green-400 border border-green-500/20">
<span class="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse"></span> 在线
</span>
</div>
<div class="flex items-end gap-1 h-8 mt-2">
<div v-for="i in 10" :key="i" class="flex-1 bg-brand-500/40 rounded-sm transition-all duration-300" :style="{height: Math.random() * 100 + '%'}"></div>
</div>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col h-full overflow-hidden bg-slate-50/50">
<!-- Header -->
<header class="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-4 md:px-8">
<button @click="mobileMenuOpen = !mobileMenuOpen" class="md:hidden p-2 text-slate-600 hover:bg-slate-100 rounded-lg">
<i class="fa-solid fa-bars"></i>
</button>
<div class="flex items-center gap-4 ml-auto">
<button @click="toggleRecording" :class="isRecording ? 'bg-red-50 text-red-600 border-red-200' : 'bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100'" class="px-4 py-2 rounded-lg text-sm font-medium border flex items-center gap-2 transition-all">
<i :class="isRecording ? 'fa-solid fa-stop animate-pulse' : 'fa-solid fa-play'"></i>
${ isRecording ? '停止记录' : '开始记录' }
</button>
<div class="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-slate-500 border border-white shadow-sm">
<i class="fa-solid fa-user"></i>
</div>
</div>
</header>
<!-- Scrollable Area -->
<div class="flex-1 overflow-y-auto p-4 md:p-8">
<!-- Dashboard View -->
<div v-if="currentView === 'dashboard'" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div v-for="(val, key) in metrics" :key="key" class="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<div class="text-xs text-slate-500 font-medium uppercase tracking-wider mb-1">${ key }</div>
<div class="text-2xl font-bold text-slate-800 flex items-baseline gap-2">
${ val.toFixed(1) }
<span class="text-xs font-normal text-slate-400">μV</span>
</div>
</div>
</div>
<div class="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-slate-800">实时 EEG 波形监控</h3>
<div class="flex gap-2 text-xs">
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-blue-500"></span> Alpha</span>
<span class="flex items-center gap-1"><span class="w-2 h-2 rounded-full bg-indigo-500"></span> Beta</span>
</div>
</div>
<div ref="signalChart" class="chart-container"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
<h3 class="font-bold text-slate-800 mb-4">频带能量分布</h3>
<div ref="spectrumChart" class="chart-container" style="height: 250px;"></div>
</div>
<div class="bg-white p-6 rounded-xl border border-slate-200 shadow-sm flex flex-col">
<h3 class="font-bold text-slate-800 mb-4">会话控制</h3>
<div class="space-y-4 flex-1">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">受试者 ID</label>
<input v-model="subjectId" type="text" class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">训练类型</label>
<select v-model="sessionType" class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-brand-500 focus:border-brand-500 outline-none text-sm">
<option>Focus Training (专注训练)</option>
<option>Relaxation (放松引导)</option>
<option>Motor Imagery (运动想象)</option>
</select>
</div>
<!-- File Upload Section -->
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">数据源 (可选)</label>
<div class="flex gap-2">
<button @click="triggerUpload" class="flex-1 px-3 py-2 border border-dashed border-slate-300 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-brand-600 transition-colors text-sm flex items-center justify-center gap-2">
<i class="fa-solid fa-cloud-arrow-up"></i>
${ uploadStatus || '上传 EEG/CSV 文件' }
</button>
<input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" accept=".csv,.txt,.edf,.bdf,.json">
</div>
</div>
<button @click="analyzeSession" :disabled="isAnalyzing" class="w-full mt-auto bg-brand-600 hover:bg-brand-700 text-white py-2.5 rounded-lg font-medium transition-all shadow-lg shadow-brand-500/30 disabled:opacity-50 disabled:cursor-not-allowed flex justify-center items-center gap-2">
<i v-if="isAnalyzing" class="fa-solid fa-circle-notch animate-spin"></i>
${ isAnalyzing ? '正在分析数据...' : '生成分析报告' }
</button>
</div>
</div>
</div>
</div>
<!-- Analysis View -->
<div v-if="currentView === 'analysis'" class="space-y-6">
<div v-if="!lastAnalysis" class="text-center py-20 bg-white rounded-xl border border-slate-200 border-dashed">
<div class="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mx-auto mb-4 text-slate-300 text-2xl">
<i class="fa-solid fa-file-contract"></i>
</div>
<h3 class="text-slate-900 font-medium">暂无分析报告</h3>
<p class="text-slate-500 text-sm mt-1">请在“实时监控”页面生成一次会话分析。</p>
<button @click="currentView = 'dashboard'" class="mt-4 text-brand-600 font-medium text-sm hover:underline">去生成 &rarr;</button>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left: Report -->
<div class="lg:col-span-2 space-y-6">
<div class="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
<div class="flex items-center gap-3 mb-6 pb-4 border-b border-slate-100">
<div class="p-2 bg-brand-50 text-brand-600 rounded-lg">
<i class="fa-solid fa-wand-magic-sparkles"></i>
</div>
<div>
<h2 class="font-bold text-slate-900">AI 认知评估报告</h2>
<p class="text-xs text-slate-500">Based on SiliconFlow Intelligence</p>
</div>
</div>
<div class="prose prose-slate prose-sm max-w-none" v-html="parsedSummary"></div>
</div>
<div class="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
<h3 class="font-bold text-slate-800 mb-4">改进建议</h3>
<ul class="space-y-3">
<li v-for="(rec, idx) in lastAnalysis.recommendations" :key="idx" class="flex items-start gap-3 bg-slate-50 p-3 rounded-lg border border-slate-100">
<span class="flex-shrink-0 w-6 h-6 bg-brand-100 text-brand-600 rounded-full flex items-center justify-center text-xs font-bold">${ idx + 1 }</span>
<span class="text-sm text-slate-700">${ rec }</span>
</li>
</ul>
</div>
</div>
<!-- Right: Visuals -->
<div class="space-y-6">
<div class="bg-white p-6 rounded-xl border border-slate-200 shadow-sm">
<h3 class="font-bold text-slate-800 mb-2">认知状态雷达</h3>
<div ref="radarChart" class="chart-container" style="height: 300px;"></div>
</div>
<div class="bg-gradient-to-br from-slate-900 to-slate-800 p-6 rounded-xl text-white shadow-lg">
<div class="text-xs text-slate-400 uppercase font-medium mb-1">主要状态</div>
<div class="text-2xl font-bold mb-4">${ lastAnalysis.cognitive_state }</div>
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-brand-400 w-3/4"></div>
</div>
<div class="mt-2 text-xs text-slate-400 flex justify-between">
<span>Confidence</span>
<span>88%</span>
</div>
</div>
</div>
</div>
</div>
<!-- History View -->
<div v-if="currentView === 'history'" class="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div class="p-6 border-b border-slate-100 flex justify-between items-center">
<h3 class="font-bold text-slate-800">历史会话档案</h3>
<button @click="fetchHistory" class="text-sm text-brand-600 hover:text-brand-700 font-medium">
<i class="fa-solid fa-rotate-right mr-1"></i> 刷新
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-slate-50 text-slate-500 font-medium">
<tr>
<th class="px-6 py-3">ID</th>
<th class="px-6 py-3">时间</th>
<th class="px-6 py-3">受试者</th>
<th class="px-6 py-3">类型</th>
<th class="px-6 py-3">状态评估</th>
<th class="px-6 py-3">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="item in historyList" :key="item.id" class="hover:bg-slate-50/50">
<td class="px-6 py-3 font-mono text-xs text-slate-400">#${ item.id }</td>
<td class="px-6 py-3 text-slate-600">${ item.timestamp }</td>
<td class="px-6 py-3 font-medium text-slate-900">${ item.subject_id }</td>
<td class="px-6 py-3">
<span class="px-2 py-1 rounded-full bg-blue-50 text-blue-600 text-xs font-medium border border-blue-100">${ item.session_type }</span>
</td>
<td class="px-6 py-3 text-slate-600">${ item.analysis.cognitive_state }</td>
<td class="px-6 py-3">
<button @click="loadAnalysis(item.analysis)" class="text-brand-600 hover:text-brand-700 font-medium text-xs">查看报告</button>
</td>
</tr>
<tr v-if="historyList.length === 0">
<td colspan="6" class="px-6 py-8 text-center text-slate-400">暂无历史记录</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</main>
</div>
<script>
const { createApp, ref, onMounted, computed, watch, nextTick } = Vue;
createApp({
delimiters: ['${', '}'],
setup() {
const currentView = ref('dashboard');
const mobileMenuOpen = ref(false);
const isRecording = ref(false);
const isAnalyzing = ref(false);
const subjectId = ref('SUB-001');
const sessionType = ref('Focus Training (专注训练)');
const metrics = ref({ alpha: 0, beta: 0, theta: 0, delta: 0 });
const lastAnalysis = ref(null);
const historyList = ref([]);
const uploadStatus = ref('');
const fileInput = ref(null);
// Chart Refs
const signalChart = ref(null);
const spectrumChart = ref(null);
const radarChart = ref(null);
let signalChartInst = null;
let spectrumChartInst = null;
let radarChartInst = null;
let timer = null;
// Data Buffers
const xData = [];
const yDataAlpha = [];
const yDataBeta = [];
const parsedSummary = computed(() => {
if (!lastAnalysis.value || !lastAnalysis.value.summary) return '';
return marked.parse(lastAnalysis.value.summary);
});
const triggerUpload = () => {
fileInput.value.click();
};
const handleFileUpload = async (event) => {
const file = event.target.files[0];
if (!file) return;
uploadStatus.value = '上传中...';
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const data = await res.json();
if (res.ok) {
uploadStatus.value = '上传成功';
// Use extracted metrics to update dashboard
if (data.extracted_metrics) {
metrics.value = data.extracted_metrics;
// Also update spectrum chart
if (spectrumChartInst) {
spectrumChartInst.setOption({
series: [{
data: [
data.extracted_metrics.alpha,
data.extracted_metrics.beta,
data.extracted_metrics.theta,
data.extracted_metrics.delta
]
}]
});
}
}
setTimeout(() => { uploadStatus.value = ''; }, 3000);
} else {
uploadStatus.value = '上传失败';
alert(data.error || '上传失败');
}
} catch (e) {
console.error(e);
uploadStatus.value = '出错';
alert('上传过程中发生错误');
}
// Reset input
event.target.value = '';
};
const initCharts = () => {
if (signalChart.value) {
signalChartInst = echarts.init(signalChart.value);
signalChartInst.setOption({
grid: { top: 20, right: 20, bottom: 30, left: 40 },
xAxis: { type: 'category', data: xData, boundaryGap: false },
yAxis: { type: 'value', min: 0, max: 40 },
series: [
{ name: 'Alpha', type: 'line', smooth: true, data: yDataAlpha, showSymbol: false, lineStyle: { color: '#3b82f6', width: 2 } },
{ name: 'Beta', type: 'line', smooth: true, data: yDataBeta, showSymbol: false, lineStyle: { color: '#6366f1', width: 2 } }
],
animation: false
});
}
if (spectrumChart.value) {
spectrumChartInst = echarts.init(spectrumChart.value);
spectrumChartInst.setOption({
color: ['#3b82f6', '#6366f1', '#10b981', '#f59e0b'],
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: ['Alpha', 'Beta', 'Theta', 'Delta'] },
yAxis: { type: 'value' },
series: [{
data: [10, 20, 5, 2],
type: 'bar',
itemStyle: { borderRadius: [4, 4, 0, 0] }
}]
});
}
};
const updateCharts = (data) => {
// Update Metrics
const latest = data[data.length - 1];
metrics.value = {
alpha: latest.alpha,
beta: latest.beta,
theta: latest.theta,
delta: latest.delta
};
// Update Signal Chart
data.forEach(p => {
xData.push(new Date().toLocaleTimeString());
yDataAlpha.push(p.alpha);
yDataBeta.push(p.beta);
if (xData.length > 50) {
xData.shift();
yDataAlpha.shift();
yDataBeta.shift();
}
});
if (signalChartInst) {
signalChartInst.setOption({
xAxis: { data: xData },
series: [
{ data: yDataAlpha },
{ data: yDataBeta }
]
});
}
// Update Spectrum
if (spectrumChartInst) {
spectrumChartInst.setOption({
series: [{
data: [latest.alpha, latest.beta, latest.theta, latest.delta]
}]
});
}
};
const startMockData = () => {
timer = setInterval(() => {
if (!isRecording.value) return;
fetch('/api/mock/signal')
.then(res => res.json())
.then(res => {
if(res.status === 'success') {
updateCharts(res.data);
}
});
}, 1000);
};
const toggleRecording = () => {
isRecording.value = !isRecording.value;
};
const analyzeSession = async () => {
isAnalyzing.value = true;
try {
const res = await fetch('/api/analyze', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
subject_id: subjectId.value,
session_type: sessionType.value
})
});
const data = await res.json();
if (data.status === 'success') {
lastAnalysis.value = data.result;
currentView.value = 'analysis';
// Wait for DOM update then init radar
setTimeout(initRadarChart, 100);
}
} catch (e) {
alert('分析失败,请重试');
} finally {
isAnalyzing.value = false;
}
};
const initRadarChart = () => {
if (radarChart.value && lastAnalysis.value) {
radarChartInst = echarts.init(radarChart.value);
const radarData = lastAnalysis.value.radar_chart;
radarChartInst.setOption({
radar: {
indicator: Object.keys(radarData).map(k => ({ name: k, max: 100 })),
splitArea: {
areaStyle: {
color: ['#f8fafc', '#f1f5f9', '#e2e8f0', '#cbd5e1'].reverse()
}
}
},
series: [{
type: 'radar',
data: [{
value: Object.values(radarData),
name: '当前状态',
areaStyle: { color: 'rgba(14, 165, 233, 0.4)' },
lineStyle: { color: '#0ea5e9' }
}]
}]
});
}
};
const fetchHistory = async () => {
const res = await fetch('/api/history');
const data = await res.json();
if (data.status === 'success') {
historyList.value = data.history;
}
};
const loadAnalysis = (analysis) => {
lastAnalysis.value = analysis;
currentView.value = 'analysis';
setTimeout(initRadarChart, 100);
};
onMounted(() => {
initCharts();
startMockData();
fetchHistory();
// Responsive charts
window.addEventListener('resize', () => {
signalChartInst && signalChartInst.resize();
spectrumChartInst && spectrumChartInst.resize();
radarChartInst && radarChartInst.resize();
});
});
watch(currentView, (newVal) => {
if (newVal === 'dashboard') {
nextTick(() => {
initCharts();
});
} else if (newVal === 'analysis') {
nextTick(() => {
initRadarChart();
});
} else if (newVal === 'history') {
fetchHistory();
}
});
return {
currentView, mobileMenuOpen, isRecording, isAnalyzing,
subjectId, sessionType, metrics, lastAnalysis, historyList,
signalChart, spectrumChart, radarChart,
parsedSummary, uploadStatus, fileInput,
toggleRecording, analyzeSession, fetchHistory, loadAnalysis,
triggerUpload, handleFileUpload
};
}
}).mount('#app');
</script>
</body>
</html>