feat: 新增打字练习记录与首页记录列表

This commit is contained in:
guoqingshan
2026-08-02 22:20:15 +08:00
parent 72d1e1adc5
commit 2ee9e9c564
7 changed files with 291 additions and 4 deletions
+8
View File
@@ -111,3 +111,11 @@ function apiAdminDeleteUser(id) {
function apiAdminResetPassword(id, password) { function apiAdminResetPassword(id, password) {
return apiFetch('/api/admin/users/' + id + '/password', { method: 'PUT', body: { password: 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 });
}
+2
View File
@@ -12,6 +12,7 @@ from server.db import BASE_DIR, get_db, init_db
from server.entries_api import entries_bp from server.entries_api import entries_bp
from server.settings_api import settings_bp from server.settings_api import settings_bp
from server.tts_api import tts_bp from server.tts_api import tts_bp
from server.typing_api import typing_bp
from server.vocab_api import vocab_bp from server.vocab_api import vocab_bp
@@ -30,6 +31,7 @@ def create_app():
app.register_blueprint(vocab_bp) app.register_blueprint(vocab_bp)
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
app.register_blueprint(tts_bp) app.register_blueprint(tts_bp)
app.register_blueprint(typing_bp)
@app.post("/api/register") @app.post("/api/register")
def register(): def register():
+17
View File
@@ -51,6 +51,23 @@ CREATE TABLE IF NOT EXISTS settings (
extra_config TEXT NOT NULL DEFAULT '{}', extra_config TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL 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);
""" """
+97
View File
@@ -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()
+66
View File
@@ -85,9 +85,15 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.type-toolbar span { font-size: 0.85rem; color: var(--text-muted); } .type-toolbar span { font-size: 0.85rem; color: var(--text-muted); }
.toolbar-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.type-toolbar button { .type-toolbar button {
background: none; background: none;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -231,6 +237,64 @@
padding-left: 1.25rem; padding-left: 1.25rem;
} }
.keyboard-guide li { margin-bottom: 0.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 { .finish-box {
text-align: center; text-align: center;
padding: 2rem 0; padding: 2rem 0;
@@ -263,4 +327,6 @@
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.75rem; padding: 0.75rem;
} }
.record-line { gap: 0.3rem 0.6rem; }
.record-meta { font-size: 0.74rem; }
} }
+12 -1
View File
@@ -39,13 +39,23 @@
</label> </label>
</div> </div>
<button id="btn-start">开始打字</button> <button id="btn-start">开始打字</button>
<div class="records-section">
<div class="records-head">
<h2>打字记录</h2>
<button id="btn-refresh-records" type="button">刷新</button>
</div>
<div id="records-list" class="records-list">加载中...</div>
</div>
</div> </div>
<div id="practice-view" class="type-card" style="display:none"> <div id="practice-view" class="type-card" style="display:none">
<div class="type-toolbar"> <div class="type-toolbar">
<span id="progress-text"></span> <span id="progress-text"></span>
<span id="accuracy-text"></span> <span id="accuracy-text"></span>
<button id="btn-restart">重新开始</button> <div class="toolbar-actions">
<button id="btn-restart">重新开始</button>
<button id="btn-choose">重新选择</button>
</div>
</div> </div>
<div class="progress-track"><div id="progress-fill" class="progress-fill"></div></div> <div class="progress-track"><div id="progress-fill" class="progress-fill"></div></div>
<div id="target-text"></div> <div id="target-text"></div>
@@ -76,6 +86,7 @@
</div> </div>
</div> </div>
<script src="api.js"></script>
<script src="typing-data.js"></script> <script src="typing-data.js"></script>
<script src="speech.js"></script> <script src="speech.js"></script>
<script src="typing.js"></script> <script src="typing.js"></script>
+89 -3
View File
@@ -3,6 +3,9 @@ var typingItems = [];
var typingIndex = 0; var typingIndex = 0;
var typingStats = { correct: 0, errors: 0 }; var typingStats = { correct: 0, errors: 0 };
var typed = ''; var typed = '';
var typingStartedAt = 0;
var typingFinished = false;
var typingSession = { mode: '', categoryId: '', categoryName: '', itemCount: 0 };
var KEY_ROWS = [ var KEY_ROWS = [
[ [
@@ -171,6 +174,14 @@ function startTyping() {
typingIndex = 0; typingIndex = 0;
typingStats = { correct: 0, errors: 0 }; typingStats = { correct: 0, errors: 0 };
typed = ''; 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('typing-input').style.display = '';
document.getElementById('setup-view').style.display = 'none'; document.getElementById('setup-view').style.display = 'none';
document.getElementById('practice-view').style.display = 'block'; document.getElementById('practice-view').style.display = 'block';
@@ -228,6 +239,79 @@ function renderTypingAccuracy() {
',正确率 ' + (totalKeys ? Math.round(typingStats.correct * 100 / totalKeys) : 100) + '%'; ',正确率 ' + (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 = '<div class="records-empty">还没有打字记录</div>';
return;
}
list.innerHTML = rows.map(function(row) {
var mode = row.mode === 'phrases' ? '日常口语' : '基础词汇';
var category = row.category_name || row.category_id || '未分类';
return '<div class="record-item">' +
'<div class="record-line">' +
'<span class="record-mode">' + escTyping(mode) + '</span>' +
'<span class="record-category">' + escTyping(category) + '</span>' +
'<span class="record-accuracy">正确率 ' + (row.accuracy || 0) + '%</span>' +
'<span class="record-duration">用时 ' + escTyping(formatTypingDuration(row.duration_seconds || 0)) + '</span>' +
'</div>' +
'<div class="record-meta">' +
escTyping(formatTypingTime(row.created_at)) + ' · ' + row.item_count +
' 个 · 正确 ' + row.correct_keys + ' · 错误 ' + row.error_keys +
'</div>' +
'</div>';
}).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) { function renderKeyboard(target) {
var next = target[typed.length] || ''; var next = target[typed.length] || '';
var press = keyPressForChar(next); var press = keyPressForChar(next);
@@ -307,6 +391,7 @@ function renderTargetProgress(target) {
function renderFinish() { function renderFinish() {
var totalKeys = typingStats.correct + typingStats.errors; var totalKeys = typingStats.correct + typingStats.errors;
var accuracy = totalKeys ? Math.round(typingStats.correct * 100 / totalKeys) : 100; var accuracy = totalKeys ? Math.round(typingStats.correct * 100 / totalKeys) : 100;
saveTypingLog(accuracy);
document.getElementById('target-text').innerHTML = ''; document.getElementById('target-text').innerHTML = '';
document.getElementById('chinese-text').textContent = ''; document.getElementById('chinese-text').textContent = '';
document.getElementById('next-key').textContent = ''; document.getElementById('next-key').textContent = '';
@@ -319,9 +404,7 @@ function renderFinish() {
'<div class="finish-box"><h2>练习完成</h2><p>完成 ' + typingItems.length + '<div class="finish-box"><h2>练习完成</h2><p>完成 ' + typingItems.length +
' 个,正确率 ' + accuracy + '%</p><button id="btn-again">再练一次</button></div>'; ' 个,正确率 ' + accuracy + '%</p><button id="btn-again">再练一次</button></div>';
document.getElementById('btn-again').addEventListener('click', function() { document.getElementById('btn-again').addEventListener('click', function() {
document.getElementById('typing-input').style.display = ''; showSetupView();
document.getElementById('setup-view').style.display = 'block';
document.getElementById('practice-view').style.display = 'none';
}); });
} }
@@ -337,6 +420,9 @@ document.querySelectorAll('.mode-select button').forEach(function(btn) {
document.getElementById('btn-start').addEventListener('click', startTyping); document.getElementById('btn-start').addEventListener('click', startTyping);
document.getElementById('btn-restart').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); document.getElementById('typing-input').addEventListener('input', handleTypingInput);
renderCategories(); renderCategories();
loadTypingLogs();