Initial English PK app

This commit is contained in:
2026-06-29 23:56:38 +08:00
commit a5de242d2c
41 changed files with 13169 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
data/*.sqlite
data/*.sqlite-*
src-tauri/target
src-tauri/gen
dist
*.log
.git
.idea
Dockerfile
docker-compose.yml
+10
View File
@@ -0,0 +1,10 @@
node_modules/
.next/
dist/
.idea
*.log
data/*.sqlite
data/*.sqlite-*
public/audio/*.mp3
src-tauri/target/
src-tauri/gen/
+198
View File
@@ -0,0 +1,198 @@
# English PK 开发文档
## 项目简介
这是一个面向中班英语课堂的浏览器 Web 应用,已改造为 Next.js 项目。应用包含两个主要页面:
- `/pk`:课堂 PK 比赛页面。
- `/admin`:后台管理页面,使用 shadcn/ui 风格组件。
旧版单文件 HTML 与 Tauri 目录仍保留为历史参考,但当前主架构以 Next.js 和 SQLite 为准。
## 技术栈
- Next.js 16 App Router
- React 19
- TypeScript
- Tailwind CSS
- shadcn/ui 风格本地组件
- Sonner toast
- SQLiteNode `node:sqlite`
- Edge TTS`edge-tts-universal`
- Web Speech API
- Web Audio API
## 目录结构
```text
englishPk/
├─ app/
│ ├─ layout.tsx # 全局布局和顶部导航
│ ├─ page.tsx # 根路径重定向到 /pk
│ ├─ globals.css # Tailwind 和 shadcn CSS 变量
│ ├─ pk/page.tsx # PK 比赛页面
│ ├─ admin/page.tsx # 后台管理页面
│ └─ api/ # SQLite CRUD API
├─ components/ui/ # shadcn 风格基础组件
├─ data/english-pk.sqlite # SQLite 数据库,运行时自动创建
├─ lib/db.ts # SQLite 建表、种子数据和 CRUD
├─ lib/utils.ts # cn 工具函数
├─ public/audio/ # Edge TTS 生成的单词音频
├─ components.json # shadcn 配置
├─ tailwind.config.ts # Tailwind 配置
├─ package.json # Next.js 开发脚本
├─ frontend/ # 旧 HTML/Tauri 前端入口,历史保留
└─ src-tauri/ # 旧 Tauri 桌面壳,历史保留
```
## 路由说明
### `/pk`
课堂比赛页面。主要功能:
- 进入页面后先选择年级和班级类型。
- 班级类型从 SQLite 数据库读取,只展示已启用项。
- 只从当前年级、班级类型且已启用的词库中出题。
- 朗读时优先播放数据库里的本地音频;没有音频时会尝试生成并入库,失败后回退到系统朗读。
- 红队、蓝队轮流答题。
- 每题 4 个选项。
- 答对加 10 分,答错不扣分。
- 使用浏览器语音能力朗读英文单词。
- 使用浏览器音频能力播放答对、答错提示音。
- 页面为左右布局,大屏左右分栏,小屏自动堆叠。
### `/admin`
后台管理页面。当前已接入 SQLite 持久化,包含:
- 词库总览。
- 启用词库数量统计。
- 年级、班级类型筛选和题库英语搜索。
- 词库新增、编辑、删除、中文、图标/图片配置和启用/停用。
- 可点击生成语音,服务端通过 Edge TTS 生成 mp3 并写回数据库。
- 可一键生成当前筛选词库的语音,并显示生成进度、成功数和失败数。
- 添加和编辑词库使用弹窗;编辑年级、班型或英语后会清空旧语音,需要重新生成。
- 设置使用弹窗,包含 Edge TTS 声音下拉、语速、试听、加分细则和课堂规则,并持久化到 SQLite。
- 操作反馈使用 Sonner toast。
- 班级类型新增、编辑、删除和启用/停用。
- 答对得分、自动朗读等课堂规则控件。
- 跳转 PK 页面入口。
## 本地开发
安装依赖:
```bash
npm install
```
启动开发服务:
```bash
npm run dev
```
访问页面:
```text
http://localhost:3000/pk
http://localhost:3000/admin
```
生产构建:
```bash
npm run build
```
启动生产服务:
```bash
npm run start
```
## SQLite 数据库
数据库文件会在首次访问 API 时自动创建:
```text
data/english-pk.sqlite
```
数据库包含两张表:
```text
class_types
- id
- name
- enabled
- created_at
- updated_at
words
- id
- grade
- class_type_id
- english
- chinese
- image
- audio
- enabled
- created_at
- updated_at
settings
- key
- value
- updated_at
```
首次初始化会写入默认班级类型:
- 基础班
- 提高班
- 复习班
也会写入原始 12 个中班 XYZ 单词。
`words.image` 可填写 emoji、站内图片路径、外部图片地址或 `data:image/...`
```text
🍎
/images/apple.png
https://example.com/apple.png
data:image/png;base64,...
```
`words.audio` 保存生成后的音频路径,例如:
```text
/audio/1-box.mp3
```
后台管理页点击某个词库项的麦克风按钮,会调用 Edge TTS 生成 mp3 到 `public/audio/`,然后把音频路径写入 SQLite。PK 页面朗读时会优先播放该音频;没有音频时才使用浏览器系统朗读作为兜底。
## API
```text
GET /api/words
POST /api/words
PUT /api/words/:id
DELETE /api/words/:id
POST /api/words/:id/generate-audio
GET /api/class-types
POST /api/class-types
PUT /api/class-types/:id
DELETE /api/class-types/:id
GET /api/settings
PUT /api/settings
```
## 后续开发建议
- 增加课堂成绩记录和历史对局统计。
- 增加删除确认弹窗,避免误删。
- 引入本地音频文件,保证离线环境下发音稳定。
+38
View File
@@ -0,0 +1,38 @@
ARG NODE_IMAGE=m.daocloud.io/docker.io/library/node:22
ARG NPM_REGISTRY=https://registry.npmmirror.com
FROM ${NODE_IMAGE} AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm config set registry ${NPM_REGISTRY} && npm ci
FROM ${NODE_IMAGE} AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM ${NODE_IMAGE} AS runner
WORKDIR /app
ARG NPM_REGISTRY=https://registry.npmmirror.com
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
COPY package.json package-lock.json ./
RUN npm config set registry ${NPM_REGISTRY} && npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.ts ./next.config.ts
RUN mkdir -p /app/data /app/public/audio
EXPOSE 3000
CMD ["npm", "run", "start"]
+109
View File
@@ -0,0 +1,109 @@
# English PK
English PK 是一个用于课堂单词 PK 的 Next.js 应用,包含课堂 PK 页面和后台管理页面。
## 功能
- PK 页面:选择年级和班级类型后开始比赛。
- 后台管理:维护词库、中文释义、图标/图片、启用状态和班级类型。
- SQLite 数据库:词库、班级类型、课堂规则和语音设置持久化存储。
- Edge TTS:支持单词语音生成、试听和批量生成。
- 课堂规则:可设置答对加分、答错扣分、每轮单词数量、选项数量、自动朗读和单词显示格式。
- Docker 部署:支持一条命令启动,并持久化数据库与生成语音。
## 技术栈
- Next.js App Router
- React
- TypeScript
- Tailwind CSS
- shadcn 风格 UI 组件
- SQLite (`node:sqlite`)
- edge-tts-universal
## 本地开发
安装依赖:
```bash
npm install
```
启动开发服务:
```bash
npm run dev
```
访问:
```text
http://localhost:3000
```
常用页面:
- PK 页面:`/pk`
- 后台管理:`/admin`
## 生产构建
```bash
npm run build
npm run start
```
## Docker 部署
默认使用 Docker Hub 加速镜像和 npm 镜像源:
- Node 镜像:`m.daocloud.io/docker.io/library/node:22`
- npm 源:`https://registry.npmmirror.com`
启动:
```bash
docker compose up -d --build
```
访问:
```text
http://localhost:3000
```
如需切换镜像源:
```bash
NODE_IMAGE=node:22 docker compose up -d --build
```
如需切换 npm 源:
```bash
NPM_REGISTRY=https://registry.npmjs.org docker compose up -d --build
```
## 数据持久化
Docker Compose 会挂载以下目录:
- `./data:/app/data`SQLite 数据库
- `./public/audio:/app/public/audio`:生成的语音文件
请备份这两个目录,避免迁移或重建服务器时丢失数据。
## 配置说明
后台管理中的「设置」包含:
- 语音设置:Edge TTS 声音、语速、试听
- 课堂规则:得分规则、显示单词数量、选项数量、自动朗读、单词大小写显示
- 班级类型:新增、编辑、启停、删除
- 页面预览:打开 PK 页面检查课堂展示效果
## 注意事项
- 项目依赖 `node:sqlite`,需要 Node.js 22 或更高版本。
- 生成语音依赖网络访问 Edge TTS 服务。
- 如果每轮题目数量为奇数,PK 页面会自动向下调整为偶数,保证两队轮流答题公平。
+1065
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { deleteClassType, updateClassType } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type RouteContext = {
params: Promise<{ id: string }>;
};
export async function PUT(request: NextRequest, context: RouteContext) {
try {
const { id } = await context.params;
const body = await request.json();
const classType = updateClassType(Number(id), {
name: String(body.name ?? ""),
enabled: Boolean(body.enabled),
});
return NextResponse.json({ classType });
} catch (error) {
return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 });
}
}
export async function DELETE(_request: NextRequest, context: RouteContext) {
try {
const { id } = await context.params;
deleteClassType(Number(id));
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 });
}
}
function getErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "操作失败";
if (message.includes("UNIQUE")) return "班级类型已存在";
if (message.includes("FOREIGN KEY")) return "该班级类型下仍有题库,不能删除";
return message;
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { createClassType, listClassTypes } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
const includeDisabled = request.nextUrl.searchParams.get("includeDisabled") !== "false";
return NextResponse.json({ classTypes: listClassTypes(includeDisabled) });
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const classType = createClassType(String(body.name ?? ""));
return NextResponse.json({ classType }, { status: 201 });
} catch (error) {
return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 });
}
}
function getErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "操作失败";
if (message.includes("UNIQUE")) return "班级类型已存在";
return message;
}
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
return NextResponse.json({ settings: getSettings() });
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const settings = updateSettings({
ttsVoice: String(body.ttsVoice ?? ""),
ttsRate: String(body.ttsRate ?? ""),
scorePerCorrect: Number(body.scorePerCorrect ?? 10),
scorePerWrong: Number(body.scorePerWrong ?? 0),
autoSpeak: Boolean(body.autoSpeak),
wordDisplayMode: String(body.wordDisplayMode ?? "uppercase") as "uppercase" | "capitalize" | "input",
pkWordCount: Number(body.pkWordCount ?? 0),
pkOptionCount: Number(body.pkOptionCount ?? 4),
});
return NextResponse.json({ settings });
} catch (error) {
const message = error instanceof Error ? error.message : "保存设置失败";
return NextResponse.json({ message }, { status: 400 });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { existsSync, mkdirSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { NextRequest, NextResponse } from "next/server";
import { EdgeTTS } from "edge-tts-universal";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const voice = String(body.voice ?? "en-US-EmmaMultilingualNeural");
const rate = String(body.rate ?? "-10%");
const text = String(body.text ?? "Hello, welcome to English PK.");
const audioDirectory = path.join(process.cwd(), "public", "audio", "preview");
if (!existsSync(audioDirectory)) {
mkdirSync(audioDirectory, { recursive: true });
}
const fileName = `tts-preview-${Date.now()}.mp3`;
const filePath = path.join(audioDirectory, fileName);
const publicPath = `/audio/preview/${fileName}`;
const tts = new EdgeTTS(text, voice, { rate });
const result = await tts.synthesize();
const audioBuffer = Buffer.from(await result.audio.arrayBuffer());
await writeFile(filePath, audioBuffer);
return NextResponse.json({ audio: publicPath });
} catch (error) {
const message = error instanceof Error ? error.message : "试听语音失败";
return NextResponse.json({ message }, { status: 500 });
}
}
@@ -0,0 +1,49 @@
import { existsSync, mkdirSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { NextRequest, NextResponse } from "next/server";
import { EdgeTTS } from "edge-tts-universal";
import { findWord, getSettings, updateWordAudio } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type RouteContext = {
params: Promise<{ id: string }>;
};
export async function POST(_request: NextRequest, context: RouteContext) {
try {
const { id } = await context.params;
const wordId = Number(id);
const word = findWord(wordId);
if (!word) {
return NextResponse.json({ message: "词库不存在" }, { status: 404 });
}
const audioDirectory = path.join(process.cwd(), "public", "audio");
if (!existsSync(audioDirectory)) {
mkdirSync(audioDirectory, { recursive: true });
}
const safeEnglish = word.english.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
const fileName = `${word.id}-${safeEnglish || "word"}.mp3`;
const filePath = path.join(audioDirectory, fileName);
const publicPath = `/audio/${fileName}`;
const settings = getSettings();
const tts = new EdgeTTS(word.english, settings.ttsVoice, {
rate: settings.ttsRate,
});
const result = await tts.synthesize();
const audioBuffer = Buffer.from(await result.audio.arrayBuffer());
await writeFile(filePath, audioBuffer);
const updatedWord = updateWordAudio(word.id, publicPath);
return NextResponse.json({ word: updatedWord });
} catch (error) {
const message = error instanceof Error ? error.message : "生成语音失败";
return NextResponse.json({ message }, { status: 500 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { deleteWord, updateWord, type Grade } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type RouteContext = {
params: Promise<{ id: string }>;
};
export async function PUT(request: NextRequest, context: RouteContext) {
try {
const { id } = await context.params;
const body = await request.json();
const word = updateWord(Number(id), {
grade: body.grade as Grade,
classTypeId: Number(body.classTypeId),
english: String(body.english ?? ""),
chinese: String(body.chinese ?? ""),
image: String(body.image ?? ""),
audio: String(body.audio ?? ""),
enabled: Boolean(body.enabled),
});
return NextResponse.json({ word });
} catch (error) {
return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 });
}
}
export async function DELETE(_request: NextRequest, context: RouteContext) {
try {
const { id } = await context.params;
deleteWord(Number(id));
return NextResponse.json({ ok: true });
} catch (error) {
return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 });
}
}
function getErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "操作失败";
if (message.includes("UNIQUE")) return "相同年级、班级类型和英语题库已存在";
if (message.includes("FOREIGN KEY")) return "班级类型不存在";
return message;
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { createWord, listWords, type Grade } from "@/lib/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const grade = searchParams.get("grade") ?? undefined;
const classTypeId = Number(searchParams.get("classTypeId"));
const enabledOnly = searchParams.get("enabledOnly") === "true";
const words = listWords({
grade,
classTypeId: Number.isFinite(classTypeId) && classTypeId > 0 ? classTypeId : undefined,
enabledOnly,
});
return NextResponse.json({ words });
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const word = createWord({
grade: body.grade as Grade,
classTypeId: Number(body.classTypeId),
english: String(body.english ?? ""),
chinese: String(body.chinese ?? ""),
image: String(body.image ?? ""),
audio: String(body.audio ?? ""),
enabled: Boolean(body.enabled),
});
return NextResponse.json({ word }, { status: 201 });
} catch (error) {
return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 });
}
}
function getErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : "操作失败";
if (message.includes("UNIQUE")) return "相同年级、班级类型和英语题库已存在";
if (message.includes("FOREIGN KEY")) return "班级类型不存在";
return message;
}
+52
View File
@@ -0,0 +1,52 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
* {
@apply border-border;
margin: 0;
padding: 0;
}
html,
body {
min-height: 100%;
}
body {
@apply bg-background text-foreground antialiased;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import Link from "next/link";
import type { ReactNode } from "react";
import { Gamepad2, Settings } from "lucide-react";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
export const metadata: Metadata = {
title: "English PK",
description: "字母单词 PK 赛",
};
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
return (
<html lang="zh-CN">
<body>
<div className="min-h-screen bg-[linear-gradient(135deg,#e8f8f7_0%,#fff1f5_45%,#eef6ff_100%)]">
<header className="border-b bg-white/80 backdrop-blur">
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6">
<Link href="/pk" className="flex items-center gap-2 font-semibold text-slate-900">
<span className="grid size-8 place-items-center rounded-md bg-rose-500 text-white">PK</span>
English PK
</Link>
<nav className="flex items-center gap-1">
<Link
href="/pk"
className="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100"
>
<Gamepad2 className="size-4" />
PK页面
</Link>
<Link
href="/admin"
className="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100"
>
<Settings className="size-4" />
</Link>
</nav>
</div>
</header>
{children}
</div>
<Toaster />
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/pk");
}
+617
View File
@@ -0,0 +1,617 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { RotateCcw, Trophy, Volume2 } from "lucide-react";
import { cn } from "@/lib/utils";
type Team = "red" | "blue";
type AnswerState = "idle" | "correct" | "wrong";
type Grade = "小班" | "中班" | "大班";
type ClassType = {
id: number;
name: string;
enabled: boolean;
};
type WordItem = {
id: number;
grade: Grade;
classTypeId: number;
classTypeName: string;
english: string;
chinese: string;
image: string;
audio: string;
enabled: boolean;
};
type AppSettings = {
ttsVoice: string;
ttsRate: string;
scorePerCorrect: number;
scorePerWrong: number;
autoSpeak: boolean;
wordDisplayMode: "uppercase" | "capitalize" | "input";
pkWordCount: number;
pkOptionCount: number;
};
const grades: Grade[] = ["小班", "中班", "大班"];
function shuffleArray<T>(array: T[]) {
const next = [...array];
for (let i = next.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[next[i], next[j]] = [next[j], next[i]];
}
return next;
}
function TeamCard({
team,
active,
score,
}: {
team: Team;
active: boolean;
score: number;
}) {
const red = team === "red";
return (
<div
className={cn(
"flex-1 rounded-lg border-4 p-[clamp(10px,1.6vw,16px)] text-center transition",
red ? "bg-red-50 text-red-700" : "bg-blue-50 text-blue-700",
active && (red ? "border-red-400 shadow-lg" : "border-blue-400 shadow-lg"),
!active && "border-transparent",
)}
>
<div className="text-[clamp(16px,1.8vw,22px)] font-bold">{red ? "🔴 红队" : "🔵 蓝队"}</div>
<div className="text-[clamp(30px,4vw,48px)] font-black leading-tight">{score}</div>
</div>
);
}
export default function PkPage() {
const [selectedGrade, setSelectedGrade] = useState<Grade>("中班");
const [classTypes, setClassTypes] = useState<ClassType[]>([]);
const [selectedClassTypeId, setSelectedClassTypeId] = useState<number>(0);
const [availableWords, setAvailableWords] = useState<WordItem[]>([]);
const [loadingSetup, setLoadingSetup] = useState(true);
const [started, setStarted] = useState(false);
const [ended, setEnded] = useState(false);
const [roundWords, setRoundWords] = useState<WordItem[]>([]);
const [questionIndex, setQuestionIndex] = useState(0);
const [currentTeam, setCurrentTeam] = useState<Team>("red");
const [scoreRed, setScoreRed] = useState(0);
const [scoreBlue, setScoreBlue] = useState(0);
const [answerState, setAnswerState] = useState<AnswerState>("idle");
const [selectedWord, setSelectedWord] = useState<number | null>(null);
const [speechTip, setSpeechTip] = useState("点击开始后可朗读单词。");
const [settings, setSettings] = useState<AppSettings>({
ttsVoice: "en-US-EmmaMultilingualNeural",
ttsRate: "-10%",
scorePerCorrect: 10,
scorePerWrong: 0,
autoSpeak: true,
wordDisplayMode: "uppercase",
pkWordCount: 0,
pkOptionCount: 4,
});
const audioContextRef = useRef<AudioContext | null>(null);
useEffect(() => {
async function loadInitialSetup() {
setLoadingSetup(true);
const [classTypesResponse, nextSettings] = await Promise.all([
fetch("/api/class-types?includeDisabled=false", { cache: "no-store" }),
loadSettings(),
]);
const classTypesData = (await classTypesResponse.json()) as { classTypes: ClassType[] };
setClassTypes(classTypesData.classTypes);
setSettings(mergeSettings(nextSettings));
setSelectedClassTypeId((current) => current || classTypesData.classTypes[0]?.id || 0);
setLoadingSetup(false);
}
void loadInitialSetup();
}, []);
useEffect(() => {
function refreshSettingsOnFocus() {
void loadSettings().then((nextSettings) => setSettings(mergeSettings(nextSettings)));
}
window.addEventListener("focus", refreshSettingsOnFocus);
return () => window.removeEventListener("focus", refreshSettingsOnFocus);
}, []);
useEffect(() => {
async function loadWords() {
if (!selectedClassTypeId) {
setAvailableWords([]);
return;
}
const params = new URLSearchParams({
grade: selectedGrade,
classTypeId: String(selectedClassTypeId),
enabledOnly: "true",
});
const response = await fetch(`/api/words?${params.toString()}`, { cache: "no-store" });
const data = (await response.json()) as { words: WordItem[] };
setAvailableWords(data.words);
}
void loadWords();
}, [selectedGrade, selectedClassTypeId]);
const selectedClassType = classTypes.find((classType) => classType.id === selectedClassTypeId);
const currentWord = roundWords[questionIndex] ?? availableWords[0];
const optionCount = normalizeOptionCount(settings.pkOptionCount);
const options = useMemo(() => {
if (!currentWord) return [];
const wrongOptions = availableWords.filter((word) => word.id !== currentWord.id);
return shuffleArray([currentWord, ...shuffleArray(wrongOptions).slice(0, optionCount - 1)]);
}, [currentWord, availableWords, optionCount]);
const progress = roundWords.length > 0 ? (questionIndex / roundWords.length) * 100 : 0;
const roundWordLimit = Math.max(0, Math.floor(settings.pkWordCount || 0));
const plannedRoundCount = getFairRoundCount(availableWords.length, roundWordLimit);
const winner =
scoreRed > scoreBlue ? "red" : scoreBlue > scoreRed ? "blue" : "draw";
function ensureAudioContext() {
if (!audioContextRef.current) {
audioContextRef.current = new window.AudioContext();
}
return audioContextRef.current;
}
async function playTone(correct: boolean) {
const audioContext = ensureAudioContext();
if (audioContext.state === "suspended") {
await audioContext.resume();
}
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
const duration = correct ? 0.4 : 0.3;
if (correct) {
osc.frequency.setValueAtTime(523.25, audioContext.currentTime);
osc.frequency.setValueAtTime(659.25, audioContext.currentTime + 0.1);
osc.frequency.setValueAtTime(783.99, audioContext.currentTime + 0.2);
gain.gain.setValueAtTime(0.25, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.4);
} else {
osc.frequency.setValueAtTime(200, audioContext.currentTime);
osc.frequency.setValueAtTime(150, audioContext.currentTime + 0.15);
gain.gain.setValueAtTime(0.25, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
}
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + duration);
}
async function speakWord(word: WordItem) {
if (word.audio) {
const audio = new Audio(word.audio);
audio.play().catch(() => speakSystemWord(word.english));
setSpeechTip(`正在播放:${word.english}`);
return;
}
try {
setSpeechTip(`正在生成语音:${word.english}`);
const response = await fetch(`/api/words/${word.id}/generate-audio`, { method: "POST" });
if (!response.ok) throw new Error("generate failed");
const data = (await response.json()) as { word: WordItem };
const audio = new Audio(data.word.audio);
audio.play().catch(() => speakSystemWord(word.english));
setSpeechTip(`正在播放:${word.english}`);
setRoundWords((items) => items.map((item) => (item.id === data.word.id ? data.word : item)));
setAvailableWords((items) => items.map((item) => (item.id === data.word.id ? data.word : item)));
} catch {
speakSystemWord(word.english);
}
}
function speakSystemWord(word: string) {
if (!("speechSynthesis" in window)) {
setSpeechTip("当前浏览器不支持英文朗读,请老师带读。");
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(word);
const voices = window.speechSynthesis.getVoices();
utterance.voice =
voices.find((voice) => /^en[-_]?US/i.test(voice.lang)) ??
voices.find((voice) => /^en/i.test(voice.lang)) ??
null;
utterance.lang = utterance.voice?.lang ?? "en-US";
utterance.rate = 0.85;
utterance.pitch = 1;
window.speechSynthesis.speak(utterance);
setSpeechTip(`正在朗读:${word}`);
}
function startGame() {
const nextRound = shuffleArray(availableWords).slice(0, plannedRoundCount);
if (nextRound.length === 0) return;
setRoundWords(nextRound);
setQuestionIndex(0);
setCurrentTeam("red");
setScoreRed(0);
setScoreBlue(0);
setAnswerState("idle");
setSelectedWord(null);
setStarted(true);
setEnded(false);
setSpeechTip(settings.autoSpeak ? "准备朗读中..." : "自动朗读已关闭。");
if (settings.autoSpeak) window.setTimeout(() => void speakWord(nextRound[0]), 350);
}
function checkAnswer(option: WordItem) {
if (answerState !== "idle") return;
const correct = option.id === currentWord.id;
setSelectedWord(option.id);
setAnswerState(correct ? "correct" : "wrong");
playTone(correct);
if (correct) {
if (currentTeam === "red") setScoreRed((score) => score + settings.scorePerCorrect);
else setScoreBlue((score) => score + settings.scorePerCorrect);
} else if (settings.scorePerWrong > 0) {
if (currentTeam === "red") setScoreRed((score) => Math.max(0, score - settings.scorePerWrong));
else setScoreBlue((score) => Math.max(0, score - settings.scorePerWrong));
}
}
function nextQuestion() {
window.speechSynthesis?.cancel();
const nextIndex = questionIndex + 1;
const nextTeam = currentTeam === "red" ? "blue" : "red";
setCurrentTeam(nextTeam);
setAnswerState("idle");
setSelectedWord(null);
if (nextIndex >= roundWords.length) {
setEnded(true);
return;
}
setQuestionIndex(nextIndex);
setSpeechTip(settings.autoSpeak ? "准备朗读中..." : "自动朗读已关闭。");
if (settings.autoSpeak) window.setTimeout(() => void speakWord(roundWords[nextIndex]), 350);
}
if (!started) {
return (
<main className="mx-auto grid min-h-[calc(100vh-56px)] max-w-5xl place-items-center px-4 py-8">
<section className="w-full rounded-[28px] bg-white p-[clamp(24px,5vw,64px)] text-center shadow-soft">
<div className="mb-5 text-[clamp(72px,11vw,126px)] leading-none">🦊 VS 🦓</div>
<h1 className="text-balance text-[clamp(32px,5vw,56px)] font-black text-rose-500">PK赛</h1>
<p className="mt-4 text-[clamp(17px,2vw,22px)] text-slate-600">
{settings.scorePerCorrect}
{settings.scorePerWrong > 0 ? `,答错扣${settings.scorePerWrong}分。` : ",答错不扣分。"}
</p>
<div className="mx-auto mt-8 grid max-w-3xl gap-5 rounded-2xl border bg-slate-50 p-5 text-left sm:p-6">
<div>
<div className="mb-3 text-sm font-semibold text-slate-600"></div>
<div className="grid grid-cols-3 gap-3">
{grades.map((grade) => (
<button
key={grade}
onClick={() => setSelectedGrade(grade)}
className={cn(
"rounded-lg border bg-white px-4 py-3 text-center text-base font-bold transition hover:border-rose-300",
selectedGrade === grade && "border-rose-500 bg-rose-50 text-rose-700 shadow-sm",
)}
>
{grade}
</button>
))}
</div>
</div>
<div>
<div className="mb-3 text-sm font-semibold text-slate-600"></div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{classTypes.map((classType) => (
<button
key={classType.id}
onClick={() => setSelectedClassTypeId(classType.id)}
className={cn(
"rounded-lg border bg-white px-4 py-3 text-center text-base font-bold transition hover:border-blue-300",
selectedClassTypeId === classType.id && "border-blue-500 bg-blue-50 text-blue-700 shadow-sm",
)}
>
{classType.name}
</button>
))}
</div>
</div>
<div className="rounded-lg bg-white px-4 py-3 text-center text-sm text-slate-600">
<span className="font-bold text-slate-900">{selectedGrade}</span>
<span className="mx-2 text-slate-300">/</span>
<span className="font-bold text-slate-900">{selectedClassType?.name ?? "未选择班型"}</span>
<span className="mx-2 text-slate-300">/</span>
<span className="font-bold text-emerald-600">{plannedRoundCount}</span>
<span className="mx-2 text-slate-300">/</span>
<span className="font-bold text-slate-900">{availableWords.length}</span>
</div>
</div>
{!loadingSetup && availableWords.length === 0 && (
<p className="mt-4 text-sm font-medium text-red-500"></p>
)}
<button
onClick={startGame}
disabled={loadingSetup || availableWords.length === 0}
className="mt-8 rounded-full bg-rose-500 px-12 py-4 text-[clamp(18px,2vw,24px)] font-bold text-white shadow-lg transition hover:bg-rose-600 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none"
>
{loadingSetup ? "加载中..." : "开始PK"}
</button>
</section>
</main>
);
}
if (ended) {
return (
<main className="mx-auto grid min-h-[calc(100vh-56px)] max-w-5xl place-items-center px-4 py-8">
<section className="w-full rounded-[28px] bg-white p-[clamp(28px,6vw,72px)] text-center shadow-soft">
<div className="mb-5 text-[clamp(72px,10vw,118px)] leading-none">{winner === "draw" ? "🤝" : "🏆"}</div>
<h1 className="text-[clamp(32px,5vw,54px)] font-black text-rose-500">PK结束</h1>
<p
className={cn(
"mt-4 text-[clamp(26px,4vw,44px)] font-black",
winner === "red" && "text-red-600",
winner === "blue" && "text-blue-600",
winner === "draw" && "text-amber-500",
)}
>
{winner === "red" ? "🔴 红队获胜!" : winner === "blue" ? "🔵 蓝队获胜!" : "平局!势均力敌!"}
</p>
<div className="mx-auto mt-8 grid max-w-lg grid-cols-2 gap-4">
<div className="rounded-lg bg-red-50 p-5 text-red-700">
<div className="font-bold"></div>
<div className="text-4xl font-black">{scoreRed}</div>
</div>
<div className="rounded-lg bg-blue-50 p-5 text-blue-700">
<div className="font-bold"></div>
<div className="text-4xl font-black">{scoreBlue}</div>
</div>
</div>
<button
onClick={startGame}
className="mt-8 inline-flex items-center gap-2 rounded-full bg-rose-500 px-10 py-4 text-lg font-bold text-white shadow-lg transition hover:bg-rose-600"
>
<RotateCcw className="size-5" />
</button>
</section>
</main>
);
}
return (
<main className="mx-auto min-h-[calc(100vh-56px)] max-w-7xl px-4 py-5 sm:px-6 sm:py-8">
<section className="relative overflow-hidden rounded-[28px] bg-white p-[clamp(18px,3vw,34px)] shadow-soft">
{answerState !== "idle" && (
<div
className={cn(
"z-10 mb-4 flex flex-col items-center gap-3 rounded-lg border bg-white/95 px-4 py-3 text-center text-[clamp(16px,1.8vw,22px)] font-black shadow-lg backdrop-blur md:absolute md:right-[clamp(18px,3vw,34px)] md:top-[clamp(18px,3vw,34px)] md:mb-0 md:max-w-[360px]",
answerState === "correct" && "border-emerald-100 text-emerald-600",
answerState === "wrong" && "border-red-100 text-red-500",
)}
>
<div>
{answerState === "correct" && `🎉 太棒了!答对啦!+${settings.scorePerCorrect}`}
{answerState === "wrong" &&
(settings.scorePerWrong > 0
? `答错啦,扣${settings.scorePerWrong}分,轮到下一队!`
: "答错啦,轮到下一队!")}
</div>
<button
onClick={nextQuestion}
className="inline-flex items-center gap-2 rounded-full bg-slate-900 px-7 py-2.5 text-base font-bold text-white shadow-lg transition hover:bg-slate-700"
>
<Trophy className="size-5" />
</button>
</div>
)}
<div className="mb-[clamp(12px,2vw,22px)] text-center">
<h1 className="text-[clamp(26px,3vw,38px)] font-black text-rose-500">PK大作战</h1>
<p className="mt-2 text-sm font-medium text-slate-500">
{selectedGrade} / {selectedClassType?.name ?? "班型"} / {roundWords.length}
</p>
</div>
<div className="grid gap-[clamp(18px,3vw,34px)] lg:grid-cols-[minmax(300px,0.9fr)_minmax(420px,1.25fr)]">
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-[clamp(10px,1.8vw,18px)]">
<TeamCard team="red" active={currentTeam === "red"} score={scoreRed} />
<div className="text-[clamp(20px,2.8vw,30px)] font-black text-amber-500">VS</div>
<TeamCard team="blue" active={currentTeam === "blue"} score={scoreBlue} />
</div>
<div
className={cn(
"mt-4 rounded-lg px-4 py-3 text-center text-[clamp(18px,2vw,23px)] font-black",
currentTeam === "red" ? "bg-red-50 text-red-700" : "bg-blue-50 text-blue-700",
)}
>
{currentTeam === "red" ? "🔴 红队请作答" : "🔵 蓝队请作答"}
</div>
<div className="mt-4 h-3 overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${progress}%` }} />
</div>
<div className="flex flex-1 flex-col items-center justify-center py-[clamp(24px,5vw,70px)] text-center">
<p className="text-[clamp(16px,1.7vw,20px)] text-slate-600"></p>
<button
onClick={() => void speakWord(currentWord)}
className="mt-4 grid size-[clamp(62px,6vw,80px)] place-items-center rounded-full bg-rose-400 text-white shadow-lg transition hover:scale-105 hover:bg-rose-500"
aria-label="重新朗读当前单词"
>
<Volume2 className="size-[clamp(28px,3vw,36px)]" />
</button>
<AdaptiveWordText text={formatWordDisplay(currentWord.english, settings.wordDisplayMode)} variant="hero" />
<p className="mt-2 min-h-6 text-[clamp(13px,1.4vw,16px)] text-slate-500">{speechTip}</p>
</div>
</div>
<div className="flex min-w-0 flex-col justify-center">
<div
className={cn(
"grid grid-cols-1 gap-[clamp(14px,2vw,22px)] sm:grid-cols-2",
optionCount >= 6 && "xl:grid-cols-3",
)}
>
{options.map((option) => {
const correct = option.id === currentWord.id;
const selected = selectedWord === option.id;
const revealCorrect = answerState !== "idle" && correct;
return (
<button
key={option.id}
onClick={() => checkAnswer(option)}
className={cn(
"flex min-h-[clamp(104px,18vh,188px)] flex-col items-center justify-center rounded-[20px] border-4 bg-white p-4 text-[clamp(22px,3vw,34px)] font-black transition hover:-translate-y-1 hover:border-sky-300 hover:shadow-lg",
answerState === "idle" && "border-slate-200",
revealCorrect && "border-emerald-400 bg-emerald-50 text-emerald-700",
selected && answerState === "wrong" && "border-red-400 bg-red-50 text-red-700",
)}
>
<WordImage value={option.image} label={option.english} />
<AdaptiveWordText text={formatWordDisplay(option.english, settings.wordDisplayMode)} variant="option" />
</button>
);
})}
</div>
</div>
</div>
</section>
</main>
);
}
function AdaptiveWordText({ text, variant }: { text: string; variant: "hero" | "option" }) {
const displayText = text.trim().replace(/\s+/g, "\n");
const compactLength = text.replace(/\s+/g, "").length;
const isVeryLongSingleWord = compactLength > 16 && !/\s/.test(text.trim());
const size =
compactLength <= 6
? "large"
: compactLength <= 10
? "medium"
: "small";
const sizeClass = {
hero: {
large: "text-[clamp(52px,7vw,82px)]",
medium: "text-[clamp(42px,5.6vw,64px)]",
small: "text-[clamp(28px,4.2vw,46px)]",
},
option: {
large: "text-[clamp(30px,4vw,48px)]",
medium: "text-[clamp(25px,3.2vw,38px)]",
small: "text-[clamp(20px,2.6vw,30px)]",
},
}[variant][size];
return (
<span
className={cn(
"mx-auto block max-w-full whitespace-pre-line text-center font-black leading-[1.05] tracking-normal",
variant === "hero" && "mt-4 text-blue-500 [text-shadow:3px_3px_0_#dbeafe]",
variant === "option" && "mt-3 text-slate-900",
sizeClass,
isVeryLongSingleWord && "max-w-[min(100%,14ch)] [overflow-wrap:anywhere]",
)}
>
{displayText}
</span>
);
}
async function loadSettings() {
const response = await fetch("/api/settings", { cache: "no-store" });
const data = (await response.json()) as { settings: Partial<AppSettings> };
return data.settings;
}
function mergeSettings(settings: Partial<AppSettings>): AppSettings {
return {
ttsVoice: settings.ttsVoice || "en-US-EmmaMultilingualNeural",
ttsRate: settings.ttsRate || "-10%",
scorePerCorrect: Number(settings.scorePerCorrect ?? 10),
scorePerWrong: Number(settings.scorePerWrong ?? 0),
autoSpeak: settings.autoSpeak ?? true,
wordDisplayMode:
settings.wordDisplayMode === "capitalize" || settings.wordDisplayMode === "input" || settings.wordDisplayMode === "uppercase"
? settings.wordDisplayMode
: "uppercase",
pkWordCount: Math.max(0, Math.floor(Number(settings.pkWordCount ?? 0))),
pkOptionCount: normalizeOptionCount(Number(settings.pkOptionCount ?? 4)),
};
}
function getFairRoundCount(availableCount: number, limit: number) {
const requestedCount = limit > 0 ? Math.min(limit, availableCount) : availableCount;
if (requestedCount < 2) return requestedCount;
return requestedCount % 2 === 0 ? requestedCount : requestedCount - 1;
}
function formatWordDisplay(text: string, mode: AppSettings["wordDisplayMode"]) {
if (mode === "input") return text;
if (mode === "capitalize") {
return text
.split(/(\s+)/)
.map((part) => {
if (/^\s+$/.test(part) || !part) return part;
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
})
.join("");
}
return text.toUpperCase();
}
function normalizeOptionCount(value: number) {
const count = Math.floor(value);
if (!Number.isFinite(count)) return 4;
if (count < 2) return 2;
if (count > 8) return 8;
return count % 2 === 0 ? count : count + 1;
}
function WordImage({ value, label }: { value: string; label: string }) {
if (!value) {
return null;
}
if (/^(https?:\/\/|\/|data:image\/)/i.test(value)) {
return (
<img
src={value}
alt={label}
className="size-[clamp(54px,7vw,92px)] rounded-lg object-contain"
/>
);
}
return <span className="text-[clamp(44px,6vw,76px)] leading-none">{value}</span>;
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline: "text-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 px-3",
lg: "h-11 px-8",
icon: "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+37
View File
@@ -0,0 +1,37 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
export { Card, CardContent, CardDescription, CardHeader, CardTitle };
+85
View File
@@ -0,0 +1,85 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;
const DialogPortal = DialogPrimitive.Portal;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/50 backdrop-blur-sm", className)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
<X className="size-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none", className)} {...props} />
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
};
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = "Input";
export { Input };
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { Toaster as Sonner } from "sonner";
export function Toaster() {
return <Sonner richColors closeButton position="top-right" />;
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface SwitchProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
checked?: boolean;
}
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(({ className, checked, ...props }, ref) => (
<button
type="button"
role="switch"
aria-checked={checked}
ref={ref}
className={cn(
"inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-primary" : "bg-input",
className,
)}
{...props}
>
<span
className={cn(
"pointer-events-none block size-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
checked ? "translate-x-5" : "translate-x-0",
)}
/>
</button>
));
Switch.displayName = "Switch";
export { Switch };
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
);
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />,
);
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr ref={ref} className={cn("border-b transition-colors hover:bg-muted/50", className)} {...props} />
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th ref={ref} className={cn("h-12 px-4 text-left align-middle font-medium text-muted-foreground", className)} {...props} />
),
);
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => <td ref={ref} className={cn("p-4 align-middle", className)} {...props} />,
);
TableCell.displayName = "TableCell";
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow };
+18
View File
@@ -0,0 +1,18 @@
services:
english-pk:
build:
context: .
dockerfile: Dockerfile
args:
NODE_IMAGE: ${NODE_IMAGE:-m.daocloud.io/docker.io/library/node:22}
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
container_name: english-pk
restart: unless-stopped
ports:
- "13000:3000"
environment:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: "1"
volumes:
- ./data:/app/data
- ./public/audio:/app/public/audio
+985
View File
@@ -0,0 +1,985 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>字母XYZ单词PK赛 - 中班英语</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Comic Sans MS', '微软雅黑', sans-serif;
}
body {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: clamp(10px, 2.2vw, 24px);
}
.game-container {
background: white;
border-radius: clamp(18px, 3vw, 30px);
box-shadow: 0 15px 40px rgba(0,0,0,0.15);
width: 100%;
max-width: 1180px;
min-height: min(760px, calc(100vh - clamp(20px, 4.4vw, 48px)));
padding: clamp(18px, 3vw, 34px);
position: relative;
overflow: hidden;
}
.game-container::before {
content: '';
position: absolute;
top: -50px;
right: -50px;
width: 200px;
height: 200px;
background: #ffeb3b;
border-radius: 50%;
opacity: 0.3;
}
.game-container::after {
content: '';
position: absolute;
bottom: -80px;
left: -60px;
width: 250px;
height: 250px;
background: #4fc3f7;
border-radius: 50%;
opacity: 0.2;
}
.header {
text-align: center;
margin-bottom: clamp(12px, 2vw, 22px);
position: relative;
z-index: 1;
}
.title {
font-size: clamp(24px, 3vw, 36px);
color: #ff6b9d;
margin-bottom: 8px;
text-shadow: 2px 2px 0 #fff, 4px 4px 0 #ffd1dc;
}
.subtitle {
font-size: clamp(15px, 1.8vw, 20px);
color: #666;
}
.play-layout {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(360px, 1.25fr);
gap: clamp(18px, 3vw, 34px);
align-items: stretch;
position: relative;
z-index: 1;
}
.left-panel,
.right-panel {
min-width: 0;
}
.left-panel {
display: flex;
flex-direction: column;
}
.right-panel {
display: flex;
flex-direction: column;
justify-content: center;
}
/* 队伍分数栏 */
.team-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: clamp(10px, 1.8vw, 18px);
margin-bottom: clamp(12px, 2vw, 18px);
position: relative;
z-index: 1;
}
.team-card {
flex: 1;
padding: clamp(10px, 1.8vw, 16px);
border-radius: clamp(14px, 2vw, 20px);
text-align: center;
transition: all 0.3s;
border: clamp(3px, 0.45vw, 4px) solid transparent;
}
.team-red {
background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%);
}
.team-blue {
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
}
.team-card.active {
transform: scale(1.03);
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
}
.team-red.active {
border-color: #ef5350;
}
.team-blue.active {
border-color: #42a5f5;
}
.team-name {
font-size: clamp(16px, 2vw, 22px);
font-weight: bold;
margin-bottom: 5px;
}
.team-red .team-name {
color: #c62828;
}
.team-blue .team-name {
color: #1565c0;
}
.team-score {
font-size: clamp(28px, 4vw, 44px);
font-weight: bold;
}
.team-red .team-score {
color: #e53935;
}
.team-blue .team-score {
color: #1e88e5;
}
.vs-text {
font-size: clamp(20px, 2.8vw, 30px);
font-weight: bold;
color: #ff9800;
text-shadow: 2px 2px 0 #fff;
}
.current-turn {
text-align: center;
font-size: clamp(17px, 2vw, 22px);
font-weight: bold;
margin-bottom: clamp(10px, 1.7vw, 16px);
padding: clamp(9px, 1.5vw, 13px);
border-radius: 15px;
position: relative;
z-index: 1;
}
.turn-red {
background: #ffebee;
color: #c62828;
}
.turn-blue {
background: #e3f2fd;
color: #1565c0;
}
.question-area {
text-align: center;
margin-bottom: clamp(14px, 2vw, 24px);
position: relative;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.question-prompt {
font-size: clamp(16px, 1.7vw, 20px);
color: #555;
margin-bottom: clamp(8px, 1.4vw, 12px);
}
.word-display {
font-size: clamp(42px, 7vw, 76px);
font-weight: bold;
color: #4a90e2;
margin: clamp(10px, 2vw, 18px) 0;
letter-spacing: 2px;
text-shadow: 3px 3px 0 #e3f2fd;
overflow-wrap: anywhere;
}
.sound-btn {
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
border: none;
width: clamp(58px, 6vw, 76px);
height: clamp(58px, 6vw, 76px);
border-radius: 50%;
font-size: clamp(24px, 3vw, 34px);
cursor: pointer;
box-shadow: 0 6px 15px rgba(255, 107, 157, 0.4);
transition: all 0.2s;
}
.sound-btn:disabled {
cursor: not-allowed;
opacity: 0.45;
box-shadow: none;
transform: none;
}
.sound-btn:hover {
transform: scale(1.1);
}
.sound-btn:active {
transform: scale(0.95);
}
.sound-btn:disabled:hover,
.sound-btn:disabled:active {
transform: none;
}
.speech-tip {
min-height: 24px;
margin-top: clamp(8px, 1.4vw, 12px);
font-size: clamp(13px, 1.4vw, 16px);
color: #7a7a7a;
}
.speech-tip.error {
color: #d84315;
}
.options-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: clamp(14px, 2vw, 22px);
margin-bottom: clamp(12px, 2vw, 20px);
position: relative;
z-index: 1;
}
.option-card {
background: white;
border: clamp(3px, 0.45vw, 4px) solid #e0e0e0;
border-radius: clamp(14px, 2vw, 20px);
min-height: clamp(126px, 19vh, 184px);
padding: clamp(16px, 2.4vw, 28px) clamp(10px, 1.5vw, 16px);
text-align: center;
cursor: pointer;
transition: all 0.3s;
font-size: clamp(22px, 3vw, 34px);
font-weight: bold;
color: #333;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.option-card:hover {
border-color: #4fc3f7;
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(79, 195, 247, 0.3);
}
.option-card.correct {
border-color: #66bb6a;
background: #e8f5e9;
animation: correct-bounce 0.5s ease;
}
.option-card.wrong {
border-color: #ef5350;
background: #ffebee;
animation: wrong-shake 0.5s ease;
}
.option-emoji {
font-size: clamp(42px, 6vw, 70px);
display: block;
margin-bottom: clamp(8px, 1.4vw, 12px);
}
@keyframes correct-bounce {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
@keyframes wrong-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
.feedback {
text-align: center;
font-size: clamp(18px, 2.2vw, 26px);
font-weight: bold;
min-height: 40px;
margin-bottom: clamp(10px, 1.7vw, 14px);
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
}
.feedback.correct {
color: #66bb6a;
}
.feedback.wrong {
color: #ef5350;
}
.next-btn {
display: block;
margin: 0 auto;
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
border: none;
padding: clamp(12px, 1.8vw, 16px) clamp(34px, 5vw, 54px);
font-size: clamp(16px, 1.8vw, 20px);
border-radius: 30px;
cursor: pointer;
color: #555;
font-weight: bold;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
transition: all 0.2s;
position: relative;
z-index: 1;
}
.next-btn:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
}
.start-screen, .end-screen {
text-align: center;
padding: clamp(32px, 6vw, 70px) clamp(18px, 3vw, 28px);
position: relative;
z-index: 1;
}
.big-emoji {
font-size: clamp(74px, 10vw, 120px);
margin-bottom: clamp(16px, 2.5vw, 24px);
animation: float 2s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-15px); }
}
.start-btn {
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
border: none;
padding: clamp(15px, 2.2vw, 20px) clamp(42px, 6vw, 62px);
font-size: clamp(19px, 2.4vw, 25px);
border-radius: 40px;
cursor: pointer;
color: white;
font-weight: bold;
box-shadow: 0 8px 25px rgba(255, 107, 157, 0.4);
transition: all 0.3s;
margin-top: 20px;
}
.start-btn:hover {
transform: scale(1.05);
}
.letter-badge {
display: inline-block;
width: clamp(38px, 4.5vw, 50px);
height: clamp(38px, 4.5vw, 50px);
line-height: clamp(38px, 4.5vw, 50px);
border-radius: 12px;
font-size: clamp(22px, 2.7vw, 29px);
font-weight: bold;
color: white;
margin: 0 4px;
}
.letter-x { background: #ff7043; }
.letter-y { background: #ffca28; }
.letter-z { background: #42a5f5; }
.hidden {
display: none;
}
.progress-bar {
width: 100%;
height: clamp(9px, 1vw, 12px);
background: #f0f0f0;
border-radius: 10px;
overflow: hidden;
margin-bottom: clamp(12px, 1.8vw, 18px);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #66bb6a, #43a047);
width: 0%;
transition: width 0.5s ease;
border-radius: 10px;
}
.winner-text {
font-size: clamp(28px, 4vw, 44px);
font-weight: bold;
margin: 20px 0;
animation: pulse 1s ease-in-out infinite;
}
.winner-red {
color: #e53935;
}
.winner-blue {
color: #1e88e5;
}
.winner-draw {
color: #ff9800;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.final-scores {
display: flex;
justify-content: center;
gap: clamp(28px, 5vw, 56px);
margin: clamp(20px, 3vw, 30px) 0;
font-size: clamp(18px, 2vw, 22px);
}
@media (max-width: 820px) {
.game-container {
min-height: auto;
}
.play-layout {
grid-template-columns: 1fr;
gap: 16px;
}
.question-area {
min-height: 210px;
}
.option-card {
min-height: 118px;
}
}
@media (max-width: 600px) {
body {
align-items: flex-start;
}
.options-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.team-bar {
gap: 8px;
}
.vs-text {
font-size: 18px;
}
.option-card {
min-height: 96px;
}
}
</style>
</head>
<body>
<div class="game-container">
<!-- 开始界面 -->
<div id="startScreen" class="start-screen">
<div class="big-emoji">🦊 VS 🦓</div>
<h1 class="title">字母XYZ单词PK赛</h1>
<p class="subtitle">
<span class="letter-badge letter-x">X</span>
<span class="letter-badge letter-y">Y</span>
<span class="letter-badge letter-z">Z</span>
小组轮流对战
</p>
<p style="margin-top: 20px; color: #888; font-size: 16px;">
红队蓝队轮流答题,每队一题,答对加10分,答错不扣分!
</p>
<button class="start-btn" onclick="startGame()">🎮 开始PK</button>
</div>
<!-- 游戏界面 -->
<div id="gameScreen" class="hidden">
<div class="header">
<h1 class="title">单词PK大作战</h1>
</div>
<div class="play-layout">
<div class="left-panel">
<!-- 队伍分数 -->
<div class="team-bar">
<div id="teamRedCard" class="team-card team-red active">
<div class="team-name">🔴 红队</div>
<div id="scoreRed" class="team-score">0</div>
</div>
<div class="vs-text">VS</div>
<div id="teamBlueCard" class="team-card team-blue">
<div class="team-name">🔵 蓝队</div>
<div id="scoreBlue" class="team-score">0</div>
</div>
</div>
<!-- 当前回合提示 -->
<div id="currentTurn" class="current-turn turn-red">
🔴 红队请作答
</div>
<div class="progress-bar">
<div id="progressFill" class="progress-fill"></div>
</div>
<div class="question-area">
<p class="question-prompt">听一听,这是哪个单词?</p>
<button id="soundBtn" class="sound-btn" onclick="playWordSound()" aria-label="重新朗读当前单词">🔊</button>
<div id="wordDisplay" class="word-display">box</div>
<div id="speechTip" class="speech-tip">准备朗读中...</div>
</div>
</div>
<div class="right-panel">
<div id="optionsGrid" class="options-grid">
<!-- 选项卡片由JS生成 -->
</div>
<div id="feedback" class="feedback"></div>
<button id="nextBtn" class="next-btn hidden" onclick="nextQuestion()">下一题 ➡️</button>
</div>
</div>
</div>
<!-- 结束界面 -->
<div id="endScreen" class="end-screen hidden">
<div class="big-emoji" id="endEmoji">🏆</div>
<h1 class="title">PK结束!</h1>
<div id="winnerText" class="winner-text">红队获胜!</div>
<div class="final-scores">
<div>
<div style="color: #e53935; font-weight: bold;">🔴 红队</div>
<div id="finalRed" style="font-size: 32px; font-weight: bold; color: #e53935;">0</div>
</div>
<div>
<div style="color: #1e88e5; font-weight: bold;">🔵 蓝队</div>
<div id="finalBlue" style="font-size: 32px; font-weight: bold; color: #1e88e5;">0</div>
</div>
</div>
<button class="start-btn" onclick="restartGame()">🔄 再来一局</button>
</div>
</div>
<script>
// 12个核心单词完整列表
const words = [
{ word: 'box', emoji: '📦', letter: 'x' },
{ word: 'fox', emoji: '🦊', letter: 'x' },
{ word: 'ox', emoji: '🐂', letter: 'x' },
{ word: 'ax', emoji: '🪓', letter: 'x' },
{ word: 'yarn', emoji: '🧶', letter: 'y' },
{ word: 'yo-yo', emoji: '🪀', letter: 'y' },
{ word: 'yam', emoji: '🍠', letter: 'y' },
{ word: 'yellow', emoji: '💛', letter: 'y' },
{ word: 'zoo', emoji: '🏞️', letter: 'z' },
{ word: 'zero', emoji: '0️⃣', letter: 'z' },
{ word: 'zebra', emoji: '🦓', letter: 'z' },
{ word: 'zipper', emoji: '🧥', letter: 'z' }
];
let currentQuestion = 0;
let scoreRed = 0;
let scoreBlue = 0;
let currentTeam = 'red';
let shuffledWords = [];
let answered = false;
let audioContext = null;
let speechEngine = null;
let speechReady = false;
let speechLoaderPromise = null;
let autoSpeakTimer = null;
let pendingSpeechWord = null;
let englishVoice = null;
// 初始化音效上下文
function initAudio() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
}
function updateSpeechTip(message, isError = false) {
const tip = document.getElementById('speechTip');
if (!tip) return;
tip.textContent = message;
tip.className = isError ? 'speech-tip error' : 'speech-tip';
}
function updateSoundButtonEnabled(enabled) {
const btn = document.getElementById('soundBtn');
if (btn) btn.disabled = !enabled;
}
function loadSpeechEngine() {
if (!('speechSynthesis' in window) || !('SpeechSynthesisUtterance' in window)) {
return Promise.reject(new Error('当前浏览器不支持英文朗读'));
}
if (speechLoaderPromise) return speechLoaderPromise;
speechLoaderPromise = new Promise((resolve) => {
const pickVoice = () => {
const voices = window.speechSynthesis.getVoices();
englishVoice =
voices.find(voice => /^en[-_]?US/i.test(voice.lang)) ||
voices.find(voice => /^en/i.test(voice.lang)) ||
voices[0] ||
null;
resolve(window.speechSynthesis);
};
pickVoice();
if (!englishVoice) {
window.speechSynthesis.onvoiceschanged = pickVoice;
setTimeout(pickVoice, 1000);
}
});
return speechLoaderPromise;
}
function initSpeech() {
speechReady = false;
updateSoundButtonEnabled(false);
updateSpeechTip('正在加载英文朗读模块...', false);
loadSpeechEngine()
.then(engine => {
speechEngine = engine;
if (!speechEngine) {
throw new Error('英文朗读未初始化');
}
speechReady = true;
updateSoundButtonEnabled(true);
updateSpeechTip('英文朗读已就绪,点击喇叭可重新朗读。');
if (pendingSpeechWord) {
const word = pendingSpeechWord;
pendingSpeechWord = null;
speakWord(word);
}
})
.catch(() => {
speechReady = false;
updateSoundButtonEnabled(false);
updateSpeechTip('英文朗读模块加载失败,请直接带读单词。', true);
});
}
function stopSpeech() {
clearTimeout(autoSpeakTimer);
pendingSpeechWord = null;
if (speechEngine && typeof speechEngine.cancel === 'function') {
speechEngine.cancel();
}
}
// 答对音效
function playCorrectSound() {
initAudio();
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
osc.frequency.setValueAtTime(523.25, audioContext.currentTime);
osc.frequency.setValueAtTime(659.25, audioContext.currentTime + 0.1);
osc.frequency.setValueAtTime(783.99, audioContext.currentTime + 0.2);
gain.gain.setValueAtTime(0.3, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.4);
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + 0.4);
}
// 答错音效
function playWrongSound() {
initAudio();
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
osc.frequency.setValueAtTime(200, audioContext.currentTime);
osc.frequency.setValueAtTime(150, audioContext.currentTime + 0.15);
gain.gain.setValueAtTime(0.3, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + 0.3);
}
// 单词标准发音(第三方英文 TTS,尽量保证跨设备可用)
function speakWord(word) {
if (!speechEngine || !speechReady) {
pendingSpeechWord = word;
updateSpeechTip('英文朗读模块正在准备中,稍后会自动播放。', false);
return false;
}
try {
speechEngine.cancel();
clearTimeout(autoSpeakTimer);
const utterance = new SpeechSynthesisUtterance(word);
utterance.lang = englishVoice?.lang || 'en-US';
utterance.voice = englishVoice;
utterance.rate = 0.85;
utterance.pitch = 1;
utterance.volume = 1;
speechEngine.speak(utterance);
updateSpeechTip(`正在朗读:${word}`);
return true;
} catch (err) {
pendingSpeechWord = word;
updateSpeechTip('英文朗读暂时不可用,请直接带读单词。', true);
return false;
}
}
// 数组随机打乱
function shuffleArray(array) {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
}
return newArray;
}
// 切换答题队伍
function switchTeam() {
currentTeam = currentTeam === 'red' ? 'blue' : 'red';
updateTeamDisplay();
}
// 更新队伍高亮状态
function updateTeamDisplay() {
const redCard = document.getElementById('teamRedCard');
const blueCard = document.getElementById('teamBlueCard');
const turnText = document.getElementById('currentTurn');
if (currentTeam === 'red') {
redCard.classList.add('active');
blueCard.classList.remove('active');
turnText.textContent = '🔴 红队请作答';
turnText.className = 'current-turn turn-red';
} else {
blueCard.classList.add('active');
redCard.classList.remove('active');
turnText.textContent = '🔵 蓝队请作答';
turnText.className = 'current-turn turn-blue';
}
}
// 开始游戏
function startGame() {
initAudio();
stopSpeech();
initSpeech();
shuffledWords = shuffleArray(words);
currentQuestion = 0;
scoreRed = 0;
scoreBlue = 0;
currentTeam = 'red';
document.getElementById('scoreRed').textContent = 0;
document.getElementById('scoreBlue').textContent = 0;
document.getElementById('startScreen').classList.add('hidden');
document.getElementById('gameScreen').classList.remove('hidden');
document.getElementById('endScreen').classList.add('hidden');
updateTeamDisplay();
loadQuestion();
}
// 加载单道题目
function loadQuestion() {
answered = false;
const currentWord = shuffledWords[currentQuestion];
stopSpeech();
document.getElementById('wordDisplay').textContent = currentWord.word;
document.getElementById('feedback').textContent = '';
document.getElementById('feedback').className = 'feedback';
document.getElementById('nextBtn').classList.add('hidden');
// 更新进度条
const progress = (currentQuestion / words.length) * 100;
document.getElementById('progressFill').style.width = `${progress}%`;
// 生成3个错误选项+1个正确选项
const wrongOptions = words.filter(w => w.word !== currentWord.word);
const shuffledWrong = shuffleArray(wrongOptions).slice(0, 3);
const allOptions = shuffleArray([currentWord, ...shuffledWrong]);
// 渲染选项卡片
const grid = document.getElementById('optionsGrid');
grid.innerHTML = '';
allOptions.forEach(option => {
const card = document.createElement('div');
card.className = 'option-card';
card.innerHTML = `<span class="option-emoji">${option.emoji}</span>${option.word}`;
card.onclick = () => checkAnswer(option.word, currentWord.word, card);
grid.appendChild(card);
});
// 题目加载后自动朗读一次单词
updateSoundButtonEnabled(speechReady);
clearTimeout(autoSpeakTimer);
autoSpeakTimer = setTimeout(() => {
speakWord(currentWord.word);
}, 450);
}
// 点击喇叭播放单词发音
function playWordSound() {
const currentWord = shuffledWords[currentQuestion];
speakWord(currentWord.word);
}
// 校验答案
function checkAnswer(selected, correct, card) {
if (answered) return;
answered = true;
const feedback = document.getElementById('feedback');
const allCards = document.querySelectorAll('.option-card');
if (selected === correct) {
playCorrectSound();
card.classList.add('correct');
feedback.textContent = '🎉 太棒了!答对啦!+10分';
feedback.classList.add('correct');
// 对应队伍加分
if (currentTeam === 'red') {
scoreRed += 10;
document.getElementById('scoreRed').textContent = scoreRed;
} else {
scoreBlue += 10;
document.getElementById('scoreBlue').textContent = scoreBlue;
}
} else {
playWrongSound();
card.classList.add('wrong');
feedback.textContent = '😅 答错啦,轮到下一队!';
feedback.classList.add('wrong');
// 标出正确答案
allCards.forEach(c => {
if (c.textContent.includes(correct)) {
c.classList.add('correct');
}
});
}
document.getElementById('nextBtn').classList.remove('hidden');
}
// 进入下一题
function nextQuestion() {
// 每道题结束后强制轮换队伍,实现一边一次
stopSpeech();
switchTeam();
currentQuestion++;
if (currentQuestion >= words.length) {
endGame();
} else {
loadQuestion();
}
}
// 游戏结束结算
function endGame() {
stopSpeech();
document.getElementById('gameScreen').classList.add('hidden');
document.getElementById('endScreen').classList.remove('hidden');
document.getElementById('finalRed').textContent = scoreRed;
document.getElementById('finalBlue').textContent = scoreBlue;
const winnerText = document.getElementById('winnerText');
const endEmoji = document.getElementById('endEmoji');
if (scoreRed > scoreBlue) {
winnerText.textContent = '🔴 红队获胜!';
winnerText.className = 'winner-text winner-red';
endEmoji.textContent = '🏆🎉';
} else if (scoreBlue > scoreRed) {
winnerText.textContent = '🔵 蓝队获胜!';
winnerText.className = 'winner-text winner-blue';
endEmoji.textContent = '🏆🎉';
} else {
winnerText.textContent = '🤝 平局!势均力敌!';
winnerText.className = 'winner-text winner-draw';
endEmoji.textContent = '🤝✨';
}
}
// 重新开始游戏
function restartGame() {
stopSpeech();
startGame();
}
</script>
</body>
</html>
+446
View File
@@ -0,0 +1,446 @@
import { existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
export type Grade = "小班" | "中班" | "大班";
export type ClassType = {
id: number;
name: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
};
export type AppSettings = {
ttsVoice: string;
ttsRate: string;
scorePerCorrect: number;
scorePerWrong: number;
autoSpeak: boolean;
wordDisplayMode: "uppercase" | "capitalize" | "input";
pkWordCount: number;
pkOptionCount: number;
};
export type WordRecord = {
id: number;
grade: Grade;
classTypeId: number;
classTypeName: string;
english: string;
chinese: string;
image: string;
audio: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
};
export type WordInput = {
grade: Grade;
classTypeId: number;
english: string;
chinese: string;
image: string;
audio?: string;
enabled: boolean;
};
type DbWordRow = {
id: number;
grade: Grade;
class_type_id: number;
class_type_name: string;
english: string;
chinese: string | null;
image: string | null;
audio: string | null;
enabled: 0 | 1;
created_at: string;
updated_at: string;
};
type DbClassTypeRow = {
id: number;
name: string;
enabled: 0 | 1;
created_at: string;
updated_at: string;
};
const dbDirectory = path.join(process.cwd(), "data");
const dbPath = path.join(dbDirectory, "english-pk.sqlite");
let db: DatabaseSync | null = null;
const defaultClassTypes = ["基础班", "提高班", "复习班"];
const defaultWords = [
["中班", "基础班", "box", "盒子", "📦"],
["中班", "基础班", "fox", "狐狸", "🦊"],
["中班", "基础班", "ox", "公牛", "🐂"],
["中班", "基础班", "ax", "斧头", "🪓"],
["中班", "提高班", "yarn", "毛线", "🧶"],
["中班", "提高班", "yo-yo", "悠悠球", "🪀"],
["中班", "提高班", "yam", "山药", "🍠"],
["中班", "提高班", "yellow", "黄色", "💛"],
["中班", "复习班", "zoo", "动物园", "🏞️"],
["中班", "复习班", "zero", "零", "0️⃣"],
["中班", "复习班", "zebra", "斑马", "🦓"],
["中班", "复习班", "zipper", "拉链", "🧥"],
] satisfies Array<[Grade, string, string, string, string]>;
function mapClassType(row: DbClassTypeRow): ClassType {
return {
id: row.id,
name: row.name,
enabled: Boolean(row.enabled),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mapWord(row: DbWordRow): WordRecord {
return {
id: row.id,
grade: row.grade,
classTypeId: row.class_type_id,
classTypeName: row.class_type_name,
english: row.english,
chinese: row.chinese ?? "",
image: row.image ?? "",
audio: row.audio ?? "",
enabled: Boolean(row.enabled),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function getDatabase() {
if (db) return db;
if (!existsSync(dbDirectory)) {
mkdirSync(dbDirectory, { recursive: true });
}
db = new DatabaseSync(dbPath);
db.exec("PRAGMA journal_mode = WAL;");
db.exec("PRAGMA foreign_keys = ON;");
initializeDatabase(db);
return db;
}
function initializeDatabase(database: DatabaseSync) {
database.exec(`
CREATE TABLE IF NOT EXISTS class_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS words (
id INTEGER PRIMARY KEY AUTOINCREMENT,
grade TEXT NOT NULL,
class_type_id INTEGER NOT NULL,
english TEXT NOT NULL,
chinese TEXT NOT NULL DEFAULT '',
image TEXT NOT NULL DEFAULT '',
audio TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (class_type_id) REFERENCES class_types(id) ON DELETE RESTRICT,
UNIQUE (grade, class_type_id, english)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
const wordColumns = database.prepare("PRAGMA table_info(words)").all() as Array<{ name: string }>;
if (!wordColumns.some((column) => column.name === "image")) {
database.exec("ALTER TABLE words ADD COLUMN image TEXT NOT NULL DEFAULT '';");
}
if (!wordColumns.some((column) => column.name === "chinese")) {
database.exec("ALTER TABLE words ADD COLUMN chinese TEXT NOT NULL DEFAULT '';");
}
if (!wordColumns.some((column) => column.name === "audio")) {
database.exec("ALTER TABLE words ADD COLUMN audio TEXT NOT NULL DEFAULT '';");
}
const classTypeCount = database.prepare("SELECT COUNT(*) AS count FROM class_types").get() as { count: number };
if (classTypeCount.count === 0) {
const insertClassType = database.prepare("INSERT INTO class_types (name, enabled) VALUES (?, 1)");
for (const name of defaultClassTypes) {
insertClassType.run(name);
}
}
const wordCount = database.prepare("SELECT COUNT(*) AS count FROM words").get() as { count: number };
if (wordCount.count === 0) {
const findClassType = database.prepare("SELECT id FROM class_types WHERE name = ?");
const insertWord = database.prepare(
"INSERT INTO words (grade, class_type_id, english, chinese, image, enabled) VALUES (?, ?, ?, ?, ?, 1)",
);
for (const [grade, classTypeName, english, chinese, image] of defaultWords) {
const classType = findClassType.get(classTypeName) as { id: number } | undefined;
if (classType) {
insertWord.run(grade, classType.id, english, chinese, image);
}
}
}
const defaultIconUpdates = database.prepare(
"UPDATE words SET image = ? WHERE english = ? AND (image IS NULL OR image = '')",
);
for (const [, , english, , image] of defaultWords) {
defaultIconUpdates.run(image, english);
}
const defaultChineseUpdates = database.prepare(
"UPDATE words SET chinese = ? WHERE english = ? AND (chinese IS NULL OR chinese = '')",
);
for (const [, , english, chinese] of defaultWords) {
defaultChineseUpdates.run(chinese, english);
}
const defaultSettings: AppSettings = {
ttsVoice: "en-US-EmmaMultilingualNeural",
ttsRate: "-10%",
scorePerCorrect: 10,
scorePerWrong: 0,
autoSpeak: true,
wordDisplayMode: "uppercase",
pkWordCount: 0,
pkOptionCount: 4,
};
const insertSetting = database.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)");
for (const [key, value] of Object.entries(defaultSettings)) {
insertSetting.run(key, String(value));
}
}
export function listClassTypes(includeDisabled = true) {
const database = getDatabase();
const sql = includeDisabled
? "SELECT * FROM class_types ORDER BY id ASC"
: "SELECT * FROM class_types WHERE enabled = 1 ORDER BY id ASC";
return (database.prepare(sql).all() as DbClassTypeRow[]).map(mapClassType);
}
export function createClassType(name: string) {
const database = getDatabase();
const trimmedName = name.trim();
if (!trimmedName) {
throw new Error("班级类型不能为空");
}
const result = database
.prepare("INSERT INTO class_types (name, enabled) VALUES (?, 1)")
.run(trimmedName);
return getClassType(Number(result.lastInsertRowid));
}
export function updateClassType(id: number, input: { name: string; enabled: boolean }) {
const database = getDatabase();
const trimmedName = input.name.trim();
if (!trimmedName) {
throw new Error("班级类型不能为空");
}
database
.prepare("UPDATE class_types SET name = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(trimmedName, input.enabled ? 1 : 0, id);
return getClassType(id);
}
export function deleteClassType(id: number) {
const database = getDatabase();
database.prepare("DELETE FROM class_types WHERE id = ?").run(id);
}
function getClassType(id: number) {
const row = getDatabase().prepare("SELECT * FROM class_types WHERE id = ?").get(id) as
| DbClassTypeRow
| undefined;
if (!row) return null;
return mapClassType(row);
}
export function listWords(filters?: { grade?: string; classTypeId?: number; enabledOnly?: boolean }) {
const database = getDatabase();
const clauses: string[] = [];
const values: Array<string | number> = [];
if (filters?.grade) {
clauses.push("words.grade = ?");
values.push(filters.grade);
}
if (filters?.classTypeId) {
clauses.push("words.class_type_id = ?");
values.push(filters.classTypeId);
}
if (filters?.enabledOnly) {
clauses.push("words.enabled = 1");
clauses.push("class_types.enabled = 1");
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
const rows = database
.prepare(
`
SELECT
words.id,
words.grade,
words.class_type_id,
class_types.name AS class_type_name,
words.english,
words.chinese,
words.image,
words.audio,
words.enabled,
words.created_at,
words.updated_at
FROM words
JOIN class_types ON class_types.id = words.class_type_id
${where}
ORDER BY words.id ASC
`,
)
.all(...values) as DbWordRow[];
return rows.map(mapWord);
}
export function findWord(id: number) {
return getWord(id);
}
export function createWord(input: WordInput) {
const database = getDatabase();
validateWordInput(input);
const result = database
.prepare("INSERT INTO words (grade, class_type_id, english, chinese, image, audio, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)")
.run(
input.grade,
input.classTypeId,
input.english.trim(),
input.chinese.trim(),
input.image.trim(),
input.audio?.trim() ?? "",
input.enabled ? 1 : 0,
);
return getWord(Number(result.lastInsertRowid));
}
export function updateWord(id: number, input: WordInput) {
const database = getDatabase();
validateWordInput(input);
const existing = getWord(id);
const shouldClearAudio =
existing &&
(existing.grade !== input.grade ||
existing.classTypeId !== input.classTypeId ||
existing.english !== input.english.trim());
database
.prepare(
"UPDATE words SET grade = ?, class_type_id = ?, english = ?, chinese = ?, image = ?, audio = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
)
.run(
input.grade,
input.classTypeId,
input.english.trim(),
input.chinese.trim(),
input.image.trim(),
shouldClearAudio ? "" : input.audio?.trim() ?? existing?.audio ?? "",
input.enabled ? 1 : 0,
id,
);
return getWord(id);
}
export function deleteWord(id: number) {
const database = getDatabase();
database.prepare("DELETE FROM words WHERE id = ?").run(id);
}
export function updateWordAudio(id: number, audio: string) {
const database = getDatabase();
database
.prepare("UPDATE words SET audio = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
.run(audio.trim(), id);
return getWord(id);
}
function getWord(id: number) {
return listWords().find((word) => word.id === id) ?? null;
}
function validateWordInput(input: WordInput) {
if (!["小班", "中班", "大班"].includes(input.grade)) {
throw new Error("年级不正确");
}
if (!Number.isInteger(input.classTypeId) || input.classTypeId <= 0) {
throw new Error("班级类型不正确");
}
if (!input.english.trim()) {
throw new Error("题库英语不能为空");
}
}
export function getSettings(): AppSettings {
const rows = getDatabase().prepare("SELECT key, value FROM settings").all() as Array<{
key: string;
value: string;
}>;
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]));
return {
ttsVoice: settings.ttsVoice || "en-US-EmmaMultilingualNeural",
ttsRate: settings.ttsRate || "-10%",
scorePerCorrect: Number(settings.scorePerCorrect || 10),
scorePerWrong: Number(settings.scorePerWrong || 0),
autoSpeak: settings.autoSpeak !== "false",
wordDisplayMode: parseWordDisplayMode(settings.wordDisplayMode),
pkWordCount: Math.max(0, Number(settings.pkWordCount || 0)),
pkOptionCount: normalizeOptionCount(Number(settings.pkOptionCount || 4)),
};
}
export function updateSettings(input: AppSettings) {
const database = getDatabase();
const statement = database.prepare(
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
);
statement.run("ttsVoice", input.ttsVoice.trim() || "en-US-EmmaMultilingualNeural");
statement.run("ttsRate", input.ttsRate.trim() || "-10%");
statement.run("scorePerCorrect", String(Math.max(1, Number(input.scorePerCorrect) || 10)));
statement.run("scorePerWrong", String(Math.max(0, Number(input.scorePerWrong) || 0)));
statement.run("autoSpeak", String(Boolean(input.autoSpeak)));
statement.run("wordDisplayMode", parseWordDisplayMode(input.wordDisplayMode));
statement.run("pkWordCount", String(Math.max(0, Math.floor(Number(input.pkWordCount) || 0))));
statement.run("pkOptionCount", String(normalizeOptionCount(Number(input.pkOptionCount) || 4)));
return getSettings();
}
function parseWordDisplayMode(value: unknown): AppSettings["wordDisplayMode"] {
return value === "capitalize" || value === "input" || value === "uppercase" ? value : "uppercase";
}
function normalizeOptionCount(value: number) {
const count = Math.floor(value);
if (!Number.isFinite(count)) return 4;
if (count < 2) return 2;
if (count > 8) return 8;
return count % 2 === 0 ? count : count + 1;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default nextConfig;
+7821
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "english-pk",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"tauri": "tauri"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.17",
"@radix-ui/react-slot": "^1.2.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"edge-tts-universal": "^1.4.0",
"lucide-react": "^0.468.0",
"next": "^16.2.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-next": "^16.2.9",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}
+985
View File
@@ -0,0 +1,985 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>字母XYZ单词PK赛 - 中班英语</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Comic Sans MS', '微软雅黑', sans-serif;
}
body {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: clamp(10px, 2.2vw, 24px);
}
.game-container {
background: white;
border-radius: clamp(18px, 3vw, 30px);
box-shadow: 0 15px 40px rgba(0,0,0,0.15);
width: 100%;
max-width: 1180px;
min-height: min(760px, calc(100vh - clamp(20px, 4.4vw, 48px)));
padding: clamp(18px, 3vw, 34px);
position: relative;
overflow: hidden;
}
.game-container::before {
content: '';
position: absolute;
top: -50px;
right: -50px;
width: 200px;
height: 200px;
background: #ffeb3b;
border-radius: 50%;
opacity: 0.3;
}
.game-container::after {
content: '';
position: absolute;
bottom: -80px;
left: -60px;
width: 250px;
height: 250px;
background: #4fc3f7;
border-radius: 50%;
opacity: 0.2;
}
.header {
text-align: center;
margin-bottom: clamp(12px, 2vw, 22px);
position: relative;
z-index: 1;
}
.title {
font-size: clamp(24px, 3vw, 36px);
color: #ff6b9d;
margin-bottom: 8px;
text-shadow: 2px 2px 0 #fff, 4px 4px 0 #ffd1dc;
}
.subtitle {
font-size: clamp(15px, 1.8vw, 20px);
color: #666;
}
.play-layout {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(360px, 1.25fr);
gap: clamp(18px, 3vw, 34px);
align-items: stretch;
position: relative;
z-index: 1;
}
.left-panel,
.right-panel {
min-width: 0;
}
.left-panel {
display: flex;
flex-direction: column;
}
.right-panel {
display: flex;
flex-direction: column;
justify-content: center;
}
/* 队伍分数栏 */
.team-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: clamp(10px, 1.8vw, 18px);
margin-bottom: clamp(12px, 2vw, 18px);
position: relative;
z-index: 1;
}
.team-card {
flex: 1;
padding: clamp(10px, 1.8vw, 16px);
border-radius: clamp(14px, 2vw, 20px);
text-align: center;
transition: all 0.3s;
border: clamp(3px, 0.45vw, 4px) solid transparent;
}
.team-red {
background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%);
}
.team-blue {
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
}
.team-card.active {
transform: scale(1.03);
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
}
.team-red.active {
border-color: #ef5350;
}
.team-blue.active {
border-color: #42a5f5;
}
.team-name {
font-size: clamp(16px, 2vw, 22px);
font-weight: bold;
margin-bottom: 5px;
}
.team-red .team-name {
color: #c62828;
}
.team-blue .team-name {
color: #1565c0;
}
.team-score {
font-size: clamp(28px, 4vw, 44px);
font-weight: bold;
}
.team-red .team-score {
color: #e53935;
}
.team-blue .team-score {
color: #1e88e5;
}
.vs-text {
font-size: clamp(20px, 2.8vw, 30px);
font-weight: bold;
color: #ff9800;
text-shadow: 2px 2px 0 #fff;
}
.current-turn {
text-align: center;
font-size: clamp(17px, 2vw, 22px);
font-weight: bold;
margin-bottom: clamp(10px, 1.7vw, 16px);
padding: clamp(9px, 1.5vw, 13px);
border-radius: 15px;
position: relative;
z-index: 1;
}
.turn-red {
background: #ffebee;
color: #c62828;
}
.turn-blue {
background: #e3f2fd;
color: #1565c0;
}
.question-area {
text-align: center;
margin-bottom: clamp(14px, 2vw, 24px);
position: relative;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.question-prompt {
font-size: clamp(16px, 1.7vw, 20px);
color: #555;
margin-bottom: clamp(8px, 1.4vw, 12px);
}
.word-display {
font-size: clamp(42px, 7vw, 76px);
font-weight: bold;
color: #4a90e2;
margin: clamp(10px, 2vw, 18px) 0;
letter-spacing: 2px;
text-shadow: 3px 3px 0 #e3f2fd;
overflow-wrap: anywhere;
}
.sound-btn {
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
border: none;
width: clamp(58px, 6vw, 76px);
height: clamp(58px, 6vw, 76px);
border-radius: 50%;
font-size: clamp(24px, 3vw, 34px);
cursor: pointer;
box-shadow: 0 6px 15px rgba(255, 107, 157, 0.4);
transition: all 0.2s;
}
.sound-btn:disabled {
cursor: not-allowed;
opacity: 0.45;
box-shadow: none;
transform: none;
}
.sound-btn:hover {
transform: scale(1.1);
}
.sound-btn:active {
transform: scale(0.95);
}
.sound-btn:disabled:hover,
.sound-btn:disabled:active {
transform: none;
}
.speech-tip {
min-height: 24px;
margin-top: clamp(8px, 1.4vw, 12px);
font-size: clamp(13px, 1.4vw, 16px);
color: #7a7a7a;
}
.speech-tip.error {
color: #d84315;
}
.options-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: clamp(14px, 2vw, 22px);
margin-bottom: clamp(12px, 2vw, 20px);
position: relative;
z-index: 1;
}
.option-card {
background: white;
border: clamp(3px, 0.45vw, 4px) solid #e0e0e0;
border-radius: clamp(14px, 2vw, 20px);
min-height: clamp(126px, 19vh, 184px);
padding: clamp(16px, 2.4vw, 28px) clamp(10px, 1.5vw, 16px);
text-align: center;
cursor: pointer;
transition: all 0.3s;
font-size: clamp(22px, 3vw, 34px);
font-weight: bold;
color: #333;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.option-card:hover {
border-color: #4fc3f7;
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(79, 195, 247, 0.3);
}
.option-card.correct {
border-color: #66bb6a;
background: #e8f5e9;
animation: correct-bounce 0.5s ease;
}
.option-card.wrong {
border-color: #ef5350;
background: #ffebee;
animation: wrong-shake 0.5s ease;
}
.option-emoji {
font-size: clamp(42px, 6vw, 70px);
display: block;
margin-bottom: clamp(8px, 1.4vw, 12px);
}
@keyframes correct-bounce {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
@keyframes wrong-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
.feedback {
text-align: center;
font-size: clamp(18px, 2.2vw, 26px);
font-weight: bold;
min-height: 40px;
margin-bottom: clamp(10px, 1.7vw, 14px);
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
}
.feedback.correct {
color: #66bb6a;
}
.feedback.wrong {
color: #ef5350;
}
.next-btn {
display: block;
margin: 0 auto;
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
border: none;
padding: clamp(12px, 1.8vw, 16px) clamp(34px, 5vw, 54px);
font-size: clamp(16px, 1.8vw, 20px);
border-radius: 30px;
cursor: pointer;
color: #555;
font-weight: bold;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
transition: all 0.2s;
position: relative;
z-index: 1;
}
.next-btn:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
}
.start-screen, .end-screen {
text-align: center;
padding: clamp(32px, 6vw, 70px) clamp(18px, 3vw, 28px);
position: relative;
z-index: 1;
}
.big-emoji {
font-size: clamp(74px, 10vw, 120px);
margin-bottom: clamp(16px, 2.5vw, 24px);
animation: float 2s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-15px); }
}
.start-btn {
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
border: none;
padding: clamp(15px, 2.2vw, 20px) clamp(42px, 6vw, 62px);
font-size: clamp(19px, 2.4vw, 25px);
border-radius: 40px;
cursor: pointer;
color: white;
font-weight: bold;
box-shadow: 0 8px 25px rgba(255, 107, 157, 0.4);
transition: all 0.3s;
margin-top: 20px;
}
.start-btn:hover {
transform: scale(1.05);
}
.letter-badge {
display: inline-block;
width: clamp(38px, 4.5vw, 50px);
height: clamp(38px, 4.5vw, 50px);
line-height: clamp(38px, 4.5vw, 50px);
border-radius: 12px;
font-size: clamp(22px, 2.7vw, 29px);
font-weight: bold;
color: white;
margin: 0 4px;
}
.letter-x { background: #ff7043; }
.letter-y { background: #ffca28; }
.letter-z { background: #42a5f5; }
.hidden {
display: none;
}
.progress-bar {
width: 100%;
height: clamp(9px, 1vw, 12px);
background: #f0f0f0;
border-radius: 10px;
overflow: hidden;
margin-bottom: clamp(12px, 1.8vw, 18px);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #66bb6a, #43a047);
width: 0%;
transition: width 0.5s ease;
border-radius: 10px;
}
.winner-text {
font-size: clamp(28px, 4vw, 44px);
font-weight: bold;
margin: 20px 0;
animation: pulse 1s ease-in-out infinite;
}
.winner-red {
color: #e53935;
}
.winner-blue {
color: #1e88e5;
}
.winner-draw {
color: #ff9800;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.final-scores {
display: flex;
justify-content: center;
gap: clamp(28px, 5vw, 56px);
margin: clamp(20px, 3vw, 30px) 0;
font-size: clamp(18px, 2vw, 22px);
}
@media (max-width: 820px) {
.game-container {
min-height: auto;
}
.play-layout {
grid-template-columns: 1fr;
gap: 16px;
}
.question-area {
min-height: 210px;
}
.option-card {
min-height: 118px;
}
}
@media (max-width: 600px) {
body {
align-items: flex-start;
}
.options-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.team-bar {
gap: 8px;
}
.vs-text {
font-size: 18px;
}
.option-card {
min-height: 96px;
}
}
</style>
</head>
<body>
<div class="game-container">
<!-- 开始界面 -->
<div id="startScreen" class="start-screen">
<div class="big-emoji">🦊 VS 🦓</div>
<h1 class="title">字母XYZ单词PK赛</h1>
<p class="subtitle">
<span class="letter-badge letter-x">X</span>
<span class="letter-badge letter-y">Y</span>
<span class="letter-badge letter-z">Z</span>
小组轮流对战
</p>
<p style="margin-top: 20px; color: #888; font-size: 16px;">
红队蓝队轮流答题,每队一题,答对加10分,答错不扣分!
</p>
<button class="start-btn" onclick="startGame()">🎮 开始PK</button>
</div>
<!-- 游戏界面 -->
<div id="gameScreen" class="hidden">
<div class="header">
<h1 class="title">单词PK大作战</h1>
</div>
<div class="play-layout">
<div class="left-panel">
<!-- 队伍分数 -->
<div class="team-bar">
<div id="teamRedCard" class="team-card team-red active">
<div class="team-name">🔴 红队</div>
<div id="scoreRed" class="team-score">0</div>
</div>
<div class="vs-text">VS</div>
<div id="teamBlueCard" class="team-card team-blue">
<div class="team-name">🔵 蓝队</div>
<div id="scoreBlue" class="team-score">0</div>
</div>
</div>
<!-- 当前回合提示 -->
<div id="currentTurn" class="current-turn turn-red">
🔴 红队请作答
</div>
<div class="progress-bar">
<div id="progressFill" class="progress-fill"></div>
</div>
<div class="question-area">
<p class="question-prompt">听一听,这是哪个单词?</p>
<button id="soundBtn" class="sound-btn" onclick="playWordSound()" aria-label="重新朗读当前单词">🔊</button>
<div id="wordDisplay" class="word-display">box</div>
<div id="speechTip" class="speech-tip">准备朗读中...</div>
</div>
</div>
<div class="right-panel">
<div id="optionsGrid" class="options-grid">
<!-- 选项卡片由JS生成 -->
</div>
<div id="feedback" class="feedback"></div>
<button id="nextBtn" class="next-btn hidden" onclick="nextQuestion()">下一题 ➡️</button>
</div>
</div>
</div>
<!-- 结束界面 -->
<div id="endScreen" class="end-screen hidden">
<div class="big-emoji" id="endEmoji">🏆</div>
<h1 class="title">PK结束!</h1>
<div id="winnerText" class="winner-text">红队获胜!</div>
<div class="final-scores">
<div>
<div style="color: #e53935; font-weight: bold;">🔴 红队</div>
<div id="finalRed" style="font-size: 32px; font-weight: bold; color: #e53935;">0</div>
</div>
<div>
<div style="color: #1e88e5; font-weight: bold;">🔵 蓝队</div>
<div id="finalBlue" style="font-size: 32px; font-weight: bold; color: #1e88e5;">0</div>
</div>
</div>
<button class="start-btn" onclick="restartGame()">🔄 再来一局</button>
</div>
</div>
<script>
// 12个核心单词完整列表
const words = [
{ word: 'box', emoji: '📦', letter: 'x' },
{ word: 'fox', emoji: '🦊', letter: 'x' },
{ word: 'ox', emoji: '🐂', letter: 'x' },
{ word: 'ax', emoji: '🪓', letter: 'x' },
{ word: 'yarn', emoji: '🧶', letter: 'y' },
{ word: 'yo-yo', emoji: '🪀', letter: 'y' },
{ word: 'yam', emoji: '🍠', letter: 'y' },
{ word: 'yellow', emoji: '💛', letter: 'y' },
{ word: 'zoo', emoji: '🏞️', letter: 'z' },
{ word: 'zero', emoji: '0️⃣', letter: 'z' },
{ word: 'zebra', emoji: '🦓', letter: 'z' },
{ word: 'zipper', emoji: '🧥', letter: 'z' }
];
let currentQuestion = 0;
let scoreRed = 0;
let scoreBlue = 0;
let currentTeam = 'red';
let shuffledWords = [];
let answered = false;
let audioContext = null;
let speechEngine = null;
let speechReady = false;
let speechLoaderPromise = null;
let autoSpeakTimer = null;
let pendingSpeechWord = null;
let englishVoice = null;
// 初始化音效上下文
function initAudio() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
}
function updateSpeechTip(message, isError = false) {
const tip = document.getElementById('speechTip');
if (!tip) return;
tip.textContent = message;
tip.className = isError ? 'speech-tip error' : 'speech-tip';
}
function updateSoundButtonEnabled(enabled) {
const btn = document.getElementById('soundBtn');
if (btn) btn.disabled = !enabled;
}
function loadSpeechEngine() {
if (!('speechSynthesis' in window) || !('SpeechSynthesisUtterance' in window)) {
return Promise.reject(new Error('当前浏览器不支持英文朗读'));
}
if (speechLoaderPromise) return speechLoaderPromise;
speechLoaderPromise = new Promise((resolve) => {
const pickVoice = () => {
const voices = window.speechSynthesis.getVoices();
englishVoice =
voices.find(voice => /^en[-_]?US/i.test(voice.lang)) ||
voices.find(voice => /^en/i.test(voice.lang)) ||
voices[0] ||
null;
resolve(window.speechSynthesis);
};
pickVoice();
if (!englishVoice) {
window.speechSynthesis.onvoiceschanged = pickVoice;
setTimeout(pickVoice, 1000);
}
});
return speechLoaderPromise;
}
function initSpeech() {
speechReady = false;
updateSoundButtonEnabled(false);
updateSpeechTip('正在加载英文朗读模块...', false);
loadSpeechEngine()
.then(engine => {
speechEngine = engine;
if (!speechEngine) {
throw new Error('英文朗读未初始化');
}
speechReady = true;
updateSoundButtonEnabled(true);
updateSpeechTip('英文朗读已就绪,点击喇叭可重新朗读。');
if (pendingSpeechWord) {
const word = pendingSpeechWord;
pendingSpeechWord = null;
speakWord(word);
}
})
.catch(() => {
speechReady = false;
updateSoundButtonEnabled(false);
updateSpeechTip('英文朗读模块加载失败,请直接带读单词。', true);
});
}
function stopSpeech() {
clearTimeout(autoSpeakTimer);
pendingSpeechWord = null;
if (speechEngine && typeof speechEngine.cancel === 'function') {
speechEngine.cancel();
}
}
// 答对音效
function playCorrectSound() {
initAudio();
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
osc.frequency.setValueAtTime(523.25, audioContext.currentTime);
osc.frequency.setValueAtTime(659.25, audioContext.currentTime + 0.1);
osc.frequency.setValueAtTime(783.99, audioContext.currentTime + 0.2);
gain.gain.setValueAtTime(0.3, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.4);
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + 0.4);
}
// 答错音效
function playWrongSound() {
initAudio();
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
osc.frequency.setValueAtTime(200, audioContext.currentTime);
osc.frequency.setValueAtTime(150, audioContext.currentTime + 0.15);
gain.gain.setValueAtTime(0.3, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + 0.3);
}
// 单词标准发音(第三方英文 TTS,尽量保证跨设备可用)
function speakWord(word) {
if (!speechEngine || !speechReady) {
pendingSpeechWord = word;
updateSpeechTip('英文朗读模块正在准备中,稍后会自动播放。', false);
return false;
}
try {
speechEngine.cancel();
clearTimeout(autoSpeakTimer);
const utterance = new SpeechSynthesisUtterance(word);
utterance.lang = englishVoice?.lang || 'en-US';
utterance.voice = englishVoice;
utterance.rate = 0.85;
utterance.pitch = 1;
utterance.volume = 1;
speechEngine.speak(utterance);
updateSpeechTip(`正在朗读:${word}`);
return true;
} catch (err) {
pendingSpeechWord = word;
updateSpeechTip('英文朗读暂时不可用,请直接带读单词。', true);
return false;
}
}
// 数组随机打乱
function shuffleArray(array) {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
}
return newArray;
}
// 切换答题队伍
function switchTeam() {
currentTeam = currentTeam === 'red' ? 'blue' : 'red';
updateTeamDisplay();
}
// 更新队伍高亮状态
function updateTeamDisplay() {
const redCard = document.getElementById('teamRedCard');
const blueCard = document.getElementById('teamBlueCard');
const turnText = document.getElementById('currentTurn');
if (currentTeam === 'red') {
redCard.classList.add('active');
blueCard.classList.remove('active');
turnText.textContent = '🔴 红队请作答';
turnText.className = 'current-turn turn-red';
} else {
blueCard.classList.add('active');
redCard.classList.remove('active');
turnText.textContent = '🔵 蓝队请作答';
turnText.className = 'current-turn turn-blue';
}
}
// 开始游戏
function startGame() {
initAudio();
stopSpeech();
initSpeech();
shuffledWords = shuffleArray(words);
currentQuestion = 0;
scoreRed = 0;
scoreBlue = 0;
currentTeam = 'red';
document.getElementById('scoreRed').textContent = 0;
document.getElementById('scoreBlue').textContent = 0;
document.getElementById('startScreen').classList.add('hidden');
document.getElementById('gameScreen').classList.remove('hidden');
document.getElementById('endScreen').classList.add('hidden');
updateTeamDisplay();
loadQuestion();
}
// 加载单道题目
function loadQuestion() {
answered = false;
const currentWord = shuffledWords[currentQuestion];
stopSpeech();
document.getElementById('wordDisplay').textContent = currentWord.word;
document.getElementById('feedback').textContent = '';
document.getElementById('feedback').className = 'feedback';
document.getElementById('nextBtn').classList.add('hidden');
// 更新进度条
const progress = (currentQuestion / words.length) * 100;
document.getElementById('progressFill').style.width = `${progress}%`;
// 生成3个错误选项+1个正确选项
const wrongOptions = words.filter(w => w.word !== currentWord.word);
const shuffledWrong = shuffleArray(wrongOptions).slice(0, 3);
const allOptions = shuffleArray([currentWord, ...shuffledWrong]);
// 渲染选项卡片
const grid = document.getElementById('optionsGrid');
grid.innerHTML = '';
allOptions.forEach(option => {
const card = document.createElement('div');
card.className = 'option-card';
card.innerHTML = `<span class="option-emoji">${option.emoji}</span>${option.word}`;
card.onclick = () => checkAnswer(option.word, currentWord.word, card);
grid.appendChild(card);
});
// 题目加载后自动朗读一次单词
updateSoundButtonEnabled(speechReady);
clearTimeout(autoSpeakTimer);
autoSpeakTimer = setTimeout(() => {
speakWord(currentWord.word);
}, 450);
}
// 点击喇叭播放单词发音
function playWordSound() {
const currentWord = shuffledWords[currentQuestion];
speakWord(currentWord.word);
}
// 校验答案
function checkAnswer(selected, correct, card) {
if (answered) return;
answered = true;
const feedback = document.getElementById('feedback');
const allCards = document.querySelectorAll('.option-card');
if (selected === correct) {
playCorrectSound();
card.classList.add('correct');
feedback.textContent = '🎉 太棒了!答对啦!+10分';
feedback.classList.add('correct');
// 对应队伍加分
if (currentTeam === 'red') {
scoreRed += 10;
document.getElementById('scoreRed').textContent = scoreRed;
} else {
scoreBlue += 10;
document.getElementById('scoreBlue').textContent = scoreBlue;
}
} else {
playWrongSound();
card.classList.add('wrong');
feedback.textContent = '😅 答错啦,轮到下一队!';
feedback.classList.add('wrong');
// 标出正确答案
allCards.forEach(c => {
if (c.textContent.includes(correct)) {
c.classList.add('correct');
}
});
}
document.getElementById('nextBtn').classList.remove('hidden');
}
// 进入下一题
function nextQuestion() {
// 每道题结束后强制轮换队伍,实现一边一次
stopSpeech();
switchTeam();
currentQuestion++;
if (currentQuestion >= words.length) {
endGame();
} else {
loadQuestion();
}
}
// 游戏结束结算
function endGame() {
stopSpeech();
document.getElementById('gameScreen').classList.add('hidden');
document.getElementById('endScreen').classList.remove('hidden');
document.getElementById('finalRed').textContent = scoreRed;
document.getElementById('finalBlue').textContent = scoreBlue;
const winnerText = document.getElementById('winnerText');
const endEmoji = document.getElementById('endEmoji');
if (scoreRed > scoreBlue) {
winnerText.textContent = '🔴 红队获胜!';
winnerText.className = 'winner-text winner-red';
endEmoji.textContent = '🏆🎉';
} else if (scoreBlue > scoreRed) {
winnerText.textContent = '🔵 蓝队获胜!';
winnerText.className = 'winner-text winner-blue';
endEmoji.textContent = '🏆🎉';
} else {
winnerText.textContent = '🤝 平局!势均力敌!';
winnerText.className = 'winner-text winner-draw';
endEmoji.textContent = '🤝✨';
}
}
// 重新开始游戏
function restartGame() {
stopSpeech();
startGame();
}
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
Binary file not shown.
Binary file not shown.
Binary file not shown.
+61
View File
@@ -0,0 +1,61 @@
import type { Config } from "tailwindcss";
const config = {
darkMode: ["class"],
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
"./data/**/*.{ts,tsx}",
],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
},
boxShadow: {
soft: "0 18px 50px rgba(15, 23, 42, 0.12)",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
export default config;
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}