From 17fc24edc7d1bdf723e5deb61a98264a76b4e41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AF=92=E5=AF=92?= <2596194220@qq.com> Date: Thu, 2 Jul 2026 14:34:00 +0800 Subject: [PATCH] feat: add word import and class type display controls --- app/admin/page.tsx | 298 ++++++++++++++++++-- app/api/class-types/[id]/route.ts | 3 +- app/api/class-types/route.ts | 20 +- app/api/words/[id]/generate-audio/route.ts | 2 +- app/api/words/export/route.ts | 25 ++ app/api/words/import/route.ts | 34 +++ app/globals.css | 7 +- app/pk/components/GameScreen.tsx | 28 +- app/pk/hooks/usePkSetup.ts | 45 ++- app/pk/page.tsx | 1 + app/pk/types.ts | 2 + lib/db.ts | 311 +++++++++++++++++++-- lib/word-excel.ts | 57 ++++ next-env.d.ts | 2 +- package.json | 3 +- pnpm-lock.yaml | 72 +++++ 16 files changed, 844 insertions(+), 66 deletions(-) create mode 100644 app/api/words/export/route.ts create mode 100644 app/api/words/import/route.ts create mode 100644 lib/word-excel.ts diff --git a/app/admin/page.tsx b/app/admin/page.tsx index d0d0561..57164fb 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,10 +1,12 @@ "use client"; -import {useEffect, useMemo, useState} from "react"; +import {useEffect, useMemo, useRef, useState} from "react"; import Link from "next/link"; import {toast} from "sonner"; import { + Download, Eye, + FileSpreadsheet, Gamepad2, Mic2, Pencil, @@ -17,6 +19,7 @@ import { Settings2, SlidersHorizontal, Trash2, + Upload, } from "lucide-react"; import {Badge} from "@/components/ui/badge"; import {Button} from "@/components/ui/button"; @@ -37,8 +40,10 @@ type Grade = "小班" | "中班" | "大班"; type ClassType = { id: number; + grade: Grade; name: string; enabled: boolean; + showCurrentWord: boolean; }; type WordRecord = { @@ -84,7 +89,7 @@ type BatchAudioProgress = { current: string; }; -type SettingsSection = "voice" | "rules" | "classTypes" | "preview"; +type SettingsSection = "voice" | "rules" | "classTypes" | "wordImport" | "preview"; const grades: Grade[] = ["小班", "中班", "大班"]; const defaultSettings: AppSettings = { @@ -126,12 +131,13 @@ const settingsSections: Array<{ id: SettingsSection; label: string; description: {id: "voice", label: "语音设置", description: "Edge TTS 声音与语速", icon: Mic2}, {id: "rules", label: "课堂规则", description: "得分与自动朗读", icon: SlidersHorizontal}, {id: "classTypes", label: "班级类型", description: "定制 PK 入口班型", icon: School}, + {id: "wordImport", label: "一键导入", description: "Excel 导入导出词库", icon: FileSpreadsheet}, {id: "preview", label: "页面预览", description: "打开课堂展示页", icon: Eye}, ]; -function createEmptyWordForm(classTypeId = 0): WordForm { +function createEmptyWordForm(classTypeId = 0, grade: Grade = "中班"): WordForm { return { - grade: "中班", + grade, classTypeId, english: "", chinese: "", @@ -152,9 +158,14 @@ export default function AdminPage() { const [wordDialogOpen, setWordDialogOpen] = useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); const [settingsSection, setSettingsSection] = useState("voice"); + const [classTypeGrade, setClassTypeGrade] = useState("中班"); + const [importGrade, setImportGrade] = useState("中班"); + const [importClassTypeId, setImportClassTypeId] = useState(0); + const [importingWords, setImportingWords] = useState(false); const [classTypeName, setClassTypeName] = useState(""); const [editingClassType, setEditingClassType] = useState(null); const [loading, setLoading] = useState(true); + const importFileInputRef = useRef(null); const [batchAudioProgress, setBatchAudioProgress] = useState({ running: false, total: 0, @@ -183,8 +194,9 @@ export default function AdminPage() { setSettings(mergeSettings(settingsData.settings)); setWordForm((form) => ({ ...form, - classTypeId: form.classTypeId || classTypesData.classTypes[0]?.id || 0, + classTypeId: getNextClassTypeId(classTypesData.classTypes, form.grade, form.classTypeId), })); + setImportClassTypeId((current) => getNextClassTypeId(classTypesData.classTypes, importGrade, current)); setLoading(false); } @@ -198,12 +210,61 @@ export default function AdminPage() { return matchQuery && matchGrade && matchClassType; }); }, [wordList, query, gradeFilter, classTypeFilter]); + const classTypesForFilter = useMemo(() => { + return gradeFilter === "all" ? classTypes : classTypes.filter((classType) => classType.grade === gradeFilter); + }, [classTypes, gradeFilter]); + const classTypesForWordForm = useMemo(() => { + return classTypes.filter((classType) => classType.grade === wordForm.grade); + }, [classTypes, wordForm.grade]); + const classTypesForImport = useMemo(() => { + return classTypes.filter((classType) => classType.grade === importGrade); + }, [classTypes, importGrade]); + const classTypesForSettings = useMemo(() => { + return classTypes.filter((classType) => classType.grade === classTypeGrade); + }, [classTypes, classTypeGrade]); function openCreateWordDialog() { - setWordForm(createEmptyWordForm(classTypes[0]?.id || 0)); + const nextGrade = gradeFilter === "all" ? "中班" : gradeFilter; + setWordForm(createEmptyWordForm(getNextClassTypeId(classTypes, nextGrade, 0), nextGrade)); setWordDialogOpen(true); } + function openWordImportSection() { + const selectedClassType = classTypeFilter === "all" ? null : classTypes.find((classType) => classType.id === classTypeFilter); + const nextGrade = gradeFilter === "all" ? selectedClassType?.grade ?? "中班" : gradeFilter; + setImportGrade(nextGrade); + setImportClassTypeId(getNextClassTypeId(classTypes, nextGrade, classTypeFilter === "all" ? 0 : classTypeFilter)); + setSettingsSection("wordImport"); + setSettingsDialogOpen(true); + } + + function changeGradeFilter(nextGrade: "all" | Grade) { + setGradeFilter(nextGrade); + setClassTypeFilter((current) => { + if (current === "all" || nextGrade === "all") return current; + const currentClassType = classTypes.find((classType) => classType.id === current); + return currentClassType?.grade === nextGrade ? current : "all"; + }); + } + + function changeWordFormGrade(nextGrade: Grade) { + setWordForm((form) => ({ + ...form, + grade: nextGrade, + classTypeId: getNextClassTypeId(classTypes, nextGrade, 0), + })); + } + + function changeImportGrade(nextGrade: Grade) { + setImportGrade(nextGrade); + setImportClassTypeId(getNextClassTypeId(classTypes, nextGrade, 0)); + } + + function changeClassTypeGrade(nextGrade: Grade) { + setClassTypeGrade(nextGrade); + setEditingClassType(null); + } + function openEditWordDialog(word: WordRecord) { setWordForm({ id: word.id, @@ -357,8 +418,10 @@ export default function AdminPage() { method: editingClassType ? "PUT" : "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ + grade: classTypeGrade, name, enabled: editingClassType?.enabled ?? true, + showCurrentWord: editingClassType?.showCurrentWord ?? true, }), }); const data = await response.json(); @@ -391,7 +454,11 @@ export default function AdminPage() { const response = await fetch(`/api/class-types/${classType.id}`, { method: "PUT", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({name: classType.name, enabled: !classType.enabled}), + body: JSON.stringify({ + name: classType.name, + enabled: !classType.enabled, + showCurrentWord: classType.showCurrentWord, + }), }); const data = await response.json(); @@ -403,6 +470,26 @@ export default function AdminPage() { await loadData(); } + async function toggleClassTypeCurrentWord(classType: ClassType) { + const response = await fetch(`/api/class-types/${classType.id}`, { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + name: classType.name, + enabled: classType.enabled, + showCurrentWord: !classType.showCurrentWord, + }), + }); + const data = await response.json(); + + if (!response.ok) { + toast.error(data.message ?? "更新当前单词展示失败"); + return; + } + + await loadData(); + } + async function saveSettings() { const response = await fetch("/api/settings", { method: "PUT", @@ -443,6 +530,62 @@ export default function AdminPage() { toast.success("正在播放试听语音", {id: toastId}); } + function exportWordExcel() { + if (!importClassTypeId) { + toast.error("请先选择班级类型"); + return; + } + + const params = new URLSearchParams({ + grade: importGrade, + classTypeId: String(importClassTypeId), + }); + window.location.href = `/api/words/export?${params.toString()}`; + } + + async function importWordExcel(file: File | undefined) { + if (!file || importingWords) return; + if (!importClassTypeId) { + toast.error("请先选择班级类型"); + return; + } + + const formData = new FormData(); + formData.append("grade", importGrade); + formData.append("classTypeId", String(importClassTypeId)); + formData.append("file", file); + + setImportingWords(true); + const toastId = toast.loading("正在导入词库表格"); + try { + const response = await fetch("/api/words/import", { + method: "POST", + body: formData, + }); + const data = (await response.json()) as { + result?: { created: number; excluded: number; skipped: number; errors: string[] }; + message?: string; + }; + + if (!response.ok || !data.result) { + toast.error(data.message ?? "导入失败", {id: toastId}); + return; + } + + const {created, excluded, skipped, errors} = data.result; + toast.success("词库导入完成", { + id: toastId, + description: `新增 ${created} 个,排除已存在 ${excluded} 个,跳过 ${skipped} 个${errors.length ? `;${errors[0]}` : ""}`, + }); + await loadData(); + } finally { + setImportingWords(false); + if (importFileInputRef.current) { + importFileInputRef.current.value = ""; + } + } + } + return (
@@ -465,6 +608,10 @@ export default function AdminPage() { 设置 +
@@ -671,10 +818,7 @@ export default function AdminPage() { 年级 changeClassTypeGrade(event.target.value as Grade)} + className="h-10 w-full rounded-md border bg-white px-3 text-sm" + > + {grades.map((grade) => ( + + ))} + +
- {classTypes.map((classType) => ( + {classTypesForSettings.map((classType) => (
{classType.name}
{classType.enabled ? "已启用" : "已停用"}
+ className="text-xs text-muted-foreground"> + {classType.grade} · {classType.enabled ? "已启用" : "已停用"} · 当前单词{classType.showCurrentWord ? "显示" : "隐藏"} +
-
- toggleClassType(classType)}/> +
+ +
))} + {classTypesForSettings.length === 0 && ( +
+ 当前年级还没有班级类型。 +
+ )} +
+
+ )} + + {settingsSection === "wordImport" && ( +
+
+

一键导入单词

+

+ 表格表头固定为:词库英语、中文、图标/图片。导入会写入当前选择的年级和班级类型。 +

+
+
+ + +
+
+
导出导入表格
+

+ 会导出当前年级和班级类型下的词库;没有词条时会导出一行示例,可直接改完再导入。 +

+ +
+
+
导入表格
+

+ 同一年级和班级类型中已存在的英语会被排除;新增英语会自动加入当前词库。 +

+ void importWordExcel(event.target.files?.[0])} + /> +
)} @@ -1038,6 +1287,11 @@ function isImageSource(value: string) { return /^(https?:\/\/|\/|data:image\/)/i.test(value); } +function getNextClassTypeId(classTypes: ClassType[], grade: Grade, currentId: number) { + const gradeClassTypes = classTypes.filter((classType) => classType.grade === grade); + return gradeClassTypes.some((classType) => classType.id === currentId) ? currentId : gradeClassTypes[0]?.id || 0; +} + function mergeSettings(settings: Partial): AppSettings { return { ttsVoice: settings.ttsVoice || defaultSettings.ttsVoice, diff --git a/app/api/class-types/[id]/route.ts b/app/api/class-types/[id]/route.ts index 8c6b378..4f9b03e 100644 --- a/app/api/class-types/[id]/route.ts +++ b/app/api/class-types/[id]/route.ts @@ -15,6 +15,7 @@ export async function PUT(request: NextRequest, context: RouteContext) { const classType = updateClassType(Number(id), { name: String(body.name ?? ""), enabled: Boolean(body.enabled), + showCurrentWord: body.showCurrentWord !== false, }); return NextResponse.json({ classType }); @@ -35,7 +36,7 @@ export async function DELETE(_request: NextRequest, context: RouteContext) { function getErrorMessage(error: unknown) { const message = error instanceof Error ? error.message : "操作失败"; - if (message.includes("UNIQUE")) return "班级类型已存在"; + if (message.includes("UNIQUE")) return "该年级下班级类型已存在"; if (message.includes("FOREIGN KEY")) return "该班级类型下仍有题库,不能删除"; return message; } diff --git a/app/api/class-types/route.ts b/app/api/class-types/route.ts index 3fe36b6..c953b5c 100644 --- a/app/api/class-types/route.ts +++ b/app/api/class-types/route.ts @@ -1,18 +1,26 @@ import { NextRequest, NextResponse } from "next/server"; -import { createClassType, listClassTypes } from "@/lib/db"; +import { createClassType, listClassTypes, type Grade } 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) }); + const grade = request.nextUrl.searchParams.get("grade") ?? undefined; + return NextResponse.json({ + classTypes: listClassTypes({ + grade: parseGrade(grade), + includeDisabled, + }), + }); } export async function POST(request: NextRequest) { try { const body = await request.json(); - const classType = createClassType(String(body.name ?? "")); + const classType = createClassType(body.grade as Grade, String(body.name ?? ""), { + showCurrentWord: body.showCurrentWord !== false, + }); return NextResponse.json({ classType }, { status: 201 }); } catch (error) { return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 }); @@ -21,6 +29,10 @@ export async function POST(request: NextRequest) { function getErrorMessage(error: unknown) { const message = error instanceof Error ? error.message : "操作失败"; - if (message.includes("UNIQUE")) return "班级类型已存在"; + if (message.includes("UNIQUE")) return "该年级下班级类型已存在"; return message; } + +function parseGrade(value: string | undefined) { + return value === "小班" || value === "中班" || value === "大班" ? value : undefined; +} diff --git a/app/api/words/[id]/generate-audio/route.ts b/app/api/words/[id]/generate-audio/route.ts index e7f0ae2..68658cf 100644 --- a/app/api/words/[id]/generate-audio/route.ts +++ b/app/api/words/[id]/generate-audio/route.ts @@ -30,7 +30,7 @@ export async function POST(_request: NextRequest, context: RouteContext) { const safeEnglish = word.english.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); const fileName = `${word.id}-${safeEnglish || "word"}.mp3`; const filePath = getAudioFilePath(fileName); - const publicPath = getAudioPublicPath(fileName); + const publicPath = `${getAudioPublicPath(fileName)}?v=${Date.now()}`; const settings = getSettings(); const tts = new EdgeTTS(word.english, settings.ttsVoice, { diff --git a/app/api/words/export/route.ts b/app/api/words/export/route.ts new file mode 100644 index 0000000..14199aa --- /dev/null +++ b/app/api/words/export/route.ts @@ -0,0 +1,25 @@ +import { NextRequest } from "next/server"; +import { listWords } from "@/lib/db"; +import { buildWordExcel } from "@/lib/word-excel"; + +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 words = listWords({ + grade, + classTypeId: Number.isFinite(classTypeId) && classTypeId > 0 ? classTypeId : undefined, + }); + const workbook = buildWordExcel(words); + + return new Response(workbook, { + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="${encodeURIComponent("词库导入表格")}.xlsx"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/app/api/words/import/route.ts b/app/api/words/import/route.ts new file mode 100644 index 0000000..d2f22b8 --- /dev/null +++ b/app/api/words/import/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { importWords, type Grade } from "@/lib/db"; +import { parseWordExcel } from "@/lib/word-excel"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const grade = String(formData.get("grade") ?? "") as Grade; + const classTypeId = Number(formData.get("classTypeId")); + const file = formData.get("file"); + + if (!(file instanceof File)) { + return NextResponse.json({ message: "请选择要导入的 Excel 表格" }, { status: 400 }); + } + + const buffer = Buffer.from(await file.arrayBuffer()); + const rows = parseWordExcel(buffer); + const result = importWords(grade, classTypeId, rows); + + return NextResponse.json({ result }); + } catch (error) { + return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 }); + } +} + +function getErrorMessage(error: unknown) { + const message = error instanceof Error ? error.message : "导入失败"; + if (message.includes("FOREIGN KEY")) return "班级类型不存在"; + if (message.includes("Unsupported file")) return "无法读取该表格,请使用 .xlsx 文件"; + return message; +} diff --git a/app/globals.css b/app/globals.css index b0ad32a..e09cc66 100644 --- a/app/globals.css +++ b/app/globals.css @@ -60,10 +60,15 @@ .pk-game-card { display: flex; flex-direction: column; - justify-content: center; + justify-content: flex-start; + min-height: min(720px, calc(100svh - 88px)); width: 100%; } + .pk-game-grid { + flex: 1; + } + .cursor-hammer { --hammer-cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(-36 16 16)'%3E%3Crect x='14' y='9' width='4' height='20' rx='2' fill='%23815a2f'/%3E%3Crect x='13' y='8' width='6' height='3' rx='1.5' fill='%236b4220'/%3E%3Cpath d='M8 5h14c1.7 0 3 1.3 3 3v4c0 1.7-1.3 3-3 3H8c-1.7 0-3-1.3-3-3V8c0-1.7 1.3-3 3-3Z' fill='%23f2b84b'/%3E%3Cpath d='M8 5h5v10H8c-1.7 0-3-1.3-3-3V8c0-1.7 1.3-3 3-3Z' fill='%23d78a2a'/%3E%3Cpath d='M19 5h3c1.7 0 3 1.3 3 3v4c0 1.7-1.3 3-3 3h-3V5Z' fill='%23999'/%3E%3Cpath d='M19 5h3c1.7 0 3 1.3 3 3v1h-6V5Z' fill='%23c9c9c9'/%3E%3C/g%3E%3C/svg%3E") 10 26, auto; cursor: var(--hammer-cursor); diff --git a/app/pk/components/GameScreen.tsx b/app/pk/components/GameScreen.tsx index 6242c43..a0ec0b6 100644 --- a/app/pk/components/GameScreen.tsx +++ b/app/pk/components/GameScreen.tsx @@ -10,6 +10,7 @@ import { WordImage } from "./WordImage"; type GameScreenProps = { selectedGrade: Grade; selectedClassTypeName?: string; + showCurrentWord: boolean; roundWords: WordItem[]; currentWord: WordItem; options: WordItem[]; @@ -30,6 +31,7 @@ type GameScreenProps = { export function GameScreen({ selectedGrade, selectedClassTypeName, + showCurrentWord, roundWords, currentWord, options, @@ -83,8 +85,13 @@ export function GameScreen({

-
-
+
+
VS
@@ -104,7 +111,14 @@ export function GameScreen({
-
+

听一听,这是哪个单词?

- -

{speechTip}

+ {showCurrentWord && ( + <> + +

{speechTip}

+ + )}
diff --git a/app/pk/hooks/usePkSetup.ts b/app/pk/hooks/usePkSetup.ts index 38a6e28..6d1af83 100644 --- a/app/pk/hooks/usePkSetup.ts +++ b/app/pk/hooks/usePkSetup.ts @@ -17,20 +17,41 @@ export function usePkSetup() { 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); + const nextSettings = await loadSettings(); setSettings(mergeSettings(nextSettings)); - setSelectedClassTypeId((current) => current || classTypesData.classTypes[0]?.id || 0); setLoadingSetup(false); } void loadInitialSetup(); }, []); + useEffect(() => { + let ignore = false; + + async function loadClassTypes() { + setLoadingSetup(true); + setSelectedClassTypeId(0); + const params = new URLSearchParams({ + includeDisabled: "false", + grade: selectedGrade, + }); + const response = await fetch(`/api/class-types?${params.toString()}`, { cache: "no-store" }); + const data = (await response.json()) as { classTypes: ClassType[] }; + + if (!ignore) { + setClassTypes(data.classTypes); + setSelectedClassTypeId(data.classTypes[0]?.id || 0); + setLoadingSetup(false); + } + } + + void loadClassTypes(); + + return () => { + ignore = true; + }; + }, [selectedGrade]); + useEffect(() => { function refreshSettingsOnFocus() { void loadSettings().then((nextSettings) => setSettings(mergeSettings(nextSettings))); @@ -41,6 +62,8 @@ export function usePkSetup() { }, []); useEffect(() => { + let ignore = false; + async function loadWords() { if (!selectedClassTypeId) { setAvailableWords([]); @@ -54,10 +77,16 @@ export function usePkSetup() { }); const response = await fetch(`/api/words?${params.toString()}`, { cache: "no-store" }); const data = (await response.json()) as { words: WordItem[] }; - setAvailableWords(data.words); + if (!ignore) { + setAvailableWords(data.words); + } } void loadWords(); + + return () => { + ignore = true; + }; }, [selectedGrade, selectedClassTypeId]); return { diff --git a/app/pk/page.tsx b/app/pk/page.tsx index ef993e3..b4b7e8d 100644 --- a/app/pk/page.tsx +++ b/app/pk/page.tsx @@ -128,6 +128,7 @@ export default function PkPage() { ; if (!wordColumns.some((column) => column.name === "image")) { database.exec("ALTER TABLE words ADD COLUMN image TEXT NOT NULL DEFAULT '';"); @@ -175,21 +202,23 @@ function initializeDatabase(database: DatabaseSync) { 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 insertClassType = database.prepare("INSERT INTO class_types (grade, name, enabled, show_current_word) VALUES (?, ?, 1, 1)"); + for (const grade of ["小班", "中班", "大班"] satisfies Grade[]) { + for (const name of defaultClassTypes) { + insertClassType.run(grade, 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 findClassType = database.prepare("SELECT id FROM class_types WHERE grade = ? AND 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; + const classType = findClassType.get(grade, classTypeName) as { id: number } | undefined; if (classType) { insertWord.run(grade, classType.id, english, chinese, image); } @@ -225,28 +254,194 @@ function initializeDatabase(database: DatabaseSync) { } } -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); +function migrateClassTypesByGrade(database: DatabaseSync) { + const classTypeColumns = database.prepare("PRAGMA table_info(class_types)").all() as Array<{ name: string }>; + if (classTypeColumns.some((column) => column.name === "grade")) { + return; + } + + const oldClassTypes = database.prepare("SELECT * FROM class_types ORDER BY id ASC").all() as Array<{ + id: number; + name: string; + enabled: 0 | 1; + created_at: string; + updated_at: string; + }>; + const wordGradeRows = database + .prepare("SELECT class_type_id, grade FROM words GROUP BY class_type_id, grade") + .all() as Array<{ class_type_id: number; grade: Grade }>; + const gradesByClassType = new Map(); + for (const row of wordGradeRows) { + const grades = gradesByClassType.get(row.class_type_id) ?? []; + if (!grades.includes(row.grade)) { + grades.push(row.grade); + } + gradesByClassType.set(row.class_type_id, grades); + } + + database.exec(` + PRAGMA foreign_keys = OFF; + CREATE TABLE class_types_next ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + grade TEXT NOT NULL DEFAULT '中班', + name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + show_current_word INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (grade, name) + ); + `); + + const insertWithId = database.prepare( + "INSERT INTO class_types_next (id, grade, name, enabled, show_current_word, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)", + ); + const insertCopy = database.prepare( + "INSERT INTO class_types_next (grade, name, enabled, show_current_word, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)", + ); + const updateWords = database.prepare("UPDATE words SET class_type_id = ? WHERE class_type_id = ? AND grade = ?"); + const allGrades = ["小班", "中班", "大班"] satisfies Grade[]; + + for (const classType of oldClassTypes) { + const usedGrades = gradesByClassType.get(classType.id) ?? []; + const targetGrades = defaultClassTypes.includes(classType.name) + ? allGrades + : usedGrades.length > 0 + ? usedGrades + : (["中班"] satisfies Grade[]); + const primaryGrade = targetGrades.includes("中班") ? "中班" : targetGrades[0]; + + insertWithId.run( + classType.id, + primaryGrade, + classType.name, + classType.enabled, + classType.created_at, + classType.updated_at, + ); + updateWords.run(classType.id, classType.id, primaryGrade); + + for (const grade of targetGrades) { + if (grade === primaryGrade) continue; + const result = insertCopy.run(grade, classType.name, classType.enabled, classType.created_at, classType.updated_at); + updateWords.run(Number(result.lastInsertRowid), classType.id, grade); + } + } + + database.exec(` + DROP TABLE class_types; + ALTER TABLE class_types_next RENAME TO class_types; + PRAGMA foreign_keys = ON; + `); } -export function createClassType(name: string) { +function migrateClassTypeDisplayOptions(database: DatabaseSync) { + const classTypeColumns = database.prepare("PRAGMA table_info(class_types)").all() as Array<{ name: string }>; + if (!classTypeColumns.some((column) => column.name === "show_current_word")) { + database.exec("ALTER TABLE class_types ADD COLUMN show_current_word INTEGER NOT NULL DEFAULT 1;"); + } +} + +function ensureWordClassTypeGradeConsistency(database: DatabaseSync) { + const mismatches = database + .prepare( + ` + SELECT DISTINCT + words.grade, + words.class_type_id AS source_class_type_id, + class_types.name, + class_types.enabled + FROM words + JOIN class_types ON class_types.id = words.class_type_id + WHERE words.grade <> class_types.grade + `, + ) + .all() as Array<{ + grade: Grade; + source_class_type_id: number; + name: string; + enabled: 0 | 1; + }>; + + if (mismatches.length === 0) return; + + const findClassType = database.prepare("SELECT id FROM class_types WHERE grade = ? AND name = ?"); + const insertClassType = database.prepare("INSERT INTO class_types (grade, name, enabled) VALUES (?, ?, ?)"); + const updateWords = database.prepare("UPDATE words SET class_type_id = ? WHERE grade = ? AND class_type_id = ?"); + + for (const mismatch of mismatches) { + const existing = findClassType.get(mismatch.grade, mismatch.name) as { id: number } | undefined; + const classTypeId = + existing?.id ?? + Number(insertClassType.run(mismatch.grade, mismatch.name, mismatch.enabled).lastInsertRowid); + updateWords.run(classTypeId, mismatch.grade, mismatch.source_class_type_id); + } +} + +function cleanupUnreferencedDuplicatedCustomClassTypes(database: DatabaseSync) { + const duplicates = database + .prepare( + ` + SELECT class_types.id, class_types.name, COUNT(words.id) AS word_count + FROM class_types + LEFT JOIN words ON words.class_type_id = class_types.id + WHERE class_types.name NOT IN (${defaultClassTypes.map(() => "?").join(", ")}) + AND class_types.name IN ( + SELECT name + FROM class_types + WHERE name NOT IN (${defaultClassTypes.map(() => "?").join(", ")}) + GROUP BY name + HAVING COUNT(*) > 1 + ) + GROUP BY class_types.id + `, + ) + .all(...defaultClassTypes, ...defaultClassTypes) as Array<{ id: number; name: string; word_count: number }>; + + const namesWithWords = new Set(duplicates.filter((row) => row.word_count > 0).map((row) => row.name)); + if (namesWithWords.size === 0) return; + + const deleteClassType = database.prepare("DELETE FROM class_types WHERE id = ?"); + for (const row of duplicates) { + if (row.word_count === 0 && namesWithWords.has(row.name)) { + deleteClassType.run(row.id); + } + } +} + +export function listClassTypes(filters?: { grade?: Grade; includeDisabled?: boolean }) { const database = getDatabase(); + const clauses: string[] = []; + const values: Array = []; + + if (filters?.grade) { + clauses.push("grade = ?"); + values.push(filters.grade); + } + + if (filters?.includeDisabled === false) { + clauses.push("enabled = 1"); + } + + const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; + return (database.prepare(`SELECT * FROM class_types ${where} ORDER BY grade ASC, id ASC`).all(...values) as DbClassTypeRow[]).map(mapClassType); +} + +export function createClassType(grade: Grade, name: string, options?: { showCurrentWord?: boolean }) { + const database = getDatabase(); + validateGrade(grade); const trimmedName = name.trim(); if (!trimmedName) { throw new Error("班级类型不能为空"); } const result = database - .prepare("INSERT INTO class_types (name, enabled) VALUES (?, 1)") - .run(trimmedName); + .prepare("INSERT INTO class_types (grade, name, enabled, show_current_word) VALUES (?, ?, 1, ?)") + .run(grade, trimmedName, options?.showCurrentWord === false ? 0 : 1); return getClassType(Number(result.lastInsertRowid)); } -export function updateClassType(id: number, input: { name: string; enabled: boolean }) { +export function updateClassType(id: number, input: { name: string; enabled: boolean; showCurrentWord?: boolean }) { const database = getDatabase(); const trimmedName = input.name.trim(); if (!trimmedName) { @@ -254,8 +449,8 @@ export function updateClassType(id: number, input: { name: string; enabled: bool } database - .prepare("UPDATE class_types SET name = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") - .run(trimmedName, input.enabled ? 1 : 0, id); + .prepare("UPDATE class_types SET name = ?, enabled = ?, show_current_word = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + .run(trimmedName, input.enabled ? 1 : 0, input.showCurrentWord === false ? 0 : 1, id); return getClassType(id); } @@ -340,6 +535,65 @@ export function createWord(input: WordInput) { return getWord(Number(result.lastInsertRowid)); } +export function importWords(grade: Grade, classTypeId: number, rows: WordImportInput[]): WordImportResult { + const database = getDatabase(); + validateWordInput({ + grade, + classTypeId, + english: "placeholder", + chinese: "", + image: "", + enabled: true, + }); + if (!getClassType(classTypeId)) { + throw new Error("班级类型不存在"); + } + + const findExisting = database.prepare( + "SELECT id FROM words WHERE grade = ? AND class_type_id = ? AND english = ?", + ); + const insertWord = database.prepare( + "INSERT INTO words (grade, class_type_id, english, chinese, image, audio, enabled) VALUES (?, ?, ?, ?, ?, '', 1)", + ); + + const result: WordImportResult = { + created: 0, + excluded: 0, + skipped: 0, + errors: [], + }; + + database.exec("BEGIN IMMEDIATE"); + try { + rows.forEach((row, index) => { + const rowNumber = index + 2; + const english = row.english.trim(); + if (!english) { + result.skipped += 1; + result.errors.push(`第 ${rowNumber} 行缺少词库英语`); + return; + } + + const chinese = row.chinese.trim(); + const image = row.image.trim(); + const existing = findExisting.get(grade, classTypeId, english) as { id: number } | undefined; + + if (existing) { + result.excluded += 1; + } else { + insertWord.run(grade, classTypeId, english, chinese, image); + result.created += 1; + } + }); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + + return result; +} + export function updateWord(id: number, input: WordInput) { const database = getDatabase(); validateWordInput(input); @@ -385,19 +639,32 @@ function getWord(id: number) { } function validateWordInput(input: WordInput) { - if (!["小班", "中班", "大班"].includes(input.grade)) { - throw new Error("年级不正确"); - } + validateGrade(input.grade); if (!Number.isInteger(input.classTypeId) || input.classTypeId <= 0) { throw new Error("班级类型不正确"); } + const classType = getClassType(input.classTypeId); + if (!classType) { + throw new Error("班级类型不存在"); + } + + if (classType.grade !== input.grade) { + throw new Error("班级类型不属于当前年级"); + } + if (!input.english.trim()) { throw new Error("题库英语不能为空"); } } +function validateGrade(grade: Grade) { + if (!["小班", "中班", "大班"].includes(grade)) { + throw new Error("年级不正确"); + } +} + export function getSettings(): AppSettings { const rows = getDatabase().prepare("SELECT key, value FROM settings").all() as Array<{ key: string; diff --git a/lib/word-excel.ts b/lib/word-excel.ts new file mode 100644 index 0000000..85b181c --- /dev/null +++ b/lib/word-excel.ts @@ -0,0 +1,57 @@ +import * as XLSX from "xlsx"; +import type { WordImportInput, WordRecord } from "@/lib/db"; + +export const wordExcelHeaders = ["词库英语", "中文", "图标/图片"] as const; + +type WordExcelHeader = (typeof wordExcelHeaders)[number]; + +type WordExcelRow = Record; + +export function parseWordExcel(buffer: Buffer): WordImportInput[] { + const workbook = XLSX.read(buffer, { type: "buffer" }); + const sheetName = workbook.SheetNames[0]; + if (!sheetName) { + throw new Error("表格中没有可导入的工作表"); + } + + const sheet = workbook.Sheets[sheetName]; + const rows = XLSX.utils.sheet_to_json>(sheet, { + defval: "", + raw: false, + }); + + return rows.map((row) => ({ + english: readCell(row, "词库英语"), + chinese: readCell(row, "中文"), + image: readCell(row, "图标/图片"), + })); +} + +export function buildWordExcel(words: WordRecord[]) { + const rows: WordExcelRow[] = + words.length > 0 + ? words.map((word) => ({ + 词库英语: word.english, + 中文: word.chinese, + "图标/图片": word.image, + })) + : [ + { + 词库英语: "apple", + 中文: "苹果", + "图标/图片": "🍎", + }, + ]; + + const worksheet = XLSX.utils.json_to_sheet(rows, { header: [...wordExcelHeaders] }); + worksheet["!cols"] = [{ wch: 24 }, { wch: 18 }, { wch: 28 }]; + + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, "词库导入"); + return XLSX.write(workbook, { bookType: "xlsx", type: "buffer" }) as Buffer; +} + +function readCell(row: Record, header: string) { + const value = row[header]; + return value == null ? "" : String(value).trim(); +} diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index f89250d..39fcde2 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "react-dom": "^19.0.0", "sonner": "^2.0.7", "tailwind-merge": "^2.5.5", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2deeb5b..5abb1cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.19) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 devDependencies: '@types/node': specifier: ^22.10.2 @@ -742,6 +745,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + agent-base@6.0.0: resolution: {integrity: sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw==} engines: {node: '>= 6.0.0'} @@ -912,6 +919,10 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -930,6 +941,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -951,6 +966,11 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cross-fetch@4.1.0: resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} @@ -1289,6 +1309,10 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -2102,6 +2126,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2338,10 +2366,18 @@ packages: engines: {node: '>= 8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2362,6 +2398,11 @@ packages: utf-8-validate: optional: true + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + xml-escape@1.1.0: resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==} @@ -3008,6 +3049,8 @@ snapshots: acorn@8.17.0: {} + adler-32@1.3.1: {} + agent-base@6.0.0: dependencies: debug: 4.4.3 @@ -3210,6 +3253,11 @@ snapshots: caniuse-lite@1.0.30001799: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3235,6 +3283,8 @@ snapshots: clsx@2.1.1: {} + codepage@1.15.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -3251,6 +3301,8 @@ snapshots: convert-source-map@2.0.0: {} + crc-32@1.2.2: {} + cross-fetch@4.1.0: dependencies: node-fetch: 2.7.0 @@ -3753,6 +3805,8 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + frac@1.1.2: {} + fraction.js@5.3.4: {} fsevents@2.3.3: @@ -4589,6 +4643,10 @@ snapshots: source-map-js@1.2.1: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -4912,8 +4970,12 @@ snapshots: dependencies: isexe: 2.0.0 + wmf@1.0.2: {} + word-wrap@1.2.5: {} + word@0.3.0: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -4928,6 +4990,16 @@ snapshots: ws@8.21.0: {} + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + xml-escape@1.1.0: {} yallist@3.1.1: {}