auto-sync: study-analysis 2026-04-03_11:23
This commit is contained in:
commit
fc8902b102
47
SKILL.md
Normal file
47
SKILL.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
name: study-analysis
|
||||||
|
description: 分析用户学习情况的工具,输入格式为「学情分析:用户角色ID,Level几,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
630
assets/template copy.html
Normal 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}} | Level {{LEVEL}} | Unit {{UNIT}} | 生成时间:{{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
465
assets/template.html
Normal 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
74
assets/template.json
Normal 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
1656
assets/test.json
Normal file
File diff suppressed because it is too large
Load Diff
8
output/10293_L1_U1_20260402160508.json
Normal file
8
output/10293_L1_U1_20260402160508.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"data": "hello world"
|
||||||
|
},
|
||||||
|
"msg": "",
|
||||||
|
"traceID": "bfc13d6b1b5669affffa521d675d7141"
|
||||||
|
}
|
||||||
1662
output/15868_L1_U1_20260402180728.json
Normal file
1662
output/15868_L1_U1_20260402180728.json
Normal file
File diff suppressed because it is too large
Load Diff
401
output/study_report_10293_L1_U1_20260402160508.html
Normal file
401
output/study_report_10293_L1_U1_20260402160508.html
Normal 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">
|
||||||
|
用户ID:10293 | Level 1 | Unit 1 | 生成时间: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>
|
||||||
401
output/study_report_12345_L1_U1_20260402161243.html
Normal file
401
output/study_report_12345_L1_U1_20260402161243.html
Normal file
File diff suppressed because one or more lines are too long
423
output/study_report_12345_L1_U1_20260402162938.html
Normal file
423
output/study_report_12345_L1_U1_20260402162938.html
Normal file
File diff suppressed because one or more lines are too long
471
output/study_report_12345_L1_U1_20260402163831.html
Normal file
471
output/study_report_12345_L1_U1_20260402163831.html
Normal file
File diff suppressed because one or more lines are too long
449
output/study_report_12345_L1_U1_20260402164851.html
Normal file
449
output/study_report_12345_L1_U1_20260402164851.html
Normal file
File diff suppressed because one or more lines are too long
526
output/study_report_12345_L1_U1_20260402165344.html
Normal file
526
output/study_report_12345_L1_U1_20260402165344.html
Normal file
File diff suppressed because one or more lines are too long
566
output/study_report_12345_L1_U1_20260402170445.html
Normal file
566
output/study_report_12345_L1_U1_20260402170445.html
Normal file
File diff suppressed because one or more lines are too long
595
output/study_report_12345_L1_U1_20260402172134.html
Normal file
595
output/study_report_12345_L1_U1_20260402172134.html
Normal file
File diff suppressed because one or more lines are too long
597
output/study_report_12345_L1_U1_20260402173214.html
Normal file
597
output/study_report_12345_L1_U1_20260402173214.html
Normal file
File diff suppressed because one or more lines are too long
630
output/study_report_12345_L1_U1_20260402174923.html
Normal file
630
output/study_report_12345_L1_U1_20260402174923.html
Normal file
File diff suppressed because one or more lines are too long
630
output/study_report_12345_L1_U1_20260402175102.html
Normal file
630
output/study_report_12345_L1_U1_20260402175102.html
Normal file
File diff suppressed because one or more lines are too long
465
output/study_report_12345_L1_U1_20260403101313.html
Normal file
465
output/study_report_12345_L1_U1_20260403101313.html
Normal file
File diff suppressed because one or more lines are too long
465
output/study_report_12345_L1_U1_20260403101641.html
Normal file
465
output/study_report_12345_L1_U1_20260403101641.html
Normal file
File diff suppressed because one or more lines are too long
630
output/study_report_15868_L1_U1_20260402180728.html
Normal file
630
output/study_report_15868_L1_U1_20260402180728.html
Normal file
File diff suppressed because one or more lines are too long
278
output/test_data_20260402161243.json
Normal file
278
output/test_data_20260402161243.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
1326
output/test_data_20260402162938.json
Normal file
1326
output/test_data_20260402162938.json
Normal file
File diff suppressed because it is too large
Load Diff
1326
output/test_data_20260402163831.json
Normal file
1326
output/test_data_20260402163831.json
Normal file
File diff suppressed because it is too large
Load Diff
1336
output/test_data_20260402164851.json
Normal file
1336
output/test_data_20260402164851.json
Normal file
File diff suppressed because it is too large
Load Diff
1336
output/test_data_20260402165344.json
Normal file
1336
output/test_data_20260402165344.json
Normal file
File diff suppressed because it is too large
Load Diff
1428
output/test_data_20260402170445.json
Normal file
1428
output/test_data_20260402170445.json
Normal file
File diff suppressed because it is too large
Load Diff
1651
output/test_data_20260402172134.json
Normal file
1651
output/test_data_20260402172134.json
Normal file
File diff suppressed because it is too large
Load Diff
1656
output/test_data_20260402173214.json
Normal file
1656
output/test_data_20260402173214.json
Normal file
File diff suppressed because it is too large
Load Diff
1656
output/test_data_20260402174923.json
Normal file
1656
output/test_data_20260402174923.json
Normal file
File diff suppressed because it is too large
Load Diff
1656
output/test_data_20260402175102.json
Normal file
1656
output/test_data_20260402175102.json
Normal file
File diff suppressed because it is too large
Load Diff
1656
output/test_data_20260403101313.json
Normal file
1656
output/test_data_20260403101313.json
Normal file
File diff suppressed because it is too large
Load Diff
1656
output/test_data_20260403101641.json
Normal file
1656
output/test_data_20260403101641.json
Normal file
File diff suppressed because it is too large
Load Diff
190
scripts/analysis.py
Normal file
190
scripts/analysis.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user