本文基于 Qoder 编程工具,结合 React + TypeScript 技术栈,实现了一个面向幼儿园及小学低年级的汉字描红学习 Web 应用。项目以 HanziWriter 作为核心描红引擎,实现汉字笔顺校验、描红判错与动画演示;同时引入 cnchar 库补充汉字拼音、笔画数、笔画名称及常见组词等教学信息。

在功能设计上,系统支持汉字逐笔画演示、错误自动重试、描红成功反馈以及自定义汉字输入,整体交互过程更符合儿童认知与学习习惯。开发过程中,利用 Qoder 提升了组件封装、复杂交互逻辑实现及 TypeScript 类型约束的效率。

本文重点介绍了项目的技术选型、核心实现思路及关键代码实现方式,可为前端开发者在 汉字教育类 Web 应用、交互式书写练习系统 等场景下的开发提供参考。

效果如下:

在这里插入图片描述
测试地址
可以注册体验AI编程 https://qoder.com/referral?referral_code=uItp8xNSq1aVeHoMGcLvtLU9atHrK8LH

二、技术选型(基于 Qoder 开发)

本项目整体采用 Qoder 作为主要开发工具,结合成熟的前端技术栈,快速完成汉字描红教学应用的实现。

1️⃣ Qoder(核心开发环境)

Qoder 是一款面向程序员的 AI 编程工具,适合用于:

  • 项目整体架构设计
  • 复杂逻辑的代码生成与重构
  • 第三方库(如 HanziWriter、cnchar)的整合
  • TypeScript 类型推导与错误修复

在本项目中,Qoder 主要用于:

  • 快速生成 React + TypeScript 项目结构
  • 协助封装汉字数据处理逻辑
  • 优化描红判定、笔顺演示等复杂交互代码
  • 提升开发效率,减少重复性编码工作

实际开发过程中,Qoder 更像一个 “智能协作者”,而不是替代人工编程。


三、安装依赖

npm install hanzi-writer cnchar cnchar-draw cnchar-order cnchar-poly cnchar-words cnchar-voice

如果使用 Vite:

npm create vite@latest hanzi-trace -- --template react-ts
cd hanzi-trace
npm install
npm run dev

四、全局类型声明(TypeScript 必须)

由于 cncharconfetti 挂载在 window 上,需要声明全局类型:

declare global {
  interface Window {
    cnchar: any;
    confetti: any;
  }
}

五、汉字数据结构设计

interface Character {
  id: string;
  name: string;
  pinyin: string;
  strokeCount: number;
  strokeNames: string[];
  words: string[];
}

该结构用于 一次性承载一个汉字完整教学信息


六、使用 cnchar 获取汉字信息

1️⃣ 封装统一方法

const getCharacterInfo = (char: string): Character => {
  const cnchar = window.cnchar;

  let pinyin = char;
  let strokeCount = 1;
  let strokeNames = ['未知'];
  let words: string[] = [];

  try { pinyin = cnchar.spell(char, 'tone'); } catch {}
  try { strokeCount = cnchar.stroke(char); } catch {}
  try { strokeNames = cnchar.order.strokeNames(char); } catch {}
  try { words = cnchar.words(char).slice(0, 8); } catch {}

  return {
    id: char,
    name: char,
    pinyin,
    strokeCount,
    strokeNames,
    words
  };
};

2️⃣ 获取的数据内容

  • 拼音:hǎo
  • 笔画数:6
  • 笔画顺序:横 → 竖 → 撇 …
  • 常见组词:好人 · 好看 · 好吃

七、描红核心:HanziWriter Quiz 模式

初始化描红区域

writer = HanziWriter.create(container, char, {
  width: 320,
  height: 320,
  showOutline: true,
  showCharacter: false,
  leniency: 0.8
});

开启描红判定

writer.quiz({
  onCorrectStroke(data) {
    if (data.strokesRemaining === 0) {
      // 写完一个字
    }
  },
  onMistake() {
    // 错误反馈
  }
});

八、错误 3 次自动重来(幼儿友好)

