447 lines
14 KiB
TypeScript
447 lines
14 KiB
TypeScript
import { existsSync, mkdirSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
|
|
export type Grade = "小班" | "中班" | "大班";
|
|
|
|
export type ClassType = {
|
|
id: number;
|
|
name: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export type AppSettings = {
|
|
ttsVoice: string;
|
|
ttsRate: string;
|
|
scorePerCorrect: number;
|
|
scorePerWrong: number;
|
|
autoSpeak: boolean;
|
|
wordDisplayMode: "uppercase" | "capitalize" | "input";
|
|
pkWordCount: number;
|
|
pkOptionCount: number;
|
|
};
|
|
|
|
export type WordRecord = {
|
|
id: number;
|
|
grade: Grade;
|
|
classTypeId: number;
|
|
classTypeName: string;
|
|
english: string;
|
|
chinese: string;
|
|
image: string;
|
|
audio: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
export type WordInput = {
|
|
grade: Grade;
|
|
classTypeId: number;
|
|
english: string;
|
|
chinese: string;
|
|
image: string;
|
|
audio?: string;
|
|
enabled: boolean;
|
|
};
|
|
|
|
type DbWordRow = {
|
|
id: number;
|
|
grade: Grade;
|
|
class_type_id: number;
|
|
class_type_name: string;
|
|
english: string;
|
|
chinese: string | null;
|
|
image: string | null;
|
|
audio: string | null;
|
|
enabled: 0 | 1;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
type DbClassTypeRow = {
|
|
id: number;
|
|
name: string;
|
|
enabled: 0 | 1;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
const dbDirectory = path.join(process.cwd(), "data");
|
|
const dbPath = path.join(dbDirectory, "english-pk.sqlite");
|
|
|
|
let db: DatabaseSync | null = null;
|
|
|
|
const defaultClassTypes = ["基础班", "提高班", "复习班"];
|
|
const defaultWords = [
|
|
["中班", "基础班", "box", "盒子", "📦"],
|
|
["中班", "基础班", "fox", "狐狸", "🦊"],
|
|
["中班", "基础班", "ox", "公牛", "🐂"],
|
|
["中班", "基础班", "ax", "斧头", "🪓"],
|
|
["中班", "提高班", "yarn", "毛线", "🧶"],
|
|
["中班", "提高班", "yo-yo", "悠悠球", "🪀"],
|
|
["中班", "提高班", "yam", "山药", "🍠"],
|
|
["中班", "提高班", "yellow", "黄色", "💛"],
|
|
["中班", "复习班", "zoo", "动物园", "🏞️"],
|
|
["中班", "复习班", "zero", "零", "0️⃣"],
|
|
["中班", "复习班", "zebra", "斑马", "🦓"],
|
|
["中班", "复习班", "zipper", "拉链", "🧥"],
|
|
] satisfies Array<[Grade, string, string, string, string]>;
|
|
|
|
function mapClassType(row: DbClassTypeRow): ClassType {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
enabled: Boolean(row.enabled),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
}
|
|
|
|
function mapWord(row: DbWordRow): WordRecord {
|
|
return {
|
|
id: row.id,
|
|
grade: row.grade,
|
|
classTypeId: row.class_type_id,
|
|
classTypeName: row.class_type_name,
|
|
english: row.english,
|
|
chinese: row.chinese ?? "",
|
|
image: row.image ?? "",
|
|
audio: row.audio ?? "",
|
|
enabled: Boolean(row.enabled),
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at,
|
|
};
|
|
}
|
|
|
|
function getDatabase() {
|
|
if (db) return db;
|
|
|
|
if (!existsSync(dbDirectory)) {
|
|
mkdirSync(dbDirectory, { recursive: true });
|
|
}
|
|
|
|
db = new DatabaseSync(dbPath);
|
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
initializeDatabase(db);
|
|
return db;
|
|
}
|
|
|
|
function initializeDatabase(database: DatabaseSync) {
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS class_types (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS words (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
grade TEXT NOT NULL,
|
|
class_type_id INTEGER NOT NULL,
|
|
english TEXT NOT NULL,
|
|
chinese TEXT NOT NULL DEFAULT '',
|
|
image TEXT NOT NULL DEFAULT '',
|
|
audio TEXT NOT NULL DEFAULT '',
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (class_type_id) REFERENCES class_types(id) ON DELETE RESTRICT,
|
|
UNIQUE (grade, class_type_id, english)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
`);
|
|
|
|
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 '';");
|
|
}
|
|
if (!wordColumns.some((column) => column.name === "chinese")) {
|
|
database.exec("ALTER TABLE words ADD COLUMN chinese TEXT NOT NULL DEFAULT '';");
|
|
}
|
|
if (!wordColumns.some((column) => column.name === "audio")) {
|
|
database.exec("ALTER TABLE words ADD COLUMN audio TEXT NOT NULL DEFAULT '';");
|
|
}
|
|
|
|
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 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 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;
|
|
if (classType) {
|
|
insertWord.run(grade, classType.id, english, chinese, image);
|
|
}
|
|
}
|
|
}
|
|
|
|
const defaultIconUpdates = database.prepare(
|
|
"UPDATE words SET image = ? WHERE english = ? AND (image IS NULL OR image = '')",
|
|
);
|
|
for (const [, , english, , image] of defaultWords) {
|
|
defaultIconUpdates.run(image, english);
|
|
}
|
|
const defaultChineseUpdates = database.prepare(
|
|
"UPDATE words SET chinese = ? WHERE english = ? AND (chinese IS NULL OR chinese = '')",
|
|
);
|
|
for (const [, , english, chinese] of defaultWords) {
|
|
defaultChineseUpdates.run(chinese, english);
|
|
}
|
|
|
|
const defaultSettings: AppSettings = {
|
|
ttsVoice: "en-US-EmmaMultilingualNeural",
|
|
ttsRate: "-10%",
|
|
scorePerCorrect: 10,
|
|
scorePerWrong: 0,
|
|
autoSpeak: true,
|
|
wordDisplayMode: "uppercase",
|
|
pkWordCount: 0,
|
|
pkOptionCount: 4,
|
|
};
|
|
const insertSetting = database.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)");
|
|
for (const [key, value] of Object.entries(defaultSettings)) {
|
|
insertSetting.run(key, String(value));
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
export function createClassType(name: string) {
|
|
const database = getDatabase();
|
|
const trimmedName = name.trim();
|
|
if (!trimmedName) {
|
|
throw new Error("班级类型不能为空");
|
|
}
|
|
|
|
const result = database
|
|
.prepare("INSERT INTO class_types (name, enabled) VALUES (?, 1)")
|
|
.run(trimmedName);
|
|
return getClassType(Number(result.lastInsertRowid));
|
|
}
|
|
|
|
export function updateClassType(id: number, input: { name: string; enabled: boolean }) {
|
|
const database = getDatabase();
|
|
const trimmedName = input.name.trim();
|
|
if (!trimmedName) {
|
|
throw new Error("班级类型不能为空");
|
|
}
|
|
|
|
database
|
|
.prepare("UPDATE class_types SET name = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
|
.run(trimmedName, input.enabled ? 1 : 0, id);
|
|
return getClassType(id);
|
|
}
|
|
|
|
export function deleteClassType(id: number) {
|
|
const database = getDatabase();
|
|
database.prepare("DELETE FROM class_types WHERE id = ?").run(id);
|
|
}
|
|
|
|
function getClassType(id: number) {
|
|
const row = getDatabase().prepare("SELECT * FROM class_types WHERE id = ?").get(id) as
|
|
| DbClassTypeRow
|
|
| undefined;
|
|
if (!row) return null;
|
|
return mapClassType(row);
|
|
}
|
|
|
|
export function listWords(filters?: { grade?: string; classTypeId?: number; enabledOnly?: boolean }) {
|
|
const database = getDatabase();
|
|
const clauses: string[] = [];
|
|
const values: Array<string | number> = [];
|
|
|
|
if (filters?.grade) {
|
|
clauses.push("words.grade = ?");
|
|
values.push(filters.grade);
|
|
}
|
|
|
|
if (filters?.classTypeId) {
|
|
clauses.push("words.class_type_id = ?");
|
|
values.push(filters.classTypeId);
|
|
}
|
|
|
|
if (filters?.enabledOnly) {
|
|
clauses.push("words.enabled = 1");
|
|
clauses.push("class_types.enabled = 1");
|
|
}
|
|
|
|
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
const rows = database
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
words.id,
|
|
words.grade,
|
|
words.class_type_id,
|
|
class_types.name AS class_type_name,
|
|
words.english,
|
|
words.chinese,
|
|
words.image,
|
|
words.audio,
|
|
words.enabled,
|
|
words.created_at,
|
|
words.updated_at
|
|
FROM words
|
|
JOIN class_types ON class_types.id = words.class_type_id
|
|
${where}
|
|
ORDER BY words.id ASC
|
|
`,
|
|
)
|
|
.all(...values) as DbWordRow[];
|
|
|
|
return rows.map(mapWord);
|
|
}
|
|
|
|
export function findWord(id: number) {
|
|
return getWord(id);
|
|
}
|
|
|
|
export function createWord(input: WordInput) {
|
|
const database = getDatabase();
|
|
validateWordInput(input);
|
|
const result = database
|
|
.prepare("INSERT INTO words (grade, class_type_id, english, chinese, image, audio, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
.run(
|
|
input.grade,
|
|
input.classTypeId,
|
|
input.english.trim(),
|
|
input.chinese.trim(),
|
|
input.image.trim(),
|
|
input.audio?.trim() ?? "",
|
|
input.enabled ? 1 : 0,
|
|
);
|
|
return getWord(Number(result.lastInsertRowid));
|
|
}
|
|
|
|
export function updateWord(id: number, input: WordInput) {
|
|
const database = getDatabase();
|
|
validateWordInput(input);
|
|
const existing = getWord(id);
|
|
const shouldClearAudio =
|
|
existing &&
|
|
(existing.grade !== input.grade ||
|
|
existing.classTypeId !== input.classTypeId ||
|
|
existing.english !== input.english.trim());
|
|
|
|
database
|
|
.prepare(
|
|
"UPDATE words SET grade = ?, class_type_id = ?, english = ?, chinese = ?, image = ?, audio = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
|
)
|
|
.run(
|
|
input.grade,
|
|
input.classTypeId,
|
|
input.english.trim(),
|
|
input.chinese.trim(),
|
|
input.image.trim(),
|
|
shouldClearAudio ? "" : input.audio?.trim() ?? existing?.audio ?? "",
|
|
input.enabled ? 1 : 0,
|
|
id,
|
|
);
|
|
return getWord(id);
|
|
}
|
|
|
|
export function deleteWord(id: number) {
|
|
const database = getDatabase();
|
|
database.prepare("DELETE FROM words WHERE id = ?").run(id);
|
|
}
|
|
|
|
export function updateWordAudio(id: number, audio: string) {
|
|
const database = getDatabase();
|
|
database
|
|
.prepare("UPDATE words SET audio = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
|
.run(audio.trim(), id);
|
|
return getWord(id);
|
|
}
|
|
|
|
function getWord(id: number) {
|
|
return listWords().find((word) => word.id === id) ?? null;
|
|
}
|
|
|
|
function validateWordInput(input: WordInput) {
|
|
if (!["小班", "中班", "大班"].includes(input.grade)) {
|
|
throw new Error("年级不正确");
|
|
}
|
|
|
|
if (!Number.isInteger(input.classTypeId) || input.classTypeId <= 0) {
|
|
throw new Error("班级类型不正确");
|
|
}
|
|
|
|
if (!input.english.trim()) {
|
|
throw new Error("题库英语不能为空");
|
|
}
|
|
}
|
|
|
|
export function getSettings(): AppSettings {
|
|
const rows = getDatabase().prepare("SELECT key, value FROM settings").all() as Array<{
|
|
key: string;
|
|
value: string;
|
|
}>;
|
|
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
|
|
|
return {
|
|
ttsVoice: settings.ttsVoice || "en-US-EmmaMultilingualNeural",
|
|
ttsRate: settings.ttsRate || "-10%",
|
|
scorePerCorrect: Number(settings.scorePerCorrect || 10),
|
|
scorePerWrong: Number(settings.scorePerWrong || 0),
|
|
autoSpeak: settings.autoSpeak !== "false",
|
|
wordDisplayMode: parseWordDisplayMode(settings.wordDisplayMode),
|
|
pkWordCount: Math.max(0, Number(settings.pkWordCount || 0)),
|
|
pkOptionCount: normalizeOptionCount(Number(settings.pkOptionCount || 4)),
|
|
};
|
|
}
|
|
|
|
export function updateSettings(input: AppSettings) {
|
|
const database = getDatabase();
|
|
const statement = database.prepare(
|
|
"INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
|
|
);
|
|
statement.run("ttsVoice", input.ttsVoice.trim() || "en-US-EmmaMultilingualNeural");
|
|
statement.run("ttsRate", input.ttsRate.trim() || "-10%");
|
|
statement.run("scorePerCorrect", String(Math.max(1, Number(input.scorePerCorrect) || 10)));
|
|
statement.run("scorePerWrong", String(Math.max(0, Number(input.scorePerWrong) || 0)));
|
|
statement.run("autoSpeak", String(Boolean(input.autoSpeak)));
|
|
statement.run("wordDisplayMode", parseWordDisplayMode(input.wordDisplayMode));
|
|
statement.run("pkWordCount", String(Math.max(0, Math.floor(Number(input.pkWordCount) || 0))));
|
|
statement.run("pkOptionCount", String(normalizeOptionCount(Number(input.pkOptionCount) || 4)));
|
|
return getSettings();
|
|
}
|
|
|
|
function parseWordDisplayMode(value: unknown): AppSettings["wordDisplayMode"] {
|
|
return value === "capitalize" || value === "input" || value === "uppercase" ? value : "uppercase";
|
|
}
|
|
|
|
function normalizeOptionCount(value: number) {
|
|
const count = Math.floor(value);
|
|
if (!Number.isFinite(count)) return 4;
|
|
if (count < 2) return 2;
|
|
if (count > 8) return 8;
|
|
return count % 2 === 0 ? count : count + 1;
|
|
}
|