auto-sync: study-analysis 2026-04-03_11:23

This commit is contained in:
ai_member_only 2026-04-03 11:23:12 +08:00
commit fc8902b102
34 changed files with 28942 additions and 0 deletions

47
SKILL.md Normal file
View File

@ -0,0 +1,47 @@
---
name: study-analysis
description: 分析用户学习情况的工具输入格式为「学情分析用户角色IDLevel几Unit几」其中Level支持Level1/L1/1格式Unit支持Unit1/U1/1格式自动请求API获取学情数据并生成可视化分析报告。触发场景用户提到「学情分析」、「学习情况分析」时使用。
---
# 学情分析技能使用指南
## 触发规则
当用户输入以下格式内容时触发本技能:
```
学情分析:[用户角色ID][Level][Unit]
```
- 用户角色ID数字类型必填
- Level支持多种格式`Level1`/`L1`/`1`/`Level2`/`L2`/`2`自动转换为数字1或2
- Unit支持多种格式`Unit1`/`U1`/`1`,自动转换为对应的数字
## 工作流程
1. **参数解析**从用户输入中提取角色ID、Level、Unit三个参数统一格式化为数字类型
2. **构造请求**将参数封装为API查询参数operator固定为Kingson
3. **请求API**调用学情分析服务API `https://api.valavala.com/v2/user/unit/learn/info`,传入请求参数
4. **存储数据**将API返回的JSON数据保存到本地 output 目录
5. **生成可视化报告**使用HTML模板渲染返回数据生成可视化HTML文件
6. **返回结果**将生成的HTML文件访问地址返回给用户
## 调用方式
执行脚本:
```bash
python3 /root/.openclaw/workspace-xiaoban/skills/study-analysis/scripts/analysis.py [role_id] [level] [unit]
```
## 参数说明
- `role_id`: 用户角色ID数字
- `level`: 等级数字1或2
- `unit`: 单元号(数字)
## API接口说明
- 请求地址:`https://api.valavala.com/v2/user/unit/learn/info`
- 请求方式GET
- 请求参数:
- `operator`: 固定值 `Kingson`
- `level`: `L1``L2`
- `userId`: 用户角色ID
- `unitIndex`: 单元号
## 输出说明
- 成功返回HTML可视化报告的本地路径/访问地址
- 失败:返回具体错误信息

630
assets/template copy.html Normal file
View File

