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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user