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
+289 -22
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)");
for (const name of defaultClassTypes) {
insertClassType.run(name);
const insertClassType = database.prepare("INSERT INTO class_types (grade, name, enabled, show_current_word) VALUES (?, ?, 1, 1)");
for (const grade of ["小班", "中班", "大班"] satisfies Grade[]) {
for (const name of defaultClassTypes) {
insertClassType.run(grade, name);
}
}
}
const wordCount = database.prepare("SELECT COUNT(*) AS count FROM words").get() as { count: number };
if (wordCount.count === 0) {
const findClassType = database.prepare("SELECT id FROM class_types WHERE name = ?");
const findClassType = database.prepare("SELECT id FROM class_types WHERE grade = ? AND name = ?");
const insertWord = database.prepare(
"INSERT INTO words (grade, class_type_id, english, chinese, image, enabled) VALUES (?, ?, ?, ?, ?, 1)",
);
for (const [grade, classTypeName, english, chinese, image] of defaultWords) {
const classType = findClassType.get(classTypeName) as { id: number } | undefined;
const classType = findClassType.get(grade, classTypeName) as { id: number } | undefined;
if (classType) {
insertWord.run(grade, classType.id, english, chinese, image);
}
@@ -225,28 +254,194 @@ function initializeDatabase(database: DatabaseSync) {
}
}
export function listClassTypes(includeDisabled = true) {
const database = getDatabase();
const sql = includeDisabled
? "SELECT * FROM class_types ORDER BY id ASC"
: "SELECT * FROM class_types WHERE enabled = 1 ORDER BY id ASC";
return (database.prepare(sql).all() as DbClassTypeRow[]).map(mapClassType);
function migrateClassTypesByGrade(database: DatabaseSync) {
const classTypeColumns = database.prepare("PRAGMA table_info(class_types)").all() as Array<{ name: string }>;
if (classTypeColumns.some((column) => column.name === "grade")) {
return;
}
const oldClassTypes = database.prepare("SELECT * FROM class_types ORDER BY id ASC").all() as Array<{
id: number;
name: string;
enabled: 0 | 1;
created_at: string;
updated_at: string;
}>;
const wordGradeRows = database
.prepare("SELECT class_type_id, grade FROM words GROUP BY class_type_id, grade")
.all() as Array<{ class_type_id: number; grade: Grade }>;
const gradesByClassType = new Map<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();
}