Files
zzTaiko/app/challenge-generator/page.tsx
2025-12-23 15:28:28 +08:00

292 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect } from 'react';
import Navbar from '@/app/components/Navbar';
import ChallengeForm from '@/app/components/ChallengeForm';
import ChallengeList from '@/app/components/ChallengeList';
import { Challenge, SongOption } from '@/app/types';
import { fetchSongs } from '@/app/lib/api';
import { Download, Trash2, Plus, FileText } from 'lucide-react';
export default function ChallengeGeneratorPage() {
const [challenges, setChallenges] = useState<Challenge[]>([]);
const [songOptions, setSongOptions] = useState<SongOption[]>([]);
const [loading, setLoading] = useState(false);
const [darkMode, setDarkMode] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// 初始化从localStorage加载已保存的课题并加载歌曲选项
useEffect(() => {
// 从localStorage加载已保存的课题
const savedChallenges = localStorage.getItem('taiko_challenges');
if (savedChallenges) {
try {
const parsedChallenges = JSON.parse(savedChallenges);
setChallenges(parsedChallenges);
} catch (error) {
console.error('加载已保存课题失败:', error);
// 如果解析失败清空localStorage中的数据
localStorage.removeItem('taiko_challenges');
}
}
loadSongOptions();
}, []);
// 加载歌曲选项
const loadSongOptions = async () => {
setLoading(true);
try {
const { songs } = await fetchSongs({ limit: 100 });
const options: SongOption[] = songs.map(song => ({
id: song.id,
title: song.title,
title_cn: song.title_cn,
levels: Object.keys(song.level)
}));
setSongOptions(options);
} catch (error) {
console.error('加载歌曲选项失败:', error);
} finally {
setLoading(false);
}
};
// 搜索歌曲
const handleSearchSongs = async (query: string) => {
setSearchQuery(query);
if (!query.trim()) {
loadSongOptions();
return;
}
setLoading(true);
try {
// 同时搜索英文标题和中文标题
const [englishResults, chineseResults] = await Promise.all([
fetchSongs({ title: query, limit: 50 }),
fetchSongs({ title_cn: query, limit: 50 })
]);
// 合并结果,去重
const allSongs = [...englishResults.songs, ...chineseResults.songs];
const uniqueSongs = Array.from(new Map(allSongs.map(song => [song.id, song])).values());
const options: SongOption[] = uniqueSongs.map(song => ({
id: song.id,
title: song.title,
title_cn: song.title_cn,
levels: Object.keys(song.level)
}));
setSongOptions(options);
} catch (error) {
console.error('搜索歌曲失败:', error);
} finally {
setLoading(false);
}
};
// 保存课题到localStorage
useEffect(() => {
if (challenges.length > 0) {
localStorage.setItem('taiko_challenges', JSON.stringify(challenges));
} else {
// 如果课题为空从localStorage中移除数据
localStorage.removeItem('taiko_challenges');
}
}, [challenges]);
// 添加课题
const handleAddChallenge = (challenge: Challenge) => {
setChallenges(prev => [...prev, challenge]);
};
// 删除课题
const handleDeleteChallenge = (id: string) => {
setChallenges(prev => prev.filter(challenge => challenge.id !== id));
};
// 更新课题
const handleUpdateChallenge = (updatedChallenge: Challenge) => {
setChallenges(prev => prev.map(challenge =>
challenge.id === updatedChallenge.id ? updatedChallenge : challenge
));
};
// 清空所有课题
const handleClearAll = () => {
if (challenges.length > 0 && window.confirm('确定要清空所有课题吗?')) {
setChallenges([]);
}
};
// 生成并下载JSON
const handleGenerateJSON = () => {
if (challenges.length === 0) {
alert('请先添加至少一个课题');
return;
}
const data = {
challenges: challenges,
generatedAt: new Date().toISOString(),
totalChallenges: challenges.length
};
const jsonString = JSON.stringify(data, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
// 生成年份-月份格式的文件名例如2025-12.json
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const fileName = `${year}-${month}.json`;
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// 切换暗色模式
const toggleDarkMode = () => {
setDarkMode(prevDarkMode => {
const newDarkMode = !prevDarkMode;
if (newDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
return newDarkMode;
});
};
return (
<div className={`min-h-screen transition-colors duration-200 ${darkMode ? 'dark' : ''}`}>
{/* 导航栏 */}
<Navbar darkMode={darkMode} onToggleDarkMode={toggleDarkMode} />
{/* 主内容 */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 页面标题 */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<FileText className="w-8 h-8" />
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 左侧:表单区域 */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 sticky top-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
</h2>
</div>
<ChallengeForm
songOptions={songOptions}
loading={loading}
searchQuery={searchQuery}
onSearch={handleSearchSongs}
onSubmit={handleAddChallenge}
darkMode={darkMode}
/>
</div>
</div>
{/* 右侧:课题列表和操作区域 */}
<div className="lg:col-span-2">
{/* 操作按钮 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 mb-6">
<div className="flex flex-wrap gap-4">
<button
onClick={handleGenerateJSON}
disabled={challenges.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Download className="w-4 h-4" />
JSON并下载
</button>
<button
onClick={handleClearAll}
disabled={challenges.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
<div className="ml-auto flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<span>:</span>
<span className="font-bold text-blue-600 dark:text-blue-400">
{challenges.length}
</span>
</div>
</div>
</div>
{/* 课题列表 */}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
</span>
</div>
{challenges.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center">
<FileText className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
</h3>
<p className="text-gray-600 dark:text-gray-400">
</p>
</div>
) : (
<ChallengeList
challenges={challenges}
songOptions={songOptions}
onDelete={handleDeleteChallenge}
onUpdate={handleUpdateChallenge}
darkMode={darkMode}
/>
)}
</div>
{/* JSON预览可选 */}
{challenges.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 mt-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
JSON预览
</h3>
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-auto max-h-64">
<pre className="text-sm text-gray-700 dark:text-gray-300">
{JSON.stringify({ challenges }, null, 2)}
</pre>
</div>
</div>
)}
</div>
</div>
</main>
</div>
);
}