新增競賽前台呈現、刪除競賽、修改競賽狀態
This commit is contained in:
@@ -88,7 +88,7 @@ export async function PUT(request: NextRequest, { params }: { params: { id: stri
|
||||
|
||||
// 驗證狀態(如果提供)
|
||||
if (body.status) {
|
||||
const validStatuses = ['upcoming', 'active', 'judging', 'completed'];
|
||||
const validStatuses = ['upcoming', 'ongoing', 'active', 'judging', 'completed'];
|
||||
if (!validStatuses.includes(body.status)) {
|
||||
return NextResponse.json({
|
||||
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 } }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
@@ -175,8 +175,8 @@ export async function DELETE(request: NextRequest, { params }: { params: { id: s
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
// 軟刪除:將 is_active 設為 false
|
||||
const success = await CompetitionService.updateCompetition(id, { is_active: false });
|
||||
// 使用雙寫功能刪除競賽
|
||||
const success = await CompetitionService.deleteCompetition(id);
|
||||
|
||||
if (!success) {
|
||||
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)) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
|
@@ -14,7 +14,8 @@ export async function GET(request: NextRequest) {
|
||||
const stats = {
|
||||
total: competitions.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,
|
||||
completed: competitions.filter(c => c.status === 'completed').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"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useCompetition } from "@/contexts/competition-context"
|
||||
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() {
|
||||
const { user, canAccessAdmin } = useAuth()
|
||||
const { competitions, awards, getAwardsByYear, getCompetitionRankings } = useCompetition()
|
||||
const { competitions, awards, getAwardsByYear, getCompetitionRankings, currentCompetition, setCurrentCompetition } = useCompetition()
|
||||
|
||||
const [selectedCompetitionTypeFilter, setSelectedCompetitionTypeFilter] = useState("all")
|
||||
const [selectedMonthFilter, setSelectedMonthFilter] = useState("all")
|
||||
@@ -31,6 +31,7 @@ export default function CompetitionPage() {
|
||||
const [showAwardDetail, setShowAwardDetail] = useState(false)
|
||||
const [selectedAward, setSelectedAward] = useState<any>(null)
|
||||
|
||||
|
||||
const getCompetitionTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
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 () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/competitions/stats')
|
||||
@@ -630,6 +691,7 @@ export function CompetitionManagement() {
|
||||
// 組件載入時獲取資料
|
||||
useEffect(() => {
|
||||
fetchCompetitions()
|
||||
fetchCurrentCompetition()
|
||||
fetchCompetitionStats()
|
||||
fetchJudges()
|
||||
fetchJudgeStats()
|
||||
@@ -1770,22 +1832,50 @@ export function CompetitionManagement() {
|
||||
if (!selectedCompetitionForAction) return
|
||||
|
||||
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)
|
||||
setShowDeleteCompetitionConfirm(false)
|
||||
setSelectedCompetitionForAction(null)
|
||||
setSuccess("競賽刪除成功!")
|
||||
|
||||
// 重新載入競賽列表
|
||||
await fetchCompetitions()
|
||||
} else {
|
||||
setError("競賽刪除失敗: " + data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刪除競賽失敗:', error)
|
||||
setError("競賽刪除失敗")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setTimeout(() => setError(""), 3000)
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateStatus = async () => {
|
||||
if (!selectedCompetitionForAction) return
|
||||
|
||||
setIsLoading(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
try {
|
||||
// 更新資料庫
|
||||
const updatedCompetition = await updateCompetitionInDb(selectedCompetitionForAction.id, {
|
||||
status: newStatus,
|
||||
})
|
||||
|
||||
if (updatedCompetition) {
|
||||
// 同時更新 context
|
||||
updateCompetition(selectedCompetitionForAction.id, {
|
||||
...selectedCompetitionForAction,
|
||||
status: newStatus,
|
||||
@@ -1794,8 +1884,16 @@ export function CompetitionManagement() {
|
||||
setShowChangeStatusDialog(false)
|
||||
setSelectedCompetitionForAction(null)
|
||||
setSuccess("競賽狀態更新成功!")
|
||||
} else {
|
||||
setError("競賽狀態更新失敗")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新競賽狀態失敗:', error)
|
||||
setError("競賽狀態更新失敗")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
setTimeout(() => setError(""), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const getCompetitionTypeIcon = (type: string) => {
|
||||
@@ -1918,6 +2016,8 @@ export function CompetitionManagement() {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "bg-green-100 text-green-800 border-green-200"
|
||||
case "ongoing":
|
||||
return "bg-yellow-100 text-yellow-800 border-yellow-200"
|
||||
case "active":
|
||||
return "bg-blue-100 text-blue-800 border-blue-200"
|
||||
case "judging":
|
||||
@@ -1933,6 +2033,7 @@ export function CompetitionManagement() {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "已完成"
|
||||
case "ongoing":
|
||||
case "active":
|
||||
return "進行中"
|
||||
case "judging":
|
||||
@@ -2079,7 +2180,7 @@ export function CompetitionManagement() {
|
||||
{isLoadingDb ? (
|
||||
<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>
|
||||
</div>
|
||||
@@ -2245,14 +2346,14 @@ export function CompetitionManagement() {
|
||||
</DropdownMenuItem>
|
||||
|
||||
{!isCurrentCompetition && (
|
||||
<DropdownMenuItem onClick={() => setCurrentCompetition(competition)}>
|
||||
<DropdownMenuItem onClick={() => setCurrentCompetitionInDb(competition.id)}>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
設為當前競賽
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{isCurrentCompetition && (
|
||||
<DropdownMenuItem onClick={() => setCurrentCompetition(null)}>
|
||||
<DropdownMenuItem onClick={() => clearCurrentCompetitionInDb()}>
|
||||
<StarOff className="w-4 h-4 mr-2" />
|
||||
取消當前競賽
|
||||
</DropdownMenuItem>
|
||||
@@ -5395,9 +5496,9 @@ export function CompetitionManagement() {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>當前狀態</Label>
|
||||
<p className="text-sm text-gray-600">
|
||||
<div className="text-sm text-gray-600">
|
||||
<Badge>{getStatusText(selectedCompetitionForAction.status)}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">新狀態</Label>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { useCompetition } from "@/contexts/competition-context"
|
||||
import {
|
||||
@@ -55,10 +55,67 @@ export function PopularityRankings() {
|
||||
const [individualCurrentPage, setIndividualCurrentPage] = 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
|
||||
|
||||
// 載入當前競賽的數據
|
||||
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
|
||||
const filteredApps = aiApps.filter((app) => {
|
||||
const filteredApps = competitionApps.filter((app) => {
|
||||
const matchesSearch =
|
||||
app.name.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
|
||||
const sortedApps = filteredApps.sort((a, b) => {
|
||||
const likesA = getLikeCount(a.id.toString())
|
||||
const likesB = getLikeCount(b.id.toString())
|
||||
return likesB - likesA
|
||||
return (b.likes || 0) - (a.likes || 0)
|
||||
})
|
||||
|
||||
// Group apps by competition type
|
||||
@@ -319,23 +374,8 @@ export function PopularityRankings() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate team popularity score: total apps × highest like count
|
||||
const teamsWithScores = teams
|
||||
.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)
|
||||
// 團隊已經從 API 獲取了人氣分數,直接使用
|
||||
const teamsWithScores = teams.sort((a, b) => b.popularityScore - a.popularityScore)
|
||||
|
||||
const currentPage = teamCurrentPage
|
||||
const setCurrentPage = setTeamCurrentPage
|
||||
@@ -512,7 +552,7 @@ export function PopularityRankings() {
|
||||
<div className="flex items-center space-x-2">
|
||||
<ThumbsUp className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">
|
||||
{team.apps.reduce((sum: number, appId: string) => sum + getLikeCount(appId), 0)} 總按讚
|
||||
{team.totalLikes || 0} 總按讚
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -593,7 +633,11 @@ export function PopularityRankings() {
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<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 className="flex items-center space-x-2">
|
||||
<Badge
|
||||
@@ -633,27 +677,39 @@ export function PopularityRankings() {
|
||||
<CardDescription>專業評審團隊</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{judges.map((judge) => (
|
||||
<div key={judge.id} className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<Avatar>
|
||||
<AvatarImage src={judge.avatar || "/placeholder.svg"} />
|
||||
<AvatarFallback className="bg-purple-100 text-purple-700">{judge.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<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>
|
||||
))}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-600">載入評審團中...</p>
|
||||
</div>
|
||||
) : competitionJudges.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{competitionJudges.map((judge) => (
|
||||
<div key={judge.id} className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<Avatar>
|
||||
<AvatarImage src={judge.avatar || "/placeholder.svg"} />
|
||||
<AvatarFallback className="bg-purple-100 text-purple-700">{judge.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<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 className="text-center py-8 text-gray-500">
|
||||
<Crown className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>暫無評審團成員</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -733,23 +789,30 @@ export function PopularityRankings() {
|
||||
|
||||
{/* Team Competition Section */}
|
||||
{(selectedCompetitionType === "all" || selectedCompetitionType === "team") &&
|
||||
currentCompetition?.type === "team" &&
|
||||
renderTeamCompetitionSection(
|
||||
mockTeams.filter(
|
||||
(team) =>
|
||||
team.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
team.members.some((member: any) => member.name.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
selectedDepartment === "all" ||
|
||||
team.department === selectedDepartment,
|
||||
competitionTeams.filter(
|
||||
(team) => {
|
||||
const matchesSearch = searchTerm === "" ||
|
||||
team.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
team.members?.some((member: any) => member.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
const matchesDepartment = selectedDepartment === "all" || team.department === selectedDepartment;
|
||||
|
||||
return matchesSearch && matchesDepartment;
|
||||
}
|
||||
),
|
||||
"團隊賽",
|
||||
)}
|
||||
|
||||
{/* No Results */}
|
||||
{filteredApps.length === 0 && (
|
||||
{filteredApps.length === 0 && competitionTeams.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<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>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
@@ -20,6 +20,13 @@ import {
|
||||
Brain,
|
||||
Zap,
|
||||
ExternalLink,
|
||||
Bot,
|
||||
Code,
|
||||
Database,
|
||||
Palette,
|
||||
Volume2,
|
||||
Search,
|
||||
BarChart3,
|
||||
} from "lucide-react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { LikeButton } from "@/components/like-button"
|
||||
@@ -31,22 +38,68 @@ interface TeamDetailDialogProps {
|
||||
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 {
|
||||
id: appId,
|
||||
name: "",
|
||||
type: "",
|
||||
description: "",
|
||||
icon: null,
|
||||
fullDescription: "",
|
||||
name: "未命名應用",
|
||||
type: "未知類型",
|
||||
description: "無描述",
|
||||
icon: Brain,
|
||||
fullDescription: "無描述",
|
||||
features: [],
|
||||
author: "",
|
||||
category: "",
|
||||
author: "未知作者",
|
||||
category: "未分類",
|
||||
tags: [],
|
||||
demoUrl: "",
|
||||
sourceUrl: "",
|
||||
}
|
||||
likes: 0,
|
||||
views: 0,
|
||||
rating: 0
|
||||
};
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
@@ -67,10 +120,10 @@ export function TeamDetailDialog({ open, onOpenChange, team }: TeamDetailDialogP
|
||||
|
||||
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 appDetails = getAppDetails(appId)
|
||||
const appDetails = getAppDetails(appId, team)
|
||||
// Create app object that matches AppDetailDialog interface
|
||||
const app = {
|
||||
id: Number.parseInt(appId),
|
||||
@@ -81,14 +134,17 @@ export function TeamDetailDialog({ open, onOpenChange, team }: TeamDetailDialogP
|
||||
icon: appDetails.icon,
|
||||
creator: appDetails.author,
|
||||
featured: false,
|
||||
judgeScore: 0,
|
||||
judgeScore: appDetails.rating || 0,
|
||||
likes: appDetails.likes || 0,
|
||||
views: appDetails.views || 0,
|
||||
}
|
||||
setSelectedApp(app)
|
||||
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 (
|
||||
<>
|
||||
@@ -179,13 +235,13 @@ export function TeamDetailDialog({ open, onOpenChange, team }: TeamDetailDialogP
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<label className="text-sm font-medium text-gray-700">聯絡信箱</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
@@ -261,11 +317,12 @@ export function TeamDetailDialog({ open, onOpenChange, team }: TeamDetailDialogP
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{team.apps.map((appId: string) => {
|
||||
const app = getAppDetails(appId)
|
||||
const IconComponent = app.icon
|
||||
const likes = getLikeCount(appId)
|
||||
const views = getViewCount(appId)
|
||||
const rating = getAppRating(appId)
|
||||
const app = getAppDetails(appId, team)
|
||||
// 如果沒有圖標,使用默認的 Brain 圖標
|
||||
const IconComponent = app.icon || Brain
|
||||
const likes = app.likes || 0
|
||||
const views = app.views || 0
|
||||
const rating = app.rating || 0
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -299,11 +356,16 @@ export function TeamDetailDialog({ open, onOpenChange, team }: TeamDetailDialogP
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Star className="w-3 h-3 text-yellow-500" />
|
||||
<span>{rating.toFixed(1)}</span>
|
||||
<span>{Number(rating).toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<LikeButton appId={appId} size="sm" />
|
||||
<LikeButton
|
||||
appId={appId}
|
||||
size="sm"
|
||||
likeCount={likes}
|
||||
showCount={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
@@ -122,6 +122,23 @@ export function CompetitionProvider({ children }: { children: ReactNode }) {
|
||||
const [teams, setTeams] = useState<Team[]>(mockTeams)
|
||||
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
|
||||
const [judgeScores, setJudgeScores] = useState<JudgeScore[]>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -186,6 +203,7 @@ export function CompetitionProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [awards])
|
||||
|
||||
|
||||
const addJudge = (judge: Omit<Judge, "id">) => {
|
||||
const newJudge: Judge = {
|
||||
...judge,
|
||||
|
@@ -89,12 +89,13 @@ CREATE TABLE `competitions` (
|
||||
`month` INT NOT NULL,
|
||||
`start_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,
|
||||
`type` ENUM('individual', 'team', 'mixed', 'proposal') NOT NULL,
|
||||
`evaluation_focus` TEXT,
|
||||
`max_team_size` INT NULL,
|
||||
`is_active` BOOLEAN DEFAULT TRUE,
|
||||
`is_current` BOOLEAN DEFAULT FALSE,
|
||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX `idx_year_month` (`year`, `month`),
|
||||
|
@@ -323,7 +323,9 @@ class DatabaseFailover {
|
||||
async query(sql, params) {
|
||||
const connection = await this.getConnection();
|
||||
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;
|
||||
}
|
||||
finally {
|
||||
@@ -345,7 +347,9 @@ class DatabaseFailover {
|
||||
async insert(sql, params) {
|
||||
const connection = await this.getConnection();
|
||||
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;
|
||||
}
|
||||
finally {
|
||||
@@ -356,7 +360,9 @@ class DatabaseFailover {
|
||||
async update(sql, params) {
|
||||
const connection = await this.getConnection();
|
||||
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;
|
||||
}
|
||||
finally {
|
||||
@@ -367,7 +373,9 @@ class DatabaseFailover {
|
||||
async delete(sql, params) {
|
||||
const connection = await this.getConnection();
|
||||
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;
|
||||
}
|
||||
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
|
||||
async smartDualInsertRelation(relationTable, masterCompetitionId, slaveCompetitionId, relationData, relationIdField) {
|
||||
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() {
|
||||
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
|
||||
async smartDualInsertRelation(
|
||||
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
|
||||
private async getSlaveCompetitionIdByName(name: string): Promise<string | 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> {
|
||||
if (this.masterPool) {
|
||||
|
@@ -616,7 +616,7 @@ export class UserService {
|
||||
const competitionStatsSql = `
|
||||
SELECT
|
||||
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
|
||||
`;
|
||||
const competitionStats = await this.queryOne(competitionStatsSql);
|
||||
@@ -1282,13 +1282,135 @@ export class CompetitionService {
|
||||
|
||||
// 更新競賽
|
||||
static async updateCompetition(id: string, updates: Partial<Competition>): Promise<boolean> {
|
||||
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'created_at');
|
||||
const setClause = fields.map(field => `${field} = ?`).join(', ');
|
||||
const values = fields.map(field => (updates as any)[field]);
|
||||
try {
|
||||
const dbSyncFixed = new DatabaseSyncFixed();
|
||||
|
||||
const sql = `UPDATE competitions SET ${setClause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
|
||||
const result = await db.update(sql, [...values, id]);
|
||||
return result.affectedRows > 0;
|
||||
// 使用雙寫功能更新競賽
|
||||
const result = await dbSyncFixed.smartDualUpdate('competitions', id, updates);
|
||||
|
||||
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[]> {
|
||||
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
|
||||
`;
|
||||
return await db.query(sql, [competitionId]);
|
||||
static async getCompetitionApps(competitionId: string, competitionType?: string): Promise<any[]> {
|
||||
// 先獲取競賽信息
|
||||
const competition = await this.getCompetitionById(competitionId);
|
||||
if (!competition) return [];
|
||||
|
||||
let apps: any[] = [];
|
||||
|
||||
if (competition.type === 'team') {
|
||||
// 對於團隊競賽,獲取所有參賽團隊的應用
|
||||
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)
|
||||
]);
|
||||
|
||||
// 根據日期動態計算競賽狀態
|
||||
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 {
|
||||
...competition,
|
||||
status: calculatedStatus, // 使用計算後的狀態
|
||||
startDate: competition.start_date,
|
||||
endDate: competition.end_date,
|
||||
evaluationFocus: competition.evaluation_focus,
|
||||
@@ -2625,7 +2814,7 @@ export class AppService {
|
||||
const competitionStatsSql = `
|
||||
SELECT
|
||||
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
|
||||
`;
|
||||
const competitionStats = await this.queryOne(competitionStatsSql);
|
||||
|
10
package.json
10
package.json
@@ -15,16 +15,6 @@
|
||||
"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: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",
|
||||
"db:init-slave": "node scripts/init-slave-database.js",
|
||||
"db:sync": "node scripts/sync-database.js",
|
||||
|
Reference in New Issue
Block a user