Single cohesive workflow for creating complete dashboard panel features in vanilla TypeScript — covers project-structure exploration, server dispatch pattern de
复制下面这句话,粘贴给 Claude Code、Codex、Cursor 等 AI 编程工具,它会读取安装说明并在你确认后完成安装。
请阅读 https://ai.atlankj.com/install/asset/gh-full-stack-panel-authoring-d3d1471bddbc ,按照其中的说明把「full-stack-panel-authoring」安装到你(当前 AI 工具)中。执行前先告诉我将运行的命令和写入的位置,等我确认。
查看 AI 将读取的安装说明正在读取 GitHub 原文…
内容来自 GitHub 原始文件,由原作者维护。在 GitHub 查看
One guide for the complete workflow: backend route → service → server wiring → Panel component with resilience → registration. Eliminates the need to consult separate skills for what is always a single cohesive task.
http.createServer dispatch loop or Express| # | File | Action |
|---|---|---|
| 1 | server/routes/{feature}-route.ts | Create |
| 2 | src/services/{feature}.ts | Create |
| 3 | src/components/{Feature}Panel.ts | Create |
| 4 | src/components/index.ts | Update — add export |
| 5 | server/index.ts | Update — import + dispatch case |
| 6 | src/utils/circuit-breaker.ts | Create if absent |
| 7 | src/utils/sparkline.ts | Create if absent |
Run these commands before writing a single line of code. Reading existing patterns prevents style drift and reveals the exact dispatch mechanism.
# Locate server entry point (critical for wiring)
find . -name "index.ts" -o -name "index.js" -o -name "server.ts" -o -name "server.js" \
| grep -v node_modules | head -10
# Find existing route handlers to use as pattern files
find server/routes -name "*.ts" -o -name "*.js" 2>/dev/null | head -10
# Find existing service files
find src/services -name "*.ts" -o -name "*.js" 2>/dev/null | head -10
# Find existing Panel components
find src/components -name "*Panel.ts" 2>/dev/null | head -10
# Find the Panel base class
find src -name "Panel.ts" 2>/dev/null | head -5
# Find component registration index
find src/components -name "index.ts" 2>/dev/null | head -3
# Check for CircuitBreaker utility
find src/utils -name "circuit-breaker.ts" 2>/dev/null
# Check framework (should NOT find React in vanilla TS projects)
grep -s '"react"' package.json | head -3
Read 1-2 examples from each layer before generating any code:
cat server/routes/<existing>-route.ts
cat src/services/<existing>.ts
cat src/components/<existing>Panel.ts
cat src/components/index.ts
cat server/index.ts # most important: reveals dispatch pattern
Read server/index.ts (or server/index.js, src/server.ts) and classify:
# Express signals
grep -n "express\|app\.use\|router\." server/index.ts 2>/dev/null
# Manual http.createServer signals
grep -n "createServer\|switch.*url\|switch.*pathname\|req\.url\|case '/" server/index.ts 2>/dev/null
| Pattern in file | Wiring method (Step 5) |
|---|---|
app.use('/api/x', xRouter) | 5A - Express |
switch(pathname) { case '/api/x': | 5B - Manual switch/case |
if (url.startsWith('/api/x')) | 5C - Manual if/else chain |
const handlers = { '/api/x': fn } | 5D - Handler map |
NOTE: Misidentifying the dispatch pattern is the most common cause of silent 404s. If unsure, read the full server entry point before proceeding.
Use the manual dispatch template for http.createServer projects (most common in this codebase). Use the Express template only when Express is confirmed.
// server/routes/{feature}-route.ts
import { IncomingMessage, ServerResponse } from 'http';
import { fetch{Feature}Data } from '../../src/services/{feature}';
export async function handle{Feature}Request(
req: IncomingMessage,
res: ServerResponse,
action?: string
): Promise<void> {
try {
const data = await fetch{Feature}Data(action);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, data }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: false,
error: err instanceof Error ? err.message : String(err)
}));
}
}
// server/routes/{feature}-route.ts
import { Router } from 'express';
import { fetch{Feature}Data } from '../../src/services/{feature}';
const router = Router();
router.get('/', async (req, res) => {
try {
const data = await fetch{Feature}Data(req.query.action as string | undefined);
res.json({ success: true, data });
} catch (err) {
res.status(500).json({ success: false, error: (err as Error).message });
}
});
export default router;
The service owns data fetching and transformation. The CircuitBreaker (Step 6) wraps the service call at the component level — keep the service itself simple.
// src/services/{feature}.ts
export interface {Feature}Item {
id: string;
// define your data shape matching the API response
}
/**
* Fetch {feature} data from the server API.
* @param action Optional sub-action / filter parameter.
*/
export async function fetch{Feature}Data(action?: string): Promise<{Feature}Item[]> {
const url = `/api/panels/{feature}${action ? `?action=${encodeURIComponent(action)}` : ''}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${localStorage.getItem('token') ?? ''}` }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data } = await res.json() as { data: {Feature}Item[] };
return data;
}
This step is the most frequently missed and causes features to silently return 404.
After creating the route file, open server/index.ts and add both an import and a dispatch entry.
// server/index.ts -- excerpt
import {feature}Router from './routes/{feature}-route';
// Add alongside other app.use() calls:
app.use('/api/panels/{feature}', {feature}Router);
// server/index.ts -- excerpt showing the additions
// 1. Add import near the top with other route imports
import { handle{Feature}Request } from './routes/{feature}-route';
// 2. Inside the switch / if-else dispatch block, add a new case
// Find the block that looks like:
// switch (pathname) {
// case '/api/panels/health': ...
// case '/api/panels/email': ...
// Add:
case '/api/panels/{feature}':
await handle{Feature}Request(req, res, action);
break;
// server/index.ts -- excerpt
import { handle{Feature}Request } from './routes/{feature}-route';
// Add a new else-if branch:
} else if (url.startsWith('/api/panels/{feature}')) {
await handle{Feature}Request(req, res, action);
}
// server/index.ts -- excerpt
import { handle{Feature}Request } from './routes/{feature}-route';
const handlers: Record<string, HandlerFn> = {
// ... existing entries ...
'/api/panels/{feature}': handle{Feature}Request,
};
Verification: After wiring, start the server and curl /api/panels/{feature} — you should receive JSON, not a 404.
Panel (base class)
+-- element: HTMLElement (.panel)
+-- header (.panel-header)
| +-- headerLeft (.panel-header-left)
| | +-- title (.panel-title)
| +-- countEl (.panel-count) [optional]
+-- content (.panel-content)
| Layer | When to use |
|---|---|
isFetching guard | Always -- prevents duplicate concurrent requests |
fetchWithRetry() | When you own the raw fetch and want per-request retry |
CircuitBreaker.execute() | When a service may fail repeatedly; serves stale cache during cooldown |
saveState() / loadState() | When collapsed/size state should survive page reloads |
// src/components/{Feature}Panel.ts
import { Panel } from './Panel';
import { createCircuitBreaker } from '../utils/circuit-breaker';
import { fetch{Feature}Data, {Feature}Item } from '../services/{feature}';
// Module-level breaker -- persists cache and failure state between re-renders
const breaker = createCircuitBreaker<{Feature}Item[]>({
name: '{Feature}',
cacheTtlMs: 60_000, // serve cache for 1 min
cooldownMs: 5 * 60_000, // open circuit for 5 min after maxFailures
maxFailures: 2,
});
export class {Feature}Panel extends Panel {
private refreshTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super({ id: '{feature}', title: '{Feature Display Name}', showCount: true });
this.loadState(); // restore collapsed/size
this.fetchData();
this.refreshTimer = setInterval(() => this.fetchData(), 60_000);
}
private async fetchData(): Promise<void> {
if (this.isFetching) return;
this.setFetching(true);
try {
// One-liner: wraps existing service call with caching + circuit-break
const items = await breaker.execute(() => fetch{Feature}Data(), []);
this.setCount(items.length);
this.render(items);
} catch (err) {
this.showError(
err instanceof Error ? err.message : 'Failed to load',
() => {
this.retryAttempts = 0;
this.retryDelay = 1000;
this.fetchData();
}
);
} finally {
this.setFetching(false);
}
}
private render(items: {Feature}Item[]): void {
if (items.length === 0) {
this.setContent('<div class="panel-empty">No items found.</div>');
return;
}
const container = document.createElement('div');
container.className = '{feature}-panel';
for (const item of items) {
const row = document.createElement('div');
row.className = '{feature}-panel__row';
// Build DOM from item fields -- no JSX, no innerHTML with untrusted data:
const label = document.createElement('span');
label.className = '{feature}-panel__label';
label.textContent = item.id; // replace with real fields
row.appendChild(label);
container.appendChild(row);
}
this.content.innerHTML = '';
this.content.appendChild(container);
this.saveState();
}
destroy(): void {
if (this.refreshTimer !== null) clearInterval(this.refreshTimer);
super.destroy();
}
}
Key rules for vanilla TS panels (no React):
Panel base class; use setContent() / showError() / setCount() / showLoading() instead of React state.document.createElement -- not JSX, not innerHTML with untrusted strings.constructor -> fetchData() -> render() -> destroy(). No useEffect..ts, not .jsx/.tsx.Export the new panel from the components barrel file so the dashboard can discover it.
// src/components/index.ts -- add alongside existing exports
export { {Feature}Panel } from './{Feature}Panel';
Then instantiate and mount in the dashboard bootstrap (follow the existing pattern):
// src/index.ts or src/dashboard.ts -- excerpt
import { {Feature}Panel } from './components';
const {feature}Panel = new {Feature}Panel();
document.getElementById('{feature}-slot')?.appendChild({feature}Panel.getElement());
Only create these if find src/utils -name "circuit-breaker.ts" returns nothing.
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`);
}
}
async execute<R extends T>(fn: () => Promise<R>, defaultValue: R): Promise<R> {
if (this.isOnCooldown()) return (this.getCached() 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 (optional — for numeric trend data)/**
* Returns an inline SVG sparkline string, or '' if data is too short.
* Embed 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>`;
}
Only create src/components/Panel.ts if no existing base class is found.
// src/components/Panel.ts
export interface PanelOptions {
id: string;
title: string;
showCount?: boolean;
className?: string;
}
export class Panel {
protected element: HTMLElement;
protected content: HTMLElement;
protected header: HTMLElement;
protected countEl: HTMLElement | null = null;
protected panelId: string;
private _fetching = false;
// Retry state (used by fetchWithRetry)
protected retryAttempts = 0;
protected retryDelay = 1000;
private maxRetries = 3;
constructor(options: PanelOptions) {
this.panelId = options.id;
this.element = document.createElement('div');
this.element.className = `panel ${options.className ?? ''}`;
this.element.dataset.panel = options.id;
this.header = document.createElement('div');
this.header.className = 'panel-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'panel-header-left';
const title = document.createElement('span');
title.className = 'panel-title';
title.textContent = options.title;
headerLeft.appendChild(title);
this.header.appendChild(headerLeft);
if (options.showCount) {
this.countEl = document.createElement('span');
this.countEl.className = 'panel-count';
this.countEl.textContent = '0';
this.header.appendChild(this.countEl);
}
this.content = document.createElement('div');
this.content.className = 'panel-content';
this.content.id = `${options.id}Content`;
this.element.appendChild(this.header);
this.element.appendChild(this.content);
this.showLoading();
}
public getElement(): HTMLElement { return this.element; }
public showLoading(message = 'Loading...'): void {
this.content.innerHTML = `
<div class="panel-loading">
<div class="panel-loading-spinner"></div>
<div class="panel-loading-text">${message}</div>
</div>`;
}
public showError(message = 'Failed to load', onRetry?: () => void): void {
this.content.innerHTML = `
<div class="panel-error-state">
<div class="panel-error-msg">${message}</div>
${onRetry ? '<button class="panel-retry-btn" data-panel-retry>Retry</button>' : ''}
</div>`;
if (onRetry) {
this.content.querySelector('[data-panel-retry]')?.addEventListener('click', onRetry);
}
}
public setContent(html: string): void { this.content.innerHTML = html; }
public setCount(count: number): void {
if (this.countEl) this.countEl.textContent = count.toString();
}
public show(): void { this.element.classList.remove('hidden'); }
public hide(): void { this.element.classList.add('hidden'); }
public destroy(): void { this.element.remove(); }
protected setFetching(v: boolean): void { this._fetching = v; }
protected get isFetching(): boolean { return this._fetching; }
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;
}
}
}
public saveState(): void {
localStorage.setItem(`panelState_${this.panelId}`, JSON.stringify({
isExpanded: !this.element.classList.contains('collapsed'),
width: this.element.style.width,
height: this.element.style.height,
}));
}
public loadState(): void {
const raw = localStorage.getItem(`panelState_${this.panelId}`);
if (!raw) return;
const { isExpanded, width, height } = JSON.parse(raw) as {
isExpanded: boolean; width: string; height: string;
};
if (!isExpanded) this.element.classList.add('collapsed');
if (width) this.element.style.width = width;
if (height) this.element.style.height = height;
}
}
Known tool issue: write_file (and some shell tools) fail silently or with "unknown error" when file content contains multi-byte Unicode characters such as emoji (e.g., document, chart, checkmark emoji).
Never use emoji in generated source code. Replace with plain-text equivalents:
[doc] or (file)[chart][ok] or (done)[warn] or WARNING:Use ASCII substitutes in comments instead of Unicode box-drawing characters.
Test-write suspect content to a throwaway file first if unsure:
echo "test content" > /tmp/write_test.txt && cat /tmp/write_test.txt
Recovery pattern -- if a write_file call fails:
cat.Before considering the feature complete, verify each layer:
Server route
[ ] File created at server/routes/{feature}-route.ts
[ ] Exports handle{Feature}Request function
[ ] Returns { success: true, data } on success
[ ] Returns { success: false, error } on failure with correct HTTP status
Service
[ ] File created at src/services/{feature}.ts
[ ] Exports fetch{Feature}Data function
[ ] Throws on non-ok HTTP status (so CircuitBreaker can record failures)
Server wiring
[ ] Import added to server/index.ts
[ ] Dispatch case/route added (matching the detected pattern)
[ ] curl /api/panels/{feature} returns JSON (not 404)
Panel component
[ ] Extends Panel base class
[ ] Uses isFetching guard
[ ] CircuitBreaker wraps the service call
[ ] showError() called with retry callback on failure
[ ] destroy() clears interval and calls super.destroy()
Registration
[ ] Export added to src/components/index.ts
[ ] Panel instantiated and mounted in dashboard bootstrap
File safety
[ ] No emoji in any generated source file
[ ] All write_file calls succeeded (check file size > 0)
| Mistake | Symptom | Fix |
|---|---|---|
| Skipping Step 5 (server wiring) | Feature returns 404 | Add import + dispatch entry to server/index.ts |
| Wrong dispatch pattern | 404 or runtime error | Re-read server/index.ts; use grep from Step 2 |
| Emoji in source files | write_file silent failure | Replace all emoji with ASCII equivalents |
| React template in vanilla TS project | Compile errors | Confirm no React in package.json; use Panel base class template |
| Creating Panel.ts when one exists | Overwrites base class | Check with find src -name "Panel.ts" first |
Not calling super.destroy() | Memory leak | Always chain super.destroy() in subclass destroy() |
| CircuitBreaker declared inside class | Cache lost between fetches | Declare const breaker = ... at module level outside the class |