FastAPI项目:构建打字速度测试网站(MySQL版本)

文章目录

    • 前言
    • 一、环境准备
      • [1.1 安装依赖](#1.1 安装依赖)
      • [1.2 配置MySQL](#1.2 配置MySQL)
    • 二、完整代码
      • [2.1 后端代码 (main.py) - SQLAlchemy版本](#2.1 后端代码 (main.py) - SQLAlchemy版本)
      • [2.2 前端代码 (templates/index.html)](#2.2 前端代码 (templates/index.html))
      • [2.3 运行项目](#2.3 运行项目)

前言

该项目页面打开是打字速度测试的页面,项目包含实时统计、多种测试模式、历史记录等功能。该项目使用FastAPI(后端) + jinja2(前端)+ MySQL(数据库),页面截图如下:

项目实现功能如下:

  • 实时打字测试 - 实时显示WPM、准确率、剩余时间等
  • 多种文本类型 - 英文、编程代码、中文文本
  • 时间限制 - 30秒、60秒、120秒或无限制
  • 实时反馈 - 正确/错误字符高亮显示
  • 历史记录 - 保存测试记录,支持删除
  • 统计分析 - 今日、本周、总体统计
  • 响应式设计 - 适配各种屏幕尺寸
  • 数据持久化 - SQLAlchemy内置连接池管理,使用SQLAlchemy的查询语法,更Pythonic,便于后续使用Alembic进行数据库迁移
  • 类型安全:更好的类型检查和IDE支持
  • 依赖注入:使用FastAPI的依赖注入管理数据库会话

一、环境准备

1.1 安装依赖

bash 复制代码
pip install fastapi uvicorn sqlalchemy pymysql python-multipart jinja2

1.2 配置MySQL

1、修改数据库连接字符串,在 main.py 中修改以下行:

python 复制代码
DATABASE_URL = "mysql+pymysql://root:your_password@localhost/typing_test?charset=utf8mb4"

your_password 替换为你的MySQL密码。

2、创建数据库(可选,程序会自动创建):

sql 复制代码
CREATE DATABASE typing_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

二、完整代码

2.1 后端代码 (main.py) - SQLAlchemy版本

python 复制代码
from fastapi import FastAPI, Depends, Request, HTTPException
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy import create_engine, Column, Integer, Float, String, DateTime, Text, func
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
import json
import random
from datetime import datetime, timedelta
import os
# MySQL数据库配置
DATABASE_URL = "mysql+pymysql://root:your_password@localhost/typing_test?charset=utf8mb4"
# 请修改上面的用户名和密码
# 创建SQLAlchemy引擎
engine = create_engine(
    DATABASE_URL,
    echo=True,  # 设置为False可以关闭SQL日志
    pool_pre_ping=True,  # 连接池预检查
    pool_recycle=3600,  # 连接回收时间
)
# 创建会话工厂
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建基础模型类
Base = declarative_base()
# 数据库模型
class TypingRecord(Base):
    __tablename__ = "typing_records"
    
    id = Column(Integer, primary_key=True, index=True)
    wpm = Column(Float, nullable=False)
    accuracy = Column(Float, nullable=False)
    text_type = Column(String(50), nullable=False, index=True)
    duration = Column(Integer, nullable=False)
    correct_chars = Column(Integer, nullable=False)
    total_chars = Column(Integer, nullable=False)
    errors = Column(Text, nullable=True)
    created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
# 依赖注入:获取数据库会话
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()
# 数据库初始化
def init_db():
    try:
        # 创建所有表
        Base.metadata.create_all(bind=engine)
        print("数据库初始化成功")
    except Exception as e:
        print(f"数据库初始化失败: {e}")
        raise
# 测试文本库
TEXT_SAMPLES = {
    "english": [
        "The quick brown fox jumps over the lazy dog. This pangram sentence contains every letter of the alphabet at least once.",
        "In the heart of the bustling city, skyscrapers tower over the streets below, casting long shadows as the sun sets behind them.",
        "Technology has revolutionized the way we communicate, work, and live our daily lives in the modern world.",
        "Learning to type quickly and accurately is a valuable skill that can improve productivity in many professional fields.",
        "The ocean waves crashed against the shore, creating a rhythmic sound that calms the mind and soothed the soul."
    ],
    "programming": [
        "def fibonacci(n): return n if n <= 1 else fibonacci(n-1) + fibonacci(n-2)",
        "const greeting = 'Hello, World!'; console.log(greeting);",
        "class Person: def __init__(self, name, age): self.name = name; self.age = age",
        "function calculateSum(numbers) { return numbers.reduce((a, b) => a + b, 0); }",
        "import numpy as np; data = np.array([1, 2, 3, 4, 5]); mean = np.mean(data)"
    ],
    "chinese": [
        "春天来了,花儿开了,鸟儿在枝头欢快地歌唱,大地一片生机勃勃的景象。",
        "学习编程需要耐心和毅力,只有不断练习和实践,才能掌握这门技能。",
        "科技改变生活,创新引领未来,在这个信息时代,我们要与时俱进。",
        "读书破万卷,下笔如有神。知识就是力量,学习成就未来。",
        "山重水复疑无路,柳暗花明又一村。坚持就是胜利,努力必有回报。"
    ]
}
app = FastAPI()
templates = Jinja2Templates(directory="templates")
# 启动时初始化数据库
init_db()
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})
@app.get("/api/text/{text_type}")
async def get_text(text_type: str):
    if text_type not in TEXT_SAMPLES:
        raise HTTPException(status_code=404, detail="Text type not found")
    
    text = random.choice(TEXT_SAMPLES[text_type])
    return {"text": text, "type": text_type}
@app.post("/api/save_result")
async def save_result(request: Request, db: Session = Depends(get_db)):
    try:
        data = await request.json()
        
        # 创建数据库记录
        record = TypingRecord(
            wpm=data['wpm'],
            accuracy=data['accuracy'],
            text_type=data['text_type'],
            duration=data['duration'],
            correct_chars=data['correct_chars'],
            total_chars=data['total_chars'],
            errors=json.dumps(data['errors']) if data.get('errors') else None
        )
        
        # 保存到数据库
        db.add(record)
        db.commit()
        db.refresh(record)
        
        return {"success": True, "record_id": record.id}
    except Exception as e:
        db.rollback()
        print(f"保存结果失败: {e}")
        raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/stats")
async def get_stats(db: Session = Depends(get_db)):
    try:
        # 今日统计
        today = datetime.now().date()
        today_stats = db.query(
            func.count(TypingRecord.id).label('count'),
            func.avg(TypingRecord.wpm).label('avg_wpm'),
            func.avg(TypingRecord.accuracy).label('avg_accuracy'),
            func.max(TypingRecord.wpm).label('max_wpm')
        ).filter(func.date(TypingRecord.created_at) == today).first()
        
        # 本周统计
        week_ago = today - timedelta(days=7)
        week_stats = db.query(
            func.count(TypingRecord.id).label('count'),
            func.avg(TypingRecord.wpm).label('avg_wpm'),
            func.avg(TypingRecord.accuracy).label('avg_accuracy'),
            func.max(TypingRecord.wpm).label('max_wpm')
        ).filter(func.date(TypingRecord.created_at) >= week_ago).first()
        
        # 总体统计
        total_stats = db.query(
            func.count(TypingRecord.id).label('count'),
            func.avg(TypingRecord.wpm).label('avg_wpm'),
            func.avg(TypingRecord.accuracy).label('avg_accuracy'),
            func.max(TypingRecord.wpm).label('max_wpm')
        ).first()
        
        def format_stats(stats):
            return {
                "count": stats.count or 0,
                "avg_wpm": round(float(stats.avg_wpm or 0), 1),
                "avg_accuracy": round(float(stats.avg_accuracy or 0), 1),
                "max_wpm": round(float(stats.max_wpm or 0), 1)
            }
        
        return {
            "today": format_stats(today_stats),
            "week": format_stats(week_stats),
            "total": format_stats(total_stats)
        }
    except Exception as e:
        print(f"获取统计失败: {e}")
        raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/history")
async def get_history(limit: int = 50, db: Session = Depends(get_db)):
    try:
        records = db.query(TypingRecord).order_by(
            TypingRecord.created_at.desc()
        ).limit(limit).all()
        
        # 转换为字典格式
        result = []
        for record in records:
            result.append({
                "id": record.id,
                "wpm": record.wpm,
                "accuracy": record.accuracy,
                "text_type": record.text_type,
                "duration": record.duration,
                "created_at": record.created_at.strftime('%Y-%m-%d %H:%M:%S')
            })
        
        return result
    except Exception as e:
        print(f"获取历史记录失败: {e}")
        raise HTTPException(status_code=500, detail=str(e))
@app.delete("/api/record/{record_id}")
async def delete_record(record_id: int, db: Session = Depends(get_db)):
    try:
        # 查找记录
        record = db.query(TypingRecord).filter(TypingRecord.id == record_id).first()
        
        if not record:
            raise HTTPException(status_code=404, detail="Record not found")
        
        # 删除记录
        db.delete(record)
        db.commit()
        
        return {"success": True}
    except HTTPException:
        raise
    except Exception as e:
        db.rollback()
        print(f"删除记录失败: {e}")
        raise HTTPException(status_code=500, detail=str(e))
# 健康检查端点
@app.get("/api/health")
async def health_check(db: Session = Depends(get_db)):
    try:
        # 尝试执行一个简单查询来检查数据库连接
        db.execute("SELECT 1")
        return {"status": "healthy", "database": "connected"}
    except Exception as e:
        return {"status": "unhealthy", "database": "disconnected", "error": str(e)}
# 数据库管理端点(可选,用于管理)
@app.get("/api/db_info")
async def get_db_info(db: Session = Depends(get_db)):
    try:
        total_records = db.query(func.count(TypingRecord.id)).scalar()
        latest_record = db.query(TypingRecord).order_by(TypingRecord.created_at.desc()).first()
        
        return {
            "total_records": total_records,
            "latest_record": {
                "id": latest_record.id,
                "created_at": latest_record.created_at.strftime('%Y-%m-%d %H:%M:%S')
            } if latest_record else None,
            "database_url": DATABASE_URL.split('@')[1] if '@' in DATABASE_URL else "unknown"
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

2.2 前端代码 (templates/index.html)

前端代码完全保持不变,与之前版本相同:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>键盘打字速度测试</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
    <style>
        body {
            background-color: #f8f9fa;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        }
        .text-display {
            font-size: 1.2rem;
            line-height: 1.8;
            padding: 20px;
            background-color: #f8f9fa;
            border-radius: 8px;
            border: 2px solid #e9ecef;
            min-height: 120px;
            font-family: 'Courier New', monospace;
        }
        .text-input {
            font-size: 1.1rem;
            line-height: 1.6;
            padding: 15px;
            border: 2px solid #dee2e6;
            border-radius: 8px;
            font-family: 'Courier New', monospace;
            resize: none;
            transition: border-color 0.3s ease;
        }
        .text-input:focus {
            border-color: #007bff;
            box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
        }
        .text-input:disabled {
            background-color: #f8f9fa;
            cursor: not-allowed;
        }
        .char-correct {
            color: #28a745;
            background-color: #d4edda;
            font-weight: bold;
        }
        .char-incorrect {
            color: #dc3545;
            background-color: #f8d7da;
            font-weight: bold;
            text-decoration: underline;
        }
        .char-current {
            background-color: #fff3cd;
            border-bottom: 3px solid #ffc107;
            animation: blink 1s infinite;
        }
        .char-untyped {
            color: #6c757d;
        }
        @keyframes blink {
            0%, 50% { opacity: 1; }
            51%, 100% { opacity: 0.3; }
        }
        .card {
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            transition: transform 0.2s ease;
        }
        .card:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 8px rgba(0,0,0,0.15);
        }
        .navbar-brand {
            font-weight: bold;
        }
        .btn {
            transition: all 0.2s ease;
        }
        .btn:hover {
            transform: translateY(-1px);
        }
        .form-select:focus, .form-control:focus {
            border-color: #007bff;
            box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
        }
        .table th {
            border-top: none;
            font-weight: 600;
            color: #495057;
        }
        .badge {
            font-size: 0.8em;
        }
        .error-highlight {
            animation: shake 0.5s;
        }
        @keyframes shake {
            0%, 100% { transform: translateX(0); }
            25% { transform: translateX(-5px); }
            75% { transform: translateX(5px); }
        }
        .typing-indicator {
            display: inline-block;
            width: 8px;
            height: 8px;
            border-radius: 50%;
            background-color: #28a745;
            margin-left: 8px;
            animation: pulse 1s infinite;
        }
        @keyframes pulse {
            0% { opacity: 1; }
            50% { opacity: 0.5; }
            100% { opacity: 1; }
        }
        @media (max-width: 768px) {
            .text-display {
                font-size: 1rem;
                padding: 15px;
            }
            
            .text-input {
                font-size: 1rem;
                padding: 12px;
            }
            
            .card-body {
                padding: 1rem;
            }
        }
    </style>
</head>
<body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
        <div class="container">
            <a class="navbar-brand" href="#">
                <i class="fas fa-keyboard me-2"></i>打字速度测试
            </a>
            <div class="navbar-nav ms-auto">
                <span class="navbar-text">
                    <i class="fas fa-chart-line me-1"></i>
                    最高记录: <span id="bestWPM">0</span> WPM
                </span>
            </div>
        </div>
    </nav>
    <div class="container mt-4">
        <!-- 设置区域 -->
        <div class="row mb-4">
            <div class="col-12">
                <div class="card">
                    <div class="card-body">
                        <div class="row align-items-center">
                            <div class="col-md-3">
                                <label class="form-label">文本类型</label>
                                <select class="form-select" id="textType">
                                    <option value="english">英文</option>
                                    <option value="programming">编程代码</option>
                                    <option value="chinese">中文</option>
                                </select>
                            </div>
                            <div class="col-md-3">
                                <label class="form-label">时间限制</label>
                                <select class="form-select" id="timeLimit">
                                    <option value="30">30秒</option>
                                    <option value="60" selected>60秒</option>
                                    <option value="120">120秒</option>
                                    <option value="0">无限制</option>
                                </select>
                            </div>
                            <div class="col-md-3">
                                <label class="form-label">难度</label>
                                <select class="form-select" id="difficulty">
                                    <option value="easy">简单</option>
                                    <option value="medium" selected>中等</option>
                                    <option value="hard">困难</option>
                                </select>
                            </div>
                            <div class="col-md-3">
                                <label class="form-label">&nbsp;</label>
                                <button class="btn btn-primary w-100" id="startBtn">
                                    <i class="fas fa-play me-1"></i>开始测试
                                </button>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <!-- 实时统计 -->
        <div class="row mb-4">
            <div class="col-md-3">
                <div class="card text-center">
                    <div class="card-body">
                        <h5 class="card-title text-primary">速度</h5>
                        <h2 class="mb-0" id="currentWPM">0</h2>
                        <small class="text-muted">WPM</small>
                    </div>
                </div>
            </div>
            <div class="col-md-3">
                <div class="card text-center">
                    <div class="card-body">
                        <h5 class="card-title text-success">准确率</h5>
                        <h2 class="mb-0" id="currentAccuracy">100%</h2>
                        <small class="text-muted">正确率</small>
                    </div>
                </div>
            </div>
            <div class="col-md-3">
                <div class="card text-center">
                    <div class="card-body">
                        <h5 class="card-title text-info">时间</h5>
                        <h2 class="mb-0" id="timeRemaining">60</h2>
                        <small class="text-muted">剩余秒数</small>
                    </div>
                </div>
            </div>
            <div class="col-md-3">
                <div class="card text-center">
                    <div class="card-body">
                        <h5 class="card-title text-warning">字符</h5>
                        <h2 class="mb-0" id="charCount">0</h2>
                        <small class="text-muted">已输入</small>
                    </div>
                </div>
            </div>
        </div>
        <!-- 打字区域 -->
        <div class="row mb-4">
            <div class="col-12">
                <div class="card">
                    <div class="card-body">
                        <div id="textDisplay" class="text-display mb-3">
                            点击"开始测试"开始打字练习
                        </div>
                        <textarea 
                            id="textInput" 
                            class="form-control text-input" 
                            placeholder="在这里输入文字..."
                            disabled
                        ></textarea>
                        <div class="mt-2">
                            <small class="text-muted">
                                <i class="fas fa-info-circle me-1"></i>
                                提示:专注于准确性,速度会随着练习自然提升
                            </small>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <!-- 结果展示 -->
        <div id="resultCard" class="row mb-4" style="display: none;">
            <div class="col-12">
                <div class="card border-success">
                    <div class="card-header bg-success text-white">
                        <h5 class="mb-0"><i class="fas fa-trophy me-2"></i>测试完成!</h5>
                    </div>
                    <div class="card-body">
                        <div class="row text-center">
                            <div class="col-md-3">
                                <h4 class="text-primary" id="finalWPM">0</h4>
                                <small>WPM</small>
                            </div>
                            <div class="col-md-3">
                                <h4 class="text-success" id="finalAccuracy">0%</h4>
                                <small>准确率</small>
                            </div>
                            <div class="col-md-3">
                                <h4 class="text-info" id="finalTime">0</h4>
                                <small>用时(秒)</small>
                            </div>
                            <div class="col-md-3">
                                <h4 class="text-warning" id="finalChars">0</h4>
                                <small>字符数</small>
                            </div>
                        </div>
                        <div class="mt-3 text-center">
                            <button class="btn btn-primary me-2" id="retryBtn">
                                <i class="fas fa-redo me-1"></i>再试一次
                            </button>
                            <button class="btn btn-success" id="saveBtn">
                                <i class="fas fa-save me-1"></i>保存记录
                            </button>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        <!-- 统计标签页 -->
        <div class="row">
            <div class="col-12">
                <ul class="nav nav-tabs" id="statsTabs" role="tablist">
                    <li class="nav-item" role="presentation">
                        <button class="nav-link active" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button">
                            <i class="fas fa-history me-1"></i>历史记录
                        </button>
                    </li>
                    <li class="nav-item" role="presentation">
                        <button class="nav-link" id="statistics-tab" data-bs-toggle="tab" data-bs-target="#statistics" type="button">
                            <i class="fas fa-chart-bar me-1"></i>统计数据
                        </button>
                    </li>
                </ul>
                <div class="tab-content" id="statsTabContent">
                    <div class="tab-pane fade show active" id="history" role="tabpanel">
                        <div class="card">
                            <div class="card-body">
                                <div class="table-responsive">
                                    <table class="table table-hover">
                                        <thead>
                                            <tr>
                                                <th>时间</th>
                                                <th>速度(WPM)</th>
                                                <th>准确率</th>
                                                <th>类型</th>
                                                <th>用时</th>
                                                <th>操作</th>
                                            </tr>
                                        </thead>
                                        <tbody id="historyTable">
                                            <!-- 动态加载 -->
                                        </tbody>
                                    </table>
                                </div>
                            </div>
                        </div>
                    </div>
                    <div class="tab-pane fade" id="statistics" role="tabpanel">
                        <div class="row">
                            <div class="col-md-4">
                                <div class="card">
                                    <div class="card-header">
                                        <h6 class="mb-0">今日统计</h6>
                                    </div>
                                    <div class="card-body">
                                        <div id="todayStats">
                                            <p>测试次数: <strong>0</strong></p>
                                            <p>平均速度: <strong>0 WPM</strong></p>
                                            <p>平均准确率: <strong>0%</strong></p>
                                        </div>
                                    </div>
                                </div>
                            </div>
                            <div class="col-md-4">
                                <div class="card">
                                    <div class="card-header">
                                        <h6 class="mb-0">本周统计</h6>
                                    </div>
                                    <div class="card-body">
                                        <div id="weekStats">
                                            <p>测试次数: <strong>0</strong></p>
                                            <p>平均速度: <strong>0 WPM</strong></p>
                                            <p>平均准确率: <strong>0%</strong></p>
                                        </div>
                                    </div>
                                </div>
                            </div>
                            <div class="col-md-4">
                                <div class="card">
                                    <div class="card-header">
                                        <h6 class="mb-0">总体统计</h6>
                                    </div>
                                    <div class="card-body">
                                        <div id="totalStats">
                                            <p>测试次数: <strong>0</strong></p>
                                            <p>平均速度: <strong>0 WPM</strong></p>
                                            <p>最高速度: <strong>0 WPM</strong></p>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
    <script>
        class TypingTest {
            constructor() {
                this.originalText = '';
                this.currentPosition = 0;
                this.startTime = null;
                this.timer = null;
                this.timeLimit = 60;
                this.isActive = false;
                this.correctChars = 0;
                this.totalChars = 0;
                this.errors = [];
                
                this.initializeElements();
                this.bindEvents();
                this.loadHistory();
                this.loadStats();
            }
            
            initializeElements() {
                this.elements = {
                    textDisplay: document.getElementById('textDisplay'),
                    textInput: document.getElementById('textInput'),
                    startBtn: document.getElementById('startBtn'),
                    retryBtn: document.getElementById('retryBtn'),
                    saveBtn: document.getElementById('saveBtn'),
                    currentWPM: document.getElementById('currentWPM'),
                    currentAccuracy: document.getElementById('currentAccuracy'),
                    timeRemaining: document.getElementById('timeRemaining'),
                    charCount: document.getElementById('charCount'),
                    resultCard: document.getElementById('resultCard'),
                    finalWPM: document.getElementById('finalWPM'),
                    finalAccuracy: document.getElementById('finalAccuracy'),
                    finalTime: document.getElementById('finalTime'),
                    finalChars: document.getElementById('finalChars'),
                    textType: document.getElementById('textType'),
                    timeLimit: document.getElementById('timeLimit'),
                    difficulty: document.getElementById('difficulty'),
                    historyTable: document.getElementById('historyTable'),
                    bestWPM: document.getElementById('bestWPM')
                };
            }
            
            bindEvents() {
                this.elements.startBtn.addEventListener('click', () => this.startTest());
                this.elements.retryBtn.addEventListener('click', () => this.resetTest());
                this.elements.saveBtn.addEventListener('click', () => this.saveResult());
                this.elements.textInput.addEventListener('input', (e) => this.handleInput(e));
                this.elements.textInput.addEventListener('keydown', (e) => this.handleKeyDown(e));
                
                // 统计标签页切换
                document.getElementById('statistics-tab').addEventListener('shown.bs.tab', () => {
                    this.loadStats();
                });
                document.getElementById('history-tab').addEventListener('shown.bs.tab', () => {
                    this.loadHistory();
                });
            }
            
            async startTest() {
                try {
                    const textType = this.elements.textType.value;
                    const response = await fetch(`/api/text/${textType}`);
                    const data = await response.json();
                    
                    this.originalText = data.text;
                    this.currentPosition = 0;
                    this.correctChars = 0;
                    this.totalChars = 0;
                    this.errors = [];
                    this.timeLimit = parseInt(this.elements.timeLimit.value);
                    
                    this.displayText();
                    this.elements.textInput.disabled = false;
                    this.elements.textInput.value = '';
                    this.elements.textInput.focus();
                    this.elements.startBtn.disabled = true;
                    this.elements.resultCard.style.display = 'none';
                    
                    this.isActive = true;
                    this.startTime = Date.now();
                    this.startTimer();
                    
                } catch (error) {
                    console.error('Error loading text:', error);
                    alert('加载文本失败,请重试');
                }
            }
            
            displayText() {
                let html = '';
                for (let i = 0; i < this.originalText.length; i++) {
                    let className = 'char-untyped';
                    if (i < this.currentPosition) {
                        const userInput = this.elements.textInput.value[i];
                        const originalChar = this.originalText[i];
                        className = userInput === originalChar ? 'char-correct' : 'char-incorrect';
                    } else if (i === this.currentPosition) {
                        className = 'char-current';
                    }
                    html += `<span class="${className}">${this.originalText[i]}</span>`;
                }
                this.elements.textDisplay.innerHTML = html;
            }
            
            handleInput(e) {
                if (!this.isActive) return;
                
                const inputValue = e.target.value;
                const inputLength = inputValue.length;
                
                // 更新当前位置
                this.currentPosition = inputLength;
                
                // 计算统计
                this.calculateStats();
                
                // 更新显示
                this.displayText();
                this.updateRealtimeStats();
                
                // 检查是否完成
                if (inputLength >= this.originalText.length) {
                    this.endTest();
                }
            }
            
            handleKeyDown(e) {
                if (!this.isActive) return;
                
                // 防止退格键超过当前位置
                if (e.key === 'Backspace' && this.elements.textInput.selectionStart === 0) {
                    e.preventDefault();
                }
                
                // Tab键完成测试
                if (e.key === 'Tab') {
                    e.preventDefault();
                    this.endTest();
                }
            }
            
            calculateStats() {
                const inputValue = this.elements.textInput.value;
                this.correctChars = 0;
                this.totalChars = inputValue.length;
                this.errors = [];
                
                for (let i = 0; i < inputValue.length; i++) {
                    if (inputValue[i] === this.originalText[i]) {
                        this.correctChars++;
                    } else {
                        this.errors.push({
                            position: i,
                            expected: this.originalText[i],
                            actual: inputValue[i]
                        });
                    }
                }
            }
            
            updateRealtimeStats() {
                if (!this.startTime) return;
                
                const currentTime = Date.now();
                const timeElapsed = (currentTime - this.startTime) / 1000 / 60; // 分钟
                
                // 计算WPM (假设每个单词平均5个字符)
                const wordsTyped = this.correctChars / 5;
                const wpm = timeElapsed > 0 ? Math.round(wordsTyped / timeElapsed) : 0;
                
                // 计算准确率
                const accuracy = this.totalChars > 0 ? Math.round((this.correctChars / this.totalChars) * 100) : 100;
                
                // 更新显示
                this.elements.currentWPM.textContent = wpm;
                this.elements.currentAccuracy.textContent = accuracy + '%';
                this.elements.charCount.textContent = this.totalChars;
            }
            
            startTimer() {
                let timeLeft = this.timeLimit;
                
                if (this.timeLimit > 0) {
                    this.timer = setInterval(() => {
                        timeLeft--;
                        this.elements.timeRemaining.textContent = timeLeft;
                        
                        if (timeLeft <= 0) {
                            this.endTest();
                        }
                    }, 1000);
                } else {
                    this.elements.timeRemaining.textContent = '∞';
                }
            }
            
            endTest() {
                this.isActive = false;
                clearInterval(this.timer);
                
                const endTime = Date.now();
                const timeElapsed = Math.round((endTime - this.startTime) / 1000);
                
                // 计算最终统计
                const timeInMinutes = timeElapsed / 60;
                const wordsTyped = this.correctChars / 5;
                const finalWPM = timeInMinutes > 0 ? Math.round(wordsTyped / timeInMinutes) : 0;
                const finalAccuracy = this.totalChars > 0 ? Math.round((this.correctChars / this.totalChars) * 100) : 0;
                
                // 显示结果
                this.elements.finalWPM.textContent = finalWPM;
                this.elements.finalAccuracy.textContent = finalAccuracy + '%';
                this.elements.finalTime.textContent = timeElapsed;
                this.elements.finalChars.textContent = this.totalChars;
                
                this.elements.resultCard.style.display = 'block';
                this.elements.textInput.disabled = true;
                this.elements.startBtn.disabled = false;
                
                // 保存当前结果供后续保存
                this.currentResult = {
                    wpm: finalWPM,
                    accuracy: finalAccuracy,
                    text_type: this.elements.textType.value,
                    duration: timeElapsed,
                    correct_chars: this.correctChars,
                    total_chars: this.totalChars,
                    errors: this.errors
                };
                
                // 滚动到结果区域
                this.elements.resultCard.scrollIntoView({ behavior: 'smooth' });
            }
            
            resetTest() {
                this.isActive = false;
                clearInterval(this.timer);
                this.currentPosition = 0;
                this.correctChars = 0;
                this.totalChars = 0;
                this.errors = [];
                
                this.elements.textDisplay.innerHTML = '点击"开始测试"开始打字练习';
                this.elements.textInput.value = '';
                this.elements.textInput.disabled = true;
                this.elements.startBtn.disabled = false;
                this.elements.resultCard.style.display = 'none';
                
                // 重置统计显示
                this.elements.currentWPM.textContent = '0';
                this.elements.currentAccuracy.textContent = '100%';
                this.elements.timeRemaining.textContent = this.elements.timeLimit.value;
                this.elements.charCount.textContent = '0';
            }
            
            async saveResult() {
                if (!this.currentResult) {
                    alert('没有可保存的结果');
                    return;
                }
                
                try {
                    const response = await fetch('/api/save_result', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        },
                        body: JSON.stringify(this.currentResult)
                    });
                    
                    if (response.ok) {
                        alert('保存成功!');
                        this.elements.saveBtn.disabled = true;
                        this.loadHistory();
                        this.loadStats();
                    } else {
                        throw new Error('保存失败');
                    }
                } catch (error) {
                    console.error('Error saving result:', error);
                    alert('保存失败,请重试');
                }
            }
            
            async loadHistory() {
                try {
                    const response = await fetch('/api/history');
                    const records = await response.json();
                    
                    let html = '';
                    if (records.length === 0) {
                        html = '<tr><td colspan="6" class="text-center text-muted">暂无历史记录</td></tr>';
                    } else {
                        records.forEach(record => {
                            const date = new Date(record.created_at).toLocaleString('zh-CN');
                            const accuracyClass = record.accuracy >= 95 ? 'text-success' : 
                                                 record.accuracy >= 85 ? 'text-warning' : 'text-danger';
                            
                            html += `
                                <tr>
                                    <td>${date}</td>
                                    <td><strong>${record.wpm}</strong> WPM</td>
                                    <td class="${accuracyClass}">${record.accuracy}%</td>
                                    <td><span class="badge bg-secondary">${this.getTextTypeLabel(record.text_type)}</span></td>
                                    <td>${record.duration}秒</td>
                                    <td>
                                        <button class="btn btn-sm btn-outline-danger" onclick="deleteRecord(${record.id})">
                                            <i class="fas fa-trash"></i>
                                        </button>
                                    </td>
                                </tr>
                            `;
                        });
                    }
                    
                    this.elements.historyTable.innerHTML = html;
                    
                    // 更新最高记录
                    if (records.length > 0) {
                        const bestWPM = Math.max(...records.map(r => r.wpm));
                        this.elements.bestWPM.textContent = bestWPM;
                    }
                    
                } catch (error) {
                    console.error('Error loading history:', error);
                }
            }
            
            async loadStats() {
                try {
                    const response = await fetch('/api/stats');
                    const stats = await response.json();
                    
                    // 更新今日统计
                    document.getElementById('todayStats').innerHTML = `
                        <p>测试次数: <strong>${stats.today.count}</strong></p>
                        <p>平均速度: <strong>${Math.round(stats.today.avg_wpm)} WPM</strong></p>
                        <p>平均准确率: <strong>${Math.round(stats.today.avg_accuracy)}%</strong></p>
                    `;
                    
                    // 更新本周统计
                    document.getElementById('weekStats').innerHTML = `
                        <p>测试次数: <strong>${stats.week.count}</strong></p>
                        <p>平均速度: <strong>${Math.round(stats.week.avg_wpm)} WPM</strong></p>
                        <p>平均准确率: <strong>${Math.round(stats.week.avg_accuracy)}%</strong></p>
                    `;
                    
                    // 更新总体统计
                    document.getElementById('totalStats').innerHTML = `
                        <p>测试次数: <strong>${stats.total.count}</strong></p>
                        <p>平均速度: <strong>${Math.round(stats.total.avg_wpm)} WPM</strong></p>
                        <p>最高速度: <strong>${Math.round(stats.total.max_wpm)} WPM</strong></p>
                    `;
                    
                } catch (error) {
                    console.error('Error loading stats:', error);
                }
            }
            
            getTextTypeLabel(type) {
                const labels = {
                    'english': '英文',
                    'programming': '编程',
                    'chinese': '中文'
                };
                return labels[type] || type;
            }
        }
        // 删除记录
        async function deleteRecord(recordId) {
            if (!confirm('确定要删除这条记录吗?')) {
                return;
            }
            
            try {
                const response = await fetch(`/api/record/${recordId}`, {
                    method: 'DELETE'
                });
                
                if (response.ok) {
                    typingTest.loadHistory();
                    typingTest.loadStats();
                } else {
                    throw new Error('删除失败');
                }
            } catch (error) {
                console.error('Error deleting record:', error);
                alert('删除失败,请重试');
            }
        }
        // 初始化应用
        let typingTest;
        document.addEventListener('DOMContentLoaded', () => {
            typingTest = new TypingTest();
        });
    </script>
</body>
</html>

2.3 运行项目

启动项目:

bash 复制代码
python main.py

项目将在 http://localhost:8000 启动。

相关推荐
vvoennvv2 小时前
【Python TensorFlow】CNN-BiLSTM-Attention时序预测 卷积神经网络-双向长短期记忆神经网络组合模型带注意力机制(附代码)
python·神经网络·cnn·tensorflow·lstm·bilstm·注意力
程序员爱钓鱼2 小时前
Python 编程实战:环境管理与依赖管理(venv / Poetry)
后端·python·trae
程序员爱钓鱼2 小时前
Python 编程实战 :打包与发布(PyInstaller / pip 包发布)
后端·python·trae
姓蔡小朋友2 小时前
redis GEO数据结构、实现附近商铺功能
数据结构·数据库·redis
冉冰学姐3 小时前
SSM农贸市场摊位管理系统c22ux(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面
数据库·ssm 框架·农贸市场·摊位管理系统
我叫侯小科3 小时前
PyTorch 实战:手写数字识别(MNIST)从入门到精通
人工智能·pytorch·python
青衫客363 小时前
浅谈 Python 的 C3 线性化算法(C3 Linearization):多继承背后的秩序之美
python·mro·c3线性化算法
面向星辰3 小时前
SQL LIKE 相似信息查找语句
数据库·sql
Gitpchy3 小时前
Day 47 注意力热图可视化
python·深度学习·cnn