if (mistakeCount >= 3) {
  显示“写错了”
  1.2 秒后重置
}

✔ 不需要孩子点击
✔ 自动进入下一轮练习


九、逐步笔画演示(重点)

为什么不用内置动画?

  • 幼儿需要 “一步一步看清楚”
  • 每一画都要能单独展示

实现思路

  1. HanziWriter.loadCharacterData
  2. 获取 strokes(SVG Path)
  3. 每次 slice(0, i + 1)
  4. 渲染为独立 SVG

核心代码

const charData = await HanziWriter.loadCharacterData(char);

for (let i = 0; i < charData.strokes.length; i++) {
  const strokes = charData.strokes.slice(0, i + 1);
  renderFanningStrokes(wrapper, strokes);
}

SVG 渲染

const renderFanningStrokes = (target, strokes) => {
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');

  const transform = HanziWriter.getScalingTransform(75, 75);
  group.setAttribute('transform', transform.transform);

  strokes.forEach(d => {
    const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    path.setAttribute('d', d);
    path.style.fill = '#1e40af';
    group.appendChild(path);
  });

  svg.appendChild(group);
  target.appendChild(svg);
};

📌 这是目前最稳定、不会错位的逐步演示方案


十、带编号的笔画动画演示

writer.animateCharacter();

onLoadCharDataSuccess 中:

  • 读取 stroke.points[0]
  • 计算缩放比例
  • 在起笔位置显示 红色编号

非常适合教学展示。


十一、汉字发音(双重兜底)

try {
  cnchar.voice(char);
} catch {
  speechSynthesis.speak(new SpeechSynthesisUtterance(char));
}

✔ 优先 cnchar 真人语音
✔ 浏览器 SpeechSynthesis 兜底


十二、自定义汉字输入

<input maxLength="1" />

逻辑说明:

  • 输入一个字 → 单字练习模式
  • 清空输入 → 回到系统字库

十五、完整代码

import { useState, useRef, useEffect } from 'react';
import HanziWriter from 'hanzi-writer';
import 'cnchar';
import 'cnchar-draw';
import 'cnchar-order';
import 'cnchar-poly';
import 'cnchar-words';
import 'cnchar-voice';

declare global {
  interface Window {
    cnchar: any;
    confetti: any;
  }
}

interface Character {
  id: string;
  name: string;
  pinyin: string;
  strokeCount: number;
  strokeNames: string[];
  words: string[];
}

