392 lines
13 KiB
JavaScript
392 lines
13 KiB
JavaScript
const { invoke } = window.__TAURI__.core;
|
||
const { getCurrentWindow } = window.__TAURI__.window;
|
||
|
||
const GAP = 1;
|
||
const WINDOW_WIDTH = 600;
|
||
const LAYOUT_WEEKS = 53;
|
||
const SETTINGS_HEIGHT = 350;
|
||
|
||
let config = { username: 'shumengya', token: '', window_x: null, window_y: null, theme: 'dark' };
|
||
let isPinned = false;
|
||
let moveTimer = null;
|
||
let cellHitMap = [];
|
||
let lastHeatmapData = null;
|
||
|
||
function getHeatmapColors() {
|
||
const style = getComputedStyle(document.body);
|
||
return [0, 1, 2, 3, 4].map((level) => style.getPropertyValue(`--hm-${level}`).trim());
|
||
}
|
||
|
||
function computeHeatmapLayout() {
|
||
const area = document.getElementById('heatmap-area');
|
||
const widget = document.querySelector('.widget');
|
||
const ws = widget ? getComputedStyle(widget) : null;
|
||
const padX = ws ? parseFloat(ws.paddingLeft) + parseFloat(ws.paddingRight) : 10;
|
||
const availW = (area && area.clientWidth > 0) ? area.clientWidth : (WINDOW_WIDTH - padX);
|
||
const LEFT = 0;
|
||
const TOP = 0;
|
||
const gridW = availW;
|
||
|
||
let step = Math.floor((gridW + GAP) / LAYOUT_WEEKS);
|
||
if (step < GAP + 2) step = GAP + 2;
|
||
|
||
const cell = step - GAP;
|
||
const cssW = availW;
|
||
const cssH = 7 * step - GAP;
|
||
|
||
return {
|
||
LEFT,
|
||
TOP,
|
||
STEP: step,
|
||
CELL: cell,
|
||
cssW,
|
||
cssH,
|
||
radius: Math.max(1, Math.round(cell * 0.22)),
|
||
};
|
||
}
|
||
|
||
function computeWidgetHeight() {
|
||
const { cssH } = computeHeatmapLayout();
|
||
const header = document.querySelector('.header');
|
||
const widget = document.querySelector('.widget');
|
||
const ws = widget ? getComputedStyle(widget) : null;
|
||
const padY = ws ? parseFloat(ws.paddingTop) + parseFloat(ws.paddingBottom) : 7;
|
||
const borderY = ws ? parseFloat(ws.borderTopWidth) + parseFloat(ws.borderBottomWidth) : 2;
|
||
const headerH = header ? header.offsetHeight : 20;
|
||
return Math.ceil(borderY + padY + headerH + cssH);
|
||
}
|
||
|
||
async function ensureWidgetSize() {
|
||
await setWindowHeight(computeWidgetHeight());
|
||
}
|
||
|
||
function applyTheme(theme, rerender = true) {
|
||
config.theme = theme === 'light' ? 'light' : 'dark';
|
||
document.body.classList.toggle('theme-light', config.theme === 'light');
|
||
const select = document.getElementById('input-theme');
|
||
if (select) select.value = config.theme;
|
||
if (rerender && lastHeatmapData) {
|
||
renderHeatmap(lastHeatmapData);
|
||
}
|
||
}
|
||
|
||
async function persistTheme() {
|
||
try {
|
||
await invoke('save_config', { config });
|
||
} catch (_) {}
|
||
}
|
||
|
||
async function toggleTheme() {
|
||
applyTheme(config.theme === 'light' ? 'dark' : 'light');
|
||
await persistTheme();
|
||
}
|
||
|
||
function levelOf(n) {
|
||
if (n === 0) return 0;
|
||
if (n <= 3) return 1;
|
||
if (n <= 8) return 2;
|
||
if (n <= 15) return 3;
|
||
return 4;
|
||
}
|
||
|
||
function localDateStr(date) {
|
||
const y = date.getFullYear();
|
||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||
const d = String(date.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${d}`;
|
||
}
|
||
|
||
function showLoading() {
|
||
document.getElementById('status-loading').style.display = 'flex';
|
||
document.getElementById('status-error').style.display = 'none';
|
||
document.getElementById('heatmap-canvas').style.display = 'none';
|
||
document.getElementById('stats-display').textContent = '加载中...';
|
||
}
|
||
|
||
function showError(msg) {
|
||
document.getElementById('status-loading').style.display = 'none';
|
||
document.getElementById('status-error').style.display = 'flex';
|
||
document.getElementById('error-text').textContent = msg;
|
||
document.getElementById('heatmap-canvas').style.display = 'none';
|
||
document.getElementById('stats-display').textContent = '加载失败';
|
||
}
|
||
|
||
function renderHeatmap(calendar) {
|
||
lastHeatmapData = calendar;
|
||
document.getElementById('status-loading').style.display = 'none';
|
||
document.getElementById('status-error').style.display = 'none';
|
||
|
||
const total = calendar.totalContributions;
|
||
document.getElementById('stats-display').textContent =
|
||
`最近一年贡献 ${total.toLocaleString()} 次`;
|
||
|
||
const canvas = document.getElementById('heatmap-canvas');
|
||
canvas.style.display = 'block';
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
const weeks = calendar.weeks.slice(0, LAYOUT_WEEKS);
|
||
const { LEFT, TOP, STEP, CELL, cssW, cssH, radius } = computeHeatmapLayout();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
|
||
canvas.style.width = `${cssW}px`;
|
||
canvas.style.height = `${cssH}px`;
|
||
document.getElementById('heatmap-area').style.minHeight = `${cssH}px`;
|
||
canvas.width = Math.round(cssW * dpr);
|
||
canvas.height = Math.round(cssH * dpr);
|
||
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.clearRect(0, 0, cssW, cssH);
|
||
|
||
// Draw cells and build hit map
|
||
cellHitMap = [];
|
||
const colors = getHeatmapColors();
|
||
weeks.forEach((week, wi) => {
|
||
week.contributionDays.forEach((day) => {
|
||
const x = LEFT + wi * STEP;
|
||
const y = TOP + day.weekday * STEP;
|
||
const color = colors[levelOf(day.contributionCount)];
|
||
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.roundRect(x, y, CELL, CELL, radius);
|
||
ctx.fill();
|
||
|
||
cellHitMap.push({ x, y, date: day.date, count: day.contributionCount });
|
||
});
|
||
});
|
||
|
||
// Tooltip
|
||
const tooltip = document.getElementById('tooltip');
|
||
canvas.onmousemove = (e) => {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const mx = e.clientX - rect.left;
|
||
const my = e.clientY - rect.top;
|
||
const hit = cellHitMap.find(c => mx >= c.x && mx < c.x + CELL && my >= c.y && my < c.y + CELL);
|
||
if (hit) {
|
||
const dateObj = new Date(hit.date + 'T12:00:00');
|
||
const fmt = dateObj.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' });
|
||
tooltip.textContent = hit.count === 0 ? `${fmt} · 无贡献` : `${fmt} · ${hit.count} 次贡献`;
|
||
tooltip.style.display = 'block';
|
||
tooltip.style.left = `${e.clientX + 12}px`;
|
||
tooltip.style.top = `${e.clientY - 34}px`;
|
||
} else {
|
||
tooltip.style.display = 'none';
|
||
}
|
||
};
|
||
canvas.onmouseleave = () => { tooltip.style.display = 'none'; };
|
||
|
||
ensureWidgetSize().catch(() => {});
|
||
}
|
||
|
||
async function fetchData() {
|
||
if (!config.token) {
|
||
showError('请点击 ⚙ 设置 GitHub Token(需 read:user 权限)');
|
||
return;
|
||
}
|
||
showLoading();
|
||
try {
|
||
const result = await invoke('fetch_github_contributions', {
|
||
username: config.username,
|
||
token: config.token,
|
||
});
|
||
|
||
if (result.errors?.length) {
|
||
showError(result.errors[0].message);
|
||
return;
|
||
}
|
||
|
||
const calendar = result?.data?.user?.contributionsCollection?.contributionCalendar;
|
||
if (!calendar) {
|
||
const msg = result?.data?.user ? '无法获取贡献日历数据' : '用户不存在或 Token 权限不足';
|
||
showError(msg);
|
||
return;
|
||
}
|
||
|
||
renderHeatmap(calendar);
|
||
} catch (e) {
|
||
showError('请求失败: ' + String(e));
|
||
}
|
||
}
|
||
|
||
// Window position persistence
|
||
async function initWindowPos() {
|
||
if (config.window_x != null && config.window_y != null) {
|
||
try {
|
||
const win = getCurrentWindow();
|
||
await win.setPosition({ type: 'Physical', x: config.window_x, y: config.window_y });
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
async function setWindowHeight(height) {
|
||
try {
|
||
await getCurrentWindow().setSize({ type: 'Logical', width: WINDOW_WIDTH, height });
|
||
} catch (_) {}
|
||
}
|
||
|
||
function clearFormError() {
|
||
document.getElementById('form-error').classList.remove('visible');
|
||
}
|
||
|
||
function showFormError() {
|
||
document.getElementById('form-error').classList.add('visible');
|
||
}
|
||
|
||
async function openSettingsModal() {
|
||
clearFormError();
|
||
document.getElementById('input-username').value = config.username;
|
||
document.getElementById('input-token').value = config.token;
|
||
document.getElementById('input-theme').value = config.theme === 'light' ? 'light' : 'dark';
|
||
await setWindowHeight(SETTINGS_HEIGHT);
|
||
document.body.classList.add('settings-open');
|
||
document.querySelector('.widget').classList.add('mouse-over');
|
||
document.getElementById('settings-modal').classList.add('visible');
|
||
document.getElementById('input-username').focus();
|
||
}
|
||
|
||
async function closeSettingsModal() {
|
||
clearFormError();
|
||
document.getElementById('settings-modal').classList.remove('visible');
|
||
document.body.classList.remove('settings-open');
|
||
await ensureWidgetSize();
|
||
}
|
||
|
||
async function saveSettings() {
|
||
const username = document.getElementById('input-username').value.trim();
|
||
const token = document.getElementById('input-token').value.trim();
|
||
const theme = document.getElementById('input-theme').value === 'light' ? 'light' : 'dark';
|
||
if (!username) {
|
||
showFormError();
|
||
document.getElementById('input-username').focus();
|
||
return;
|
||
}
|
||
|
||
const newConfig = { ...config, username, token, theme };
|
||
try {
|
||
await invoke('save_config', { config: newConfig });
|
||
config = newConfig;
|
||
applyTheme(config.theme);
|
||
document.getElementById('username-display').textContent = config.username;
|
||
await closeSettingsModal();
|
||
await fetchData();
|
||
} catch (e) {
|
||
alert('保存失败: ' + e);
|
||
}
|
||
}
|
||
|
||
// Settings modal
|
||
async function togglePin() {
|
||
isPinned = !isPinned;
|
||
try {
|
||
await getCurrentWindow().setAlwaysOnTop(isPinned);
|
||
} catch (_) {}
|
||
}
|
||
|
||
async function initTrayActions() {
|
||
try {
|
||
const { listen } = window.__TAURI__.event;
|
||
await listen('tray-action', async ({ payload }) => {
|
||
switch (payload) {
|
||
case 'refresh':
|
||
await fetchData();
|
||
break;
|
||
case 'pin':
|
||
await togglePin();
|
||
break;
|
||
case 'theme':
|
||
await toggleTheme();
|
||
break;
|
||
case 'settings':
|
||
await openSettingsModal();
|
||
break;
|
||
}
|
||
});
|
||
} catch (_) {}
|
||
}
|
||
|
||
document.getElementById('btn-cancel').addEventListener('click', () => {
|
||
closeSettingsModal().catch(() => {});
|
||
});
|
||
|
||
document.getElementById('settings-modal').addEventListener('click', (e) => {
|
||
if (e.target === document.getElementById('settings-modal')) {
|
||
closeSettingsModal().catch(() => {});
|
||
}
|
||
});
|
||
|
||
document.getElementById('settings-panel').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
});
|
||
|
||
document.getElementById('btn-save').addEventListener('click', () => {
|
||
saveSettings().catch(() => {});
|
||
});
|
||
|
||
document.getElementById('settings-modal').addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
closeSettingsModal().catch(() => {});
|
||
} else if (e.key === 'Enter' && e.target.tagName === 'INPUT') {
|
||
e.preventDefault();
|
||
saveSettings().catch(() => {});
|
||
}
|
||
});
|
||
|
||
['input-username', 'input-token'].forEach((id) => {
|
||
document.getElementById(id).addEventListener('input', clearFormError);
|
||
});
|
||
|
||
function initHeatmapResize() {
|
||
const area = document.getElementById('heatmap-area');
|
||
if (!area || typeof ResizeObserver === 'undefined') return;
|
||
let lastWidth = 0;
|
||
const observer = new ResizeObserver(() => {
|
||
const w = area.clientWidth;
|
||
if (!w || w === lastWidth || !lastHeatmapData) return;
|
||
lastWidth = w;
|
||
renderHeatmap(lastHeatmapData);
|
||
});
|
||
observer.observe(area);
|
||
}
|
||
|
||
// Window drag (canvas blocks native drag-region; use startDragging as fallback)
|
||
function initWindowDrag() {
|
||
document.querySelector('.widget').addEventListener('mousedown', (e) => {
|
||
if (e.button !== 0) return;
|
||
if (document.getElementById('settings-modal').classList.contains('visible')) return;
|
||
if (e.target.closest('input') || e.target.closest('button')) return;
|
||
getCurrentWindow().startDragging().catch(() => {});
|
||
});
|
||
}
|
||
|
||
// Save window position on move
|
||
(async () => {
|
||
try {
|
||
const win = getCurrentWindow();
|
||
await win.listen('tauri://move', (e) => {
|
||
clearTimeout(moveTimer);
|
||
moveTimer = setTimeout(() => {
|
||
const { x, y } = e.payload;
|
||
invoke('save_window_pos', { x, y }).catch(() => {});
|
||
}, 600);
|
||
});
|
||
} catch (_) {}
|
||
})();
|
||
|
||
// Auto refresh every 30 minutes
|
||
setInterval(fetchData, 30 * 60 * 1000);
|
||
|
||
// Init
|
||
(async () => {
|
||
config = await invoke('load_config').catch(() => config);
|
||
if (!config.theme) config.theme = 'dark';
|
||
applyTheme(config.theme, false);
|
||
document.getElementById('username-display').textContent = config.username;
|
||
initWindowDrag();
|
||
initHeatmapResize();
|
||
await initTrayActions();
|
||
await ensureWidgetSize();
|
||
await initWindowPos();
|
||
await fetchData();
|
||
})();
|