feat: 新增西班牙语词汇与口语打字练习页面

This commit is contained in:
guoqingshan
2026-08-02 20:25:54 +08:00
parent 0d37a1c46e
commit 835514f927
6 changed files with 498 additions and 0 deletions
+1
View File
@@ -10,6 +10,7 @@
- 🔊 Piper 回退:手机无系统 TTS 时自动调用自建 Piper 服务并缓存音频 - 🔊 Piper 回退:手机无系统 TTS 时自动调用自建 Piper 服务并缓存音频
- 📖 单词本:自动提取输入中的关键词并给出解释,支持点击发音 - 📖 单词本:自动提取输入中的关键词并给出解释,支持点击发音
- 📕 专用单词本页面:支持搜索、分类筛选、添加、编辑、删除和三语朗读 - 📕 专用单词本页面:支持搜索、分类筛选、添加、编辑、删除和三语朗读
- ⌨️ 西班牙语打字练习:词汇/口语分类练习、中文对照、自动朗读、键盘提示
- 📚 语法知识库:按教材整理的 40 个语音和语法知识点 - 📚 语法知识库:按教材整理的 40 个语音和语法知识点
## 使用 ## 使用
+2
View File
@@ -18,6 +18,7 @@
<div class="mobile-menu-head">Mi Diario</div> <div class="mobile-menu-head">Mi Diario</div>
<button data-menu="diary">📋 日记</button> <button data-menu="diary">📋 日记</button>
<a data-menu="wordbook" href="wordbook.html">📖 单词本</a> <a data-menu="wordbook" href="wordbook.html">📖 单词本</a>
<a data-menu="typing" href="typing.html">⌨️ 打字练习</a>
<a data-menu="knowledge" href="knowledge.html">📚 语法速查</a> <a data-menu="knowledge" href="knowledge.html">📚 语法速查</a>
<button data-menu="stats">📊 我的统计</button> <button data-menu="stats">📊 我的统计</button>
<button data-menu="password">🔑 修改密码</button> <button data-menu="password">🔑 修改密码</button>
@@ -55,6 +56,7 @@
<div id="user-area"> <div id="user-area">
<span id="user-name">未登录</span> <span id="user-name">未登录</span>
<a id="wordbook-link" href="wordbook.html">单词本</a> <a id="wordbook-link" href="wordbook.html">单词本</a>
<a id="typing-link" href="typing.html">打字练习</a>
<button id="btn-settings" title="翻译设置">⚙️ 设置</button> <button id="btn-settings" title="翻译设置">⚙️ 设置</button>
<a id="admin-link" href="admin.html" style="display:none">用户管理</a> <a id="admin-link" href="admin.html" style="display:none">用户管理</a>
<button id="btn-change-password" style="display:none" title="修改密码">🔑 修改密码</button> <button id="btn-change-password" style="display:none" title="修改密码">🔑 修改密码</button>
+1
View File
File diff suppressed because one or more lines are too long
+214
View File
@@ -0,0 +1,214 @@
.type-wrap {
width: 100%;
max-width: 920px;
margin: 0 auto;
padding: 1rem;
}
.type-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.type-top h1 { font-size: 1.3rem; }
.type-top a {
color: var(--accent);
text-decoration: none;
font-size: 0.9rem;
white-space: nowrap;
}
.type-card {
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.25rem;
}
.mode-select {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.6rem;
margin-bottom: 1rem;
}
.mode-select button {
min-height: 54px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-size: 1rem;
cursor: pointer;
}
.mode-select button.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.setup-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.setup-grid label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.setup-grid select {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.55rem 0.6rem;
background: var(--bg);
color: var(--text);
font-size: 0.9rem;
outline: none;
}
.setup-grid select:focus { border-color: var(--accent); }
#btn-start {
width: 100%;
min-height: 48px;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
font-size: 1rem;
cursor: pointer;
}
#btn-start:hover { background: var(--accent-hover); }
.type-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.type-toolbar span { font-size: 0.85rem; color: var(--text-muted); }
.type-toolbar button {
background: none;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.35rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
color: var(--text-muted);
}
.progress-track {
height: 6px;
background: var(--bg);
border-radius: 999px;
overflow: hidden;
margin-bottom: 1rem;
}
.progress-fill {
height: 100%;
background: var(--accent);
width: 0%;
transition: width 0.2s ease;
}
#target-text {
font-family: var(--font-mono);
font-size: clamp(1.35rem, 4vw, 2rem);
line-height: 1.6;
min-height: 3.5rem;
padding: 0.8rem;
background: var(--bg);
border-radius: var(--radius);
border: 1px solid var(--border);
margin-bottom: 0.75rem;
overflow-wrap: anywhere;
}
#target-text .done { color: var(--success); }
#target-text .current {
color: var(--accent);
background: var(--highlight);
border-radius: 3px;
}
#chinese-text {
font-size: 1rem;
color: var(--text);
padding: 0.6rem 0.75rem;
background: var(--highlight);
border-radius: var(--radius);
margin-bottom: 0.75rem;
min-height: 2rem;
}
#typing-input {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem;
font-size: 1.05rem;
font-family: var(--font-mono);
background: var(--bg);
color: var(--text);
outline: none;
margin-bottom: 0.75rem;
}
#typing-input:focus { border-color: var(--accent); }
#next-key {
min-height: 1.6rem;
font-size: 0.9rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
#next-key b {
color: var(--accent);
font-family: var(--font-mono);
}
#keyboard {
display: grid;
gap: 0.35rem;
}
.kb-row {
display: flex;
justify-content: center;
gap: 0.35rem;
}
.kb-key {
min-width: 34px;
height: 40px;
padding: 0 0.4rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-bg);
color: var(--text);
font-size: 0.9rem;
font-family: var(--font-mono);
cursor: pointer;
}
.kb-key.space { flex: 1; max-width: 220px; }
.kb-key.hint {
border-color: var(--accent);
background: var(--accent);
color: #fff;
font-weight: 700;
}
.finish-box {
text-align: center;
padding: 2rem 0;
}
.finish-box h2 { margin-bottom: 0.75rem; }
.finish-box p { color: var(--text-muted); margin-bottom: 1rem; }
.finish-box button {
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 0.6rem 1.2rem;
font-size: 0.95rem;
cursor: pointer;
}
@media (max-width: 640px) {
.kb-key {
min-width: 28px;
height: 38px;
font-size: 0.8rem;
padding: 0 0.25rem;
}
.kb-row { gap: 0.25rem; }
}
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>打字练习 — Mi Diario</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="typing.css">
</head>
<body>
<div class="type-wrap">
<div class="type-top">
<h1>⌨️ 西班牙语打字练习</h1>
<a href="index.html">← 返回日记</a>
</div>
<div id="setup-view" class="type-card">
<div class="mode-select">
<button data-mode="vocab" class="active">📖 基础词汇</button>
<button data-mode="phrases">💬 日常口语</button>
</div>
<div class="setup-grid">
<label>分类
<select id="category-select"></select>
</label>
<label>题量
<select id="count-select">
<option value="10">10 个</option>
<option value="20">20 个</option>
<option value="50">50 个</option>
<option value="all">全部</option>
</select>
</label>
<label>顺序
<select id="order-select">
<option value="random">随机</option>
<option value="order">按顺序</option>
</select>
</label>
</div>
<button id="btn-start">开始打字</button>
</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>
<div class="progress-track"><div id="progress-fill" class="progress-fill"></div></div>
<div id="target-text"></div>
<div id="chinese-text"></div>
<input id="typing-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="">
<div id="next-key"></div>
<div id="keyboard"></div>
</div>
</div>
<script src="typing-data.js"></script>
<script src="speech.js"></script>
<script src="typing.js"></script>
</body>
</html>
+217
View File
@@ -0,0 +1,217 @@
var typingMode = 'vocab';
var typingItems = [];
var typingIndex = 0;
var typingStats = { correct: 0, errors: 0 };
var typed = '';
var KEY_ROWS = [
['1','2','3','4','5','6','7','8','9','0'],
['q','w','e','r','t','y','u','i','o','p'],
['a','s','d','f','g','h','j','k','l','ñ'],
['z','x','c','v','b','n','m',',','.',';'],
['á','é','í','ó','ú','ü','¿','¡',':','?','!',' ']
];
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 = '';
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;
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];
document.getElementById('next-key').innerHTML = typed.length >= target.length
? '已完成'
: '下一键:<b>' + escTyping(next) + '</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 renderKeyboard(target) {
var next = target[typed.length] || '';
var container = document.getElementById('keyboard');
container.innerHTML = KEY_ROWS.map(function(row) {
return '<div class="kb-row">' + row.map(function(key) {
var label = key === ' ' ? '␣' : key;
var isHint = next && (
key === next ||
key.toLowerCase() === next.toLowerCase() ||
(next === ' ' && key === ' ')
);
return '<button type="button" class="kb-key' + (isHint ? ' hint' : '') + (key === ' ' ? ' space' : '') + '" data-key="' + escTyping(key) + '">' + escTyping(label) + '</button>';
}).join('') + '</div>';
}).join('');
}
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);
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;
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() {
document.getElementById('typing-input').style.display = '';
document.getElementById('setup-view').style.display = 'block';
document.getElementById('practice-view').style.display = 'none';
});
}
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('typing-input').addEventListener('input', handleTypingInput);
document.getElementById('keyboard').addEventListener('click', function(e) {
var btn = e.target.closest('.kb-key');
if (!btn) return;
var input = document.getElementById('typing-input');
var key = btn.dataset.key;
var next = typingItems[typingIndex] && typingItems[typingIndex].es[typed.length];
if (next && key.toLowerCase() === next.toLowerCase() && key !== next) {
key = next;
}
input.value += key;
handleTypingInput();
input.focus();
});
renderCategories();