const defaultCharacterList = [
'一', '二', '三', '十', '口', '人', '入', '八', '九', '了',
'儿', '几', '刀', '力', '又', '工', '土', '才', '寸', '大',
'小', '山', '川', '千', '万', '上', '下', '中', '五', '六',
'七', '八', '九', '十', '厂', '广', '门', '丫', '丸', '飞',
'马', '女', '子', '弓', '己', '已', '卫', '也', '习', '乡',
'书', '买', '乱', '乳', '乾', '云', '互', '井', '天', '夫',
'太', '夫', '区', '历', '尤', '友', '匹', '车', '牙', '屯',
'戈', '比', '毛', '气', '升', '长', '仁', '什', '片', '仆',
'化', '仇', '今', '介', '父', '从', '今', '凶', '分', '乏',
'公', '仓', '月', '氏', '勿', '欠', '风', '丹', '匀', '乌',
'勾', '凤', '六', '文', '方', '火', '为', '斗', '忆', '计',
'订', '户', '认', '冗', '讥', '心', '尺', '引', '丑', '巴',
'孔', '队', '办', '以', '允', '予', '邓', '劝', '双', '书',
'幻', '玉', '刊', '未', '末', '示', '击', '打', '巧', '正',
'扑', '扒', '功', '扔', '去', '甘', '世', '艾', '古', '节',
'本', '术', '可', '丙', '左', '厉', '石', '右', '布', '龙',
'平', '灭', '轧', '东', '卡', '北', '占', '凸', '卢', '业',
'旧', '帅', '归', '旦', '目', '且', '叶', '甲', '申', '电',
'田', '由', '史', '只', '央', '兄', '叼', '叫', '叩', '叨',
'另', '叹', '冉', '皿', '凹', '四', '生', '失', '禾', '丘',
'付', '仗', '代', '仙', '们', '仪', '白', '仔', '他', '斥',
'瓜', '乎', '丛', '令', '用', '甩', '印', '乐', '句', '匆',
'册', '卯', '犯', '外', '处', '冬', '鸟', '务', '包', '饥',
'主', '市', '立', '玄', '闪', '兰', '半', '汁', '汇', '头',
'汉', '宁', '穴', '它', '讨', '写', '让', '礼', '训', '议',
'必', '讯', '记', '永', '司', '尼', '民', '弗', '弘', '出',
'辽', '奶', '奴', '加', '召', '皮', '边', '发', '圣', '对',
'台', '矛', '纠', '母', '幼', '丝', '邦', '式', '刑', '戎',
'动', '扛', '寺', '吉', '扣', '考', '托', '老', '巩', '执',
'扩', '扫', '地', '场', '扬', '耳', '芋', '共', '芒', '亚',
'芝', '朽', '朴', '机', '权', '过', '臣', '再', '协', '西',
'压', '厌', '在', '百', '有', '存', '而', '页', '匠', '夸',
'夺', '灰', '达', '列', '死', '成', '夹', '夷', '轨', '邪',
'尧', '划', '迈', '毕', '至', '此', '贞', '师', '尘', '尖',
'劣', '光', '当', '早', '吐', '吓', '虫', '曲', '团', '同',
'吊', '吃', '因', '吸', '吗', '吆', '屿', '屹', '岁', '帆',
'回', '岂', '则', '刚', '网', '肉', '年', '朱', '先', '丢',
'廷', '舌', '竹', '迁', '乔', '迄', '伟', '传', '乒', '乓',
'休', '伍', '伏', '优', '臼', '伐', '延', '仲', '件', '任',
'伤', '价', '份', '华', '仰', '仿', '伙', '伪', '自', '伊',
'血', '向', '似', '后', '行', '舟', '全', '会', '杀', '合',
'兆', '企', '众', '爷', '伞', '创', '肌', '肋', '朵', '杂',
'危', '旬', '旨', '旭', '负', '匈', '名', '各', '多', '争',
'色', '壮', '冲', '妆', '冰', '庄', '庆', '亦', '刘', '齐',
'交', '衣', '次', '产', '决', '充', '妄', '闭', '问', '闯',
'羊', '并', '关', '米', '灯', '州', '汗', '污', '江', '池',
'汤', '忙', '兴', '宇', '守', '宅', '字', '安', '讲', '讳',
'军', '许', '论', '农', '讽', '设', '访', '寻', '那', '迅',
'尽', '导', '异', '弛', '孙', '阵', '阳', '收', '阶', '阴',
'防', '奸', '如', '妇', '好', '她', '妈', '戏', '羽', '观',
'欢', '买', '红', '纤', '级', '约', '纪', '驰', '纫', '巡',
'寿', '弄', '麦', '玖', '玛', '形', '进', '戒', '吞', '远',
'违', '运', '扶', '抚', '坛', '技', '坏', '抠', '扼', '找',
'批', '址', '扯', '走', '抄', '贡', '汞', '坝', '攻', '赤',
'折', '抓', '扳', '抡', '扮', '抢', '孝', '坎', '均', '抑',
'抛', '投', '坟', '抗', '坑', '坊', '抖', '护', '壳', '志',
'扭', '块', '声', '把', '报', '拟', '却', '抒', '劫', '芙',
'芜', '苇', '芽', '花', '芹', '芥', '苍', '芳', '严', '芦',
'芯', '劳', '克', '芭', '苏', '杆', '杠', '杜', '材', '村',
'杖', '杏', '杉', '巫', '极', '李', '杨', '求', '甫', '匣',
'更', '束', '吾', '豆', '两', '酉', '丽', '医', '辰', '励',
'否', '还', '尬', '歼', '来', '连', '轩', '步', '卤', '坚',
'肖', '旱', '盯', '呈', '时', '吴', '助', '县', '里', '呆',
'吱', '吠', '呕', '园', '围', '呀', '吨', '足', '邮', '男',
'困', '吵', '串', '员', '呐', '听', '吟', '吩', '呛', '吻',
'吹', '吼', '吧', '邑', '吼', '囤', '别', '吮', '岖', '岗',
'帐', '财', '针', '钉', '牡', '告', '我', '乱', '利', '秃',
'秀', '私', '每', '兵', '估', '体', '何', '佐', '佑', '但',
'伸', '佃', '作', '伯', '伶', '佣', '低', '你', '住', '位',
'伴', '身', '皂', '伺', '佛', '囱', '近', '彻', '役', '返',
'余', '希', '坐', '谷', '妥', '含', '邻', '岔', '肝', '肚',
'肘', '肠', '龟', '免', '狂', '犹', '狈', '狐', '狗', '狞',
'状', '狈', '狙', '猪', '猫', '猛', '猜', '猪', '猎', '猫',
'凰', '狠', '率', '猴', '猩', '猾', '现', '琢', '班', '狸',
'球', '琅', '理', '琉', '琉', '琅', '斑', '琢', '斑', '理',
'琉', '琅', '斑', '琢', '班', '狸', '球', '琅', '理', '琉',
'琉', '琅', '斑', '琢', '班', '狸', '球', '琅', '理', '琉'
];

