完成評審評分機制

This commit is contained in:
2025-09-18 18:34:31 +08:00
parent 2101767690
commit ffa1e45f63
54 changed files with 5730 additions and 709 deletions

View File

@@ -699,6 +699,15 @@ export function CompetitionManagement() {
fetchTeamStats()
}, [])
// 當競賽列表載入完成後,載入每個競賽的評分進度
useEffect(() => {
if (competitions.length > 0) {
competitions.forEach(competition => {
loadScoringProgress(competition.id);
});
}
}, [competitions])
// 当筛选条件改变时重置分页
const resetAwardPagination = () => {
setAwardCurrentPage(1)
@@ -1608,80 +1617,235 @@ export function CompetitionManagement() {
setTimeout(() => setSuccess(""), 3000)
}
const handleManualScoring = (competition: any) => {
const handleManualScoring = async (competition: any) => {
setSelectedCompetition(competition)
// 設定初始參賽者類型
let participantType = "individual";
if (competition.type === "mixed") {
setSelectedParticipantType("individual") // 混合賽預設從個人賽開始
participantType = "individual";
} else {
setSelectedParticipantType(competition.type)
participantType = competition.type;
}
// 初始化評分項目
const initialScores = getInitialScores(competition, competition.type === "mixed" ? "individual" : competition.type)
const initialScores = getInitialScores(competition, participantType)
setManualScoring({
judgeId: "",
participantId: "",
participantType: competition.type || "individual",
participantType: participantType,
scores: initialScores,
comments: "",
})
// 載入競賽相關數據
await loadCompetitionDataForScoring(competition)
setShowManualScoring(true)
}
// 載入競賽相關數據用於評分
const loadCompetitionDataForScoring = async (competition: any) => {
try {
console.log('🔍 開始載入競賽評分數據競賽ID:', competition.id)
// 載入競賽評審
const judgesResponse = await fetch(`/api/competitions/${competition.id}/judges`)
const judgesData = await judgesResponse.json()
console.log('🔍 競賽評審API回應:', judgesData)
console.log('🔍 檢查條件:')
console.log(' - judgesData.success:', judgesData.success)
console.log(' - judgesData.data:', !!judgesData.data)
console.log(' - judgesData.data.judges:', !!judgesData.data?.judges)
console.log(' - judgesData.data.judges.length:', judgesData.data?.judges?.length)
if (judgesData.success && judgesData.data && judgesData.data.judges) {
console.log('✅ 競賽評審載入成功:', judgesData.data.judges.length, '個評審')
// 更新評審數據到dbJudges如果需要的話
setDbJudges(prev => {
const existingIds = prev.map(j => j.id)
const newJudges = judgesData.data.judges.filter((j: any) => !existingIds.includes(j.id))
return [...prev, ...newJudges]
})
} else {
console.error('❌ 競賽評審載入失敗:', judgesData.message)
console.error('❌ 詳細錯誤信息:', judgesData.error)
}
// 載入競賽參賽者(應用和團隊)
const [appsResponse, teamsResponse] = await Promise.all([
fetch(`/api/competitions/${competition.id}/apps`),
fetch(`/api/competitions/${competition.id}/teams`)
])
const appsData = await appsResponse.json()
const teamsData = await teamsResponse.json()
console.log('應用API回應:', appsData)
console.log('團隊API回應:', teamsData)
// 更新selectedCompetition以包含載入的數據
const updatedCompetition = { ...competition }
// 添加評審數據到selectedCompetition
if (judgesData.success && judgesData.data && judgesData.data.judges) {
console.log('✅ 競賽評審載入成功添加到selectedCompetition:', judgesData.data.judges.length, '個評審')
updatedCompetition.judges = judgesData.data.judges
} else {
console.error('❌ 競賽評審載入失敗,設置空數組')
updatedCompetition.judges = []
}
if (appsData.success && appsData.data && appsData.data.apps) {
console.log('✅ 競賽應用載入成功:', appsData.data.apps.length, '個應用')
updatedCompetition.apps = appsData.data.apps
} else {
console.error('❌ 競賽應用載入失敗:', appsData.message)
updatedCompetition.apps = []
}
if (teamsData.success && teamsData.data && teamsData.data.teams) {
console.log('✅ 競賽團隊載入成功:', teamsData.data.teams.length, '個團隊')
updatedCompetition.teams = teamsData.data.teams
// 同時更新dbTeams
setDbTeams(prev => {
const existingIds = prev.map(t => t.id)
const newTeams = teamsData.data.teams.filter((t: any) => !existingIds.includes(t.id))
return [...prev, ...newTeams]
})
} else {
console.error('❌ 競賽團隊載入失敗:', teamsData.message)
updatedCompetition.teams = []
}
// 更新selectedCompetition
setSelectedCompetition(updatedCompetition)
} catch (error) {
console.error('❌ 載入競賽評分數據失敗:', error)
}
}
// 獲取初始評分項目的輔助函數
const getInitialScores = (competition: any, participantType: "individual" | "team") => {
const initialScores: Record<string, number> = {}
if (competition.type === "mixed") {
// 混合賽:根據參賽者類型選擇對應的評分規則
const config = participantType === "individual" ? competition.individualConfig : competition.teamConfig
if (config && config.rules && config.rules.length > 0) {
config.rules.forEach((rule: any) => {
initialScores[rule.name] = 0
})
// 獲取實際的評分項目(與顯示邏輯一致)
let currentRules: any[] = [];
if (competition?.type === 'mixed') {
const config = participantType === 'individual'
? competition.individualConfig
: competition.teamConfig;
currentRules = config?.rules || [];
} else {
// 預設評分項目
getDefaultScoringItems(participantType).forEach(item => {
initialScores[item.name] = 0
})
}
} else {
// 單一類型競賽
if (competition.rules && competition.rules.length > 0) {
competition.rules.forEach((rule: any) => {
initialScores[rule.name] = 0
})
} else {
// 預設評分項目
getDefaultScoringItems(participantType).forEach(item => {
initialScores[item.name] = 0
})
}
currentRules = competition?.rules || [];
}
// 如果有自定義規則,使用自定義規則;否則使用預設規則
const scoringItems = currentRules.length > 0
? currentRules
: getDefaultScoringItems(participantType);
scoringItems.forEach(item => {
initialScores[item.name] = 0
})
return initialScores
}
// 計算總分10分制
const calculateTotalScore = () => {
const scores = Object.values(manualScoring.scores);
return scores.reduce((total, score) => total + (score || 0), 0);
};
// 計算100分制總分
const calculateTotalScore100 = () => {
const totalScore = calculateTotalScore();
// 獲取實際的評分項目數量
let currentRules: any[] = [];
if (selectedCompetition?.type === 'mixed') {
const config = selectedParticipantType === 'individual'
? selectedCompetition.individualConfig
: selectedCompetition.teamConfig;
currentRules = config?.rules || [];
} else {
currentRules = selectedCompetition?.rules || [];
}
// 如果有自定義規則,使用自定義規則的數量;否則使用預設規則
const scoringItems = currentRules.length > 0
? currentRules
: getDefaultScoringItems(
selectedCompetition?.type === 'mixed'
? selectedParticipantType
: selectedCompetition?.type || 'individual'
);
const maxScore = scoringItems.length * 10;
return Math.round((totalScore / maxScore) * 100);
};
// 計算加權總分100分制
const calculateWeightedTotalScore100 = () => {
let currentRules: any[] = [];
if (selectedCompetition?.type === 'mixed') {
const config = selectedParticipantType === 'individual'
? selectedCompetition.individualConfig
: selectedCompetition.teamConfig;
currentRules = config?.rules || [];
} else {
currentRules = selectedCompetition?.rules || [];
}
if (currentRules.length === 0) {
// 如果沒有自定義規則,使用預設權重(每個項目權重相等)
return calculateTotalScore100();
}
// 使用自定義權重計算
let weightedTotal = 0;
let totalWeight = 0;
currentRules.forEach((rule: any) => {
const score = manualScoring.scores[rule.name] || 0;
const weight = parseFloat(rule.weight) || 0;
weightedTotal += (score * weight) / 10; // 10分制轉換
totalWeight += weight;
});
// 如果權重總和不是100%,按比例調整
if (totalWeight > 0 && totalWeight !== 100) {
weightedTotal = (weightedTotal * 100) / totalWeight;
}
return Math.round(weightedTotal); // 100分制整數
};
// 獲取預設評分項目
const getDefaultScoringItems = (participantType: "individual" | "team") => {
if (participantType === "team") {
return [
{ name: '團隊合作', description: '團隊協作和溝通能力' },
{ name: '創新性', description: '創新程度和獨特性' },
{ name: '技術性', description: '技術實現的複雜度和品質' },
{ name: '實用性', description: '實際應用價值和用戶體驗' },
{ name: '展示效果', description: '團隊展示的清晰度和吸引力' }
{ name: 'innovation', description: '創新程度和獨特性' },
{ name: 'technical', description: '技術實現的複雜度和品質' },
{ name: 'usability', description: '實際應用價值和用戶體驗' },
{ name: 'presentation', description: '團隊展示的清晰度和吸引力' },
{ name: 'impact', description: '對行業或社會的潛在影響' }
]
} else {
return [
{ name: '創新性', description: '創新程度和獨特性' },
{ name: '技術性', description: '技術實現的複雜度和品質' },
{ name: '實用性', description: '實際應用價值和用戶體驗' },
{ name: '展示效果', description: '展示的清晰度和吸引力' },
{ name: '影響力', description: '對行業或社會的潛在影響' }
{ name: 'innovation', description: '創新程度和獨特性' },
{ name: 'technical', description: '技術實現的複雜度和品質' },
{ name: 'usability', description: '實際應用價值和用戶體驗' },
{ name: 'presentation', description: '展示的清晰度和吸引力' },
{ name: 'impact', description: '對行業或社會的潛在影響' }
]
}
}
@@ -1692,6 +1856,7 @@ export function CompetitionManagement() {
// 重新初始化評分項目
const newScores = getInitialScores(selectedCompetition, newType)
setManualScoring({
...manualScoring,
participantId: "", // 清空選擇的參賽者
@@ -1719,15 +1884,69 @@ export function CompetitionManagement() {
}
setIsLoading(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
try {
// 直接使用競賽規則的評分項目,不需要轉換
// API 會根據 competition_rules 表來驗證和處理評分
const apiScores = { ...manualScoring.scores };
submitJudgeScore({
// 驗證評分是否有效1-10分
const invalidScores = Object.entries(apiScores).filter(([key, value]) => value < 1 || value > 10);
if (invalidScores.length > 0) {
setError(`評分必須在1-10分之間${invalidScores.map(([key]) => key).join(', ')}`);
return;
}
// 根據參賽者類型確定participantType和實際的APP ID
let actualAppId = manualScoring.participantId;
let participantType = 'app'; // 默認為APP評分
// 檢查是否為團隊選擇
const selectedTeam = selectedCompetition?.teams?.find((team: any) => team.id === manualScoring.participantId);
if (selectedTeam) {
// 如果是團隊使用團隊下的第一個APP進行評分
if (selectedTeam.apps && selectedTeam.apps.length > 0) {
actualAppId = selectedTeam.apps[0].id; // 使用團隊下的第一個APP
participantType = 'app'; // 對APP進行評分
} else {
setError("該團隊暫無APP無法進行評分");
return;
}
} else {
// 檢查是否為個人APP
const selectedApp = selectedCompetition?.apps?.find((app: any) => app.id === manualScoring.participantId);
if (selectedApp) {
actualAppId = selectedApp.id;
participantType = 'app';
}
}
const response = await fetch('/api/admin/scoring', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
judgeId: manualScoring.judgeId,
appId: manualScoring.participantId, // Using appId field for all participant types
scores: manualScoring.scores,
participantId: actualAppId, // 使用實際的APP ID
participantType: participantType,
scores: apiScores,
comments: manualScoring.comments.trim(),
})
competitionId: selectedCompetition?.id // 添加競賽ID
})
})
const data = await response.json()
if (data.success) {
const selectedTeam = selectedCompetition?.teams?.find((team: any) => team.id === manualScoring.participantId);
const successMessage = selectedTeam
? `團隊「${selectedTeam.name}」的APP評分提交成功`
: "APP評分提交成功";
setSuccess(successMessage)
// 重置表單
setManualScoring({
judgeId: "",
participantId: "",
@@ -1742,9 +1961,17 @@ export function CompetitionManagement() {
comments: "",
})
setSuccess("評分提交成功!")
setShowManualScoring(false)
} else {
setError(data.message || "評分提交失敗")
}
} catch (err) {
console.error('評分提交失敗:', err)
setError("評分提交失敗,請重試")
} finally {
setIsLoading(false)
setTimeout(() => setSuccess(""), 3000)
}
}
const handleViewCompetition = (competition: any) => {
@@ -1992,25 +2219,27 @@ export function CompetitionManagement() {
}
}
const getScoringProgress = (competitionId: string) => {
const competition = competitions.find((c) => c.id === competitionId)
if (!competition) return { completed: 0, total: 0, percentage: 0 }
const [scoringProgress, setScoringProgress] = useState<Record<string, { completed: number; total: number; percentage: number }>>({});
const participantCount = getParticipantCount(competition)
const judgesCount = competition.judges?.length || 0
const totalExpected = judgesCount * participantCount
const completed = judgeScores.filter((score) => {
const individualParticipants = competition.participatingApps || []
const teamParticipants = competition.participatingTeams || []
const allParticipants = [...individualParticipants, ...teamParticipants]
return allParticipants.includes(score.appId) && (competition.judges || []).includes(score.judgeId)
}).length
return {
completed,
total: totalExpected,
percentage: totalExpected > 0 ? Math.round((completed / totalExpected) * 100) : 0,
// 載入評分進度數據
const loadScoringProgress = async (competitionId: string) => {
try {
const response = await fetch(`/api/competitions/scoring-progress?competitionId=${competitionId}`);
const data = await response.json();
if (data.success) {
setScoringProgress(prev => ({
...prev,
[competitionId]: data.data
}));
}
} catch (error) {
console.error('載入評分進度失敗:', error);
}
};
const getScoringProgress = (competitionId: string) => {
return scoringProgress[competitionId] || { completed: 0, total: 0, percentage: 0 };
}
const getStatusColor = (status: string) => {
@@ -5581,33 +5810,39 @@ export function CompetitionManagement() {
<SelectValue placeholder="選擇評審" />
</SelectTrigger>
<SelectContent>
{/* 混合賽時根據參賽者類型過濾評審 */}
{selectedCompetition?.type === 'mixed' ? (
(dbJudges.length > 0 ? dbJudges : judges).filter(judge => {
const config = selectedParticipantType === 'individual'
? selectedCompetition.individualConfig
: selectedCompetition.teamConfig;
return config?.judges?.includes(judge.id) || false;
}).map((judge) => (
<SelectItem key={judge.id} value={judge.id}>
{judge.name} - {judge.expertise}
{/* 顯示競賽綁定的評審 */}
{(() => {
// 使用競賽綁定的評審,而不是所有評審
const competitionJudges = selectedCompetition?.judges || [];
console.log('🔍 競賽綁定的評審:', competitionJudges);
console.log('🔍 selectedCompetition:', selectedCompetition);
if (competitionJudges.length === 0) {
return (
<SelectItem value="" disabled>
<span className="text-gray-500"></span>
</SelectItem>
))
) : (
(dbJudges.length > 0 ? dbJudges : judges).filter(judge => selectedCompetition?.judges?.includes(judge.id) || false).map((judge) => (
);
}
return competitionJudges.map((judge) => (
<SelectItem key={judge.id} value={judge.id}>
{judge.name} - {judge.expertise}
<div className="flex items-center space-x-2">
<User className="w-4 h-4 text-blue-600" />
<span>{judge.name}</span>
<span className="text-xs text-gray-500">({judge.title || judge.expertise})</span>
</div>
</SelectItem>
))
)}
));
})()}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>
{selectedCompetition?.type === 'mixed'
? (selectedParticipantType === 'individual' ? '選擇個人' : '選擇團隊')
: (selectedCompetition?.type === 'team' ? '選擇團隊' : '選擇個人')
? (selectedParticipantType === 'individual' ? '選擇個人APP' : '選擇團隊APP')
: (selectedCompetition?.type === 'team' ? '選擇團隊APP' : '選擇個人APP')
}
</Label>
<Select
@@ -5617,31 +5852,70 @@ export function CompetitionManagement() {
<SelectTrigger>
<SelectValue placeholder={
selectedCompetition?.type === 'mixed'
? (selectedParticipantType === 'individual' ? '選擇個人' : '選擇團隊')
: (selectedCompetition?.type === 'team' ? '選擇團隊' : '選擇個人')
? (selectedParticipantType === 'individual' ? '選擇個人APP' : '選擇團隊APP')
: (selectedCompetition?.type === 'team' ? '選擇團隊APP' : '選擇個人APP')
} />
</SelectTrigger>
<SelectContent>
{/* 根據競賽類型和選擇的參賽者類型顯示參賽者 */}
{(selectedCompetition?.type === 'individual' ||
(selectedCompetition?.type === 'mixed' && selectedParticipantType === 'individual')) &&
mockIndividualApps
.filter(app => selectedCompetition.participatingApps?.includes(app.id))
.map((app) => (
(() => {
// 從API載入的應用數據
const apps = selectedCompetition?.apps || []
return apps.length > 0 ? apps.map((app: any) => (
<SelectItem key={app.id} value={app.id}>
{app.name} - {app.creator}
<div className="flex items-center space-x-2">
<User className="w-4 h-4 text-blue-600" />
<span>{app.name}</span>
<span className="text-xs text-gray-500">({app.creator})</span>
</div>
</SelectItem>
))
)) : (
<SelectItem value="" disabled>
<span className="text-gray-500"></span>
</SelectItem>
)
})()
}
{(selectedCompetition?.type === 'team' ||
(selectedCompetition?.type === 'mixed' && selectedParticipantType === 'team')) &&
teams
.filter(team => selectedCompetition.participatingTeams?.includes(team.id))
.map((team) => (
(() => {
// 使用selectedCompetition.teams數據顯示為「團隊名 - APP名」格式
const teamsData = selectedCompetition?.teams || []
return teamsData.length > 0 ? teamsData.map((team: any) => {
// 檢查團隊是否有APP
const teamApps = team.apps || []
if (teamApps.length === 0) {
return (
<SelectItem key={team.id} value={team.id} disabled>
<div className="flex items-center space-x-2">
<Users className="w-4 h-4 text-gray-400" />
<span className="text-gray-400">{team.name}</span>
<span className="text-xs text-gray-400">(APP)</span>
</div>
</SelectItem>
)
}
// 顯示團隊和其第一個APP
const firstApp = teamApps[0]
return (
<SelectItem key={team.id} value={team.id}>
{team.name} - {team.leader}
<div className="flex items-center space-x-2">
<Users className="w-4 h-4 text-green-600" />
<span>{team.name}</span>
<span className="text-gray-400">-</span>
<span className="text-blue-600">{firstApp.name}</span>
</div>
</SelectItem>
))
)
}) : (
<SelectItem value="" disabled>
<span className="text-gray-500"></span>
</SelectItem>
)
})()
}
</SelectContent>
</Select>
@@ -5652,11 +5926,74 @@ export function CompetitionManagement() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<Label className="text-lg font-medium"></Label>
<div className="flex items-center space-x-4">
{selectedCompetition?.type === 'mixed' && (
<Badge variant="outline" className="text-sm">
{selectedParticipantType === 'individual' ? '個人賽評分' : '團體賽評分'}
</Badge>
)}
{/* 總分顯示 */}
<div className="bg-gray-50 border border-gray-200 rounded-lg px-4 py-3">
<div className="flex items-center space-x-3">
<div className="text-sm text-gray-600"></div>
<div className="text-2xl font-bold text-gray-800">
{calculateTotalScore()}
</div>
<div className="text-sm text-gray-500">/ {(() => {
let currentRules: any[] = [];
if (selectedCompetition?.type === 'mixed') {
const config = selectedParticipantType === 'individual'
? selectedCompetition.individualConfig
: selectedCompetition.teamConfig;
currentRules = config?.rules || [];
} else {
currentRules = selectedCompetition?.rules || [];
}
const scoringItems = currentRules.length > 0
? currentRules
: getDefaultScoringItems(
selectedCompetition?.type === 'mixed'
? selectedParticipantType
: selectedCompetition?.type || 'individual'
);
return scoringItems.length * 10;
})()}</div>
</div>
{/* 整體進度條 */}
<div className="mt-2">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-gray-600 h-2 rounded-full transition-all duration-500"
style={{
width: `${calculateTotalScore100()}%`
}}
></div>
</div>
<div className="text-xs text-gray-500 mt-1">
: {calculateTotalScore100()}%
</div>
</div>
{/* 加權總分顯示 */}
{(() => {
const weightedTotal = calculateWeightedTotalScore100();
const simpleTotal = calculateTotalScore100();
const hasCustomWeights = selectedCompetition?.rules?.some((rule: any) => rule.weight) ||
selectedCompetition?.individualConfig?.rules?.some((rule: any) => rule.weight) ||
selectedCompetition?.teamConfig?.rules?.some((rule: any) => rule.weight);
if (hasCustomWeights && weightedTotal !== simpleTotal) {
return (
<div className="text-xs text-gray-600 mt-2 font-medium">
: {weightedTotal}
</div>
);
}
return null;
})()}
</div>
</div>
</div>
{/* 動態顯示競賽的評分項目 */}
@@ -5686,17 +6023,29 @@ export function CompetitionManagement() {
return scoringItems.map((item: any, index: number) => (
<div key={index} className="space-y-3">
<div className="flex justify-between items-center">
<div>
<div className="flex-1">
<Label className="text-base font-medium">{item.name}</Label>
<p className="text-sm text-gray-600 mt-1">{item.description}</p>
{item.weight && (
<p className="text-xs text-purple-600 mt-1">{item.weight}%</p>
)}
{/* 進度條 */}
<div className="mt-2">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-gray-500 h-2 rounded-full transition-all duration-300"
style={{
width: `${((manualScoring.scores[item.name] || 0) / 10) * 100}%`
}}
></div>
</div>
<div className="text-right">
<span className="text-lg font-bold">
{manualScoring.scores[item.name] || 0} / 10
</div>
</div>
<div className="text-right ml-4">
<span className="text-lg font-bold text-gray-800">
{manualScoring.scores[item.name] || 0}
</span>
<span className="text-sm text-gray-500">/ 10</span>
</div>
</div>
@@ -5712,8 +6061,8 @@ export function CompetitionManagement() {
})}
className={`w-10 h-10 rounded-lg border-2 font-medium transition-all ${
(manualScoring.scores[item.name] || 0) === score
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400 hover:bg-blue-50'
? 'bg-gray-700 text-white border-gray-700'
: 'bg-white text-gray-700 border-gray-300 hover:border-gray-500 hover:bg-gray-50'
}`}
>
{score}