diff --git a/api.js b/api.js index 772bf29..6bd1b8d 100644 --- a/api.js +++ b/api.js @@ -111,3 +111,11 @@ function apiAdminDeleteUser(id) { function apiAdminResetPassword(id, password) { return apiFetch('/api/admin/users/' + id + '/password', { method: 'PUT', body: { password: password } }); } + +function apiGetTypingLogs() { + return apiFetch('/api/typing/logs'); +} + +function apiSaveTypingLog(data) { + return apiFetch('/api/typing/logs', { method: 'POST', body: data }); +} diff --git a/server/app.py b/server/app.py index bb83726..e0e7ec9 100644 --- a/server/app.py +++ b/server/app.py @@ -12,6 +12,7 @@ from server.db import BASE_DIR, get_db, init_db from server.entries_api import entries_bp from server.settings_api import settings_bp from server.tts_api import tts_bp +from server.typing_api import typing_bp from server.vocab_api import vocab_bp @@ -30,6 +31,7 @@ def create_app(): app.register_blueprint(vocab_bp) app.register_blueprint(settings_bp) app.register_blueprint(tts_bp) + app.register_blueprint(typing_bp) @app.post("/api/register") def register(): diff --git a/server/db.py b/server/db.py index cf06c4a..5f3bbc4 100644 --- a/server/db.py +++ b/server/db.py @@ -51,6 +51,23 @@ CREATE TABLE IF NOT EXISTS settings ( extra_config TEXT NOT NULL DEFAULT '{}', updated_at TEXT NOT NULL ); + +CREATE TABLE IF NOT EXISTS typing_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mode TEXT NOT NULL, + category_id TEXT NOT NULL DEFAULT '', + category_name TEXT NOT NULL DEFAULT '', + item_count INTEGER NOT NULL DEFAULT 0, + correct_keys INTEGER NOT NULL DEFAULT 0, + error_keys INTEGER NOT NULL DEFAULT 0, + accuracy INTEGER NOT NULL DEFAULT 0, + duration_seconds INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_typing_logs_user_created +ON typing_logs(user_id, created_at); """ diff --git a/server/typing_api.py b/server/typing_api.py new file mode 100644 index 0000000..6c5cfe2 --- /dev/null +++ b/server/typing_api.py @@ -0,0 +1,97 @@ +from flask import Blueprint, jsonify, request + +from server.auth import login_required +from server.db import get_db, now_iso + +typing_bp = Blueprint("typing", __name__) + + +def row_to_dict(row): + return dict(row) + + +def to_int(value, default=0): + try: + return int(value) + except (TypeError, ValueError): + return default + + +@typing_bp.get("/api/typing/logs") +@login_required +def list_typing_logs(user): + conn = get_db() + try: + rows = conn.execute( + """ + SELECT id, mode, category_id, category_name, item_count, + correct_keys, error_keys, accuracy, duration_seconds, created_at + FROM typing_logs + WHERE user_id = ? + ORDER BY id DESC + LIMIT 200 + """, + (user["id"],), + ).fetchall() + return jsonify([row_to_dict(r) for r in rows]) + finally: + conn.close() + + +@typing_bp.post("/api/typing/logs") +@login_required +def create_typing_log(user): + data = request.get_json(silent=True) or {} + mode = (data.get("mode") or "").strip() + if mode not in ("vocab", "phrases"): + return jsonify({"error": "练习类型不正确"}), 400 + + item_count = to_int(data.get("itemCount")) + correct_keys = to_int(data.get("correctKeys")) + error_keys = to_int(data.get("errorKeys")) + accuracy = to_int(data.get("accuracy")) + duration_seconds = to_int(data.get("durationSeconds")) + + if item_count < 1: + return jsonify({"error": "练习数量不正确"}), 400 + if accuracy < 0 or accuracy > 100: + return jsonify({"error": "正确率不正确"}), 400 + if duration_seconds < 0: + return jsonify({"error": "练习时长不正确"}), 400 + + conn = get_db() + try: + cur = conn.execute( + """ + INSERT INTO typing_logs ( + user_id, mode, category_id, category_name, item_count, + correct_keys, error_keys, accuracy, duration_seconds, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user["id"], + mode, + (data.get("categoryId") or "").strip(), + (data.get("categoryName") or "").strip(), + item_count, + max(0, correct_keys), + max(0, error_keys), + accuracy, + duration_seconds, + now_iso(), + ), + ) + conn.commit() + row = conn.execute( + """ + SELECT id, mode, category_id, category_name, item_count, + correct_keys, error_keys, accuracy, duration_seconds, created_at + FROM typing_logs + WHERE id = ? + """, + (cur.lastrowid,), + ).fetchone() + return jsonify(row_to_dict(row)), 201 + finally: + conn.close() diff --git a/typing.css b/typing.css index 066e21a..504606d 100644 --- a/typing.css +++ b/typing.css @@ -85,9 +85,15 @@ align-items: center; justify-content: space-between; gap: 0.75rem; + flex-wrap: wrap; margin-bottom: 0.75rem; } .type-toolbar span { font-size: 0.85rem; color: var(--text-muted); } +.toolbar-actions { + display: flex; + gap: 0.5rem; + margin-left: auto; +} .type-toolbar button { background: none; border: 1px solid var(--border); @@ -231,6 +237,64 @@ padding-left: 1.25rem; } .keyboard-guide li { margin-bottom: 0.25rem; } +.records-section { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} +.records-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} +.records-head h2 { + margin: 0; + font-size: 1rem; + color: var(--text); +} +.records-head button { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.3rem 0.7rem; + font-size: 0.8rem; + cursor: pointer; + color: var(--text-muted); +} +.records-list { + min-height: 2.5rem; + font-size: 0.86rem; + color: var(--text-muted); +} +.records-empty { padding: 0.75rem 0; } +.record-item { + padding: 0.65rem 0; + border-bottom: 1px solid var(--border); +} +.record-item:last-child { border-bottom: 0; } +.record-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.75rem; +} +.record-mode { + font-weight: 700; + color: var(--text); +} +.record-category { color: var(--text-muted); } +.record-accuracy { + font-weight: 700; + color: var(--success); +} +.record-duration { color: var(--text-muted); } +.record-meta { + margin-top: 0.2rem; + font-size: 0.78rem; + color: var(--text-muted); +} .finish-box { text-align: center; padding: 2rem 0; @@ -263,4 +327,6 @@ font-size: 0.8rem; padding: 0.75rem; } + .record-line { gap: 0.3rem 0.6rem; } + .record-meta { font-size: 0.74rem; } } diff --git a/typing.html b/typing.html index 241da1b..b7f1f07 100644 --- a/typing.html +++ b/typing.html @@ -39,13 +39,23 @@ +
完成 ' + typingItems.length + ' 个,正确率 ' + accuracy + '%