const getCharacterInfo = (char: string): Character => {
  if (!char || char.length !== 1) return { id: '', name: '', pinyin: '', strokeCount: 1, strokeNames: ['未知'], words: [] };

  const cnchar = (window as any).cnchar;
  if (!cnchar) return { id: char, name: char, pinyin: char, strokeCount: 1, strokeNames: ['未知'], words: [] };

  let pinyin = char;
  let strokeCount = 1;
  let strokeNames: string[] = ['未知'];
  let words: string[] = [];

  try { const spell = cnchar.spell(char, 'tone'); if (spell) pinyin = spell; } catch {}
  try { const count = cnchar.stroke(char); if (typeof count === 'number' && count > 0) strokeCount = count; } catch {}
  try { const names = cnchar.order.strokeNames(char); if (Array.isArray(names) && names.length > 0) strokeNames = names; } catch {}
  try { const wordList = cnchar.words(char); if (Array.isArray(wordList)) words = wordList.slice(0, 8); } catch {}

  return { id: char, name: char, pinyin, strokeCount, strokeNames, words };
};

// ====== 你提供的经典逐步演示函数(已转成 TS 并优化) ======
const renderFanningStrokes = (target: HTMLDivElement, strokes: string[]) => {
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  svg.style.width = '90px';
  svg.style.height = '90px';
  svg.style.border = '2px solid #e5e7eb';
  svg.style.borderRadius = '12px';
  svg.style.background = '#ffffff';
  svg.style.boxShadow = '0 4px 10px rgba(0,0,0,0.1)';
  svg.style.margin = '0 8px';
  target.appendChild(svg);

  const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
  const transformData = HanziWriter.getScalingTransform(75, 75);
  group.setAttributeNS(null, 'transform', transformData.transform);
  svg.appendChild(group);

  strokes.forEach(strokePath => {
    const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
    path.setAttributeNS(null, 'd', strokePath);
    path.style.fill = '#1e40af';
    group.appendChild(path);
  });
};

