Unified guide for authoring dashboard panel components in vanilla TypeScript — covers the authoritative Panel base class (grid-span persistence, dual resize han
复制下面这句话,粘贴给 Claude Code、Codex、Cursor 等 AI 编程工具,它会读取安装说明并在你确认后完成安装。
请阅读 https://ai.atlankj.com/install/asset/gh-panel-base-advanced-07cc597451f8 ,按照其中的说明把「panel-base-advanced」安装到你(当前 AI 工具)中。执行前先告诉我将运行的命令和写入的位置,等我确认。
查看 AI 将读取的安装说明正在读取 GitHub 原文…
内容来自 GitHub 原始文件,由原作者维护。在 GitHub 查看
Create dashboard panel components using vanilla TypeScript (no framework, no JSX).
Each panel is a class extending a Panel base class and optionally wraps its data source with a CircuitBreaker.
Panel (base class)
├── element: HTMLElement (.panel)
│ ├── header: HTMLElement (.panel-header)
│ │ ├── headerLeft (.panel-header-left)
│ │ │ ├── title (.panel-title)
│ │ │ └── newBadge (.panel-new-badge) [optional, via setNewBadge()]
│ │ ├── statusBadge (.panel-data-badge) [optional, via setDataBadge()]
│ │ └── countEl (.panel-count) [optional]
│ ├── content: HTMLElement (.panel-content)
│ ├── resizeHandleRow (.panel-resize-handle-row) [bottom edge]
│ └── resizeHandleCol (.panel-resize-handle-col) [right edge]
| Layer | File | When to use |
|---|---|---|
| isFetching guard | Panel base class | Always — prevents concurrent duplicate requests |
| Exponential-backoff retry | Panel.fetchWithRetry() | When you own the fetch call and want per-request retry before surfacing an error |
| Error countdown in showError() | Panel base class | Automatic retry countdown rendered inside the error state UI |
| CircuitBreaker | src/utils/circuit-breaker.ts | When a service may fail repeatedly; stops hammering the API and serves stale cache instead |
| localStorage persistence | Panel.saveMap() / loadMap() | When you want grid-span (not pixel size) state to survive page reloads |
Wrapping an existing service call with CircuitBreaker is a one-liner:
// Before const data = await fetchStockQuotes(); // After — adds caching + cooldown with zero refactor const data = await breaker.execute(() => fetchStockQuotes(), []);
src/components/Panel.tsAuthoritative implementation. Use this verbatim; do not revert to the older
saveState()/loadState()width/height approach.
export interface PanelOptions {
id: string;
title: string;
showCount?: boolean;
className?: string;
}
export class Panel {
protected element: HTMLElement;
protected content: HTMLElement;
protected header: HTMLElement;
protected headerLeft: HTMLElement;
protected countEl: HTMLElement | null = null;
protected statusBadge: HTMLElement | null = null;
protected newBadge: HTMLElement | null = null;
protected panelId: string;
private _fetching = false;
private _countdownInterval: ReturnType<typeof setInterval> | null = null;
private _resizeListeners: Array<{ target: EventTarget; type: string; fn: EventListenerOrEventListenerObject }> = [];
// Retry state (used by fetchWithRetry)
protected retryAttempts = 0;
protected retryDelay = 1000; // ms; doubles each attempt
private maxRetries = 3;
constructor(options: PanelOptions) {
this.panelId = options.id;
this.element = document.createElement('div');
this.element.className = `panel${options.className ? ' ' + options.className : ''}`;
this.element.dataset.panel = options.id;
// Header
this.header = document.createElement('div');
this.header.className = 'panel-header';
this.headerLeft = document.createElement('div');
this.headerLeft.className = 'panel-header-left';
const title = document.createElement('span');
title.className = 'panel-title';
title.textContent = options.title;
this.headerLeft.appendChild(title);
this.header.appendChild(this.headerLeft);
// Count badge (optional)
if (options.showCount) {
this.countEl = document.createElement('span');
this.countEl.className = 'panel-count';
this.countEl.textContent = '0';
this.header.appendChild(this.countEl);
}
// Content area
this.content = document.createElement('div');
this.content.className = 'panel-content';
this.content.id = `${options.id}Content`;
// Dual resize handles
const resizeHandleRow = document.createElement('div');
resizeHandleRow.className = 'panel-resize-handle-row';
const resizeHandleCol = document.createElement('div');
resizeHandleCol.className = 'panel-resize-handle-col';
this.element.appendChild(this.header);
this.element.appendChild(this.content);
this.element.appendChild(resizeHandleRow);
this.element.appendChild(resizeHandleCol);
this._initResizeHandles(resizeHandleRow, resizeHandleCol);
this.showLoading();
}
// Public API
public getElement(): HTMLElement { return this.element; }
/** Radar-ring loading animation with optional message. */
public showLoading(message = 'Loading...'): void {
this._clearCountdown();
this.content.innerHTML = `
<div class="panel-loading">
<div class="panel-loading-radar">
<div class="radar-ring radar-ring-1"></div>
<div class="radar-ring radar-ring-2"></div>
<div class="radar-ring radar-ring-3"></div>
</div>
<div class="panel-loading-text">${message}</div>
</div>`;
}
/**
* Show an error state.
* If autoRetryMs is provided, renders an exponential-backoff countdown
* inside the error UI and calls onRetry automatically when it reaches 0.
*/
public showError(
message = 'Failed to load',
onRetry?: () => void,
autoRetryMs?: number
): void {
this._clearCountdown();
const countdownId = `panel-error-countdown-${this.panelId}`;
this.content.innerHTML = `
<div class="panel-error-state">
<div class="panel-error-msg">${message}</div>
${autoRetryMs != null
? `<div class="panel-error-countdown" id="${countdownId}">Retrying in ${Math.ceil(autoRetryMs / 1000)}s...</div>`
: ''}
${onRetry ? '<button class="panel-retry-btn" data-panel-retry>Retry</button>' : ''}
</div>`;
if (onRetry) {
this.content.querySelector('[data-panel-retry]')
?.addEventListener('click', () => { this._clearCountdown(); onRetry(); });
}
if (autoRetryMs != null && onRetry) {
let remaining = autoRetryMs;
const getEl = () => this.content.querySelector<HTMLElement>(`#${countdownId}`);
this._countdownInterval = setInterval(() => {
remaining -= 1000;
if (remaining <= 0) {
this._clearCountdown();
onRetry();
} else {
const node = getEl();
if (node) node.textContent = `Retrying in ${Math.ceil(remaining / 1000)}s...`;
}
}, 1000);
}
}
public setContent(html: string): void {
this._clearCountdown();
this.content.innerHTML = html;
}
public setCount(count: number): void {
if (this.countEl) this.countEl.textContent = count.toString();
}
/**
* Show (or update) the data-status badge in the panel header.
* Pass a variant CSS modifier such as 'live', 'stale', or 'error'.
*/
public setDataBadge(text: string, variant = 'live'): void {
if (!this.statusBadge) {
this.statusBadge = document.createElement('span');
this.statusBadge.className = 'panel-data-badge';
this.header.appendChild(this.statusBadge);
}
this.statusBadge.textContent = text;
this.statusBadge.dataset.variant = variant;
}
public clearDataBadge(): void {
this.statusBadge?.remove();
this.statusBadge = null;
}
/** Show the "NEW" badge next to the panel title (idempotent). */
public setNewBadge(text = 'NEW'): void {
if (!this.newBadge) {
this.newBadge = document.createElement('span');
this.newBadge.className = 'panel-new-badge';
this.headerLeft.appendChild(this.newBadge);
}
this.newBadge.textContent = text;
}
public clearNewBadge(): void {
this.newBadge?.remove();
this.newBadge = null;
}
public show(): void { this.element.classList.remove('hidden'); }
public hide(): void { this.element.classList.add('hidden'); }
/** Full teardown: removes element, clears countdown timer, removes all resize listeners. */
public destroy(): void {
this._clearCountdown();
for (const { target, type, fn } of this._resizeListeners) {
target.removeEventListener(type, fn);
}
this._resizeListeners = [];
this.element.remove();
}
// Protected helpers
protected setFetching(v: boolean): void { this._fetching = v; }
protected get isFetching(): boolean { return this._fetching; }
/**
* Fetch url with exponential-backoff retry (up to maxRetries attempts).
* Reset this.retryAttempts and this.retryDelay before calling if you
* want a fresh retry sequence (e.g. from a user-initiated Retry button).
*/
protected async fetchWithRetry(url: string): Promise<unknown> {
while (this.retryAttempts < this.maxRetries) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
this.retryAttempts++;
if (this.retryAttempts >= this.maxRetries) {
throw new Error(
`Failed after ${this.maxRetries} attempts: ${(error as Error).message}`
);
}
await new Promise(resolve => setTimeout(resolve, this.retryDelay));
this.retryDelay *= 2;
}
}
}
// Grid-span persistence
//
// Panels live inside a CSS grid. Their size is expressed as span classes
// (e.g. "col-span-2", "row-span-3"), NOT inline pixel widths/heights.
// Use saveMap / loadMap to persist this.
/**
* Persist an arbitrary key->value map to localStorage under this panel's key.
* Typical use: save current grid-span class numbers.
*
* this.saveMap({ colSpan: '2', rowSpan: '1', collapsed: 'false' });
*/
public saveMap(data: Record<string, string>): void {
localStorage.setItem(`panelState_${this.panelId}`, JSON.stringify(data));
}
/**
* Load the map saved by saveMap(), or null if nothing was stored.
*
* const state = this.loadMap();
* if (state) {
* this.element.classList.add(`col-span-${state.colSpan}`);
* this.element.classList.add(`row-span-${state.rowSpan}`);
* if (state.collapsed === 'true') this.element.classList.add('collapsed');
* }
*/
public loadMap(): Record<string, string> | null {
const raw = localStorage.getItem(`panelState_${this.panelId}`);
if (!raw) return null;
try {
return JSON.parse(raw) as Record<string, string>;
} catch {
return null;
}
}
// Private internals
private _clearCountdown(): void {
if (this._countdownInterval !== null) {
clearInterval(this._countdownInterval);
this._countdownInterval = null;
}
}
/**
* Wire up the two resize handles.
* Row handle drags vertically and updates the row-span class.
* Col handle drags horizontally and updates the col-span class.
* All listeners are tracked in _resizeListeners for clean destroy().
*/
private _initResizeHandles(
rowHandle: HTMLElement,
colHandle: HTMLElement
): void {
const track = (
target: EventTarget,
type: string,
fn: EventListenerOrEventListenerObject
) => {
target.addEventListener(type, fn);
this._resizeListeners.push({ target, type, fn });
};
// Row resize (bottom handle)
let rowDragging = false;
let rowStartY = 0;
let rowStartSpan = 1;
const onRowMouseDown = (e: Event) => {
const me = e as MouseEvent;
rowDragging = true;
rowStartY = me.clientY;
const m = (this.element.className.match(/row-span-(\d+)/) ?? [])[1];
rowStartSpan = m ? parseInt(m, 10) : 1;
me.preventDefault();
};
const onRowMouseMove = (e: Event) => {
if (!rowDragging) return;
const me = e as MouseEvent;
const delta = Math.round((me.clientY - rowStartY) / 60);
const newSpan = Math.max(1, Math.min(6, rowStartSpan + delta));
this.element.className = this.element.className
.replace(/row-span-\d+/, `row-span-${newSpan}`)
.trim();
if (!/row-span-\d+/.test(this.element.className)) {
this.element.classList.add(`row-span-${newSpan}`);
}
};
const onRowMouseUp = () => { rowDragging = false; };
track(rowHandle, 'mousedown', onRowMouseDown);
track(document, 'mousemove', onRowMouseMove);
track(document, 'mouseup', onRowMouseUp);
// Col resize (right handle)
let colDragging = false;
let colStartX = 0;
let colStartSpan = 1;
const onColMouseDown = (e: Event) => {
const me = e as MouseEvent;
colDragging = true;
colStartX = me.clientX;
const m = (this.element.className.match(/col-span-(\d+)/) ?? [])[1];
colStartSpan = m ? parseInt(m, 10) : 1;
me.preventDefault();
};
const onColMouseMove = (e: Event) => {
if (!colDragging) return;
const me = e as MouseEvent;
const delta = Math.round((me.clientX - colStartX) / 120);
const newSpan = Math.max(1, Math.min(12, colStartSpan + delta));
this.element.className = this.element.className
.replace(/col-span-\d+/, `col-span-${newSpan}`)
.trim();
if (!/col-span-\d+/.test(this.element.className)) {
this.element.classList.add(`col-span-${newSpan}`);
}
};
const onColMouseUp = () => { colDragging = false; };
track(colHandle, 'mousedown', onColMouseDown);
track(document, 'mousemove', onColMouseMove);
track(document, 'mouseup', onColMouseUp);
}
}
src/utils/circuit-breaker.tsinterface CircuitState {
failures: number;
cooldownUntil: number;
}
interface CacheEntry<T> {
data: T;
timestamp: number;
}
export interface CircuitBreakerOptions {
name: string;
maxFailures?: number; // default 2
cooldownMs?: number; // default 5 min
cacheTtlMs?: number; // default 10 min
}
export class CircuitBreaker<T> {
private state: CircuitState = { failures: 0, cooldownUntil: 0 };
private cache: CacheEntry<T> | null = null;
private readonly name: string;
private readonly maxFailures: number;
private readonly cooldownMs: number;
private readonly cacheTtlMs: number;
constructor(options: CircuitBreakerOptions) {
this.name = options.name;
this.maxFailures = options.maxFailures ?? 2;
this.cooldownMs = options.cooldownMs ?? 5 * 60 * 1000;
this.cacheTtlMs = options.cacheTtlMs ?? 10 * 60 * 1000;
}
isOnCooldown(): boolean {
if (Date.now() < this.state.cooldownUntil) return true;
if (this.state.cooldownUntil > 0) {
this.state = { failures: 0, cooldownUntil: 0 };
}
return false;
}
getCached(): T | null {
if (this.cache && Date.now() - this.cache.timestamp < this.cacheTtlMs) {
return this.cache.data;
}
return null;
}
recordSuccess(data: T): void {
this.state = { failures: 0, cooldownUntil: 0 };
this.cache = { data, timestamp: Date.now() };
}
recordFailure(): void {
this.state.failures++;
if (this.state.failures >= this.maxFailures) {
this.state.cooldownUntil = Date.now() + this.cooldownMs;
console.warn(`[${this.name}] Circuit open — cooldown ${this.cooldownMs / 1000}s`);
}
}
/**
* Execute fn. On success, caches result. On failure, records failure and
* returns defaultValue. While on cooldown or cache is fresh, skips fn
* entirely and returns cached/default value.
*/
async execute<R extends T>(fn: () => Promise<R>, defaultValue: R): Promise<R> {
if (this.isOnCooldown()) {
const cached = this.getCached();
return (cached as R) ?? defaultValue;
}
const cached = this.getCached();
if (cached !== null) return cached as R;
try {
const result = await fn();
this.recordSuccess(result);
return result;
} catch (e) {
console.error(`[${this.name}] Request failed:`, e);
this.recordFailure();
return defaultValue;
}
}
}
export function createCircuitBreaker<T>(
options: CircuitBreakerOptions
): CircuitBreaker<T> {
return new CircuitBreaker<T>(options);
}
src/utils/sparkline.ts/**
* Returns an inline SVG sparkline string, or '' if data is too short.
* Embed the return value directly in HTML template strings.
*/
export function miniSparkline(
data: number[] | undefined,
change: number | null,
w = 50,
h = 16
): string {
if (!data || data.length < 2) return '';
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const color = change != null && change >= 0 ? 'var(--green)' : 'var(--red)';
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - ((v - min) / range) * (h - 2) - 1;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
`<polyline points="${points}" fill="none" stroke="${color}" ` +
`stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
}
| Feature | Old pattern | New (authoritative) pattern |
|---|---|---|
| Persistence | saveState()/loadState() with inline width/height strings | saveMap()/loadMap() with arbitrary key/value map; store grid-span class numbers |
| Resize | Single .panel-resize-handle | Dual handles: .panel-resize-handle-row (vertical) + .panel-resize-handle-col (horizontal) |
| Loading UI | Spinner div | Radar-ring animation (3 nested .radar-ring divs) |
| Error UI | Static message + optional retry button | Message + optional countdown timer (autoRetryMs) + retry button; countdown clears on manual retry |
| Badges | None | setDataBadge(text, variant) / clearDataBadge() and setNewBadge(text) / clearNewBadge() |
destroy() | element.remove() only | Clears countdown interval + removes all resize document listeners + element.remove() |
Use this when the project already has a fetchStockQuotes() (or similar) function.
The circuit breaker is a one-liner wrapper — no refactoring of the service needed.
import { Panel } from './Panel';
import { createCircuitBreaker } from '../utils/circuit-breaker';
import { fetchStockQuotes } from '../services/stocks'; // existing project service
interface StockQuote {
symbol: string;
name: string;
price: number | null;
change: number | null;
sparkline?: number[];
}
// Module-level breaker — shared across re-renders, persists cache between fetches
const breaker = createCircuitBreaker<StockQuote[]>({
name: 'StockMarket',
cacheTtlMs: 60_000,
cooldownMs: 5 * 60_000,
});
export class StockPanel extends Panel {
private refreshTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super({ id: 'stocks', title: 'Stock Market', showCount: true });
// Restore grid-span state from localStorage
const state = this.loadMap();
if (state) {
if (state.colSpan) this.element.classList.add(`col-span-${state.colSpan}`);
if (state.rowSpan) this.element.classList.add(`row-span-${state.rowSpan}`);
if (state.collapsed === 'true') this.element.classList.add('collapsed');
}
this.fetchData();
this.refreshTimer = setInterval(() => this.fetchData(), 60_000);
const btn = document.createElement('button');
btn.className = 'panel-refresh-btn';
btn.textContent = 'Refresh';
btn.addEventListener('click', () => this.fetchData());
this.header.appendChild(btn);
this.setNewBadge('NEW');
}
private async fetchData(): Promise<void> {
if (this.isFetching) return;
this.setFetching(true);
this.showLoading();
try {
const quotes = await breaker.execute(() => fetchStockQuotes(), []);
this.setContent(this.renderQuotes(quotes));
this.setCount(quotes.length);
this.setDataBadge('LIVE', 'live');
const colM = this.element.className.match(/col-span-(\d+)/);
const rowM = this.element.className.match(/row-span-(\d+)/);
this.saveMap({
colSpan: colM ? colM[1] : '1',
rowSpan: rowM ? rowM[1] : '1',
});
} catch (err) {
this.setDataBadge('ERROR', 'error');
this.showError('Failed to load stock data', () => this.fetchData(), 30_000);
} finally {
this.setFetching(false);
}
}
private renderQuotes(quotes: StockQuote[]): string {
if (!quotes.length) return '<p class="panel-empty">No data</p>';
return quotes.map(q => `
<div class="stock-row">
<span class="stock-symbol">${q.symbol}</span>
<span class="stock-name">${q.name}</span>
<span class="stock-price">${q.price ?? '—'}</span>
<span class="stock-change ${(q.change ?? 0) >= 0 ? 'positive' : 'negative'}">
${q.change != null ? (q.change >= 0 ? '+' : '') + q.change.toFixed(2) + '%' : '—'}
</span>
</div>`).join('');
}
public override destroy(): void {
if (this.refreshTimer) clearInterval(this.refreshTimer);
super.destroy();
}
}
fetchWithRetry (no external service)Use this when there is no existing service function and you want full control over the HTTP call with per-request retry baked in.
export class NewsPanel extends Panel {
constructor() {
super({ id: 'news', title: 'Latest News' });
const state = this.loadMap();
if (state?.collapsed === 'true') this.element.classList.add('collapsed');
this.load();
}
private async load(): Promise<void> {
if (this.isFetching) return;
this.setFetching(true);
this.retryAttempts = 0;
this.retryDelay = 1000;
try {
const data = await this.fetchWithRetry('/api/news') as NewsItem[];
this.setContent(this.renderNews(data));
this.setDataBadge('LIVE', 'live');
} catch (err) {
this.setDataBadge('ERROR', 'error');
this.showError('Could not load news', () => this.load(), 15_000);
} finally {
this.setFetching(false);
}
}
private renderNews(items: NewsItem[]): string {
return items.map(i => `<div class="news-item">${i.headline}</div>`).join('');
}
}
/* Radar loading animation */
.panel-loading-radar {
position: relative;
width: 40px;
height: 40px;
margin: 0 auto 8px;
}
.radar-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--accent, #4af);
opacity: 0;
animation: radar-pulse 1.8s ease-out infinite;
}
.radar-ring-2 { animation-delay: 0.6s; }
.radar-ring-3 { animation-delay: 1.2s; }
@keyframes radar-pulse {
0% { transform: scale(0.3); opacity: 0.8; }
100% { transform: scale(1.4); opacity: 0; }
}
/* Data badge variants */
.panel-data-badge[data-variant="live"] { background: var(--green, #2a2); color: #fff; }
.panel-data-badge[data-variant="stale"] { background: var(--yellow, #aa2); color: #fff; }
.panel-data-badge[data-variant="error"] { background: var(--red, #a22); color: #fff; }
/* Dual resize handles */
.panel-resize-handle-row {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 6px;
cursor: row-resize;
}
.panel-resize-handle-col {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
}
/* Error countdown */
.panel-error-countdown {
font-size: 0.75rem;
opacity: 0.7;
margin-top: 4px;
}