请编写一个单文件 HTML 的沉浸式语言
2026-01-08
请编写一个单文件 HTML 的沉浸式语言学习应用(Flashcards)。默认设置为日语平假名/片假名或英语高频词汇学习。卡片正面显示单词,点击翻转显示发音(调用浏览器 TTS)和例句。核心功能包含基于“艾宾浩斯遗忘曲线”的复习算法模拟,用户标记“认识/模糊/不认识”后,系统自动安排下次出现的时间。界面需带有温和的 gamification 元素(如连续打卡火焰特效、进度环)。风格采用温馨的插画风,色彩柔和护眼。
思考过程
**Designing a Flashcard App**
I'm looking to create a single HTML file for a flashcard app focused on immersive language learning, featuring Japanese Hiragana/Katakana and high-frequency English words. The front shows the word, and clicking flips it to display pronunciation and an example sentence, using TTS. I want to implement spaced repetition based on Ebbinghaus’ forgetting curve. Users will mark their knowledge levels, and I'll gamify the UI with elements like a streak flame and progress ring, all in warm colors.
**Simulating Learning with Flashcards**
I'm planning to simulate the spaced repetition curve, adjusting time intervals for each knowledge level: "know" gets an increase of 60 seconds, "fuzzy" 20 seconds, and "don't know" 5 seconds. I'll implement Ebbinghaus-like timing for exposures as well. For the UI, I'll use an SVG circle for the progress ring and a small flame icon for the streak. TTS will work through speech synthesis based on the language. The dataset will include words in Japanese Hiragana/Katakana and English, and I might allow toggling decks for simplicity!
**Creating a Minimal Dataset**
I'm planning to create a minimal dataset with 10 Japanese kana and 10 English words. For spaced repetition, I'll use the Ebbinghaus forgetting curve with timed intervals: [5, 25, 120, 600, 1800] seconds. Each card will have a level index. If the user marks "know," the level will increase but be capped, scheduling based on the forgetting curve for that level. For "fuzzy," the schedule will use half the time, and for "don't know," the level resets to 0 with a shorter schedule. I'll also implement a progress ring based on the number of reviewed cards.
```html
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>沉浸式闪卡 · 语言学习</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root {
--bg: #f7f3ec;
--card: #fffaf2;
--primary: #7aa6ff;
--accent: #ffb6b9;
--text: #4a4a4a;
--muted: #888;
}
* {box-sizing: border-box;}
body {
margin:0;
font-family: "Noto Sans SC","Hiragino Sans","Segoe UI",sans-serif;
background: linear-gradient(180deg, #fdfaf5 0%, #f4f1ea 100%);
color: var(--text);
display:flex;
justify-content:center;
}
.app {
width: 100%;
max-width: 720px;
min-height: 100vh;
padding: 16px;
position:relative;
}
.header {
display:flex;
align-items:center;
justify-content:space-between;
margin-bottom: 16px;
}
.streak {
display:flex;
align-items:center;
gap:6px;
background: rgba(255,182,185,0.15);
padding:8px 12px;
border-radius: 12px;
box-shadow: 0 6px 18px rgba(0,0,0,0.05);
}
.flame {
width: 22px; height: 22px;
background: radial-gradient(circle at 30% 30%, #ffd56b 40%, #ff8b5f 70%, #ff5e62 100%);
border-radius: 50% 50% 45% 55%;
position: relative;
animation: flicker 1s infinite ease-in-out;
}
.flame:after {
content:"";
position:absolute;
top: -6px; left: 5px;
width: 14px; height: 14px;
background: radial-gradient(circle at 30% 30%, #fff1a8 30%, #ff9f68 100%);
border-radius: 50% 50% 45% 55%;
opacity: .9;
}
@keyframes flicker {
0% { transform: translateY(0) scale(1);}
50% { transform: translateY(-1px) scale(1.03);}
100% { transform: translateY(0) scale(1);}
}
.progress-ring {
position:relative;
width: 70px; height: 70px;
}
.progress-ring svg {transform: rotate(-90deg);}
.progress-ring circle {
fill: none;
stroke-width: 8;
stroke-linecap: round;
}
.progress-ring .bg {stroke: #e3dfd7;}
.progress-ring .fg {stroke: url(#grad);}
.progress-ring .text {
position:absolute;
inset: 0;
display:flex;
align-items:center;
justify-content:center;
font-weight:600;
}
.card-container {
perspective: 1000px;
margin: 12px 0 20px;
}
.card {
width:100%;
min-height:240px;
background: var(--card);
border-radius: 18px;
box-shadow: 0 12px 30px rgba(0,0,0,0.08);
position: relative;
transition: transform .6s;
transform-style: preserve-3d;
cursor: pointer;
}
.card.flipped {
transform: rotateY(180deg);
}
.side {
position:absolute;
inset:0;
padding: 20px;
display:flex;
flex-direction:column;
justify-content:center;
align-items:center;
backface-visibility: hidden;
border-radius: 18px;
background: var(--card);
}
.back {
transform: rotateY(180deg);
}
.word {
font-size: 36px;
font-weight: 700;
margin-bottom: 8px;
}
.pron {
font-size: 18px;
color: var(--muted);
margin-bottom: 12px;
}
.example {
font-size: 17px;
text-align:center;
line-height: 1.5;
}
.buttons {
display:flex;
gap: 10px;
margin-top: 16px;
justify-content:center;
}
.btn {
border: none;
padding: 12px 16px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 16px rgba(0,0,0,0.06);
transition: transform .1s, box-shadow .2s;
}
.btn:active {transform: translateY(1px);}
.btn.good {background: #c8f7c5; color:#2f8f2f;}
.btn.ok {background: #ffe9b3; color:#b36b00;}
.btn.bad {background: #ffd6d6; color:#c72626;}
.footer {
display:flex;
align-items:center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
color: var(--muted);
font-size: 14px;
}
.tag {
padding: 6px 10px;
border-radius: 999px;
background: rgba(122,166,255,0.15);
color: #5a7ee3;
font-weight:600;
}
.deck-switch {
display:flex;
gap: 10px;
margin: 10px 0;
}
.deck-btn {
border: none;
background: #eef2ff;
color:#4d65c1;
padding: 10px 14px;
border-radius: 10px;
cursor: pointer;
font-weight:600;
}
.deck-btn.active {
background: #4d65c1;
color:#fff;
}
.info {
display:flex;
gap: 12px;
align-items:center;
}
.soft-illus {
position:absolute;
right:-20px;
top:140px;
width: 120px;
height: 120px;
opacity: .25;
background:
radial-gradient(circle at 30% 30%, #ffc9c9 0 30px, transparent 32px),
radial-gradient(circle at 70% 60%, #c9e5ff 0 30px, transparent 32px);
filter: blur(0.5px);
}
</style>
</head>
<body>
<div class="app">
<div class="header">
<div class="streak">
<div class="flame"></div>
<div>
<div style="font-weight:700;">连续打卡</div>
<div id="streakDays" style="font-size:13px;color:#888;">0 天</div>
</div>
</div>
<div class="progress-ring">
<svg width="70" height="70">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#7aa6ff"/>
<stop offset="100%" stop-color="#ffb6b9"/>
</linearGradient>
</defs>
<circle class="bg" cx="35" cy="35" r="28"></circle>
<circle class="fg" cx="35" cy="35" r="28"
stroke-dasharray="176" stroke-dashoffset="176"></circle>
</svg>
<div class="text" id="progressText">0%</div>
</div>
</div>
<div class="deck-switch">
<button class="deck-btn active" data-deck="jp">日语假名/基础</button>
<button class="deck-btn" data-deck="en">英语高频词</button>
</div>
<div class="card-container">
<div class="card" id="card">
<div class="side front">
<div class="word" id="frontWord">こんにちは</div>
<div class="pron" id="frontPron">点击翻转查看释义</div>
</div>
<div class="side back">
<div class="word" id="backWord">こんにちは</div>
<div class="pron" id="backPron">[ko-n-ni-chi-wa]</div>
<div class="example" id="example">こんにちは!お元気ですか?</div>
</div>
</div>
</div>
<div class="buttons">
<button class="btn bad" onclick="mark('again')">不认识</button>
<button class="btn ok" onclick="mark('hard')">模糊</button>
<button class="btn good" onclick="mark('good')">认识</button>
</div>
<div class="footer">
<div class="info">
<span class="tag" id="deckLabel">日语 · 沉浸</span>
<span id="nextInfo">下次复习:--</span>
</div>
<div id="status">加载中…</div>
</div>
<div class="soft-illus"></div>
</div>
<script>
const forgettingCurve = [5, 25, 120, 600, 1800]; // seconds
const data = {
jp: [
{word:'あ', pron:'a', example:'あめ (雨) がふっています。', lang:'ja-JP'},
{word:'い', pron:'i', example:'いぬ (犬) がかわいい。', lang:'ja-JP'},
{word:'う', pron:'u', example:'うみ (海) はひろい。', lang:'ja-JP'},
{word:'え', pron:'e', example:'えんぴつ (鉛筆) をください。', lang:'ja-JP'},
{word:'お', pron:'o', example:'おちゃ (お茶) をのみます。', lang:'ja-JP'},
{word:'か', pron:'ka', example:'かさ (傘) をもっています。', lang:'ja-JP'},
{word:'き', pron:'ki', example:'き (木) がたくさんあります。', lang:'ja-JP'},
{word:'さようなら', pron:'sayōnara', example:'さようなら、またあした。', lang:'ja-JP'},
{word:'ありがとう', pron:'arigatō', example:'ありがとう!たすかりました。', lang:'ja-JP'},
{word:'おはよう', pron:'ohayō', example:'おはようございます!', lang:'ja-JP'},
],
en: [
{word:'the', pron:'ðə', example:'The cat is on the mat.', lang:'en-US'},
{word:'be', pron:'bi', example:'I want to be a teacher.', lang:'en-US'},
{word:'and', pron:'ænd', example:'Tom and Jerry are friends.', lang:'en-US'},
{word:'of', pron:'ʌv', example:'A cup of coffee.', lang:'en-US'},
{word:'to', pron:'tuː', example:'Nice to meet you.', lang:'en-US'},
{word:'in', pron:'ɪn', example:'There is water in the glass.', lang:'en-US'},
{word:'that', pron:'ðæt', example:'That is my bag.', lang:'en-US'},
{word:'it', pron:'ɪt', example:'It is raining.', lang:'en-US'},
{word:'was', pron:'wʌz', example:'It was sunny yesterday.', lang:'en-US'},
{word:'for', pron:'fɔːr', example:'A gift for you.', lang:'en-US'},
]
};
let deck = 'jp';
let queue = [];
let current = null;
let reviewed = 0;
let goal = 15;
let streak = 0;
function initDeck(d) {
deck = d;
queue = data[deck].map((item, idx)=>({
...item,
id: `${deck}-${idx}`,
level: 0,
due: Date.now() + 1000
}));
reviewed = 0;
updateProgress();
document.getElementById('deckLabel').innerText = d==='jp'?'日语 · 沉浸':'英语 · 高频';
document.getElementById('status').innerText = `共 ${queue.length} 张卡片`;
nextCard();
}
function nextCard() {
const now = Date.now();
queue.sort((a,b)=>a.due - b.due);
current = queue.find(item => item.due <= now) || queue[0];
if (!current) return;
renderCard(current);
const inSec = Math.max(0, Math.round((current.due - now)/1000));
document.getElementById('nextInfo').innerText = `下次复习:${inSec}s 后`;
}
function renderCard(card) {
const c = document.getElementById('card');
c.classList.remove('flipped');
document.getElementById('frontWord').innerText = card.word;
document.getElementById('frontPron').innerText = '点击翻转查看释义';
document.getElementById('backWord').innerText = card.word;
document.getElementById('backPron').innerText = `[${card.pron}]`;
document.getElementById('example').innerText = card.example;
}
function speakCard(card) {
if (!('speechSynthesis' in window)) return;
const u = new SpeechSynthesisUtterance(card.word);
u.lang = card.lang;
speechSynthesis.speak(u);
}
document.getElementById('card').addEventListener('click', ()=>{
document.getElementById('card').classList.toggle('flipped');
if (document.getElementById('card').classList.contains('flipped')) {
speakCard(current);
}
});
function mark(grade) {
if (!current) return;
const idx = queue.findIndex(x=>x.id === current.id);
let level = current.level;
if (grade === 'good') {
level = Math.min(level+1, forgettingCurve.length-1);
reviewed++;
} else if (grade === 'hard') {
level = Math.max(level, 1);
} else {
level = 0;
}
const interval = grade === 'good' ? forgettingCurve[level]
: grade === 'hard' ? Math.max(8, forgettingCurve[level]*0.4)
: 5;
queue[idx].level = level;
queue[idx].due = Date.now() + interval*1000;
updateProgress();
nextCard();
}
function updateProgress() {
const progress = Math.min(1, reviewed/goal);
const circumference = 2*Math.PI*28;
const offset = circumference * (1-progress);
document.querySelector('.fg').style.strokeDasharray = circumference.toFixed(0);
document.querySelector('.fg').style.strokeDashoffset = offset.toFixed(0);
document.getElementById('progressText').innerText = `${Math.round(progress*100)}%`;
}
function loadStreak() {
const last = localStorage.getItem('lastVisit');
const today = new Date().toDateString();
if (last !== today) {
const prevStreak = Number(localStorage.getItem('streak')||0);
const yesterday = new Date(Date.now()-86400000).toDateString();
streak = (last === yesterday) ? prevStreak + 1 : 1;
localStorage.setItem('streak', streak);
localStorage.setItem('lastVisit', today);
} else {
streak = Number(localStorage.getItem('streak')||1);
}
document.getElementById('streakDays').innerText = `${streak} 天`;
}
document.querySelectorAll('.deck-btn').forEach(btn=>{
btn.addEventListener('click', ()=>{
document.querySelectorAll('.deck-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
initDeck(btn.dataset.deck);
});
});
loadStreak();
initDeck('jp');
</script>
</body>
</html>
```