export default function App() {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [score, setScore] = useState(0);
  const [showSuccess, setShowSuccess] = useState(false);
  const [showMistake, setShowMistake] = useState(false);
  const [customChar, setCustomChar] = useState('');
  const [customInfo, setCustomInfo] = useState<Character | null>(null);

  const displayRef = useRef<HTMLDivElement>(null);
  const fanningRef = useRef<HTMLDivElement>(null);
  const writerRef = useRef<any>(null);
  const mistakeCountRef = useRef(0);

  const list = customInfo ? [customInfo] : defaultCharacterList.map(getCharacterInfo);
  const currentChar = customInfo || list[currentIndex];

  useEffect(() => {
    const script = document.createElement('script');
    script.src = 'https://cdnjs.cloudflare.com/ajax/libs/canvas-confetti/1.9.3/confetti.min.js';
    script.async = true;
    document.body.appendChild(script);
    
    return () => {
      document.body.removeChild(script);
    };
  }, []);

  const fireConfetti = () => {
    if (!window.confetti) return;
    window.confetti({
      particleCount: 150,
      spread: 70,
      origin: { y: 0.6 },
      zIndex: 9999
    });
  };

  // ====== 逐步笔画演示:使用你提供的可靠方式 ======
  const renderProgressiveStrokes = async () => {
    if (!fanningRef.current) return;
    fanningRef.current.innerHTML = '';

    const charData = await HanziWriter.loadCharacterData(currentChar.name);
    if (!charData || !charData.strokes) return;

    for (let i = 0; i < charData.strokes.length; i++) {
      const strokesPortion = charData.strokes.slice(0, i + 1);

      const wrapper = document.createElement('div');
      wrapper.className = 'flex flex-col items-center flex-shrink-0';

      renderFanningStrokes(wrapper, strokesPortion);

      const label = document.createElement('div');
      label.textContent = `${i + 1}`;
      label.className = 'mt-3 w-10 h-10 rounded-full bg-emerald-600 text-white flex items-center justify-center text-lg font-bold shadow';

      wrapper.appendChild(label);
      fanningRef.current.appendChild(wrapper);
    }
  };

  const destroyWriter = () => {
    if (writerRef.current) {
      writerRef.current.cancelQuiz?.();
      writerRef.current = null;
    }
    if (displayRef.current) displayRef.current.innerHTML = '';
  };

  const initQuiz = () => {
    if (!displayRef.current || !currentChar) return;
    destroyWriter();
    mistakeCountRef.current = 0;

    const size = Math.min(window.innerWidth - 40, 340);
    const inner = size - 40;

    writerRef.current = HanziWriter.create(displayRef.current, currentChar.name, {
      width: size,
      height: size,
      padding: 20,
      showOutline: true,
      outlineColor: '#e5e7eb',
      strokeColor: '#166534',
      showCharacter: false,
      drawingWidth: 50,
      showHintAfterMisses: 5,
      leniency: 0.8,
      highlightOnComplete: true,
      onLoadCharDataSuccess: () => {
        const grid = `
          <svg width="${size}" height="${size}" style="position:absolute;top:0;left:0;pointer-events:none;z-index:1;">
            <rect x="20" y="20" width="${inner}" height="${inner}" fill="none" stroke="#ccc" stroke-width="2"/>
            <line x1="${size/2}" y1="20" x2="${size/2}" y2="${size-20}" stroke="#eee" stroke-width="1.5"/>
            <line x1="20" y1="${size/2}" x2="${size-20}" y2="${size/2}" stroke="#eee" stroke-width="1.5"/>
          </svg>`;
        displayRef.current!.insertAdjacentHTML('afterbegin', grid);
      }
    });

    writerRef.current.quiz({
      onCorrectStroke: (data: any) => {
        mistakeCountRef.current = 0;
        if (data.strokesRemaining === 0) {
          setScore(s => s + 10);
          setShowSuccess(true);
          fireConfetti();
          setTimeout(() => {
            setShowSuccess(false);
            if (!customInfo && currentIndex < defaultCharacterList.length - 1) {
              setCurrentIndex(i => i + 1);
            }
          }, 2500);
        }
      },
      onMistake: () => {
        navigator.vibrate?.([150]);
        writerRef.current.hideIncorrectStroke?.();
        writerRef.current.flashStroke({ color: '#ef4444', duration: 600 });

        mistakeCountRef.current += 1;
        if (mistakeCountRef.current >= 3) {
          setShowMistake(true);
          setTimeout(() => {
            setShowMistake(false);
            initQuiz();
          }, 1200);
        }
      }
    });
  };

  const showAnimationWithNumbers = () => {
    destroyWriter();
    if (!displayRef.current || !currentChar) return;

    const size = Math.min(window.innerWidth - 40, 340);
    const inner = size - 40;

    writerRef.current = HanziWriter.create(displayRef.current, currentChar.name, {
      width: size,
      height: size,
      padding: 20,
      showOutline: true,
      outlineColor: '#e5e7eb',
      strokeColor: '#166534',
      showCharacter: false,
      drawingWidth: 45,
      onLoadCharDataSuccess: (data: any) => {
        const grid = `
          <svg width="${size}" height="${size}" style="position:absolute;top:0;left:0;pointer-events:none;z-index:1;">
            <rect x="20" y="20" width="${inner}" height="${inner}" fill="none" stroke="#ccc" stroke-width="2"/>
            <line x1="${size/2}" y1="20" x2="${size/2}" y2="${size-20}" stroke="#eee" stroke-width="1.5"/>
            <line x1="20" y1="${size/2}" x2="${size-20}" y2="${size/2}" stroke="#eee" stroke-width="1.5"/>
          </svg>`;
        displayRef.current!.insertAdjacentHTML('afterbegin', grid);

        data.strokes.forEach((stroke: any, i: number) => {
          if (stroke.points?.length > 0) {
            const start = stroke.points[0];
            const scale = inner / 1024;
            const x = 20 + (size - inner - 40) / 2 + start.x * scale;
            const y = 20 + (size - inner - 40) / 2 + start.y * scale - 25;

            const num = document.createElement('div');
            num.textContent = `${i + 1}`;
            num.className = 'absolute text-5xl font-black text-red-600 pointer-events-none z-10 drop-shadow-lg';
            num.style.left = `${x}px`;
            num.style.top = `${y}px`;
            displayRef.current!.appendChild(num);
          }
        });
      }
    });

    writerRef.current.animateCharacter();
  };

  const playVoice = () => {
    const cnchar = (window as any).cnchar;
    try {
      cnchar.voice(currentChar.name);
    } catch {
      speechSynthesis.speak(new SpeechSynthesisUtterance(currentChar.name));
    }
  };

  useEffect(() => {
    if (!currentChar) return;

    // 渲染逐步演示
    renderProgressiveStrokes();

    setTimeout(initQuiz, 300);

    return () => destroyWriter();
  }, [currentChar]);

  const handleCustomInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    const val = e.target.value.trim();
    setCustomChar(val);
    if (val.length === 1) {
      setCustomInfo(getCharacterInfo(val));
      setCurrentIndex(0);
      setScore(0);
    } else {
      setCustomInfo(null);
    }
  };

  const prev = () => { if (!customInfo && currentIndex > 0) setCurrentIndex(i => i - 1); };
  const next = () => { if (!customInfo && currentIndex < defaultCharacterList.length - 1) setCurrentIndex(i => i + 1); };

  return (
    <div className="min-h-screen bg-gradient-to-b from-emerald-50 to-white flex flex-col">
      <header className="bg-emerald-600 text-white py-3 shadow-lg">
        <div className="container mx-auto px-4">
          <h1 className="text-2xl font-bold text-center">幼儿园汉字描红</h1>
          <div className="flex justify-around items-center mt-2 text-sm">
            <div className="text-center">
              <p className="opacity-90">得分</p>
              <p className="text-2xl font-bold">{score}</p>
            </div>
            <div className="text-center">
              <p className="text-4xl font-black my-1">{currentChar.name}</p>
              {!customInfo && <p className="text-xs opacity-80">({currentIndex + 1}/{defaultCharacterList.length})</p>}
            </div>
          </div>
        </div>
      </header>

      <main className="flex-1 container mx-auto px-4 py-4 max-w-lg">
        <div className="relative bg-white rounded-2xl shadow-xl overflow-hidden mb-5" style={{ maxHeight: '380px' }}>
          <div ref={displayRef} className="w-full h-full" />
          {showMistake && mistakeCountRef.current >= 3 && (
            <div className="absolute inset-0 bg-red-600/95 rounded-2xl flex flex-col items-center justify-center text-white z-50 animate-pulse">
              <div className="text-8xl mb-4"></div>
              <div className="text-3xl font-bold">写错了!</div>
              <div className="text-xl mt-2">重新开始</div>
            </div>
          )}
        </div>

        <div className="grid grid-cols-3 gap-3 mb-5">
          <button onClick={playVoice} className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-4 rounded-xl shadow-md active:scale-95 transition">
            <span className="text-2xl">🔊</span><br />发音
          </button>
          <button onClick={showAnimationWithNumbers} className="bg-purple-500 hover:bg-purple-600 text-white font-medium py-4 rounded-xl shadow-md active:scale-95 transition">
            <span className="text-2xl">▶️</span><br />演示
          </button>
          <button onClick={initQuiz} className="bg-orange-500 hover:bg-orange-600 text-white font-medium py-4 rounded-xl shadow-md active:scale-95 transition">
            <span className="text-2xl">🔄</span><br />重写
          </button>
        </div>

        {/* 自定义输入框 - 高度适中 */}
          <div className="flex w-full mb-4">
        <span
          className="
            bg-emerald-600 text-white
            px-3 sm:px-5
            py-2 sm:py-3
            rounded-l-xl
            font-medium
            flex items-center
            text-base sm:text-lg
            whitespace-nowrap
          "
        >
          自定义
        </span>

        <input
          type="text"
          value={customChar}
          onChange={handleCustomInput}
          placeholder="输入汉字"
          maxLength={1}
          className="
            flex-1 min-w-0
            text-center
            text-2xl sm:text-4xl
            py-2 sm:py-3
            border-2 border-emerald-600
            rounded-r-xl
            focus:outline-none
            focus:ring-4 focus:ring-emerald-300
          "
        />
      </div>


        <div className="flex flex-wrap justify-center gap-2 mb-4">
          <span className="bg-gray-700 text-white px-4 py-2 rounded-full text-sm">拼音:{currentChar.pinyin}</span>
          <span className="bg-emerald-600 text-white px-4 py-2 rounded-full text-sm">{currentChar.strokeCount}</span>
        </div>
        {currentChar.strokeNames.length > 1 && (
          <p className="text-center text-sm text-gray-600 mb-3">
            笔画:{currentChar.strokeNames.join(' → ')}
          </p>
        )}
        {currentChar.words.length > 0 && (
          <p className="text-center text-sm text-gray-600 mb-5">
            组词:{currentChar.words.join(' · ')}
          </p>
        )}

        {/* 逐步笔画演示 - 现在一定正确显示! */}
        <div className="mb-6">
          <p className="text-center font-semibold text-gray-800 mb-3 text-lg">逐步笔画演示(← 左右滑动查看 →)</p>
          <div className="overflow-x-auto pb-3">
            <div ref={fanningRef} className="flex" />
          </div>
        </div>

        {!customInfo && (
          <div className="flex justify-center gap-8 mt-4">
            <button onClick={prev} disabled={currentIndex === 0} className="bg-gray-200 hover:bg-gray-300 disabled:opacity-50 px-8 py-3 rounded-xl font-medium text-lg shadow active:scale-95">
              ← 上一个
            </button>
            <button onClick={next} disabled={currentIndex === defaultCharacterList.length - 1} className="bg-gray-200 hover:bg-gray-300 disabled:opacity-50 px-8 py-3 rounded-xl font-medium text-lg shadow active:scale-95">
              下一个 →
            </button>
          </div>
        )}
      </main>

      {showSuccess && (
        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
          <div className="bg-white rounded-3xl px-12 py-10 text-center shadow-2xl animate-bounce">
            <p className="text-7xl mb-4">🎉</p>
            <p className="text-3xl font-bold text-emerald-600">写对了!</p>
            <p className="text-2xl mt-2">+10 分</p>
          </div>
        </div>
      )}
    </div>
  );
}
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