feat: 新增打字练习记录与首页记录列表
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
+12
-1
@@ -39,13 +39,23 @@
|
||||
</label>
|
||||
</div>
|
||||
<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 id="practice-view" class="type-card" style="display:none">
|
||||
<div class="type-toolbar">
|
||||
<span id="progress-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 class="progress-track"><div id="progress-fill" class="progress-fill"></div></div>
|
||||
<div id="target-text"></div>
|
||||
@@ -76,6 +86,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="api.js"></script>
|
||||
<script src="typing-data.js"></script>
|
||||
<script src="speech.js"></script>
|
||||
<script src="typing.js"></script>
|
||||
|
||||
@@ -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 = '<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) {
|
||||
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() {
|
||||
'<div class="finish-box"><h2>练习完成</h2><p>完成 ' + typingItems.length +
|
||||
' 个,正确率 ' + accuracy + '%</p><button id="btn-again">再练一次</button></div>';
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user