Initial English PK app
This commit is contained in:
@@ -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
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
.idea
|
||||||
|
*.log
|
||||||
|
data/*.sqlite
|
||||||
|
data/*.sqlite-*
|
||||||
|
public/audio/*.mp3
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/
|
||||||
+198
@@ -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
|
||||||
|
- SQLite(Node `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
@@ -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"]
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
redirect("/pk");
|
||||||
|
}
|
||||||
+617
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Toaster as Sonner } from "sonner";
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
return <Sonner richColors closeButton position="top-right" />;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
Vendored
+6
@@ -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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+7821
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user