feat: add word import and class type display controls
Build Docker Package / docker-package (push) Successful in 6m34s

This commit is contained in:
2026-07-02 14:34:00 +08:00
parent 0378504be0
commit 17fc24edc7
16 changed files with 844 additions and 66 deletions
+274 -20
View File
@@ -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<SettingsSection>("voice");
const [classTypeGrade, setClassTypeGrade] = useState<Grade>("中班");
const [importGrade, setImportGrade] = useState<Grade>("中班");
const [importClassTypeId, setImportClassTypeId] = useState(0);
const [importingWords, setImportingWords] = useState(false);
const [classTypeName, setClassTypeName] = useState("");
const [editingClassType, setEditingClassType] = useState<ClassType | null>(null);
const [loading, setLoading] = useState(true);
const importFileInputRef = useRef<HTMLInputElement>(null);
const [batchAudioProgress, setBatchAudioProgress] = useState<BatchAudioProgress>({
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 (
<main className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:py-8">
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
@@ -465,6 +608,10 @@ export default function AdminPage() {
<Settings2 className="size-4"/>
</Button>
<Button variant="outline" onClick={openWordImportSection}>
<FileSpreadsheet className="size-4"/>
</Button>
<Button onClick={loadData}>
<RefreshCw className="size-4"/>
@@ -567,7 +714,7 @@ export default function AdminPage() {
</div>
<select
value={gradeFilter}
onChange={(event) => setGradeFilter(event.target.value as "all" | Grade)}
onChange={(event) => changeGradeFilter(event.target.value as "all" | Grade)}
className="h-10 rounded-md border bg-white px-3 text-sm"
>
<option value="all"></option>
@@ -585,9 +732,9 @@ export default function AdminPage() {
className="h-10 rounded-md border bg-white px-3 text-sm"
>
<option value="all"></option>
{classTypes.map((classType) => (
{classTypesForFilter.map((classType) => (
<option key={classType.id} value={classType.id}>
{classType.name}
{gradeFilter === "all" ? `${classType.grade} - ${classType.name}` : classType.name}
</option>
))}
</select>
@@ -671,10 +818,7 @@ export default function AdminPage() {
<select
value={wordForm.grade}
onChange={(event) => setWordForm((form) => ({
...form,
grade: event.target.value as Grade
}))}
onChange={(event) => changeWordFormGrade(event.target.value as Grade)}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{grades.map((grade) => (
@@ -694,7 +838,8 @@ export default function AdminPage() {
}))}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{classTypes.map((classType) => (
{classTypesForWordForm.length === 0 && <option value={0}></option>}
{classTypesForWordForm.map((classType) => (
<option key={classType.id} value={classType.id}>
{classType.name}
</option>
@@ -943,9 +1088,23 @@ export default function AdminPage() {
<div className="space-y-5">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-muted-foreground">PK
</p>
<p className="mt-1 text-sm text-muted-foreground">PK
</p>
</div>
<label className="block space-y-2 text-sm font-medium">
<select
value={classTypeGrade}
onChange={(event) => changeClassTypeGrade(event.target.value as Grade)}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{grades.map((grade) => (
<option key={grade} value={grade}>
{grade}
</option>
))}
</select>
</label>
<div className="flex gap-2">
<Input
value={editingClassType ? editingClassType.name : classTypeName}
@@ -968,17 +1127,29 @@ export default function AdminPage() {
</div>
<div className="space-y-2">
{classTypes.map((classType) => (
{classTypesForSettings.map((classType) => (
<div key={classType.id}
className="flex items-center justify-between rounded-md border p-3">
<div>
<div className="font-medium">{classType.name}</div>
<div
className="text-xs text-muted-foreground">{classType.enabled ? "已启用" : "已停用"}</div>
className="text-xs text-muted-foreground">
{classType.grade} · {classType.enabled ? "已启用" : "已停用"} · {classType.showCurrentWord ? "显示" : "隐藏"}
</div>
<div className="flex items-center gap-2">
</div>
<div className="flex flex-wrap items-center justify-end gap-3">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch checked={classType.enabled}
onClick={() => toggleClassType(classType)}/>
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch
checked={classType.showCurrentWord}
onClick={() => toggleClassTypeCurrentWord(classType)}
/>
</label>
<Button variant="outline" size="icon"
onClick={() => setEditingClassType(classType)}>
<Pencil className="size-4"/>
@@ -990,6 +1161,84 @@ export default function AdminPage() {
</div>
</div>
))}
{classTypesForSettings.length === 0 && (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
</div>
)}
</div>
</div>
)}
{settingsSection === "wordImport" && (
<div className="space-y-5">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-muted-foreground">
/
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-2 text-sm font-medium">
<select
value={importGrade}
onChange={(event) => changeImportGrade(event.target.value as Grade)}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{grades.map((grade) => (
<option key={grade} value={grade}>
{grade}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm font-medium">
<select
value={importClassTypeId}
onChange={(event) => setImportClassTypeId(Number(event.target.value))}
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
>
{classTypesForImport.length === 0 && <option value={0}></option>}
{classTypesForImport.map((classType) => (
<option key={classType.id} value={classType.id}>
{classType.name}
</option>
))}
</select>
</label>
</div>
<div className="rounded-md border p-4">
<div className="font-medium"></div>
<p className="mt-1 text-sm text-muted-foreground">
</p>
<Button variant="outline" className="mt-3" onClick={exportWordExcel}>
<Download className="size-4"/>
</Button>
</div>
<div className="rounded-md border p-4">
<div className="font-medium"></div>
<p className="mt-1 text-sm text-muted-foreground">
</p>
<input
ref={importFileInputRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={(event) => void importWordExcel(event.target.files?.[0])}
/>
<Button
className="mt-3"
onClick={() => importFileInputRef.current?.click()}
disabled={importingWords}
>
<Upload className="size-4"/>
{importingWords ? "导入中" : "选择表格并导入"}
</Button>
</div>
</div>
)}
@@ -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>): AppSettings {
return {
ttsVoice: settings.ttsVoice || defaultSettings.ttsVoice,
+2 -1
View File
@@ -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;
}
+16 -4
View File
@@ -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;
}
+1 -1
View File
@@ -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, {
+25
View File
@@ -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",
},
});
}
+34
View File
@@ -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;
}
+6 -1
View File
@@ -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);
+21 -3
View File
@@ -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({
</p>
</div>
<div className="pk-game-grid 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={cn(
"pk-game-grid grid items-center gap-[clamp(18px,3vw,34px)] lg:grid-cols-[minmax(300px,0.9fr)_minmax(420px,1.25fr)]",
!showCurrentWord && "pk-game-grid-word-hidden",
)}
>
<div className="flex min-w-0 flex-col justify-center">
<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>
@@ -104,7 +111,14 @@ export function GameScreen({
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${progress}%` }} />
</div>
<div className="pk-listen-area flex flex-1 flex-col items-center justify-center py-[clamp(24px,5vw,70px)] text-center">
<div
className={cn(
"pk-listen-area flex flex-col items-center justify-center text-center",
showCurrentWord
? "py-[clamp(24px,5vw,70px)]"
: "min-h-[clamp(150px,24vh,230px)] py-[clamp(16px,3vw,34px)]",
)}
>
<p className="text-[clamp(16px,1.7vw,20px)] text-slate-600"></p>
<button
onClick={() => onSpeakWord(currentWord)}
@@ -113,8 +127,12 @@ export function GameScreen({
>
<Volume2 className="size-[clamp(28px,3vw,36px)]" />
</button>
{showCurrentWord && (
<>
<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>
+36 -7
View File
@@ -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[] };
if (!ignore) {
setAvailableWords(data.words);
}
}
void loadWords();
return () => {
ignore = true;
};
}, [selectedGrade, selectedClassTypeId]);
return {
+1
View File
@@ -128,6 +128,7 @@ export default function PkPage() {
<GameScreen
selectedGrade={selectedGrade}
selectedClassTypeName={selectedClassType?.name}
showCurrentWord={selectedClassType?.showCurrentWord ?? true}
roundWords={roundWords}
currentWord={currentWord}
options={options}
+2
View File
@@ -4,8 +4,10 @@ export type Grade = "小班" | "中班" | "大班";
export type ClassType = {
id: number;
grade: Grade;
name: string;
enabled: boolean;
showCurrentWord: boolean;
};
export type WordItem = {
+288 -21
View File
@@ -6,8 +6,10 @@ export type Grade = "小班" | "中班" | "大班";
export type ClassType = {
id: number;
grade: Grade;
name: string;
enabled: boolean;
showCurrentWord: boolean;
createdAt: string;
updatedAt: string;
};
@@ -47,6 +49,19 @@ export type WordInput = {
enabled: boolean;
};
export type WordImportInput = {
english: string;
chinese: string;
image: string;
};
export type WordImportResult = {
created: number;
excluded: number;
skipped: number;
errors: string[];
};
type DbWordRow = {
id: number;
grade: Grade;
@@ -63,8 +78,10 @@ type DbWordRow = {
type DbClassTypeRow = {
id: number;
grade: Grade;
name: string;
enabled: 0 | 1;
show_current_word: 0 | 1;
created_at: string;
updated_at: string;
};
@@ -93,8 +110,10 @@ const defaultWords = [
function mapClassType(row: DbClassTypeRow): ClassType {
return {
id: row.id,
grade: row.grade,
name: row.name,
enabled: Boolean(row.enabled),
showCurrentWord: Boolean(row.show_current_word),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
@@ -134,10 +153,13 @@ function initializeDatabase(database: DatabaseSync) {
database.exec(`
CREATE TABLE IF NOT EXISTS class_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
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
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (grade, name)
);
CREATE TABLE IF NOT EXISTS words (
@@ -162,6 +184,11 @@ function initializeDatabase(database: DatabaseSync) {
);
`);
migrateClassTypesByGrade(database);
migrateClassTypeDisplayOptions(database);
ensureWordClassTypeGradeConsistency(database);
cleanupUnreferencedDuplicatedCustomClassTypes(database);
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 '';");
@@ -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)");
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(name);
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<number, Grade[]>();
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<string | number> = [];
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;
+57
View File
@@ -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<WordExcelHeader, string>;
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<Record<string, unknown>>(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<string, unknown>, header: string) {
const value = row[header];
return value == null ? "" : String(value).trim();
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
+2 -1
View File
@@ -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",
+72
View File
@@ -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: {}