feat: add word import and class type display controls
Build Docker Package / docker-package (push) Successful in 6m34s
Build Docker Package / docker-package (push) Successful in 6m34s
This commit is contained in:
+274
-20
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {useEffect, useMemo, useState} from "react";
|
import {useEffect, useMemo, useRef, useState} from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {
|
import {
|
||||||
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
|
FileSpreadsheet,
|
||||||
Gamepad2,
|
Gamepad2,
|
||||||
Mic2,
|
Mic2,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {Badge} from "@/components/ui/badge";
|
import {Badge} from "@/components/ui/badge";
|
||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
@@ -37,8 +40,10 @@ type Grade = "小班" | "中班" | "大班";
|
|||||||
|
|
||||||
type ClassType = {
|
type ClassType = {
|
||||||
id: number;
|
id: number;
|
||||||
|
grade: Grade;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
showCurrentWord: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WordRecord = {
|
type WordRecord = {
|
||||||
@@ -84,7 +89,7 @@ type BatchAudioProgress = {
|
|||||||
current: string;
|
current: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsSection = "voice" | "rules" | "classTypes" | "preview";
|
type SettingsSection = "voice" | "rules" | "classTypes" | "wordImport" | "preview";
|
||||||
|
|
||||||
const grades: Grade[] = ["小班", "中班", "大班"];
|
const grades: Grade[] = ["小班", "中班", "大班"];
|
||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
@@ -126,12 +131,13 @@ const settingsSections: Array<{ id: SettingsSection; label: string; description:
|
|||||||
{id: "voice", label: "语音设置", description: "Edge TTS 声音与语速", icon: Mic2},
|
{id: "voice", label: "语音设置", description: "Edge TTS 声音与语速", icon: Mic2},
|
||||||
{id: "rules", label: "课堂规则", description: "得分与自动朗读", icon: SlidersHorizontal},
|
{id: "rules", label: "课堂规则", description: "得分与自动朗读", icon: SlidersHorizontal},
|
||||||
{id: "classTypes", label: "班级类型", description: "定制 PK 入口班型", icon: School},
|
{id: "classTypes", label: "班级类型", description: "定制 PK 入口班型", icon: School},
|
||||||
|
{id: "wordImport", label: "一键导入", description: "Excel 导入导出词库", icon: FileSpreadsheet},
|
||||||
{id: "preview", label: "页面预览", description: "打开课堂展示页", icon: Eye},
|
{id: "preview", label: "页面预览", description: "打开课堂展示页", icon: Eye},
|
||||||
];
|
];
|
||||||
|
|
||||||
function createEmptyWordForm(classTypeId = 0): WordForm {
|
function createEmptyWordForm(classTypeId = 0, grade: Grade = "中班"): WordForm {
|
||||||
return {
|
return {
|
||||||
grade: "中班",
|
grade,
|
||||||
classTypeId,
|
classTypeId,
|
||||||
english: "",
|
english: "",
|
||||||
chinese: "",
|
chinese: "",
|
||||||
@@ -152,9 +158,14 @@ export default function AdminPage() {
|
|||||||
const [wordDialogOpen, setWordDialogOpen] = useState(false);
|
const [wordDialogOpen, setWordDialogOpen] = useState(false);
|
||||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>("voice");
|
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 [classTypeName, setClassTypeName] = useState("");
|
||||||
const [editingClassType, setEditingClassType] = useState<ClassType | null>(null);
|
const [editingClassType, setEditingClassType] = useState<ClassType | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [batchAudioProgress, setBatchAudioProgress] = useState<BatchAudioProgress>({
|
const [batchAudioProgress, setBatchAudioProgress] = useState<BatchAudioProgress>({
|
||||||
running: false,
|
running: false,
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -183,8 +194,9 @@ export default function AdminPage() {
|
|||||||
setSettings(mergeSettings(settingsData.settings));
|
setSettings(mergeSettings(settingsData.settings));
|
||||||
setWordForm((form) => ({
|
setWordForm((form) => ({
|
||||||
...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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,12 +210,61 @@ export default function AdminPage() {
|
|||||||
return matchQuery && matchGrade && matchClassType;
|
return matchQuery && matchGrade && matchClassType;
|
||||||
});
|
});
|
||||||
}, [wordList, query, gradeFilter, classTypeFilter]);
|
}, [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() {
|
function openCreateWordDialog() {
|
||||||
setWordForm(createEmptyWordForm(classTypes[0]?.id || 0));
|
const nextGrade = gradeFilter === "all" ? "中班" : gradeFilter;
|
||||||
|
setWordForm(createEmptyWordForm(getNextClassTypeId(classTypes, nextGrade, 0), nextGrade));
|
||||||
setWordDialogOpen(true);
|
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) {
|
function openEditWordDialog(word: WordRecord) {
|
||||||
setWordForm({
|
setWordForm({
|
||||||
id: word.id,
|
id: word.id,
|
||||||
@@ -357,8 +418,10 @@ export default function AdminPage() {
|
|||||||
method: editingClassType ? "PUT" : "POST",
|
method: editingClassType ? "PUT" : "POST",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
grade: classTypeGrade,
|
||||||
name,
|
name,
|
||||||
enabled: editingClassType?.enabled ?? true,
|
enabled: editingClassType?.enabled ?? true,
|
||||||
|
showCurrentWord: editingClassType?.showCurrentWord ?? true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -391,7 +454,11 @@ export default function AdminPage() {
|
|||||||
const response = await fetch(`/api/class-types/${classType.id}`, {
|
const response = await fetch(`/api/class-types/${classType.id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {"Content-Type": "application/json"},
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
@@ -403,6 +470,26 @@ export default function AdminPage() {
|
|||||||
await loadData();
|
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() {
|
async function saveSettings() {
|
||||||
const response = await fetch("/api/settings", {
|
const response = await fetch("/api/settings", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -443,6 +530,62 @@ export default function AdminPage() {
|
|||||||
toast.success("正在播放试听语音", {id: toastId});
|
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 (
|
return (
|
||||||
<main className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:py-8">
|
<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">
|
<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"/>
|
<Settings2 className="size-4"/>
|
||||||
设置
|
设置
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" onClick={openWordImportSection}>
|
||||||
|
<FileSpreadsheet className="size-4"/>
|
||||||
|
一键导入单词
|
||||||
|
</Button>
|
||||||
<Button onClick={loadData}>
|
<Button onClick={loadData}>
|
||||||
<RefreshCw className="size-4"/>
|
<RefreshCw className="size-4"/>
|
||||||
刷新
|
刷新
|
||||||
@@ -567,7 +714,7 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={gradeFilter}
|
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"
|
className="h-10 rounded-md border bg-white px-3 text-sm"
|
||||||
>
|
>
|
||||||
<option value="all">全部年级</option>
|
<option value="all">全部年级</option>
|
||||||
@@ -585,9 +732,9 @@ export default function AdminPage() {
|
|||||||
className="h-10 rounded-md border bg-white px-3 text-sm"
|
className="h-10 rounded-md border bg-white px-3 text-sm"
|
||||||
>
|
>
|
||||||
<option value="all">全部班型</option>
|
<option value="all">全部班型</option>
|
||||||
{classTypes.map((classType) => (
|
{classTypesForFilter.map((classType) => (
|
||||||
<option key={classType.id} value={classType.id}>
|
<option key={classType.id} value={classType.id}>
|
||||||
{classType.name}
|
{gradeFilter === "all" ? `${classType.grade} - ${classType.name}` : classType.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -671,10 +818,7 @@ export default function AdminPage() {
|
|||||||
年级
|
年级
|
||||||
<select
|
<select
|
||||||
value={wordForm.grade}
|
value={wordForm.grade}
|
||||||
onChange={(event) => setWordForm((form) => ({
|
onChange={(event) => changeWordFormGrade(event.target.value as Grade)}
|
||||||
...form,
|
|
||||||
grade: event.target.value as Grade
|
|
||||||
}))}
|
|
||||||
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
|
className="h-10 w-full rounded-md border bg-white px-3 text-sm"
|
||||||
>
|
>
|
||||||
{grades.map((grade) => (
|
{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"
|
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}>
|
<option key={classType.id} value={classType.id}>
|
||||||
{classType.name}
|
{classType.name}
|
||||||
</option>
|
</option>
|
||||||
@@ -943,9 +1088,23 @@ export default function AdminPage() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">班级类型</h3>
|
<h3 className="text-lg font-semibold">班级类型</h3>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">这里可以定制班级类型,PK
|
<p className="mt-1 text-sm text-muted-foreground">这里可以按年级定制班级类型,PK
|
||||||
页面会读取启用项。</p>
|
页面会读取当前年级下的启用项。</p>
|
||||||
</div>
|
</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">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
value={editingClassType ? editingClassType.name : classTypeName}
|
value={editingClassType ? editingClassType.name : classTypeName}
|
||||||
@@ -968,17 +1127,29 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{classTypes.map((classType) => (
|
{classTypesForSettings.map((classType) => (
|
||||||
<div key={classType.id}
|
<div key={classType.id}
|
||||||
className="flex items-center justify-between rounded-md border p-3">
|
className="flex items-center justify-between rounded-md border p-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{classType.name}</div>
|
<div className="font-medium">{classType.name}</div>
|
||||||
<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>
|
||||||
<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}
|
<Switch checked={classType.enabled}
|
||||||
onClick={() => toggleClassType(classType)}/>
|
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"
|
<Button variant="outline" size="icon"
|
||||||
onClick={() => setEditingClassType(classType)}>
|
onClick={() => setEditingClassType(classType)}>
|
||||||
<Pencil className="size-4"/>
|
<Pencil className="size-4"/>
|
||||||
@@ -990,6 +1161,84 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1038,6 +1287,11 @@ function isImageSource(value: string) {
|
|||||||
return /^(https?:\/\/|\/|data:image\/)/i.test(value);
|
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 {
|
function mergeSettings(settings: Partial<AppSettings>): AppSettings {
|
||||||
return {
|
return {
|
||||||
ttsVoice: settings.ttsVoice || defaultSettings.ttsVoice,
|
ttsVoice: settings.ttsVoice || defaultSettings.ttsVoice,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export async function PUT(request: NextRequest, context: RouteContext) {
|
|||||||
const classType = updateClassType(Number(id), {
|
const classType = updateClassType(Number(id), {
|
||||||
name: String(body.name ?? ""),
|
name: String(body.name ?? ""),
|
||||||
enabled: Boolean(body.enabled),
|
enabled: Boolean(body.enabled),
|
||||||
|
showCurrentWord: body.showCurrentWord !== false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ classType });
|
return NextResponse.json({ classType });
|
||||||
@@ -35,7 +36,7 @@ export async function DELETE(_request: NextRequest, context: RouteContext) {
|
|||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
function getErrorMessage(error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : "操作失败";
|
const message = error instanceof Error ? error.message : "操作失败";
|
||||||
if (message.includes("UNIQUE")) return "班级类型已存在";
|
if (message.includes("UNIQUE")) return "该年级下班级类型已存在";
|
||||||
if (message.includes("FOREIGN KEY")) return "该班级类型下仍有题库,不能删除";
|
if (message.includes("FOREIGN KEY")) return "该班级类型下仍有题库,不能删除";
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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 runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const includeDisabled = request.nextUrl.searchParams.get("includeDisabled") !== "false";
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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 });
|
return NextResponse.json({ classType }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 });
|
return NextResponse.json({ message: getErrorMessage(error) }, { status: 400 });
|
||||||
@@ -21,6 +29,10 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
function getErrorMessage(error: unknown) {
|
function getErrorMessage(error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : "操作失败";
|
const message = error instanceof Error ? error.message : "操作失败";
|
||||||
if (message.includes("UNIQUE")) return "班级类型已存在";
|
if (message.includes("UNIQUE")) return "该年级下班级类型已存在";
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseGrade(value: string | undefined) {
|
||||||
|
return value === "小班" || value === "中班" || value === "大班" ? value : undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 safeEnglish = word.english.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
||||||
const fileName = `${word.id}-${safeEnglish || "word"}.mp3`;
|
const fileName = `${word.id}-${safeEnglish || "word"}.mp3`;
|
||||||
const filePath = getAudioFilePath(fileName);
|
const filePath = getAudioFilePath(fileName);
|
||||||
const publicPath = getAudioPublicPath(fileName);
|
const publicPath = `${getAudioPublicPath(fileName)}?v=${Date.now()}`;
|
||||||
|
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const tts = new EdgeTTS(word.english, settings.ttsVoice, {
|
const tts = new EdgeTTS(word.english, settings.ttsVoice, {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -60,10 +60,15 @@
|
|||||||
.pk-game-card {
|
.pk-game-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
|
min-height: min(720px, calc(100svh - 88px));
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pk-game-grid {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.cursor-hammer {
|
.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;
|
--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);
|
cursor: var(--hammer-cursor);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { WordImage } from "./WordImage";
|
|||||||
type GameScreenProps = {
|
type GameScreenProps = {
|
||||||
selectedGrade: Grade;
|
selectedGrade: Grade;
|
||||||
selectedClassTypeName?: string;
|
selectedClassTypeName?: string;
|
||||||
|
showCurrentWord: boolean;
|
||||||
roundWords: WordItem[];
|
roundWords: WordItem[];
|
||||||
currentWord: WordItem;
|
currentWord: WordItem;
|
||||||
options: WordItem[];
|
options: WordItem[];
|
||||||
@@ -30,6 +31,7 @@ type GameScreenProps = {
|
|||||||
export function GameScreen({
|
export function GameScreen({
|
||||||
selectedGrade,
|
selectedGrade,
|
||||||
selectedClassTypeName,
|
selectedClassTypeName,
|
||||||
|
showCurrentWord,
|
||||||
roundWords,
|
roundWords,
|
||||||
currentWord,
|
currentWord,
|
||||||
options,
|
options,
|
||||||
@@ -83,8 +85,13 @@ export function GameScreen({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pk-game-grid grid gap-[clamp(18px,3vw,34px)] lg:grid-cols-[minmax(300px,0.9fr)_minmax(420px,1.25fr)]">
|
<div
|
||||||
<div className="flex min-w-0 flex-col">
|
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)]">
|
<div className="flex items-center gap-[clamp(10px,1.8vw,18px)]">
|
||||||
<TeamCard team="red" active={currentTeam === "red"} score={scoreRed} />
|
<TeamCard team="red" active={currentTeam === "red"} score={scoreRed} />
|
||||||
<div className="text-[clamp(20px,2.8vw,30px)] font-black text-amber-500">VS</div>
|
<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 className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</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>
|
<p className="text-[clamp(16px,1.7vw,20px)] text-slate-600">听一听,这是哪个单词?</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => onSpeakWord(currentWord)}
|
onClick={() => onSpeakWord(currentWord)}
|
||||||
@@ -113,8 +127,12 @@ export function GameScreen({
|
|||||||
>
|
>
|
||||||
<Volume2 className="size-[clamp(28px,3vw,36px)]" />
|
<Volume2 className="size-[clamp(28px,3vw,36px)]" />
|
||||||
</button>
|
</button>
|
||||||
|
{showCurrentWord && (
|
||||||
|
<>
|
||||||
<AdaptiveWordText text={formatWordDisplay(currentWord.english, settings.wordDisplayMode)} variant="hero" />
|
<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>
|
<p className="mt-2 min-h-6 text-[clamp(13px,1.4vw,16px)] text-slate-500">{speechTip}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,20 +17,41 @@ export function usePkSetup() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadInitialSetup() {
|
async function loadInitialSetup() {
|
||||||
setLoadingSetup(true);
|
setLoadingSetup(true);
|
||||||
const [classTypesResponse, nextSettings] = await Promise.all([
|
const nextSettings = await loadSettings();
|
||||||
fetch("/api/class-types?includeDisabled=false", { cache: "no-store" }),
|
|
||||||
loadSettings(),
|
|
||||||
]);
|
|
||||||
const classTypesData = (await classTypesResponse.json()) as { classTypes: ClassType[] };
|
|
||||||
setClassTypes(classTypesData.classTypes);
|
|
||||||
setSettings(mergeSettings(nextSettings));
|
setSettings(mergeSettings(nextSettings));
|
||||||
setSelectedClassTypeId((current) => current || classTypesData.classTypes[0]?.id || 0);
|
|
||||||
setLoadingSetup(false);
|
setLoadingSetup(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadInitialSetup();
|
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(() => {
|
useEffect(() => {
|
||||||
function refreshSettingsOnFocus() {
|
function refreshSettingsOnFocus() {
|
||||||
void loadSettings().then((nextSettings) => setSettings(mergeSettings(nextSettings)));
|
void loadSettings().then((nextSettings) => setSettings(mergeSettings(nextSettings)));
|
||||||
@@ -41,6 +62,8 @@ export function usePkSetup() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let ignore = false;
|
||||||
|
|
||||||
async function loadWords() {
|
async function loadWords() {
|
||||||
if (!selectedClassTypeId) {
|
if (!selectedClassTypeId) {
|
||||||
setAvailableWords([]);
|
setAvailableWords([]);
|
||||||
@@ -54,10 +77,16 @@ export function usePkSetup() {
|
|||||||
});
|
});
|
||||||
const response = await fetch(`/api/words?${params.toString()}`, { cache: "no-store" });
|
const response = await fetch(`/api/words?${params.toString()}`, { cache: "no-store" });
|
||||||
const data = (await response.json()) as { words: WordItem[] };
|
const data = (await response.json()) as { words: WordItem[] };
|
||||||
|
if (!ignore) {
|
||||||
setAvailableWords(data.words);
|
setAvailableWords(data.words);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void loadWords();
|
void loadWords();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
}, [selectedGrade, selectedClassTypeId]);
|
}, [selectedGrade, selectedClassTypeId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export default function PkPage() {
|
|||||||
<GameScreen
|
<GameScreen
|
||||||
selectedGrade={selectedGrade}
|
selectedGrade={selectedGrade}
|
||||||
selectedClassTypeName={selectedClassType?.name}
|
selectedClassTypeName={selectedClassType?.name}
|
||||||
|
showCurrentWord={selectedClassType?.showCurrentWord ?? true}
|
||||||
roundWords={roundWords}
|
roundWords={roundWords}
|
||||||
currentWord={currentWord}
|
currentWord={currentWord}
|
||||||
options={options}
|
options={options}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ export type Grade = "小班" | "中班" | "大班";
|
|||||||
|
|
||||||
export type ClassType = {
|
export type ClassType = {
|
||||||
id: number;
|
id: number;
|
||||||
|
grade: Grade;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
showCurrentWord: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WordItem = {
|
export type WordItem = {
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ export type Grade = "小班" | "中班" | "大班";
|
|||||||
|
|
||||||
export type ClassType = {
|
export type ClassType = {
|
||||||
id: number;
|
id: number;
|
||||||
|
grade: Grade;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
showCurrentWord: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
@@ -47,6 +49,19 @@ export type WordInput = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WordImportInput = {
|
||||||
|
english: string;
|
||||||
|
chinese: string;
|
||||||
|
image: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WordImportResult = {
|
||||||
|
created: number;
|
||||||
|
excluded: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type DbWordRow = {
|
type DbWordRow = {
|
||||||
id: number;
|
id: number;
|
||||||
grade: Grade;
|
grade: Grade;
|
||||||
@@ -63,8 +78,10 @@ type DbWordRow = {
|
|||||||
|
|
||||||
type DbClassTypeRow = {
|
type DbClassTypeRow = {
|
||||||
id: number;
|
id: number;
|
||||||
|
grade: Grade;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: 0 | 1;
|
enabled: 0 | 1;
|
||||||
|
show_current_word: 0 | 1;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
@@ -93,8 +110,10 @@ const defaultWords = [
|
|||||||
function mapClassType(row: DbClassTypeRow): ClassType {
|
function mapClassType(row: DbClassTypeRow): ClassType {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
grade: row.grade,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
enabled: Boolean(row.enabled),
|
enabled: Boolean(row.enabled),
|
||||||
|
showCurrentWord: Boolean(row.show_current_word),
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
@@ -134,10 +153,13 @@ function initializeDatabase(database: DatabaseSync) {
|
|||||||
database.exec(`
|
database.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS class_types (
|
CREATE TABLE IF NOT EXISTS class_types (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
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,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
show_current_word INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
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 (
|
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 }>;
|
const wordColumns = database.prepare("PRAGMA table_info(words)").all() as Array<{ name: string }>;
|
||||||
if (!wordColumns.some((column) => column.name === "image")) {
|
if (!wordColumns.some((column) => column.name === "image")) {
|
||||||
database.exec("ALTER TABLE words ADD COLUMN image TEXT NOT NULL DEFAULT '';");
|
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 };
|
const classTypeCount = database.prepare("SELECT COUNT(*) AS count FROM class_types").get() as { count: number };
|
||||||
if (classTypeCount.count === 0) {
|
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) {
|
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 };
|
const wordCount = database.prepare("SELECT COUNT(*) AS count FROM words").get() as { count: number };
|
||||||
if (wordCount.count === 0) {
|
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(
|
const insertWord = database.prepare(
|
||||||
"INSERT INTO words (grade, class_type_id, english, chinese, image, enabled) VALUES (?, ?, ?, ?, ?, 1)",
|
"INSERT INTO words (grade, class_type_id, english, chinese, image, enabled) VALUES (?, ?, ?, ?, ?, 1)",
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const [grade, classTypeName, english, chinese, image] of defaultWords) {
|
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) {
|
if (classType) {
|
||||||
insertWord.run(grade, classType.id, english, chinese, image);
|
insertWord.run(grade, classType.id, english, chinese, image);
|
||||||
}
|
}
|
||||||
@@ -225,28 +254,194 @@ function initializeDatabase(database: DatabaseSync) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listClassTypes(includeDisabled = true) {
|
function migrateClassTypesByGrade(database: DatabaseSync) {
|
||||||
const database = getDatabase();
|
const classTypeColumns = database.prepare("PRAGMA table_info(class_types)").all() as Array<{ name: string }>;
|
||||||
const sql = includeDisabled
|
if (classTypeColumns.some((column) => column.name === "grade")) {
|
||||||
? "SELECT * FROM class_types ORDER BY id ASC"
|
return;
|
||||||
: "SELECT * FROM class_types WHERE enabled = 1 ORDER BY id ASC";
|
}
|
||||||
return (database.prepare(sql).all() as DbClassTypeRow[]).map(mapClassType);
|
|
||||||
|
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 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();
|
const trimmedName = name.trim();
|
||||||
if (!trimmedName) {
|
if (!trimmedName) {
|
||||||
throw new Error("班级类型不能为空");
|
throw new Error("班级类型不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = database
|
const result = database
|
||||||
.prepare("INSERT INTO class_types (name, enabled) VALUES (?, 1)")
|
.prepare("INSERT INTO class_types (grade, name, enabled, show_current_word) VALUES (?, ?, 1, ?)")
|
||||||
.run(trimmedName);
|
.run(grade, trimmedName, options?.showCurrentWord === false ? 0 : 1);
|
||||||
return getClassType(Number(result.lastInsertRowid));
|
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 database = getDatabase();
|
||||||
const trimmedName = input.name.trim();
|
const trimmedName = input.name.trim();
|
||||||
if (!trimmedName) {
|
if (!trimmedName) {
|
||||||
@@ -254,8 +449,8 @@ export function updateClassType(id: number, input: { name: string; enabled: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
database
|
database
|
||||||
.prepare("UPDATE class_types SET name = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
.prepare("UPDATE class_types SET name = ?, enabled = ?, show_current_word = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||||
.run(trimmedName, input.enabled ? 1 : 0, id);
|
.run(trimmedName, input.enabled ? 1 : 0, input.showCurrentWord === false ? 0 : 1, id);
|
||||||
return getClassType(id);
|
return getClassType(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +535,65 @@ export function createWord(input: WordInput) {
|
|||||||
return getWord(Number(result.lastInsertRowid));
|
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) {
|
export function updateWord(id: number, input: WordInput) {
|
||||||
const database = getDatabase();
|
const database = getDatabase();
|
||||||
validateWordInput(input);
|
validateWordInput(input);
|
||||||
@@ -385,19 +639,32 @@ function getWord(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateWordInput(input: WordInput) {
|
function validateWordInput(input: WordInput) {
|
||||||
if (!["小班", "中班", "大班"].includes(input.grade)) {
|
validateGrade(input.grade);
|
||||||
throw new Error("年级不正确");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Number.isInteger(input.classTypeId) || input.classTypeId <= 0) {
|
if (!Number.isInteger(input.classTypeId) || input.classTypeId <= 0) {
|
||||||
throw new Error("班级类型不正确");
|
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()) {
|
if (!input.english.trim()) {
|
||||||
throw new Error("题库英语不能为空");
|
throw new Error("题库英语不能为空");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateGrade(grade: Grade) {
|
||||||
|
if (!["小班", "中班", "大班"].includes(grade)) {
|
||||||
|
throw new Error("年级不正确");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getSettings(): AppSettings {
|
export function getSettings(): AppSettings {
|
||||||
const rows = getDatabase().prepare("SELECT key, value FROM settings").all() as Array<{
|
const rows = getDatabase().prepare("SELECT key, value FROM settings").all() as Array<{
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
+2
-1
@@ -22,7 +22,8 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
|
|||||||
Generated
+72
@@ -44,6 +44,9 @@ importers:
|
|||||||
tailwindcss-animate:
|
tailwindcss-animate:
|
||||||
specifier: ^1.0.7
|
specifier: ^1.0.7
|
||||||
version: 1.0.7(tailwindcss@3.4.19)
|
version: 1.0.7(tailwindcss@3.4.19)
|
||||||
|
xlsx:
|
||||||
|
specifier: ^0.18.5
|
||||||
|
version: 0.18.5
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.10.2
|
specifier: ^22.10.2
|
||||||
@@ -742,6 +745,10 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
adler-32@1.3.1:
|
||||||
|
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
agent-base@6.0.0:
|
agent-base@6.0.0:
|
||||||
resolution: {integrity: sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw==}
|
resolution: {integrity: sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
@@ -912,6 +919,10 @@ packages:
|
|||||||
caniuse-lite@1.0.30001799:
|
caniuse-lite@1.0.30001799:
|
||||||
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
|
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:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -930,6 +941,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -951,6 +966,11 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
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:
|
cross-fetch@4.1.0:
|
||||||
resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
|
resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
|
||||||
|
|
||||||
@@ -1289,6 +1309,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
frac@1.1.2:
|
||||||
|
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
@@ -2102,6 +2126,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
stop-iteration-iterator@1.1.0:
|
||||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2338,10 +2366,18 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
wmf@1.0.2:
|
||||||
|
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
word-wrap@1.2.5:
|
word-wrap@1.2.5:
|
||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2362,6 +2398,11 @@ packages:
|
|||||||
utf-8-validate:
|
utf-8-validate:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
xlsx@0.18.5:
|
||||||
|
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
xml-escape@1.1.0:
|
xml-escape@1.1.0:
|
||||||
resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==}
|
resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==}
|
||||||
|
|
||||||
@@ -3008,6 +3049,8 @@ snapshots:
|
|||||||
|
|
||||||
acorn@8.17.0: {}
|
acorn@8.17.0: {}
|
||||||
|
|
||||||
|
adler-32@1.3.1: {}
|
||||||
|
|
||||||
agent-base@6.0.0:
|
agent-base@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -3210,6 +3253,11 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001799: {}
|
caniuse-lite@1.0.30001799: {}
|
||||||
|
|
||||||
|
cfb@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
adler-32: 1.3.1
|
||||||
|
crc-32: 1.2.2
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -3235,6 +3283,8 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
codepage@1.15.0: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@@ -3251,6 +3301,8 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
crc-32@1.2.2: {}
|
||||||
|
|
||||||
cross-fetch@4.1.0:
|
cross-fetch@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
node-fetch: 2.7.0
|
node-fetch: 2.7.0
|
||||||
@@ -3753,6 +3805,8 @@ snapshots:
|
|||||||
hasown: 2.0.4
|
hasown: 2.0.4
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
|
|
||||||
|
frac@1.1.2: {}
|
||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
@@ -4589,6 +4643,10 @@ snapshots:
|
|||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
ssf@0.11.2:
|
||||||
|
dependencies:
|
||||||
|
frac: 1.1.2
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -4912,8 +4970,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
wmf@1.0.2: {}
|
||||||
|
|
||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
|
word@0.3.0: {}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -4928,6 +4990,16 @@ snapshots:
|
|||||||
|
|
||||||
ws@8.21.0: {}
|
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: {}
|
xml-escape@1.1.0: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user