DEV-004: M-DEVBOARD环节0-2-开发者实时看板-纯前端-PCA雷达图+排行榜

This commit is contained in:
zhizhi200271 2026-03-11 20:27:40 +08:00
commit 451a513d1b
10 changed files with 651 additions and 0 deletions

View File

@ -0,0 +1,140 @@
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: linear-gradient(145deg, var(--color-bg-card), #2a3442);
border-radius: 16px;
padding: 1.5rem 1rem;
text-align: center;
border: 1px solid var(--color-border);
}
.stat-label {
font-size: 0.9rem;
color: var(--color-text-secondary);
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.stat-number {
font-size: 2.5rem;
font-weight: 800;
background: linear-gradient(135deg, #fff, var(--color-accent-primary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.25rem;
}
.dev-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.dev-card {
background: var(--color-bg-card);
border-radius: 20px;
padding: 1.5rem;
border: 1px solid var(--color-border);
cursor: pointer;
}
.dev-card-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.2rem;
}
.dev-avatar {
width: 50px;
height: 50px;
border-radius: 25px;
background: linear-gradient(135deg, var(--color-accent-primary), #4cc9f0);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.5rem;
color: white;
}
.dev-name {
font-size: 1.2rem;
font-weight: 700;
}
.dev-id {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.dev-streak {
font-size: 1.8rem;
font-weight: 800;
color: var(--color-accent-primary);
}
.ranking-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.ranking-list {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.ranking-item {
display: grid;
grid-template-columns: 40px 1fr 70px;
align-items: center;
background: var(--color-bg-card);
border-radius: 12px;
padding: 0.8rem;
border: 1px solid var(--color-border);
}
.ranking-streak-bar {
background: var(--color-bg-secondary);
height: 16px;
border-radius: 20px;
position: relative;
overflow: hidden;
}
.streak-progress {
height: 100%;
background: linear-gradient(90deg, #00b4d8, #4cc9f0);
}
.streak-count {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
font-size: 0.7rem;
color: white;
}
.loading-spinner {
width: 30px;
height: 30px;
border: 3px solid var(--color-border);
border-top-color: var(--color-accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@ -0,0 +1,58 @@
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background-color: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.navbar-logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: bold;
font-size: 1.2rem;
}
.navbar-logo span {
color: var(--color-accent-primary);
}
.navbar-info {
display: flex;
gap: 1.5rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.main-container {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
gap: 1.5rem;
padding: 1.5rem;
min-height: calc(100vh - 70px);
}
.sidebar-left, .main-board, .sidebar-right {
background-color: var(--color-bg-secondary);
border-radius: 12px;
padding: 1.5rem;
}
@media (max-width: 1024px) {
.main-container {
grid-template-columns: 1fr 2fr;
}
.sidebar-right {
grid-column: span 2;
}
}
@media (max-width: 768px) {
.main-container {
grid-template-columns: 1fr;
}
.sidebar-left, .main-board, .sidebar-right {
grid-column: span 1;
}
}

View File

@ -0,0 +1,43 @@
:root {
--color-bg-primary: #0a0e1a;
--color-bg-secondary: #111827;
--color-bg-card: #1e2433;
--color-text-primary: #ffffff;
--color-text-secondary: #a0aec0;
--color-accent-primary: #00b4d8;
--color-accent-secondary: #0077be;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
--color-border: #2d3748;
--breakpoint-mobile: 768px;
--breakpoint-tablet: 1024px;
--font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family-base);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
line-height: 1.6;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}

View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HoloLake 开发者实时看板</title>
<link rel="stylesheet" href="css/theme.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/components.css">
</head>
<body>
<header class="navbar">
<div class="navbar-logo">
<span></span>
<span>HoloLake · DevBoard</span>
</div>
<div class="navbar-info">
<span id="last-update-time">最后更新: --:--:--</span>
<span id="refresh-indicator">⏳ 30s</span>
</div>
</header>
<main class="main-container">
<aside class="sidebar-left">
<h3 style="color: var(--color-accent-primary);">🧠 PCA雷达图</h3>
<div style="text-align: center; margin: 0.5rem 0;">
<span id="current-dev-name">之之</span> · 点击卡片切换
</div>
<canvas id="pca-canvas" width="300" height="300" style="width:100%; height:auto;"></canvas>
</aside>
<section class="main-board">
<div id="stats-container"></div>
<div id="devgrid-container"></div>
</section>
<aside class="sidebar-right">
<div id="ranking-container"></div>
</aside>
</main>
<script src="js/utils.js"></script>
<script src="js/api.js"></script>
<script src="js/board.js"></script>
<script src="js/radar.js"></script>
<script src="js/ranking.js"></script>
<script src="js/app.js"></script>
</body>
</html>

View File

@ -0,0 +1,54 @@
const MOCK_DEVELOPERS = [
{ id: 'DEV-001', name: '玄泓', streak: 8, el: 6, pca: { exe: 75, tec: 68, sys: 82, col: 79, ini: 85 }, totalScore: 78, status: 'doing', module: 'M-CORE', lastActive: '2026-03-11T10:30:00Z' },
{ id: 'DEV-002', name: '苍钺', streak: 5, el: 5, pca: { exe: 82, tec: 71, sys: 65, col: 90, ini: 72 }, totalScore: 76, status: 'pending', module: 'M-UI', lastActive: '2026-03-11T09:15:00Z' },
{ id: 'DEV-003', name: '桔子', streak: 13, el: 8, pca: { exe: 88, tec: 85, sys: 92, col: 78, ini: 90 }, totalScore: 87, status: 'doing', module: 'M-ORCHESTRATOR', lastActive: '2026-03-11T11:20:00Z' },
{ id: 'DEV-004', name: '之之', streak: 12, el: 8, pca: { exe: 70, tec: 32, sys: 80, col: 80, ini: 83 }, totalScore: 66, status: 'doing', module: 'M-DEVBOARD', lastActive: '2026-03-11T12:05:00Z' },
{ id: 'DEV-005', name: '琉光', streak: 7, el: 6, pca: { exe: 79, tec: 74, sys: 71, col: 88, ini: 77 }, totalScore: 78, status: 'done', module: 'M-TEST', lastActive: '2026-03-10T16:40:00Z' },
{ id: 'DEV-006', name: '墨羽', streak: 4, el: 4, pca: { exe: 65, tec: 60, sys: 55, col: 92, ini: 68 }, totalScore: 68, status: 'doing', module: 'M-DOC', lastActive: '2026-03-11T08:50:00Z' },
{ id: 'DEV-007', name: '霜砚', streak: 10, el: 7, pca: { exe: 84, tec: 79, sys: 81, col: 75, ini: 82 }, totalScore: 80, status: 'blocked', module: 'M-API', lastActive: '2026-03-11T07:30:00Z' },
{ id: 'DEV-008', name: '岚茵', streak: 6, el: 5, pca: { exe: 73, tec: 70, sys: 68, col: 85, ini: 74 }, totalScore: 74, status: 'pending', module: 'M-DESIGN', lastActive: '2026-03-10T14:20:00Z' }
];
function getTopStreak(developers) {
if (!developers || developers.length === 0) return { name: '无', count: 0 };
let top = developers[0];
for (let i = 1; i < developers.length; i++) {
if (developers[i].streak > top.streak) {
top = developers[i];
}
}
return { name: top.name, count: top.streak };
}
async function getDevStatus() {
return MOCK_DEVELOPERS;
}
async function getPCA(devId) {
const dev = MOCK_DEVELOPERS.find(d => d.id === devId);
if (!dev) return null;
return {
exe: dev.pca.exe,
tec: dev.pca.tec,
sys: dev.pca.sys,
col: dev.pca.col,
ini: dev.pca.ini,
total: dev.totalScore,
level: getPCAColor(dev.totalScore).level
};
}
async function getAllStats() {
const activeDevs = MOCK_DEVELOPERS.filter(d => d.status === 'doing').length;
const topStreak = getTopStreak(MOCK_DEVELOPERS);
return {
totalDevs: 8,
activeDevs: activeDevs,
totalCodeLines: 85600,
totalModules: 24,
modulesCompleted: 8,
modulesInProgress: 12,
modulesPending: 4,
topStreak: topStreak
};
}

View File

@ -0,0 +1,53 @@
let refreshInterval;
let countdownInterval;
let seconds = 30;
function updateLastUpdateTime() {
const el = document.getElementById('last-update-time');
if (el) {
const now = new Date();
el.textContent = `最后更新: ${now.toLocaleTimeString('zh-CN', { hour12: false })}`;
}
}
function updateCountdown() {
const el = document.getElementById('refresh-indicator');
if (el) el.textContent = `${seconds}s`;
}
function startCountdown() {
seconds = 30;
updateCountdown();
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(() => {
seconds--;
if (seconds <= 0) seconds = 30;
updateCountdown();
}, 1000);
}
async function refreshAllData() {
const devs = await getDevStatus();
const stats = await getAllStats();
if (typeof renderStats === 'function') renderStats(stats);
if (typeof renderDevCards === 'function') renderDevCards(devs);
if (typeof renderRanking === 'function') renderRanking(devs);
updateLastUpdateTime();
}
async function initApp() {
await initBoard();
await initRanking();
initRadar();
await refreshAllData();
startCountdown();
if (refreshInterval) clearInterval(refreshInterval);
refreshInterval = setInterval(refreshAllData, 30000);
}
document.addEventListener('DOMContentLoaded', initApp);
window.addEventListener('beforeunload', () => {
if (refreshInterval) clearInterval(refreshInterval);
if (countdownInterval) clearInterval(countdownInterval);
});

View File

@ -0,0 +1,72 @@
function renderStats(stats) {
const container = document.getElementById('stats-container');
if (!container) return;
container.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">活跃开发者</div>
<div class="stat-number">${stats.activeDevs}</div>
<div class="stat-sub">总人数 ${stats.totalDevs}</div>
</div>
<div class="stat-card">
<div class="stat-label">总代码量</div>
<div class="stat-number">${formatNumber(stats.totalCodeLines)}</div>
<div class="stat-sub">行代码</div>
</div>
<div class="stat-card">
<div class="stat-label">模块进度</div>
<div class="stat-number">${stats.modulesCompleted}/${stats.totalModules}</div>
<div class="stat-sub"> ${stats.modulesCompleted} / ${stats.modulesInProgress} / ${stats.modulesPending}</div>
</div>
<div class="stat-card">
<div class="stat-label">最高连胜</div>
<div class="stat-number">${stats.topStreak.count}</div>
<div class="stat-sub">${stats.topStreak.name} ${getStreakEmoji(stats.topStreak.count)}</div>
</div>
</div>
`;
}
function renderDevCards(developers) {
const container = document.getElementById('devgrid-container');
if (!container) return;
const sorted = [...developers].sort((a, b) => b.streak - a.streak);
let html = '<div class="dev-grid">';
sorted.forEach(dev => {
const pcaColor = getPCAColor(dev.totalScore);
html += `
<div class="dev-card" data-dev-id="${dev.id}">
<div class="dev-card-header">
<div class="dev-avatar">${dev.name[0]}</div>
<div class="dev-info">
<div class="dev-name">${dev.name}</div>
<div class="dev-id">${dev.id}</div>
</div>
</div>
<div class="dev-module">📁 ${dev.module}</div>
<div class="dev-stats">
<div class="dev-streak">${dev.streak}<span>连胜</span></div>
<div class="dev-badges">
<span class="badge badge-el">EL-${dev.el}</span>
<span class="badge" style="background:${pcaColor.color}20; color:${pcaColor.color}">${pcaColor.level} · ${dev.totalScore}</span>
</div>
</div>
<div class="dev-footer">
<span>${getStatusBadge(dev.status)}</span>
<span>🕒 ${formatDate(dev.lastActive)}</span>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
async function initBoard() {
const devs = await getDevStatus();
const stats = await getAllStats();
renderStats(stats);
renderDevCards(devs);
}
window.initBoard = initBoard;

View File

@ -0,0 +1,112 @@
function drawRadar(canvasId, pcaData) {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const maxR = Math.min(w, h) * 0.35;
ctx.clearRect(0, 0, w, h);
const dims = [
{ label: '执行力', val: pcaData.exe || 0 },
{ label: '技术纵深', val: pcaData.tec || 0 },
{ label: '系统理解', val: pcaData.sys || 0 },
{ label: '协作力', val: pcaData.col || 0 },
{ label: '主动性', val: pcaData.ini || 0 }
];
for (let lv = 1; lv <= 5; lv++) {
const r = (maxR * lv) / 5;
ctx.beginPath();
for (let i = 0; i < 5; i++) {
const angle = (i * 2 * Math.PI / 5) - Math.PI / 2;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.strokeStyle = '#2d3748';
ctx.stroke();
}
for (let i = 0; i < 5; i++) {
const angle = (i * 2 * Math.PI / 5) - Math.PI / 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + maxR * Math.cos(angle), cy + maxR * Math.sin(angle));
ctx.strokeStyle = '#4a5568';
ctx.stroke();
}
ctx.fillStyle = '#cbd5e0';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
for (let i = 0; i < 5; i++) {
const angle = (i * 2 * Math.PI / 5) - Math.PI / 2;
const x = cx + (maxR + 25) * Math.cos(angle);
const y = cy + (maxR + 25) * Math.sin(angle);
ctx.fillText(dims[i].label, x, y);
}
const points = [];
for (let i = 0; i < 5; i++) {
const angle = (i * 2 * Math.PI / 5) - Math.PI / 2;
const r = (dims[i].val / 100) * maxR;
points.push({
x: cx + r * Math.cos(angle),
y: cy + r * Math.sin(angle)
});
}
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < 5; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.closePath();
ctx.fillStyle = 'rgba(0, 180, 216, 0.3)';
ctx.fill();
ctx.strokeStyle = '#00b4d8';
ctx.lineWidth = 2;
ctx.stroke();
points.forEach(p => {
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, 2 * Math.PI);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = '#00b4d8';
ctx.lineWidth = 2;
ctx.stroke();
});
ctx.fillStyle = '#fff';
ctx.font = 'bold 24px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(pcaData.total || '0', cx, cy - 10);
ctx.font = 'bold 14px sans-serif';
const colorInfo = getPCAColor(pcaData.total || 0);
ctx.fillStyle = colorInfo.color;
ctx.fillText(pcaData.level || 'C', cx, cy + 15);
}
async function loadRadarForDev(devId) {
const pcaData = await getPCA(devId);
if (pcaData) drawRadar('pca-canvas', pcaData);
}
function initRadar() {
document.querySelectorAll('.dev-card').forEach(card => {
card.addEventListener('click', () => {
loadRadarForDev(card.dataset.devId);
});
});
loadRadarForDev('DEV-004');
}
window.initRadar = initRadar;
window.loadRadarForDev = loadRadarForDev;

View File

@ -0,0 +1,34 @@
function renderRanking(developers) {
const container = document.getElementById('ranking-container');
if (!container) return;
const sorted = [...developers].sort((a, b) => b.streak - a.streak);
const maxStreak = sorted[0].streak;
let html = '<div class="ranking-header"><h3>🏆 连胜排行榜</h3><span>实时更新</span></div><div class="ranking-list">';
sorted.forEach((dev, idx) => {
const medal = idx === 0 ? '🥇' : idx === 1 ? '🥈' : idx === 2 ? '🥉' : `${idx+1}.`;
const percent = (dev.streak / maxStreak) * 100;
html += `
<div class="ranking-item" data-dev-id="${dev.id}">
<div class="ranking-rank">${medal}</div>
<div class="ranking-info">
<div class="ranking-name">${dev.name}</div>
<div class="ranking-streak-bar">
<div class="streak-progress" style="width:${percent}%"></div>
<span class="streak-count">${dev.streak}连胜</span>
</div>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
async function initRanking() {
const devs = await getDevStatus();
renderRanking(devs);
}
window.initRanking = initRanking;

View File

@ -0,0 +1,36 @@
function formatDate(isoString) {
if (!isoString) return '--:--:--';
const date = new Date(isoString);
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function getStreakEmoji(count) {
if (count >= 20) return '🔥🔥🔥';
if (count >= 15) return '🔥🔥';
if (count >= 10) return '🔥';
if (count >= 5) return '⚡';
if (count >= 3) return '👍';
return '👣';
}
function getPCAColor(score) {
if (score >= 90) return { color: '#FFD700', level: 'S', text: '传奇' };
if (score >= 80) return { color: '#00B4D8', level: 'A', text: '精英' };
if (score >= 70) return { color: '#10B981', level: 'B', text: '专家' };
if (score >= 60) return { color: '#F59E0B', level: 'C', text: '熟手' };
return { color: '#EF4444', level: 'D', text: '新手' };
}
function getStatusBadge(status) {
const map = {
'done': '✅ 已完成',
'doing': '⚡ 进行中',
'pending': '⏳ 待分配',
'blocked': '⚠️ 阻塞'
};
return map[status] || '⏳ 待分配';
}
function formatNumber(num) {
return num?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') || '0';
}