|
|
// 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);
|
|
|
};
|
|
|
}
|