You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
openrank/front/frontend/app.js

1954 lines
90 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// OpenRank 前端应用 - 主逻辑文件
class OpenRankDashboard {
constructor() {
this.currentTab = 'overview';
this.charts = {};
this.mockData = this.generateMockData();
// 新增:模式状态与项目模式缓存
this.mode = 'global'; // 'global' | 'project'
this.projectState = {
list: [], // {fullName, approxScore, contributorsCount}
current: null, // fullName
approxSnapshots: {}, // key -> snapshot
inFlightFull: new Set(), // 追踪正在进行的 full 计算
};
this.init();
}
// 初始化应用
init() {
this.setupEventListeners();
this.loadMockData();
this.initCharts();
this.updateLastUpdateTime();
this.setupTabNavigation();
this.setupModeSwitch();
// 默认显示开发者分析数据
this.displayDeveloperAnalysis(this.mockData.developers);
this.hydrateFromURL();
}
// 设置事件监听器
setupEventListeners() {
// 计算按钮点击事件
document.getElementById('calculate-btn').addEventListener('click', () => {
this.openCalcModal();
});
// 项目搜索
const projectSearch = document.getElementById('project-search');
if (projectSearch) {
projectSearch.addEventListener('input', (e) => {
this.renderProjectList(e.target.value.trim());
});
}
// 搜索功能
const searchInput = document.getElementById('developer-search');
searchInput.addEventListener('input', (e) => {
this.filterDevelopers(e.target.value);
});
// 仓库过滤器
document.getElementById('repo-filter').addEventListener('change', (e) => {
this.filterRepositories(e.target.value);
});
// Delegated handler for repo card action buttons (reliable for dynamic content)
const repoGrid = document.getElementById('repo-grid');
if (repoGrid) {
repoGrid.addEventListener('click', (ev) => {
const btn = ev.target.closest && ev.target.closest('.btn-view-details, .btn-view-contrib');
if (!btn) return;
const repoName = btn.getAttribute('data-repo-name') || '';
const owner = btn.getAttribute('data-repo-owner') || btn.closest('.repo-card')?.getAttribute('data-owner') || '';
console.log('repoGrid click handler triggered for', { repoName, owner });
if (btn.classList.contains('btn-view-details')) {
ev.preventDefault();
this.onViewRepoDetails(repoName);
} else if (btn.classList.contains('btn-view-contrib')) {
ev.preventDefault();
this.onViewRepoContributions(repoName);
}
});
}
// 配置表单变化
this.setupConfigListeners();
}
// 模式切换按钮逻辑
setupModeSwitch() {
const container = document.getElementById('mode-switch');
if (!container) return;
container.addEventListener('click', (e) => {
const btn = e.target.closest('.mode-btn');
if (!btn) return;
const targetMode = btn.getAttribute('data-mode');
if (targetMode && targetMode !== this.mode) {
this.switchMode(targetMode);
}
});
}
switchMode(mode) {
this.mode = mode;
// 更新按钮样式
document.querySelectorAll('.mode-btn').forEach(b => {
b.classList.toggle('active', b.getAttribute('data-mode') === mode);
});
// 切换根容器的显示
const globalRoot = document.getElementById('global-root');
const projectRoot = document.getElementById('project-mode-root');
if (mode === 'global') {
globalRoot.classList.remove('hidden');
projectRoot.classList.add('hidden');
document.body.classList.remove('project-mode-active');
} else {
globalRoot.classList.add('hidden');
projectRoot.classList.remove('hidden');
document.body.classList.add('project-mode-active');
// 首次进入时构建项目列表
if (!this.projectState.list.length) {
this.buildProjectListFromGlobal();
}
}
this.pushURLState();
}
// URL 同步:写入 ?mode=...&repo=...
pushURLState() {
const params = new URLSearchParams(window.location.search);
params.set('mode', this.mode);
if (this.mode === 'project' && this.projectState.current) params.set('repo', this.projectState.current);
else params.delete('repo');
const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.replaceState({}, '', newUrl);
}
hydrateFromURL() {
const params = new URLSearchParams(window.location.search);
const mode = params.get('mode');
const repo = params.get('repo');
if (mode === 'project') {
this.switchMode('project');
if (repo) {
// 等待仓库列表加载后再选择
const wait = setInterval(() => {
if (this.projectState.list.length) {
clearInterval(wait);
this.projectState.current = repo;
this.renderProjectList();
this.renderApproxProject(repo);
}
}, 200);
setTimeout(()=>clearInterval(wait), 8000);
}
}
}
buildProjectListFromGlobal() {
// 从后端获取真实仓库列表(失败则 fallback 到 mock
const params = new URLSearchParams({ page: '1', size: '100', sort: 'stars', order: 'desc' });
fetch('http://localhost:4001/api/repos?' + params.toString())
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then(data => {
const rowsRaw = (data.repos || []).map(r => ({
fullName: r.full_name || r.fullName || r.name,
approxScore: r.stars || r.forks || 0, // 用 stars / forks 简单排序
contributorsCount: r.contributors || 0
}));
// 去重fullName 唯一)
const seen = new Set();
const rows = rowsRaw.filter(r => { if (seen.has(r.fullName)) return false; seen.add(r.fullName); return true; });
if (rows.length) {
this.projectState.list = rows.sort((a,b)=> b.approxScore - a.approxScore);
this.renderProjectList();
} else {
throw new Error('empty repo list');
}
})
.catch(() => {
const reposRaw = (this.mockData.repositories || []).map(r => ({
fullName: r.name,
approxScore: r.openrank || 0,
contributorsCount: r.contributors || 0
}));
const seen2 = new Set();
this.projectState.list = reposRaw.filter(r=>{ if(seen2.has(r.fullName)) return false; seen2.add(r.fullName); return true; });
this.renderProjectList();
this.showNotification('使用 mock 仓库列表(后端 /api/repos 不可用)', 'info');
});
}
renderProjectList(keyword = '') {
const ul = document.getElementById('project-list');
if (!ul) return;
const lower = keyword.toLowerCase();
const filtered = this.projectState.list.filter(p => !keyword || p.fullName.toLowerCase().includes(lower));
if (!filtered.length) {
ul.innerHTML = '<li style="opacity:.7;cursor:default;">无匹配项目</li>';
return;
}
ul.innerHTML = filtered.map(p => {
const active = this.projectState.current === p.fullName;
return `<li class="${active ? 'active' : ''}" data-project="${p.fullName}">
<span class="repo-name" title="${p.fullName}">${p.fullName}</span>
<span class="repo-mini-score">${p.approxScore.toFixed(1)}</span>
</li>`;
}).join('');
ul.querySelectorAll('li[data-project]').forEach(li => {
li.addEventListener('click', () => {
const fullName = li.getAttribute('data-project');
this.projectState.current = fullName;
this.renderProjectList(keyword); // 重新渲染高亮
this.renderApproxProject(fullName);
this.pushURLState();
});
});
}
// 生成近似项目级快照(基于全局数据局部化)
renderApproxProject(fullName, days) {
// 已缓存
if (this.projectState.approxSnapshots[fullName] && !days) {
this.mountProjectView(this.projectState.approxSnapshots[fullName]);
// 若还没有 full 结果且未在进行,自动尝试触发
const approxSnap = this.projectState.approxSnapshots[fullName];
const hasFull = approxSnap && approxSnap.meta && approxSnap.meta.strategy === 'full';
if (!hasFull && !this.projectState.inFlightFull.has(fullName)) {
this.autoTriggerFull(fullName, approxSnap.window && approxSnap.window.days);
}
return;
}
// 后端 approximate
const d = days && days>0 ? days : 30;
fetch(`http://localhost:4001/api/project/${encodeURIComponent(fullName.split('/')[0]||'')}/${encodeURIComponent(fullName.split('/')[1]||'')}/overview?strategy=approx&days=${d}`)
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then(snap => {
const snapshot = {
repo: snap.repo,
window: snap.window,
generatedAt: snap.generatedAt,
contributors: snap.contributors || [],
activityBreakdown: snap.activityBreakdown || { issues:0, prs:0, comments:0 },
timeseries: snap.timeseries || [],
meta: { ...(snap.meta||{}), approx: true }
};
this.projectState.approxSnapshots[fullName] = snapshot;
this.mountProjectView(snapshot);
// 自动触发 full首次
if (!this.projectState.inFlightFull.has(fullName)) {
this.autoTriggerFull(fullName, snapshot.window && snapshot.window.days);
}
})
.catch(() => {
// fallback 原 mock 逻辑
const scale = 0.9;
const baseDevelopers = (this.mockData.developers || []).slice(0, 30);
const contributors = baseDevelopers.map(d => ({
login: d.name,
openrankProject: d.openrank * scale * (0.5 + Math.random() * 0.5),
channels: { issues: d.activity.issues, prs: d.activity.prs, comments: d.activity.comments }
})).sort((a,b)=> b.openrankProject - a.openrankProject);
const snapshot = {
repo: fullName,
window: { start: '-', end: '-', days: d },
generatedAt: new Date().toISOString(),
contributors,
activityBreakdown: { issues: contributors.reduce((s,c)=>s+c.channels.issues,0), prs: contributors.reduce((s,c)=>s+c.channels.prs,0), comments: contributors.reduce((s,c)=>s+c.channels.comments,0) },
timeseries: [],
meta: { source: 'mock-fallback', approx: true }
};
this.projectState.approxSnapshots[fullName] = snapshot;
this.mountProjectView(snapshot);
this.showNotification('后端 approximate 获取失败,使用本地近似方案', 'info');
});
}
mountProjectView(snapshot) {
const container = document.getElementById('project-content');
if (!container) return;
const { repo, contributors, activityBreakdown, meta, window } = snapshot;
const top = contributors.slice(0, 15);
const tableRows = top.map((c,idx) => `<tr>
<td style="opacity:.65;font-size:.65rem;">${idx+1}</td>
<td>${c.login}</td>
<td style="text-align:right">${c.channels.issues}</td>
<td style="text-align:right">${c.channels.prs}</td>
<td style="text-align:right">${c.channels.comments}</td>
<td style="text-align:right;font-weight:600;color:${meta.approx?'#ffc107':'#00ff88'}">${c.openrankProject.toFixed(2)}</td>
</tr>`).join('');
const totalIssues = contributors.reduce((s,c)=>s+(c.channels.issues||0),0);
const totalPRs = contributors.reduce((s,c)=>s+(c.channels.prs||0),0);
const totalComments = contributors.reduce((s,c)=>s+(c.channels.comments||0),0);
const distinctContribs = contributors.length;
const hasTS = !meta.approx && Array.isArray(snapshot.timeseries) && snapshot.timeseries.length > 0;
const winDays = window?.days || Math.round((new Date(window?.end||Date.now()) - new Date(window?.start||Date.now()-30*86400000))/86400000);
container.innerHTML = `
<div class="project-header-inline">
<div style="display:flex;flex-direction:column;gap:.35rem;min-width:260px;">
<h2 style="margin:0;font-size:1.15rem;line-height:1.15;">${repo}</h2>
<div style="display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;font-size:.6rem;opacity:.75;">
<span>窗口: ${winDays} 天</span>
<span>贡献者: ${distinctContribs}</span>
<span>Issues: ${totalIssues}</span>
<span>PRs: ${totalPRs}</span>
<span>Comments: ${totalComments}</span>
</div>
</div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;">
<span class="badge ${meta.approx ? 'approx':'full'}" title="${meta.approx ? '基于全局/活动加权的近似派生结果; 触发正式计算后替换' : '已完成项目级图重算(OpenRank子图)'}">${meta.approx ? '近似估算':'正式计算'}</span>
<button class="btn-secondary" id="trigger-full-project">${meta.approx ? '触发正式计算':'重新计算'}</button>
</div>
</div>
<div class="project-top-row">
<div class="panel-box panel-ranking">
<h4 style="display:flex;justify-content:space-between;align-items:center;">贡献者排名 <span style="font-size:.6rem;opacity:.6;">Top 15</span></h4>
<div class="table-wrap-scroll">
<table class="mini-table">
<thead><tr><th>#</th><th>用户</th><th>Issues</th><th>PRs</th><th>Comments</th><th>Proj-OR</th></tr></thead>
<tbody>${tableRows}</tbody>
</table>
</div>
</div>
<div class="panel-box panel-activity">
<h4>活动占比 <span style="font-size:.55rem;opacity:.6;">(issues / prs / comments)</span></h4>
<div class="chart-slot"><canvas id="project-activity-pie"></canvas></div>
</div>
<div class="panel-box panel-metrics">
<h4>结构指标</h4>
<ul class="metric-list">
<li><span>平均 Issues/贡献者</span><strong>${distinctContribs? (totalIssues/distinctContribs).toFixed(2):'-'}</strong></li>
<li><span>平均 PRs/贡献者</span><strong>${distinctContribs? (totalPRs/distinctContribs).toFixed(2):'-'}</strong></li>
<li><span>平均 Comments/贡献者</span><strong>${distinctContribs? (totalComments/distinctContribs).toFixed(1):'-'}</strong></li>
<li><span>Top1 占比(issues)</span><strong>${totalIssues? ((top[0]?.channels.issues||0)/totalIssues*100).toFixed(1)+'%':'-'}</strong></li>
<li><span>Top1 占比(PRs)</span><strong>${totalPRs? ((top[0]?.channels.prs||0)/totalPRs*100).toFixed(1)+'%':'-'}</strong></li>
<li><span>Top1 占比(comments)</span><strong>${totalComments? ((top[0]?.channels.comments||0)/totalComments*100).toFixed(1)+'%':'-'}</strong></li>
</ul>
</div>
</div>
<div class="panel-box panel-timeseries" style="margin-top:1.15rem;">
<h4>时间序列 ${meta.approx ? '(正式计算后显示)' : ''}</h4>
<div class="chart-slot" style="height:260px;position:relative;">
<div id="project-timeseries-placeholder" style="position:absolute;inset:0;display:${hasTS ? 'none':'flex'};align-items:center;justify-content:center;color:rgba(255,255,255,.5);font-size:.75rem;">
${meta.approx ? '暂未计算(正式计算后展示)' : (hasTS ? '' : '窗口内无活动数据')}
</div>
<canvas id="project-timeseries-canvas" style="${hasTS ? 'opacity:1;':'opacity:0;pointer-events:none;'}"></canvas>
</div>
</div>`;
// 绑定正式计算按钮(当前占位)
const calcBtn = document.getElementById('trigger-full-project');
if (calcBtn) {
calcBtn.addEventListener('click', () => {
this.triggerFullProjectCalculation(repo);
});
}
setTimeout(()=>{
// 活动占比
try {
const ctx = document.getElementById('project-activity-pie').getContext('2d');
if (this.charts.projectActivityPie) this.charts.projectActivityPie.destroy();
this.charts.projectActivityPie = new Chart(ctx, { type:'doughnut', data:{ labels:['Issues','PRs','Comments'], datasets:[{ data:[activityBreakdown.issues,activityBreakdown.prs,activityBreakdown.comments], backgroundColor:['#00d4ff','#00ff88','#ffd700'] }] }, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ position:'bottom', labels:{ color:'#fff' } } } } });
} catch(e){ console.warn('project activity pie error', e); }
// 时间序列(仅 full
try {
if (!meta.approx && hasTS) {
const tsCtx = document.getElementById('project-timeseries-canvas').getContext('2d');
const labels = snapshot.timeseries.map(p=>p.period);
const issues = snapshot.timeseries.map(p=>p.issues||0);
const prs = snapshot.timeseries.map(p=>p.prs||0);
const comments = snapshot.timeseries.map(p=>p.comments||0);
const merged = snapshot.timeseries.map(p=>p.merged||0);
if (this.charts.projectTimeseries) this.charts.projectTimeseries.destroy();
this.charts.projectTimeseries = new Chart(tsCtx, { type:'line', data:{ labels, datasets:[
{ label:'Issues', data:issues, borderColor:'#00d4ff', backgroundColor:'rgba(0,212,255,.15)', tension:.25 },
{ label:'PRs', data:prs, borderColor:'#00ff88', backgroundColor:'rgba(0,255,136,.15)', tension:.25 },
{ label:'Comments', data:comments, borderColor:'#ffd700', backgroundColor:'rgba(255,215,0,.10)', tension:.25 },
{ label:'Merged', data:merged, borderColor:'#ff7af0', backgroundColor:'rgba(255,122,240,.12)', borderDash:[4,3], tension:.25 }
]}, options:{ responsive:true, maintainAspectRatio:false, plugins:{ legend:{ labels:{ color:'#fff' } } }, scales:{ x:{ ticks:{ color:'#bbb' } }, y:{ ticks:{ color:'#bbb' }, beginAtZero:true } } } });
const cvs = document.getElementById('project-timeseries-canvas');
if (cvs) cvs.style.opacity = '1';
}
} catch(tsErr){ console.warn('project timeseries chart error', tsErr); }
},50);
}
// 触发正式项目计算并轮询任务
triggerFullProjectCalculation(fullName) {
if (!fullName || fullName.indexOf('/') === -1) return;
const [owner, repo] = fullName.split('/');
this.showNotification('已提交正式项目级计算任务', 'success');
fetch(`http://localhost:4001/api/project/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/calculate`, { method: 'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({}) })
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then(task => this.pollTask(task.taskId, fullName))
.catch(err => this.showNotification('触发计算失败: '+err, 'negative'));
}
autoTriggerFull(fullName, days) {
if (!fullName || fullName.indexOf('/') === -1) return;
const [owner, repo] = fullName.split('/');
if (this.projectState.inFlightFull.has(fullName)) return;
this.projectState.inFlightFull.add(fullName);
const payload = days ? { days } : {};
fetch(`http://localhost:4001/api/project/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/calculate`, { method: 'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(payload) })
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then(task => this.pollTask(task.taskId, fullName, true))
.catch(err => { this.projectState.inFlightFull.delete(fullName); console.warn('auto full calc failed', err); });
}
pollTask(taskId, fullName, silent) {
if (!taskId) return;
const interval = 1500;
const tick = () => {
fetch(`http://localhost:4001/api/tasks/${taskId}`)
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then(task => {
if (task.status === 'done') {
if (!silent) this.showNotification('项目级正式计算完成', 'success');
this.projectState.inFlightFull.delete(fullName);
if (task.result) {
const snap = { ...task.result, meta: { ...(task.result.meta||{}), approx:false } };
// 缓存 full 快照(可与 approx 并存)
this.projectState.approxSnapshots[fullName] = snap; // 简化:复用存储键
this.mountProjectView(snap);
this.pushURLState();
}
} else if (task.status === 'error') {
this.projectState.inFlightFull.delete(fullName);
if (!silent) this.showNotification('项目级计算失败: '+ task.error, 'negative');
} else {
// 更新按钮或显示进度(简单:修改按钮文本)
const btn = document.getElementById('trigger-full-project');
if (btn) btn.textContent = `计算中… ${task.progress || 0}%`;
setTimeout(tick, interval);
}
})
.catch(() => setTimeout(tick, interval));
};
setTimeout(tick, interval);
}
// 设置标签页导航
setupTabNavigation() {
const navButtons = document.querySelectorAll('.nav-btn');
navButtons.forEach(button => {
button.addEventListener('click', (e) => {
const tab = e.target.dataset.tab;
this.switchTab(tab);
});
});
}
// 切换标签页
switchTab(tab) {
// 移除所有活动状态
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
// 添加新的活动状态
document.querySelector(`[data-tab="${tab}"]`).classList.add('active');
document.getElementById(tab).classList.add('active');
this.currentTab = tab;
// 如果是网络图谱标签,初始化图谱
if (tab === 'network') {
this.initNetworkGraph();
}
}
// 点击查看仓库详情
// 点击查看仓库详情(现在调用后端详情接口并打开 modal
// helper: parse strings like 'owner/repo' or just 'repo'
parseOwnerRepo(str) {
if (!str) return null;
const parts = String(str).split('/');
if (parts.length >= 2) {
return { owner: parts[0], repo: parts.slice(1).join('/') };
}
return null;
}
async onViewRepoDetails(repoName) {
// If repoName contains owner (owner/repo), split it
const parsed = this.parseOwnerRepo(repoName);
let owner = '';
let repo = repoName;
if (parsed) {
owner = parsed.owner;
repo = parsed.repo;
} else {
// Try to get owner from DOM attribute on the card/button
try {
const grid = document.getElementById('repo-grid');
const card = Array.from(grid.querySelectorAll('.repo-card')).find(el => el.textContent && el.textContent.indexOf(repoName) !== -1);
if (card && card.getAttribute('data-owner')) owner = card.getAttribute('data-owner');
} catch (_) { /* ignore */ }
if (!owner) owner = prompt('请输入仓库 owner例如 FISCO-BCOS:', 'FISCO-BCOS') || 'FISCO-BCOS';
}
// Open details modal with separate owner and repo
this.showRepoDetails(owner, repo);
}
// 点击查看仓库贡献分析
async onViewRepoContributions(repoName) {
// 切换到 developers tab where contributions can be shown
this.switchTab('developers');
// Determine owner and repo, support repoName like 'owner/repo'
let owner = null;
let repo = repoName;
const parsed = this.parseOwnerRepo(repoName);
if (parsed) {
owner = parsed.owner;
repo = parsed.repo;
} else {
try {
const grid = document.getElementById('repo-grid');
const card = Array.from(grid.querySelectorAll('.repo-card')).find(el => el.textContent && el.textContent.indexOf(repoName) !== -1);
if (card && card.getAttribute('data-owner')) owner = card.getAttribute('data-owner');
} catch (_) { /* ignore */ }
if (!owner) {
owner = prompt('请输入仓库 owner用于查询仓库级贡献者或留空使用默认 FISCO-BCOS:', 'FISCO-BCOS') || 'FISCO-BCOS';
}
}
// Prefer calling backend details endpoint (DB-backed) to get robust contributor stats
try {
const params = new URLSearchParams({ start: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(), end: new Date().toISOString(), granularity: 'day', useCache: '1' });
const resp = await fetch(`http://localhost:4001/api/repo/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/details?${params.toString()}`);
if (resp.ok) {
const body = await resp.json();
const contributors = (body.contributors || []).map((c) => ({
id: c.id || null,
name: c.name || (c.actor_login || 'unknown'),
openrank: Number(c.openrank || c.openRank || 0) || 0,
change: 0,
activity: {
issues: Number((c.activity && (c.activity.issues !== undefined ? c.activity.issues : c.issues)) || 0) || 0,
prs: Number((c.activity && (c.activity.prs !== undefined ? c.activity.prs : c.prs)) || 0) || 0,
comments: Number((c.activity && (c.activity.comments !== undefined ? c.activity.comments : c.comments)) || 0) || 0
}
}));
// 如果 openrank 都是 0尝试与当前缓存的 global developers 匹配(同名)进行补填
const allZero = contributors.every(c => !c.openrank);
if (allZero && this.mockData && Array.isArray(this.mockData.developers)) {
const devMap = new Map(this.mockData.developers.map(d => [String(d.name).toLowerCase(), d.openrank]));
contributors.forEach(c => {
const v = devMap.get(String(c.name).toLowerCase());
if (typeof v === 'number' && v > 0) c.openrank = v;
});
}
if (contributors.length === 0) {
this.showNotification(`未在 DB 中找到 ${owner}/${repoName} 的贡献者数据,显示全局开发者`, 'info');
this.displayDeveloperAnalysis(this.mockData.developers);
return;
}
this.displayDeveloperAnalysis(contributors);
this.showNotification(`显示 ${owner}/${repoName} 的贡献者分析 (${contributors.length} 人)`, 'success');
return;
}
} catch (e) {
console.warn('Failed to fetch repo details, falling back to local repoContributors if available', e);
}
// Fallback: use in-memory repoContributors from previous calculate call (normalize fields)
if (this.repoContributors && (this.repoContributors[repoName] || this.repoContributors[`${owner}/${repoName}`])) {
const key = this.repoContributors[repoName] ? repoName : `${owner}/${repoName}`;
const arr = this.repoContributors[key] || [];
const contributors = arr.map(c => ({
id: c.id || null,
name: c.name || c.actor_login || 'unknown',
openrank: Number(c.openrank || 0) || 0,
change: 0,
activity: {
issues: Number((c.activity && (c.activity.issues !== undefined ? c.activity.issues : c.issues)) || 0) || 0,
prs: Number((c.activity && (c.activity.prs !== undefined ? c.activity.prs : c.prs)) || 0) || 0,
comments: Number((c.activity && (c.activity.comments !== undefined ? c.activity.comments : c.comments)) || 0) || 0
}
}));
if (contributors.length === 0) {
this.showNotification(`仓库 ${repoName} 没有贡献者数据,显示全局开发者`, 'info');
this.displayDeveloperAnalysis(this.mockData.developers);
return;
}
this.displayDeveloperAnalysis(contributors);
this.showNotification(`显示 ${repoName} 的贡献者分析 (${contributors.length} 人)`, 'success');
return;
}
// 最后回退:显示全局开发者
this.showNotification(`未找到 ${repoName} 的仓库级数据,显示全局开发者`, 'info');
this.displayDeveloperAnalysis(this.mockData.developers);
}
// 设置配置监听器
setupConfigListeners() {
const configInputs = document.querySelectorAll('.setting-item input');
configInputs.forEach(input => {
input.addEventListener('change', (e) => {
this.updateConfig(e.target.id, e.target.value);
});
});
}
// 生成模拟数据(包含丰富的示例数据)
generateMockData() {
// 创建有意义的开发者示例数据
const exampleDevelopers = [
{
id: 1,
name: "张三",
openrank: 95.2,
change: 12.5,
activity: { issues: 42, prs: 28, comments: 156 }
},
{
id: 2,
name: "李四",
openrank: 88.7,
change: 8.3,
activity: { issues: 35, prs: 32, comments: 198 }
},
{
id: 3,
name: "王五",
openrank: 82.1,
change: -3.2,
activity: { issues: 28, prs: 25, comments: 132 }
},
{
id: 4,
name: "赵六",
openrank: 76.5,
change: 15.7,
activity: { issues: 31, prs: 19, comments: 145 }
},
{
id: 5,
name: "钱七",
openrank: 68.9,
change: 5.4,
activity: { issues: 22, prs: 26, comments: 112 }
},
{
id: 6,
name: "孙八",
openrank: 62.3,
change: -2.1,
activity: { issues: 18, prs: 15, comments: 98 }
},
{
id: 7,
name: "周九",
openrank: 55.8,
change: 9.8,
activity: { issues: 25, prs: 12, comments: 87 }
},
{
id: 8,
name: "吴十",
openrank: 48.2,
change: -5.6,
activity: { issues: 15, prs: 8, comments: 65 }
},
{
id: 9,
name: "郑十一",
openrank: 41.7,
change: 3.2,
activity: { issues: 12, prs: 6, comments: 54 }
},
{
id: 10,
name: "王十二",
openrank: 35.4,
change: 7.1,
activity: { issues: 8, prs: 4, comments: 42 }
}
];
// 添加更多随机开发者
const randomDevelopers = Array.from({ length: 40 }, (_, i) => ({
id: i + 11,
name: `dev_${String.fromCharCode(65 + i)}`,
openrank: Math.random() * 100,
change: (Math.random() - 0.5) * 20,
activity: {
issues: Math.floor(Math.random() * 50),
prs: Math.floor(Math.random() * 30),
comments: Math.floor(Math.random() * 200)
}
}));
// 创建有意义的仓库示例数据
const exampleRepositories = [
{
id: 1,
name: "openrank-core",
openrank: 487.3,
stars: 1250,
forks: 342,
contributors: 45
},
{
id: 2,
name: "data-analytics",
openrank: 356.8,
stars: 876,
forks: 215,
contributors: 32
},
{
id: 3,
name: "web-dashboard",
openrank: 298.4,
stars: 654,
forks: 178,
contributors: 28
},
{
id: 4,
name: "api-service",
openrank: 245.1,
stars: 432,
forks: 123,
contributors: 22
},
{
id: 5,
name: "mobile-app",
openrank: 198.7,
stars: 321,
forks: 98,
contributors: 18
}
];
// 添加更多随机仓库
const randomRepositories = Array.from({ length: 15 }, (_, i) => ({
id: i + 6,
name: `project-${i + 1}`,
openrank: Math.random() * 500,
stars: Math.floor(Math.random() * 1000),
forks: Math.floor(Math.random() * 200),
contributors: Math.floor(Math.random() * 50) + 5
}));
return {
developers: [...exampleDevelopers, ...randomDevelopers],
repositories: [...exampleRepositories, ...randomRepositories],
activityDistribution: {
issueComment: 45.2,
openIssue: 23.8,
openPull: 18.5,
reviewComment: 8.7,
mergedPull: 3.8
},
openrankDistribution: [
{ range: "0-10", count: 5 },
{ range: "10-20", count: 8 },
{ range: "20-30", count: 12 },
{ range: "30-40", count: 15 },
{ range: "40-50", count: 18 },
{ range: "50-60", count: 22 },
{ range: "60-70", count: 16 },
{ range: "70-80", count: 12 },
{ range: "80-90", count: 8 },
{ range: "90-100", count: 4 }
]
};
}
// 加载模拟数据到界面
loadMockData() {
this.updateStats();
this.updateRankingTable();
this.updateRepositoryGrid();
}
// 更新统计信息
updateStats() {
const totalOpenrank = this.mockData.developers.reduce((sum, dev) => sum + dev.openrank, 0);
const avgContribution = totalOpenrank / this.mockData.developers.length;
document.getElementById('active-developers').textContent = this.mockData.developers.length;
document.getElementById('active-repos').textContent = this.mockData.repositories.length;
document.getElementById('total-openrank').textContent = totalOpenrank.toFixed(1);
document.getElementById('avg-contribution').textContent = avgContribution.toFixed(2);
}
// 更新排名表格
updateRankingTable() {
const rankingBody = document.getElementById('ranking-body');
const topDevelopers = [...this.mockData.developers]
.sort((a, b) => b.openrank - a.openrank)
.slice(0, 10);
rankingBody.innerHTML = topDevelopers.map((dev, index) => `
<div class="ranking-item">
<span class="rank rank-${index + 1}">${index + 1}</span>
<span>${dev.name}</span>
<span>${dev.openrank.toFixed(2)}</span>
<span class="stat-change ${dev.change >= 0 ? 'positive' : 'negative'}">
${dev.change >= 0 ? '+' : ''}${dev.change.toFixed(1)}%
</span>
</div>
`).join('');
}
// 更新仓库网格(增强版)
updateRepositoryGrid() {
const repoGrid = document.getElementById('repo-grid');
const sortedRepos = [...this.mockData.repositories].sort((a, b) => b.openrank - a.openrank);
repoGrid.innerHTML = sortedRepos.map((repo, index) => `
<div class="repo-card slide-in" data-owner="${repo.owner || ''}" style="animation-delay: ${index * 0.1}s">
<div class="repo-header">
<div class="repo-icon">
<i class="fas fa-code-branch"></i>
</div>
<div class="repo-info">
<h3>${repo.name}</h3>
<span class="repo-rank">排名: #${index + 1}</span>
</div>
<span class="repo-score">${repo.openrank.toFixed(1)}</span>
</div>
<div class="repo-metrics">
<div class="metric-row">
<div class="metric">
<i class="fas fa-star"></i>
<span class="metric-value">${repo.stars}</span>
<span class="metric-label">Stars</span>
</div>
<div class="metric">
<i class="fas fa-code-branch"></i>
<span class="metric-value">${repo.forks}</span>
<span class="metric-label">Forks</span>
</div>
<div class="metric">
<i class="fas fa-users"></i>
<span class="metric-value">${repo.contributors}</span>
<span class="metric-label">Contributors</span>
</div>
</div>
</div>
<div class="repo-progress">
<div class="progress-info">
<span>OpenRank 进度</span>
<span>${repo.openrank.toFixed(1)}/500</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${(repo.openrank / 500 * 100).toFixed(1)}%"></div>
</div>
</div>
<div class="repo-tags">
<span class="tag ${repo.openrank > 400 ? 'tag-success' : repo.openrank > 250 ? 'tag-warning' : 'tag-primary'}">
${repo.openrank > 400 ? '顶级项目' : repo.openrank > 250 ? '优秀项目' : '普通项目'}
</span>
<span class="tag ${repo.stars > 800 ? 'tag-success' : repo.stars > 400 ? 'tag-warning' : 'tag-primary'}">
${repo.stars > 800 ? '热门项目' : repo.stars > 400 ? '受欢迎' : '新兴项目'}
</span>
<span class="tag ${repo.contributors > 30 ? 'tag-success' : repo.contributors > 15 ? 'tag-warning' : 'tag-primary'}">
${repo.contributors > 30 ? '大型团队' : repo.contributors > 15 ? '中型团队' : '小型团队'}
</span>
</div>
<div class="repo-actions">
<button class="btn-secondary btn-view-details" data-repo-id="${repo.id}" data-repo-name="${repo.name}" data-repo-owner="${repo.owner || ''}">
<i class="fas fa-chart-line"></i>
查看详情
</button>
<button class="btn-secondary btn-view-contrib" data-repo-id="${repo.id}" data-repo-name="${repo.name}" data-repo-owner="${repo.owner || ''}">
<i class="fas fa-code"></i>
贡献分析
</button>
</div>
</div>
`).join('');
/*
// Previously attached per-button handlers here; now using delegated handler added in setupEventListeners
setTimeout(() => {
// noop
}, 0);
*/
}
// 初始化图表
initCharts() {
this.initDistributionChart();
this.initActivityChart();
}
// 初始化分布图表
initDistributionChart() {
const ctx = document.getElementById('distribution-canvas').getContext('2d');
this.charts.distribution = new Chart(ctx, {
type: 'bar',
data: {
labels: this.mockData.openrankDistribution.map(d => d.range),
datasets: [{
label: '开发者数量',
data: this.mockData.openrankDistribution.map(d => d.count),
backgroundColor: 'rgba(0, 212, 255, 0.6)',
borderColor: 'rgba(0, 212, 255, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#ffffff'
}
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(255, 255, 255, 0.1)'
},
ticks: {
color: '#ffffff'
}
},
x: {
grid: {
color: 'rgba(255, 255, 255, 0.1)'
},
ticks: {
color: '#ffffff'
}
}
}
}
});
}
// 初始化活动类型图表
initActivityChart() {
const ctx = document.getElementById('activity-canvas').getContext('2d');
this.charts.activity = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Issue 评论', '创建 Issue', '创建 PR', '代码评审', '合入 PR'],
datasets: [{
data: [
this.mockData.activityDistribution.issueComment,
this.mockData.activityDistribution.openIssue,
this.mockData.activityDistribution.openPull,
this.mockData.activityDistribution.reviewComment,
this.mockData.activityDistribution.mergedPull
],
backgroundColor: [
'rgba(0, 212, 255, 0.8)',
'rgba(0, 255, 136, 0.8)',
'rgba(255, 193, 7, 0.8)',
'rgba(156, 39, 176, 0.8)',
'rgba(255, 87, 34, 0.8)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
color: '#ffffff',
font: {
size: 12
}
}
}
}
}
});
}
// 初始化网络图谱
initNetworkGraph() {
const canvas = document.getElementById('graph-canvas');
const ctx = canvas.getContext('2d');
// 设置画布尺寸
canvas.width = canvas.parentElement.offsetWidth;
canvas.height = canvas.parentElement.offsetHeight;
// 简单的网络图谱绘制
this.drawSimpleNetwork(ctx, canvas.width, canvas.height);
}
// 绘制基于OpenRank参数的复杂网络图谱
drawSimpleNetwork(ctx, width, height) {
ctx.clearRect(0, 0, width, height);
// 绘制背景网格
this.drawGrid(ctx, width, height);
// 获取配置参数用于网络图谱计算
const attenuationFactor = parseFloat(document.getElementById('attenuation-factor').value) || 0.85;
const developerRetention = parseFloat(document.getElementById('developer-retention').value) || 0.5;
const repoRetention = parseFloat(document.getElementById('repo-retention').value) || 0.3;
const issueCommentWeight = parseFloat(document.getElementById('issue-comment-weight').value) || 0.5252;
const openIssueWeight = parseFloat(document.getElementById('open-issue-weight').value) || 2.2235;
const openPullWeight = parseFloat(document.getElementById('open-pull-weight').value) || 4.0679;
// 基于配置参数生成节点数据
const nodes = [
{
x: width * 0.3,
y: height * 0.4,
size: 20 + (developerRetention * 30), // 开发者继承比例影响节点大小
color: '#00d4ff',
label: `User\nRet:${developerRetention}`,
type: 'User',
value: developerRetention * 100
},
{
x: width * 0.7,
y: height * 0.4,
size: 25 + (repoRetention * 25), // 仓库继承比例影响节点大小
color: '#00ff88',
label: `Repo\nRet:${repoRetention}`,
type: 'Repo',
value: repoRetention * 100
},
{
x: width * 0.5,
y: height * 0.2,
size: 18 + (issueCommentWeight * 5), // Issue评论权重影响节点大小
color: '#ffd700',
label: `Issue\nW:${issueCommentWeight}`,
type: 'Issue',
value: issueCommentWeight * 20
},
{
x: width * 0.5,
y: height * 0.6,
size: 22 + (openPullWeight * 3), // PR创建权重影响节点大小
color: '#ff6b6b',
label: `PR\nW:${openPullWeight}`,
type: 'PullRequest',
value: openPullWeight * 10
},
{
x: width * 0.2,
y: height * 0.2,
size: 15 + (openIssueWeight * 4), // Issue创建权重影响节点大小
color: '#9c27b0',
label: `OpenIssue\nW:${openIssueWeight}`,
type: 'Issue',
value: openIssueWeight * 8
}
];
// 基于衰减因子计算连接线权重和样式
const baseLineWidth = 2;
const lineWidthMultiplier = attenuationFactor * 3;
// 绘制连接线(基于算法参数)
const drawConnection = (source, target, weight, type) => {
ctx.beginPath();
ctx.moveTo(source.x, source.y);
ctx.lineTo(target.x, target.y);
// 根据权重设置线条样式
const lineWidth = baseLineWidth + (weight * lineWidthMultiplier);
const alpha = 0.3 + (weight * 0.4);
ctx.strokeStyle = type === 'activity' ?
`rgba(0, 212, 255, ${alpha})` :
`rgba(0, 255, 136, ${alpha})`;
ctx.lineWidth = lineWidth;
ctx.stroke();
// 绘制权重标签
const midX = (source.x + target.x) / 2;
const midY = (source.y + target.y) / 2;
ctx.fillStyle = '#ffffff';
ctx.font = '10px Inter';
ctx.textAlign = 'center';
ctx.fillText(weight.toFixed(2), midX, midY - 5);
};
// 绘制不同类型的连接关系
drawConnection(nodes[0], nodes[1], developerRetention, 'belong');
drawConnection(nodes[0], nodes[2], issueCommentWeight, 'activity');
drawConnection(nodes[0], nodes[3], openPullWeight, 'activity');
drawConnection(nodes[0], nodes[4], openIssueWeight, 'activity');
drawConnection(nodes[1], nodes[2], repoRetention * 0.5, 'reverse');
drawConnection(nodes[1], nodes[3], repoRetention * 0.7, 'reverse');
// 绘制节点(包含参数信息)
nodes.forEach(node => {
// 绘制节点外圈(表示参数值)
ctx.beginPath();
ctx.arc(node.x, node.y, node.size + 5, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(255, 255, 255, 0.2)`;
ctx.lineWidth = 2;
ctx.stroke();
// 绘制节点主体
ctx.beginPath();
ctx.arc(node.x, node.y, node.size, 0, Math.PI * 2);
ctx.fillStyle = node.color;
ctx.fill();
// 绘制节点内圈(表示衰减因子影响)
ctx.beginPath();
ctx.arc(node.x, node.y, node.size * attenuationFactor, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
ctx.lineWidth = 1;
ctx.stroke();
// 绘制节点标签
ctx.fillStyle = '#ffffff';
ctx.font = '10px Inter';
ctx.textAlign = 'center';
// 多行标签显示
const lines = node.label.split('\n');
lines.forEach((line, index) => {
ctx.fillText(line, node.x, node.y + node.size + 20 + (index * 14));
});
// 显示参数值
ctx.font = '9px Inter';
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.fillText(`Value: ${node.value.toFixed(1)}`, node.x, node.y + node.size + 40 + (lines.length * 14));
});
// 绘制参数说明图例
this.drawEnhancedGraphLegend(ctx, width, height, {
attenuationFactor,
developerRetention,
repoRetention,
issueCommentWeight,
openIssueWeight,
openPullWeight
});
// 绘制网络统计信息
this.drawNetworkStats(ctx, width, height, nodes);
}
// 绘制增强的图例(包含参数信息)
drawEnhancedGraphLegend(ctx, width, height, params) {
const legendItems = [
{ color: '#00d4ff', label: '开发者节点', desc: `继承比例: ${params.developerRetention}` },
{ color: '#00ff88', label: '仓库节点', desc: `继承比例: ${params.repoRetention}` },
{ color: '#ffd700', label: 'Issue节点', desc: `评论权重: ${params.issueCommentWeight}` },
{ color: '#ff6b6b', label: 'PR节点', desc: `创建权重: ${params.openPullWeight}` },
{ color: '#9c27b0', label: 'Issue创建', desc: `权重: ${params.openIssueWeight}` }
];
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(width - 200, 20, 180, legendItems.length * 40 + 60);
// 绘制标题
ctx.fillStyle = '#00d4ff';
ctx.font = '12px Inter';
ctx.textAlign = 'left';
ctx.fillText('网络图谱参数说明', width - 190, 40);
// 绘制图例项
legendItems.forEach((item, index) => {
const yPos = 60 + index * 40;
// 绘制颜色标记
ctx.fillStyle = item.color;
ctx.beginPath();
ctx.arc(width - 190, yPos, 6, 0, Math.PI * 2);
ctx.fill();
// 绘制标签
ctx.fillStyle = '#ffffff';
ctx.font = '11px Inter';
ctx.fillText(item.label, width - 175, yPos + 4);
// 绘制参数描述
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.font = '10px Inter';
ctx.fillText(item.desc, width - 175, yPos + 18);
});
// 绘制全局参数
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.font = '10px Inter';
ctx.fillText(`衰减因子: ${params.attenuationFactor}`, width - 190, legendItems.length * 40 + 50);
}
// 绘制网络统计信息
drawNetworkStats(ctx, width, height, nodes) {
const totalNodes = nodes.length;
const totalConnections = 6; // 手动计算的连接数
const avgNodeSize = nodes.reduce((sum, node) => sum + node.size, 0) / totalNodes;
const maxNodeValue = Math.max(...nodes.map(node => node.value));
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(20, height - 100, 200, 80);
ctx.fillStyle = '#ffffff';
ctx.font = '12px Inter';
ctx.textAlign = 'left';
ctx.fillText('网络统计信息', 30, height - 80);
ctx.font = '10px Inter';
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.fillText(`节点数量: ${totalNodes}`, 30, height - 65);
ctx.fillText(`连接数量: ${totalConnections}`, 30, height - 50);
ctx.fillText(`平均节点大小: ${avgNodeSize.toFixed(1)}`, 30, height - 35);
ctx.fillText(`最大节点价值: ${maxNodeValue.toFixed(1)}`, 30, height - 20);
}
// 绘制网格背景
drawGrid(ctx, width, height) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
ctx.lineWidth = 1;
// 水平线
for (let y = 0; y < height; y += 50) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
// 垂直线
for (let x = 0; x < width; x += 50) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
}
// 绘制图例
drawGraphLegend(ctx, width, height) {
const legend = [
{ color: '#00d4ff', label: '开发者' },
{ color: '#00ff88', label: '仓库' },
{ color: '#ffd700', label: 'Issue' },
{ color: '#ff6b6b', label: 'Pull Request' }
];
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(width - 150, 20, 130, legend.length * 25 + 20);
legend.forEach((item, index) => {
ctx.fillStyle = item.color;
ctx.beginPath();
ctx.arc(width - 130, 40 + index * 25, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '12px Inter';
ctx.textAlign = 'left';
ctx.fillText(item.label, width - 120, 45 + index * 25);
});
}
// 过滤开发者
filterDevelopers(query) {
const filtered = this.mockData.developers.filter(dev =>
dev.name.toLowerCase().includes(query.toLowerCase())
);
this.displayDeveloperAnalysis(filtered);
}
// 显示开发者分析
displayDeveloperAnalysis(developers) {
const developerStats = document.querySelector('.developer-stats');
if (developers.length === 0) {
developerStats.innerHTML = `
<div class="no-results">
<i class="fas fa-search"></i>
<h3>未找到匹配的开发者</h3>
<p>请尝试其他搜索关键词</p>
</div>
`;
return;
}
// 对开发者进行排序按OpenRank降序
const sortedDevelopers = [...developers].sort((a, b) => b.openrank - a.openrank);
developerStats.innerHTML = `
<div class="developer-analysis-header">
<h3>开发者分析结果 (${developers.length} 人)</h3>
<div class="analysis-summary">
<span class="summary-item">
<i class="fas fa-crown"></i>
最高 OpenRank: ${Math.max(...developers.map(d => d.openrank)).toFixed(2)}
</span>
<span class="summary-item">
<i class="fas fa-chart-line"></i>
平均 OpenRank: ${(developers.reduce((sum, d) => sum + d.openrank, 0) / developers.length).toFixed(2)}
</span>
</div>
</div>
<div class="developers-grid">
${sortedDevelopers.map(dev => this.createDeveloperCard(dev)).join('')}
</div>
<div class="developer-charts">
<div class="chart-section">
<h4>OpenRank 分布</h4>
<div class="chart-container" style="height: 200px">
<canvas id="developer-distribution-chart"></canvas>
</div>
</div>
<div class="chart-section">
<h4>活动类型占比</h4>
<div class="chart-container" style="height: 200px">
<canvas id="developer-activity-chart"></canvas>
</div>
</div>
</div>
`;
// 初始化开发者分析图表
this.initDeveloperCharts(developers);
}
// 创建开发者卡片
createDeveloperCard(developer) {
// normalize activity fields to avoid undefined/null
const issuesCount = Number((developer.activity && developer.activity.issues) || developer.issues || 0) || 0;
const prsCount = Number((developer.activity && developer.activity.prs) || developer.prs || 0) || 0;
const commentsCount = Number((developer.activity && developer.activity.comments) || developer.comments || 0) || 0;
const activityTotal = issuesCount + prsCount + commentsCount;
const issuesPercent = activityTotal > 0 ? (issuesCount / activityTotal * 100).toFixed(1) : 0;
const prsPercent = activityTotal > 0 ? (prsCount / activityTotal * 100).toFixed(1) : 0;
const commentsPercent = activityTotal > 0 ? (commentsCount / activityTotal * 100).toFixed(1) : 0;
return `
<div class="developer-card slide-in">
<div class="developer-header">
<div class="developer-avatar">
<i class="fas fa-user"></i>
</div>
<div class="developer-info">
<h4>${developer.name}</h4>
<span class="developer-rank">OpenRank: ${developer.openrank.toFixed(2)}</span>
</div>
<span class="rank-badge">#${this.mockData.developers.sort((a, b) => b.openrank - a.openrank).findIndex(d => d.id === developer.id) + 1}</span>
</div>
<div class="developer-stats">
<div class="stat-row">
<span class="stat-label">Issues</span>
<span class="stat-value">${issuesCount}</span>
<div class="progress-bar">
<div class="progress-fill" style="width: ${issuesPercent}%"></div>
</div>
</div>
<div class="stat-row">
<span class="stat-label">PRs</span>
<span class="stat-value">${prsCount}</span>
<div class="progress-bar">
<div class="progress-fill" style="width: ${prsPercent}%"></div>
</div>
</div>
<div class="stat-row">
<span class="stat-label">Comments</span>
<span class="stat-value">${commentsCount}</span>
<div class="progress-bar">
<div class="progress-fill" style="width: ${commentsPercent}%"></div>
</div>
</div>
</div>
<div class="developer-metrics">
<div class="metric">
<span class="metric-label">活跃度</span>
<span class="metric-value">${activityTotal}</span>
</div>
<div class="metric">
<span class="metric-label">变化率</span>
<span class="metric-value ${developer.change >= 0 ? 'positive' : 'negative'}">
${developer.change >= 0 ? '+' : ''}${developer.change.toFixed(1)}%
</span>
</div>
</div>
<div class="developer-tags">
<span class="tag ${developer.openrank > 80 ? 'tag-success' : developer.openrank > 50 ? 'tag-warning' : 'tag-primary'}">
${developer.openrank > 80 ? '核心贡献者' : developer.openrank > 50 ? '活跃开发者' : '普通贡献者'}
</span>
<span class="tag ${activityTotal > 100 ? 'tag-success' : activityTotal > 50 ? 'tag-warning' : 'tag-primary'}">
${activityTotal > 100 ? '高度活跃' : activityTotal > 50 ? '中等活跃' : '低活跃度'}
</span>
</div>
</div>
`;
}
// 初始化开发者分析图表
initDeveloperCharts(developers) {
// OpenRank 分布图表
const distCtx = document.getElementById('developer-distribution-chart').getContext('2d');
const openrankValues = developers.map(d => d.openrank);
new Chart(distCtx, {
type: 'bar',
data: {
labels: developers.map(d => d.name),
datasets: [{
label: 'OpenRank 值',
data: openrankValues,
backgroundColor: openrankValues.map(val =>
val > 80 ? 'rgba(0, 255, 136, 0.6)' :
val > 50 ? 'rgba(255, 193, 7, 0.6)' :
'rgba(0, 212, 255, 0.6)'
),
borderColor: openrankValues.map(val =>
val > 80 ? 'rgba(0, 255, 136, 1)' :
val > 50 ? 'rgba(255, 193, 7, 1)' :
'rgba(0, 212, 255, 1)'
),
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (context) => `OpenRank: ${context.raw.toFixed(2)}`
}
}
},
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(255, 255, 255, 0.1)' },
ticks: { color: '#ffffff' }
},
x: {
grid: { display: false },
ticks: {
color: '#ffffff',
maxRotation: 45,
minRotation: 45
}
}
}
}
});
// 活动类型占比图表
const activityCtx = document.getElementById('developer-activity-chart').getContext('2d');
const totalIssues = developers.reduce((sum, d) => sum + d.activity.issues, 0);
const totalPRs = developers.reduce((sum, d) => sum + d.activity.prs, 0);
const totalComments = developers.reduce((sum, d) => sum + d.activity.comments, 0);
new Chart(activityCtx, {
type: 'doughnut',
data: {
labels: ['Issues', 'PRs', 'Comments'],
datasets: [{
data: [totalIssues, totalPRs, totalComments],
backgroundColor: [
'rgba(0, 212, 255, 0.8)',
'rgba(0, 255, 136, 0.8)',
'rgba(255, 193, 7, 0.8)'
],
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: { color: '#ffffff' }
}
}
}
});
}
// 过滤仓库
filterRepositories(filterType) {
let filteredRepos = [...this.mockData.repositories];
switch (filterType) {
case 'top10':
filteredRepos = filteredRepos.sort((a, b) => b.openrank - a.openrank).slice(0, 10);
break;
case 'rising':
// 模拟上升最快的仓库(按某种指标排序)
filteredRepos = filteredRepos.sort((a, b) => b.stars - a.stars).slice(0, 10);
break;
}
this.displayFilteredRepositories(filteredRepos);
}
// 显示过滤后的仓库
displayFilteredRepositories(repos) {
const repoGrid = document.getElementById('repo-grid');
repoGrid.innerHTML = repos.map(repo => `
<div class="repo-card">
<h3>${repo.name}</h3>
<div class="repo-stats">
<div>OpenRank: <strong>${repo.openrank.toFixed(2)}</strong></div>
<div>Stars: ${repo.stars}</div>
<div>Forks: ${repo.forks}</div>
<div>Contributors: ${repo.contributors}</div>
</div>
</div>
`).join('');
}
// 更新配置
updateConfig(settingId, value) {
console.log('Config updated:', settingId, value);
// 这里可以添加配置保存逻辑
}
// 计算 OpenRank模拟
calculateOpenRank() {
const ownerInput = document.getElementById('calc-owner');
const repoInput = document.getElementById('calc-repo');
const owner = ownerInput ? ownerInput.value.trim() : '';
const repo = repoInput ? repoInput.value.trim() : '';
const startInput = document.getElementById('calc-start');
const endInput = document.getElementById('calc-end');
const startVal = startInput && startInput.value ? startInput.value : '';
const endVal = endInput && endInput.value ? endInput.value : '';
const useExplicitDates = startVal && endVal;
if (!owner || !repo) {
const btn = document.getElementById('calculate-btn');
if (btn) {
const old = btn.dataset.originalText || btn.textContent;
btn.dataset.originalText = old;
btn.textContent = '请输入 owner 和 repo 再计算';
btn.disabled = true;
setTimeout(()=>{ if (btn){ btn.textContent = old; btn.disabled = false; } }, 1800);
}
this.showNotification('请输入 owner 和 repo 再计算', 'negative');
return;
}
this.showLoading();
const payload = { owner, repo, topNUsers: 50, topNRepos: 20, useReal: true };
if (useExplicitDates) {
payload.startDate = new Date(startVal + 'T00:00:00Z').toISOString();
const endDateObj = new Date(endVal + 'T00:00:00Z');
endDateObj.setUTCDate(endDateObj.getUTCDate() + 1);
endDateObj.setUTCSeconds(endDateObj.getUTCSeconds() - 1);
payload.endDate = endDateObj.toISOString();
} else {
// 默认最近 30 天
payload.days = 30;
}
fetch('http://localhost:4001/api/calculate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(async resp => {
if (!resp.ok) {
const err = await resp.json().catch(()=>({}));
throw new Error(err.error || resp.statusText || '计算失败');
}
return resp.json();
})
.then(data => {
if (data && data.results) {
const r = data.results;
this.mockData.developers = (r.users || []).map(u => ({ id: u.id, name: u.name, openrank: u.openrank||0, change: u.change||0, activity: u.activity || { issues:0, prs:0, comments:0 } }));
this.mockData.repositories = (r.repos || []).map(p => ({ id: p.id, name: p.name, openrank: p.openrank||0, stars: p.stars||0, forks: p.forks||0, contributors: p.contributors||0 }));
if (Array.isArray(r.openrankDistribution) && r.openrankDistribution.length) this.mockData.openrankDistribution = r.openrankDistribution;
if (r.activityDistribution && Object.keys(r.activityDistribution).length) this.mockData.activityDistribution = r.activityDistribution;
this.repoContributors = r.repoContributors || {};
const sumActivity = (this.mockData.developers||[]).reduce((s,d)=> s + (d.activity.issues||0)+(d.activity.prs||0)+(d.activity.comments||0),0);
if (sumActivity === 0) {
this.showNotification('警告:本次计算活动总量为 0可能没有抓到真实数据或窗口内为空', 'negative');
}
}
this.hideLoading();
this.loadMockData();
try {
if (this.charts.distribution) {
const labels = (this.mockData.openrankDistribution || []).map(d=>d.range);
const counts = (this.mockData.openrankDistribution || []).map(d=>d.count);
this.charts.distribution.data.labels = labels;
if (this.charts.distribution.data.datasets && this.charts.distribution.data.datasets[0]) {
this.charts.distribution.data.datasets[0].data = counts;
}
this.charts.distribution.update();
}
if (this.charts.activity && this.mockData.activityDistribution) {
this.charts.activity.data.datasets[0].data = [
this.mockData.activityDistribution.issueComment,
this.mockData.activityDistribution.openIssue,
this.mockData.activityDistribution.openPull,
this.mockData.activityDistribution.reviewComment,
this.mockData.activityDistribution.mergedPull
];
this.charts.activity.update();
}
} catch (e) { console.warn('chart update failed', e); }
if (this.mode === 'project') {
this.projectState.list = []; // clear to force reload
this.buildProjectListFromGlobal();
}
const winDays = (data && data.window && data.window.days) ? data.window.days : (payload.days|| (useExplicitDates ? Math.round((new Date(payload.endDate)-new Date(payload.startDate))/86400000) : 30));
const winLabel = useExplicitDates ? `${payload.startDate.slice(0,10)} ~ ${payload.endDate.slice(0,10)} (${winDays}天)` : `最近 ${winDays}`;
this.showNotification('OpenRank 计算完成!窗口: ' + winLabel, 'success');
this.closeCalcModal();
// 自动切换到项目模式并高亮新仓库
this.switchMode('project');
const fullName = owner + '/' + repo;
this.projectState.current = fullName;
// 延迟等待列表刷新
setTimeout(()=>{
this.renderProjectList();
if (!this.projectState.approxSnapshots[fullName]) this.renderApproxProject(fullName, winDays);
}, 600);
})
.catch(err => {
console.error('计算失败', err);
this.hideLoading();
this.showNotification('OpenRank 计算失败: ' + (err.message || err), 'negative');
});
}
openCalcModal() {
const modal = document.getElementById('calc-modal');
if (!modal) return;
modal.classList.add('open');
const ownerInput = document.getElementById('calc-owner');
if (ownerInput) ownerInput.focus();
// bind close buttons
modal.querySelectorAll('[data-close-calc]').forEach(btn => btn.addEventListener('click', () => this.closeCalcModal(), { once: true }));
// form submit
const form = document.getElementById('calc-form');
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
this.calculateOpenRank();
}, { once: true });
}
// quick range buttons
const startInput = document.getElementById('calc-start');
const endInput = document.getElementById('calc-end');
const quickBtns = modal.querySelectorAll('.quick-range-btn');
if (quickBtns && startInput && endInput) {
quickBtns.forEach(btn => {
btn.addEventListener('click', () => {
const days = parseInt(btn.getAttribute('data-range')||'0', 10);
if (!days || days <= 0) return;
// remove previous active
quickBtns.forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
const today = new Date();
const endDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
const startDate = new Date(endDate.getTime() - (days - 1) * 86400000);
const fmt = d => d.toISOString().slice(0,10);
startInput.value = fmt(startDate);
endInput.value = fmt(endDate);
}, { once: true }); // once per modal open
});
}
}
closeCalcModal() {
const modal = document.getElementById('calc-modal');
if (modal) modal.classList.remove('open');
}
// 显示加载动画
showLoading() {
document.getElementById('loading-overlay').classList.add('active');
}
// 隐藏加载动画
hideLoading() {
document.getElementById('loading-overlay').classList.remove('active');
}
// 刷新数据
refreshData() {
this.mockData = this.generateMockData();
this.loadMockData();
// 更新图表
if (this.charts.distribution) {
this.charts.distribution.data.datasets[0].data =
this.mockData.openrankDistribution.map(d => d.count);
this.charts.distribution.update();
}
if (this.charts.activity) {
this.charts.activity.data.datasets[0].data = [
this.mockData.activityDistribution.issueComment,
this.mockData.activityDistribution.openIssue,
this.mockData.activityDistribution.openPull,
this.mockData.activityDistribution.reviewComment,
this.mockData.activityDistribution.mergedPull
];
this.charts.activity.update();
}
}
// 显示通知
showNotification(message, type = 'info') {
// 简单的通知实现
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
border-radius: 8px;
color: white;
font-weight: 500;
z-index: 1001;
background: ${type === 'success' ? '#00ff88' : '#00d4ff'};
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.remove();
}, 3000);
}
// Fetch and show repo details modal
async showRepoDetails(owner, repoName) {
// create modal container if not exist
let modal = document.getElementById('repo-detail-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'repo-detail-modal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content">
<div style="position:relative;">
<button class="modal-close">×</button>
<h3 id="repo-detail-title">仓库详细</h3>
</div>
<div id="repo-detail-summary"></div>
<div id="repo-detail-contributors"></div>
<div style="height:240px"><canvas id="repo-detail-timeseries"></canvas></div>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('.modal-close').addEventListener('click', () => modal.classList.remove('open'));
}
// show loading state
const title = modal.querySelector('#repo-detail-title');
const summary = modal.querySelector('#repo-detail-summary');
const contribContainer = modal.querySelector('#repo-detail-contributors');
const canvas = modal.querySelector('#repo-detail-timeseries');
title.textContent = `仓库:${owner}/${repoName}`;
summary.innerHTML = `<p>加载中…</p>`;
contribContainer.innerHTML = '';
modal.classList.add('open');
try {
const params = new URLSearchParams({ start: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(), end: new Date().toISOString(), granularity: 'day', useCache: '1' });
// ensure owner and repo are separate path segments (do not include a slash in repoName)
const resp = await fetch(`http://localhost:4001/api/repo/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/details?${params.toString()}`);
if (!resp.ok) {
const err = await resp.json().catch(()=>({}));
throw new Error(err.error || resp.statusText || '无法获取仓库详情');
}
const data = await resp.json();
// repository window summary
const winStart = data.window && data.window.start ? data.window.start : '';
const winEnd = data.window && data.window.end ? data.window.end : '';
// repo metadata (be defensive against different field names)
const repoMeta = data.repo || data.repository || {};
const fullName = repoMeta.full_name || repoMeta.fullName || `${owner}/${repoName}`;
const stars = repoMeta.stars || repoMeta.stargazers_count || repoMeta.stargazers || 0;
const forks = repoMeta.forks || repoMeta.forks_count || 0;
const repoDescription = repoMeta.description || '';
summary.innerHTML = `
<div class="repo-overview">
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap;">
<div style="flex:1;min-width:240px;">
<h4 style="margin:0 0 .25rem 0">${fullName}</h4>
<div style="color:rgba(255,255,255,0.8);margin-bottom:.5rem">${repoDescription}</div>
<div style="font-size:.9rem;color:rgba(255,255,255,0.75)">时间窗口: ${winStart}${winEnd}</div>
</div>
<div style="display:flex;gap:0.75rem;align-items:center;">
<div class="metric" style="padding:.6rem .8rem;min-width:110px;text-align:center">
<div style="font-size:.85rem;color:rgba(255,255,255,0.8)">Stars</div>
<div style="font-weight:700;font-size:1.1rem">${formatNumber(stars)}</div>
</div>
<div class="metric" style="padding:.6rem .8rem;min-width:110px;text-align:center">
<div style="font-size:.85rem;color:rgba(255,255,255,0.8)">Forks</div>
<div style="font-weight:700;font-size:1.1rem">${formatNumber(forks)}</div>
</div>
<div class="metric" style="padding:.6rem .8rem;min-width:110px;text-align:center">
<div style="font-size:.85rem;color:rgba(255,255,255,0.8)">Contributors</div>
<div style="font-weight:700;font-size:1.1rem">${(data.contributors && data.contributors.length) || '-'}</div>
</div>
</div>
</div>
</div>
`;
// Build a compact contributors section (top 10) and an activity breakdown area
const contributors = (data.contributors || []).slice(0, 10);
if (!contributors || contributors.length === 0) {
contribContainer.innerHTML = `<p>未找到贡献者数据。</p>`;
} else {
const rows = contributors.map(c => `
<tr>
<td>${c.name || c.actor_login || 'unknown'}</td>
<td style="text-align:right">${(c.activity && (c.activity.issues||0)) || (c.issues||0)}</td>
<td style="text-align:right">${(c.activity && (c.activity.prs||0)) || (c.prs||0)}</td>
<td style="text-align:right">${(c.activity && (c.activity.comments||0)) || (c.comments||0)}</td>
<td style="text-align:right">${c.merged || 0}</td>
</tr>
`).join('');
contribContainer.innerHTML = `
<h4>Top 贡献者(最多 10 人)</h4>
<table class="simple-table">
<thead><tr><th>名称</th><th style="text-align:right">Issues</th><th style="text-align:right">PRs</th><th style="text-align:right">Comments</th><th style="text-align:right">Merged</th></tr></thead>
<tbody>${rows}</tbody>
</table>
`;
}
// compute aggregated activity for breakdown (fallback to timeseries sums)
const ts = data.timeseries || [];
const labels = ts.map(p=>p.period);
const issuesData = ts.map(p=>p.issues || 0);
const prsData = ts.map(p=>p.prs || 0);
const mergedData = ts.map(p=>p.merged || 0);
const commentsData = ts.map(p=>p.comments || 0);
const totalIssues = issuesData.reduce((a,b)=>a+b,0);
const totalPRs = prsData.reduce((a,b)=>a+b,0);
const totalMerged = mergedData.reduce((a,b)=>a+b,0);
const totalComments = commentsData.reduce((a,b)=>a+b,0);
// activity breakdown canvas (insert above timeseries if not present)
let breakdownCanvas = modal.querySelector('#repo-activity-breakdown');
if (!breakdownCanvas) {
const el = document.createElement('div');
el.innerHTML = `<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-top:1rem">
<div style="flex:1;min-width:260px">
<h4>活动占比</h4>
<div style="height:180px"><canvas id="repo-activity-breakdown"></canvas></div>
</div>
<div style="flex:2;min-width:300px">
<h4>时间序列</h4>
<div style="height:220px"><canvas id="repo-detail-timeseries"></canvas></div>
</div>
</div>`;
// replace existing canvas area
canvas.parentElement.parentElement.insertBefore(el, canvas.parentElement);
// remove original single-canvas wrapper
canvas.parentElement.remove();
}
// cleanup existing chart instances
if (this.charts.repoTimeseries) { try { this.charts.repoTimeseries.destroy(); } catch(e){} }
if (this.charts.repoActivityBreakdown) { try { this.charts.repoActivityBreakdown.destroy(); } catch(e){} }
// create breakdown chart
const breakdownCtx = (modal.querySelector('#repo-activity-breakdown') || document.getElementById('repo-activity-breakdown')).getContext('2d');
this.charts.repoActivityBreakdown = new Chart(breakdownCtx, {
type: 'doughnut',
data: {
labels: ['Issues','PRs','Merged','Comments'],
datasets: [{ data: [totalIssues, totalPRs, totalMerged, totalComments], backgroundColor: ['#00d4ff','#00ff88','#ffd700','#ff7ab6'] }]
},
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{position:'right', labels:{color:'#fff'}}} }
});
// create timeseries chart
const tsCanvas = modal.querySelector('#repo-detail-timeseries') || canvas;
const ctxTs = tsCanvas.getContext('2d');
this.charts.repoTimeseries = new Chart(ctxTs, {
type: 'line',
data: {
labels,
datasets: [
{ label: 'Issues', data: issuesData, borderColor: '#00d4ff', fill:false },
{ label: 'PRs', data: prsData, borderColor: '#00ff88', fill:false },
{ label: 'Merged', data: mergedData, borderColor: '#ffd700', fill:false }
]
},
options: { responsive:true, maintainAspectRatio:false }
});
} catch (err) {
console.error('showRepoDetails error', err);
summary.innerHTML = `<div class="error">无法获取仓库详情: ${(err && err.message) || err}</div>`;
}
}
// 更新最后更新时间
updateLastUpdateTime() {
const now = new Date();
document.getElementById('last-update').textContent =
now.toLocaleString('zh-CN');
}
}
// 页面加载完成后初始化应用
document.addEventListener('DOMContentLoaded', () => {
new OpenRankDashboard();
});
// 工具函数
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}