Refactor PK page and add Trellis setup

This commit is contained in:
2026-06-30 21:03:33 +08:00
parent 908bad097c
commit 1d566bd3c0
119 changed files with 17053 additions and 538 deletions
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useRef, useState } from "react";
import type { WordItem } from "../types";
type UsePkAudioOptions = {
onGeneratedAudio: (word: WordItem) => void;
};
export function usePkAudio({ onGeneratedAudio }: UsePkAudioOptions) {
const [speechTip, setSpeechTip] = useState("点击开始后可朗读单词。");
const audioContextRef = useRef<AudioContext | null>(null);
function ensureAudioContext() {
if (!audioContextRef.current) {
audioContextRef.current = new window.AudioContext();
}
return audioContextRef.current;
}
async function playTone(correct: boolean) {
const audioContext = ensureAudioContext();
if (audioContext.state === "suspended") {
await audioContext.resume();
}
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.connect(gain);
gain.connect(audioContext.destination);
const duration = correct ? 0.4 : 0.3;
if (correct) {
osc.frequency.setValueAtTime(523.25, audioContext.currentTime);
osc.frequency.setValueAtTime(659.25, audioContext.currentTime + 0.1);
osc.frequency.setValueAtTime(783.99, audioContext.currentTime + 0.2);
gain.gain.setValueAtTime(0.25, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.4);
} else {
osc.frequency.setValueAtTime(200, audioContext.currentTime);
osc.frequency.setValueAtTime(150, audioContext.currentTime + 0.15);
gain.gain.setValueAtTime(0.25, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
}
osc.start(audioContext.currentTime);
osc.stop(audioContext.currentTime + duration);
}
async function speakWord(word: WordItem) {
if (word.audio) {
const audio = new Audio(word.audio);
audio.play().catch(() => speakSystemWord(word.english));
setSpeechTip(`正在播放:${word.english}`);
return;
}
try {
setSpeechTip(`正在生成语音:${word.english}`);
const response = await fetch(`/api/words/${word.id}/generate-audio`, { method: "POST" });
if (!response.ok) throw new Error("generate failed");
const data = (await response.json()) as { word: WordItem };
const audio = new Audio(data.word.audio);
audio.play().catch(() => speakSystemWord(word.english));
setSpeechTip(`正在播放:${word.english}`);
onGeneratedAudio(data.word);
} catch {
speakSystemWord(word.english);
}
}
function speakSystemWord(word: string) {
if (!("speechSynthesis" in window)) {
setSpeechTip("当前浏览器不支持英文朗读,请老师带读。");
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(word);
const voices = window.speechSynthesis.getVoices();
utterance.voice =
voices.find((voice) => /^en[-_]?US/i.test(voice.lang)) ??
voices.find((voice) => /^en/i.test(voice.lang)) ??
null;
utterance.lang = utterance.voice?.lang ?? "en-US";
utterance.rate = 0.85;
utterance.pitch = 1;
window.speechSynthesis.speak(utterance);
setSpeechTip(`正在朗读:${word}`);
}
return {
speechTip,
setSpeechTip,
playTone,
speakWord,
};
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { loadSettings } from "../api";
import { defaultSettings } from "../constants";
import type { AppSettings, ClassType, Grade, WordItem } from "../types";
import { mergeSettings } from "../utils";
export function usePkSetup() {
const [selectedGrade, setSelectedGrade] = useState<Grade>("中班");
const [classTypes, setClassTypes] = useState<ClassType[]>([]);
const [selectedClassTypeId, setSelectedClassTypeId] = useState<number>(0);
const [availableWords, setAvailableWords] = useState<WordItem[]>([]);
const [loadingSetup, setLoadingSetup] = useState(true);
const [settings, setSettings] = useState<AppSettings>(defaultSettings);
useEffect(() => {
async function loadInitialSetup() {
setLoadingSetup(true);
const [classTypesResponse, nextSettings] = await Promise.all([
fetch("/api/class-types?includeDisabled=false", { cache: "no-store" }),
loadSettings(),
]);
const classTypesData = (await classTypesResponse.json()) as { classTypes: ClassType[] };
setClassTypes(classTypesData.classTypes);
setSettings(mergeSettings(nextSettings));
setSelectedClassTypeId((current) => current || classTypesData.classTypes[0]?.id || 0);
setLoadingSetup(false);
}
void loadInitialSetup();
}, []);
useEffect(() => {
function refreshSettingsOnFocus() {
void loadSettings().then((nextSettings) => setSettings(mergeSettings(nextSettings)));
}
window.addEventListener("focus", refreshSettingsOnFocus);
return () => window.removeEventListener("focus", refreshSettingsOnFocus);
}, []);
useEffect(() => {
async function loadWords() {
if (!selectedClassTypeId) {
setAvailableWords([]);
return;
}
const params = new URLSearchParams({
grade: selectedGrade,
classTypeId: String(selectedClassTypeId),
enabledOnly: "true",
});
const response = await fetch(`/api/words?${params.toString()}`, { cache: "no-store" });
const data = (await response.json()) as { words: WordItem[] };
setAvailableWords(data.words);
}
void loadWords();
}, [selectedGrade, selectedClassTypeId]);
return {
selectedGrade,
setSelectedGrade,
classTypes,
selectedClassTypeId,
setSelectedClassTypeId,
availableWords,
setAvailableWords,
loadingSetup,
settings,
};
}