New
This commit is contained in:
225
web/static/js/status-grid.js
Normal file
225
web/static/js/status-grid.js
Normal file
@@ -0,0 +1,225 @@
|
||||
// Filename: web/static/js/status-grid.js (V6.0 - 健壮初始化最终版)
|
||||
// export default function initializeStatusGrid() {
|
||||
class StatusGrid {
|
||||
constructor(canvasId) {
|
||||
this.canvas = document.getElementById(canvasId);
|
||||
if (!this.canvas) { return; }
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
|
||||
this.config = {
|
||||
cols: 100, rows: 4, gap: 2, cornerRadius: 2,
|
||||
colors: {
|
||||
'ACTIVE': '#22C55E', 'ACTIVE_BLINK': '#A7F3D0',
|
||||
'PENDING': '#9CA3AF', 'COOLDOWN': '#EAB308',
|
||||
'DISABLED': '#F97316', 'BANNED': '#EF4444',
|
||||
'EMPTY': '#F3F4F6',
|
||||
},
|
||||
blinkInterval: 100, blinksPerInterval: 2, blinkDuration: 200,
|
||||
statusOrder: ['ACTIVE', 'COOLDOWN', 'DISABLED', 'BANNED', 'PENDING']
|
||||
};
|
||||
this.state = {
|
||||
squares: [], squareSize: 0,
|
||||
devicePixelRatio: window.devicePixelRatio || 1,
|
||||
animationFrameId: null, blinkIntervalId: null,
|
||||
blinkingSquares: new Set()
|
||||
};
|
||||
|
||||
this.debouncedResize = this.debounce(this.resize.bind(this), 250);
|
||||
}
|
||||
init() {
|
||||
this.setupCanvas();
|
||||
// 初始化时,绘制一个完全为空的网格
|
||||
this.state.squares = Array(this.config.rows * this.config.cols).fill({ status: 'EMPTY' });
|
||||
this.drawFullGrid();
|
||||
window.addEventListener('resize', this.debouncedResize);
|
||||
}
|
||||
|
||||
/**
|
||||
* [灵魂重塑] 这是被彻底重写的核心函数
|
||||
* @param {object} keyStatusCounts - 例如 { "BANNED": 1, "DISABLED": 4, ... }
|
||||
*/
|
||||
updateData(keyStatusCounts) {
|
||||
if (!keyStatusCounts) return;
|
||||
|
||||
this.destroyAnimation();
|
||||
this.state.squares = [];
|
||||
this._activeIndices = null;
|
||||
this.state.blinkingSquares.clear();
|
||||
const totalKeys = Object.values(keyStatusCounts).reduce((s, c) => s + c, 0);
|
||||
const totalSquares = this.config.rows * this.config.cols;
|
||||
// 如果没有密钥,则显示为空白的网格
|
||||
if (totalKeys === 0) {
|
||||
this.init();
|
||||
return;
|
||||
}
|
||||
let statusMap = [];
|
||||
let calculatedSquares = 0;
|
||||
// 1. 严格按照比例,计算每个状态应该占据多少个“像素”
|
||||
for (const status of this.config.statusOrder) {
|
||||
const count = keyStatusCounts[status] || 0;
|
||||
if (count > 0) {
|
||||
const proportion = count / totalKeys;
|
||||
const squaresForStatus = Math.floor(proportion * totalSquares);
|
||||
for (let i = 0; i < squaresForStatus; i++) {
|
||||
statusMap.push(status);
|
||||
}
|
||||
calculatedSquares += squaresForStatus;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. [关键] 修正四舍五入的误差,将剩余的方块填满
|
||||
const remainingSquares = totalSquares - calculatedSquares;
|
||||
if (remainingSquares > 0) {
|
||||
// 将剩余方块,全部分配给数量最多的那个状态,以使其最不失真
|
||||
let largestStatus = this.config.statusOrder[0]; // 默认给第一个
|
||||
let maxCount = -1;
|
||||
for (const status in keyStatusCounts) {
|
||||
if (keyStatusCounts[status] > maxCount) {
|
||||
maxCount = keyStatusCounts[status];
|
||||
largestStatus = status;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < remainingSquares; i++) {
|
||||
statusMap.push(largestStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 将最终的、按比例填充的地图,转化为内部的 square 对象
|
||||
this.state.squares = statusMap.map(status => ({
|
||||
status: status,
|
||||
isBlinking: false,
|
||||
blinkUntil: 0
|
||||
}));
|
||||
|
||||
// 4. [渲染修正] 直接、完整地重绘整个网格,然后启动动画
|
||||
this.drawFullGrid();
|
||||
this.startAnimationSystem();
|
||||
}
|
||||
// [渲染修正] 一个简单、直接、一次性绘制所有方块的函数
|
||||
drawFullGrid() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
|
||||
// offsetX 和 offsetY 是在 setupCanvas 中计算的
|
||||
const offsetX = this.state.offsetX;
|
||||
const offsetY = this.state.offsetY;
|
||||
this.state.squares.forEach((square, i) => {
|
||||
const c = i % this.config.cols;
|
||||
const r = Math.floor(i / this.config.cols);
|
||||
|
||||
const x = offsetX + c * (this.state.squareSize + this.config.gap);
|
||||
const y = offsetY + r * (this.state.squareSize + this.config.gap);
|
||||
const color = this.config.colors[square.status];
|
||||
this.drawRoundedRect(x, y, this.state.squareSize, this.config.cornerRadius, color);
|
||||
});
|
||||
}
|
||||
|
||||
startAnimationSystem() {
|
||||
if(this.state.blinkIntervalId) clearInterval(this.state.blinkIntervalId);
|
||||
if(this.state.animationFrameId) cancelAnimationFrame(this.state.animationFrameId);
|
||||
|
||||
this.state.blinkIntervalId = setInterval(() => {
|
||||
const activeSquareIndices = this.getActiveSquareIndices();
|
||||
if (activeSquareIndices.length === 0) return;
|
||||
|
||||
for(let i = 0; i < this.config.blinksPerInterval; i++) {
|
||||
const randomIndex = activeSquareIndices[Math.floor(Math.random() * activeSquareIndices.length)];
|
||||
const square = this.state.squares[randomIndex];
|
||||
|
||||
if (square && !square.isBlinking) {
|
||||
square.isBlinking = true;
|
||||
square.blinkUntil = performance.now() + this.config.blinkDuration;
|
||||
this.state.blinkingSquares.add(randomIndex);
|
||||
}
|
||||
}
|
||||
}, this.config.blinkInterval);
|
||||
|
||||
const animationLoop = (timestamp) => {
|
||||
if (this.state.blinkingSquares.size > 0) {
|
||||
this.state.blinkingSquares.forEach(index => {
|
||||
const square = this.state.squares[index];
|
||||
if(!square) { // 防御性检查
|
||||
this.state.blinkingSquares.delete(index);
|
||||
return;
|
||||
}
|
||||
const c = index % this.config.cols;
|
||||
const r = Math.floor(index / this.config.cols);
|
||||
|
||||
this.drawSquare(square, c, r);
|
||||
|
||||
if (timestamp > square.blinkUntil) {
|
||||
square.isBlinking = false;
|
||||
this.state.blinkingSquares.delete(index);
|
||||
this.drawSquare(square, c, r);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.state.animationFrameId = requestAnimationFrame(animationLoop);
|
||||
};
|
||||
animationLoop();
|
||||
}
|
||||
|
||||
setupCanvas() {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.canvas.width = rect.width * this.state.devicePixelRatio;
|
||||
this.canvas.height = rect.height * this.state.devicePixelRatio;
|
||||
this.ctx.scale(this.state.devicePixelRatio, this.state.devicePixelRatio);
|
||||
const calculatedWidth = (rect.width - (this.config.cols - 1) * this.config.gap) / this.config.cols;
|
||||
const calculatedHeight = (rect.height - (this.config.rows - 1) * this.config.gap) / this.config.rows;
|
||||
this.state.squareSize = Math.max(1, Math.floor(Math.min(calculatedWidth, calculatedHeight)));
|
||||
const totalGridWidth = this.config.cols * this.state.squareSize + (this.config.cols - 1) * this.config.gap;
|
||||
const totalGridHeight = this.config.rows * this.state.squareSize + (this.config.rows - 1) * this.config.gap;
|
||||
this.state.offsetX = Math.floor((rect.width - totalGridWidth) / 2);
|
||||
this.state.offsetY = Math.floor((rect.height - totalGridHeight) / 2);
|
||||
}
|
||||
|
||||
getActiveSquareIndices() {
|
||||
if (!this._activeIndices) {
|
||||
this._activeIndices = [];
|
||||
this.state.squares.forEach((s, i) => {
|
||||
if (s.status === 'ACTIVE') this._activeIndices.push(i);
|
||||
});
|
||||
}
|
||||
return this._activeIndices;
|
||||
}
|
||||
|
||||
drawRoundedRect(x, y, size, radius, color) {
|
||||
this.ctx.fillStyle = color;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(x + radius, y);
|
||||
this.ctx.arcTo(x + size, y, x + size, y + size, radius);
|
||||
this.ctx.arcTo(x + size, y + size, x, y + size, radius);
|
||||
this.ctx.arcTo(x, y + size, x, y, radius);
|
||||
this.ctx.arcTo(x, y, x + size, y, radius);
|
||||
this.ctx.closePath();
|
||||
this.ctx.fill();
|
||||
}
|
||||
|
||||
drawSquare(square, c, r) {
|
||||
const x = this.state.offsetX + c * (this.state.squareSize + this.config.gap);
|
||||
const y = this.state.offsetY + r * (this.state.squareSize + this.config.gap);
|
||||
const color = square.isBlinking ? this.config.colors.ACTIVE_BLINK : this.config.colors[square.status];
|
||||
this.drawRoundedRect(x, y, this.state.squareSize, this.config.cornerRadius, color);
|
||||
}
|
||||
|
||||
destroyAnimation() {
|
||||
if(this.state.animationFrameId) cancelAnimationFrame(this.state.animationFrameId);
|
||||
if(this.state.blinkIntervalId) clearInterval(this.state.blinkIntervalId);
|
||||
this.state.animationFrameId = null;
|
||||
this.state.blinkIntervalId = null;
|
||||
}
|
||||
|
||||
resize() {
|
||||
this.destroyAnimation();
|
||||
this.init(); // 重新绘制空网格
|
||||
// 这里依赖dashboard.js在resize后重新获取并传入数据
|
||||
}
|
||||
|
||||
debounce(func, delay) {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user