feat: 新增西班牙语词汇与口语打字练习页面
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
- 🔊 Piper 回退:手机无系统 TTS 时自动调用自建 Piper 服务并缓存音频
|
||||
- 📖 单词本:自动提取输入中的关键词并给出解释,支持点击发音
|
||||
- 📕 专用单词本页面:支持搜索、分类筛选、添加、编辑、删除和三语朗读
|
||||
- ⌨️ 西班牙语打字练习:词汇/口语分类练习、中文对照、自动朗读、键盘提示
|
||||
- 📚 语法知识库:按教材整理的 40 个语音和语法知识点
|
||||
|
||||
## 使用
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<div class="mobile-menu-head">Mi Diario</div>
|
||||
<button data-menu="diary">📋 日记</button>
|
||||
<a data-menu="wordbook" href="wordbook.html">📖 单词本</a>
|
||||
<a data-menu="typing" href="typing.html">⌨️ 打字练习</a>
|
||||
<a data-menu="knowledge" href="knowledge.html">📚 语法速查</a>
|
||||
<button data-menu="stats">📊 我的统计</button>
|
||||
<button data-menu="password">🔑 修改密码</button>
|
||||
@@ -55,6 +56,7 @@
|
||||
<div id="user-area">
|
||||
<span id="user-name">未登录</span>
|
||||
<a id="wordbook-link" href="wordbook.html">单词本</a>
|
||||
<a id="typing-link" href="typing.html">打字练习</a>
|
||||
<button id="btn-settings" title="翻译设置">⚙️ 设置</button>
|
||||
<a id="admin-link" href="admin.html" style="display:none">用户管理</a>
|
||||
<button id="btn-change-password" style="display:none" title="修改密码">🔑 修改密码</button>
|
||||
|
||||
File diff suppressed because one or more lines are too long
+214
@@ -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
@@ -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>
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user