init
This commit is contained in:
+252
@@ -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
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user