Files
spanish-diary/typing.js
T

470 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
var typingMode = 'vocab';
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 typingSelectionTimer = null;
var KEY_ROWS = [
[
{ key: 'º', sub: '`' },
{ key: '1' }, { key: '2' }, { key: '3' }, { key: '4' }, { key: '5' },
{ key: '6' }, { key: '7' }, { key: '8' }, { key: '9' }, { key: '0' },
{ key: "'", sub: '-' }, { key: '¡', sub: '=' }
],
[
{ key: 'q' }, { key: 'w' }, { key: 'e' }, { key: 'r' }, { key: 't' },
{ key: 'y' }, { key: 'u' }, { key: 'i' }, { key: 'o' }, { key: 'p' },
{ key: '`', sub: '[' }, { key: '+', sub: ']' }, { key: 'ç', sub: '\\' }
],
[
{ key: 'a' }, { key: 's' }, { key: 'd' }, { key: 'f' }, { key: 'g' },
{ key: 'h' }, { key: 'j' }, { key: 'k' }, { key: 'l' },
{ key: 'ñ', sub: ';' }, { key: '´', sub: "'" }
],
[
{ key: 'Shift' }, { key: '<' }, { key: 'z' }, { key: 'x' }, { key: 'c' },
{ key: 'v' }, { key: 'b' }, { key: 'n' }, { key: 'm' }, { key: ',' },
{ key: '.' }, { key: '-', sub: '/' }, { key: 'Shift' }
],
[
{ key: 'Ctrl' }, { key: 'Win' }, { key: 'Alt' }, { key: 'Space' },
{ key: 'AltGr' }, { key: 'Ctrl' }
]
];
var SPECIAL_HINTS = {
'á': '先按 L 右边的 ´ 键,再按 a',
'é': '先按 L 右边的 ´ 键,再按 e',
'í': '先按 L 右边的 ´ 键,再按 i',
'ó': '先按 L 右边的 ´ 键,再按 o',
'ú': '先按 L 右边的 ´ 键,再按 u',
'Á': '先按 L 右边的 ´ 键,再按 Shift + A',
'É': '先按 L 右边的 ´ 键,再按 Shift + E',
'Í': '先按 L 右边的 ´ 键,再按 Shift + I',
'Ó': '先按 L 右边的 ´ 键,再按 Shift + O',
'Ú': '先按 L 右边的 ´ 键,再按 Shift + U',
'ü': 'Shift + L 右边的 ´ 键,松开后按 u',
'Ü': 'Shift + L 右边的 ´ 键,松开后按 Shift + U',
'ñ': '直接按 L 右边的 ñ 键',
'Ñ': 'Shift + L 右边的 ñ 键',
'¿': 'Shift + ¡ 键(标准键盘上是 Shift + 加号键)',
'¡': '直接按 ¡ 键(标准键盘上是加号键)',
'?': 'Shift + \' 键(标准键盘上是 Shift + 减号键)',
'!': 'Shift + 数字 1',
':': 'Shift + 句号键',
';': 'Shift + 逗号键'
};
function keyPressForChar(ch) {
if (!ch) return null;
if (ch === ' ') return { keys: ['Space'], shift: false };
var lower = ch.toLowerCase();
if (/[a-z0-9]/.test(lower)) {
return { keys: [lower], shift: ch !== lower };
}
var map = {
'á': { keys: ['´'], shift: false },
'é': { keys: ['´'], shift: false },
'í': { keys: ['´'], shift: false },
'ó': { keys: ['´'], shift: false },
'ú': { keys: ['´'], shift: false },
'Á': { keys: ['´'], shift: false },
'É': { keys: ['´'], shift: false },
'Í': { keys: ['´'], shift: false },
'Ó': { keys: ['´'], shift: false },
'Ú': { keys: ['´'], shift: false },
'ü': { keys: ['´'], shift: true },
'Ü': { keys: ['´'], shift: true },
'ñ': { keys: ['ñ'], shift: false },
'Ñ': { keys: ['ñ'], shift: true },
'¿': { keys: ['¡'], shift: true },
'¡': { keys: ['¡'], shift: false },
'?': { keys: ["'"], shift: true },
'!': { keys: ['1'], shift: true },
':': { keys: ['.'], shift: true },
';': { keys: [','], shift: true },
'/': { keys: ['7'], shift: true },
'"': { keys: ['2'], shift: true },
'(': { keys: ['8'], shift: true },
')': { keys: ['9'], shift: true },
'_': { keys: ['-'], shift: true },
'-': { keys: ['-'], shift: false },
"'": { keys: ["'"], shift: false },
',': { keys: [','], shift: false },
'.': { keys: ['.'], shift: false },
'<': { keys: ['<'], shift: false },
'>': { keys: ['<'], shift: true },
'`': { keys: ['`'], shift: false },
'^': { keys: ['`'], shift: true },
'+': { keys: ['+'], shift: false },
'*': { keys: ['+'], shift: true },
'º': { keys: ['º'], shift: false },
'ª': { keys: ['º'], shift: true },
'ç': { keys: ['ç'], shift: false },
'Ç': { keys: ['ç'], shift: true }
};
return map[ch] || null;
}
function renderSpecialHint(ch) {
var el = document.getElementById('special-hint');
if (!ch) {
el.textContent = '';
return;
}
if (SPECIAL_HINTS[ch]) {
el.textContent = '输入提示:' + SPECIAL_HINTS[ch];
return;
}
if (ch === ' ') {
el.textContent = '输入提示:按空格键';
return;
}
var press = keyPressForChar(ch);
if (press && press.shift) {
el.textContent = '输入提示:先按 Shift,再按 ' + press.keys.join(' + ') + ' 键';
return;
}
if (ch !== ch.toLowerCase() && ch === ch.toUpperCase() && /[a-z]/i.test(ch)) {
el.textContent = '输入提示:先按 Shift,再按 ' + ch.toLowerCase();
return;
}
el.textContent = '输入提示:直接按 ' + (press ? press.keys.join(' + ') : ch) + ' 键';
}
function escTyping(s) {
var div = document.createElement('div');
div.textContent = s == null ? '' : String(s);
return div.innerHTML;
}
function currentCategory() {
var id = document.getElementById('category-select').value;
return window.TYPING_DATA[typingMode].categories.find(function(c) {
return c.id === id;
});
}
function renderCategories() {
var data = window.TYPING_DATA[typingMode];
var select = document.getElementById('category-select');
select.innerHTML = data.categories.map(function(cat) {
return '<option value="' + escTyping(cat.id) + '">' +
escTyping(cat.name) + '(' + cat.items.length + ')</option>';
}).join('');
}
function startTyping() {
var category = currentCategory();
if (!category) return;
var source = category.items.map(function(item, idx) {
return Object.assign({}, item, { id: idx + 1 });
});
if (document.getElementById('order-select').value === 'random') {
for (var i = source.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = source[i]; source[i] = source[j]; source[j] = t;
}
}
var count = document.getElementById('count-select').value;
typingItems = count === 'all' ? source : source.slice(0, parseInt(count, 10) || 10);
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';
renderPractice();
}
function renderPractice() {
if (typingIndex >= typingItems.length) {
renderFinish();
return;
}
var item = typingItems[typingIndex];
var target = item.es;
speakText(item.es, 'es');
var targetHtml = '';
for (var i = 0; i < target.length; i++) {
var ch = escTyping(target[i]);
if (i < typed.length) {
targetHtml += '<span class="done">' + ch + '</span>';
} else if (i === typed.length) {
targetHtml += '<span class="current">' + ch + '</span>';
} else {
targetHtml += ch;
}
}
document.getElementById('target-text').innerHTML = targetHtml;
document.getElementById('chinese-text').textContent = item.zh || '';
document.getElementById('progress-text').textContent =
(typingIndex + 1) + ' / ' + typingItems.length;
var totalKeys = typingStats.correct + typingStats.errors;
document.getElementById('accuracy-text').textContent =
'正确按键 ' + typingStats.correct + ',错误 ' + typingStats.errors +
',正确率 ' + (totalKeys ? Math.round(typingStats.correct * 100 / totalKeys) : 100) + '%';
document.getElementById('progress-fill').style.width =
(typingIndex * 100 / typingItems.length) + '%';
var input = document.getElementById('typing-input');
input.value = typed;
renderNextKey(target);
renderKeyboard(target);
setTimeout(function() { input.focus(); }, 50);
}
function renderNextKey(target) {
var next = target[typed.length];
var label = next === ' ' ? '空格' : next;
document.getElementById('next-key').innerHTML = typed.length >= target.length
? '已完成'
: '下一键:<b>' + escTyping(label) + '</b>';
}
function renderTypingAccuracy() {
var totalKeys = typingStats.correct + typingStats.errors;
document.getElementById('accuracy-text').textContent =
'正确按键 ' + typingStats.correct + ',错误 ' + typingStats.errors +
',正确率 ' + (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 readCurrentTarget() {
var item = typingItems[typingIndex];
if (item && item.es) speakText(item.es, 'es');
}
function getTypingSelectedSpanish() {
var input = document.getElementById('typing-input');
if (document.activeElement === input && input.selectionStart !== input.selectionEnd) {
return input.value.substring(input.selectionStart, input.selectionEnd).trim();
}
var selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) return '';
var node = selection.anchorNode;
var container = node && node.nodeType === 3 ? node.parentNode : node;
var target = document.getElementById('target-text');
return container && target && target.contains(container)
? selection.toString().trim()
: '';
}
function handleTypingSelection() {
var text = getTypingSelectedSpanish();
if (!text || !typingItems[typingIndex]) return;
if (typingSelectionTimer) clearTimeout(typingSelectionTimer);
typingSelectionTimer = setTimeout(function() {
speakText(text, 'es');
}, 150);
}
function renderKeyboard(target) {
var next = target[typed.length] || '';
var press = keyPressForChar(next);
var highlightKeys = {};
var highlightShift = false;
if (press) {
press.keys.forEach(function(key) {
highlightKeys[key.toLowerCase()] = true;
});
highlightShift = press.shift;
}
var container = document.getElementById('keyboard');
container.innerHTML = KEY_ROWS.map(function(row) {
return '<div class="kb-row">' + row.map(function(item) {
var key = item.key;
var sub = item.sub || '';
var isHint = highlightKeys[key.toLowerCase()] ||
(key === 'Shift' && highlightShift);
var cls = ['kb-key'];
if (isHint) cls.push('hint');
if (key === 'Shift' || key === 'Ctrl' || key === 'Alt') cls.push('wide');
if (key === 'AltGr') cls.push('altgr');
if (key === 'Space') cls.push('space');
var face = sub
? '<span class="kb-main">' + escTyping(key) + '</span><span class="kb-sub">' + escTyping(sub) + '</span>'
: escTyping(key);
return '<div class="' + cls.join(' ') + '">' + face + '</div>';
}).join('') + '</div>';
}).join('');
renderSpecialHint(next);
}
function handleTypingInput() {
var input = document.getElementById('typing-input');
var value = input.value;
var item = typingItems[typingIndex];
if (!item) return;
var target = item.es;
var oldLength = typed.length;
typed = value;
if (target.indexOf(typed) === 0) {
typingStats.correct += Math.max(0, typed.length - oldLength);
renderTargetProgress(target);
} else {
typingStats.errors++;
typed = target.slice(0, oldLength);
input.value = typed;
renderTargetProgress(target);
}
renderNextKey(target);
renderKeyboard(target);
renderTypingAccuracy();
if (typed.length >= target.length) {
typingStats.correct += Math.max(0, target.length - typed.length);
speakText(item.es, 'es');
typingIndex++;
typed = '';
setTimeout(renderPractice, 700);
}
}
function renderTargetProgress(target) {
var targetHtml = '';
for (var i = 0; i < target.length; i++) {
var ch = escTyping(target[i]);
if (i < typed.length) {
targetHtml += '<span class="done">' + ch + '</span>';
} else if (i === typed.length) {
targetHtml += '<span class="current">' + ch + '</span>';
} else {
targetHtml += ch;
}
}
document.getElementById('target-text').innerHTML = targetHtml;
}
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 = '';
document.getElementById('keyboard').innerHTML = '';
document.getElementById('progress-text').textContent = '';
document.getElementById('accuracy-text').textContent = '';
document.getElementById('typing-input').style.display = 'none';
document.getElementById('progress-fill').style.width = '100%';
document.getElementById('target-text').innerHTML =
'<div class="finish-box"><h2>练习完成</h2><p>完成 ' + typingItems.length +
' 个,正确率 ' + accuracy + '%</p><button id="btn-again">再练一次</button></div>';
document.getElementById('btn-again').addEventListener('click', function() {
showSetupView();
});
}
document.querySelectorAll('.mode-select button').forEach(function(btn) {
btn.addEventListener('click', function() {
typingMode = btn.dataset.mode;
document.querySelectorAll('.mode-select button').forEach(function(b) {
b.classList.toggle('active', b === btn);
});
renderCategories();
});
});
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('btn-typing-speak').addEventListener('click', readCurrentTarget);
document.getElementById('typing-input').addEventListener('input', handleTypingInput);
document.getElementById('typing-input').addEventListener('mouseup', handleTypingSelection);
document.getElementById('typing-input').addEventListener('keyup', handleTypingSelection);
document.addEventListener('selectionchange', handleTypingSelection);
document.addEventListener('keydown', function(e) {
if (e.key === 'F6') {
e.preventDefault();
readCurrentTarget();
}
});
renderCategories();
loadTypingLogs();