survey/main.py
2025-11-30 20:11:22 +08:00

2547 lines
89 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sqlite3
import json
import uuid
import os
from datetime import datetime
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Dict, List, Optional, Any
import asyncio
import threading
from enhanced_survey_system import enhanced_system, get_east8_time_string, get_east8_time
from excel_reader import get_questions_data, get_questions_by_tag, get_questions_by_filters, get_available_filters
app = FastAPI(title="Enhanced Survey System")
# 配置CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 挂载静态文件
if os.path.exists("public"):
app.mount("/public", StaticFiles(directory="public"), name="public")
# 模板配置
templates = Jinja2Templates(directory="public" if os.path.exists("public") else ".")
# 数据模型
class CreateSessionRequest(BaseModel):
name: str
school: str
grade: str
phone: str = ""
selectedSubject: str = "" # 学科
selectedSemester: str = "" # 册次(保留兼容性)
selectedExamType: str = "" # 考试类型(保留兼容性)
selectedUnit: str = "" # 单元
selectedCategory: str = "" # 分类
selectedQuestionType: str = "" # 题型
selectedTag: str = ""
selectedTagsList: List[str] = []
questionsConfig: Dict[str, int]
totalQuestions: int = 0 # 总题目数
totalScore: int = 0 # 总分数
class SaveAnswersRequest(BaseModel):
sessionId: str
answers: List[Dict[str, Any]]
class GenerateReportRequest(BaseModel):
sessionId: str
class ReportCallbackRequest(BaseModel):
session_id: str
data: Dict[str, Any]
class SessionResponse(BaseModel):
success: bool
sessionId: str
studentId: str
class SaveAnswersResponse(BaseModel):
success: bool
totalScore: int
sessionId: str
class ApiResponse(BaseModel):
success: bool
message: str
# API端点
@app.post("/api/create-session", response_model=SessionResponse)
async def create_session(request: CreateSessionRequest):
"""创建答题会话"""
name = request.name.strip()
school = request.school.strip()
grade = request.grade.strip()
phone = request.phone.strip()
selected_subject = request.selectedSubject.strip()
selected_semester = request.selectedSemester.strip()
selected_unit = request.selectedUnit.strip()
# 构建描述性标签
selected_tag = ""
filters = []
if selected_subject: filters.append(f"学科:{selected_subject}")
if selected_semester: filters.append(f"年级:{selected_semester}") # 这里实际是完整年级信息
if selected_unit: filters.append(f"单元:{selected_unit}")
selected_tag = " | ".join(filters) if filters else "全部题目"
questions_config = request.questionsConfig
if not name or not school or not grade or not phone:
raise HTTPException(status_code=400, detail="姓名、学校、年级和手机号不能为空")
# 创建学员记录
student_id = str(uuid.uuid4())
conn = sqlite3.connect('data/survey.db')
cursor = conn.cursor()
cursor.execute('''
INSERT INTO students (id, name, school, grade, phone, selected_tag)
VALUES (?, ?, ?, ?, ?, ?)
''', (student_id, name, school, grade, phone, selected_tag))
# 创建答题会话
session_id = str(uuid.uuid4())
# 如果前端没有传递总题目数和总分数,则从配置中计算
if request.totalQuestions == 0 or request.totalScore == 0:
# 根据题目配置计算总题目数和总分数
total_questions = sum(questions_config.values())
# 基础题5分进阶题10分竞赛题15分
total_score = (questions_config.get('基础题', 0) * 5 +
questions_config.get('进阶题', 0) * 10 +
questions_config.get('竞赛题', 0) * 15)
else:
total_questions = request.totalQuestions
total_score = request.totalScore
cursor.execute('''
INSERT INTO quiz_sessions (id, student_id, questions_config, status, total_questions, max_score)
VALUES (?, ?, ?, 'created', ?, ?)
''', (session_id, student_id, json.dumps(questions_config), total_questions, total_score))
conn.commit()
conn.close()
return SessionResponse(
success=True,
sessionId=session_id,
studentId=student_id
)
def async_generate_report(session_id: str):
"""异步生成报告"""
try:
asyncio.run(enhanced_system.auto_generate_report(session_id))
print(f"报告生成成功: {session_id}")
except Exception as e:
print(f"报告生成失败: {e}")
@app.post("/api/save-answers", response_model=SaveAnswersResponse)
async def save_answers(request: SaveAnswersRequest):
"""保存答题结果"""
session_id = request.sessionId
answers = request.answers
if not session_id or not answers:
raise HTTPException(status_code=400, detail="会话ID和答题结果不能为空")
# 保存答题结果 - JSON格式存储包含用户信息
conn = sqlite3.connect('data/survey.db')
conn.row_factory = sqlite3.Row # 设置行工厂以支持字典访问
cursor = conn.cursor()
# 获取用户信息和会话信息
cursor.execute('''
SELECT s.name, s.school, s.grade, s.selected_tag, qs.questions_config
FROM students s
JOIN quiz_sessions qs ON s.id = qs.student_id
WHERE qs.id = ?
''', (session_id,))
session_info = cursor.fetchone()
if not session_info:
raise HTTPException(status_code=404, detail="会话不存在")
student_name = session_info['name']
student_school = session_info['school']
student_grade = session_info['grade']
selected_tag = session_info['selected_tag']
total_score = 0
# 处理答题数据
processed_answers = []
for answer in answers:
# 兼容驼峰和下划线命名
user_answer = answer.get('userAnswer') or answer.get('user_answer', '')
correct_answer = answer.get('correctAnswer') or answer.get('correct_answer', '')
question_id = answer.get('questionId') or answer.get('question_id', '')
question_text = answer.get('questionText') or answer.get('question_text', '')
question_type = answer.get('questionType') or answer.get('question_type', '')
is_correct = user_answer == correct_answer
score = answer.get('score', 0) if is_correct else 0
total_score += score
processed_answers.append({
'questionId': question_id,
'questionText': question_text,
'questionType': question_type,
'userAnswer': user_answer,
'correctAnswer': correct_answer,
'isCorrect': is_correct,
'score': score
})
# 将整个答题数据保存为JSON
answers_json = json.dumps(processed_answers, ensure_ascii=False)
answer_id = str(uuid.uuid4())
# 使用INSERT OR REPLACE确保每个session只有一条记录包含用户信息
cursor.execute('''
INSERT OR REPLACE INTO quiz_answers
(id, session_id, student_name, student_school, student_grade, selected_tag, answers_data, total_score)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (answer_id, session_id, student_name, student_school, student_grade, selected_tag, answers_json, total_score))
# 更新会话状态
cursor.execute('''
UPDATE quiz_sessions
SET status = 'completed', total_score = ?, completed_at = ?
WHERE id = ?
''', (total_score, get_east8_time_string(), session_id))
conn.commit()
conn.close()
# 异步生成报告
threading.Thread(target=async_generate_report, args=(session_id,), daemon=True).start()
return SaveAnswersResponse(
success=True,
totalScore=total_score,
sessionId=session_id
)
@app.post("/api/generate-report", response_model=ApiResponse)
async def generate_report(request: GenerateReportRequest):
"""手动生成报告"""
session_id = request.sessionId
if not session_id:
raise HTTPException(status_code=400, detail="会话ID不能为空")
# 同步生成报告
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(enhanced_system.auto_generate_report(session_id))
loop.close()
return ApiResponse(
success=result['success'],
message='报告生成成功' if result['success'] else result['error']
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/regenerate-report", response_model=ApiResponse)
async def regenerate_report(request: GenerateReportRequest):
"""处理重新生成报告请求"""
session_id = request.sessionId
if not session_id:
raise HTTPException(status_code=400, detail="会话ID不能为空")
# 同步重新生成报告
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(enhanced_system.regenerate_report(session_id))
loop.close()
response = ApiResponse(
success=result['success'],
message='报告重新生成成功' if result['success'] else result['error']
)
if result['success']:
# 使用额外的字段来存储report_id
response.extra = {"reportId": result.get('report_id')}
return response
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/sessions-can-regenerate")
async def get_sessions_can_regenerate():
"""获取可以重新生成的会话列表"""
try:
sessions = enhanced_system.get_sessions_can_regenerate()
return {
'success': True,
'sessions': sessions
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/reports")
async def get_reports(page: int = 1, page_size: int = 10):
"""获取报告列表"""
data = enhanced_system.get_reports_list(page, page_size)
return data
@app.get("/api/report/{report_id}")
async def get_report(report_id: str):
"""获取单个报告"""
report = enhanced_system.get_report_by_id(report_id)
if not report:
raise HTTPException(status_code=404, detail="报告不存在")
# 解析报告数据
try:
report_data = json.loads(report['report_data'])
analysis_data = json.loads(report['analysis_data'])
# 返回与外部API相同格式的数据
response_data = {
'studentInfo': report_data.get('studentInfo', {}),
'report': report_data.get('report', {}),
'footer': {
'copyright': '© 2024 尚逸基石教育科技有限公司 版权所有'
}
}
return response_data
except Exception as e:
raise HTTPException(status_code=500, detail=f"解析报告数据失败: {str(e)}")
@app.delete("/api/reports/{report_id}", response_model=ApiResponse)
async def delete_report(report_id: str):
"""删除报告"""
result = enhanced_system.delete_report(report_id)
if result['success']:
return ApiResponse(
success=True,
message=result['message']
)
else:
raise HTTPException(status_code=400, detail=result['message'])
@app.get("/api/questions")
async def get_questions():
"""获取题库数据"""
try:
questions_data = get_questions_data()
return questions_data
except FileNotFoundError:
raise HTTPException(status_code=404, detail="题库文件不存在")
except Exception as e:
raise HTTPException(status_code=500, detail=f"加载题库失败: {str(e)}")
@app.get("/api/filters")
async def get_filters():
"""获取所有可用的筛选条件"""
try:
filters_data = get_available_filters()
return {
"success": True,
"data": filters_data
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取筛选条件失败: {str(e)}")
@app.get("/api/questions-by-filters")
async def get_questions_by_filters_api(
subject: str = "",
grade: str = "",
unit: str = ""
):
"""根据筛选条件获取题目"""
try:
# 根据筛选条件获取题目
filtered_questions = get_questions_by_filters(
subject=subject,
grade=grade,
unit=unit
)
return {
"success": True,
"data": filtered_questions
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取筛选题目失败: {str(e)}")
@app.get("/api/questions/{session_id}")
async def get_filtered_questions(session_id: str):
"""根据会话ID获取筛选后的题目"""
try:
# 从数据库获取会话信息,包括选择的标签和题目配置
conn = sqlite3.connect('data/survey.db')
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('''
SELECT s.*, qs.questions_config
FROM students s
JOIN quiz_sessions qs ON s.id = qs.student_id
WHERE qs.id = ?
''', (session_id,))
session_data = cursor.fetchone()
conn.close()
if not session_data:
raise HTTPException(status_code=404, detail="会话不存在")
selected_tag = session_data['selected_tag'] or ''
questions_config = json.loads(session_data['questions_config'])
# 解析标签,提取筛选条件
subject = ""
grade = ""
unit = ""
# 如果标签包含筛选条件格式,提取它们
if selected_tag and "学科:" in selected_tag:
# 格式: "学科:科学 | 年级:一年级上册 | 单元:1-周围的植物"
filters = selected_tag.split(' | ')
for filter_item in filters:
if filter_item.startswith("学科:"):
subject = filter_item[3:]
elif filter_item.startswith("年级:"):
grade = filter_item[3:]
elif filter_item.startswith("单元:"):
unit = filter_item[3:]
# 根据筛选条件获取题目
filtered_questions = get_questions_by_filters(
subject=subject,
grade=grade,
unit=unit
)
# 根据配置选择题目
selected_questions = select_questions_by_config(filtered_questions, questions_config)
response_data = {
'questions': selected_questions,
'selectedTag': selected_tag,
'questionsConfig': questions_config
}
return response_data
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取筛选题目失败: {str(e)}")
@app.get("/api/quiz-results/{session_id}")
async def get_quiz_results(session_id: str):
"""获取答题结果详情"""
try:
conn = sqlite3.connect('data/survey.db')
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 获取答题结果
cursor.execute('''
SELECT qa.*, s.name, s.school, s.grade, qs.questions_config, qs.status,
qs.total_questions, qs.max_score
FROM quiz_answers qa
JOIN students s ON qa.student_name = s.name AND qa.student_school = s.school AND qa.student_grade = s.grade
JOIN quiz_sessions qs ON qa.session_id = qs.id
WHERE qa.session_id = ?
''', (session_id,))
result = cursor.fetchone()
if not result:
raise HTTPException(status_code=404, detail="答题结果不存在")
# 解析答题数据
answers_data = json.loads(result['answers_data'])
questions_config = json.loads(result['questions_config'])
# 获取原始题目数据以便显示选项
cursor.execute('''
SELECT s.selected_tag
FROM students s
JOIN quiz_sessions qs ON s.id = qs.student_id
WHERE qs.id = ?
''', (session_id,))
session_info = cursor.fetchone()
selected_tag = session_info['selected_tag'] if session_info else ''
# 解析标签,提取筛选条件
subject = ""
grade = ""
unit = ""
if selected_tag and "学科:" in selected_tag:
filters = selected_tag.split(' | ')
for filter_item in filters:
if filter_item.startswith("学科:"):
subject = filter_item[3:]
elif filter_item.startswith("年级:"):
grade = filter_item[3:]
elif filter_item.startswith("单元:"):
unit = filter_item[3:]
# 获取筛选后的题目
filtered_questions = get_questions_by_filters(
subject=subject,
grade=grade,
unit=unit
)
# 重新选择题目以获取完整的题目数据
selected_questions = select_questions_by_config(filtered_questions, questions_config)
# 合并题目数据和答题结果
detailed_results = []
for i, answer in enumerate(answers_data):
# 找到对应的题目
question = None
for q in selected_questions:
if q['questionId'] == answer['questionId']:
question = q
break
# 如果找不到完整题目数据,使用答题数据中的基本信息
if not question:
detailed_results.append({
'questionNumber': i + 1,
'questionText': answer['questionText'],
'questionType': answer['questionType'],
'options': {}, # 空选项,因为没有完整题目数据
'userAnswer': answer['userAnswer'],
'correctAnswer': answer['correctAnswer'],
'isCorrect': answer['isCorrect'],
'score': answer['score'],
'questionId': answer['questionId']
})
else:
# 获取所有选项
options = {}
labels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
for label in labels:
for key in question:
# 使用正则表达式来匹配选项键
import re
if re.sub(r'\s+', '', key).find(f'选项{label}') != -1:
options[label] = question[key]
break
detailed_results.append({
'questionNumber': i + 1,
'questionText': question['题干'],
'questionType': question['questionType'],
'options': options,
'userAnswer': answer['userAnswer'],
'correctAnswer': answer['correctAnswer'],
'isCorrect': answer['isCorrect'],
'score': answer['score'],
'questionId': answer['questionId']
})
conn.close()
return {
'success': True,
'studentInfo': {
'name': result['name'],
'school': result['school'],
'grade': result['grade']
},
'totalScore': result['total_score'],
'maxScore': result['max_score'],
'totalQuestions': result['total_questions'],
'selectedTag': selected_tag,
'results': detailed_results,
'sessionStatus': result['status']
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取答题结果失败: {str(e)}")
def select_questions_by_config(filtered_questions: Dict, questions_config: Dict) -> List[Dict]:
"""根据配置从筛选后的题目中选择题目"""
selected = []
# 按照题目类型顺序:基础题 -> 进阶题 -> 竞赛题
question_order = ["基础题", "进阶题", "竞赛题"]
for question_type in question_order:
if question_type not in questions_config:
continue
count = questions_config[question_type]
available_questions = filtered_questions.get(question_type, [])
if len(available_questions) < count:
print(f"警告:{question_type}可用题目不足 ({len(available_questions)}/{count})")
# 随机选择指定数量的题目
import random
random.shuffle(available_questions)
selected_for_type = available_questions[:min(count, len(available_questions))]
for question in selected_for_type:
selected.append({
**question,
'questionType': question_type,
'questionId': f"{question_type}_{question['序号']}"
})
return selected
# HTML页面路由
@app.get("/")
async def index_page(request: Request):
"""提供主页面"""
try:
with open('public/index.html', 'r', encoding='utf-8') as f:
content = f.read()
return Response(content=content, media_type="text/html; charset=utf-8")
except FileNotFoundError:
raise HTTPException(status_code=404, detail="主页文件不存在")
@app.get("/login.html")
async def login_page(request: Request):
"""提供登录页面"""
try:
with open('public/login.html', 'r', encoding='utf-8') as f:
content = f.read()
return Response(content=content, media_type="text/html; charset=utf-8")
except FileNotFoundError:
raise HTTPException(status_code=404, detail="登录页面文件不存在")
@app.get("/survey.html")
async def survey_page(request: Request):
"""提供测评配置页面"""
try:
with open('public/survey.html', 'r', encoding='utf-8') as f:
content = f.read()
return Response(content=content, media_type="text/html; charset=utf-8")
except FileNotFoundError:
raise HTTPException(status_code=404, detail="测评页面文件不存在")
@app.get("/report.html")
async def report_page(request: Request):
"""提供报告查看页面"""
try:
with open('public/report.html', 'r', encoding='utf-8') as f:
content = f.read()
return Response(content=content, media_type="text/html; charset=utf-8")
except FileNotFoundError:
raise HTTPException(status_code=404, detail="报告页面文件不存在")
@app.get("/quiz-results/{session_id}")
async def quiz_results_page(session_id: str, request: Request):
"""提供答题结果展示页面"""
try:
conn = sqlite3.connect('data/survey.db')
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('''
SELECT s.*, qs.questions_config, qs.status, qs.total_score, qs.completed_at
FROM students s
JOIN quiz_sessions qs ON s.id = qs.student_id
WHERE qs.id = ?
''', (session_id,))
session_data = cursor.fetchone()
conn.close()
if not session_data:
raise HTTPException(status_code=404, detail="会话不存在")
# 生成答题结果页面
html_content = generate_quiz_results_page(session_data, session_id)
return Response(content=html_content, media_type="text/html; charset=utf-8")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"生成答题结果页面失败: {str(e)}")
@app.get("/quiz/{session_id}")
async def quiz_page(session_id: str, request: Request):
"""处理答题页面"""
conn = sqlite3.connect('data/survey.db')
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('''
SELECT s.*, qs.questions_config, qs.status, qs.total_score, qs.completed_at
FROM students s
JOIN quiz_sessions qs ON s.id = qs.student_id
WHERE qs.id = ?
''', (session_id,))
session_data = cursor.fetchone()
conn.close()
if not session_data:
raise HTTPException(status_code=404, detail="Session not found")
# 检查会话状态
if session_data['status'] in ['completed', 'can_regenerate']:
# 显示完成状态页面
html_content = generate_completion_page(session_data, session_id)
else:
# 显示答题页面
html_content = generate_quiz_page(session_data, session_id)
return Response(content=html_content, media_type="text/html; charset=utf-8")
def generate_quiz_page(session_data: sqlite3.Row, session_id: str) -> str:
"""生成答题页面HTML"""
questions_config = json.loads(session_data['questions_config'])
student_name = session_data['name']
school = session_data['school']
grade = session_data['grade']
return f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{student_name} - 学科能力测评</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Microsoft YaHei', sans-serif;
}}
body {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}}
.container {{
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
overflow: hidden;
}}
.header {{
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
padding: 30px;
text-align: center;
}}
.student-info {{
background: #f8f9fa;
padding: 20px;
border-bottom: 1px solid #e9ecef;
}}
.info-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 15px;
}}
.info-item {{
background: white;
padding: 15px;
border-radius: 8px;
border-left: 4px solid #4facfe;
}}
.info-label {{
font-weight: 600;
color: #2d5a3d;
margin-bottom: 5px;
}}
.info-value {{
color: #333;
font-size: 16px;
}}
.progress-bar {{
background: #e9ecef;
height: 8px;
border-radius: 4px;
margin: 20px 30px;
overflow: hidden;
}}
.progress-fill {{
background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
width: 0%;
}}
.questions-container {{
padding: 30px;
}}
.question-card {{
background: white;
border: 2px solid #e9ecef;
border-radius: 12px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}}
.question-header {{
display: flex;
align-items: center;
margin-bottom: 20px;
}}
.question-number {{
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
width: 35px;
height: 35px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
margin-right: 15px;
}}
.question-type {{
background: #e8f5e8;
color: #2e7d32;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
margin-left: auto;
border: 1px solid #2e7d32;
}}
.question-type.基础题 {{
background: #e8f5e8;
color: #2e7d32;
border: 1px solid #2e7d32;
}}
.question-type.进阶题 {{
background: #fff3e0;
color: #f57c00;
border: 1px solid #f57c00;
}}
.question-type.竞赛题 {{
background: #fce4ec;
color: #c2185b;
border: 1px solid #c2185b;
}}
.question-text {{
font-size: 18px;
color: #333;
line-height: 1.6;
margin-bottom: 20px;
font-weight: 500;
}}
.options-list {{
list-style: none;
}}
.option-item {{
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 15px 20px;
margin-bottom: 10px;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
}}
.option-item:hover {{
background: #e3f2fd;
border-color: #4facfe;
transform: translateY(-1px);
}}
.option-item.selected {{
background: #e3f2fd;
border-color: #4facfe;
}}
.option-item input[type="radio"] {{
margin-right: 12px;
transform: scale(1.2);
}}
.option-text {{
flex: 1;
font-size: 16px;
color: #333;
}}
.submit-btn {{
background: linear-gradient(135deg, #4CAF50, #66BB6A);
color: white;
border: none;
padding: 15px 30px;
border-radius: 8px;
font-size: 18px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: block;
margin: 30px auto;
}}
.submit-btn:hover {{
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(76, 175, 80, 0.4);
}}
.submit-btn:disabled {{
background: #ccc;
cursor: not-allowed;
transform: none;
}}
.result-section {{
text-align: center;
padding: 40px 30px;
display: none;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 20px;
margin: 20px 0;
}}
.score-display {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px;
border-radius: 20px;
margin: 0 auto 30px;
max-width: 500px;
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
position: relative;
overflow: hidden;
}}
.score-display::before {{
content: '';
position: absolute;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, rgba(255,255,255,0.1) 0%, transparent 50%, rgba(255,255,255,0.1) 100%);
animation: shimmer 3s ease-in-out infinite;
}}
@keyframes shimmer {{
0% {{ transform: translateX(-100%) translateY(-100%) rotate(45deg); }}
50% {{ transform: translateX(100%) translateY(100%) rotate(45deg); }}
100% {{ transform: translateX(-100%) translateY(-100%) rotate(45deg); }}
}}
.score-value {{
font-size: 48px;
font-weight: 700;
margin-bottom: 10px;
position: relative;
z-index: 2;
}}
.score-label {{
font-size: 18px;
opacity: 0.9;
margin-bottom: 20px;
position: relative;
z-index: 2;
}}
.success-icon {{
font-size: 60px;
margin-bottom: 20px;
animation: bounce 2s ease-in-out infinite;
position: relative;
z-index: 2;
}}
@keyframes bounce {{
0%, 20%, 50%, 80%, 100% {{ transform: translateY(0); }}
40% {{ transform: translateY(-10px); }}
60% {{ transform: translateY(-5px); }}
}}
.report-status-container {{
background: white;
padding: 30px;
border-radius: 15px;
margin: 0 auto;
max-width: 600px;
box-shadow: 0 5px 20px rgba(0,0,0,0.1);
}}
.status-header {{
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 25px;
}}
.status-icon {{
font-size: 40px;
margin-right: 15px;
animation: pulse 2s ease-in-out infinite;
}}
@keyframes pulse {{
0% {{ transform: scale(1); opacity: 1; }}
50% {{ transform: scale(1.1); opacity: 0.8; }}
100% {{ transform: scale(1); opacity: 1; }}
}}
.status-title {{
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}}
.status-message {{
font-size: 16px;
color: #666;
margin-bottom: 25px;
line-height: 1.6;
}}
.loading-indicator {{
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 25px;
}}
.loading-dots {{
display: inline-flex;
gap: 8px;
}}
.loading-dot {{
width: 12px;
height: 12px;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
border-radius: 50%;
animation: bounce-dots 1.4s ease-in-out infinite both;
}}
.loading-dot:nth-child(1) {{ animation-delay: -0.32s; }}
.loading-dot:nth-child(2) {{ animation-delay: -0.16s; }}
.loading-dot:nth-child(3) {{ animation-delay: 0s; }}
@keyframes bounce-dots {{
0%, 80%, 100% {{ transform: scale(0); }}
40% {{ transform: scale(1); }}
}}
.action-buttons {{
display: flex;
flex-direction: column;
gap: 15px;
align-items: center;
}}
.primary-action {{
background: linear-gradient(135deg, #4CAF50, #66BB6A);
color: white;
padding: 15px 30px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-block;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(76, 175, 80, 0.3);
}}
.primary-action:hover {{
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(76, 175, 80, 0.4);
}}
.secondary-info {{
font-size: 14px;
color: #888;
text-align: center;
margin-top: 15px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #4facfe;
}}
.report-link {{
display: inline-block;
background: linear-gradient(135deg, #ff7e5f, #feb47b);
color: white;
padding: 12px 24px;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
margin-top: 20px;
transition: all 0.3s;
}}
.report-link:hover {{
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(255, 126, 95, 0.3);
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎯 学科能力测评</h1>
<p>请认真回答以下问题</p>
</div>
<div class="student-info">
<div class="info-grid">
<div class="info-item">
<div class="info-label">姓名</div>
<div class="info-value">{student_name}</div>
</div>
<div class="info-item">
<div class="info-label">学校</div>
<div class="info-value">{school}</div>
</div>
<div class="info-item">
<div class="info-label">年级</div>
<div class="info-value">{grade}</div>
</div>
<div class="info-item">
<div class="info-label">选题范围</div>
<div class="info-value" id="selectedTagDisplay">正在加载...</div>
</div>
</div>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="questions-container" id="questionsContainer">
<div class="loading">正在加载题目...</div>
</div>
<div class="result-section" id="resultSection">
<div class="score-display">
<div class="success-icon">🎉</div>
<div class="score-value" id="scoreValue">0分</div>
<div class="score-label">测评完成!</div>
</div>
<div class="report-status-container">
<div id="reportStatus">
<div class="status-header">
<div class="status-icon">⚡</div>
<div>
<div class="status-title">智能分析报告生成中</div>
<div class="status-message">系统正在基于您的答题情况AI正在深度分析并生成个性化学习建议...</div>
</div>
</div>
<div class="loading-indicator">
<div class="loading-dots">
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
</div>
<span style="margin-left: 15px; color: #4facfe; font-weight: 600;">AI正在分析中...</span>
</div>
<div class="action-buttons">
<a href="/quiz-results/{session_id}" class="primary-action">
📊 立即查看答题结果
</a>
<div class="secondary-info">
💡 您现在就可以查看详细的答题情况AI报告生成完成后将在这里显示
</div>
</div>
</div>
</div>
</div>
</div>
<script>
class QuizSystem {{
constructor() {{
this.sessionId = '{session_id}';
this.questions = [];
this.answers = {{}};
this.init();
}}
async init() {{
try {{
await this.loadQuestions();
this.renderQuestions();
}} catch (error) {{
this.showError('系统初始化失败: ' + error.message);
}}
}}
async loadQuestions() {{
const response = await fetch(`/api/questions/{session_id}`);
if (!response.ok) {{
throw new Error('题目加载失败');
}}
const data = await response.json();
this.questions = data.questions;
this.selectedTag = data.selectedTag;
this.questionsConfig = data.questionsConfig;
// 更新选题范围显示
const selectedTagDisplay = document.getElementById('selectedTagDisplay');
if (selectedTagDisplay) {{
selectedTagDisplay.textContent = this.selectedTag || '全部题目';
}}
if (this.questions.length === 0) {{
throw new Error('没有可用的题目');
}}
}}
renderQuestions() {{
const container = document.getElementById('questionsContainer');
container.innerHTML = '';
this.questions.forEach((question, index) => {{
const questionCard = document.createElement('div');
questionCard.className = 'question-card';
questionCard.innerHTML = `
<div class="question-header">
<div class="question-number">${{index + 1}}</div>
<div class="question-type ${{question.questionType}}">${{question.questionType}}</div>
</div>
<div class="question-text">${{question['题干']}}</div>
<ul class="options-list">
${{this.renderOptions(question, index)}}
</ul>
`;
container.appendChild(questionCard);
}});
this.bindOptionEvents();
}}
renderOptions(question, questionIndex) {{
const options = [];
const labels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
// 通过遍历所有键来找到选项
for (let i = 0; i < labels.length; i++) {{
let optionText = null;
// 遍历问题的所有键,寻找匹配的选项
for (const key in question) {{
// 检查键是否包含当前选项字母(忽略空格数量)
if (key.replace(/\\s+/g, '').includes(`选项${{labels[i]}}`)) {{
optionText = question[key];
break;
}}
}}
if (optionText && optionText.trim()) {{
options.push(`
<li class="option-item" data-question="${{questionIndex}}" data-option="${{labels[i]}}">
<input type="radio" name="question_${{questionIndex}}" value="${{labels[i]}}" id="option_${{questionIndex}}_${{i}}">
<label for="option_${{questionIndex}}_${{i}}" class="option-text">${{labels[i]}}. ${{optionText}}</label>
</li>
`);
}}
}}
return options.join('');
}}
bindOptionEvents() {{
document.querySelectorAll('.option-item').forEach(item => {{
item.addEventListener('click', function() {{
const questionIndex = this.dataset.question;
const option = this.dataset.option;
const radio = this.querySelector('input[type="radio"]');
document.querySelectorAll(`.option-item[data-question="${{questionIndex}}"]`).forEach(otherItem => {{
otherItem.classList.remove('selected');
}});
this.classList.add('selected');
radio.checked = true;
window.quiz.answers[questionIndex] = option;
window.quiz.updateProgress();
window.quiz.checkCompletion();
}});
}});
const container = document.getElementById('questionsContainer');
const submitBtn = document.createElement('button');
submitBtn.className = 'submit-btn';
submitBtn.textContent = '提交答题';
submitBtn.disabled = true;
submitBtn.onclick = () => this.submitQuiz();
container.appendChild(submitBtn);
}}
updateProgress() {{
const total = this.questions.length;
const answered = Object.keys(this.answers).length;
const percentage = total > 0 ? (answered / total) * 100 : 0;
document.getElementById('progressFill').style.width = percentage + '%';
}}
checkCompletion() {{
const total = this.questions.length;
const answered = Object.keys(this.answers).length;
const submitBtn = document.querySelector('.submit-btn');
submitBtn.disabled = answered !== total;
}}
async submitQuiz() {{
try {{
const submitBtn = document.querySelector('.submit-btn');
submitBtn.textContent = '正在提交...';
submitBtn.disabled = true;
const answersData = this.questions.map((question, index) => ({{
questionId: question.questionId,
questionText: question['题干'],
questionType: question.questionType,
userAnswer: this.answers[index] || '',
correctAnswer: question['答案'],
score: parseInt(question['分数']) || 0
}}));
const response = await fetch('/api/save-answers', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json'
}},
body: JSON.stringify({{
sessionId: this.sessionId,
answers: answersData
}})
}});
if (response.ok) {{
const result = await response.json();
this.showResults(result.totalScore);
this.checkReportStatus();
}} else {{
throw new Error('提交失败');
}}
}} catch (error) {{
this.showError('提交失败: ' + error.message);
const submitBtn = document.querySelector('.submit-btn');
submitBtn.textContent = '提交答题';
submitBtn.disabled = false;
}}
}}
showResults(totalScore) {{
document.getElementById('questionsContainer').style.display = 'none';
document.getElementById('resultSection').style.display = 'block';
// 获取总分信息并显示为"分数/总分"格式
this.fetchSessionInfo().then(sessionInfo => {{
const maxScore = sessionInfo.maxScore || 100;
document.getElementById('scoreValue').innerHTML =
`<span style="font-size: 48px; font-weight: bold;">${{totalScore}}</span>` +
`<span style="font-size: 24px; opacity: 0.8;"> / ${{maxScore}}分</span>`;
}}).catch(error => {{
// 如果获取失败,使用默认值
document.getElementById('scoreValue').innerHTML =
`<span style="font-size: 48px; font-weight: bold;">${{totalScore}}</span>` +
`<span style="font-size: 24px; opacity: 0.8;"> / 100分</span>`;
}});
this.checkReportStatus();
}}
async fetchSessionInfo() {{
try {{
const response = await fetch(`/api/quiz-results/${{this.sessionId}}`);
if (response.ok) {{
const result = await response.json();
if (result.success) {{
return {{
maxScore: result.maxScore,
totalQuestions: result.totalQuestions
}};
}}
}}
// 如果API不可用返回默认值
return {{ maxScore: 100, totalQuestions: 14 }};
}} catch (error) {{
console.warn('获取会话信息失败,使用默认值:', error);
return {{ maxScore: 100, totalQuestions: 14 }};
}}
}}
checkReportStatus() {{
// HTML已经包含完整的加载状态不需要在这里设置
// 继续检查报告生成状态
let checkCount = 0;
const maxChecks = 30;
const checkInterval = setInterval(async () => {{
checkCount++;
try {{
const response = await fetch('/api/reports');
const data = await response.json();
const latestReport = data.reports.find(r => r.session_id === this.sessionId);
if (latestReport) {{
clearInterval(checkInterval);
this.showReportLink(latestReport.id);
}} else if (checkCount >= maxChecks) {{
clearInterval(checkInterval);
this.showTimeoutMessage();
}}
}} catch (error) {{
console.error('检查报告状态失败:', error);
}}
}}, 2000);
}}
showReportLink(reportId) {{
document.getElementById('reportStatus').innerHTML =
`<div class="status-header">
<div class="status-icon">🎯</div>
<div>
<div class="status-title">测评完成!</div>
<div class="status-message">🎉 恭喜您完成测评AI已为您生成了个性化的学习建议报告。</div>
</div>
</div>
<div class="action-buttons">
<a href="/quiz-results/${{this.sessionId}}" class="primary-action">
📊 查看答题结果
</a>
<a href="/report.html?id=${{reportId}}" class="primary-action" style="background: linear-gradient(135deg, #ff7e5f, #feb47b); box-shadow: 0 4px 15px rgba(255, 126, 95, 0.3);">
🧠 查看AI智能报告
</a>
<div class="secondary-info">
💡 建议先查看答题结果了解具体错题再查看AI报告获得深度学习建议
</div>
</div>`;
}}
showTimeoutMessage() {{
document.getElementById('reportStatus').innerHTML =
`<div class="status-header">
<div class="status-icon">⏰</div>
<div>
<div class="status-title">报告生成时间较长</div>
<div class="status-message">当前用户较多AI报告正在加急处理中您可以先查看答题结果。</div>
</div>
</div>
<div class="action-buttons">
<a href="/quiz-results/${{this.sessionId}}" class="primary-action">
📊 查看答题结果
</a>
<div class="secondary-info">
⏱️ AI报告完成后可在首页报告列表中查看给您带来不便敬请谅解
</div>
</div>`;
}}
showError(message) {{
const container = document.getElementById('questionsContainer');
container.innerHTML = `<div style="text-align: center; padding: 50px; color: #e74c3c; background: #ffebee; border-radius: 8px; margin: 20px;">${{message}}</div>`;
}}
}}
window.quiz = new QuizSystem();
</script>
</body>
</html>'''
@app.post("/api/report-callback", response_model=ApiResponse)
async def report_callback(request: ReportCallbackRequest):
"""接收异步报告生成完成的回调数据"""
session_id = request.session_id
report_data = request.data
if not session_id or not report_data:
raise HTTPException(status_code=400, detail="session_id和data不能为空")
try:
# 验证会话是否存在
conn = sqlite3.connect('data/survey.db')
cursor = conn.cursor()
cursor.execute('''
SELECT id FROM quiz_sessions WHERE id = ?
''', (session_id,))
session_exists = cursor.fetchone()
if not session_exists:
conn.close()
raise HTTPException(status_code=404, detail="会话不存在")
# 获取分析数据(如果有保存的话)
cursor.execute('''
SELECT analysis_data FROM temp_analysis_data
WHERE session_id = ?
ORDER BY created_at DESC
LIMIT 1
''', (session_id,))
analysis_result = cursor.fetchone()
analysis_data = json.loads(analysis_result[0]) if analysis_result else {}
# 构造完整的报告数据
full_report_data = {
'studentInfo': report_data.get('studentInfo', {}),
'report': report_data.get('report', {}),
'generated_at': get_east8_time().isoformat(),
'session_id': session_id,
'analysis_data': analysis_data
}
# 保存报告到数据库
report_id = str(uuid.uuid4())
cursor.execute('''
INSERT INTO reports (id, session_id, report_data, analysis_data, generated_at)
VALUES (?, ?, ?, ?, ?)
''', (report_id, session_id, json.dumps(full_report_data), json.dumps(analysis_data), get_east8_time_string()))
# 更新会话状态为已完成
cursor.execute('''
UPDATE quiz_sessions
SET status = 'completed', completed_at = ?
WHERE id = ?
''', (get_east8_time_string(), session_id))
conn.commit()
conn.close()
print(f"✅ 回调处理成功: {session_id}")
print(f" 报告ID: {report_id}")
return ApiResponse(
success=True,
message='回调数据处理成功'
)
except HTTPException:
raise
except Exception as e:
print(f"❌ 回调处理失败: {e}")
raise HTTPException(status_code=500, detail=f"回调处理失败: {str(e)}")
def generate_completion_page(session_data: sqlite3.Row, session_id: str) -> str:
"""生成答题完成页面HTML"""
student_name = session_data['name']
school = session_data['school']
grade = session_data['grade']
total_score = session_data['total_score'] or 0
completed_at = session_data['completed_at'] or '未知时间'
return f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{student_name} - 测评已完成</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Microsoft YaHei', sans-serif;
}}
body {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}}
.container {{
background: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
padding: 40px;
text-align: center;
max-width: 600px;
width: 100%;
}}
.success-icon {{
width: 80px;
height: 80px;
background: linear-gradient(135deg, #4CAF50, #45a049);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 30px;
font-size: 40px;
color: white;
}}
h1 {{
color: #333;
margin-bottom: 20px;
font-size: 28px;
}}
.student-info {{
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
margin: 20px 0;
text-align: left;
}}
.info-row {{
display: flex;
justify-content: space-between;
margin: 10px 0;
padding: 5px 0;
border-bottom: 1px solid #eee;
}}
.info-row:last-child {{
border-bottom: none;
}}
.info-label {{
font-weight: bold;
color: #666;
}}
.info-value {{
color: #333;
}}
.score-display {{
background: linear-gradient(135deg, #FF6B6B, #4ECDC4);
color: white;
padding: 30px;
border-radius: 15px;
margin: 30px 0;
position: relative;
overflow: hidden;
}}
.score-display::before {{
content: '';
position: absolute;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background: rgba(255,255,255,0.1);
transform: rotate(45deg);
}}
.score-number {{
font-size: 48px;
font-weight: bold;
margin-bottom: 10px;
position: relative;
z-index: 1;
}}
.score-label {{
font-size: 18px;
opacity: 0.9;
position: relative;
z-index: 1;
}}
.message {{
color: #666;
font-size: 16px;
margin: 20px 0;
line-height: 1.6;
}}
.actions {{
margin-top: 30px;
}}
.btn {{
display: inline-block;
padding: 12px 30px;
margin: 10px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
text-decoration: none;
border-radius: 25px;
font-weight: bold;
transition: all 0.3s ease;
border: none;
cursor: pointer;
}}
.btn:hover {{
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}}
.btn-secondary {{
background: linear-gradient(135deg, #6c757d, #5a6268);
}}
.footer {{
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #eee;
color: #999;
font-size: 14px;
}}
@media (max-width: 768px) {{
.container {{
padding: 15px;
}}
h1 {{
font-size: 24px;
}}
.result-section {{
padding: 25px 15px;
}}
.score-display {{
padding: 30px 20px;
margin-bottom: 20px;
}}
.score-value {{
font-size: 36px;
}}
.success-icon {{
font-size: 45px;
}}
.report-status-container {{
padding: 25px 20px;
margin: 0 10px;
}}
.status-header {{
flex-direction: column;
text-align: center;
}}
.status-icon {{
font-size: 35px;
margin-right: 0;
margin-bottom: 10px;
}}
.status-title {{
font-size: 20px;
}}
.status-message {{
font-size: 14px;
}}
.primary-action {{
width: 100%;
max-width: 280px;
padding: 14px 25px;
font-size: 15px;
}}
.secondary-info {{
font-size: 13px;
margin-top: 12px;
padding: 10px;
}}
.loading-dots {{
gap: 6px;
}}
.loading-dot {{
width: 10px;
height: 10px;
}}
.btn {{
display: block;
margin: 10px auto;
width: 80%;
}}
}}
</style>
</head>
<body>
<div class="container">
<div class="success-icon">✓</div>
<h1>测评已完成!</h1>
<div class="student-info">
<div class="info-row">
<span class="info-label">姓名:</span>
<span class="info-value">{student_name}</span>
</div>
<div class="info-row">
<span class="info-label">学校:</span>
<span class="info-value">{school}</span>
</div>
<div class="info-row">
<span class="info-label">年级:</span>
<span class="info-value">{grade}</span>
</div>
<div class="info-row">
<span class="info-label">完成时间:</span>
<span class="info-value">{completed_at}</span>
</div>
</div>
<div class="score-display">
<div class="score-number">{total_score} 分</div>
<div class="score-label">本次测评得分</div>
</div>
<div class="message">
恭喜您完成了本次学科能力测评!<br>
系统正在为您生成详细的个性化报告,请稍后查看。
</div>
<div class="actions">
<a href="/" class="btn">返回首页</a>
<a href="/quiz-results/{session_id}" class="btn btn-secondary">查看答题结果</a>
</div>
<script>
async function checkAndShowReport(sessionId) {{
try {{
// 首先尝试通过session_id查找报告
const response = await fetch('/api/reports');
const data = await response.json();
const report = data.reports.find(r => r.session_id === sessionId);
if (report) {{
// 找到报告,跳转到报告页面
window.location.href = `/report.html?id=${{report.id}}`;
}} else {{
// 报告还在生成中,显示提示
alert('报告正在生成中,请稍后再试。\\\\n您也可以稍后返回首页查看历史报告。');
}}
}} catch (error) {{
console.error('检查报告失败:', error);
alert('检查报告状态失败,请稍后再试。');
}}
}}
</script>
<div class="footer">
学科能力测评系统 | 专业 · 科学 · 个性化
</div>
</div>
</body>
</html>'''
def generate_quiz_results_page(session_data: sqlite3.Row, session_id: str) -> str:
"""生成答题结果详情页面HTML"""
student_name = session_data['name']
school = session_data['school']
grade = session_data['grade']
return f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{student_name} - 答题结果详情</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Microsoft YaHei', sans-serif;
}}
body {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}}
.container {{
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
overflow: hidden;
}}
.header {{
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
padding: 30px;
text-align: center;
}}
.header h1 {{
font-size: 32px;
margin-bottom: 10px;
}}
.header p {{
opacity: 0.9;
font-size: 16px;
}}
.student-info {{
background: #f8f9fa;
padding: 20px 30px;
border-bottom: 1px solid #e9ecef;
}}
.info-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}}
.info-item {{
background: white;
padding: 15px;
border-radius: 10px;
border-left: 4px solid #4facfe;
}}
.info-label {{
font-weight: 600;
color: #666;
margin-bottom: 5px;
font-size: 14px;
}}
.info-value {{
color: #333;
font-size: 16px;
font-weight: 500;
}}
.results-summary {{
background: linear-gradient(135deg, #ff7e5f 0%, #feb47b 50%, #ff7e5f 100%);
color: white;
padding: 30px;
text-align: center;
}}
.score-display {{
font-size: 48px;
font-weight: bold;
margin-bottom: 10px;
}}
.score-label {{
font-size: 18px;
opacity: 0.9;
}}
.results-container {{
padding: 30px;
}}
.question-card {{
background: white;
border: 2px solid #e9ecef;
border-radius: 15px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
transition: all 0.3s;
}}
.question-card.correct {{
border-color: #4CAF50;
box-shadow: 0 2px 15px rgba(76, 175, 80, 0.1);
}}
.question-card.incorrect {{
border-color: #f44336;
box-shadow: 0 2px 15px rgba(244, 67, 54, 0.1);
}}
.question-header {{
display: flex;
align-items: center;
margin-bottom: 20px;
}}
.question-number {{
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
margin-right: 15px;
font-size: 18px;
}}
.question-type {{
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
margin-left: auto;
}}
.question-type.基础题 {{
background: #e8f5e8;
color: #2e7d32;
border: 1px solid #2e7d32;
}}
.question-type.进阶题 {{
background: #fff3e0;
color: #f57c00;
border: 1px solid #f57c00;
}}
.question-type.竞赛题 {{
background: #fce4ec;
color: #c2185b;
border: 1px solid #c2185b;
}}
.result-status {{
margin-left: 10px;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}}
.result-status.correct {{
background: #4CAF50;
color: white;
}}
.result-status.incorrect {{
background: #f44336;
color: white;
}}
.question-text {{
font-size: 18px;
color: #333;
line-height: 1.6;
margin-bottom: 20px;
font-weight: 500;
}}
.options-list {{
list-style: none;
margin-bottom: 20px;
}}
.option-item {{
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 8px;
padding: 15px 20px;
margin-bottom: 10px;
display: flex;
align-items: center;
position: relative;
}}
.option-item.selected-correct {{
background: #e8f5e8;
border-color: #4CAF50;
}}
.option-item.selected-incorrect {{
background: #ffebee;
border-color: #f44336;
}}
.option-item.correct-answer {{
background: #e3f2fd;
border-color: #2196F3;
}}
.option-label {{
display: inline-block;
width: 25px;
height: 25px;
background: #6c757d;
color: white;
text-align: center;
line-height: 25px;
border-radius: 50%;
margin-right: 12px;
font-weight: 600;
font-size: 14px;
}}
.option-text {{
flex: 1;
font-size: 16px;
color: #333;
}}
.option-status {{
margin-left: 10px;
padding: 4px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}}
.option-status.user-correct {{
background: #4CAF50;
color: white;
}}
.option-status.user-incorrect {{
background: #f44336;
color: white;
}}
.option-status.correct-answer {{
background: #2196F3;
color: white;
}}
.answer-summary {{
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}}
.answer-label {{
font-weight: 600;
color: #666;
}}
.answer-value {{
font-weight: 500;
}}
.answer-value.correct {{
color: #4CAF50;
}}
.answer-value.incorrect {{
color: #f44336;
}}
.loading {{
text-align: center;
padding: 50px;
color: #666;
}}
.error {{
background: #ffebee;
color: #f44336;
padding: 20px;
border-radius: 8px;
margin: 20px;
text-align: center;
}}
.actions {{
padding: 30px;
text-align: center;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
}}
.btn {{
display: inline-block;
padding: 12px 24px;
margin: 0 10px;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
text-decoration: none;
border-radius: 25px;
font-weight: 600;
transition: all 0.3s;
border: none;
cursor: pointer;
}}
.btn:hover {{
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(79, 172, 254, 0.4);
}}
.btn-secondary {{
background: linear-gradient(135deg, #6c757d, #5a6268);
}}
.btn-success {{
background: linear-gradient(135deg, #4CAF50, #45a049);
}}
@media (max-width: 768px) {{
.container {{
border-radius: 15px;
}}
.header {{
padding: 20px;
}}
.header h1 {{
font-size: 24px;
}}
.info-grid {{
grid-template-columns: 1fr;
gap: 10px;
}}
.question-text {{
font-size: 16px;
}}
.option-text {{
font-size: 14px;
}}
.score-display {{
font-size: 36px;
}}
.btn {{
display: block;
margin: 10px auto;
width: 80%;
}}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 答题结果详情</h1>
<p>查看每道题的答题情况和正确答案</p>
</div>
<div class="student-info">
<div class="info-grid">
<div class="info-item">
<div class="info-label">姓名</div>
<div class="info-value">{student_name}</div>
</div>
<div class="info-item">
<div class="info-label">学校</div>
<div class="info-value">{school}</div>
</div>
<div class="info-item">
<div class="info-label">年级</div>
<div class="info-value">{grade}</div>
</div>
<div class="info-item">
<div class="info-label">选题范围</div>
<div class="info-value" id="selectedTagDisplay">正在加载...</div>
</div>
</div>
</div>
<div class="results-summary">
<div class="score-display">
<div style="font-size: 36px; font-weight: bold;" id="totalScoreDisplay">正在计算...</div>
<div style="font-size: 16px; opacity: 0.9; margin-top: 5px;">
得分 / <span id="maxScoreDisplay">...</span>分
</div>
</div>
<div class="score-label">测评成绩</div>
<div style="margin-top: 15px; font-size: 14px; opacity: 0.9;">
<span id="correctCount">0</span> 题正确 /
<span id="answeredCount">0</span> 题已答
(共 <span id="totalQuestionsCount">0</span> 题)
(正确率: <span id="accuracy">0</span>%)
</div>
</div>
<div class="results-container" id="resultsContainer">
<div class="loading">正在加载答题结果...</div>
</div>
<div class="actions">
<a href="/" class="btn">返回首页</a>
<button id="checkReportBtn" class="btn btn-success" style="display: none;">查看详细报告</button>
<button id="refreshBtn" class="btn btn-secondary">刷新页面</button>
</div>
</div>
<script>
class QuizResultsSystem {{
constructor() {{
this.sessionId = '{session_id}';
this.results = null;
this.init();
}}
async init() {{
try {{
await this.loadResults();
this.displayResults();
}} catch (error) {{
this.showError('加载答题结果失败: ' + error.message);
}}
}}
async loadResults() {{
const response = await fetch(`/api/quiz-results/${{this.sessionId}}`);
if (!response.ok) {{
throw new Error('无法获取答题结果');
}}
this.results = await response.json();
if (!this.results.success) {{
throw new Error('答题结果数据无效');
}}
}}
displayResults() {{
// 更新学生信息
const selectedTagDisplay = document.getElementById('selectedTagDisplay');
if (selectedTagDisplay) {{
selectedTagDisplay.textContent = this.results.selectedTag || '全部题目';
}}
// 计算并显示统计信息
const answeredQuestions = this.results.results.length;
const correctQuestions = this.results.results.filter(r => r.isCorrect).length;
const totalQuestions = this.results.totalQuestions || answeredQuestions;
const accuracy = answeredQuestions > 0 ? Math.round((correctQuestions / answeredQuestions) * 100) : 0;
// 显示分数
document.getElementById('totalScoreDisplay').textContent = this.results.totalScore + '';
document.getElementById('maxScoreDisplay').textContent = this.results.maxScore || 100;
// 显示题目统计
document.getElementById('correctCount').textContent = correctQuestions;
document.getElementById('answeredCount').textContent = answeredQuestions;
document.getElementById('totalQuestionsCount').textContent = totalQuestions;
document.getElementById('accuracy').textContent = accuracy;
// 显示题目详情
this.renderQuestions();
// 如果报告已生成,显示查看报告按钮
if (this.results.sessionStatus === 'completed') {{
this.checkReportStatus();
}}
}}
renderQuestions() {{
const container = document.getElementById('resultsContainer');
container.innerHTML = '';
this.results.results.forEach((result, index) => {{
const questionCard = document.createElement('div');
questionCard.className = `question-card ${{result.isCorrect ? 'correct' : 'incorrect'}}`;
questionCard.innerHTML = `
<div class="question-header">
<div class="question-number">${{result.questionNumber}}</div>
<div class="question-type ${{result.questionType}}">${{result.questionType}}</div>
<div class="result-status ${{result.isCorrect ? 'correct' : 'incorrect'}}">
${{result.isCorrect ? '✓ 正确' : '✗ 错误'}}
</div>
</div>
<div class="question-text">${{result.questionText}}</div>
<ul class="options-list">
${{this.renderOptions(result)}}
</ul>
<div class="answer-summary">
<div>
<span class="answer-label">你的答案:</span>
<span class="answer-value ${{result.isCorrect ? 'correct' : 'incorrect'}}">${{result.userAnswer || '未作答'}}</span>
</div>
<div>
<span class="answer-label">正确答案:</span>
<span class="answer-value correct">${{result.correctAnswer}}</span>
</div>
<div>
<span class="answer-label">得分:</span>
<span class="answer-value">${{result.isCorrect ? result.score : 0}} 分</span>
</div>
</div>
`;
container.appendChild(questionCard);
}});
}}
renderOptions(result) {{
let optionsHTML = '';
const labels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
labels.forEach(label => {{
const optionText = result.options[label];
if (optionText) {{
let optionClass = 'option-item';
let statusBadge = '';
if (result.userAnswer === label && result.isCorrect) {{
optionClass += ' selected-correct';
statusBadge = '<span class="option-status user-correct">你的答案 ✓</span>';
}} else if (result.userAnswer === label && !result.isCorrect) {{
optionClass += ' selected-incorrect';
statusBadge = '<span class="option-status user-incorrect">你的答案 ✗</span>';
}} else if (result.correctAnswer === label && result.userAnswer !== label) {{
optionClass += ' correct-answer';
statusBadge = '<span class="option-status correct-answer">正确答案 ✓</span>';
}}
optionsHTML += `
<li class="${{optionClass}}">
<span class="option-label">${{label}}</span>
<span class="option-text">${{optionText}}</span>
${{statusBadge}}
</li>
`;
}}
}});
return optionsHTML;
}}
async checkReportStatus() {{
// 如果报告已经生成,直接显示按钮
if (this.results.sessionStatus === 'completed') {{
this.startReportStatusCheck();
return;
}}
// 否则,先短暂等待,然后开始检查
setTimeout(() => {{
this.startReportStatusCheck();
}}, 2000);
}}
startReportStatusCheck() {{
let checkCount = 0;
const maxChecks = 30;
const checkInterval = setInterval(async () => {{
checkCount++;
try {{
const response = await fetch('/api/reports');
const data = await response.json();
const report = data.reports.find(r => r.session_id === this.sessionId);
if (report) {{
clearInterval(checkInterval);
const reportBtn = document.getElementById('checkReportBtn');
reportBtn.style.display = 'inline-block';
reportBtn.textContent = '查看详细报告';
reportBtn.onclick = () => {{
window.location.href = `/report.html?id=${{report.id}}`;
}};
}} else if (checkCount >= maxChecks) {{
clearInterval(checkInterval);
// 超时后显示提示信息
const reportBtn = document.getElementById('checkReportBtn');
reportBtn.style.display = 'inline-block';
reportBtn.textContent = '报告生成中';
reportBtn.disabled = true;
reportBtn.style.opacity = '0.6';
}}
}} catch (error) {{
console.error('检查报告状态失败:', error);
}}
}}, 2000);
}}
showError(message) {{
const container = document.getElementById('resultsContainer');
container.innerHTML = `<div class="error">${{message}}</div>`;
}}
}}
// 初始化系统
window.quizResults = new QuizResultsSystem();
// 刷新按钮事件
document.getElementById('refreshBtn').addEventListener('click', () => {{
window.location.reload();
}});
</script>
</body>
</html>'''
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)