前言
大家好,我是奈德丽。
今天女朋友突然跟我说:"我下周就要法语考试了,但是完全不知道该怎么复习,冠词和过去时复合搞得我头都大了..." 女朋友让我帮她背法语,这我一个工科男怎么会法语啊,太高估我了吧,但记住咱是程序员呀,秉着程序为人而服务的理念,我想着能不能给她做个小工具,帮她来复习呢,现在Ai这么强大,刚好年费的Cursor不能闲着,让它来帮我辅助开发了一款工具,让女朋友学起来就跟玩游戏一样,虽然会有点耗费时间,但是最终也是如愿以偿,得到了不错的效果。
先来看下受到女朋友连连好评的程序长什么样吧!

那就简单做一下需求分析吧
女朋友向我抱怨了一堆话,总结起来就是:
- 语法规则记不住 - 法语冠词有定冠词、不定冠词、部分冠词,各种变化规则
- 动词变位太复杂 - 不规则动词的过去分词总是记混
- 缺乏练习工具 - 课本上的练习题做完就没了,想多练都没地方
- 学习效率低 - 翻书查资料浪费时间,没有系统性
哪有那么难,说到底还是上课没好好听! [狗头保命 !!]
那么怎么去实现这样的工具呢?我把它分成了五步
第一步:语法知识库 - 把复杂的规则可视化
初版实现:静态页面
我先从最基础的开始,把她需要的语法知识整理成一个结构化的页面:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>法语语法总结</title>
</head>
<body>
<div class="container">
<h1>🇫🇷 法语语法总结</h1>
<!-- 冠词部分 -->
<section class="grammar-section">
<h2>冠词 (Les Articles)</h2>
<div class="rule-card">
<h3>定冠词</h3>
<div class="examples">
<span class="article masculine">le</span> livre (阳性单数)
<span class="article feminine">la</span> table (阴性单数)
<span class="article plural">les</span> livres (复数)
</div>
</div>
</section>
<!-- 过去时复合部分 -->
<section class="grammar-section">
<h2>过去时复合 (Le Passé Composé)</h2>
<div class="rule-card">
<h3>构成公式</h3>
<div class="formula">
助动词(avoir/être) + 过去分词
</div>
</div>
</section>
</div>
</body>
</html>
配上一些基础样式:
css
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.rule-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 15px;
padding: 25px;
margin-bottom: 20px;
color: white;
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
}
.rule-card:hover {
transform: translateY(-5px);
}
.article {
padding: 4px 12px;
border-radius: 20px;
font-weight: bold;
margin: 0 5px;
}
.masculine { background: #3498db; }
.feminine { background: #e74c3c; }
.plural { background: #f39c12; }
女朋友看了第一版说:"哇,这个比课本清楚多了!但是能不能加点练习?光看不练还是记不住。"
好吧,需求升级了。
第二步:动词卡片系统 - 让学习变成游戏
核心数据结构设计
既然要做练习,那就得先把数据结构设计好:
javascript
// 不规则动词数据
const irregularVerbs = [
{
infinitive: 'avoir',
participle: 'eu',
example: "J'ai eu de la chance",
difficulty: 'easy',
pronunciation: '[a.vwaʁ]'
},
{
infinitive: 'être',
participle: 'été',
example: "Il a été malade",
difficulty: 'easy',
pronunciation: '[ɛtʁ]'
},
// ... 更多动词
];
// être动词(需要用être作助动词的动词)
const etreVerbs = [
{
infinitive: 'aller',
participle: 'allé(e)',
example: "Je suis allé(e) au cinéma",
difficulty: 'easy'
},
// ... 更多动词
];
3D翻转卡片实现
这里是最有趣的部分,我想做一个可以翻转的卡片,正面显示动词原形,背面显示过去分词和例句:
css
.card {
width: 400px;
height: 250px;
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s;
cursor: pointer;
}
.card.flipped {
transform: rotateY(180deg);
}
.card-face {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.card-front {
background: linear-gradient(135deg, #74b9ff, #0984e3);
color: white;
}
.card-back {
background: linear-gradient(135deg, #00b894, #00a085);
color: white;
transform: rotateY(180deg);
}
JavaScript控制逻辑:
javascript
class VerbCardManager {
constructor() {
this.currentMode = 'irregular';
this.currentCards = irregularVerbs;
this.currentIndex = 0;
this.isFlipped = false;
this.stats = {
correct: 0,
total: 0
};
}
// 翻转卡片
flipCard() {
const card = document.getElementById('flashcard');
card.classList.toggle('flipped');
this.isFlipped = !this.isFlipped;
}
// 更新卡片内容
updateCard() {
const card = this.currentCards[this.currentIndex];
document.getElementById('verbInfinitive').textContent = card.infinitive;
document.getElementById('pastParticiple').textContent = card.participle;
document.getElementById('example').textContent = card.example;
// 重置翻转状态
document.getElementById('flashcard').classList.remove('flipped');
this.isFlipped = false;
this.updateStats();
this.updateProgress();
}
// 下一张卡片
nextCard() {
if (this.currentIndex < this.currentCards.length - 1) {
this.currentIndex++;
this.updateCard();
}
}
// 上一张卡片
previousCard() {
if (this.currentIndex > 0) {
this.currentIndex--;
this.updateCard();
}
}
}
女朋友试用后兴奋地说:"这个翻转效果太酷了!但是我想测试一下自己到底记住了多少,能不能加个测试模式?"
需求又升级了...
第三步:智能测试系统 - 四选一选择题
测试题生成算法
为了让测试更有挑战性,我设计了一个干扰项生成算法:
javascript
function generateQuizOptions() {
const currentCard = currentCards[currentIndex];
const correctAnswer = currentCard.participle;
// 从所有动词中筛选出错误选项
const allParticiples = currentCards.map(card => card.participle);
const wrongAnswers = allParticiples.filter(p => p !== correctAnswer);
// 随机选择3个错误答案
const shuffledWrong = wrongAnswers.sort(() => 0.5 - Math.random()).slice(0, 3);
const options = [correctAnswer, ...shuffledWrong].sort(() => 0.5 - Math.random());
// 更新UI
document.getElementById('verbInfinitive').textContent = currentCard.infinitive;
document.getElementById('cardInfo').textContent = `${currentCard.infinitive} 的过去分词是?`;
// 生成选项HTML
const optionsContainer = document.getElementById('quizOptions');
optionsContainer.innerHTML = '';
options.forEach(option => {
const optionElement = document.createElement('div');
optionElement.className = 'option';
optionElement.textContent = option;
optionElement.onclick = () => selectOption(option, correctAnswer, optionElement);
optionsContainer.appendChild(optionElement);
});
}
function selectOption(selected, correct, element) {
totalAttempts++;
// 禁用所有选项
const allOptions = document.querySelectorAll('.option');
allOptions.forEach(opt => opt.style.pointerEvents = 'none');
if (selected === correct) {
element.classList.add('correct');
correctAnswers++;
} else {
element.classList.add('incorrect');
// 高亮正确答案
allOptions.forEach(opt => {
if (opt.textContent === correct) {
opt.classList.add('correct');
}
});
}
updateStats();
// 3秒后自动下一题
setTimeout(() => {
if (currentIndex < currentCards.length - 1) {
nextCard();
} else {
showTestResult();
}
}, 3000);
}
选项样式:
css
.options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
max-width: 500px;
margin: 20px auto;
}
.option {
padding: 15px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 10px;
background: rgba(255,255,255,0.1);
color: white;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
font-weight: bold;
}
.option:hover {
background: rgba(255,255,255,0.2);
border-color: rgba(255,255,255,0.6);
}
.option.correct {
background: #00b894;
border-color: #00b894;
animation: correctPulse 0.5s ease;
}
.option.incorrect {
background: #e17055;
border-color: #e17055;
animation: shake 0.5s ease;
}
@keyframes correctPulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
女朋友做了几道题后说:"这个测试很有意思!但是我发现冠词的练习还没有,能不能也加上?"
好家伙,需求还在继续...
第四步:冠词填空练习 - 语法规则的实战应用
练习数据设计
冠词练习比动词练习复杂一些,因为需要考虑语境,当然这部分大多是借助的Ai,因为我不太懂这些专业词汇
javascript
const articleExercises = [
{
before: "Je vais à",
after: "cinéma ce soir.",
correct: "au",
explanation: "au = à + le (缩合形式,阳性单数)",
translation: "我今晚去电影院。",
difficulty: 'easy'
},
{
before: "Elle parle de",
after: "enfants dans le parc.",
correct: "des",
explanation: "des = de + les (缩合形式,复数)",
translation: "她谈论公园里的孩子们。",
difficulty: 'easy'
},
{
before: "Il boit",
after: "café tous les matins.",
correct: "du",
explanation: "du (部分冠词,阳性,不可数名词)",
translation: "他每天早上都喝咖啡。",
difficulty: 'medium'
},
// ... 更多练习
];
交互式填空实现
html
<div class="article-question">
<h3>请选择正确的冠词</h3>
<div class="sentence-container">
<span id="sentenceBefore"></span>
<select id="articleSelect" onchange="checkArticle()">
<option value="">选择冠词</option>
<option value="le">le</option>
<option value="la">la</option>
<option value="les">les</option>
<option value="l'">l'</option>
<option value="un">un</option>
<option value="une">une</option>
<option value="des">des</option>
<option value="du">du</option>
<option value="de la">de la</option>
<option value="de l'">de l'</option>
<option value="au">au</option>
<option value="aux">aux</option>
</select>
<span id="sentenceAfter"></span>
</div>
<div class="article-feedback" id="articleFeedback"></div>
<div class="article-translation" id="articleTranslation"></div>
<div class="article-explanation" id="articleExplanation"></div>
</div>
检查答案的逻辑:
javascript
function checkArticle() {
const selectedArticle = document.getElementById('articleSelect').value;
const exercise = currentCards[currentIndex];
const feedback = document.getElementById('articleFeedback');
const explanation = document.getElementById('articleExplanation');
if (!selectedArticle) return;
totalAttempts++;
if (selectedArticle === exercise.correct) {
feedback.textContent = "✅ 正确!";
feedback.className = "article-feedback correct";
correctAnswers++;
} else {
feedback.textContent = `❌ 错误!正确答案是: ${exercise.correct}`;
feedback.className = "article-feedback incorrect";
}
// 显示解释和翻译
explanation.textContent = exercise.explanation;
explanation.classList.add('show');
const translation = document.getElementById('articleTranslation');
translation.textContent = `中文:${exercise.translation}`;
updateStats();
// 3秒后自动下一题
setTimeout(() => {
if (currentIndex < currentCards.length - 1) {
nextCard();
} else {
showCompletionMessage();
}
}, 3000);
}
女朋友练习了一会儿说:"太棒了,你这个程序里面有很多刚好也是我老师之前讲到的内容,有一个程序员男朋友太棒了!"
HAH, 受了一番表扬,我心里很舒服,特别开心,都要成翘嘴了,这谁听了谁不迷糊啊,于是我又主动给她优化了一下这个工具,增加了朗读功能
第五步:语音朗读功能 - 让学习更生动
Web Speech API的使用
现代浏览器都支持Web Speech API,正好可以用来实现朗读功能:
javascript
// 语音相关变量
let speechSynthesis = window.speechSynthesis;
let voices = [];
let selectedVoice = null;
let speechRate = 0.8;
let isAutoSpeechEnabled = true; // 默认开启
let currentSpeech = null;
// 加载可用语音
function loadVoices() {
voices = speechSynthesis.getVoices();
const voiceSelect = document.getElementById('voiceSelect');
voiceSelect.innerHTML = '<option value="">选择语音</option>';
// 优先显示法语语音
const frenchVoices = voices.filter(voice =>
voice.lang.startsWith('fr') ||
voice.name.toLowerCase().includes('french') ||
voice.name.toLowerCase().includes('français')
);
frenchVoices.forEach((voice, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `${voice.name} (${voice.lang})`;
voiceSelect.appendChild(option);
});
// 默认选择第一个法语语音
if (frenchVoices.length > 0) {
voices = frenchVoices;
selectedVoice = voices[0];
voiceSelect.value = 0;
}
}
// 朗读文本
function speakText(event, side) {
event.stopPropagation();
if (!selectedVoice) {
alert('请先选择一个语音!');
return;
}
// 停止当前播放
if (currentSpeech) {
speechSynthesis.cancel();
}
let textToSpeak = '';
const currentCard = currentCards[currentIndex];
if (currentMode === 'articles') {
// 冠词模式:朗读完整句子
const exercise = currentCards[currentIndex];
const selectedArticle = document.getElementById('articleSelect').value || '[冠词]';
textToSpeak = `${exercise.before} ${selectedArticle} ${exercise.after}`;
} else {
// 动词模式
if (side === 'front') {
textToSpeak = currentCard.infinitive;
} else {
textToSpeak = `${currentCard.participle}. ${currentCard.example}`;
}
}
currentSpeech = new SpeechSynthesisUtterance(textToSpeak);
currentSpeech.voice = selectedVoice;
currentSpeech.rate = speechRate;
currentSpeech.lang = 'fr-FR';
// 添加视觉反馈
const speechBtn = event.target;
speechBtn.classList.add('speaking');
currentSpeech.onend = () => {
speechBtn.classList.remove('speaking');
currentSpeech = null;
};
speechSynthesis.speak(currentSpeech);
}
结语
这个法语学习工具的开发过程,让我重新思考了技术的意义。 为什么我会想到用程序去解决女朋友的烦恼,第一点当然是我不会法语,哈哈,第二点是我可能具备一些将需求抽象化的思想,(撒一波狗粮)当然最主要的还是我想让她开心一些,不要有那么多烦恼,不就一门考试嘛,就算没过的话可以补考。
技术不是为了炫技,而是为了解决实际问题。
对象已经拿着我写工具在给好闺蜜炫耀了,我的脸上也是有了藏不住的笑。我想说一下,作为程序员,我们有能力用代码改变身边人的生活,哪怕只是一个小小的学习工具,也能带来实实在在的帮助。
有对象没对象的朋友们,都快来开动自己的脑筋,给Ta去写一个小工具,说不定你也一样可以俘获另一半的心。
奥利给!
恩恩......女朋友驱动开发,真香!