update
This commit is contained in:
92
app/api/config/routes.ts
Normal file
92
app/api/config/routes.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/app/lib/PrismaClient';
|
||||
|
||||
// TODO: admin 验证
|
||||
|
||||
// GET: 获取配置
|
||||
export async function GET() {
|
||||
try {
|
||||
let config = await prisma.tournamentConfig.findUnique({
|
||||
where: { id: 1 },
|
||||
});
|
||||
return NextResponse.json(config);
|
||||
} catch (error) {
|
||||
console.error('获取比赛配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: 更新配置 需要管理验证
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
// 验证必要字段
|
||||
const requiredFields = [
|
||||
'tournament_name',
|
||||
'max_pp_for_registration',
|
||||
'min_pp_for_registration',
|
||||
'current_seasonal',
|
||||
'current_category',
|
||||
'canRegister',
|
||||
];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (data[field] === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: `缺少必要字段: ${field}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新或创建配置
|
||||
const config = await prisma.tournamentConfig.upsert({
|
||||
where: { id: 1 },
|
||||
update: data,
|
||||
create: {
|
||||
id: 1,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(config);
|
||||
} catch (error) {
|
||||
console.error('更新比赛配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '更新配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// INIT: 初始化配置 仅主办使用
|
||||
export async function INIT() {
|
||||
try {
|
||||
let config = await prisma.tournamentConfig.findUnique({
|
||||
where: { id: 1 },
|
||||
});
|
||||
if (!config) {
|
||||
config = await prisma.tournamentConfig.create({
|
||||
data: {
|
||||
tournament_name: 'Astar Cup',
|
||||
max_pp_for_registration: 1000,
|
||||
min_pp_for_registration: 0,
|
||||
current_seasonal: 'S1',
|
||||
current_category: 'QUA',
|
||||
canRegister: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
return NextResponse.json(config);
|
||||
} catch (error) {
|
||||
console.error('初始化比赛配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '初始化配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
0
app/components/ui/LargeCard.tsx
Normal file
0
app/components/ui/LargeCard.tsx
Normal file
231
app/components/ui/MainLogo.tsx
Normal file
231
app/components/ui/MainLogo.tsx
Normal file
File diff suppressed because one or more lines are too long
77
app/debug/api/tournament-config/route.ts
Normal file
77
app/debug/api/tournament-config/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/app/lib/PrismaClient';
|
||||
|
||||
// GET: 获取配置
|
||||
export async function GET() {
|
||||
try {
|
||||
let config = await prisma.tournamentConfig.findUnique({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
config = await prisma.tournamentConfig.create({
|
||||
data: {
|
||||
id: 1,
|
||||
tournament_name: 'AstarCup',
|
||||
max_pp_for_registration: 0,
|
||||
min_pp_for_registration: 0,
|
||||
current_seasonal: 'S1',
|
||||
current_category: 'QUA',
|
||||
canRegister: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(config);
|
||||
} catch (error) {
|
||||
console.error('获取比赛配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: 更新配置
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
// 验证必要字段
|
||||
const requiredFields = [
|
||||
'tournament_name',
|
||||
'max_pp_for_registration',
|
||||
'min_pp_for_registration',
|
||||
'current_seasonal',
|
||||
'current_category',
|
||||
'canRegister',
|
||||
];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (data[field] === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: `缺少必要字段: ${field}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新或创建配置
|
||||
const config = await prisma.tournamentConfig.upsert({
|
||||
where: { id: 1 },
|
||||
update: data,
|
||||
create: {
|
||||
id: 1,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(config);
|
||||
} catch (error) {
|
||||
console.error('更新比赛配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '更新配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
365
app/debug/components/TournamentConfigCard.tsx
Normal file
365
app/debug/components/TournamentConfigCard.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
"use client"
|
||||
import { useState, useEffect, use } from 'react';
|
||||
import { Season, Category } from '@/app/generated/prisma/enums';
|
||||
import { useSeasonOptions, useCategoryOptions } from '@/app/lib/enum-labels';
|
||||
|
||||
interface TournamentConfigData {
|
||||
id: number;
|
||||
tournament_name: string;
|
||||
max_pp_for_registration: number;
|
||||
min_pp_for_registration: number;
|
||||
current_seasonal: Season;
|
||||
current_category: Category;
|
||||
canRegister: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface TournamentConfigCardProps {
|
||||
initialData?: TournamentConfigData;
|
||||
onUpdate?: (data: TournamentConfigData) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export default function TournamentConfigCard({
|
||||
initialData,
|
||||
onUpdate,
|
||||
onError
|
||||
}: TournamentConfigCardProps) {
|
||||
// 状态管理
|
||||
const [config, setConfig] = useState<TournamentConfigData | null>(initialData || null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState({
|
||||
tournament_name: '',
|
||||
max_pp_for_registration: 0,
|
||||
min_pp_for_registration: 0,
|
||||
current_seasonal: 'S1' as Season,
|
||||
current_category: 'QUA' as Category,
|
||||
canRegister: false,
|
||||
});
|
||||
|
||||
// 枚举选项
|
||||
const seasonOptions = useSeasonOptions();
|
||||
const categoryOptions = useCategoryOptions();
|
||||
|
||||
// 初始化函数
|
||||
const initializeForm = (data: TournamentConfigData) => {
|
||||
setFormData({
|
||||
tournament_name: data.tournament_name,
|
||||
max_pp_for_registration: data.max_pp_for_registration,
|
||||
min_pp_for_registration: data.min_pp_for_registration,
|
||||
current_seasonal: data.current_seasonal,
|
||||
current_category: data.current_category,
|
||||
canRegister: data.canRegister,
|
||||
});
|
||||
};
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 这里需要调用 API 路由
|
||||
const response = await fetch('/debug/api/tournament-config');
|
||||
if (!response.ok) {
|
||||
throw new Error(`加载失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setConfig(data);
|
||||
initializeForm(data);
|
||||
setSuccess('配置加载成功');
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(data);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '未知错误';
|
||||
setError(`加载配置失败: ${errorMessage}`);
|
||||
|
||||
if (onError) {
|
||||
onError(err instanceof Error ? err : new Error(errorMessage));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/debug/api/tournament-config', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`保存失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setConfig(data);
|
||||
setSuccess('配置保存成功');
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate(data);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '未知错误';
|
||||
setError(`保存配置失败: ${errorMessage}`);
|
||||
|
||||
if (onError) {
|
||||
onError(err instanceof Error ? err : new Error(errorMessage));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置为默认值
|
||||
const resetToDefaults = () => {
|
||||
setFormData({
|
||||
tournament_name: 'AstarCup',
|
||||
max_pp_for_registration: 0,
|
||||
min_pp_for_registration: 0,
|
||||
current_seasonal: 'S1',
|
||||
current_category: 'QUA',
|
||||
canRegister: false,
|
||||
});
|
||||
setSuccess('已重置为默认值');
|
||||
};
|
||||
|
||||
// 表单变化处理
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFormData(prev => ({ ...prev, [name]: checked }));
|
||||
} else if (type === 'number') {
|
||||
setFormData(prev => ({ ...prev, [name]: parseFloat(value) || 0 }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时加载数据
|
||||
useEffect(() => {
|
||||
if (!initialData) {
|
||||
loadConfig();
|
||||
} else {
|
||||
initializeForm(initialData);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// 渲染组件
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-800">比赛配置管理</h2>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={loadConfig}
|
||||
disabled={loading}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 disabled:opacity-50"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={resetToDefaults}
|
||||
disabled={loading}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
重置默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态提示 */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-4 p-3 bg-green-50 border border-green-200 text-green-700 rounded">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配置信息 */}
|
||||
{config && (
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>ID: <span className="font-mono">{config.id}</span></div>
|
||||
<div>创建时间: <span className="font-mono">{new Date(config.createdAt).toLocaleString()}</span></div>
|
||||
<div>更新时间: <span className="font-mono">{new Date(config.updatedAt).toLocaleString()}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表单 */}
|
||||
<div className="space-y-4">
|
||||
{/* 比赛名称 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
比赛名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="tournament_name"
|
||||
value={formData.tournament_name}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="输入比赛名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* PP 限制 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
最小PP限制
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="min_pp_for_registration"
|
||||
value={formData.min_pp_for_registration}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
step="0.1"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
最大PP限制
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="max_pp_for_registration"
|
||||
value={formData.max_pp_for_registration}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
step="0.1"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 赛季和分类 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
当前赛季
|
||||
</label>
|
||||
<select
|
||||
name="current_seasonal"
|
||||
value={formData.current_seasonal}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{seasonOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
当前分类
|
||||
</label>
|
||||
<select
|
||||
name="current_category"
|
||||
value={formData.current_category}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{categoryOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 注册开关 */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="canRegister"
|
||||
name="canRegister"
|
||||
checked={formData.canRegister}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="canRegister" className="ml-2 block text-sm text-gray-700">
|
||||
允许玩家注册
|
||||
</label>
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
{formData.canRegister ? '✓ 注册已开放' : '✗ 注册已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<button
|
||||
onClick={loadConfig}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={saveConfig}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
'保存配置'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 调试信息(开发环境显示) */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">调试信息</h3>
|
||||
<pre className="text-xs bg-gray-50 p-3 rounded overflow-auto">
|
||||
{JSON.stringify({
|
||||
formData,
|
||||
config,
|
||||
loading,
|
||||
error,
|
||||
success,
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
app/debug/page.tsx
Normal file
12
app/debug/page.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import TournamentConfigCard from '@/app/debug/components/TournamentConfigCard';
|
||||
|
||||
export default function DebugPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">调试页面</h1>
|
||||
|
||||
<TournamentConfigCard />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { prisma } from "@/app/lib/PrismaClient";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
async function fetchData() {
|
||||
const data = await prisma.tournamentConfig.findUnique({
|
||||
where: { id: 1 },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
title: fetchData().then((data) => data?.tournament_name || "NoTitle").toString(),
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
@@ -25,7 +22,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
|
||||
21
app/lib/PrismaClient.ts
Normal file
21
app/lib/PrismaClient.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PrismaClient } from '@/app/generated/prisma/client'
|
||||
import { PrismaMariaDb } from '@prisma/adapter-mariadb'
|
||||
|
||||
const adapter = new PrismaMariaDb(
|
||||
{
|
||||
host: process.env.DATABASE_HOST || 'localhost',
|
||||
port: parseInt(process.env.DATABASE_PORT || '3306'),
|
||||
password: process.env.DATABASE_PASSWORD || 'pwd',
|
||||
user: process.env.DATABASE_USER || 'user',
|
||||
database: process.env.DATABASE_NAME || 'database',
|
||||
connectionLimit: 5
|
||||
}
|
||||
)
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
adapter,
|
||||
});
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
33
app/lib/SettingServer.ts
Normal file
33
app/lib/SettingServer.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { prisma } from "@/app/lib/PrismaClient";
|
||||
import { Season, Category } from "@/app/generated/prisma/enums";
|
||||
|
||||
export interface Config {
|
||||
tournament_name?: string;
|
||||
max_pp_for_registration?: number;
|
||||
min_pp_for_registration?: number;
|
||||
current_seasonal?: Season
|
||||
current_category?: Category
|
||||
canRegister?: boolean;
|
||||
}
|
||||
|
||||
export async function getServerSideConfig() {
|
||||
const config = await prisma.tournamentConfig.findUnique({
|
||||
where: { id: 1 },
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function setServerSideConfig(settings: Config) {
|
||||
const updatedConfig = await prisma.tournamentConfig.update({
|
||||
where: { id: 1 },
|
||||
data: {
|
||||
tournament_name: settings.tournament_name,
|
||||
max_pp_for_registration: settings.max_pp_for_registration,
|
||||
min_pp_for_registration: settings.min_pp_for_registration,
|
||||
current_seasonal: settings.current_seasonal,
|
||||
current_category: settings.current_category,
|
||||
canRegister: settings.canRegister,
|
||||
},
|
||||
});
|
||||
return updatedConfig;
|
||||
}
|
||||
0
app/lib/UserOperation.ts
Normal file
0
app/lib/UserOperation.ts
Normal file
43
app/lib/enum-labels.ts
Normal file
43
app/lib/enum-labels.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Season, Category } from '@/app/generated/prisma/enums';
|
||||
|
||||
interface EnumOption<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const enumLabels = {
|
||||
Season: {
|
||||
[Season.S1]: 'S1',
|
||||
[Season.S2]: 'S2',
|
||||
},
|
||||
Category: {
|
||||
[Category.QUA]: '资格赛QUA',
|
||||
[Category.RO16]: 'RO16',
|
||||
[Category.QF]: '四分之一决赛QF',
|
||||
[Category.SF]: '半决赛SF',
|
||||
[Category.F]: '决赛F',
|
||||
[Category.GF]: '总决赛GF',
|
||||
},
|
||||
};
|
||||
|
||||
export function useEnumOptions<T extends string>(
|
||||
enumType: keyof typeof enumLabels,
|
||||
enumValues: readonly T[]
|
||||
): EnumOption<T>[] {
|
||||
return useMemo(() => {
|
||||
const labels = enumLabels[enumType] as Record<string, string>;
|
||||
return enumValues.map(value => ({
|
||||
value,
|
||||
label: labels[value] || value,
|
||||
}));
|
||||
}, [enumType, enumValues]);
|
||||
}
|
||||
|
||||
export function useSeasonOptions() {
|
||||
return useEnumOptions('Season', Object.values(Season));
|
||||
}
|
||||
|
||||
export function useCategoryOptions() {
|
||||
return useEnumOptions('Category', Object.values(Category));
|
||||
}
|
||||
@@ -64,7 +64,6 @@ export async function getOsuClientToken(): Promise<{
|
||||
token_type: string;
|
||||
}> {
|
||||
try {
|
||||
console.log('Checking OSU_CLIENT_ID and OSU_CLIENT_SECRET...');
|
||||
if (!OSU_CLIENT_ID || !OSU_CLIENT_SECRET) {
|
||||
throw new Error('OSU_CLIENT_ID and OSU_CLIENT_SECRET must be configured');
|
||||
}
|
||||
|
||||
118
app/lib/osuBeatmapApi.ts
Normal file
118
app/lib/osuBeatmapApi.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { getValidClientToken } from './osuAuth';
|
||||
|
||||
export interface BeatmapInfo {
|
||||
id: number;
|
||||
beatmapset_id: number;
|
||||
title: string;
|
||||
title_unicode: string;
|
||||
artist: string;
|
||||
artist_unicode: string;
|
||||
version: string;
|
||||
creator: string;
|
||||
star_rating: number;
|
||||
bpm: number;
|
||||
total_length: number;
|
||||
max_combo: number;
|
||||
ar: number;
|
||||
cs: number;
|
||||
od: number;
|
||||
hp: number;
|
||||
url: string;
|
||||
cover_url: string;
|
||||
}
|
||||
|
||||
/// get beatmap info by beatmap ID
|
||||
export async function getBeatmapInfo(beatmapId: number): Promise<BeatmapInfo | null> {
|
||||
try {
|
||||
const accessToken = await getValidClientToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
};
|
||||
const response = await fetch(`https://osu.ppy.sh/api/v2/beatmaps/${beatmapId}`, {
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Beatmap不存在');
|
||||
}
|
||||
throw new Error(`获取Beatmap信息失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
beatmapset_id: data.beatmapset_id,
|
||||
title: data.beatmapset?.title || '',
|
||||
title_unicode: data.beatmapset?.title_unicode || data.beatmapset?.title || '',
|
||||
artist: data.beatmapset?.artist || '',
|
||||
artist_unicode: data.beatmapset?.artist_unicode || data.beatmapset?.artist || '',
|
||||
version: data.version || '',
|
||||
creator: data.beatmapset?.creator || '',
|
||||
star_rating: data.difficulty_rating || 0,
|
||||
bpm: data.bpm || 0,
|
||||
total_length: data.total_length || 0,
|
||||
max_combo: data.max_combo || 0,
|
||||
ar: data.ar || 0,
|
||||
cs: data.cs || 0,
|
||||
od: data.accuracy || 0,
|
||||
hp: data.drain || 0,
|
||||
url: data.url || `https://osu.ppy.sh/beatmaps/${data.id}`,
|
||||
cover_url: data.beatmapset?.covers?.cover || data.beatmapset?.covers?.card || ''
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching beatmap info:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/// get beatmapset info by beatmapset ID
|
||||
export async function getBeatmapsetInfo(beatmapsetId: number): Promise<BeatmapInfo[]> {
|
||||
try {
|
||||
const accessToken = await getValidClientToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
};
|
||||
|
||||
const response = await fetch(`https://osu.ppy.sh/api/v2/beatmapsets/${beatmapsetId}`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Beatmapset不存在');
|
||||
}
|
||||
throw new Error(`获取Beatmapset信息失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data.beatmaps?.map((beatmap: any) => ({
|
||||
id: beatmap.id,
|
||||
beatmapset_id: beatmap.beatmapset_id,
|
||||
title: data.title || '',
|
||||
title_unicode: data.title_unicode || data.title || '',
|
||||
artist: data.artist || '',
|
||||
artist_unicode: data.artist_unicode || data.artist || '',
|
||||
version: beatmap.version || '',
|
||||
creator: data.creator || '',
|
||||
star_rating: beatmap.difficulty_rating || 0,
|
||||
bpm: beatmap.bpm || 0,
|
||||
total_length: beatmap.total_length || 0,
|
||||
max_combo: beatmap.max_combo || 0,
|
||||
ar: beatmap.ar || 0,
|
||||
cs: beatmap.cs || 0,
|
||||
od: beatmap.accuracy || 0,
|
||||
hp: beatmap.drain || 0,
|
||||
url: beatmap.url || `https://osu.ppy.sh/beatmaps/${beatmap.id}`,
|
||||
cover_url: data.covers?.cover || data.covers?.card || ''
|
||||
})) || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching beatmapset info:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
96
app/lib/osuUserApi.ts
Normal file
96
app/lib/osuUserApi.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { getValidClientToken } from './osuAuth';
|
||||
|
||||
export interface OsuUser {
|
||||
id: number;
|
||||
username: string;
|
||||
avatar_url: string;
|
||||
country_code: string;
|
||||
cover?: {
|
||||
custom_url: string | null;
|
||||
url: string;
|
||||
id: string | null;
|
||||
};
|
||||
statistics: {
|
||||
pp: number;
|
||||
global_rank: number | null;
|
||||
country_rank: number | null;
|
||||
country: string;
|
||||
ranked_score: number;
|
||||
hit_accuracy: number;
|
||||
play_count: number;
|
||||
play_time: number;
|
||||
level: {
|
||||
current: number;
|
||||
progress: number;
|
||||
};
|
||||
grade_counts: {
|
||||
ss: number;
|
||||
ssh: number;
|
||||
s: number;
|
||||
sh: number;
|
||||
a: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// input osu! user ID, output osu! user data
|
||||
export async function getUserData(userID: number): Promise<OsuUser | null> {
|
||||
try {
|
||||
// 获取客户端token
|
||||
const accessToken = await getValidClientToken();
|
||||
|
||||
const response = await fetch(`https://osu.ppy.sh/api/v2/users/${userID}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('玩家不存在');
|
||||
}
|
||||
throw new Error(`获取玩家数据失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
username: data.username,
|
||||
avatar_url: data.avatar_url,
|
||||
country_code: data.country_code,
|
||||
cover: data.cover ? {
|
||||
custom_url: data.cover.custom_url || null,
|
||||
url: data.cover.url || '',
|
||||
id: data.cover.id || null,
|
||||
} : undefined,
|
||||
statistics: {
|
||||
pp: data.statistics?.pp || 0,
|
||||
global_rank: data.statistics?.global_rank || null,
|
||||
country_rank: data.statistics?.country_rank || null,
|
||||
country: data.country_code || '',
|
||||
ranked_score: data.statistics?.ranked_score || 0,
|
||||
hit_accuracy: data.statistics?.hit_accuracy || 0,
|
||||
play_count: data.statistics?.play_count || 0,
|
||||
play_time: data.statistics?.play_time || 0,
|
||||
level: {
|
||||
current: data.statistics?.level?.current || 0,
|
||||
progress: data.statistics?.level?.progress || 0,
|
||||
},
|
||||
grade_counts: {
|
||||
ss: data.statistics?.grade_counts?.ss || 0,
|
||||
ssh: data.statistics?.grade_counts?.ssh || 0,
|
||||
s: data.statistics?.grade_counts?.s || 0,
|
||||
sh: data.statistics?.grade_counts?.sh || 0,
|
||||
a: data.statistics?.grade_counts?.a || 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching osu! user data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
60
app/page.tsx
60
app/page.tsx
@@ -1,65 +1,11 @@
|
||||
import Image from "next/image";
|
||||
import MainLogo from "./components/ui/MainLogo";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<MainLogo />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
946
package-lock.json
generated
946
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,10 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/adapter-mariadb": "^7.3.0",
|
||||
"@prisma/client": "^7.3.0",
|
||||
"@prisma/extension-accelerate": "^3.0.1",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
@@ -27,4 +30,4 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ model TournamentConfig {
|
||||
min_pp_for_registration Float @default(0)
|
||||
current_seasonal Season @default(S1)
|
||||
current_category Category @default(QUA)
|
||||
canRegister Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
1
public/NewLogo/background.svg
Normal file
1
public/NewLogo/background.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.2 KiB |
Reference in New Issue
Block a user