This commit is contained in:
hanyixuanten
2026-08-25 20:51:48 +08:00
commit 3eadb2540b
3 changed files with 472 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
const vscode = require('vscode');
const fs = require('fs');
const path = require('path');
const https = require('https');
const http = require('http');
// 本地化翻译表(与 PHP 版本一致)
const translations = {
zh: {
htmlLang: 'zh-CN',
pageTitle: '信息竞赛日程',
heading: '信息竞赛日程',
upcomingTitle: '即将到来的信息竞赛',
finishedTitle: '已结束的信息竞赛',
loadError: '暂时无法加载赛事数据,请稍后再试。',
finishedLoadError: '暂时无法加载已结束赛事数据,请稍后再试。',
emptyUpcoming: '暂无即将开始的赛事。',
emptyFinished: '暂无最近结束的赛事。',
ended: '已结束',
running: '进行中',
timezoneLabel: '时区',
lastUpdatedLabel: '最后更新时间',
day: '天',
hour: '时',
minute: '分',
second: '秒'
},
en: {
htmlLang: 'en',
pageTitle: 'OI Contest Schedule',
heading: 'OI Contest Schedule',
upcomingTitle: 'Upcoming Contests',
finishedTitle: 'Finished Contests',
loadError: 'Contest data is temporarily unavailable. Please try again later.',
finishedLoadError: 'Finished contest data is temporarily unavailable. Please try again later.',
emptyUpcoming: 'No upcoming contests.',
emptyFinished: 'No recently finished contests.',
ended: 'Ended',
running: 'Running',
timezoneLabel: 'Time zone',
lastUpdatedLabel: 'Last updated',
day: 'd',
hour: 'h',
minute: 'm',
second: 's'
}
};
function normalizeLanguage(lang) {
lang = (lang || '').toLowerCase();
if (lang.startsWith('zh')) return 'zh';
if (lang.startsWith('en')) return 'en';
return 'en';
}
function detectLanguage() {
// 优先使用 VS Code 语言设置
const configLang = vscode.env.language;
return normalizeLanguage(configLang);
}
function detectTimezone() {
// 从配置读取,若为空则尝试系统时区
const config = vscode.workspace.getConfiguration('oiContestSchedule');
const configuredTz = config.get('timezone', '');
if (configuredTz) {
try {
// 验证时区是否有效
new Intl.DateTimeFormat('en', { timeZone: configuredTz });
return configuredTz;
} catch (e) {
console.warn(`Invalid timezone: ${configuredTz}, falling back to system timezone.`);
}
}
// 自动检测系统时区
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (e) {
return 'UTC';
}
}
// 获取远程 JSON(支持 http/https,带缓存)
function fetchWithCache(url, cacheMinutes) {
return new Promise((resolve, reject) => {
const cacheFile = path.join(__dirname, '.cache', 'contests.json');
const cacheDir = path.dirname(cacheFile);
// 检查缓存
if (cacheMinutes > 0 && fs.existsSync(cacheFile)) {
const stat = fs.statSync(cacheFile);
const ageMinutes = (Date.now() - stat.mtimeMs) / 60000;
if (ageMinutes < cacheMinutes) {
fs.readFile(cacheFile, 'utf8', (err, data) => {
if (!err) return resolve(data);
});
return;
}
}
// 发起请求
const client = url.startsWith('https') ? https : http;
const req = client.get(url, (res) => {
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
let rawData = '';
res.on('data', chunk => rawData += chunk);
res.on('end', () => {
// 尝试解析 JSON 验证
try {
JSON.parse(rawData);
// 写入缓存
if (cacheMinutes > 0) {
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFile(cacheFile, rawData, err => {});
}
resolve(rawData);
} catch (e) {
// JSON 无效,尝试读取旧缓存
if (fs.existsSync(cacheFile)) {
fs.readFile(cacheFile, 'utf8', (err, data) => resolve(data));
} else {
reject(e);
}
}
});
});
req.on('error', (err) => {
// 网络错误,尝试读旧缓存
if (fs.existsSync(cacheFile)) {
fs.readFile(cacheFile, 'utf8', (readErr, data) => {
if (!readErr) resolve(data);
else reject(err);
});
} else {
reject(err);
}
});
req.setTimeout(20000, () => req.destroy());
});
}
function parseContestPayload(json) {
const data = JSON.parse(json);
if (!data || typeof data !== 'object') return null;
let contests = [];
let generatedAt = 0;
if (Array.isArray(data.contests)) {
contests = data.contests;
generatedAt = data.generated_at || 0;
} else if (Array.isArray(data)) {
contests = data;
} else {
return null;
}
return { contests, generatedAt };
}
function splitContests(all) {
const upcoming = [];
const finished = [];
for (const c of all) {
if (c.status === 'finished') finished.push(c);
else upcoming.push(c);
}
upcoming.sort((a, b) => (a.start_time || 0) - (b.start_time || 0));
finished.sort((a, b) => (a.end_time || 0) - (b.end_time || 0));
return { upcoming, finished };
}
function getWebviewContent(webview, context, payload) {
const { contests, generatedAt } = payload;
const lang = detectLanguage();
const t = translations[lang];
const timezone = detectTimezone();
// 将数据注入到 HTML 中
const dataScript = `
window.contestsData = ${JSON.stringify(contests)};
window.generatedAt = ${JSON.stringify(generatedAt)};
window.translations = ${JSON.stringify(t)};
window.serverLanguage = ${JSON.stringify(lang)};
window.serverTimezone = ${JSON.stringify(timezone)};
`;
// 读取 webview.html 并替换占位符
const htmlPath = path.join(context.extensionPath, 'media', 'webview.html');
const html = fs.readFileSync(htmlPath, 'utf8');
return html.replace('<!-- DATA_SCRIPT -->', `<script>${dataScript}</script>`);
}
function activate(context) {
console.log('OI Contest Schedule extension activated');
let disposable = vscode.commands.registerCommand('oiContestSchedule.show', async () => {
// 创建 Webview Panel
const panel = vscode.window.createWebviewPanel(
'oiContestSchedule',
'OI Contest Schedule',
vscode.ViewColumn.One,
{
enableScripts: true,
localResourceRoots: [vscode.Uri.file(path.join(context.extensionPath, 'media'))]
}
);
// 设置初始内容
const config = vscode.workspace.getConfiguration('oiContestSchedule');
const jsonUrl = config.get('jsonUrl', 'https://raw.githubusercontent.com/hanyixuanten/OI-contest-fetch/master/contests_all.json');
const cacheMinutes = config.get('cacheMinutes', 5);
panel.webview.html = '<p>Loading...</p>';
try {
const json = await fetchWithCache(jsonUrl, cacheMinutes);
const payload = parseContestPayload(json);
if (!payload) throw new Error('Invalid JSON payload');
const { upcoming, finished } = splitContests(payload.contests);
const data = {
contests: { upcoming, finished },
generatedAt: payload.generatedAt
};
panel.webview.html = getWebviewContent(panel.webview, context, data);
} catch (err) {
vscode.window.showErrorMessage(`Failed to load contest data: ${err.message}`);
panel.webview.html = `<p>Error loading data.</p>`;
}
// 监听配置变化,自动刷新
const configListener = vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('oiContestSchedule')) {
// 简化:提示用户重新打开
vscode.window.showInformationMessage('OI Contest Schedule configuration changed. Please reopen the view.');
}
});
panel.onDidDispose(() => {
configListener.dispose();
});
});
context.subscriptions.push(disposable);
}
function deactivate() {}
module.exports = {
activate,
deactivate
};
+178
View File
@@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OI Contest Schedule</title>
<style>
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #f5f7fa; margin: 0; padding: 20px; }
.container { max-width: 900px; margin: 0 auto; }
h1 { text-align: center; color: #2c3e50; }
.meta { text-align: center; color: #7f8c8d; font-size: 13px; margin-top: 4px; }
.card {
background: white; border-radius: 12px; padding: 20px; margin: 15px 0;
box-shadow: 0 4px 12px rgba(0,0,0,0.08); display: flex; justify-content: space-between;
align-items: center; flex-wrap: wrap;
}
.platform {
font-weight: 700; color: white; padding: 4px 12px; border-radius: 20px;
font-size: 14px; background: #3498db;
}
.platform.cf { background: #1f8acb; }
.platform.atc { background: #5b8c5a; }
.platform.uoj { background: #c0392b; }
.platform.nowcoder { background: #00a6a6; }
.info { flex: 1; margin-left: 15px; }
.title { font-size: 18px; font-weight: 600; color: #2c3e50; }
.time { font-size: 14px; color: #7f8c8d; margin-top: 5px; }
.countdown { font-weight: 700; color: #e74c3c; margin-left: auto; min-width: 120px; text-align: right; }
.status-ended { font-weight: 700; color: #95a5a6; margin-left: auto; min-width: 120px; text-align: right; }
.section-title { color: #2c3e50; margin: 35px 0 10px; border-bottom: 1px solid #dfe6e9; padding-bottom: 8px; }
a { text-decoration: none; color: inherit; }
.error { text-align: center; color: #e74c3c; padding: 20px; }
</style>
</head>
<body>
<div class="container">
<h1>📅 <span id="heading"></span></h1>
<div class="meta" id="timezone-meta"></div>
<div class="meta" id="last-updated-meta"></div>
<h2 class="section-title" id="upcoming-title"></h2>
<div id="upcoming-container"></div>
<h2 class="section-title" id="finished-title"></h2>
<div id="finished-container"></div>
</div>
<!-- DATA_SCRIPT -->
<script>
// 从注入的全局变量获取数据
const translations = window.translations;
const serverLanguage = window.serverLanguage;
const serverTimezone = window.serverTimezone;
const contestsData = window.contestsData;
const generatedAt = window.generatedAt;
// 设置本地化文本
document.getElementById('heading').textContent = translations.heading;
document.getElementById('upcoming-title').textContent = translations.upcomingTitle;
document.getElementById('finished-title').textContent = translations.finishedTitle;
document.getElementById('timezone-meta').textContent = translations.timezoneLabel + ': ' + serverTimezone;
if (generatedAt) {
document.getElementById('last-updated-meta').textContent = translations.lastUpdatedLabel + ': ' + formatDateTimeWithSeconds(generatedAt);
}
// 格式化函数
function formatDateTime(ts) {
if (window.Intl && Intl.DateTimeFormat) {
return new Intl.DateTimeFormat(serverLanguage === 'en' ? 'en-US' : 'zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
timeZone: serverTimezone
}).format(new Date(ts * 1000));
}
return '';
}
function formatDateTimeWithSeconds(ts) {
if (window.Intl && Intl.DateTimeFormat) {
return new Intl.DateTimeFormat(serverLanguage === 'en' ? 'en-US' : 'zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
timeZone: serverTimezone
}).format(new Date(ts * 1000));
}
return '';
}
// 平台样式类
const platformClassMap = {
'Codeforces': 'cf',
'AtCoder': 'atc',
'UOJ': 'uoj',
'Nowcoder': 'nowcoder'
};
function platformClass(platform) { return platformClassMap[platform] || ''; }
// 创建卡片
function createCard(contest, isFinished) {
const card = document.createElement('a');
card.className = 'card';
card.href = contest.url || '#';
card.target = '_blank';
const platformSpan = document.createElement('span');
platformSpan.className = 'platform ' + platformClass(contest.platform);
platformSpan.textContent = contest.platform;
card.appendChild(platformSpan);
const infoDiv = document.createElement('div');
infoDiv.className = 'info';
const titleDiv = document.createElement('div');
titleDiv.className = 'title';
titleDiv.textContent = contest.title;
const timeDiv = document.createElement('div');
timeDiv.className = 'time';
timeDiv.textContent = formatDateTime(contest.start_time) + ' ~ ' + formatDateTime(contest.end_time);
infoDiv.appendChild(titleDiv);
infoDiv.appendChild(timeDiv);
card.appendChild(infoDiv);
if (isFinished) {
const endedDiv = document.createElement('div');
endedDiv.className = 'status-ended';
endedDiv.textContent = translations.ended;
card.appendChild(endedDiv);
} else {
const countdownDiv = document.createElement('div');
countdownDiv.className = 'countdown';
countdownDiv.setAttribute('data-start', contest.start_time);
card.appendChild(countdownDiv);
}
return card;
}
function renderContests() {
const upcomingContainer = document.getElementById('upcoming-container');
const finishedContainer = document.getElementById('finished-container');
upcomingContainer.innerHTML = '';
finishedContainer.innerHTML = '';
if (!contestsData.upcoming || contestsData.upcoming.length === 0) {
upcomingContainer.innerHTML = '<p style="text-align:center">' + translations.emptyUpcoming + '</p>';
} else {
contestsData.upcoming.forEach(c => upcomingContainer.appendChild(createCard(c, false)));
}
if (!contestsData.finished || contestsData.finished.length === 0) {
finishedContainer.innerHTML = '<p style="text-align:center">' + translations.emptyFinished + '</p>';
} else {
// 按照 PHP 逻辑:已结束的逆序显示
[...contestsData.finished].reverse().forEach(c => finishedContainer.appendChild(createCard(c, true)));
}
}
// 倒计时更新
function calcCountdown(startTs) {
const diff = startTs * 1000 - Date.now();
if (diff <= 0) return translations.running;
const d = Math.floor(diff / 86400000);
const h = Math.floor((diff % 86400000) / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
return d + translations.day + ' ' + h + translations.hour + ' ' + m + translations.minute + ' ' + s + translations.second;
}
function updateCountdowns() {
document.querySelectorAll('.countdown').forEach(el => {
const start = parseInt(el.getAttribute('data-start'));
el.textContent = calcCountdown(start);
});
}
renderContests();
setInterval(updateCountdowns, 1000);
updateCountdowns();
</script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{
"name": "oi-contest-schedule",
"displayName": "OI Contest Schedule",
"description": "View upcoming and finished OI contests in VS Code",
"version": "1.0.0",
"publisher": "your-name",
"engines": {
"vscode": "^1.60.0"
},
"categories": ["Other"],
"activationEvents": [],
"main": "./extension.js",
"contributes": {
"commands": [
{
"command": "oiContestSchedule.show",
"title": "OI Contest Schedule: Show"
}
],
"configuration": {
"title": "OI Contest Schedule",
"properties": {
"oiContestSchedule.jsonUrl": {
"type": "string",
"default": "https://raw.githubusercontent.com/hanyixuanten/OI-contest-fetch/master/contests_all.json",
"description": "URL of the contests JSON feed"
},
"oiContestSchedule.cacheMinutes": {
"type": "number",
"default": 5,
"minimum": 0,
"description": "Cache lifetime in minutes (0 to disable cache)"
},
"oiContestSchedule.timezone": {
"type": "string",
"default": "",
"description": "Timezone (IANA name, e.g. Asia/Shanghai). Leave empty to auto-detect from system."
}
}
}
}
}