update
This commit is contained in:
147
app/api/songs/route.ts
Normal file
147
app/api/songs/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
253
app/challenge-generator/page.tsx
Normal file
253
app/challenge-generator/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
380
app/components/ChallengeForm.tsx
Normal file
380
app/components/ChallengeForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
313
app/components/ChallengeList.tsx
Normal file
313
app/components/ChallengeList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
23622
app/lib/songs/songs.json
Normal file
File diff suppressed because it is too large
Load Diff
105
app/lib/utils.ts
105
app/lib/utils.ts
@@ -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)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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[]; // 可用的难度等级
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user