From 2ee9e9c564ee2221f0de6589cff04544ae2c92d1 Mon Sep 17 00:00:00 2001 From: guoqingshan Date: Sun, 2 Aug 2026 22:20:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=89=93=E5=AD=97?= =?UTF-8?q?=E7=BB=83=E4=B9=A0=E8=AE=B0=E5=BD=95=E4=B8=8E=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api.js | 8 ++++ server/app.py | 2 + server/db.py | 17 ++++++++ server/typing_api.py | 97 ++++++++++++++++++++++++++++++++++++++++++++ typing.css | 66 ++++++++++++++++++++++++++++++ typing.html | 13 +++++- typing.js | 92 +++++++++++++++++++++++++++++++++++++++-- 7 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 server/typing_api.py 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 @@ +
+
+

打字记录

+ +
+
加载中...
+
+ diff --git a/typing.js b/typing.js index ae86abc..54d3528 100644 --- a/typing.js +++ b/typing.js @@ -3,6 +3,9 @@ var typingItems = []; var typingIndex = 0; var typingStats = { correct: 0, errors: 0 }; var typed = ''; +var typingStartedAt = 0; +var typingFinished = false; +var typingSession = { mode: '', categoryId: '', categoryName: '', itemCount: 0 }; var KEY_ROWS = [ [ @@ -171,6 +174,14 @@ function startTyping() { typingIndex = 0; typingStats = { correct: 0, errors: 0 }; typed = ''; + typingStartedAt = Date.now(); + typingFinished = false; + typingSession = { + mode: typingMode, + categoryId: category.id, + categoryName: category.name, + itemCount: typingItems.length + }; document.getElementById('typing-input').style.display = ''; document.getElementById('setup-view').style.display = 'none'; document.getElementById('practice-view').style.display = 'block'; @@ -228,6 +239,79 @@ function renderTypingAccuracy() { ',正确率 ' + (totalKeys ? Math.round(typingStats.correct * 100 / totalKeys) : 100) + '%'; } +function formatTypingDuration(seconds) { + if (seconds < 60) return seconds + ' 秒'; + var minutes = Math.floor(seconds / 60); + var rest = seconds % 60; + return minutes + ' 分 ' + rest + ' 秒'; +} + +function formatTypingTime(value) { + if (!value) return ''; + var date = new Date(value); + if (isNaN(date.getTime())) return value; + return date.toLocaleString('zh-CN', { hour12: false }); +} + +function renderTypingLogs(rows) { + var list = document.getElementById('records-list'); + if (!list) return; + if (!rows || !rows.length) { + list.innerHTML = '
还没有打字记录
'; + return; + } + list.innerHTML = rows.map(function(row) { + var mode = row.mode === 'phrases' ? '日常口语' : '基础词汇'; + var category = row.category_name || row.category_id || '未分类'; + return '
' + + '
' + + '' + escTyping(mode) + '' + + '' + escTyping(category) + '' + + '正确率 ' + (row.accuracy || 0) + '%' + + '用时 ' + escTyping(formatTypingDuration(row.duration_seconds || 0)) + '' + + '
' + + '
' + + escTyping(formatTypingTime(row.created_at)) + ' · ' + row.item_count + + ' 个 · 正确 ' + row.correct_keys + ' · 错误 ' + row.error_keys + + '
' + + '
'; + }).join(''); +} + +function loadTypingLogs() { + var list = document.getElementById('records-list'); + if (!list) return; + list.textContent = '加载中...'; + apiGetTypingLogs().then(renderTypingLogs).catch(function(err) { + list.textContent = err && err.status === 401 + ? '登录后可查看打字记录' + : '记录加载失败'; + }); +} + +function saveTypingLog(accuracy) { + if (!typingStartedAt || typingFinished) return; + typingFinished = true; + var durationSeconds = Math.max(1, Math.round((Date.now() - typingStartedAt) / 1000)); + apiSaveTypingLog({ + mode: typingSession.mode, + categoryId: typingSession.categoryId, + categoryName: typingSession.categoryName, + itemCount: typingSession.itemCount, + correctKeys: typingStats.correct, + errorKeys: typingStats.errors, + accuracy: accuracy, + durationSeconds: durationSeconds + }).then(loadTypingLogs).catch(function() {}); +} + +function showSetupView() { + document.getElementById('typing-input').style.display = ''; + document.getElementById('practice-view').style.display = 'none'; + document.getElementById('setup-view').style.display = 'block'; + loadTypingLogs(); +} + function renderKeyboard(target) { var next = target[typed.length] || ''; var press = keyPressForChar(next); @@ -307,6 +391,7 @@ function renderTargetProgress(target) { function renderFinish() { var totalKeys = typingStats.correct + typingStats.errors; var accuracy = totalKeys ? Math.round(typingStats.correct * 100 / totalKeys) : 100; + saveTypingLog(accuracy); document.getElementById('target-text').innerHTML = ''; document.getElementById('chinese-text').textContent = ''; document.getElementById('next-key').textContent = ''; @@ -319,9 +404,7 @@ function renderFinish() { '

练习完成

完成 ' + typingItems.length + ' 个,正确率 ' + accuracy + '%

'; document.getElementById('btn-again').addEventListener('click', function() { - document.getElementById('typing-input').style.display = ''; - document.getElementById('setup-view').style.display = 'block'; - document.getElementById('practice-view').style.display = 'none'; + showSetupView(); }); } @@ -337,6 +420,9 @@ document.querySelectorAll('.mode-select button').forEach(function(btn) { document.getElementById('btn-start').addEventListener('click', startTyping); document.getElementById('btn-restart').addEventListener('click', startTyping); +document.getElementById('btn-choose').addEventListener('click', showSetupView); +document.getElementById('btn-refresh-records').addEventListener('click', loadTypingLogs); document.getElementById('typing-input').addEventListener('input', handleTypingInput); renderCategories(); +loadTypingLogs();