This commit is contained in:
2025-12-23 12:04:58 +08:00
parent 479d757d49
commit 00be6beb34
10 changed files with 24984 additions and 7 deletions

147
app/api/songs/route.ts Normal file
View File

@@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import { Song, SongFetchParams } from '@/app/types';
// 歌曲数据缓存
let songsCache: Song[] | null = null;
let cacheTimestamp: number = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
// 读取歌曲数据
async function readSongsData(): Promise<Song[]> {
const now = Date.now();
// 使用缓存
if (songsCache && (now - cacheTimestamp) < CACHE_DURATION) {
return songsCache;
}
try {
const filePath = path.join(process.cwd(), 'app/lib/songs/songs.json');
const fileContent = fs.readFileSync(filePath, 'utf-8');
const songs = JSON.parse(fileContent) as Song[];
// 更新缓存
songsCache = songs;
cacheTimestamp = now;
return songs;
} catch (error) {
console.error('Error reading songs data:', error);
throw new Error('Failed to read songs data');
}
}
// 过滤歌曲数据
function filterSongs(songs: Song[], params: SongFetchParams): Song[] {
let filtered = [...songs];
// 按ID过滤
if (params.id !== undefined) {
filtered = filtered.filter(song => song.id === params.id);
}
// 按标题过滤(支持模糊匹配)
if (params.title) {
const searchTerm = params.title.toLowerCase();
filtered = filtered.filter(song =>
song.title.toLowerCase().includes(searchTerm)
);
}
// 按中文标题过滤(支持模糊匹配)
if (params.title_cn) {
const searchTerm = params.title_cn.toLowerCase();
filtered = filtered.filter(song =>
song.title_cn.toLowerCase().includes(searchTerm)
);
}
// 按等级过滤
if (params.level) {
filtered = filtered.filter(song =>
Object.keys(song.level).includes(params.level!)
);
}
return filtered;
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// 解析查询参数
const params: SongFetchParams = {
id: searchParams.has('id') ? parseInt(searchParams.get('id')!) : undefined,
title: searchParams.get('title') || undefined,
title_cn: searchParams.get('title_cn') || undefined,
level: searchParams.get('level') || undefined,
page: searchParams.has('page') ? parseInt(searchParams.get('page')!) : 1,
limit: searchParams.has('limit') ? parseInt(searchParams.get('limit')!) : 20,
};
// 读取歌曲数据
const allSongs = await readSongsData();
// 过滤歌曲
let filteredSongs = filterSongs(allSongs, params);
// 计算分页
const page = params.page || 1;
const limit = params.limit || 20;
const skip = (page - 1) * limit;
const total = filteredSongs.length;
const totalPages = Math.ceil(total / limit);
// 应用分页
const paginatedSongs = filteredSongs.slice(skip, skip + limit);
// 构建响应
const responseData = {
success: true,
data: {
songs: paginatedSongs,
pagination: {
page,
limit,
total,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
},
},
};
// 如果是单个ID查询且未找到返回404
if (params.id !== undefined && filteredSongs.length === 0) {
return NextResponse.json(
{ success: false, error: `Song with id ${params.id} not found` },
{ status: 404 }
);
}
return NextResponse.json(responseData);
} catch (error) {
console.error('Get songs error:', error);
const errorMessage = error instanceof Error ? error.message : 'Internal server error';
return NextResponse.json(
{ success: false, error: errorMessage },
{ status: 500 }
);
}
}
// 添加 OPTIONS 方法处理 CORS 预检请求
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}

View File

@@ -0,0 +1,253 @@
'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('');
// 初始化:加载歌曲选项
useEffect(() => {
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 { songs } = await fetchSongs({ title: query, limit: 50 });
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 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');
a.href = url;
a.download = `课题生成_${new Date().toISOString().split('T')[0]}.json`;
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">
JSON导出
</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>
<Plus className="w-5 h-5 text-blue-500" />
</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>
);
}

View File