@ -0,0 +1,630 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>学情分析报告 - 用户{{ROLE_ID}} Level{{LEVEL}} Unit{{UNIT}}</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
background-color: #f0f2f5;
padding: 24px;
color: #333;
}
.container { max-width: 1200px; margin: 0 auto; }
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 32px;
border-radius: 16px;
margin-bottom: 24px;
}
.header h1 { font-size: 28px; margin-bottom: 8px; }
.header .meta { font-size: 14px; opacity: 0.9; }
.card {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.card h2 {
font-size: 18px;
color: #333;
margin-bottom: 20px;
border-left: 4px solid #667eea;
padding-left: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-item {
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
padding: 20px;
border-radius: 12px;
text-align: center;
}
.stat-item .label { font-size: 13px; color: #888; margin-bottom: 8px; }
.stat-item .value { font-size: 28px; font-weight: 700; color: #333; }
.stat-item .value.green { color: #52c41a; }
.stat-item .value.orange { color: #fa8c16; }
.stat-item .value.blue { color: #1890ff; }
.stat-item .value.purple { color: #722ed1; }
.chart-container { height: 400px; width: 100%; }
.chart-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 20px;
}
@media (max-width: 1024px) {
.chart-row { grid-template-columns: 1fr; }
}
.lesson-nav {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.lesson-card {
background: white;
border: 2px solid #e8e8e8;
border-radius: 12px;
padding: 20px;
cursor: pointer;
transition: all 0.3s;
text-align: center;
}
.lesson-card:hover {
border-color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
}
.lesson-card.active {
border-color: #667eea;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.lesson-card .lesson-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
}
.lesson-card .lesson-time {
font-size: 13px;
opacity: 0.9;
line-height: 1.6;
}
.lesson-card .lesson-time .time-label {
opacity: 0.7;
}
.lesson-card .lesson-stats {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(102, 126, 234, 0.2);
font-size: 13px;
}
.lesson-card.active .lesson-stats {
border-top-color: rgba(255, 255, 255, 0.3);
}
.lesson-card .lesson-stats .stat {
display: flex;
flex-direction: column;
align-items: center;
}
.lesson-card .lesson-stats .stat-label {
font-size: 11px;
opacity: 0.7;
margin-bottom: 2px;
}
.lesson-card .lesson-stats .stat-value {
font-weight: 600;
font-size: 14px;
}
.lesson-card .lesson-stats .stat-value.oops { color: #fa541c; }
.lesson-card .lesson-stats .stat-value.wrong { color: #f5222d; }
.lesson-card.active .lesson-stats .stat-value.oops,
.lesson-card.active .lesson-stats .stat-value.wrong { color: #ffccc7; }
.lesson-detail { display: none; }
.lesson-detail.active { display: block; }
.knowledge-section { margin-bottom: 20px; }
.knowledge-section h3 {
font-size: 15px;
color: #555;
margin-bottom: 12px;
}
.word-list, .pattern-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.word-tag {
background: #e6f7ff;
border: 1px solid #91d5ff;
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
}
.word-tag .en { font-weight: 600; color: #1890ff; }
.word-tag .cn { color: #666; margin-left: 6px; }
.word-tag .pos { color: #999; font-size: 12px; margin-left: 4px; }
.pattern-tag {
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 8px;
padding: 10px 16px;
font-size: 14px;
flex: 1 1 300px;
}
.pattern-tag .en { font-weight: 600; color: #fa8c16; }
.pattern-tag .cn { color: #666; display: block; margin-top: 4px; font-size: 13px; }
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
table th {
background: #f5f7fa;
padding: 12px;
text-align: left;
font-weight: 600;
color: #555;
border-bottom: 2px solid #e8e8e8;
}
table td {
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
}
table tr:hover td { background: #fafafa; }
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.badge.perfect { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
.badge.good { background: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
.badge.oops { background: #fff2e8; color: #fa541c; border: 1px solid #ffbb96; }
.badge.correct { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
.badge.wrong { background: #fff1f0; color: #f5222d; border: 1px solid #ffa39e; }
.audio-btn {
cursor: pointer;
margin-left: 6px;
font-size: 16px;
vertical-align: middle;
opacity: 0.7;
transition: opacity 0.2s;
}
.audio-btn:hover { opacity: 1; }
.audio-btn.playing { opacity: 1; color: #1890ff; }
.time-info {
display: flex;
gap: 20px;
margin-bottom: 16px;
font-size: 14px;
color: #666;
}
.time-info span { display: flex; align-items: center; gap: 6px; }
.raw-data {
background: #f8f9fa;
padding: 16px;
border-radius: 8px;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📚 学情分析报告</h1>
<div class="meta">
用户ID{{ROLE_ID}} &nbsp;|&nbsp; Level {{LEVEL}} &nbsp;|&nbsp; Unit {{UNIT}} &nbsp;|&nbsp; 生成时间:{{GENERATE_TIME}}
</div>
</div>
<div class="card">
<h2>📊 总览</h2>
<div class="stats-grid" id="stats-grid"></div>
</div>
<div class="chart-row">
<div class="card">
<h2>📈 互动组件表现</h2>
<div id="interactive-chart" class="chart-container"></div>
</div>
<div class="card">
<h2>📝 巩固练习正确率</h2>
<div id="exercise-chart" class="chart-container"></div>
</div>
<div class="card">
<h2>🎯 能力训练正确率</h2>
<div id="ability-chart" class="chart-container"></div>
</div>
</div>
<div class="card">
<h2>📖 课程详情</h2>
<div class="lesson-nav" id="lesson-nav"></div>
<div id="lesson-details"></div>
</div>
<div class="card">
<h2>🎯 能力训练</h2>
<table id="ability-training-table">
<thead>
<tr>
<th>#</th>
<th>来源</th>
<th>题型</th>
<th>标题</th>
<th>题目详情</th>
<th>子题目情况</th>
<th>结果</th>
</tr>
</thead>
<tbody id="ability-training-tbody"></tbody>
</table>
</div>
</div>
<script>
const data = {{DATA}};
// 兼容data可能直接就是lessons数组也可能有外层包装
const lessons = data.lessons || data.data?.lessons || (Array.isArray(data) ? data : []);
const summary = data.summary || data.data?.summary || null;
// ==================== 总览统计 ====================
const statsGrid = document.getElementById('stats-grid');
const totalLessons = lessons.length;
let totalInteractive = 0, totalExercises = 0;
let totalStudyDuration = 0; // 单元学习时长(分钟)
let unitStartTime = null, unitEndTime = null;
lessons.forEach(lesson => {
const ics = lesson.interactive_components || lesson.interactiveComponents || [];
const ces = lesson.consolidation_exercises || lesson.consolidationExercises || [];
totalInteractive += ics.length;
totalExercises += ces.length;
// 计算每个lesson的学习时长
const entryTime = lesson.entry_time || lesson.entryTime;
const completionTime = lesson.completion_time || lesson.completionTime;
const consolidationEntry = lesson.consolidation_entry_time || lesson.consolidationEntryTime;
const consolidationCompletion = lesson.consolidation_completion_time || lesson.consolidationCompletionTime;
if (entryTime && completionTime) {
const lessonDuration = (new Date(completionTime) - new Date(entryTime)) / (1000 * 60);
if (lessonDuration > 0) totalStudyDuration += lessonDuration;
}
if (consolidationEntry && consolidationCompletion) {
const consolidationDuration = (new Date(consolidationCompletion) - new Date(consolidationEntry)) / (1000 * 60);
if (consolidationDuration > 0) totalStudyDuration += consolidationDuration;
}
// 记录单元开始和结束时间
if (entryTime) {
const entryDate = new Date(entryTime);
if (!unitStartTime || entryDate < unitStartTime) unitStartTime = entryDate;
}
if (consolidationCompletion) {
const completionDate = new Date(consolidationCompletion);
if (!unitEndTime || completionDate > unitEndTime) unitEndTime = completionDate;
}
});
// 计算单元长度从第1个lesson进入到第5个lesson巩固完成
let unitLengthStr = '-';
if (unitStartTime && unitEndTime) {
const unitLengthMs = unitEndTime - unitStartTime;
const unitLengthMinutes = Math.round(unitLengthMs / (1000 * 60));
const days = Math.floor(unitLengthMinutes / (24 * 60));
const hours = Math.floor((unitLengthMinutes % (24 * 60)) / 60);
const minutes = unitLengthMinutes % 60;
if (days > 0) {
unitLengthStr = `${days}天${hours}小时${minutes}分钟`;
} else if (hours > 0) {
unitLengthStr = `${hours}小时${minutes}分钟`;
} else {
unitLengthStr = `${minutes}分钟`;
}
}
// 格式化学习时长
let studyDurationStr = '-';
if (totalStudyDuration > 0) {
const studyHours = Math.floor(totalStudyDuration / 60);
const studyMinutes = Math.round(totalStudyDuration % 60);
if (studyHours > 0) {
studyDurationStr = `${studyHours}小时${studyMinutes}分钟`;
} else {
studyDurationStr = `${studyMinutes}分钟`;
}
}
const statsItems = [
{ label: '单元学习时长', value: studyDurationStr, cls: 'purple' },
{ label: '总互动次数', value: totalInteractive, cls: 'blue' },
{ label: '总练习题数', value: totalExercises, cls: 'green' },
{ label: '单元长度', value: unitLengthStr, cls: 'orange' }
];
statsItems.forEach(item => {
const div = document.createElement('div');
div.className = 'stat-item';
div.innerHTML = `<div class="label">${item.label}</div><div class="value ${item.cls}">${item.value}</div>`;
statsGrid.appendChild(div);
});
// ==================== 互动组件柱状图(按课程,三段堆叠) ====================
const interactiveChart = echarts.init(document.getElementById('interactive-chart'));
const lessonLabels = lessons.map((l, i) => l.lesson_name || l.lessonName || `Lesson ${i + 1}`);
const perfectCounts = [];
const goodCounts = [];
const oopsCounts = [];
lessons.forEach(lesson => {
const ics = lesson.interactive_components || lesson.interactiveComponents || [];
let perfect = 0, good = 0, oops = 0;
ics.forEach(ic => {
const r = (ic.user_result || ic.userResult || '').toLowerCase();
if (r === 'perfect') perfect++;
else if (r === 'good') good++;
else if (r === 'oops') oops++;
});
perfectCounts.push(perfect);
goodCounts.push(good);
oopsCounts.push(oops);
});
interactiveChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { bottom: 10, data: ['Perfect', 'Good', 'Oops'] },
grid: { left: '3%', right: '4%', bottom: '15%', top: '5%', containLabel: true },
xAxis: { type: 'category', data: lessonLabels, axisLabel: { rotate: 20, fontSize: 12 } },
yAxis: { type: 'value' },
color: ['#52c41a', '#1890ff', '#fa541c'],
series: [
{ name: 'Perfect', type: 'bar', stack: 'total', data: perfectCounts, itemStyle: { borderRadius: [0, 0, 4, 4] } },
{ name: 'Good', type: 'bar', stack: 'total', data: goodCounts, itemStyle: { borderRadius: [0, 0, 0, 0] } },
{ name: 'Oops', type: 'bar', stack: 'total', data: oopsCounts, itemStyle: { borderRadius: [4, 4, 0, 0] } }
]
});
// ==================== 巩固练习柱状图(按课程,两段堆叠) ====================
const exerciseChart = echarts.init(document.getElementById('exercise-chart'));
const correctCounts = [];
const wrongCounts = [];
lessons.forEach(lesson => {
const ces = lesson.consolidation_exercises || lesson.consolidationExercises || [];
let correct = 0, wrong = 0;
ces.forEach(ce => {
if (ce.is_correct === true || ce.isCorrect === true) correct++;
else wrong++;
});
correctCounts.push(correct);
wrongCounts.push(wrong);
});
exerciseChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { bottom: 10, data: ['正确', '错误'] },
grid: { left: '3%', right: '4%', bottom: '15%', top: '5%', containLabel: true },
xAxis: { type: 'category', data: lessonLabels, axisLabel: { rotate: 20, fontSize: 12 } },
yAxis: { type: 'value' },
color: ['#52c41a', '#f5222d'],
series: [
{ name: '正确', type: 'bar', stack: 'total', data: correctCounts, itemStyle: { borderRadius: [0, 0, 4, 4] } },
{ name: '错误', type: 'bar', stack: 'total', data: wrongCounts, itemStyle: { borderRadius: [4, 4, 0, 0] } }
]
});
// ==================== 能力训练饼图 ====================
const abilityTraining = data.ability_training || data.abilityTraining || [];
let perfectCount = 0, goodCount = 0, oopsCount = 0;
abilityTraining.forEach(at => {
const result = (at.result || '').toLowerCase();
if (result === 'perfect') perfectCount++;
else if (result === 'good') goodCount++;
else if (result === 'oops') oopsCount++;
});
const abilityChart = echarts.init(document.getElementById('ability-chart'));
abilityChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 10, data: ['Perfect', 'Good', 'Oops'] },
color: ['#52c41a', '#1890ff', '#fa541c'],
series: [{
name: '能力训练结果',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '45%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 },
label: { show: true, formatter: '{b}: {c}' },
data: [
{ value: perfectCount, name: 'Perfect' },
{ value: goodCount, name: 'Good' },
{ value: oopsCount, name: 'Oops' }
]
}]
});
const navContainer = document.getElementById('lesson-nav');
const detailContainer = document.getElementById('lesson-details');
lessons.forEach((lesson, idx) => {
// 大方块导航卡片
const card = document.createElement('div');
card.className = 'lesson-card' + (idx === 0 ? ' active' : '');
card.onclick = () => switchTab(idx);
const entryTime = lesson.entry_time || lesson.entryTime || '-';
const completionTime = lesson.completion_time || lesson.completionTime || '-';
const lessonName = lesson.lesson_name || lesson.lessonName || `Lesson ${idx + 1}`;
// 计算统计数据
const icsForStats = lesson.interactive_components || lesson.interactiveComponents || [];
const cesForStats = lesson.consolidation_exercises || lesson.consolidationExercises || [];
let oopsCount = 0;
icsForStats.forEach(ic => {
const r = (ic.user_result || ic.userResult || '').toLowerCase();
if (r === 'oops') oopsCount++;
});
const oopsRate = icsForStats.length > 0 ? Math.round(oopsCount / icsForStats.length * 100) : 0;
let wrongCount = 0;
cesForStats.forEach(ce => {
if (ce.is_correct !== true && ce.isCorrect !== true) wrongCount++;
});
const wrongRate = cesForStats.length > 0 ? Math.round(wrongCount / cesForStats.length * 100) : 0;
card.innerHTML = `
<div class="lesson-title">${lessonName}</div>
<div class="lesson-time">
<div><span class="time-label">进入:</span>${formatTime(entryTime)}</div>
<div><span class="time-label">完成:</span>${formatTime(completionTime)}</div>
</div>
<div class="lesson-stats">
<div class="stat">
<span class="stat-label">Oops率</span>
<span class="stat-value oops">${oopsRate}%</span>
</div>
<div class="stat">
<span class="stat-label">错误率</span>
<span class="stat-value wrong">${wrongRate}%</span>
</div>
</div>
`;
navContainer.appendChild(card);
// 详情区域
const detail = document.createElement('div');
detail.className = 'lesson-detail' + (idx === 0 ? ' active' : '');
detail.id = `lesson-${idx}`;
const kp = lesson.knowledge_points || lesson.knowledgePoints || {};
const words = kp.words || [];
const patterns = kp.sentence_patterns || kp.sentencePatterns || [];
const ics = lesson.interactive_components || lesson.interactiveComponents || [];
const ces = lesson.consolidation_exercises || lesson.consolidationExercises || [];
let html = `
<div class="knowledge-section">
<h3>📗 单词 (${words.length}个)</h3>
<div class="word-list">
${words.map(w => `<div class="word-tag"><span class="en">${w.word}</span><span class="cn">${w.meaning}</span><span class="pos">${w.pos || ''}</span></div>`).join('')}
</div>
</div>
<div class="knowledge-section">
<h3>📘 句型 (${patterns.length}个)</h3>
<div class="pattern-list">
${patterns.map(p => `<div class="pattern-tag"><span class="en">${p.pattern}</span><span class="cn">${p.meaning}</span></div>`).join('')}
</div>
</div>
<div class="knowledge-section">
<h3>🎯 互动组件 (${ics.length}个)</h3>
<table>
<thead><tr><th>#</th><th>题型</th><th>知识点</th><th>详情</th><th>结果</th></tr></thead>
<tbody>
${ics.map((ic, i) => {
const result = (ic.user_result || ic.userResult || '').toLowerCase();
const cls = result === 'perfect' ? 'perfect' : result === 'good' ? 'good' : 'oops';
const kpText = ic.knowledge || '-';
const audioBtn = ic.audio ? `<span class="audio-btn" onclick="playAudio('${ic.audio}', this)" title="播放录音">🔊</span>` : '';
return `<tr><td>${i + 1}</td><td>${ic.type}</td><td>${kpText}</td><td>${ic.detail}</td><td><span class="badge ${cls}">${result}</span>${audioBtn}</td></tr>`;
}).join('')}
</tbody>
</table>
</div>
<div class="knowledge-section">
<h3>📝 学习巩固 (${ces.length}题)</h3>
<table>
<thead><tr><th>#</th><th>题型</th><th>知识点</th><th>详情</th><th>结果</th></tr></thead>
<tbody>
${ces.map((ce, i) => {
const correct = ce.is_correct === true || ce.isCorrect === true;
const kpText = ce.knowledge || '-';
const audioBtn = ce.audio ? `<span class="audio-btn" onclick="playAudio('${ce.audio}', this)" title="播放录音">🔊</span>` : '';
return `<tr><td>${i + 1}</td><td>${ce.type}</td><td>${kpText}</td><td>${ce.detail}</td><td><span class="badge ${correct ? 'correct' : 'wrong'}">${correct ? '✓ 正确' : '✗ 错误'}</span>${audioBtn}</td></tr>`;
}).join('')}
</tbody>
</table>
</div>
`;
detail.innerHTML = html;
detailContainer.appendChild(detail);
});
let currentAudio = null;
function playAudio(url, el) {
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
document.querySelectorAll('.audio-btn.playing').forEach(b => b.classList.remove('playing'));
}
const audio = new Audio(url);
currentAudio = audio;
el.classList.add('playing');
audio.play().catch(() => {});
audio.onended = () => { el.classList.remove('playing'); currentAudio = null; };
audio.onerror = () => { el.classList.remove('playing'); currentAudio = null; };
}
function switchTab(idx) {
document.querySelectorAll('.lesson-card').forEach((c, i) => c.classList.toggle('active', i === idx));
document.querySelectorAll('.lesson-detail').forEach((d, i) => d.classList.toggle('active', i === idx));
}
function formatTime(t) {
if (!t || t === '-') return '-';
try {
const d = new Date(t);
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch { return t; }
}
// ==================== 能力训练展示 ====================
const abilityTbody = document.getElementById('ability-training-tbody');
abilityTraining.forEach((at, idx) => {
const tr = document.createElement('tr');
const result = (at.result || '').toLowerCase();
const badgeCls = result === 'perfect' ? 'perfect' : result === 'good' ? 'good' : 'oops';
const subQuestionInfo = `${at.sub_question_correct || 0}/${at.sub_question_count || 0}`;
const atAudioBtn = at.audio ? `<span class="audio-btn" onclick="playAudio('${at.audio}', this)" title="播放录音">🔊</span>` : '';
tr.innerHTML = `
<td>${idx + 1}</td>
<td>${at.source || '-'}</td>
<td>${at.type || '-'}</td>
<td>${at.title || '-'}</td>
<td>${at.detail || '-'}</td>
<td>${subQuestionInfo}</td>
<td><span class="badge ${badgeCls}">${result}</span>${atAudioBtn}</td>
`;
abilityTbody.appendChild(tr);
});
// 响应式
window.addEventListener('resize', () => {
interactiveChart.resize();
exerciseChart.resize();
abilityChart.resize();
});
</script>
</body>
</html>

465
assets/template.html Normal file
View File

@ -0,0 +1,465 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>学情分析报告 - 用户{{ROLE_ID}} Level{{LEVEL}} Unit{{UNIT}}</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
accent: { DEFAULT: '#7A5ADB', light: '#EDE9F9', dark: '#5B3DB0' }
}
}
}
}
</script>
<style>
.chart-container { height: 360px; width: 100%; }
.lesson-detail { display: none; }
.lesson-detail.active { display: block; }
.lesson-card { transition: all 0.25s ease; }
.lesson-card:hover { transform: translateY(-2px); }
.lesson-card.active {
background: linear-gradient(135deg, #7A5ADB 0%, #5B3DB0 100%);
color: white;
border-color: transparent;
}
.lesson-card.active .stat-value-oops,
.lesson-card.active .stat-value-wrong { color: #fecaca !important; }
.lesson-card.active .time-label-dim { opacity: 0.7; }
.lesson-card.active .border-accent-light { border-color: rgba(255,255,255,0.25); }
.audio-btn { cursor: pointer; transition: opacity 0.2s; }
.audio-btn:hover { opacity: 1; }
.audio-btn.playing { color: #7A5ADB; opacity: 1; }
table { border-collapse: collapse; }
</style>
</head>
<body class="bg-gray-50 text-gray-700 antialiased">
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-6 sm:py-10">
<!-- Header -->
<div class="rounded-2xl bg-gradient-to-br from-accent to-accent-dark text-white px-6 sm:px-10 py-8 mb-6">
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight">学情分析报告</h1>
<p class="mt-2 text-sm opacity-80">用户ID{{ROLE_ID}} · Level {{LEVEL}} · Unit {{UNIT}} · {{GENERATE_TIME}}</p>
</div>
<!-- 总览 -->
<div class="bg-white rounded-2xl shadow-sm p-6 mb-5">
<h2 class="text-lg font-semibold text-gray-800 border-l-4 border-accent pl-3 mb-5">总览</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4" id="stats-grid"></div>
</div>
<!-- 图表 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5 mb-5">
<div class="bg-white rounded-2xl shadow-sm p-6">
<h2 class="text-base font-semibold text-gray-800 border-l-4 border-accent pl-3 mb-4">互动组件表现</h2>
<div id="interactive-chart" class="chart-container"></div>
</div>
<div class="bg-white rounded-2xl shadow-sm p-6">
<h2 class="text-base font-semibold text-gray-800 border-l-4 border-accent pl-3 mb-4">巩固练习正确率</h2>
<div id="exercise-chart" class="chart-container"></div>
</div>
<div class="bg-white rounded-2xl shadow-sm p-6">
<h2 class="text-base font-semibold text-gray-800 border-l-4 border-accent pl-3 mb-4">能力训练正确率</h2>
<div id="ability-chart" class="chart-container"></div>
</div>
</div>
<!-- 课程详情 -->
<div class="bg-white rounded-2xl shadow-sm p-6 mb-5">
<h2 class="text-lg font-semibold text-gray-800 border-l-4 border-accent pl-3 mb-5">课程详情</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-6" id="lesson-nav"></div>
<div id="lesson-details"></div>
</div>
<!-- 能力训练 -->
<div class="bg-white rounded-2xl shadow-sm p-6 mb-5">
<h2 class="text-lg font-semibold text-gray-800 border-l-4 border-accent pl-3 mb-5">能力训练</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm" id="ability-training-table">
<thead>
<tr class="border-b-2 border-gray-100">
<th class="text-left py-3 px-3 font-semibold text-gray-500 bg-gray-50/60">#</th>
<th class="text-left py-3 px-3 font-semibold text-gray-500 bg-gray-50/60">来源</th>
<th class="text-left py-3 px-3 font-semibold text-gray-500 bg-gray-50/60">题型</th>
<th class="text-left py-3 px-3 font-semibold text-gray-500 bg-gray-50/60">标题</th>
<th class="text-left py-3 px-3 font-semibold text-gray-500 bg-gray-50/60 hidden sm:table-cell">题目详情</th>
<th class="text-left py-3 px-3 font-semibold text-gray-500 bg-gray-50/60">子题目</th>
<th class="text-left py-3 px-3 font-semibold text-gray-500 bg-gray-50/60">结果</th>
</tr>
</thead>
<tbody id="ability-training-tbody"></tbody>
</table>
</div>
</div>
</div>
<script>
const data = {{DATA}};
// 兼容data可能直接就是lessons数组也可能有外层包装
const lessons = data.lessons || data.data?.lessons || (Array.isArray(data) ? data : []);
const summary = data.summary || data.data?.summary || null;
// ==================== 总览统计 ====================
const statsGrid = document.getElementById('stats-grid');
const totalLessons = lessons.length;
let totalInteractive = 0, totalExercises = 0;
let totalStudyDuration = 0; // 单元学习时长(分钟)
let unitStartTime = null, unitEndTime = null;
lessons.forEach(lesson => {
const ics = lesson.interactive_components || lesson.interactiveComponents || [];
const ces = lesson.consolidation_exercises || lesson.consolidationExercises || [];
totalInteractive += ics.length;
totalExercises += ces.length;
// 计算每个lesson的学习时长
const entryTime = lesson.entry_time || lesson.entryTime;
const completionTime = lesson.completion_time || lesson.completionTime;
const consolidationEntry = lesson.consolidation_entry_time || lesson.consolidationEntryTime;
const consolidationCompletion = lesson.consolidation_completion_time || lesson.consolidationCompletionTime;
if (entryTime && completionTime) {
const lessonDuration = (new Date(completionTime) - new Date(entryTime)) / (1000 * 60);
if (lessonDuration > 0) totalStudyDuration += lessonDuration;
}
if (consolidationEntry && consolidationCompletion) {
const consolidationDuration = (new Date(consolidationCompletion) - new Date(consolidationEntry)) / (1000 * 60);
if (consolidationDuration > 0) totalStudyDuration += consolidationDuration;
}
// 记录单元开始和结束时间
if (entryTime) {
const entryDate = new Date(entryTime);
if (!unitStartTime || entryDate < unitStartTime) unitStartTime = entryDate;
}
if (consolidationCompletion) {
const completionDate = new Date(consolidationCompletion);
if (!unitEndTime || completionDate > unitEndTime) unitEndTime = completionDate;
}
});
// 计算单元长度从第1个lesson进入到第5个lesson巩固完成
let unitLengthStr = '-';
if (unitStartTime && unitEndTime) {
const unitLengthMs = unitEndTime - unitStartTime;
const unitLengthMinutes = Math.round(unitLengthMs / (1000 * 60));
const days = Math.floor(unitLengthMinutes / (24 * 60));
const hours = Math.floor((unitLengthMinutes % (24 * 60)) / 60);
const minutes = unitLengthMinutes % 60;
if (days > 0) {
unitLengthStr = `${days}天${hours}小时${minutes}分钟`;
} else if (hours > 0) {
unitLengthStr = `${hours}小时${minutes}分钟`;
} else {
unitLengthStr = `${minutes}分钟`;
}
}
// 格式化学习时长
let studyDurationStr = '-';
if (totalStudyDuration > 0) {
const studyHours = Math.floor(totalStudyDuration / 60);
const studyMinutes = Math.round(totalStudyDuration % 60);
if (studyHours > 0) {
studyDurationStr = `${studyHours}小时${studyMinutes}分钟`;
} else {
studyDurationStr = `${studyMinutes}分钟`;
}
}
const statsItems = [
{ label: '单元学习时长', value: studyDurationStr, color: 'text-accent' },
{ label: '总互动次数', value: totalInteractive, color: 'text-blue-500' },
{ label: '总练习题数', value: totalExercises, color: 'text-emerald-500' },
{ label: '单元长度', value: unitLengthStr, color: 'text-amber-500' }
];
statsItems.forEach(item => {
const div = document.createElement('div');
div.className = 'bg-gray-50 rounded-xl p-5 text-center';
div.innerHTML = `<div class="text-xs text-gray-400 mb-1">${item.label}</div><div class="text-2xl font-bold ${item.color}">${item.value}</div>`;
statsGrid.appendChild(div);
});
// ==================== 互动组件柱状图(按课程,三段堆叠) ====================
const interactiveChart = echarts.init(document.getElementById('interactive-chart'));
const lessonLabels = lessons.map((l, i) => l.lesson_name || l.lessonName || `Lesson ${i + 1}`);
const perfectCounts = [];
const goodCounts = [];
const oopsCounts = [];
lessons.forEach(lesson => {
const ics = lesson.interactive_components || lesson.interactiveComponents || [];
let perfect = 0, good = 0, oops = 0;
ics.forEach(ic => {
const r = (ic.user_result || ic.userResult || '').toLowerCase();
if (r === 'perfect') perfect++;
else if (r === 'good') good++;
else if (r === 'oops') oops++;
});
perfectCounts.push(perfect);
goodCounts.push(good);
oopsCounts.push(oops);
});
interactiveChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { bottom: 10, data: ['Perfect', 'Good', 'Oops'], textStyle: { color: '#9ca3af' } },
grid: { left: '3%', right: '4%', bottom: '15%', top: '5%', containLabel: true },
xAxis: { type: 'category', data: lessonLabels, axisLabel: { rotate: 20, fontSize: 11, color: '#9ca3af' }, axisLine: { lineStyle: { color: '#e5e7eb' } } },
yAxis: { type: 'value', axisLabel: { color: '#9ca3af' }, splitLine: { lineStyle: { color: '#f3f4f6' } } },
color: ['#10b981', '#7A5ADB', '#f97316'],
series: [
{ name: 'Perfect', type: 'bar', stack: 'total', data: perfectCounts, itemStyle: { borderRadius: [0, 0, 4, 4] } },
{ name: 'Good', type: 'bar', stack: 'total', data: goodCounts, itemStyle: { borderRadius: [0, 0, 0, 0] } },
{ name: 'Oops', type: 'bar', stack: 'total', data: oopsCounts, itemStyle: { borderRadius: [4, 4, 0, 0] } }
]
});
// ==================== 巩固练习柱状图(按课程,两段堆叠) ====================
const exerciseChart = echarts.init(document.getElementById('exercise-chart'));
const correctCounts = [];
const wrongCounts = [];
lessons.forEach(lesson => {
const ces = lesson.consolidation_exercises || lesson.consolidationExercises || [];
let correct = 0, wrong = 0;
ces.forEach(ce => {
if (ce.is_correct === true || ce.isCorrect === true) correct++;
else wrong++;
});
correctCounts.push(correct);
wrongCounts.push(wrong);
});
exerciseChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { bottom: 10, data: ['正确', '错误'], textStyle: { color: '#9ca3af' } },
grid: { left: '3%', right: '4%', bottom: '15%', top: '5%', containLabel: true },
xAxis: { type: 'category', data: lessonLabels, axisLabel: { rotate: 20, fontSize: 11, color: '#9ca3af' }, axisLine: { lineStyle: { color: '#e5e7eb' } } },
yAxis: { type: 'value', axisLabel: { color: '#9ca3af' }, splitLine: { lineStyle: { color: '#f3f4f6' } } },
color: ['#10b981', '#ef4444'],
series: [
{ name: '正确', type: 'bar', stack: 'total', data: correctCounts, itemStyle: { borderRadius: [0, 0, 4, 4] } },
{ name: '错误', type: 'bar', stack: 'total', data: wrongCounts, itemStyle: { borderRadius: [4, 4, 0, 0] } }
]
});
// ==================== 能力训练饼图 ====================
const abilityTraining = data.ability_training || data.abilityTraining || [];
let perfectCount = 0, goodCount = 0, oopsCount = 0;
abilityTraining.forEach(at => {
const result = (at.result || '').toLowerCase();
if (result === 'perfect') perfectCount++;
else if (result === 'good') goodCount++;
else if (result === 'oops') oopsCount++;
});
const abilityChart = echarts.init(document.getElementById('ability-chart'));
abilityChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 10, data: ['Perfect', 'Good', 'Oops'], textStyle: { color: '#9ca3af' } },
color: ['#10b981', '#7A5ADB', '#f97316'],
series: [{
name: '能力训练结果',
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '45%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 },
label: { show: true, formatter: '{b}: {c}' },
data: [
{ value: perfectCount, name: 'Perfect' },
{ value: goodCount, name: 'Good' },
{ value: oopsCount, name: 'Oops' }
]
}]
});
const navContainer = document.getElementById('lesson-nav');
const detailContainer = document.getElementById('lesson-details');
lessons.forEach((lesson, idx) => {
const card = document.createElement('div');
card.className = 'lesson-card border-2 border-gray-100 rounded-xl p-4 cursor-pointer text-center shadow-sm' + (idx === 0 ? ' active' : '');
card.onclick = () => switchTab(idx);
const entryTime = lesson.entry_time || lesson.entryTime || '-';
const completionTime = lesson.completion_time || lesson.completionTime || '-';
const lessonName = lesson.lesson_name || lesson.lessonName || `Lesson ${idx + 1}`;
const icsForStats = lesson.interactive_components || lesson.interactiveComponents || [];
const cesForStats = lesson.consolidation_exercises || lesson.consolidationExercises || [];
let oopsCount = 0;
icsForStats.forEach(ic => {
const r = (ic.user_result || ic.userResult || '').toLowerCase();
if (r === 'oops') oopsCount++;
});
const oopsRate = icsForStats.length > 0 ? Math.round(oopsCount / icsForStats.length * 100) : 0;
let wrongCount = 0;
cesForStats.forEach(ce => {
if (ce.is_correct !== true && ce.isCorrect !== true) wrongCount++;
});
const wrongRate = cesForStats.length > 0 ? Math.round(wrongCount / cesForStats.length * 100) : 0;
card.innerHTML = `
<div class="text-base font-semibold mb-2">${lessonName}</div>
<div class="text-xs opacity-80 leading-relaxed">
<div><span class="time-label-dim opacity-60">进入:</span>${formatTime(entryTime)}</div>
<div><span class="time-label-dim opacity-60">完成:</span>${formatTime(completionTime)}</div>
</div>
<div class="flex justify-center gap-4 mt-3 pt-3 border-t border-accent-light border-gray-100 text-xs">
<div class="flex flex-col items-center">
<span class="opacity-60 mb-0.5">Oops率</span>
<span class="font-semibold text-sm stat-value-oops text-orange-500">${oopsRate}%</span>
</div>
<div class="flex flex-col items-center">
<span class="opacity-60 mb-0.5">错误率</span>
<span class="font-semibold text-sm stat-value-wrong text-red-500">${wrongRate}%</span>
</div>
</div>
`;
navContainer.appendChild(card);
// 详情区域
const detail = document.createElement('div');
detail.className = 'lesson-detail' + (idx === 0 ? ' active' : '');
detail.id = `lesson-${idx}`;
const kp = lesson.knowledge_points || lesson.knowledgePoints || {};
const words = kp.words || [];
const patterns = kp.sentence_patterns || kp.sentencePatterns || [];
const ics = lesson.interactive_components || lesson.interactiveComponents || [];
const ces = lesson.consolidation_exercises || lesson.consolidationExercises || [];
let html = `
<div class="mb-5">
<h3 class="text-sm font-semibold text-gray-500 mb-3">📗 单词 (${words.length}个)</h3>
<div class="flex flex-wrap gap-2">
${words.map(w => `<span class="inline-flex items-center gap-1.5 bg-accent-light text-sm rounded-lg px-3 py-1.5"><span class="font-semibold text-accent">${w.word}</span><span class="text-gray-500">${w.meaning}</span><span class="text-gray-400 text-xs">${w.pos || ''}</span></span>`).join('')}
</div>
</div>
<div class="mb-5">
<h3 class="text-sm font-semibold text-gray-500 mb-3">📘 句型 (${patterns.length}个)</h3>
<div class="grid sm:grid-cols-2 gap-2">
${patterns.map(p => `<div class="bg-amber-50 border border-amber-200 rounded-lg px-4 py-2.5 text-sm"><span class="font-semibold text-amber-600">${p.pattern}</span><span class="block text-gray-500 text-xs mt-1">${p.meaning}</span></div>`).join('')}
</div>
</div>
<div class="mb-5">
<h3 class="text-sm font-semibold text-gray-500 mb-3">🎯 互动组件 (${ics.length}个)</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="border-b-2 border-gray-100">
<th class="text-left py-2.5 px-3 font-semibold text-gray-400 bg-gray-50/60 text-xs">#</th>
<th class="text-left py-2.5 px-3 font-semibold text-gray-400 bg-gray-50/60 text-xs">题型</th>
<th class="text-left py-2.5 px-3 font-semibold text-gray-400 bg-gray-50/60 text-xs">知识点</th>
<th class="text-left py-2.5 px-3 font-semibold text-gray-400 bg-gray-50/60 text-xs hidden sm:table-cell">详情</th>
<th class="text-left py-2.5 px-3 font-semibold text-gray-400 bg-gray-50/60 text-xs">结果</th>
</tr></thead>
<tbody>
${ics.map((ic, i) => {
const result = (ic.user_result || ic.userResult || '').toLowerCase();
const badgeColor = result === 'perfect' ? 'bg-emerald-50 text-emerald-600 border-emerald-200' : result === 'good' ? 'bg-blue-50 text-blue-600 border-blue-200' : 'bg-orange-50 text-orange-600 border-orange-200';
const kpText = ic.knowledge || '-';
const audioBtn = ic.audio ? `<span class="audio-btn opacity-60 ml-1.5 text-base" onclick="playAudio('${ic.audio}', this)" title="播放录音">🔊</span>` : '';
return `<tr class="border-b border-gray-50 hover:bg-gray-50/50"><td class="py-2 px-3 text-gray-400">${i + 1}</td><td class="py-2 px-3">${ic.type}</td><td class="py-2 px-3 text-gray-500">${kpText}</td><td class="py-2 px-3 text-gray-500 hidden sm:table-cell">${ic.detail}</td><td class="py-2 px-3"><span class="inline-block text-xs font-semibold px-2.5 py-0.5 rounded-full border ${badgeColor}">${result}</span>${audioBtn}</td></tr>`;
}).join('')}
</tbody>
</table>
</div>
</div>
<div class="mb-5">
<h3 class="text-sm font-semibold text-gray-500 mb-3">📝 学习巩固 (${ces.length}题)</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="border-b-2 border-gray-100">
<th class="text-left py-2.5 px-3 font-semibold text-gray-400 bg-gray-50/60 text-xs">#</th>
<th class="text-left py-2.5 px-3 font-semibold text-gray-400 bg-gray-50/60 text-xs">题型</th>
<th class="text-left py-2.5 px-3 font-semibold text-gray-400 bg-gray-50/60 text-xs">知识点</th>
<th class="text-left py-2.5 px-3 font-semibold text-gray-400 bg-gray-50/60 text-xs hidden sm:table-cell">详情</th>
<th class="text-left py-2.5 px-3 font-semibold text-gray-400 bg-gray-50/60 text-xs">结果</th>
</tr></thead>
<tbody>
${ces.map((ce, i) => {
const correct = ce.is_correct === true || ce.isCorrect === true;
const badgeColor = correct ? 'bg-emerald-50 text-emerald-600 border-emerald-200' : 'bg-red-50 text-red-500 border-red-200';
const kpText = ce.knowledge || '-';
const audioBtn = ce.audio ? `<span class="audio-btn opacity-60 ml-1.5 text-base" onclick="playAudio('${ce.audio}', this)" title="播放录音">🔊</span>` : '';
return `<tr class="border-b border-gray-50 hover:bg-gray-50/50"><td class="py-2 px-3 text-gray-400">${i + 1}</td><td class="py-2 px-3">${ce.type}</td><td class="py-2 px-3 text-gray-500">${kpText}</td><td class="py-2 px-3 text-gray-500 hidden sm:table-cell">${ce.detail}</td><td class="py-2 px-3"><span class="inline-block text-xs font-semibold px-2.5 py-0.5 rounded-full border ${badgeColor}">${correct ? '✓ 正确' : '✗ 错误'}</span>${audioBtn}</td></tr>`;
}).join('')}
</tbody>
</table>
</div>
</div>
`;
detail.innerHTML = html;
detailContainer.appendChild(detail);
});
let currentAudio = null;
function playAudio(url, el) {
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
document.querySelectorAll('.audio-btn.playing').forEach(b => b.classList.remove('playing'));
}
const audio = new Audio(url);
currentAudio = audio;
el.classList.add('playing');
audio.play().catch(() => {});
audio.onended = () => { el.classList.remove('playing'); currentAudio = null; };
audio.onerror = () => { el.classList.remove('playing'); currentAudio = null; };
}
function switchTab(idx) {
document.querySelectorAll('.lesson-card').forEach((c, i) => c.classList.toggle('active', i === idx));
document.querySelectorAll('.lesson-detail').forEach((d, i) => d.classList.toggle('active', i === idx));
}
function formatTime(t) {
if (!t || t === '-') return '-';
try {
const d = new Date(t);
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch { return t; }
}
// ==================== 能力训练展示 ====================
const abilityTbody = document.getElementById('ability-training-tbody');
abilityTraining.forEach((at, idx) => {
const tr = document.createElement('tr');
tr.className = 'border-b border-gray-50 hover:bg-gray-50/50';
const result = (at.result || '').toLowerCase();
const badgeColor = result === 'perfect' ? 'bg-emerald-50 text-emerald-600 border-emerald-200' : result === 'good' ? 'bg-blue-50 text-blue-600 border-blue-200' : 'bg-orange-50 text-orange-600 border-orange-200';
const subQuestionInfo = `${at.sub_question_correct || 0}/${at.sub_question_count || 0}`;
const atAudioBtn = at.audio ? `<span class="audio-btn opacity-60 ml-1.5 text-base" onclick="playAudio('${at.audio}', this)" title="播放录音">🔊</span>` : '';
tr.innerHTML = `
<td class="py-2.5 px-3 text-gray-400">${idx + 1}</td>
<td class="py-2.5 px-3">${at.source || '-'}</td>
<td class="py-2.5 px-3">${at.type || '-'}</td>
<td class="py-2.5 px-3">${at.title || '-'}</td>
<td class="py-2.5 px-3 text-gray-500 hidden sm:table-cell">${at.detail || '-'}</td>
<td class="py-2.5 px-3">${subQuestionInfo}</td>
<td class="py-2.5 px-3"><span class="inline-block text-xs font-semibold px-2.5 py-0.5 rounded-full border ${badgeColor}">${result}</span>${atAudioBtn}</td>
`;
abilityTbody.appendChild(tr);
});
// 响应式
window.addEventListener('resize', () => {
interactiveChart.resize();
exerciseChart.resize();
abilityChart.resize();
});
</script>
</body>
</html>

74
assets/template.json Normal file
View File

@ -0,0 +1,74 @@
{
"role_id": 12345,
"level": 1,
"unit": 1,
"unit_name": "Unit 1: Hello World",
"lessons": [
{
"lesson_id": 1,
"lesson_name": "Lesson 1: Greetings",
"entry_time": "2024-01-15T09:00:00Z",
"completion_time": "2024-01-15T09:25:30Z",
"knowledge_points": {
"words": [
{"word": "hello", "meaning": "你好", "pos": "int."},
{"word": "hi", "meaning": "嗨", "pos": "int."},
{"word": "goodbye", "meaning": "再见", "pos": "int."},
{"word": "name", "meaning": "名字", "pos": "n."}
],
"sentence_patterns": [
{"pattern": "Hello, my name is [name].", "meaning": "你好,我的名字是[名字]。", "example": "Hello, my name is Tom."},
{"pattern": "Nice to meet you.", "meaning": "很高兴认识你。", "example": "Nice to meet you too."}
]
},
"interactive_components": [
{"component_id": "ic_001", "title": "单词跟读 - hello", "detail": "请跟读单词 hello注意发音准确", "user_result": "perfect"},
{"component_id": "ic_002", "title": "单词跟读 - hi", "detail": "请跟读单词 hi注意语调自然", "user_result": "good"},
{"component_id": "ic_003", "title": "单词听选 - goodbye", "detail": "听录音,选择正确的单词 goodbye", "user_result": "perfect"},
{"component_id": "ic_004", "title": "单词拼写 - name", "detail": "根据发音,拼写出单词 name", "user_result": "good"},
{"component_id": "ic_005", "title": "句型跟读 - Hello, my name is...", "detail": "请跟读句型Hello, my name is [name]", "user_result": "perfect"},
{"component_id": "ic_006", "title": "句型替换练习", "detail": "替换名字完成句子Hello, my name is ______", "user_result": "oops"},
{"component_id": "ic_007", "title": "情景对话 - 打招呼", "detail": "在情景中与角色进行打招呼对话", "user_result": "good"},
{"component_id": "ic_008", "title": "听力理解 - 问候语", "detail": "听录音,选择正确的问候回应", "user_result": "perfect"},
{"component_id": "ic_009", "title": "语音识别 - hi", "detail": "请说出单词 hi", "user_result": "good"},
{"component_id": "ic_010", "title": "图文匹配 - 单词", "detail": "将单词与对应的图片进行匹配", "user_result": "perfect"},
{"component_id": "ic_011", "title": "句型听选 - Nice to meet you", "detail": "听录音,选择正确的回应", "user_result": "good"},
{"component_id": "ic_012", "title": "角色扮演 - 自我介绍", "detail": "扮演角色进行自我介绍对话", "user_result": "perfect"},
{"component_id": "ic_013", "title": "单词排序 - 句子组成", "detail": "将单词按正确顺序排列成句子", "user_result": "oops"},
{"component_id": "ic_014", "title": "发音练习 - 语调训练", "detail": "练习疑问句和陈述句的语调区别", "user_result": "good"},
{"component_id": "ic_015", "title": "单词填空 - 补全句子", "detail": "在句子中填入正确的单词", "user_result": "perfect"},
{"component_id": "ic_016", "title": "情景判断 - 选择回应", "detail": "根据情景选择最合适的回应", "user_result": "good"},
{"component_id": "ic_017", "title": "综合对话 - 完整对话", "detail": "完成完整的打招呼和自我介绍对话", "user_result": "perfect"},
{"component_id": "ic_018", "title": "复习测验 - 本课回顾", "detail": "完成本课知识点的综合复习测验", "user_result": "good"}
],
"consolidation_exercises": [
{"exercise_id": "ce_001", "type": "单词听选", "detail": "听录音从A、B、C三个选项中选择听到的单词", "is_correct": true},
{"exercise_id": "ce_002", "type": "单词拼写", "detail": "根据中文意思,写出对应的英文单词:你好", "is_correct": true},
{"exercise_id": "ce_003", "type": "图文匹配", "detail": "将单词 goodbye 与对应的图片匹配", "is_correct": false},
{"exercise_id": "ce_004", "type": "句型填空", "detail": "用正确的单词填空Hello, my ______ is Tom.", "is_correct": true},
{"exercise_id": "ce_005", "type": "情景选择", "detail": "初次见面时应该说A. Hello B. Goodbye C. Hi", "is_correct": true},
{"exercise_id": "ce_006", "type": "句子排序", "detail": "将下列单词排列成正确的句子name / my / is / Hello", "is_correct": true},
{"exercise_id": "ce_007", "type": "听力理解", "detail": "听对话,回答问题:他们正在做什么?", "is_correct": false},
{"exercise_id": "ce_008", "type": "发音选择", "detail": "听录音,选择包含 /eɪ/ 音的单词", "is_correct": true},
{"exercise_id": "ce_009", "type": "翻译练习", "detail": "将下列句子翻译成英文:很高兴认识你。", "is_correct": true},
{"exercise_id": "ce_010", "type": "对话补全", "detail": "补全对话A: Hello! B: ______!", "is_correct": true},
{"exercise_id": "ce_011", "type": "单词分类", "detail": "将单词按问候语和非问候语分类", "is_correct": false},
{"exercise_id": "ce_012", "type": "句型转换", "detail": "将 Hello 转换为同义词", "is_correct": true},
{"exercise_id": "ce_013", "type": "连线匹配", "detail": "将单词与对应的中文意思连线", "is_correct": true},
{"exercise_id": "ce_014", "type": "判断正误", "detail": "判断句子是否正确Goodbye 用于告别时说。", "is_correct": true},
{"exercise_id": "ce_015", "type": "选词填空", "detail": "选择合适的单词填空______ to meet you. (Nice/Fine/Good)", "is_correct": true},
{"exercise_id": "ce_016", "type": "语音辨识", "detail": "听录音,判断是哪个单词", "is_correct": false},
{"exercise_id": "ce_017", "type": "完形填空", "detail": "阅读短文,选择合适的问候语填入空格", "is_correct": true},
{"exercise_id": "ce_018", "type": "综合测验", "detail": "完成本课知识的综合测验题", "is_correct": true}
]
}
],
"summary": {
"total_lessons": 5,
"completed_lessons": 1,
"total_interactive_components": 18,
"total_consolidation_exercises": 18,
"mastery_rate": 0.85,
"study_duration_minutes": 25
}
}

1656
assets/test.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
{
"code": 200,
"data": {
"data": "hello world"
},
"msg": "",
"traceID": "bfc13d6b1b5669affffa521d675d7141"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,401 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>学情分析报告 - 用户10293 Level1 Unit1</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
background-color: #f0f2f5;
padding: 24px;
color: #333;
}
.container { max-width: 1200px; margin: 0 auto; }
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 32px;
border-radius: 16px;
margin-bottom: 24px;
}
.header h1 { font-size: 28px; margin-bottom: 8px; }
.header .meta { font-size: 14px; opacity: 0.9; }
.card {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.card h2 {
font-size: 18px;
color: #333;
margin-bottom: 20px;
border-left: 4px solid #667eea;
padding-left: 12px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-item {
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
padding: 20px;
border-radius: 12px;
text-align: center;
}
.stat-item .label { font-size: 13px; color: #888; margin-bottom: 8px; }
.stat-item .value { font-size: 28px; font-weight: 700; color: #333; }
.stat-item .value.green { color: #52c41a; }
.stat-item .value.orange { color: #fa8c16; }
.stat-item .value.blue { color: #1890ff; }
.stat-item .value.purple { color: #722ed1; }
.chart-container { height: 400px; width: 100%; }
.chart-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 768px) {
.chart-row { grid-template-columns: 1fr; }
}
.lesson-tab {
display: flex;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.lesson-tab .tab-btn {
padding: 8px 20px;
border: 2px solid #667eea;
border-radius: 20px;
background: white;
color: #667eea;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.lesson-tab .tab-btn.active, .lesson-tab .tab-btn:hover {
background: #667eea;
color: white;
}
.lesson-detail { display: none; }
.lesson-detail.active { display: block; }
.knowledge-section { margin-bottom: 20px; }
.knowledge-section h3 {
font-size: 15px;
color: #555;
margin-bottom: 12px;
}
.word-list, .pattern-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.word-tag {
background: #e6f7ff;
border: 1px solid #91d5ff;
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
}
.word-tag .en { font-weight: 600; color: #1890ff; }
.word-tag .cn { color: #666; margin-left: 6px; }
.word-tag .pos { color: #999; font-size: 12px; margin-left: 4px; }
.pattern-tag {
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 8px;
padding: 10px 16px;
font-size: 14px;
flex: 1 1 300px;
}
.pattern-tag .en { font-weight: 600; color: #fa8c16; }
.pattern-tag .cn { color: #666; display: block; margin-top: 4px; font-size: 13px; }
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
table th {
background: #f5f7fa;
padding: 12px;
text-align: left;
font-weight: 600;
color: #555;
border-bottom: 2px solid #e8e8e8;
}
table td {
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
}
table tr:hover td { background: #fafafa; }
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.badge.perfect { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
.badge.good { background: #e6f7ff; color: #1890ff; border: 1px solid #91d5ff; }
.badge.oops { background: #fff2e8; color: #fa541c; border: 1px solid #ffbb96; }
.badge.correct { background: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; }
.badge.wrong { background: #fff1f0; color: #f5222d; border: 1px solid #ffa39e; }
.time-info {
display: flex;
gap: 20px;
margin-bottom: 16px;
font-size: 14px;
color: #666;
}
.time-info span { display: flex; align-items: center; gap: 6px; }
.raw-data {
background: #f8f9fa;
padding: 16px;
border-radius: 8px;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📚 学情分析报告</h1>
<div class="meta">
用户ID10293 &nbsp;|&nbsp; Level 1 &nbsp;|&nbsp; Unit 1 &nbsp;|&nbsp; 生成时间2026-04-02 16:05:08
</div>
</div>
<div class="card">
<h2>📊 总览</h2>
<div class="stats-grid" id="stats-grid"></div>
</div>
<div class="chart-row">
<div class="card">
<h2>📈 互动组件表现</h2>
<div id="interactive-chart" class="chart-container"></div>
</div>
<div class="card">
<h2>📝 巩固练习正确率</h2>
<div id="exercise-chart" class="chart-container"></div>
</div>
</div>
<div class="card">
<h2>📖 课程详情</h2>
<div class="lesson-tab" id="lesson-tabs"></div>
<div id="lesson-details"></div>
</div>
<div class="card">
<h2>🔍 原始数据</h2>
<div class="raw-data" id="raw-data"></div>
</div>
</div>
<script>
const data = {"code": 200, "data": {"data": "hello world"}, "msg": "", "traceID": "bfc13d6b1b5669affffa521d675d7141"};
// 兼容data可能直接就是lessons数组也可能有外层包装
const lessons = data.lessons || data.data?.lessons || (Array.isArray(data) ? data : []);
const summary = data.summary || data.data?.summary || null;
// ==================== 总览统计 ====================
const statsGrid = document.getElementById('stats-grid');
const totalLessons = lessons.length;
let totalInteractive = 0, totalPerfect = 0, totalGood = 0, totalOops = 0;
let totalExercises = 0, totalCorrect = 0;
lessons.forEach(lesson => {
const ics = lesson.interactive_components || lesson.interactiveComponents || [];
const ces = lesson.consolidation_exercises || lesson.consolidationExercises || [];
totalInteractive += ics.length;
ics.forEach(ic => {
const r = (ic.user_result || ic.userResult || '').toLowerCase();
if (r === 'perfect') totalPerfect++;
else if (r === 'good') totalGood++;
else if (r === 'oops') totalOops++;
});
totalExercises += ces.length;
ces.forEach(ce => {
if (ce.is_correct === true || ce.isCorrect === true) totalCorrect++;
});
});
const exerciseAccuracy = totalExercises > 0 ? Math.round(totalCorrect / totalExercises * 100) : 0;
const interactiveScore = totalInteractive > 0 ? Math.round((totalPerfect * 3 + totalGood * 2 + totalOops * 1) / (totalInteractive * 3) * 100) : 0;
const statsItems = [
{ label: '课程数', value: totalLessons, cls: 'blue' },
{ label: '互动组件总数', value: totalInteractive, cls: 'purple' },
{ label: 'Perfect 次数', value: totalPerfect, cls: 'green' },
{ label: 'Good 次数', value: totalGood, cls: 'blue' },
{ label: 'Oops 次数', value: totalOops, cls: 'orange' },
{ label: '巩固题总数', value: totalExercises, cls: 'purple' },
{ label: '巩固正确率', value: exerciseAccuracy + '%', cls: 'green' },
{ label: '互动综合得分', value: interactiveScore + '%', cls: 'blue' }
];
statsItems.forEach(item => {
const div = document.createElement('div');
div.className = 'stat-item';
div.innerHTML = `<div class="label">${item.label}</div><div class="value ${item.cls}">${item.value}</div>`;
statsGrid.appendChild(div);
});
// ==================== 互动组件表现饼图 ====================
const interactiveChart = echarts.init(document.getElementById('interactive-chart'));
interactiveChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 10 },
color: ['#52c41a', '#1890ff', '#fa541c'],
series: [{
type: 'pie',
radius: ['40%', '65%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 },
label: { show: true, fontSize: 14 },
data: [
{ value: totalPerfect, name: 'Perfect' },
{ value: totalGood, name: 'Good' },
{ value: totalOops, name: 'Oops' }
]
}]
});
// ==================== 巩固练习柱状图(按课程) ====================
const exerciseChart = echarts.init(document.getElementById('exercise-chart'));
const lessonLabels = lessons.map((l, i) => l.lesson_name || l.lessonName || `Lesson ${i + 1}`);
const correctCounts = [];
const wrongCounts = [];
lessons.forEach(lesson => {
const ces = lesson.consolidation_exercises || lesson.consolidationExercises || [];
let correct = 0, wrong = 0;
ces.forEach(ce => {
if (ce.is_correct === true || ce.isCorrect === true) correct++;
else wrong++;
});
correctCounts.push(correct);
wrongCounts.push(wrong);
});
exerciseChart.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { bottom: 10 },
grid: { left: '3%', right: '4%', bottom: '15%', top: '5%', containLabel: true },
xAxis: { type: 'category', data: lessonLabels, axisLabel: { rotate: 20, fontSize: 12 } },
yAxis: { type: 'value' },
color: ['#52c41a', '#f5222d'],
series: [
{ name: '正确', type: 'bar', stack: 'total', data: correctCounts, itemStyle: { borderRadius: [4, 4, 0, 0] } },
{ name: '错误', type: 'bar', stack: 'total', data: wrongCounts, itemStyle: { borderRadius: [4, 4, 0, 0] } }
]
});
// ==================== 课程详情 tabs ====================
const tabContainer = document.getElementById('lesson-tabs');
const detailContainer = document.getElementById('lesson-details');
lessons.forEach((lesson, idx) => {
// tab 按钮
const btn = document.createElement('button');
btn.className = 'tab-btn' + (idx === 0 ? ' active' : '');
btn.textContent = lesson.lesson_name || lesson.lessonName || `Lesson ${idx + 1}`;
btn.onclick = () => switchTab(idx);
tabContainer.appendChild(btn);
// 详情区域
const detail = document.createElement('div');
detail.className = 'lesson-detail' + (idx === 0 ? ' active' : '');
detail.id = `lesson-${idx}`;
const entryTime = lesson.entry_time || lesson.entryTime || '-';
const completionTime = lesson.completion_time || lesson.completionTime || '-';
const kp = lesson.knowledge_points || lesson.knowledgePoints || {};
const words = kp.words || [];
const patterns = kp.sentence_patterns || kp.sentencePatterns || [];
const ics = lesson.interactive_components || lesson.interactiveComponents || [];
const ces = lesson.consolidation_exercises || lesson.consolidationExercises || [];
let html = `
<div class="time-info">
<span>🕐 进入时间:${formatTime(entryTime)}</span>
<span>✅ 完成时间:${formatTime(completionTime)}</span>
</div>
<div class="knowledge-section">
<h3>📗 单词 (${words.length}个)</h3>
<div class="word-list">
${words.map(w => `<div class="word-tag"><span class="en">${w.word}</span><span class="cn">${w.meaning}</span><span class="pos">${w.pos || ''}</span></div>`).join('')}
</div>
</div>
<div class="knowledge-section">
<h3>📘 句型 (${patterns.length}个)</h3>
<div class="pattern-list">
${patterns.map(p => `<div class="pattern-tag"><span class="en">${p.pattern}</span><span class="cn">${p.meaning}</span></div>`).join('')}
</div>
</div>
<div class="knowledge-section">
<h3>🎯 互动组件 (${ics.length}个)</h3>
<table>
<thead><tr><th>#</th><th>标题</th><th>详情</th><th>结果</th></tr></thead>
<tbody>
${ics.map((ic, i) => {
const result = (ic.user_result || ic.userResult || '').toLowerCase();
const cls = result === 'perfect' ? 'perfect' : result === 'good' ? 'good' : 'oops';
return `<tr><td>${i + 1}</td><td>${ic.title}</td><td>${ic.detail}</td><td><span class="badge ${cls}">${result}</span></td></tr>`;
}).join('')}
</tbody>
</table>
</div>
<div class="knowledge-section">
<h3>📝 学习巩固 (${ces.length}题)</h3>
<table>
<thead><tr><th>#</th><th>题型</th><th>详情</th><th>结果</th></tr></thead>
<tbody>
${ces.map((ce, i) => {
const correct = ce.is_correct === true || ce.isCorrect === true;
return `<tr><td>${i + 1}</td><td>${ce.type}</td><td>${ce.detail}</td><td><span class="badge ${correct ? 'correct' : 'wrong'}">${correct ? '✓ 正确' : '✗ 错误'}</span></td></tr>`;
}).join('')}
</tbody>
</table>
</div>
`;
detail.innerHTML = html;
detailContainer.appendChild(detail);
});
function switchTab(idx) {
document.querySelectorAll('.tab-btn').forEach((b, i) => b.classList.toggle('active', i === idx));
document.querySelectorAll('.lesson-detail').forEach((d, i) => d.classList.toggle('active', i === idx));
}
function formatTime(t) {
if (!t || t === '-') return '-';
try {
const d = new Date(t);
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch { return t; }
}
// 原始数据
document.getElementById('raw-data').textContent = JSON.stringify(data, null, 2);
// 响应式
window.addEventListener('resize', () => {
interactiveChart.resize();
exerciseChart.resize();
});
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,278 @@
{
"role_id": 12345,
"level": 1,
"unit": 1,
"unit_name": "Unit 1: Hello World",
"lessons": [
{
"lesson_id": 1,
"lesson_name": "Lesson 1: Greetings",
"entry_time": "2024-01-15T09:00:00Z",
"completion_time": "2024-01-15T09:25:30Z",
"knowledge_points": {
"words": [
{
"word": "hello",
"meaning": "你好",
"pos": "int."
},
{
"word": "hi",
"meaning": "嗨",
"pos": "int."
},
{
"word": "goodbye",
"meaning": "再见",
"pos": "int."
},
{
"word": "name",
"meaning": "名字",
"pos": "n."
}
],
"sentence_patterns": [
{
"pattern": "Hello, my name is [name].",
"meaning": "你好,我的名字是[名字]。",
"example": "Hello, my name is Tom."
},
{
"pattern": "Nice to meet you.",
"meaning": "很高兴认识你。",
"example": "Nice to meet you too."
}
]
},
"interactive_components": [
{
"component_id": "ic_001",
"title": "单词跟读 - hello",
"detail": "请跟读单词 hello注意发音准确",
"user_result": "perfect"
},
{
"component_id": "ic_002",
"title": "单词跟读 - hi",
"detail": "请跟读单词 hi注意语调自然",
"user_result": "good"
},
{
"component_id": "ic_003",
"title": "单词听选 - goodbye",
"detail": "听录音,选择正确的单词 goodbye",
"user_result": "perfect"
},
{
"component_id": "ic_004",
"title": "单词拼写 - name",
"detail": "根据发音,拼写出单词 name",
"user_result": "good"
},
{
"component_id": "ic_005",
"title": "句型跟读 - Hello, my name is...",
"detail": "请跟读句型Hello, my name is [name]",
"user_result": "perfect"
},
{
"component_id": "ic_006",
"title": "句型替换练习",
"detail": "替换名字完成句子Hello, my name is ______",
"user_result": "oops"
},
{
"component_id": "ic_007",
"title": "情景对话 - 打招呼",
"detail": "在情景中与角色进行打招呼对话",
"user_result": "good"
},
{
"component_id": "ic_008",
"title": "听力理解 - 问候语",
"detail": "听录音,选择正确的问候回应",
"user_result": "perfect"
},
{
"component_id": "ic_009",
"title": "语音识别 - hi",
"detail": "请说出单词 hi",
"user_result": "good"
},
{
"component_id": "ic_010",
"title": "图文匹配 - 单词",
"detail": "将单词与对应的图片进行匹配",
"user_result": "perfect"
},
{
"component_id": "ic_011",
"title": "句型听选 - Nice to meet you",
"detail": "听录音,选择正确的回应",
"user_result": "good"
},
{
"component_id": "ic_012",
"title": "角色扮演 - 自我介绍",
"detail": "扮演角色进行自我介绍对话",
"user_result": "perfect"
},
{
"component_id": "ic_013",
"title": "单词排序 - 句子组成",
"detail": "将单词按正确顺序排列成句子",
"user_result": "oops"
},
{
"component_id": "ic_014",
"title": "发音练习 - 语调训练",
"detail": "练习疑问句和陈述句的语调区别",
"user_result": "good"
},
{
"component_id": "ic_015",
"title": "单词填空 - 补全句子",
"detail": "在句子中填入正确的单词",
"user_result": "perfect"
},
{
"component_id": "ic_016",
"title": "情景判断 - 选择回应",
"detail": "根据情景选择最合适的回应",
"user_result": "good"
},
{
"component_id": "ic_017",
"title": "综合对话 - 完整对话",
"detail": "完成完整的打招呼和自我介绍对话",
"user_result": "perfect"
},
{
"component_id": "ic_018",
"title": "复习测验 - 本课回顾",
"detail": "完成本课知识点的综合复习测验",
"user_result": "good"
}
],
"consolidation_exercises": [
{
"exercise_id": "ce_001",
"type": "单词听选",
"detail": "听录音从A、B、C三个选项中选择听到的单词",
"is_correct": true
},
{
"exercise_id": "ce_002",
"type": "单词拼写",
"detail": "根据中文意思,写出对应的英文单词:你好",
"is_correct": true
},
{
"exercise_id": "ce_003",
"type": "图文匹配",
"detail": "将单词 goodbye 与对应的图片匹配",
"is_correct": false
},
{
"exercise_id": "ce_004",
"type": "句型填空",
"detail": "用正确的单词填空Hello, my ______ is Tom.",
"is_correct": true
},
{
"exercise_id": "ce_005",
"type": "情景选择",
"detail": "初次见面时应该说A. Hello B. Goodbye C. Hi",
"is_correct": true
},
{
"exercise_id": "ce_006",
"type": "句子排序",
"detail": "将下列单词排列成正确的句子name / my / is / Hello",
"is_correct": true
},
{
"exercise_id": "ce_007",
"type": "听力理解",
"detail": "听对话,回答问题:他们正在做什么?",
"is_correct": false
},
{
"exercise_id": "ce_008",
"type": "发音选择",
"detail": "听录音,选择包含 /eɪ/ 音的单词",
"is_correct": true
},
{
"exercise_id": "ce_009",
"type": "翻译练习",
"detail": "将下列句子翻译成英文:很高兴认识你。",
"is_correct": true
},
{
"exercise_id": "ce_010",
"type": "对话补全",
"detail": "补全对话A: Hello! B: ______!",
"is_correct": true
},
{
"exercise_id": "ce_011",
"type": "单词分类",
"detail": "将单词按问候语和非问候语分类",
"is_correct": false
},
{
"exercise_id": "ce_012",
"type": "句型转换",
"detail": "将 Hello 转换为同义词",
"is_correct": true
},
{
"exercise_id": "ce_013",
"type": "连线匹配",
"detail": "将单词与对应的中文意思连线",
"is_correct": true
},
{
"exercise_id": "ce_014",
"type": "判断正误",
"detail": "判断句子是否正确Goodbye 用于告别时说。",
"is_correct": true
},
{
"exercise_id": "ce_015",
"type": "选词填空",
"detail": "选择合适的单词填空______ to meet you. (Nice/Fine/Good)",
"is_correct": true
},
{
"exercise_id": "ce_016",
"type": "语音辨识",
"detail": "听录音,判断是哪个单词",
"is_correct": false
},
{
"exercise_id": "ce_017",
"type": "完形填空",
"detail": "阅读短文,选择合适的问候语填入空格",
"is_correct": true
},
{
"exercise_id": "ce_018",
"type": "综合测验",
"detail": "完成本课知识的综合测验题",
"is_correct": true
}
]
}
],
"summary": {
"total_lessons": 5,
"completed_lessons": 1,
"total_interactive_components": 18,
"total_consolidation_exercises": 18,
"mastery_rate": 0.85,
"study_duration_minutes": 25
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

190
scripts/analysis.py Normal file
View File

@ -0,0 +1,190 @@
#!/usr/bin/env python3
import sys
import requests
import json
import os
from datetime import datetime
# 配置项
API_URL = "https://api.valavala.com/v2/user/unit/learn/info"
OPERATOR = "Kingson"
# 存储目录配置
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUTPUT_DIR = os.path.join(BASE_DIR, "output")
TEMPLATE_PATH = os.path.join(BASE_DIR, "assets/template.html")
TEST_DATA_PATH = os.path.join(BASE_DIR, "assets/test.json")
def init_dirs():
"""初始化必要的目录"""
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR)
def parse_params():
"""解析并验证输入参数
支持两种模式
1. 正常模式python analysis.py [角色ID] [Level] [Unit]
2. 测试模式python analysis.py test
"""
if len(sys.argv) == 2 and sys.argv[1].lower() == "test":
# 测试模式
return "test", None, None
if len(sys.argv) != 4:
print("参数错误!使用方式:")
print(" 正常模式python analysis.py [角色ID] [Level] [Unit]")
print(" 测试模式python analysis.py test")
sys.exit(1)
role_id = sys.argv[1]
level = sys.argv[2]
unit = sys.argv[3]
# 验证角色ID是数字
if not role_id.isdigit():
print("错误角色ID必须是数字")
sys.exit(1)
# 处理Level参数支持Level1/L1/1等格式
level = level.lower().replace("level", "").replace("l", "")
if not level.isdigit() or int(level) not in [1, 2]:
print("错误Level只能是1或2支持格式Level1/L1/1/Level2/L2/2")
sys.exit(1)
# 处理Unit参数支持Unit1/U1/1等格式
unit = unit.lower().replace("unit", "").replace("u", "")
if not unit.isdigit() or int(unit) < 1:
print("错误Unit必须是正整数支持格式Unit1/U1/1")
sys.exit(1)
return int(role_id), int(level), int(unit)
def request_api(role_id, level, unit):
"""请求学情分析API
调用接口: https://api.valavala.com/v2/user/unit/learn/info
参数:
- operator: Kingson (固定值)
- level: L1 L2 (根据传入的level参数)
- userId: 用户角色ID
- unitIndex: 单元号
"""
params = {
"operator": OPERATOR,
"level": f"L{level}",
"userId": role_id,
"unitIndex": unit
}
try:
response = requests.get(API_URL, params=params, timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.Timeout:
print("API请求超时请稍后重试")
sys.exit(1)
except requests.exceptions.ConnectionError:
print("API连接失败请检查网络连接")
sys.exit(1)
except requests.exceptions.HTTPError as e:
print(f"API返回错误状态码{e.response.status_code}")
sys.exit(1)
except Exception as e:
print(f"API请求失败{str(e)}")
sys.exit(1)
def load_test_data():
"""加载测试数据"""
try:
with open(TEST_DATA_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
print(f"错误:测试数据文件不存在 {TEST_DATA_PATH}")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"错误:测试数据文件格式不正确 - {str(e)}")
sys.exit(1)
except Exception as e:
print(f"加载测试数据失败:{str(e)}")
sys.exit(1)
def save_data(role_id, level, unit, data):
"""保存API返回的原始数据"""
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
filename = f"{role_id}_L{level}_U{unit}_{timestamp}.json"
file_path = os.path.join(OUTPUT_DIR, filename)
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return file_path
def save_test_data(data):
"""保存测试数据副本"""
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
filename = f"test_data_{timestamp}.json"
file_path = os.path.join(OUTPUT_DIR, filename)
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return file_path
def generate_html(role_id, level, unit, data):
"""生成可视化HTML报告"""
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
filename = f"study_report_{role_id}_L{level}_U{unit}_{timestamp}.html"
html_path = os.path.join(OUTPUT_DIR, filename)
# 读取模板
with open(TEMPLATE_PATH, "r", encoding="utf-8") as f:
template = f.read()
# 替换模板中的数据占位符
html_content = template.replace("{{DATA}}", json.dumps(data, ensure_ascii=False))
html_content = html_content.replace("{{ROLE_ID}}", str(role_id))
html_content = html_content.replace("{{LEVEL}}", str(level))
html_content = html_content.replace("{{UNIT}}", str(unit))
html_content = html_content.replace("{{GENERATE_TIME}}", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
# 保存HTML文件
with open(html_path, "w", encoding="utf-8") as f:
f.write(html_content)
return html_path
def main():
init_dirs()
role_id, level, unit = parse_params()
# 判断是否为测试模式
is_test_mode = role_id == "test"
if is_test_mode:
print("【测试模式】使用本地测试数据生成报告...")
api_data = load_test_data()
# 测试模式使用默认值或从数据中读取
role_id = api_data.get("role_id", 99999)
level = api_data.get("level", 1)
unit = api_data.get("unit", 1)
print(f"测试数据:用户{role_id} Level{level} Unit{unit}")
else:
print(f"正在分析用户{role_id} Level{level} Unit{unit}的学习情况...")
# 请求API
api_data = request_api(role_id, level, unit)
print("API数据获取成功")
# 保存原始数据
if is_test_mode:
data_path = save_test_data(api_data)
else:
data_path = save_data(role_id, level, unit, api_data)
print(f"原始数据已保存到:{data_path}")
# 生成HTML报告
html_path = generate_html(role_id, level, unit, api_data)
print(f"可视化报告已生成:{html_path}")
print(f"请访问以下地址查看报告file://{html_path}")
if __name__ == "__main__":
main()