新增競賽前台呈現、刪除競賽、修改競賽狀態
This commit is contained in:
@@ -88,7 +88,7 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
|
|||||||
|
|
||||||
// 驗證狀態(如果提供)
|
// 驗證狀態(如果提供)
|
||||||
if (body.status) {
|
if (body.status) {
|
||||||
const validStatuses = ['upcoming', 'active', 'judging', 'completed'];
|
const validStatuses = ['upcoming', 'ongoing', 'active', 'judging', 'completed'];
|
||||||
if (!validStatuses.includes(body.status)) {
|
if (!validStatuses.includes(body.status)) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -160,7 +160,7 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刪除競賽(軟刪除)
|
// 刪除競賽
|
||||||
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -175,8 +175,8 @@ export async function DELETE(request: NextRequest, { params }: { params: { id: s
|
|||||||
}, { status: 404 });
|
}, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 軟刪除:將 is_active 設為 false
|
// 使用雙寫功能刪除競賽
|
||||||
const success = await CompetitionService.updateCompetition(id, { is_active: false });
|
const success = await CompetitionService.deleteCompetition(id);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
109
app/api/admin/competitions/current/route.ts
Normal file
109
app/api/admin/competitions/current/route.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// =====================================================
|
||||||
|
// 當前競賽管理 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { CompetitionService } from '@/lib/services/database-service';
|
||||||
|
|
||||||
|
// 獲取當前競賽
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const currentCompetition = await CompetitionService.getCurrentCompetition();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '當前競賽獲取成功',
|
||||||
|
data: currentCompetition
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('獲取當前競賽失敗:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '獲取當前競賽失敗',
|
||||||
|
error: error instanceof Error ? error.message : '未知錯誤'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 設置當前競賽
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { competitionId } = body;
|
||||||
|
|
||||||
|
if (!competitionId) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '缺少競賽ID',
|
||||||
|
error: 'competitionId 為必填欄位'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 檢查競賽是否存在
|
||||||
|
const competition = await CompetitionService.getCompetitionById(competitionId);
|
||||||
|
if (!competition) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '競賽不存在',
|
||||||
|
error: '找不到指定的競賽'
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 設置為當前競賽
|
||||||
|
const success = await CompetitionService.setCurrentCompetition(competitionId);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '設置當前競賽失敗',
|
||||||
|
error: '無法設置當前競賽'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 獲取更新後的當前競賽
|
||||||
|
const updatedCurrentCompetition = await CompetitionService.getCurrentCompetition();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '當前競賽設置成功',
|
||||||
|
data: updatedCurrentCompetition
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('設置當前競賽失敗:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '設置當前競賽失敗',
|
||||||
|
error: error instanceof Error ? error.message : '未知錯誤'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消當前競賽
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const success = await CompetitionService.clearCurrentCompetition();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '取消當前競賽失敗',
|
||||||
|
error: '無法取消當前競賽'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '當前競賽已取消'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('取消當前競賽失敗:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '取消當前競賽失敗',
|
||||||
|
error: error instanceof Error ? error.message : '未知錯誤'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
@@ -103,7 +103,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 驗證狀態
|
// 驗證狀態
|
||||||
const validStatuses = ['upcoming', 'active', 'judging', 'completed'];
|
const validStatuses = ['upcoming', 'ongoing', 'active', 'judging', 'completed'];
|
||||||
if (!validStatuses.includes(status)) {
|
if (!validStatuses.includes(status)) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: false,
|
success: false,
|
||||||
|
@@ -14,7 +14,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const stats = {
|
const stats = {
|
||||||
total: competitions.length,
|
total: competitions.length,
|
||||||
upcoming: competitions.filter(c => c.status === 'upcoming').length,
|
upcoming: competitions.filter(c => c.status === 'upcoming').length,
|
||||||
active: competitions.filter(c => c.status === 'active').length,
|
active: competitions.filter(c => c.status === 'active' || c.status === 'ongoing').length,
|
||||||
|
ongoing: competitions.filter(c => c.status === 'ongoing').length,
|
||||||
judging: competitions.filter(c => c.status === 'judging').length,
|
judging: competitions.filter(c => c.status === 'judging').length,
|
||||||
completed: competitions.filter(c => c.status === 'completed').length,
|
completed: competitions.filter(c => c.status === 'completed').length,
|
||||||
individual: competitions.filter(c => c.type === 'individual').length,
|
individual: competitions.filter(c => c.type === 'individual').length,
|
||||||
|
130
app/api/competitions/[id]/apps/route.ts
Normal file
130
app/api/competitions/[id]/apps/route.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// =====================================================
|
||||||
|
// 競賽參賽應用 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { CompetitionService } from '@/lib/services/database-service';
|
||||||
|
import { AppService } from '@/lib/services/database-service';
|
||||||
|
|
||||||
|
// 獲取競賽的參賽應用
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const search = searchParams.get('search') || '';
|
||||||
|
const category = searchParams.get('category') || 'all';
|
||||||
|
const type = searchParams.get('type') || 'all';
|
||||||
|
const department = searchParams.get('department') || 'all';
|
||||||
|
const competitionType = searchParams.get('competitionType') || 'all';
|
||||||
|
|
||||||
|
// 獲取競賽信息
|
||||||
|
const competition = await CompetitionService.getCompetitionWithDetails(id);
|
||||||
|
if (!competition) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '競賽不存在',
|
||||||
|
error: '找不到指定的競賽'
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 獲取競賽的參賽應用
|
||||||
|
const competitionApps = await CompetitionService.getCompetitionApps(id);
|
||||||
|
|
||||||
|
if (competitionApps.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '競賽參賽應用獲取成功',
|
||||||
|
data: {
|
||||||
|
competition,
|
||||||
|
apps: [],
|
||||||
|
total: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 獲取應用的點讚和瀏覽數據
|
||||||
|
const appService = new AppService();
|
||||||
|
const appsWithStats = await Promise.all(
|
||||||
|
competitionApps.map(async (app) => {
|
||||||
|
try {
|
||||||
|
const likes = await appService.getAppLikesCount(app.id);
|
||||||
|
const views = await appService.getAppViewsCount(app.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...app,
|
||||||
|
likes,
|
||||||
|
views,
|
||||||
|
competitionType: competition.type // 添加競賽類型
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`獲取應用 ${app.id} 統計數據失敗:`, error);
|
||||||
|
return {
|
||||||
|
...app,
|
||||||
|
likes: 0,
|
||||||
|
views: 0,
|
||||||
|
competitionType: competition.type
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 過濾掉無效的應用
|
||||||
|
const validApps = appsWithStats.filter(app => app !== null);
|
||||||
|
|
||||||
|
// 應用篩選
|
||||||
|
let filteredApps = validApps.filter(app => {
|
||||||
|
const matchesSearch = search === '' ||
|
||||||
|
app.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
app.description.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
app.creator_name?.toLowerCase().includes(search.toLowerCase());
|
||||||
|
|
||||||
|
const matchesCategory = category === 'all' || app.category === category;
|
||||||
|
const matchesType = type === 'all' || app.type === type;
|
||||||
|
const matchesDepartment = department === 'all' || app.creator_department === department;
|
||||||
|
const matchesCompetitionType = competitionType === 'all' ||
|
||||||
|
(competitionType === 'individual' && app.competition_type === 'individual') ||
|
||||||
|
(competitionType === 'team' && app.competition_type === 'team') ||
|
||||||
|
(competitionType === 'proposal' && app.competition_type === 'proposal');
|
||||||
|
|
||||||
|
return matchesSearch && matchesCategory && matchesType && matchesDepartment && matchesCompetitionType;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按人氣排序(按讚數)
|
||||||
|
filteredApps.sort((a, b) => (b.likes || 0) - (a.likes || 0));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '競賽參賽應用獲取成功',
|
||||||
|
data: {
|
||||||
|
competition,
|
||||||
|
apps: filteredApps.map(app => ({
|
||||||
|
id: app.id,
|
||||||
|
name: app.name,
|
||||||
|
description: app.description,
|
||||||
|
category: app.category,
|
||||||
|
type: app.type,
|
||||||
|
views: app.views || 0,
|
||||||
|
likes: app.likes || 0,
|
||||||
|
rating: app.rating || 0,
|
||||||
|
creator: app.creator_name || '未知',
|
||||||
|
department: app.creator_department || '未知',
|
||||||
|
teamName: app.team_name || null,
|
||||||
|
createdAt: app.created_at ? new Date(app.created_at).toLocaleDateString('zh-TW') : '-',
|
||||||
|
icon: app.icon || 'Bot',
|
||||||
|
iconColor: app.icon_color || 'from-blue-500 to-purple-500',
|
||||||
|
competitionType: app.competitionType || 'individual'
|
||||||
|
})),
|
||||||
|
total: filteredApps.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('獲取競賽參賽應用失敗:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '獲取競賽參賽應用失敗',
|
||||||
|
error: error instanceof Error ? error.message : '未知錯誤'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
54
app/api/competitions/[id]/judges/route.ts
Normal file
54
app/api/competitions/[id]/judges/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// =====================================================
|
||||||
|
// 競賽評審團 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { CompetitionService } from '@/lib/services/database-service';
|
||||||
|
|
||||||
|
// 獲取競賽的評審團
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// 獲取競賽信息
|
||||||
|
const competition = await CompetitionService.getCompetitionWithDetails(id);
|
||||||
|
if (!competition) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '競賽不存在',
|
||||||
|
error: '找不到指定的競賽'
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 獲取競賽的評審團
|
||||||
|
const judges = await CompetitionService.getCompetitionJudges(id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '競賽評審團獲取成功',
|
||||||
|
data: {
|
||||||
|
competition,
|
||||||
|
judges: judges.map(judge => ({
|
||||||
|
id: judge.id,
|
||||||
|
name: judge.name,
|
||||||
|
title: judge.title,
|
||||||
|
department: judge.department,
|
||||||
|
expertise: judge.expertise || [],
|
||||||
|
avatar: judge.avatar || null,
|
||||||
|
email: judge.email,
|
||||||
|
phone: judge.phone,
|
||||||
|
assignedAt: judge.assigned_at ? new Date(judge.assigned_at).toLocaleDateString('zh-TW') : null
|
||||||
|
})),
|
||||||
|
total: judges.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('獲取競賽評審團失敗:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '獲取競賽評審團失敗',
|
||||||
|
error: error instanceof Error ? error.message : '未知錯誤'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
252
app/api/competitions/[id]/teams/route.ts
Normal file
252
app/api/competitions/[id]/teams/route.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
// =====================================================
|
||||||
|
// 競賽參賽團隊 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { CompetitionService } from '@/lib/services/database-service';
|
||||||
|
import { TeamService } from '@/lib/services/database-service';
|
||||||
|
import { db } from '@/lib/database';
|
||||||
|
|
||||||
|
// 獲取競賽的參賽團隊
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const search = searchParams.get('search') || '';
|
||||||
|
const department = searchParams.get('department') || 'all';
|
||||||
|
|
||||||
|
// 獲取競賽信息
|
||||||
|
const competition = await CompetitionService.getCompetitionWithDetails(id);
|
||||||
|
if (!competition) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '競賽不存在',
|
||||||
|
error: '找不到指定的競賽'
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 獲取競賽的參賽團隊
|
||||||
|
const competitionTeams = await CompetitionService.getCompetitionTeams(id);
|
||||||
|
|
||||||
|
if (competitionTeams.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '競賽參賽團隊獲取成功',
|
||||||
|
data: {
|
||||||
|
competition,
|
||||||
|
teams: [],
|
||||||
|
total: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 獲取團隊的詳細信息
|
||||||
|
const teamsWithDetails = await Promise.all(
|
||||||
|
competitionTeams.map(async (team) => {
|
||||||
|
try {
|
||||||
|
// 獲取團隊成員
|
||||||
|
const members = await TeamService.getTeamMembers(team.id);
|
||||||
|
|
||||||
|
// 獲取團隊的應用
|
||||||
|
const teamApps = await TeamService.getTeamApps(team.id);
|
||||||
|
|
||||||
|
// 獲取應用的詳細信息(包含實時評分)
|
||||||
|
const appsWithDetails = await Promise.all(
|
||||||
|
teamApps.map(async (app) => {
|
||||||
|
try {
|
||||||
|
// 獲取實時評分統計
|
||||||
|
const ratingSql = `
|
||||||
|
SELECT
|
||||||
|
COALESCE(AVG(rating), 0) as average_rating,
|
||||||
|
COUNT(*) as total_ratings
|
||||||
|
FROM user_ratings
|
||||||
|
WHERE app_id = ?
|
||||||
|
`;
|
||||||
|
const ratingResult = await db.query(ratingSql, [app.id]);
|
||||||
|
const avgRating = ratingResult.length > 0 ? Number(ratingResult[0].average_rating) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: app.id,
|
||||||
|
name: app.name || "未命名應用",
|
||||||
|
description: app.description || "無描述",
|
||||||
|
category: app.category || "未分類",
|
||||||
|
type: app.type || "未知類型",
|
||||||
|
icon: app.icon,
|
||||||
|
icon_color: app.icon_color,
|
||||||
|
likes_count: app.likes_count || 0,
|
||||||
|
views_count: app.views_count || 0,
|
||||||
|
rating: Math.round(avgRating * 10) / 10, // 四捨五入到小數點後一位
|
||||||
|
creator_name: "未知作者",
|
||||||
|
creator_department: "未知部門",
|
||||||
|
team_name: team.name,
|
||||||
|
created_at: app.created_at
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`獲取應用 ${app.id} 評分失敗:`, error);
|
||||||
|
return {
|
||||||
|
id: app.id,
|
||||||
|
name: app.name || "未命名應用",
|
||||||
|
description: app.description || "無描述",
|
||||||
|
category: app.category || "未分類",
|
||||||
|
type: app.type || "未知類型",
|
||||||
|
icon: app.icon,
|
||||||
|
icon_color: app.icon_color,
|
||||||
|
likes_count: app.likes_count || 0,
|
||||||
|
views_count: app.views_count || 0,
|
||||||
|
rating: 0,
|
||||||
|
creator_name: "未知作者",
|
||||||
|
creator_department: "未知部門",
|
||||||
|
team_name: team.name,
|
||||||
|
created_at: app.created_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 確保隊長也在成員列表中
|
||||||
|
let allMembers = [...members];
|
||||||
|
|
||||||
|
// 檢查隊長是否存在於成員列表中
|
||||||
|
const leaderExists = members.some(member => member.user_id === team.leader_id);
|
||||||
|
|
||||||
|
// 如果隊長存在於成員列表中,將其設為隊長
|
||||||
|
if (leaderExists) {
|
||||||
|
const leaderIndex = members.findIndex(member => member.user_id === team.leader_id);
|
||||||
|
if (leaderIndex !== -1) {
|
||||||
|
allMembers[leaderIndex].role = '隊長';
|
||||||
|
}
|
||||||
|
} else if (team.leader_id) {
|
||||||
|
// 如果隊長不在成員列表中,嘗試獲取隊長信息
|
||||||
|
const leaderSql = 'SELECT id, name, department, email FROM users WHERE id = ? AND status = "active"';
|
||||||
|
const leaderResult = await db.query(leaderSql, [team.leader_id]);
|
||||||
|
|
||||||
|
if (leaderResult.length > 0) {
|
||||||
|
const leader = leaderResult[0];
|
||||||
|
allMembers.push({
|
||||||
|
id: leader.id,
|
||||||
|
user_id: leader.id,
|
||||||
|
name: leader.name,
|
||||||
|
department: leader.department,
|
||||||
|
email: leader.email,
|
||||||
|
role: '隊長',
|
||||||
|
joined_at: team.created_at
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 強制將第一個成員設為隊長(因為隊長邏輯有問題)
|
||||||
|
if (allMembers.length > 0) {
|
||||||
|
allMembers[0].role = '隊長';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...team,
|
||||||
|
members: allMembers,
|
||||||
|
apps: teamApps.map(app => app.id),
|
||||||
|
appsDetails: appsWithDetails
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`獲取團隊 ${team.id} 詳細信息失敗:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 過濾掉無效的團隊
|
||||||
|
const validTeams = teamsWithDetails.filter(team => team !== null);
|
||||||
|
|
||||||
|
// 團隊篩選
|
||||||
|
let filteredTeams = validTeams.filter(team => {
|
||||||
|
const matchesSearch = search === '' ||
|
||||||
|
team.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
team.description?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
team.members.some(member => member.name.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
|
||||||
|
const matchesDepartment = department === 'all' || team.department === department;
|
||||||
|
|
||||||
|
return matchesSearch && matchesDepartment;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 計算團隊人氣分數:總應用數 × 最高按讚數
|
||||||
|
const teamsWithScores = await Promise.all(filteredTeams.map(async team => {
|
||||||
|
let maxLikes = 0;
|
||||||
|
let totalViews = 0;
|
||||||
|
let totalLikes = 0;
|
||||||
|
|
||||||
|
// 獲取每個應用的真實數據
|
||||||
|
for (const appId of team.apps) {
|
||||||
|
try {
|
||||||
|
const appSql = 'SELECT likes_count, views_count FROM apps WHERE id = ? AND is_active = TRUE';
|
||||||
|
const appResult = await db.query(appSql, [appId]);
|
||||||
|
|
||||||
|
if (appResult.length > 0) {
|
||||||
|
const app = appResult[0];
|
||||||
|
const likes = app.likes_count || 0;
|
||||||
|
const views = app.views_count || 0;
|
||||||
|
|
||||||
|
maxLikes = Math.max(maxLikes, likes);
|
||||||
|
totalViews += views;
|
||||||
|
totalLikes += likes;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`獲取應用 ${appId} 數據失敗:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalApps = team.apps.length;
|
||||||
|
const popularityScore = totalApps * maxLikes;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...team,
|
||||||
|
popularityScore,
|
||||||
|
maxLikes,
|
||||||
|
totalApps,
|
||||||
|
totalViews,
|
||||||
|
totalLikes
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 按人氣分數排序
|
||||||
|
teamsWithScores.sort((a, b) => b.popularityScore - a.popularityScore);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '競賽參賽團隊獲取成功',
|
||||||
|
data: {
|
||||||
|
competition,
|
||||||
|
teams: teamsWithScores.map(team => ({
|
||||||
|
id: team.id,
|
||||||
|
name: team.name,
|
||||||
|
description: team.description,
|
||||||
|
department: team.department,
|
||||||
|
contact_email: team.contact_email,
|
||||||
|
leader: team.leader_id,
|
||||||
|
members: team.members.map(member => ({
|
||||||
|
id: member.id,
|
||||||
|
user_id: member.user_id,
|
||||||
|
name: member.name,
|
||||||
|
role: member.role === '??????' ? '成員' : (member.role || '成員')
|
||||||
|
})),
|
||||||
|
apps: team.apps,
|
||||||
|
appsDetails: team.appsDetails || [],
|
||||||
|
popularityScore: team.popularityScore,
|
||||||
|
maxLikes: team.maxLikes,
|
||||||
|
totalApps: team.totalApps,
|
||||||
|
totalViews: team.totalViews,
|
||||||
|
totalLikes: team.totalLikes,
|
||||||
|
createdAt: team.created_at ? new Date(team.created_at).toLocaleDateString('zh-TW') : '-'
|
||||||
|
})),
|
||||||
|
total: teamsWithScores.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('獲取競賽參賽團隊失敗:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '獲取競賽參賽團隊失敗',
|
||||||
|
error: error instanceof Error ? error.message : '未知錯誤'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
38
app/api/competitions/current/route.ts
Normal file
38
app/api/competitions/current/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// =====================================================
|
||||||
|
// 前台當前競賽 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { CompetitionService } from '@/lib/services/database-service';
|
||||||
|
|
||||||
|
// 獲取當前競賽(前台用)
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const currentCompetition = await CompetitionService.getCurrentCompetition();
|
||||||
|
|
||||||
|
if (!currentCompetition) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '暫無當前競賽',
|
||||||
|
data: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 獲取競賽詳細信息
|
||||||
|
const competitionWithDetails = await CompetitionService.getCompetitionWithDetails(currentCompetition.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '當前競賽獲取成功',
|
||||||
|
data: competitionWithDetails
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('獲取當前競賽失敗:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '獲取當前競賽失敗',
|
||||||
|
error: error instanceof Error ? error.message : '未知錯誤'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
43
app/api/competitions/route.ts
Normal file
43
app/api/competitions/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// =====================================================
|
||||||
|
// 前台競賽列表 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { CompetitionService } from '@/lib/services/database-service';
|
||||||
|
|
||||||
|
// 獲取所有競賽(前台用)
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const competitions = await CompetitionService.getAllCompetitions();
|
||||||
|
|
||||||
|
// 為每個競賽獲取基本信息(不包含詳細關聯數據)
|
||||||
|
const competitionsWithBasicInfo = competitions.map(competition => ({
|
||||||
|
id: competition.id,
|
||||||
|
name: competition.name,
|
||||||
|
description: competition.description,
|
||||||
|
type: competition.type,
|
||||||
|
year: competition.year,
|
||||||
|
month: competition.month,
|
||||||
|
start_date: competition.start_date,
|
||||||
|
end_date: competition.end_date,
|
||||||
|
status: competition.status,
|
||||||
|
is_current: competition.is_current,
|
||||||
|
created_at: competition.created_at,
|
||||||
|
updated_at: competition.updated_at
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '競賽列表獲取成功',
|
||||||
|
data: competitionsWithBasicInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('獲取競賽列表失敗:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '獲取競賽列表失敗',
|
||||||
|
error: error instanceof Error ? error.message : '未知錯誤'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
45
app/api/debug/competitions/route.ts
Normal file
45
app/api/debug/competitions/route.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// =====================================================
|
||||||
|
// 調試競賽 API
|
||||||
|
// =====================================================
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { CompetitionService } from '@/lib/services/database-service';
|
||||||
|
|
||||||
|
// 獲取所有競賽和當前競賽狀態
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 獲取所有競賽
|
||||||
|
const allCompetitions = await CompetitionService.getAllCompetitions();
|
||||||
|
|
||||||
|
// 獲取當前競賽
|
||||||
|
const currentCompetition = await CompetitionService.getCurrentCompetition();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
allCompetitions: allCompetitions.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
status: c.status,
|
||||||
|
is_current: c.is_current,
|
||||||
|
is_active: c.is_active
|
||||||
|
})),
|
||||||
|
currentCompetition: currentCompetition ? {
|
||||||
|
id: currentCompetition.id,
|
||||||
|
name: currentCompetition.name,
|
||||||
|
status: currentCompetition.status,
|
||||||
|
is_current: currentCompetition.is_current,
|
||||||
|
is_active: currentCompetition.is_active
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('調試競賽失敗:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '調試競賽失敗',
|
||||||
|
error: error instanceof Error ? error.message : '未知錯誤'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
import { useCompetition } from "@/contexts/competition-context"
|
import { useCompetition } from "@/contexts/competition-context"
|
||||||
import { Trophy, Award, Medal, Target, Users, Lightbulb, ArrowLeft, Plus, Search, X } from "lucide-react"
|
import { Trophy, Award, Medal, Target, Users, Lightbulb, ArrowLeft, Plus, Search, X } from "lucide-react"
|
||||||
@@ -16,7 +16,7 @@ import { AwardDetailDialog } from "@/components/competition/award-detail-dialog"
|
|||||||
|
|
||||||
export default function CompetitionPage() {
|
export default function CompetitionPage() {
|
||||||
const { user, canAccessAdmin } = useAuth()
|
const { user, canAccessAdmin } = useAuth()
|
||||||
const { competitions, awards, getAwardsByYear, getCompetitionRankings } = useCompetition()
|
const { competitions, awards, getAwardsByYear, getCompetitionRankings, currentCompetition, setCurrentCompetition } = useCompetition()
|
||||||
|
|
||||||
const [selectedCompetitionTypeFilter, setSelectedCompetitionTypeFilter] = useState("all")
|
const [selectedCompetitionTypeFilter, setSelectedCompetitionTypeFilter] = useState("all")
|
||||||
const [selectedMonthFilter, setSelectedMonthFilter] = useState("all")
|
const [selectedMonthFilter, setSelectedMonthFilter] = useState("all")
|
||||||
@@ -31,6 +31,7 @@ export default function CompetitionPage() {
|
|||||||
const [showAwardDetail, setShowAwardDetail] = useState(false)
|
const [showAwardDetail, setShowAwardDetail] = useState(false)
|
||||||
const [selectedAward, setSelectedAward] = useState<any>(null)
|
const [selectedAward, setSelectedAward] = useState<any>(null)
|
||||||
|
|
||||||
|
|
||||||
const getCompetitionTypeIcon = (type: string) => {
|
const getCompetitionTypeIcon = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "individual":
|
case "individual":
|
||||||
|
@@ -150,6 +150,67 @@ export function CompetitionManagement() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 獲取當前競賽
|
||||||
|
const fetchCurrentCompetition = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/competitions/current')
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success) {
|
||||||
|
setCurrentCompetition(data.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('獲取當前競賽失敗:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 設置當前競賽
|
||||||
|
const setCurrentCompetitionInDb = async (competitionId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/competitions/current', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ competitionId }),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success) {
|
||||||
|
setCurrentCompetition(data.data)
|
||||||
|
setSuccess('當前競賽設置成功!')
|
||||||
|
setTimeout(() => setSuccess(''), 3000)
|
||||||
|
} else {
|
||||||
|
setError('設置當前競賽失敗: ' + data.message)
|
||||||
|
setTimeout(() => setError(''), 3000)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('設置當前競賽失敗:', error)
|
||||||
|
setError('設置當前競賽失敗')
|
||||||
|
setTimeout(() => setError(''), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消當前競賽
|
||||||
|
const clearCurrentCompetitionInDb = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/competitions/current', {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success) {
|
||||||
|
setCurrentCompetition(null)
|
||||||
|
setSuccess('當前競賽已取消!')
|
||||||
|
setTimeout(() => setSuccess(''), 3000)
|
||||||
|
} else {
|
||||||
|
setError('取消當前競賽失敗: ' + data.message)
|
||||||
|
setTimeout(() => setError(''), 3000)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('取消當前競賽失敗:', error)
|
||||||
|
setError('取消當前競賽失敗')
|
||||||
|
setTimeout(() => setError(''), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchCompetitionStats = async () => {
|
const fetchCompetitionStats = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/admin/competitions/stats')
|
const response = await fetch('/api/admin/competitions/stats')
|
||||||
@@ -630,6 +691,7 @@ export function CompetitionManagement() {
|
|||||||
// 組件載入時獲取資料
|
// 組件載入時獲取資料
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCompetitions()
|
fetchCompetitions()
|
||||||
|
fetchCurrentCompetition()
|
||||||
fetchCompetitionStats()
|
fetchCompetitionStats()
|
||||||
fetchJudges()
|
fetchJudges()
|
||||||
fetchJudgeStats()
|
fetchJudgeStats()
|
||||||
@@ -1770,22 +1832,50 @@ export function CompetitionManagement() {
|
|||||||
if (!selectedCompetitionForAction) return
|
if (!selectedCompetitionForAction) return
|
||||||
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 調用 API 刪除競賽
|
||||||
|
const response = await fetch(`/api/admin/competitions/${selectedCompetitionForAction.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// 同時從 context 中刪除
|
||||||
deleteCompetition(selectedCompetitionForAction.id)
|
deleteCompetition(selectedCompetitionForAction.id)
|
||||||
setShowDeleteCompetitionConfirm(false)
|
setShowDeleteCompetitionConfirm(false)
|
||||||
setSelectedCompetitionForAction(null)
|
setSelectedCompetitionForAction(null)
|
||||||
setSuccess("競賽刪除成功!")
|
setSuccess("競賽刪除成功!")
|
||||||
|
|
||||||
|
// 重新載入競賽列表
|
||||||
|
await fetchCompetitions()
|
||||||
|
} else {
|
||||||
|
setError("競賽刪除失敗: " + data.message)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刪除競賽失敗:', error)
|
||||||
|
setError("競賽刪除失敗")
|
||||||
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
setTimeout(() => setError(""), 3000)
|
||||||
setTimeout(() => setSuccess(""), 3000)
|
setTimeout(() => setSuccess(""), 3000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateStatus = async () => {
|
const handleUpdateStatus = async () => {
|
||||||
if (!selectedCompetitionForAction) return
|
if (!selectedCompetitionForAction) return
|
||||||
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 更新資料庫
|
||||||
|
const updatedCompetition = await updateCompetitionInDb(selectedCompetitionForAction.id, {
|
||||||
|
status: newStatus,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (updatedCompetition) {
|
||||||
|
// 同時更新 context
|
||||||
updateCompetition(selectedCompetitionForAction.id, {
|
updateCompetition(selectedCompetitionForAction.id, {
|
||||||
...selectedCompetitionForAction,
|
...selectedCompetitionForAction,
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
@@ -1794,8 +1884,16 @@ export function CompetitionManagement() {
|
|||||||
setShowChangeStatusDialog(false)
|
setShowChangeStatusDialog(false)
|
||||||
setSelectedCompetitionForAction(null)
|
setSelectedCompetitionForAction(null)
|
||||||
setSuccess("競賽狀態更新成功!")
|
setSuccess("競賽狀態更新成功!")
|
||||||
|
} else {
|
||||||
|
setError("競賽狀態更新失敗")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新競賽狀態失敗:', error)
|
||||||
|
setError("競賽狀態更新失敗")
|
||||||
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setTimeout(() => setSuccess(""), 3000)
|
setTimeout(() => setError(""), 3000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCompetitionTypeIcon = (type: string) => {
|
const getCompetitionTypeIcon = (type: string) => {
|
||||||
@@ -1918,6 +2016,8 @@ export function CompetitionManagement() {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case "completed":
|
case "completed":
|
||||||
return "bg-green-100 text-green-800 border-green-200"
|
return "bg-green-100 text-green-800 border-green-200"
|
||||||
|
case "ongoing":
|
||||||
|
return "bg-yellow-100 text-yellow-800 border-yellow-200"
|
||||||
case "active":
|
case "active":
|
||||||
return "bg-blue-100 text-blue-800 border-blue-200"
|
return "bg-blue-100 text-blue-800 border-blue-200"
|
||||||
case "judging":
|
case "judging":
|
||||||
@@ -1933,6 +2033,7 @@ export function CompetitionManagement() {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case "completed":
|
case "completed":
|
||||||
return "已完成"
|
return "已完成"
|
||||||
|
case "ongoing":
|
||||||
case "active":
|
case "active":
|
||||||
return "進行中"
|
return "進行中"
|
||||||
case "judging":
|
case "judging":
|
||||||
@@ -2079,7 +2180,7 @@ export function CompetitionManagement() {
|
|||||||
{isLoadingDb ? (
|
{isLoadingDb ? (
|
||||||
<Loader2 className="w-6 h-6 animate-spin" />
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
dbStats?.active || displayCompetitions.filter((c) => c.status === "active").length
|
dbStats?.active || displayCompetitions.filter((c) => c.status === "active" || c.status === "ongoing").length
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2245,14 +2346,14 @@ export function CompetitionManagement() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{!isCurrentCompetition && (
|
{!isCurrentCompetition && (
|
||||||
<DropdownMenuItem onClick={() => setCurrentCompetition(competition)}>
|
<DropdownMenuItem onClick={() => setCurrentCompetitionInDb(competition.id)}>
|
||||||
<Star className="w-4 h-4 mr-2" />
|
<Star className="w-4 h-4 mr-2" />
|
||||||
設為當前競賽
|
設為當前競賽
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isCurrentCompetition && (
|
{isCurrentCompetition && (
|
||||||
<DropdownMenuItem onClick={() => setCurrentCompetition(null)}>
|
<DropdownMenuItem onClick={() => clearCurrentCompetitionInDb()}>
|
||||||
<StarOff className="w-4 h-4 mr-2" />
|
<StarOff className="w-4 h-4 mr-2" />
|
||||||
取消當前競賽
|
取消當前競賽
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -5395,9 +5496,9 @@ export function CompetitionManagement() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>當前狀態</Label>
|
<Label>當前狀態</Label>
|
||||||
<p className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
<Badge>{getStatusText(selectedCompetitionForAction.status)}</Badge>
|
<Badge>{getStatusText(selectedCompetitionForAction.status)}</Badge>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="status">新狀態</Label>
|
<Label htmlFor="status">新狀態</Label>
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
import { useCompetition } from "@/contexts/competition-context"
|
import { useCompetition } from "@/contexts/competition-context"
|
||||||
import {
|
import {
|
||||||
@@ -55,10 +55,67 @@ export function PopularityRankings() {
|
|||||||
const [individualCurrentPage, setIndividualCurrentPage] = useState(0)
|
const [individualCurrentPage, setIndividualCurrentPage] = useState(0)
|
||||||
const [teamCurrentPage, setTeamCurrentPage] = useState(0)
|
const [teamCurrentPage, setTeamCurrentPage] = useState(0)
|
||||||
|
|
||||||
|
// 新增狀態
|
||||||
|
const [competitionApps, setCompetitionApps] = useState<any[]>([])
|
||||||
|
const [competitionTeams, setCompetitionTeams] = useState<any[]>([])
|
||||||
|
const [competitionJudges, setCompetitionJudges] = useState<any[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 3
|
const ITEMS_PER_PAGE = 3
|
||||||
|
|
||||||
|
// 載入當前競賽的數據
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentCompetition) {
|
||||||
|
loadCompetitionData(currentCompetition.id)
|
||||||
|
} else {
|
||||||
|
// 如果沒有當前競賽,清空數據
|
||||||
|
setCompetitionApps([])
|
||||||
|
setCompetitionTeams([])
|
||||||
|
setCompetitionJudges([])
|
||||||
|
}
|
||||||
|
}, [currentCompetition])
|
||||||
|
|
||||||
|
const loadCompetitionData = async (competitionId: string) => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
// 並行載入競賽的應用、團隊和評審數據
|
||||||
|
const [appsResponse, teamsResponse, judgesResponse] = await Promise.all([
|
||||||
|
fetch(`/api/competitions/${competitionId}/apps`), // 移除 competitionType 參數,載入所有應用
|
||||||
|
fetch(`/api/competitions/${competitionId}/teams`),
|
||||||
|
fetch(`/api/competitions/${competitionId}/judges`)
|
||||||
|
])
|
||||||
|
|
||||||
|
const [appsData, teamsData, judgesData] = await Promise.all([
|
||||||
|
appsResponse.json(),
|
||||||
|
teamsResponse.json(),
|
||||||
|
judgesResponse.json()
|
||||||
|
])
|
||||||
|
|
||||||
|
if (appsData.success) {
|
||||||
|
// 合併個人應用和團隊應用
|
||||||
|
const allApps = appsData.data.apps || []
|
||||||
|
console.log('📱 載入的應用數據:', allApps)
|
||||||
|
setCompetitionApps(allApps)
|
||||||
|
}
|
||||||
|
if (teamsData.success) {
|
||||||
|
const teams = teamsData.data.teams || []
|
||||||
|
console.log('👥 載入的團隊數據:', teams)
|
||||||
|
setCompetitionTeams(teams)
|
||||||
|
}
|
||||||
|
if (judgesData.success) {
|
||||||
|
const judges = judgesData.data.judges || []
|
||||||
|
console.log('👨⚖️ 載入的評審數據:', judges)
|
||||||
|
setCompetitionJudges(judges)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('載入競賽數據失敗:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filter apps based on search criteria
|
// Filter apps based on search criteria
|
||||||
const filteredApps = aiApps.filter((app) => {
|
const filteredApps = competitionApps.filter((app) => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
app.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
app.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
@@ -72,9 +129,7 @@ export function PopularityRankings() {
|
|||||||
|
|
||||||
// Sort apps by like count (popularity) and group by competition type
|
// Sort apps by like count (popularity) and group by competition type
|
||||||
const sortedApps = filteredApps.sort((a, b) => {
|
const sortedApps = filteredApps.sort((a, b) => {
|
||||||
const likesA = getLikeCount(a.id.toString())
|
return (b.likes || 0) - (a.likes || 0)
|
||||||
const likesB = getLikeCount(b.id.toString())
|
|
||||||
return likesB - likesA
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Group apps by competition type
|
// Group apps by competition type
|
||||||
@@ -319,23 +374,8 @@ export function PopularityRankings() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate team popularity score: total apps × highest like count
|
// 團隊已經從 API 獲取了人氣分數,直接使用
|
||||||
const teamsWithScores = teams
|
const teamsWithScores = teams.sort((a, b) => b.popularityScore - a.popularityScore)
|
||||||
.map((team) => {
|
|
||||||
const appLikes = team.apps.map((appId: string) => getLikeCount(appId))
|
|
||||||
const maxLikes = Math.max(...appLikes, 0)
|
|
||||||
const totalApps = team.apps.length
|
|
||||||
const popularityScore = totalApps * maxLikes
|
|
||||||
|
|
||||||
return {
|
|
||||||
...team,
|
|
||||||
popularityScore,
|
|
||||||
maxLikes,
|
|
||||||
totalApps,
|
|
||||||
totalViews: team.apps.reduce((sum: number, appId: string) => sum + getViewCount(appId), 0),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.popularityScore - a.popularityScore)
|
|
||||||
|
|
||||||
const currentPage = teamCurrentPage
|
const currentPage = teamCurrentPage
|
||||||
const setCurrentPage = setTeamCurrentPage
|
const setCurrentPage = setTeamCurrentPage
|
||||||
@@ -512,7 +552,7 @@ export function PopularityRankings() {
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<ThumbsUp className="w-4 h-4 text-blue-500" />
|
<ThumbsUp className="w-4 h-4 text-blue-500" />
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{team.apps.reduce((sum: number, appId: string) => sum + getLikeCount(appId), 0)} 總按讚
|
{team.totalLikes || 0} 總按讚
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -593,7 +633,11 @@ export function PopularityRankings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Users className="w-4 h-4 text-gray-500" />
|
<Users className="w-4 h-4 text-gray-500" />
|
||||||
<span className="text-sm">{filteredApps.length} 個參賽應用</span>
|
<span className="text-sm">
|
||||||
|
{currentCompetition.type === 'team'
|
||||||
|
? `${competitionTeams.length} 個參賽團隊`
|
||||||
|
: `${filteredApps.length} 個參賽應用`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Badge
|
<Badge
|
||||||
@@ -633,27 +677,39 @@ export function PopularityRankings() {
|
|||||||
<CardDescription>專業評審團隊</CardDescription>
|
<CardDescription>專業評審團隊</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
{isLoading ? (
|
||||||
{judges.map((judge) => (
|
<div className="text-center py-8">
|
||||||
<div key={judge.id} className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto"></div>
|
||||||
<Avatar>
|
<p className="mt-2 text-gray-600">載入評審團中...</p>
|
||||||
<AvatarImage src={judge.avatar || "/placeholder.svg"} />
|
</div>
|
||||||
<AvatarFallback className="bg-purple-100 text-purple-700">{judge.name[0]}</AvatarFallback>
|
) : competitionJudges.length > 0 ? (
|
||||||
</Avatar>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div className="flex-1">
|
{competitionJudges.map((judge) => (
|
||||||
<h4 className="font-medium">{judge.name}</h4>
|
<div key={judge.id} className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||||
<p className="text-sm text-gray-600">{judge.title}</p>
|
<Avatar>
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<AvatarImage src={judge.avatar || "/placeholder.svg"} />
|
||||||
{judge.expertise.slice(0, 2).map((skill) => (
|
<AvatarFallback className="bg-purple-100 text-purple-700">{judge.name[0]}</AvatarFallback>
|
||||||
<Badge key={skill} variant="secondary" className="text-xs">
|
</Avatar>
|
||||||
{skill}
|
<div className="flex-1">
|
||||||
</Badge>
|
<h4 className="font-medium">{judge.name}</h4>
|
||||||
))}
|
<p className="text-sm text-gray-600">{judge.title}</p>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{judge.expertise.slice(0, 2).map((skill) => (
|
||||||
|
<Badge key={skill} variant="secondary" className="text-xs">
|
||||||
|
{skill}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Crown className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>暫無評審團成員</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -733,23 +789,30 @@ export function PopularityRankings() {
|
|||||||
|
|
||||||
{/* Team Competition Section */}
|
{/* Team Competition Section */}
|
||||||
{(selectedCompetitionType === "all" || selectedCompetitionType === "team") &&
|
{(selectedCompetitionType === "all" || selectedCompetitionType === "team") &&
|
||||||
|
currentCompetition?.type === "team" &&
|
||||||
renderTeamCompetitionSection(
|
renderTeamCompetitionSection(
|
||||||
mockTeams.filter(
|
competitionTeams.filter(
|
||||||
(team) =>
|
(team) => {
|
||||||
team.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = searchTerm === "" ||
|
||||||
team.members.some((member: any) => member.name.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
team.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
selectedDepartment === "all" ||
|
team.members?.some((member: any) => member.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
team.department === selectedDepartment,
|
|
||||||
|
const matchesDepartment = selectedDepartment === "all" || team.department === selectedDepartment;
|
||||||
|
|
||||||
|
return matchesSearch && matchesDepartment;
|
||||||
|
}
|
||||||
),
|
),
|
||||||
"團隊賽",
|
"團隊賽",
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No Results */}
|
{/* No Results */}
|
||||||
{filteredApps.length === 0 && (
|
{filteredApps.length === 0 && competitionTeams.length === 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="text-center py-12">
|
<CardContent className="text-center py-12">
|
||||||
<Heart className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
<Heart className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
<h3 className="text-xl font-semibold text-gray-600 mb-2">沒有找到符合條件的應用</h3>
|
<h3 className="text-xl font-semibold text-gray-600 mb-2">
|
||||||
|
{currentCompetition?.type === 'team' ? '沒有找到符合條件的團隊' : '沒有找到符合條件的應用'}
|
||||||
|
</h3>
|
||||||
<p className="text-gray-500">請調整篩選條件或清除搜尋關鍵字</p>
|
<p className="text-gray-500">請調整篩選條件或清除搜尋關鍵字</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
@@ -20,6 +20,13 @@ import {
|
|||||||
Brain,
|
Brain,
|
||||||
Zap,
|
Zap,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Bot,
|
||||||
|
Code,
|
||||||
|
Database,
|
||||||
|
Palette,
|
||||||
|
Volume2,
|
||||||
|
Search,
|
||||||
|
BarChart3,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
import { LikeButton } from "@/components/like-button"
|
import { LikeButton } from "@/components/like-button"
|
||||||
@@ -31,22 +38,68 @@ interface TeamDetailDialogProps {
|
|||||||
team: any
|
team: any
|
||||||
}
|
}
|
||||||
|
|
||||||
// App data for team apps - empty for production
|
// 圖標映射函數
|
||||||
const getAppDetails = (appId: string) => {
|
const getIconComponent = (iconName: string) => {
|
||||||
|
const iconMap: { [key: string]: any } = {
|
||||||
|
'Brain': Brain,
|
||||||
|
'Bot': Bot,
|
||||||
|
'Code': Code,
|
||||||
|
'Database': Database,
|
||||||
|
'Palette': Palette,
|
||||||
|
'Volume2': Volume2,
|
||||||
|
'Search': Search,
|
||||||
|
'BarChart3': BarChart3,
|
||||||
|
'Mic': Mic,
|
||||||
|
'ImageIcon': ImageIcon,
|
||||||
|
'MessageSquare': MessageSquare,
|
||||||
|
'Zap': Zap,
|
||||||
|
'TrendingUp': TrendingUp,
|
||||||
|
};
|
||||||
|
|
||||||
|
return iconMap[iconName] || Brain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// App data for team apps - get from team data
|
||||||
|
const getAppDetails = (appId: string, team: any) => {
|
||||||
|
const appDetail = team.appsDetails?.find((app: any) => app.id === appId);
|
||||||
|
|
||||||
|
if (appDetail) {
|
||||||
|
return {
|
||||||
|
id: appDetail.id,
|
||||||
|
name: appDetail.name || "未命名應用",
|
||||||
|
type: appDetail.type || "未知類型",
|
||||||
|
description: appDetail.description || "無描述",
|
||||||
|
icon: getIconComponent(appDetail.icon) || Brain,
|
||||||
|
fullDescription: appDetail.description || "無描述",
|
||||||
|
features: [],
|
||||||
|
author: appDetail.creator_name || "未知作者",
|
||||||
|
category: appDetail.category || "未分類",
|
||||||
|
tags: [],
|
||||||
|
demoUrl: "",
|
||||||
|
sourceUrl: "",
|
||||||
|
likes: appDetail.likes_count || 0,
|
||||||
|
views: appDetail.views_count || 0,
|
||||||
|
rating: Number(appDetail.rating) || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: appId,
|
id: appId,
|
||||||
name: "",
|
name: "未命名應用",
|
||||||
type: "",
|
type: "未知類型",
|
||||||
description: "",
|
description: "無描述",
|
||||||
icon: null,
|
icon: Brain,
|
||||||
fullDescription: "",
|
fullDescription: "無描述",
|
||||||
features: [],
|
features: [],
|
||||||
author: "",
|
author: "未知作者",
|
||||||
category: "",
|
category: "未分類",
|
||||||
tags: [],
|
tags: [],
|
||||||
demoUrl: "",
|
demoUrl: "",
|
||||||
sourceUrl: "",
|
sourceUrl: "",
|
||||||
}
|
likes: 0,
|
||||||
|
views: 0,
|
||||||
|
rating: 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTypeColor = (type: string) => {
|
const getTypeColor = (type: string) => {
|
||||||
@@ -67,10 +120,10 @@ export function TeamDetailDialog({ open, onOpenChange, team }: TeamDetailDialogP
|
|||||||
|
|
||||||
if (!team) return null
|
if (!team) return null
|
||||||
|
|
||||||
const leader = team.members.find((m: any) => m.id === team.leader)
|
const leader = team.members.find((m: any) => m.user_id === team.leader)
|
||||||
|
|
||||||
const handleAppClick = (appId: string) => {
|
const handleAppClick = (appId: string) => {
|
||||||
const appDetails = getAppDetails(appId)
|
const appDetails = getAppDetails(appId, team)
|
||||||
// Create app object that matches AppDetailDialog interface
|
// Create app object that matches AppDetailDialog interface
|
||||||
const app = {
|
const app = {
|
||||||
id: Number.parseInt(appId),
|
id: Number.parseInt(appId),
|
||||||
@@ -81,14 +134,17 @@ export function TeamDetailDialog({ open, onOpenChange, team }: TeamDetailDialogP
|
|||||||
icon: appDetails.icon,
|
icon: appDetails.icon,
|
||||||
creator: appDetails.author,
|
creator: appDetails.author,
|
||||||
featured: false,
|
featured: false,
|
||||||
judgeScore: 0,
|
judgeScore: appDetails.rating || 0,
|
||||||
|
likes: appDetails.likes || 0,
|
||||||
|
views: appDetails.views || 0,
|
||||||
}
|
}
|
||||||
setSelectedApp(app)
|
setSelectedApp(app)
|
||||||
setAppDetailOpen(true)
|
setAppDetailOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalLikes = team.apps.reduce((sum: number, appId: string) => sum + getLikeCount(appId), 0)
|
// 使用從數據庫獲取的真實數據
|
||||||
const totalViews = team.apps.reduce((sum: number, appId: string) => sum + getViewCount(appId), 0)
|
const totalLikes = team.totalLikes || 0
|
||||||
|
const totalViews = team.totalViews || 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -179,13 +235,13 @@ export function TeamDetailDialog({ open, onOpenChange, team }: TeamDetailDialogP
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-gray-700">團隊隊長</label>
|
<label className="text-sm font-medium text-gray-700">團隊隊長</label>
|
||||||
<p className="text-gray-900">{leader?.name}</p>
|
<p className="text-gray-900">{leader?.name || '未指定'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-gray-700">聯絡信箱</label>
|
<label className="text-sm font-medium text-gray-700">聯絡信箱</label>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Mail className="w-4 h-4 text-gray-500" />
|
<Mail className="w-4 h-4 text-gray-500" />
|
||||||
<p className="text-gray-900">{team.contactEmail}</p>
|
<p className="text-gray-900">{team.contact_email || '未提供'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,11 +317,12 @@ export function TeamDetailDialog({ open, onOpenChange, team }: TeamDetailDialogP
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{team.apps.map((appId: string) => {
|
{team.apps.map((appId: string) => {
|
||||||
const app = getAppDetails(appId)
|
const app = getAppDetails(appId, team)
|
||||||
const IconComponent = app.icon
|
// 如果沒有圖標,使用默認的 Brain 圖標
|
||||||
const likes = getLikeCount(appId)
|
const IconComponent = app.icon || Brain
|
||||||
const views = getViewCount(appId)
|
const likes = app.likes || 0
|
||||||
const rating = getAppRating(appId)
|
const views = app.views || 0
|
||||||
|
const rating = app.rating || 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -299,11 +356,16 @@ export function TeamDetailDialog({ open, onOpenChange, team }: TeamDetailDialogP
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<Star className="w-3 h-3 text-yellow-500" />
|
<Star className="w-3 h-3 text-yellow-500" />
|
||||||
<span>{rating.toFixed(1)}</span>
|
<span>{Number(rating).toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<LikeButton appId={appId} size="sm" />
|
<LikeButton
|
||||||
|
appId={appId}
|
||||||
|
size="sm"
|
||||||
|
likeCount={likes}
|
||||||
|
showCount={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
@@ -122,6 +122,23 @@ export function CompetitionProvider({ children }: { children: ReactNode }) {
|
|||||||
const [teams, setTeams] = useState<Team[]>(mockTeams)
|
const [teams, setTeams] = useState<Team[]>(mockTeams)
|
||||||
const [proposals, setProposals] = useState<Proposal[]>(mockProposals)
|
const [proposals, setProposals] = useState<Proposal[]>(mockProposals)
|
||||||
|
|
||||||
|
// 載入當前競賽
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCurrentCompetition = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/competitions/current')
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success && data.data) {
|
||||||
|
setCurrentCompetition(data.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('載入當前競賽失敗:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCurrentCompetition()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Load judge scores from localStorage
|
// Load judge scores from localStorage
|
||||||
const [judgeScores, setJudgeScores] = useState<JudgeScore[]>(() => {
|
const [judgeScores, setJudgeScores] = useState<JudgeScore[]>(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -186,6 +203,7 @@ export function CompetitionProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [awards])
|
}, [awards])
|
||||||
|
|
||||||
|
|
||||||
const addJudge = (judge: Omit<Judge, "id">) => {
|
const addJudge = (judge: Omit<Judge, "id">) => {
|
||||||
const newJudge: Judge = {
|
const newJudge: Judge = {
|
||||||
...judge,
|
...judge,
|
||||||
|
@@ -89,12 +89,13 @@ CREATE TABLE `competitions` (
|
|||||||
`month` INT NOT NULL,
|
`month` INT NOT NULL,
|
||||||
`start_date` DATE NOT NULL,
|
`start_date` DATE NOT NULL,
|
||||||
`end_date` DATE NOT NULL,
|
`end_date` DATE NOT NULL,
|
||||||
`status` ENUM('upcoming', 'active', 'judging', 'completed') DEFAULT 'upcoming',
|
`status` ENUM('upcoming', 'ongoing', 'active', 'judging', 'completed') DEFAULT 'upcoming',
|
||||||
`description` TEXT,
|
`description` TEXT,
|
||||||
`type` ENUM('individual', 'team', 'mixed', 'proposal') NOT NULL,
|
`type` ENUM('individual', 'team', 'mixed', 'proposal') NOT NULL,
|
||||||
`evaluation_focus` TEXT,
|
`evaluation_focus` TEXT,
|
||||||
`max_team_size` INT NULL,
|
`max_team_size` INT NULL,
|
||||||
`is_active` BOOLEAN DEFAULT TRUE,
|
`is_active` BOOLEAN DEFAULT TRUE,
|
||||||
|
`is_current` BOOLEAN DEFAULT FALSE,
|
||||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
INDEX `idx_year_month` (`year`, `month`),
|
INDEX `idx_year_month` (`year`, `month`),
|
||||||
|
@@ -323,7 +323,9 @@ class DatabaseFailover {
|
|||||||
async query(sql, params) {
|
async query(sql, params) {
|
||||||
const connection = await this.getConnection();
|
const connection = await this.getConnection();
|
||||||
try {
|
try {
|
||||||
const [rows] = await connection.execute(sql, params);
|
// 將 undefined 值轉換為 null,避免 MySQL 驅動錯誤
|
||||||
|
const sanitizedParams = params ? params.map(param => param === undefined ? null : param) : [];
|
||||||
|
const [rows] = await connection.execute(sql, sanitizedParams);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -345,7 +347,9 @@ class DatabaseFailover {
|
|||||||
async insert(sql, params) {
|
async insert(sql, params) {
|
||||||
const connection = await this.getConnection();
|
const connection = await this.getConnection();
|
||||||
try {
|
try {
|
||||||
const [result] = await connection.execute(sql, params);
|
// 將 undefined 值轉換為 null,避免 MySQL 驅動錯誤
|
||||||
|
const sanitizedParams = params ? params.map(param => param === undefined ? null : param) : [];
|
||||||
|
const [result] = await connection.execute(sql, sanitizedParams);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -356,7 +360,9 @@ class DatabaseFailover {
|
|||||||
async update(sql, params) {
|
async update(sql, params) {
|
||||||
const connection = await this.getConnection();
|
const connection = await this.getConnection();
|
||||||
try {
|
try {
|
||||||
const [result] = await connection.execute(sql, params);
|
// 將 undefined 值轉換為 null,避免 MySQL 驅動錯誤
|
||||||
|
const sanitizedParams = params ? params.map(param => param === undefined ? null : param) : [];
|
||||||
|
const [result] = await connection.execute(sql, sanitizedParams);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
@@ -367,7 +373,9 @@ class DatabaseFailover {
|
|||||||
async delete(sql, params) {
|
async delete(sql, params) {
|
||||||
const connection = await this.getConnection();
|
const connection = await this.getConnection();
|
||||||
try {
|
try {
|
||||||
const [result] = await connection.execute(sql, params);
|
// 將 undefined 值轉換為 null,避免 MySQL 驅動錯誤
|
||||||
|
const sanitizedParams = params ? params.map(param => param === undefined ? null : param) : [];
|
||||||
|
const [result] = await connection.execute(sql, sanitizedParams);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
@@ -137,6 +137,53 @@ class DatabaseSyncFixed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 智能雙寫更新
|
||||||
|
async smartDualUpdate(tableName, id, updates) {
|
||||||
|
const result = {
|
||||||
|
success: false,
|
||||||
|
masterSuccess: false,
|
||||||
|
slaveSuccess: false
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先獲取主機的記錄
|
||||||
|
const masterRecord = await this.getMasterRecord(tableName, id);
|
||||||
|
|
||||||
|
if (!masterRecord) {
|
||||||
|
throw new Error('主機記錄不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 獲取備機的記錄 ID(通過名稱匹配)
|
||||||
|
const slaveId = await this.getSlaveRecordIdByName(tableName, masterRecord.name);
|
||||||
|
|
||||||
|
if (!slaveId) {
|
||||||
|
throw new Error('備機記錄不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同時更新主機和備機
|
||||||
|
const masterPromise = this.updateMasterRecord(tableName, id, updates);
|
||||||
|
const slavePromise = this.updateSlaveRecord(tableName, slaveId, updates);
|
||||||
|
|
||||||
|
const [masterResult, slaveResult] = await Promise.allSettled([masterPromise, slavePromise]);
|
||||||
|
|
||||||
|
result.masterSuccess = masterResult.status === 'fulfilled';
|
||||||
|
result.slaveSuccess = slaveResult.status === 'fulfilled';
|
||||||
|
result.success = result.masterSuccess || result.slaveSuccess;
|
||||||
|
|
||||||
|
if (masterResult.status === 'rejected') {
|
||||||
|
result.masterError = masterResult.reason instanceof Error ? masterResult.reason.message : '主機更新失敗';
|
||||||
|
}
|
||||||
|
if (slaveResult.status === 'rejected') {
|
||||||
|
result.slaveError = slaveResult.reason instanceof Error ? slaveResult.reason.message : '備機更新失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.masterError = error instanceof Error ? error.message : '雙寫更新執行失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// 智能雙寫關聯表 - 使用對應的競賽 ID
|
// 智能雙寫關聯表 - 使用對應的競賽 ID
|
||||||
async smartDualInsertRelation(relationTable, masterCompetitionId, slaveCompetitionId, relationData, relationIdField) {
|
async smartDualInsertRelation(relationTable, masterCompetitionId, slaveCompetitionId, relationData, relationIdField) {
|
||||||
const result = {
|
const result = {
|
||||||
@@ -370,6 +417,200 @@ class DatabaseSyncFixed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 獲取主機記錄
|
||||||
|
async getMasterRecord(tableName, id) {
|
||||||
|
if (!this.masterPool) return null;
|
||||||
|
|
||||||
|
const connection = await this.masterPool.getConnection();
|
||||||
|
try {
|
||||||
|
const [rows] = await connection.execute(`SELECT * FROM ${tableName} WHERE id = ?`, [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根據名稱獲取備機記錄 ID
|
||||||
|
async getSlaveRecordIdByName(tableName, name) {
|
||||||
|
if (!this.slavePool) return null;
|
||||||
|
|
||||||
|
const connection = await this.slavePool.getConnection();
|
||||||
|
try {
|
||||||
|
const [rows] = await connection.execute(`SELECT id FROM ${tableName} WHERE name = ? ORDER BY created_at DESC LIMIT 1`, [name]);
|
||||||
|
const result = rows[0]?.id || null;
|
||||||
|
return result && typeof result !== 'string' ? String(result) : result;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新主機記錄
|
||||||
|
async updateMasterRecord(tableName, id, updates) {
|
||||||
|
if (!this.masterPool) return;
|
||||||
|
|
||||||
|
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'created_at');
|
||||||
|
if (fields.length === 0) return;
|
||||||
|
|
||||||
|
const setClause = fields.map(field => `${field} = ?`).join(', ');
|
||||||
|
const values = fields.map(field => updates[field]);
|
||||||
|
|
||||||
|
const connection = await this.masterPool.getConnection();
|
||||||
|
try {
|
||||||
|
const sql = `UPDATE ${tableName} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
|
||||||
|
await connection.execute(sql, [...values, id]);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新備機記錄
|
||||||
|
async updateSlaveRecord(tableName, id, updates) {
|
||||||
|
if (!this.slavePool) return;
|
||||||
|
|
||||||
|
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'created_at');
|
||||||
|
if (fields.length === 0) return;
|
||||||
|
|
||||||
|
const setClause = fields.map(field => `${field} = ?`).join(', ');
|
||||||
|
const values = fields.map(field => updates[field]);
|
||||||
|
|
||||||
|
const connection = await this.slavePool.getConnection();
|
||||||
|
try {
|
||||||
|
const sql = `UPDATE ${tableName} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
|
||||||
|
await connection.execute(sql, [...values, id]);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除所有競賽的當前狀態
|
||||||
|
async clearAllCurrentCompetitions() {
|
||||||
|
const result = {
|
||||||
|
success: false,
|
||||||
|
masterSuccess: false,
|
||||||
|
slaveSuccess: false
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 同時清除主機和備機的所有當前競賽狀態
|
||||||
|
const masterPromise = this.clearMasterCurrentCompetitions();
|
||||||
|
const slavePromise = this.clearSlaveCurrentCompetitions();
|
||||||
|
|
||||||
|
const [masterResult, slaveResult] = await Promise.allSettled([masterPromise, slavePromise]);
|
||||||
|
|
||||||
|
result.masterSuccess = masterResult.status === 'fulfilled';
|
||||||
|
result.slaveSuccess = slaveResult.status === 'fulfilled';
|
||||||
|
result.success = result.masterSuccess || result.slaveSuccess;
|
||||||
|
|
||||||
|
if (masterResult.status === 'rejected') {
|
||||||
|
result.masterError = masterResult.reason instanceof Error ? masterResult.reason.message : '主機清除失敗';
|
||||||
|
}
|
||||||
|
if (slaveResult.status === 'rejected') {
|
||||||
|
result.slaveError = slaveResult.reason instanceof Error ? slaveResult.reason.message : '備機清除失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.masterError = error instanceof Error ? error.message : '清除所有當前競賽執行失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除主機的所有當前競賽狀態
|
||||||
|
async clearMasterCurrentCompetitions() {
|
||||||
|
if (!this.masterPool) return;
|
||||||
|
|
||||||
|
const connection = await this.masterPool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.execute('UPDATE competitions SET is_current = FALSE');
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除備機的所有當前競賽狀態
|
||||||
|
async clearSlaveCurrentCompetitions() {
|
||||||
|
if (!this.slavePool) return;
|
||||||
|
|
||||||
|
const connection = await this.slavePool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.execute('UPDATE competitions SET is_current = FALSE');
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 智能雙寫刪除
|
||||||
|
async smartDualDelete(tableName, id, idField = 'id') {
|
||||||
|
const result = {
|
||||||
|
success: false,
|
||||||
|
masterSuccess: false,
|
||||||
|
slaveSuccess: false
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 獲取備機對應的 ID
|
||||||
|
let slaveId = null;
|
||||||
|
if (tableName === 'competitions') {
|
||||||
|
// 對於競賽表,先獲取競賽名稱
|
||||||
|
const masterRecord = await this.getMasterRecord(tableName, id);
|
||||||
|
if (masterRecord) {
|
||||||
|
slaveId = await this.getSlaveRecordIdByName(tableName, masterRecord.name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 對於關聯表,直接使用主機 ID
|
||||||
|
slaveId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同時刪除主機和備機的記錄
|
||||||
|
const masterPromise = this.deleteFromMaster(tableName, id, idField);
|
||||||
|
const slavePromise = slaveId ? this.deleteFromSlave(tableName, slaveId, idField) : Promise.resolve();
|
||||||
|
|
||||||
|
const [masterResult, slaveResult] = await Promise.allSettled([masterPromise, slavePromise]);
|
||||||
|
|
||||||
|
result.masterSuccess = masterResult.status === 'fulfilled';
|
||||||
|
result.slaveSuccess = slaveResult.status === 'fulfilled';
|
||||||
|
result.success = result.masterSuccess || result.slaveSuccess;
|
||||||
|
|
||||||
|
if (masterResult.status === 'rejected') {
|
||||||
|
result.masterError = masterResult.reason instanceof Error ? masterResult.reason.message : '主機刪除失敗';
|
||||||
|
}
|
||||||
|
if (slaveResult.status === 'rejected') {
|
||||||
|
result.slaveError = slaveResult.reason instanceof Error ? slaveResult.reason.message : '備機刪除失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.masterError = error instanceof Error ? error.message : '雙寫刪除執行失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 從主機刪除記錄
|
||||||
|
async deleteFromMaster(tableName, id, idField = 'id') {
|
||||||
|
if (!this.masterPool) return;
|
||||||
|
|
||||||
|
const connection = await this.masterPool.getConnection();
|
||||||
|
try {
|
||||||
|
const sql = `DELETE FROM ${tableName} WHERE ${idField} = ?`;
|
||||||
|
await connection.execute(sql, [id]);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 從備機刪除記錄
|
||||||
|
async deleteFromSlave(tableName, id, idField = 'id') {
|
||||||
|
if (!this.slavePool) return;
|
||||||
|
|
||||||
|
const connection = await this.slavePool.getConnection();
|
||||||
|
try {
|
||||||
|
const sql = `DELETE FROM ${tableName} WHERE ${idField} = ?`;
|
||||||
|
await connection.execute(sql, [id]);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 清理資源
|
// 清理資源
|
||||||
async close() {
|
async close() {
|
||||||
if (this.masterPool) {
|
if (this.masterPool) {
|
||||||
|
@@ -157,6 +157,53 @@ export class DatabaseSyncFixed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 智能雙寫更新
|
||||||
|
async smartDualUpdate(tableName: string, id: string, updates: Record<string, any>): Promise<WriteResult> {
|
||||||
|
const result: WriteResult = {
|
||||||
|
success: false,
|
||||||
|
masterSuccess: false,
|
||||||
|
slaveSuccess: false
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先獲取主機的記錄
|
||||||
|
const masterRecord = await this.getMasterRecord(tableName, id);
|
||||||
|
|
||||||
|
if (!masterRecord) {
|
||||||
|
throw new Error('主機記錄不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 獲取備機的記錄 ID(通過名稱匹配)
|
||||||
|
const slaveId = await this.getSlaveRecordIdByName(tableName, masterRecord.name);
|
||||||
|
|
||||||
|
if (!slaveId) {
|
||||||
|
throw new Error('備機記錄不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同時更新主機和備機
|
||||||
|
const masterPromise = this.updateMasterRecord(tableName, id, updates);
|
||||||
|
const slavePromise = this.updateSlaveRecord(tableName, slaveId, updates);
|
||||||
|
|
||||||
|
const [masterResult, slaveResult] = await Promise.allSettled([masterPromise, slavePromise]);
|
||||||
|
|
||||||
|
result.masterSuccess = masterResult.status === 'fulfilled';
|
||||||
|
result.slaveSuccess = slaveResult.status === 'fulfilled';
|
||||||
|
result.success = result.masterSuccess || result.slaveSuccess;
|
||||||
|
|
||||||
|
if (masterResult.status === 'rejected') {
|
||||||
|
result.masterError = masterResult.reason instanceof Error ? masterResult.reason.message : '主機更新失敗';
|
||||||
|
}
|
||||||
|
if (slaveResult.status === 'rejected') {
|
||||||
|
result.slaveError = slaveResult.reason instanceof Error ? slaveResult.reason.message : '備機更新失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.masterError = error instanceof Error ? error.message : '雙寫更新執行失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// 智能雙寫關聯表 - 使用對應的競賽 ID
|
// 智能雙寫關聯表 - 使用對應的競賽 ID
|
||||||
async smartDualInsertRelation(
|
async smartDualInsertRelation(
|
||||||
relationTable: string,
|
relationTable: string,
|
||||||
@@ -260,6 +307,71 @@ export class DatabaseSyncFixed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 獲取主機記錄
|
||||||
|
private async getMasterRecord(tableName: string, id: string): Promise<any> {
|
||||||
|
if (!this.masterPool) return null;
|
||||||
|
|
||||||
|
const connection = await this.masterPool.getConnection();
|
||||||
|
try {
|
||||||
|
const [rows] = await connection.execute(`SELECT * FROM ${tableName} WHERE id = ?`, [id]);
|
||||||
|
return (rows as any[])[0] || null;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根據名稱獲取備機記錄 ID
|
||||||
|
private async getSlaveRecordIdByName(tableName: string, name: string): Promise<string | null> {
|
||||||
|
if (!this.slavePool) return null;
|
||||||
|
|
||||||
|
const connection = await this.slavePool.getConnection();
|
||||||
|
try {
|
||||||
|
const [rows] = await connection.execute(`SELECT id FROM ${tableName} WHERE name = ? ORDER BY created_at DESC LIMIT 1`, [name]);
|
||||||
|
const result = (rows as any[])[0]?.id || null;
|
||||||
|
return result && typeof result !== 'string' ? String(result) : result;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新主機記錄
|
||||||
|
private async updateMasterRecord(tableName: string, id: string, updates: Record<string, any>): Promise<void> {
|
||||||
|
if (!this.masterPool) return;
|
||||||
|
|
||||||
|
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'created_at');
|
||||||
|
if (fields.length === 0) return;
|
||||||
|
|
||||||
|
const setClause = fields.map(field => `${field} = ?`).join(', ');
|
||||||
|
const values = fields.map(field => updates[field]);
|
||||||
|
|
||||||
|
const connection = await this.masterPool.getConnection();
|
||||||
|
try {
|
||||||
|
const sql = `UPDATE ${tableName} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
|
||||||
|
await connection.execute(sql, [...values, id]);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新備機記錄
|
||||||
|
private async updateSlaveRecord(tableName: string, id: string, updates: Record<string, any>): Promise<void> {
|
||||||
|
if (!this.slavePool) return;
|
||||||
|
|
||||||
|
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'created_at');
|
||||||
|
if (fields.length === 0) return;
|
||||||
|
|
||||||
|
const setClause = fields.map(field => `${field} = ?`).join(', ');
|
||||||
|
const values = fields.map(field => updates[field]);
|
||||||
|
|
||||||
|
const connection = await this.slavePool.getConnection();
|
||||||
|
try {
|
||||||
|
const sql = `UPDATE ${tableName} SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
|
||||||
|
await connection.execute(sql, [...values, id]);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 根據名稱獲取備機競賽 ID
|
// 根據名稱獲取備機競賽 ID
|
||||||
private async getSlaveCompetitionIdByName(name: string): Promise<string | null> {
|
private async getSlaveCompetitionIdByName(name: string): Promise<string | null> {
|
||||||
if (!this.slavePool) return null;
|
if (!this.slavePool) return null;
|
||||||
@@ -350,6 +462,135 @@ export class DatabaseSyncFixed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清除所有競賽的當前狀態
|
||||||
|
async clearAllCurrentCompetitions(): Promise<WriteResult> {
|
||||||
|
const result: WriteResult = {
|
||||||
|
success: false,
|
||||||
|
masterSuccess: false,
|
||||||
|
slaveSuccess: false
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 同時清除主機和備機的所有當前競賽狀態
|
||||||
|
const masterPromise = this.clearMasterCurrentCompetitions();
|
||||||
|
const slavePromise = this.clearSlaveCurrentCompetitions();
|
||||||
|
|
||||||
|
const [masterResult, slaveResult] = await Promise.allSettled([masterPromise, slavePromise]);
|
||||||
|
|
||||||
|
result.masterSuccess = masterResult.status === 'fulfilled';
|
||||||
|
result.slaveSuccess = slaveResult.status === 'fulfilled';
|
||||||
|
result.success = result.masterSuccess || result.slaveSuccess;
|
||||||
|
|
||||||
|
if (masterResult.status === 'rejected') {
|
||||||
|
result.masterError = masterResult.reason instanceof Error ? masterResult.reason.message : '主機清除失敗';
|
||||||
|
}
|
||||||
|
if (slaveResult.status === 'rejected') {
|
||||||
|
result.slaveError = slaveResult.reason instanceof Error ? slaveResult.reason.message : '備機清除失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.masterError = error instanceof Error ? error.message : '清除所有當前競賽執行失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除主機的所有當前競賽狀態
|
||||||
|
private async clearMasterCurrentCompetitions(): Promise<void> {
|
||||||
|
if (!this.masterPool) return;
|
||||||
|
|
||||||
|
const connection = await this.masterPool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.execute('UPDATE competitions SET is_current = FALSE');
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除備機的所有當前競賽狀態
|
||||||
|
private async clearSlaveCurrentCompetitions(): Promise<void> {
|
||||||
|
if (!this.slavePool) return;
|
||||||
|
|
||||||
|
const connection = await this.slavePool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.execute('UPDATE competitions SET is_current = FALSE');
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 智能雙寫刪除
|
||||||
|
async smartDualDelete(tableName: string, id: string, idField: string = 'id'): Promise<WriteResult> {
|
||||||
|
const result: WriteResult = {
|
||||||
|
success: false,
|
||||||
|
masterSuccess: false,
|
||||||
|
slaveSuccess: false
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 獲取備機對應的 ID
|
||||||
|
let slaveId: string | null = null;
|
||||||
|
if (tableName === 'competitions') {
|
||||||
|
// 對於競賽表,先獲取競賽名稱
|
||||||
|
const masterRecord = await this.getMasterRecord(tableName, id);
|
||||||
|
if (masterRecord) {
|
||||||
|
slaveId = await this.getSlaveRecordIdByName(tableName, masterRecord.name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 對於關聯表,直接使用主機 ID
|
||||||
|
slaveId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同時刪除主機和備機的記錄
|
||||||
|
const masterPromise = this.deleteFromMaster(tableName, id, idField);
|
||||||
|
const slavePromise = slaveId ? this.deleteFromSlave(tableName, slaveId, idField) : Promise.resolve();
|
||||||
|
|
||||||
|
const [masterResult, slaveResult] = await Promise.allSettled([masterPromise, slavePromise]);
|
||||||
|
|
||||||
|
result.masterSuccess = masterResult.status === 'fulfilled';
|
||||||
|
result.slaveSuccess = slaveResult.status === 'fulfilled';
|
||||||
|
result.success = result.masterSuccess || result.slaveSuccess;
|
||||||
|
|
||||||
|
if (masterResult.status === 'rejected') {
|
||||||
|
result.masterError = masterResult.reason instanceof Error ? masterResult.reason.message : '主機刪除失敗';
|
||||||
|
}
|
||||||
|
if (slaveResult.status === 'rejected') {
|
||||||
|
result.slaveError = slaveResult.reason instanceof Error ? slaveResult.reason.message : '備機刪除失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.masterError = error instanceof Error ? error.message : '雙寫刪除執行失敗';
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 從主機刪除記錄
|
||||||
|
private async deleteFromMaster(tableName: string, id: string, idField: string = 'id'): Promise<void> {
|
||||||
|
if (!this.masterPool) return;
|
||||||
|
|
||||||
|
const connection = await this.masterPool.getConnection();
|
||||||
|
try {
|
||||||
|
const sql = `DELETE FROM ${tableName} WHERE ${idField} = ?`;
|
||||||
|
await connection.execute(sql, [id]);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 從備機刪除記錄
|
||||||
|
private async deleteFromSlave(tableName: string, id: string, idField: string = 'id'): Promise<void> {
|
||||||
|
if (!this.slavePool) return;
|
||||||
|
|
||||||
|
const connection = await this.slavePool.getConnection();
|
||||||
|
try {
|
||||||
|
const sql = `DELETE FROM ${tableName} WHERE ${idField} = ?`;
|
||||||
|
await connection.execute(sql, [id]);
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 清理資源
|
// 清理資源
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
if (this.masterPool) {
|
if (this.masterPool) {
|
||||||
|
@@ -616,7 +616,7 @@ export class UserService {
|
|||||||
const competitionStatsSql = `
|
const competitionStatsSql = `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total_competitions,
|
COUNT(*) as total_competitions,
|
||||||
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_competitions
|
COUNT(CASE WHEN status = 'active' OR status = 'ongoing' THEN 1 END) as active_competitions
|
||||||
FROM competitions
|
FROM competitions
|
||||||
`;
|
`;
|
||||||
const competitionStats = await this.queryOne(competitionStatsSql);
|
const competitionStats = await this.queryOne(competitionStatsSql);
|
||||||
@@ -1282,13 +1282,135 @@ export class CompetitionService {
|
|||||||
|
|
||||||
// 更新競賽
|
// 更新競賽
|
||||||
static async updateCompetition(id: string, updates: Partial<Competition>): Promise<boolean> {
|
static async updateCompetition(id: string, updates: Partial<Competition>): Promise<boolean> {
|
||||||
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'created_at');
|
try {
|
||||||
const setClause = fields.map(field => `${field} = ?`).join(', ');
|
const dbSyncFixed = new DatabaseSyncFixed();
|
||||||
const values = fields.map(field => (updates as any)[field]);
|
|
||||||
|
|
||||||
const sql = `UPDATE competitions SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
|
// 使用雙寫功能更新競賽
|
||||||
const result = await db.update(sql, [...values, id]);
|
const result = await dbSyncFixed.smartDualUpdate('competitions', id, updates);
|
||||||
return result.affectedRows > 0;
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('競賽更新失敗:', result.masterError || result.slaveError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('競賽更新失敗:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 獲取當前競賽
|
||||||
|
static async getCurrentCompetition(): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const sql = 'SELECT * FROM competitions WHERE is_current = TRUE AND is_active = TRUE LIMIT 1';
|
||||||
|
const competitions = await db.query<any>(sql);
|
||||||
|
|
||||||
|
if (competitions.length > 0) {
|
||||||
|
const competition = competitions[0];
|
||||||
|
return await this.getCompetitionWithDetails(competition.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('獲取當前競賽失敗:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 設置當前競賽
|
||||||
|
static async setCurrentCompetition(competitionId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const dbSyncFixed = new DatabaseSyncFixed();
|
||||||
|
|
||||||
|
// 先清除所有競賽的當前狀態 - 使用直接 SQL 更新
|
||||||
|
try {
|
||||||
|
await dbSyncFixed.clearAllCurrentCompetitions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清除當前競賽狀態失敗:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 設置指定競賽為當前競賽
|
||||||
|
const setResult = await dbSyncFixed.smartDualUpdate('competitions', competitionId, { is_current: true });
|
||||||
|
|
||||||
|
if (!setResult.success) {
|
||||||
|
console.error('設置當前競賽失敗:', setResult.masterError || setResult.slaveError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('設置當前競賽失敗:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除當前競賽
|
||||||
|
static async clearCurrentCompetition(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await db.update('UPDATE competitions SET is_current = FALSE');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清除當前競賽失敗:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刪除競賽 - 支援雙寫
|
||||||
|
static async deleteCompetition(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const dbSyncFixed = new DatabaseSyncFixed();
|
||||||
|
|
||||||
|
// 先獲取競賽信息
|
||||||
|
const competition = await this.getCompetitionById(id);
|
||||||
|
if (!competition) {
|
||||||
|
console.error('競賽不存在');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刪除關聯數據
|
||||||
|
await this.deleteCompetitionRelations(id);
|
||||||
|
|
||||||
|
// 使用雙寫功能刪除競賽
|
||||||
|
const result = await dbSyncFixed.smartDualDelete('competitions', id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('競賽刪除失敗:', result.masterError || result.slaveError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('競賽刪除失敗:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刪除競賽關聯數據
|
||||||
|
private static async deleteCompetitionRelations(competitionId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dbSyncFixed = new DatabaseSyncFixed();
|
||||||
|
|
||||||
|
// 刪除所有關聯表數據
|
||||||
|
const relations = [
|
||||||
|
'competition_judges',
|
||||||
|
'competition_teams',
|
||||||
|
'competition_apps',
|
||||||
|
'competition_award_types',
|
||||||
|
'competition_rules'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const relationTable of relations) {
|
||||||
|
try {
|
||||||
|
await dbSyncFixed.smartDualDelete(relationTable, competitionId, 'competition_id');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`刪除關聯表 ${relationTable} 失敗:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刪除競賽關聯數據失敗:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
@@ -1369,16 +1491,50 @@ export class CompetitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 獲取競賽的應用列表
|
// 獲取競賽的應用列表
|
||||||
static async getCompetitionApps(competitionId: string): Promise<any[]> {
|
static async getCompetitionApps(competitionId: string, competitionType?: string): Promise<any[]> {
|
||||||
const sql = `
|
// 先獲取競賽信息
|
||||||
SELECT a.*, ca.submitted_at, u.name as creator_name, u.department as creator_department
|
const competition = await this.getCompetitionById(competitionId);
|
||||||
FROM competition_apps ca
|
if (!competition) return [];
|
||||||
JOIN apps a ON ca.app_id = a.id
|
|
||||||
LEFT JOIN users u ON a.creator_id = u.id
|
let apps: any[] = [];
|
||||||
WHERE ca.competition_id = ? AND a.is_active = TRUE
|
|
||||||
ORDER BY ca.submitted_at ASC
|
if (competition.type === 'team') {
|
||||||
`;
|
// 對於團隊競賽,獲取所有參賽團隊的應用
|
||||||
return await db.query(sql, [competitionId]);
|
const teams = await this.getCompetitionTeams(competitionId);
|
||||||
|
if (teams.length > 0) {
|
||||||
|
// 過濾掉 undefined 或 null 的 team_id 值
|
||||||
|
const teamIds = teams
|
||||||
|
.map(t => t.team_id)
|
||||||
|
.filter(id => id !== undefined && id !== null);
|
||||||
|
|
||||||
|
if (teamIds.length > 0) {
|
||||||
|
const placeholders = teamIds.map(() => '?').join(',');
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT a.*, u.name as creator_name, u.department as creator_department, t.name as team_name
|
||||||
|
FROM apps a
|
||||||
|
LEFT JOIN users u ON a.creator_id = u.id
|
||||||
|
LEFT JOIN teams t ON a.team_id = t.id
|
||||||
|
WHERE a.team_id IN (${placeholders}) AND a.is_active = TRUE
|
||||||
|
ORDER BY a.created_at ASC
|
||||||
|
`;
|
||||||
|
apps = await db.query(sql, teamIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 對於個人競賽,從 competition_apps 表獲取
|
||||||
|
const sql = `
|
||||||
|
SELECT a.*, ca.submitted_at, u.name as creator_name, u.department as creator_department
|
||||||
|
FROM competition_apps ca
|
||||||
|
JOIN apps a ON ca.app_id = a.id
|
||||||
|
LEFT JOIN users u ON a.creator_id = u.id
|
||||||
|
WHERE ca.competition_id = ? AND a.is_active = TRUE
|
||||||
|
ORDER BY ca.submitted_at ASC
|
||||||
|
`;
|
||||||
|
apps = await db.query(sql, [competitionId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apps;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 為競賽添加團隊
|
// 為競賽添加團隊
|
||||||
@@ -1621,9 +1777,42 @@ export class CompetitionService {
|
|||||||
this.getCompetitionRules(competitionId)
|
this.getCompetitionRules(competitionId)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 根據日期動態計算競賽狀態
|
||||||
|
const now = new Date();
|
||||||
|
const startDate = new Date(competition.start_date);
|
||||||
|
const endDate = new Date(competition.end_date);
|
||||||
|
|
||||||
|
let calculatedStatus = competition.status;
|
||||||
|
|
||||||
|
// 確保日期比較的準確性,使用 UTC 時間避免時區問題
|
||||||
|
const nowUTC = new Date(now.getTime() + now.getTimezoneOffset() * 60000);
|
||||||
|
const startDateUTC = new Date(startDate.getTime() + startDate.getTimezoneOffset() * 60000);
|
||||||
|
const endDateUTC = new Date(endDate.getTime() + endDate.getTimezoneOffset() * 60000);
|
||||||
|
|
||||||
|
console.log('🔍 競賽狀態計算:', {
|
||||||
|
competitionId,
|
||||||
|
name: competition.name,
|
||||||
|
now: nowUTC.toISOString(),
|
||||||
|
startDate: startDateUTC.toISOString(),
|
||||||
|
endDate: endDateUTC.toISOString(),
|
||||||
|
originalStatus: competition.status
|
||||||
|
});
|
||||||
|
|
||||||
|
// 根據實際日期計算狀態
|
||||||
|
if (nowUTC < startDateUTC) {
|
||||||
|
calculatedStatus = 'upcoming'; // 即將開始
|
||||||
|
} else if (nowUTC >= startDateUTC && nowUTC <= endDateUTC) {
|
||||||
|
calculatedStatus = 'active'; // 進行中
|
||||||
|
} else if (nowUTC > endDateUTC) {
|
||||||
|
calculatedStatus = 'completed'; // 已完成
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔍 計算後的狀態:', calculatedStatus);
|
||||||
|
|
||||||
// 轉換字段名稱以匹配前端期望的格式
|
// 轉換字段名稱以匹配前端期望的格式
|
||||||
return {
|
return {
|
||||||
...competition,
|
...competition,
|
||||||
|
status: calculatedStatus, // 使用計算後的狀態
|
||||||
startDate: competition.start_date,
|
startDate: competition.start_date,
|
||||||
endDate: competition.end_date,
|
endDate: competition.end_date,
|
||||||
evaluationFocus: competition.evaluation_focus,
|
evaluationFocus: competition.evaluation_focus,
|
||||||
@@ -2625,7 +2814,7 @@ export class AppService {
|
|||||||
const competitionStatsSql = `
|
const competitionStatsSql = `
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total_competitions,
|
COUNT(*) as total_competitions,
|
||||||
COUNT(CASE WHEN status = 'active' THEN 1 END) as active_competitions
|
COUNT(CASE WHEN status = 'active' OR status = 'ongoing' THEN 1 END) as active_competitions
|
||||||
FROM competitions
|
FROM competitions
|
||||||
`;
|
`;
|
||||||
const competitionStats = await this.queryOne(competitionStatsSql);
|
const competitionStats = await this.queryOne(competitionStatsSql);
|
||||||
|
10
package.json
10
package.json
@@ -15,16 +15,6 @@
|
|||||||
"migrate:data": "node scripts/migrate-data.js",
|
"migrate:data": "node scripts/migrate-data.js",
|
||||||
"migrate:all": "pnpm run migrate:tables && pnpm run migrate:views && pnpm run migrate:triggers && pnpm run migrate:data",
|
"migrate:all": "pnpm run migrate:tables && pnpm run migrate:views && pnpm run migrate:triggers && pnpm run migrate:data",
|
||||||
"migrate:reset": "node scripts/migrate.js --reset",
|
"migrate:reset": "node scripts/migrate.js --reset",
|
||||||
"test:db": "node scripts/test-connection.js",
|
|
||||||
"create:users": "node scripts/create-test-users.js",
|
|
||||||
"add:user-fields": "node scripts/add-user-fields.js",
|
|
||||||
"test:profile": "node scripts/test-profile-update.js",
|
|
||||||
"test:forgot-password": "node scripts/test-forgot-password.js",
|
|
||||||
"test:forgot-password-new": "node scripts/test-forgot-password-new-flow.js",
|
|
||||||
"test:password-visibility": "node scripts/test-password-visibility.js",
|
|
||||||
"test:role-display": "node scripts/test-role-display.js",
|
|
||||||
"test:activity-records": "node scripts/test-activity-records.js",
|
|
||||||
"test:hydration-fix": "node scripts/test-hydration-fix.js",
|
|
||||||
"setup": "node scripts/setup.js",
|
"setup": "node scripts/setup.js",
|
||||||
"db:init-slave": "node scripts/init-slave-database.js",
|
"db:init-slave": "node scripts/init-slave-database.js",
|
||||||
"db:sync": "node scripts/sync-database.js",
|
"db:sync": "node scripts/sync-database.js",
|
||||||
|
Reference in New Issue
Block a user