@@ -0,0 +1,380 @@
'use client';
import { useState, useEffect } from 'react';
import { Challenge, ChallengeFormData, ChallengeReward, SongOption } from '@/app/types';
import { fetchSongs } from '@/app/lib/api';
import { Search, Music, Star, Target, Gift, MessageSquare, ChevronDown, Loader2, Plus } from 'lucide-react';
interface ChallengeFormProps {
songOptions: SongOption[];
loading: boolean;
searchQuery: string;
onSearch: (query: string) => void;
onSubmit: (challenge: Challenge) => void;
darkMode: boolean;
}
export default function ChallengeForm({
songOptions,
loading,
searchQuery,
onSearch,
onSubmit,
darkMode
}: ChallengeFormProps) {
const [formData, setFormData] = useState<ChallengeFormData>({
reward: '15币'
});
const [selectedSong, setSelectedSong] = useState<SongOption | null>(null);
const [availableDifficulties, setAvailableDifficulties] = useState<string[]>([]);
const [selectedDifficulty, setSelectedDifficulty] = useState<string>('');
const [stars, setStars] = useState<number>(0);
const [showSongDropdown, setShowSongDropdown] = useState(false);
const [songDetails, setSongDetails] = useState<any>(null);
// 奖励选项
const rewardOptions: ChallengeReward[] = ['15币', '30币', '45币', '其他奖励'];
// 当选择歌曲时,获取歌曲详情和可用难度
useEffect(() => {
if (formData.songId && songOptions.length > 0) {
const song = songOptions.find(s => s.id === formData.songId);
if (song) {
setSelectedSong(song);
setAvailableDifficulties(song.levels);
// 获取歌曲详情以显示星数
fetchSongDetails(song.id);
}
} else {
setSelectedSong(null);
setAvailableDifficulties([]);
setSelectedDifficulty('');
setStars(0);
}
}, [formData.songId, songOptions]);
// 当选择难度时,更新星数
useEffect(() => {
if (selectedDifficulty && songDetails) {
const levelData = songDetails.level[selectedDifficulty];
if (levelData) {
setStars(levelData.constant);
}
}
}, [selectedDifficulty, songDetails]);
// 获取歌曲详情
const fetchSongDetails = async (songId: number) => {
try {
const { songs } = await fetchSongs({ id: songId });
if (songs.length > 0) {
setSongDetails(songs[0]);
}
} catch (error) {
console.error('获取歌曲详情失败:', error);
}
};
// 处理表单提交
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
const challenge: Challenge = {
id: `challenge_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
songId: formData.songId!,
songTitle: selectedSong?.title || '',
songTitleCn: selectedSong?.title_cn || '',
difficulty: selectedDifficulty,
stars: stars,
requiredScore: formData.requiredScore || 0,
reward: formData.reward,
customReward: formData.reward === '其他奖励' ? formData.customReward : undefined,
notes: formData.notes,
createdAt: new Date().toISOString()
};
onSubmit(challenge);
resetForm();
};
// 验证表单
const validateForm = (): boolean => {
if (!formData.songId) {
alert('请选择歌曲');
return false;
}
if (!selectedDifficulty) {
alert('请选择难度');
return false;
}
if (!formData.requiredScore || formData.requiredScore <= 0) {
alert('请输入有效的过关分数');
return false;
}
if (formData.reward === '其他奖励' && !formData.customReward?.trim()) {
alert('请输入其他奖励内容');
return false;
}
return true;
};
// 重置表单
const resetForm = () => {
setFormData({
reward: '15币'
});
setSelectedSong(null);
setSelectedDifficulty('');
setStars(0);
setSongDetails(null);
};
// 处理歌曲选择
const handleSongSelect = (song: SongOption) => {
setFormData(prev => ({ ...prev, songId: song.id }));
setShowSongDropdown(false);
};
// 处理难度选择
const handleDifficultySelect = (difficulty: string) => {
setSelectedDifficulty(difficulty);
};
// 处理奖励选择
const handleRewardSelect = (reward: ChallengeReward) => {
setFormData(prev => ({ ...prev, reward }));
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* 歌曲选择 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Music className="w-4 h-4" />
</div>
</label>
<div className="relative">
{/* 搜索框 */}
<div className="flex gap-2 mb-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => onSearch(e.target.value)}
placeholder="搜索歌曲..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{loading && (
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
)}
</div>
{/* 歌曲选择下拉框 */}
<div className="relative">
<button
type="button"
onClick={() => setShowSongDropdown(!showSongDropdown)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-left flex items-center justify-between"
>
<div className="flex items-center gap-3">
{selectedSong ? (
<>
<div className="w-8 h-8 rounded-md bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
<Music className="w-4 h-4 text-blue-600 dark:text-blue-300" />
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">
{selectedSong.title}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{selectedSong.title_cn}
</div>
</div>
</>
) : (
<span className="text-gray-500 dark:text-gray-400">...</span>
)}
</div>
<ChevronDown className={`w-5 h-5 text-gray-400 transition-transform ${showSongDropdown ? 'rotate-180' : ''}`} />
</button>
{showSongDropdown && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto">
{songOptions.length === 0 ? (
<div className="px-4 py-3 text-gray-500 dark:text-gray-400 text-center">
{loading ? '加载中...' : '未找到歌曲'}
</div>
) : (
songOptions.map(song => (
<button
key={song.id}
type="button"
onClick={() => handleSongSelect(song)}
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 border-b border-gray-100 dark:border-gray-700 last:border-b-0"
>
<div className="font-medium text-gray-900 dark:text-white">
{song.title}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{song.title_cn}
</div>
<div className="flex gap-1 mt-1">
{song.levels.map(level => (
<span
key={level}
className="px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
>
{level}
</span>
))}
</div>
</button>
))
)}
</div>
)}
</div>
</div>
</div>
{/* 难度选择 */}
{availableDifficulties.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<div className="flex flex-wrap gap-2">
{availableDifficulties.map(difficulty => (
<button
key={difficulty}
type="button"
onClick={() => handleDifficultySelect(difficulty)}
className={`px-4 py-2 rounded-lg transition-colors ${selectedDifficulty === difficulty
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{difficulty}
</button>
))}
</div>
</div>
)}
{/* 星数显示 */}
{stars > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/30 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Star className="w-5 h-5 text-yellow-500" />
<span className="font-medium text-gray-900 dark:text-white"></span>
</div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{stars.toFixed(1)}
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
constant值
</p>
</div>
)}
{/* 过关分数 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Target className="w-4 h-4" />
</div>
</label>
<input
type="number"
min="0"
step="0.1"
value={formData.requiredScore || ''}
onChange={(e) => setFormData(prev => ({
...prev,
requiredScore: e.target.value ? parseFloat(e.target.value) : undefined
}))}
placeholder="例如10.5 表示 10.5万分"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* 奖励选择 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Gift className="w-4 h-4" />
</div>
</label>
<div className="grid grid-cols-2 gap-2">
{rewardOptions.map(reward => (
<button
key={reward}
type="button"
onClick={() => handleRewardSelect(reward)}
className={`px-4 py-3 rounded-lg transition-colors ${formData.reward === reward
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{reward}
</button>
))}
</div>
{/* 其他奖励输入框 */}
{formData.reward === '其他奖励' && (
<div className="mt-3">
<input
type="text"
value={formData.customReward || ''}
onChange={(e) => setFormData(prev => ({ ...prev, customReward: e.target.value }))}
placeholder="请输入其他奖励内容..."
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
)}
</div>
{/* 注释 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
</div>
</label>
<textarea
value={formData.notes || ''}
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
placeholder="添加备注..."
rows={3}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
</div>
{/* 提交按钮 */}
<button
type="submit"
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium flex items-center justify-center gap-2"
>
<Plus className="w-5 h-5" />
</button>
</form>
);
}

