board/src/components/Board.vue
2025-09-29 19:47:15 +08:00

824 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="scoreboard-container">
<div class="controls">
<div class="left-controls">
<button @click="refreshScoreboard" :disabled="loading">
{{ loading ? '刷新中...' : '刷新排行榜' }}
</button>
<span class="contest-time" v-if="contestStatus">{{ contestStatus }}</span>
</div>
<div class="right-controls">
<span class="last-update">最后更新: {{ lastUpdate }}</span>
<span class="file-info">数据文件: {{ dataFileName }}</span>
</div>
</div>
<table>
<thead>
<tr>
<th>排名</th>
<th>参赛者</th>
<th>通过数</th>
<th>罚时</th>
<th v-for="problem in problems">{{ alphabet[problem] || problem }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(record, index) in (scoreboardData?.slice(pageIndex * config.eachPage, (pageIndex + 1) * config.eachPage) ?? [])"
:key="record.userId"
@click="showUserDetails(record.userId)"
class="clickable-row"
>
<td class="rank">{{ pageIndex * config.eachPage + index + 1 }}</td>
<td class="user-name">{{ record.userName }}</td>
<td class="solved-info">
<div class="solved-count">{{ record.solved }}</div>
</td>
<td class="penalty-info">
<div class="penalty-time">{{ record.penalty }}</div>
</td>
<td
v-for="problem in problems"
:style="genStyle(record.problems?.[problem])"
class="problem-cell"
>
<div class="problem-result">
<div class="time-display">{{ genTime(record.problems?.[problem]) }}</div>
<div class="attempts-display">{{ genAttempts(record.problems?.[problem]) }}</div>
</div>
</td>
</tr>
</tbody>
</table>
<!-- 分页控件 -->
<div class="pagination" v-if="scoreboardData">
<button @click="prevPage" :disabled="pageIndex === 0">上一页</button>
<span>第 {{ pageIndex + 1 }} 页 / 共 {{ totalPages }} 页</span>
<button @click="nextPage" :disabled="pageIndex >= totalPages - 1">下一页</button>
</div>
<!-- 用户提交详情模态框 -->
<div v-if="showDetails" class="modal">
<div class="modal-content">
<h2>{{ selectedUser?.userName }} 的提交记录</h2>
<div class="submissions-list">
<div
v-for="sub in userSubmissions"
:key="sub.id"
class="submission-item"
:class="`result-${sub.result.toLowerCase()}`"
>
<span class="problem-id">题目 {{ sub.problemId }}</span>
<span class="result">{{ getResultText(sub.result) }}</span>
<span class="time">{{ formatTime(sub.submitTime) }}</span>
<span class="contest-time">{{ sub.contestTime }}min</span>
</div>
</div>
<button @click="showDetails = false" class="close-btn">关闭</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
// 类型定义
interface ProblemStatus {
solved: boolean;
penalty: number;
submitCount: number;
acTime: number;
}
interface ScoreboardRecord {
userId: string;
userName: string;
solved: number;
penalty: number;
problems: { [key: string]: ProblemStatus };
}
interface Submission {
id: number;
userId: string;
problemId: string;
submitTime: string;
result: string;
contestTime: number;
}
interface User {
id: string;
name: string;
}
interface Problem {
id: string;
title: string;
}
interface ContestInfo {
name: string;
startTime: string;
endTime: string;
frozenTime?: string;
}
interface ContestData {
contest: ContestInfo;
users: User[];
problems: Problem[];
submissions: Submission[];
}
// 响应式数据
const scoreboardData = ref<ScoreboardRecord[]>([]);
const loading = ref(false);
const lastUpdate = ref('');
const showDetails = ref(false);
const selectedUser = ref<ScoreboardRecord | null>(null);
const userSubmissions = ref<Submission[]>([]);
const pageIndex = ref(0);
const contestData = ref<ContestData>({
contest: {
name: '',
startTime: '',
endTime: ''
},
users: [],
problems: [],
submissions: []
});
// 配置
const config = ref({
eachPage: 20
});
const dataFileName = ref('contest-data.json');
// 字母映射
const alphabet = ref<{ [key: string]: string }>({
'A': 'A', 'B': 'B', 'C': 'C', 'D': 'D', 'E': 'E',
'F': 'F', 'G': 'G', 'H': 'H', 'I': 'I', 'J': 'J'
});
// 计算属性
const problems = computed(() => {
if (contestData.value.problems.length > 0) {
return contestData.value.problems.map(p => p.id);
}
// 如果没有定义problems从提交记录中推断
if (!scoreboardData.value.length) return [];
const problemSet = new Set<string>();
scoreboardData.value.forEach(record => {
Object.keys(record.problems || {}).forEach(problemId => {
problemSet.add(problemId);
});
});
return Array.from(problemSet).sort();
});
const totalPages = computed(() => {
return Math.ceil((scoreboardData.value?.length || 0) / config.value.eachPage);
});
const contestStatus = computed(() => {
const contest = contestData.value.contest;
if (!contest.startTime) return '';
const now = new Date();
const start = new Date(contest.startTime);
const end = new Date(contest.endTime);
if (now < start) {
// 比赛未开始
const diff = start.getTime() - now.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
return `距离比赛开始: ${hours}小时${minutes}分钟`;
} else if (now > end) {
// 比赛已结束
return '比赛已结束';
} else {
// 比赛进行中
const elapsed = now.getTime() - start.getTime();
const remaining = end.getTime() - now.getTime();
const elapsedHours = Math.floor(elapsed / (1000 * 60 * 60));
const elapsedMinutes = Math.floor((elapsed % (1000 * 60 * 60)) / (1000 * 60));
const remainingHours = Math.floor(remaining / (1000 * 60 * 60));
const remainingMinutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
return `已进行: ${elapsedHours}:${elapsedMinutes.toString().padStart(2, '0')} | 剩余: ${remainingHours}:${remainingMinutes.toString().padStart(2, '0')}`;
}
});
// 方法
const loadContestData = async () => {
try {
loading.value = true;
const response = await fetch(`/${dataFileName.value}?t=${Date.now()}`);
const data = await response.json();
contestData.value = data;
calculateScoreboard();
lastUpdate.value = new Date().toLocaleTimeString();
} catch (error) {
console.error('加载比赛数据失败:', error);
} finally {
loading.value = false;
}
};
const calculateScoreboard = () => {
const { users, submissions } = contestData.value;
// 计算每个用户的过题和罚时
const userStats = new Map<string, ScoreboardRecord & { problems: Map<string, ProblemStatus> }>();
users.forEach((user: User) => {
userStats.set(user.id, {
userId: user.id,
userName: user.name,
solved: 0,
penalty: 0,
problems: new Map()
});
});
// 按提交时间排序
const sortedSubmissions = [...submissions].sort((a: Submission, b: Submission) =>
new Date(a.submitTime).getTime() - new Date(b.submitTime).getTime()
);
// 计算AC和罚时
sortedSubmissions.forEach((submission: Submission) => {
const user = userStats.get(submission.userId);
if (!user) return;
const problemKey = submission.problemId;
if (!user.problems.has(problemKey)) {
user.problems.set(problemKey, {
solved: false,
penalty: 0,
submitCount: 0,
acTime: 0
});
}
const problem = user.problems.get(problemKey)!;
if (!problem.solved) {
if (submission.result === 'AC') {
problem.solved = true;
problem.acTime = submission.contestTime;
problem.penalty += submission.contestTime;
user.solved++;
user.penalty += problem.penalty;
} else {
problem.penalty += 20; // 每次错误提交加20分钟罚时
problem.submitCount++;
}
}
});
// 转换为数组并排序
const scoreboard = Array.from(userStats.values()).map(user => ({
userId: user.userId,
userName: user.userName,
solved: user.solved,
penalty: user.penalty,
problems: Object.fromEntries(user.problems)
})).sort((a, b) => {
if (a.solved !== b.solved) return b.solved - a.solved;
return a.penalty - b.penalty;
});
scoreboardData.value = scoreboard;
};
const refreshScoreboard = () => {
loadContestData();
};
const calculateTotalAttempts = (record: ScoreboardRecord) => {
if (!record.problems) return 0;
return Object.values(record.problems).reduce((total, problem) => {
return total + (problem.submitCount || 0);
}, 0);
};
const genStyle = (problem: ProblemStatus | undefined) => {
if (!problem) return { backgroundColor: 'transparent' };
if (problem.solved) {
return {
backgroundColor: '#e8f5e9',
fontWeight: 'bold',
color: '#2e7d32',
border: '1px solid #c8e6c9'
};
} else if (problem.submitCount > 0) {
return {
backgroundColor: '#ffebee',
color: '#c62828',
border: '1px solid #ffcdd2'
};
}
return {
backgroundColor: '#fafafa',
border: '1px solid #e0e0e0'
};
};
const genTime = (problem: ProblemStatus | undefined) => {
if (!problem) return '';
if (problem.solved) {
return problem.acTime.toString();
}
return '';
};
const genAttempts = (problem: ProblemStatus | undefined) => {
if (!problem) return '';
if (problem.solved && problem.submitCount > 0) {
return `+${problem.submitCount}`;
} else if (!problem.solved && problem.submitCount > 0) {
return `-${problem.submitCount}`;
}
return '';
};
const showUserDetails = (userId: string) => {
userSubmissions.value = contestData.value.submissions
.filter(sub => sub.userId === userId)
.sort((a, b) => new Date(b.submitTime).getTime() - new Date(a.submitTime).getTime());
selectedUser.value = scoreboardData.value.find(user => user.userId === userId) || null;
showDetails.value = true;
};
const getResultText = (result: string) => {
const resultMap: { [key: string]: string } = {
'AC': 'Accepted',
'WA': 'Wrong Answer',
'TLE': 'Time Limit Exceeded',
'MLE': 'Memory Limit Exceeded',
'CE': 'Compilation Error',
'PE': 'Presentation Error'
};
return resultMap[result] || result;
};
const formatTime = (timeString: string) => {
return new Date(timeString).toLocaleString();
};
const prevPage = () => {
if (pageIndex.value > 0) {
pageIndex.value--;
}
};
const nextPage = () => {
if (pageIndex.value < totalPages.value - 1) {
pageIndex.value++;
}
};
// 生命周期
let refreshInterval: number;
let timeUpdateInterval: number;
onMounted(() => {
loadContestData();
refreshInterval = setInterval(loadContestData, 5000);
// 每秒更新一次比赛时间状态
timeUpdateInterval = setInterval(() => {
// 强制重新计算比赛状态
contestStatus.value;
}, 1000);
});
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
if (timeUpdateInterval) {
clearInterval(timeUpdateInterval);
}
});
</script>
<style scoped lang="scss">
.scoreboard-container {
padding: 20px;
background: #f5f7fa;
min-height: 100vh;
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 15px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
flex-wrap: wrap;
gap: 10px;
.left-controls {
display: flex;
align-items: center;
gap: 20px;
}
.right-controls {
display: flex;
align-items: center;
gap: 15px;
}
button {
padding: 8px 16px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
&:hover:not(:disabled) {
background: #2980b9;
transform: translateY(-1px);
}
&:disabled {
background: #bdc3c7;
cursor: not-allowed;
transform: none;
}
}
.contest-time {
font-weight: 600;
color: #e74c3c;
font-size: 1em;
padding: 4px 8px;
background: #fff5f5;
border-radius: 4px;
border: 1px solid #ffebee;
}
.last-update, .file-info {
color: #7f8c8d;
font-size: 0.9em;
}
}
table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
tr {
td, th {
text-align: center;
padding: 12px 8px;
border-bottom: 1px solid #ecf0f1;
color: #2c3e50;
// &:nth-child(1) {
// text-align: right;
// padding-right: 16px;
// font-weight: 600;
// color: #34495e;
// }
// &:nth-child(2) {
// text-align: left;
// padding-left: 16px;
// font-weight: 500;
// }
}
}
th {
text-align: left;
background-color: #cee7ff !important;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: white;
font-size: 0.9em;
}
thead {
th {
background-color: #34495e;
// &:nth-child(1) {
// text-align: right;
// }
// &:nth-child(2) {
// text-align: left;
// }
}
}
tbody {
tr {
transition: all 0.3s ease;
&.clickable-row {
cursor: pointer;
&:hover {
background-color: #f8f9fa !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
&:nth-child(even) {
background-color: #f8f9fa;
}
}
td {
position: relative;
transition: all 0.3s ease;
&.problem-cell {
font-weight: 500;
font-family: 'Courier New', monospace;
font-size: 0.95em;
}
&.rank {
font-weight: 600;
color: #2c3e50;
}
&.user-name {
font-weight: 500;
color: #2c3e50;
}
&.solved-info {
.solved-count {
font-size: 1.2em;
font-weight: 700;
color: #27ae60;
}
}
&.penalty-info {
.penalty-time {
font-size: 1em;
font-weight: 600;
color: #e74c3c;
}
.penalty-count {
font-size: 0.8em;
color: #7f8c8d;
margin-top: 2px;
}
}
&:hover {
background-color: #ecf0f1 !important;
}
}
}
.problem-cell .problem-result {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 40px;
}
.problem-cell .time-display {
font-weight: 600;
font-size: 1em;
line-height: 1.2;
}
.problem-cell .attempts-display {
font-size: 0.8em;
line-height: 1.2;
margin-top: 2px;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-top: 20px;
padding: 15px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
button {
padding: 8px 16px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
&:hover:not(:disabled) {
background: #2980b9;
transform: translateY(-1px);
}
&:disabled {
background: #bdc3c7;
cursor: not-allowed;
transform: none;
}
}
span {
color: #2c3e50;
font-weight: 500;
}
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(5px);
}
.modal-content {
background: white;
padding: 30px;
border-radius: 12px;
max-width: 800px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
h2 {
color: #2c3e50;
margin-bottom: 20px;
text-align: center;
font-weight: 600;
border-bottom: 2px solid #ecf0f1;
padding-bottom: 10px;
}
}
.submissions-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.submission-item {
display: grid;
grid-template-columns: 1fr 1.5fr 2fr 1fr;
gap: 15px;
padding: 12px 15px;
border-radius: 6px;
align-items: center;
transition: all 0.3s ease;
border: 1px solid #ecf0f1;
&.result-ac {
background: #e8f5e9;
border-left: 4px solid #4caf50;
}
&.result-wa, &.result-tle, &.result-mle, &.result-pe {
background: #ffebee;
border-left: 4px solid #f44336;
}
&.result-ce {
background: #fff3e0;
border-left: 4px solid #ff9800;
}
.problem-id {
font-weight: 600;
color: #2c3e50;
}
.result {
font-weight: 500;
}
.result-ac .result {
color: #4caf50;
}
.result-wa .result, .result-tle .result, .result-mle .result, .result-pe .result {
color: #f44336;
}
.result-ce .result {
color: #ff9800;
}
.time {
font-size: 0.9em;
color: #7f8c8d;
}
.contest-time {
text-align: right;
font-weight: 600;
font-family: 'Courier New', monospace;
color: #34495e;
}
}
.close-btn {
width: 100%;
padding: 12px;
background: #34495e;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1em;
transition: all 0.3s ease;
font-weight: 500;
&:hover {
background: #2c3e50;
}
}
// 响应式设计
@media (max-width: 768px) {
.controls {
flex-direction: column;
gap: 10px;
text-align: center;
.left-controls, .right-controls {
flex-direction: column;
gap: 10px;
width: 100%;
}
}
table {
font-size: 0.85em;
}
th, td {
padding: 8px 4px;
}
th:nth-child(1), td:nth-child(1) {
padding-right: 8px;
}
th:nth-child(2), td:nth-child(2) {
padding-left: 8px;
}
.submission-item {
grid-template-columns: 1fr 1fr;
gap: 8px;
font-size: 0.9em;
}
}
</style>