View File

@@ -0,0 +1,313 @@
'use client';
import { useState } from 'react';
import { Challenge, SongOption } from '@/app/types';
import { Music, Star, Target, Gift, MessageSquare, Edit2, Trash2, Check, X, ChevronDown, ChevronUp } from 'lucide-react';
interface ChallengeListProps {
challenges: Challenge[];
songOptions: SongOption[];
onDelete: (id: string) => void;
onUpdate: (challenge: Challenge) => void;
darkMode: boolean;
}
export default function ChallengeList({
challenges,
songOptions,
onDelete,
onUpdate,
darkMode
}: ChallengeListProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [editForm, setEditForm] = useState<Partial<Challenge>>({});
// 开始编辑
const startEdit = (challenge: Challenge) => {
setEditingId(challenge.id);
setEditForm({ ...challenge });
};
// 取消编辑
const cancelEdit = () => {
setEditingId(null);
setEditForm({});
};
// 保存编辑
const saveEdit = () => {
if (editingId && editForm) {
const updatedChallenge = {
...challenges.find(c => c.id === editingId),
...editForm
} as Challenge;
onUpdate(updatedChallenge);
setEditingId(null);
setEditForm({});
}
};
// 切换展开/收起
const toggleExpand = (id: string) => {
setExpandedId(expandedId === id ? null : id);
};
// 格式化奖励显示
const formatReward = (challenge: Challenge) => {
if (challenge.reward === '其他奖励' && challenge.customReward) {
return challenge.customReward;
}
return challenge.reward;
};
// 格式化分数显示
const formatScore = (score: number) => {
return `${score}`;
};
return (
<div className="space-y-4">
{challenges.map((challenge, index) => {
const isEditing = editingId === challenge.id;
const isExpanded = expandedId === challenge.id;
return (
<div
key={challenge.id}
className={`border rounded-xl overflow-hidden transition-all duration-200 ${darkMode ? 'border-gray-700 bg-gray-800' : 'border-gray-200 bg-white'
} ${isExpanded ? 'shadow-lg' : 'shadow-sm'}`}
>
{/* 课题卡片头部 */}
<div
className={`p-4 cursor-pointer hover:bg-opacity-50 transition-colors ${darkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50'
}`}
onClick={() => toggleExpand(challenge.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${darkMode ? 'bg-blue-900' : 'bg-blue-100'
}`}>
<span className={`font-bold ${darkMode ? 'text-blue-300' : 'text-blue-600'}`}>
{index + 1}
</span>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">
{challenge.songTitle}
{challenge.songTitleCn && (
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
({challenge.songTitleCn})
</span>
)}
</h3>
<div className="flex items-center gap-3 mt-1">
<span className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1">
<Music className="w-3 h-3" />
{challenge.difficulty}
</span>
<span className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1">
<Star className="w-3 h-3 text-yellow-500" />
{challenge.stars.toFixed(1)}
</span>
<span className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1">
<Target className="w-3 h-3 text-red-500" />
{formatScore(challenge.requiredScore)}
</span>
<span className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1">
<Gift className="w-3 h-3 text-green-500" />
{formatReward(challenge)}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</div>
</div>
</div>
{/* 展开的内容 */}
{isExpanded && (
<div className={`px-4 pb-4 border-t ${darkMode ? 'border-gray-700' : 'border-gray-200'}`}>
{isEditing ? (
// 编辑模式
<div className="space-y-4 pt-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 过关分数编辑 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
type="number"
min="0"
step="0.1"
value={editForm.requiredScore || ''}
onChange={(e) => setEditForm(prev => ({
...prev,
requiredScore: e.target.value ? parseFloat(e.target.value) : 0
}))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
{/* 奖励编辑 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<select
value={editForm.reward}
onChange={(e) => setEditForm(prev => ({
...prev,
reward: e.target.value as any
}))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="15币">15</option>
<option value="30币">30</option>
<option value="45币">45</option>
<option value="其他奖励"></option>
</select>
</div>
{/* 其他奖励编辑 */}
{editForm.reward === '其他奖励' && (
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<input
type="text"
value={editForm.customReward || ''}
onChange={(e) => setEditForm(prev => ({
...prev,
customReward: e.target.value
}))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="请输入奖励内容..."
/>
</div>
)}
{/* 注释编辑 */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
</label>
<textarea
value={editForm.notes || ''}
onChange={(e) => setEditForm(prev => ({
...prev,
notes: e.target.value
}))}
rows={2}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-none"
placeholder="添加备注..."
/>
</div>
</div>
{/* 编辑操作按钮 */}
<div className="flex justify-end gap-2 pt-2">
<button
onClick={cancelEdit}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2"
>
<X className="w-4 h-4" />
</button>
<button
onClick={saveEdit}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2"
>
<Check className="w-4 h-4" />
</button>
</div>
</div>
) : (
// 查看模式
<div className="space-y-3 pt-4">
{/* 详细信息 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400">ID:</span>
<span className="font-medium text-gray-900 dark:text-white">{challenge.songId}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="font-medium text-gray-900 dark:text-white">{challenge.difficulty}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="font-medium text-gray-900 dark:text-white">{challenge.stars.toFixed(1)}</span>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="font-medium text-gray-900 dark:text-white">{formatScore(challenge.requiredScore)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="font-medium text-gray-900 dark:text-white">{formatReward(challenge)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400">:</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{new Date(challenge.createdAt).toLocaleString('zh-CN')}
</span>
</div>
</div>
</div>
{/* 注释 */}
{challenge.notes && (
<div className={`mt-3 p-3 rounded-lg ${darkMode ? 'bg-gray-700' : 'bg-gray-50'}`}>
<div className="flex items-center gap-2 mb-1">
<MessageSquare className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300"></span>
</div>
<p className="text-gray-700 dark:text-gray-300 text-sm">{challenge.notes}</p>
</div>
)}
{/* 操作按钮 */}
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => startEdit(challenge)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => {
if (window.confirm('确定要删除这个课题吗?')) {
onDelete(challenge.id);
}
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Menu, X, Moon, Sun, Home, Images } from 'lucide-react';
import { Menu, X, Moon, Sun, Home, Images, FileText } from 'lucide-react';
interface NavbarProps {
darkMode: boolean;
@@ -17,6 +17,7 @@ export default function Navbar({ darkMode, onToggleDarkMode }: NavbarProps) {
const navigation = [
{ name: '每月课题', href: '/', icon: Home },
{ name: '群相册', href: '/photos', icon: Images },
{ name: '课题生成器', href: '/challenge-generator', icon: FileText },
];
const toggleMobileMenu = () => {
@@ -36,7 +37,12 @@ export default function Navbar({ darkMode, onToggleDarkMode }: NavbarProps) {
{/* Logo */}
<div className="flex items-center">
<Link href="/" className="flex items-center space-x-2">
<h1 className="text-2xl font-bold text-black dark:text-white">
<h1
className="text-3xl"
style={{
color: darkMode ? 'white' : '#FB923C'
}}
>
</h1>
</Link>
@@ -52,8 +58,12 @@ export default function Navbar({ darkMode, onToggleDarkMode }: NavbarProps) {
key={item.name}
href={item.href}
className={`flex items-center space-x-2 px-3 py-2 rounded-lg transition-colors ${isActive
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
? darkMode
? 'bg-[#FFEDD5]/60 text-[#EA580C]'
: 'bg-[#FFEDD5]/60 text-[#EA580C]'
: darkMode
? 'text-gray-300 hover:text-white hover:bg-[#EA580C]/80'
: 'text-gray-700 hover:text-white hover:bg-[#EA580C]/80'
}`}
>
<Icon className="w-4 h-4" />
@@ -160,8 +170,12 @@ export default function Navbar({ darkMode, onToggleDarkMode }: NavbarProps) {
href={item.href}
onClick={closeMobileMenu}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${isActive
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
? darkMode
? 'bg-blue-900/30 text-blue-300'
: 'bg-blue-100 text-blue-700'
: darkMode
? 'text-gray-300 hover:bg-gray-800'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<Icon className="w-5 h-5" />

View File

@@ -11,6 +11,15 @@
--body-gradient-color-3: transparent;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
/* 设置 dark 模式为 class 策略 */
--dark-mode: class;
}
/* 暗色主题变量覆盖 */
.dark {

View File

@@ -1,4 +1,4 @@
import { Photo, Pagination, PhotoFetchParams } from '@/app/types';
import { Photo, Pagination, PhotoFetchParams, Song, SongFetchParams } from '@/app/types';
/**
* 获取照片数据
@@ -60,3 +60,68 @@ export async function fetchAllPhotos(): Promise<Photo[]> {
throw error;
}
}
/**
* 获取歌曲数据
* @param params 获取参数
* @returns 歌曲数据和分页信息
*/
export async function fetchSongs(params: SongFetchParams = {}): Promise<{
songs: Song[];
pagination: Pagination;
}> {
const {
id,
title,
title_cn,
level,
page = 1,
limit = 20,
} = params;
try {
const queryParams = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
...(id !== undefined && { id: id.toString() }),
...(title && { title }),
...(title_cn && { title_cn }),
...(level && { level }),
});
const response = await fetch(`/api/songs?${queryParams}`);
const data = await response.json();
if (data.success) {
return {
songs: data.data.songs,
pagination: data.data.pagination,
};
} else {
throw new Error(data.error || '获取歌曲失败');
}
} catch (error) {
console.error('Error fetching songs:', error);
throw error;
}
}
/**
* 获取所有歌曲(不分页)
* @returns 所有歌曲数组
*/
export async function fetchAllSongs(): Promise<Song[]> {
try {
const response = await fetch('/api/songs?limit=1000');
const data = await response.json();
if (data.success) {
return data.data.songs;
} else {
throw new Error(data.error || '获取歌曲失败');
}
} catch (error) {
console.error('Error fetching all songs:', error);
throw error;
}
}

23622
app/lib/songs/songs.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -51,3 +51,108 @@ export function formatFileSize(bytes: number): string {
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* 验证课题数据
* @param challenge 课题数据
* @returns 验证结果和错误信息
*/
export function validateChallenge(challenge: any): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
if (!challenge.songId || typeof challenge.songId !== 'number') {
errors.push('歌曲ID无效');
}
if (!challenge.difficulty || typeof challenge.difficulty !== 'string') {
errors.push('难度无效');
}
if (!challenge.stars || typeof challenge.stars !== 'number' || challenge.stars <= 0) {
errors.push('星数无效');
}
if (!challenge.requiredScore || typeof challenge.requiredScore !== 'number' || challenge.requiredScore <= 0) {
errors.push('过关分数无效');
}
if (!challenge.reward || !['15币', '30币', '45币', '其他奖励'].includes(challenge.reward)) {
errors.push('奖励类型无效');
}
if (challenge.reward === '其他奖励' && (!challenge.customReward || challenge.customReward.trim() === '')) {
errors.push('其他奖励内容不能为空');
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* 格式化分数显示(单位:万)
* @param score 分数
* @returns 格式化后的字符串
*/
export function formatScore(score: number): string {
return `${score}`;
}
/**
* 生成课题JSON文件名
* @returns 文件名
*/
export function generateChallengeFilename(): string {
const now = new Date();
const dateStr = now.toISOString().split('T')[0];
const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, '-');
return `课题生成_${dateStr}_${timeStr}.json`;
}
/**
* 导出课题数据为JSON
* @param challenges 课题数组
* @returns JSON字符串
*/
export function exportChallengesToJSON(challenges: any[]): string {
const data = {
challenges: challenges,
generatedAt: new Date().toISOString(),
totalChallenges: challenges.length,
version: '1.0.0'
};
return JSON.stringify(data, null, 2);
}
/**
* 下载JSON文件
* @param jsonString JSON字符串
* @param filename 文件名
*/
export function downloadJSON(jsonString: string, filename: string): void {
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* 从歌曲数据生成歌曲选项
* @param songs 歌曲数组
* @returns 歌曲选项数组
*/
export function generateSongOptions(songs: any[]): any[] {
return songs.map(song => ({
id: song.id,
title: song.title,
title_cn: song.title_cn,
levels: Object.keys(song.level)
}));
}

View File

@@ -27,3 +27,72 @@ export interface PhotoFetchParams {
search?: string;
sort?: 'newest' | 'oldest' | 'title';
}
// 歌曲等级类型定义
export interface SongLevel {
constant: number;
totalNotes: number;
composite: number;
avgDensity: number;
instDensity: number;
separation: number;
bpmChange: number;
hsChange: number;
}
// 歌曲类型定义
export interface Song {
id: number;
title: string;
level: {
[key: string]: SongLevel;
};
title_cn: string;
is_cn: boolean;
}
// 歌曲获取参数类型
export interface SongFetchParams {
id?: number;
title?: string;
title_cn?: string;
level?: string;
page?: number;
limit?: number;
}
// 课题奖励类型
export type ChallengeReward = '15币' | '30币' | '45币' | '其他奖励';
// 课题类型定义
export interface Challenge {
id: string; // 唯一标识符
songId: number; // 歌曲ID
songTitle: string; // 歌曲标题
songTitleCn: string; // 歌曲中文标题
difficulty: string; // 难度(如"4"、"5"等)
stars: number; // 星数对应constant值
requiredScore: number; // 需要过关的分数(单位:万)
reward: ChallengeReward; // 奖励
customReward?: string; // 其他奖励的文本当reward为"其他奖励"时)
notes?: string; // 注释
createdAt: string; // 创建时间
}
// 课题表单数据类型
export interface ChallengeFormData {
songId?: number; // 歌曲ID
difficulty?: string; // 难度
requiredScore?: number; // 需要过关的分数(单位:万)
reward: ChallengeReward; // 奖励
customReward?: string; // 其他奖励的文本
notes?: string; // 注释
}
// 歌曲选择项类型
export interface SongOption {
id: number;
title: string;
title_cn: string;
levels: string[]; // 可用的难度等级
}