Files
qihuaiflow/frontend/src/pages/admin/DeveloperTools.tsx
T
2026-07-08 13:37:35 +08:00

538 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import './Developer.css';
const API_BASE = '/app/api';
interface Webhook {
id: string; name: string; url: string; events: string[];
has_secret: boolean; headers: Record<string, string>; status: string;
last_triggered_at: string | null; last_status: string | null;
total_deliveries: number; success_count: number; fail_count: number;
success_rate: number; created_at: string; updated_at: string;
}
interface WebhookEvent {
value: string; label: string; desc: string;
}
interface WebhookDelivery {
id: string; event: string; status: string; response_code: number | null;
response_time_ms: number | null; attempts: number; error_message: string | null;
payload: any; response_body: string; created_at: string;
}
interface SDKPackage {
language: string; name: string; version: string; description: string;
install_cmd: string; download_url: string; github_url: string;
min_version: string; features: string[]; code_example: string;
}
interface ApiDocSection {
section: string; icon: string; description: string;
endpoints: Array<{
method: string; path: string; title: string; description: string;
params: Array<{ name: string; type: string; required: boolean; desc: string }>;
example_request: string; example_response: string;
}>;
}
interface PageData<T> { total: number; page: number; page_size: number; items: T[] }
async function api(path: string, options?: RequestInit) {
const token = localStorage.getItem('token');
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...(options?.headers || {}),
},
});
if (res.status === 307) {
const location = res.headers.get('Location');
if (location) return api(location.replace(API_BASE, ''), options);
}
const data = await res.json();
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
return data;
}
export default function AdminDeveloperTools() {
const [activeTab, setActiveTab] = useState<'webhooks' | 'sdk' | 'docs'>('webhooks');
const [loading, setLoading] = useState(false);
// Webhooks state
const [webhooks, setWebhooks] = useState<PageData<Webhook>>({ total: 0, page: 1, page_size: 20, items: [] });
const [webhookEvents, setWebhookEvents] = useState<WebhookEvent[]>([]);
const [showWebhookModal, setShowWebhookModal] = useState(false);
const [webhookForm, setWebhookForm] = useState({ name: '', url: '', events: [] as string[] });
const [selectedWebhook, setSelectedWebhook] = useState<Webhook | null>(null);
const [deliveries, setDeliveries] = useState<PageData<WebhookDelivery>>({ total: 0, page: 1, page_size: 20, items: [] });
const [testResult, setTestResult] = useState<any>(null);
// SDK state
const [sdkPackages, setSdkPackages] = useState<SDKPackage[]>([]);
const [selectedSdk, setSelectedSdk] = useState<string>('python');
// API Docs state
const [docSections, setDocSections] = useState<ApiDocSection[]>([]);
const [expandedSection, setExpandedSection] = useState<string | null>(null);
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
const loadWebhooks = useCallback(async (page = 1) => {
setLoading(true);
try {
const res = await api(`/admin/developer-tools/webhooks?page=${page}&page_size=20`);
if (res.code === 0) setWebhooks(res.data);
} catch (e) { console.error('Webhooks error:', e); }
setLoading(false);
}, []);
const loadWebhookEvents = useCallback(async () => {
try {
const res = await api('/admin/developer-tools/webhooks/events');
if (res.code === 0) setWebhookEvents(res.data.events);
} catch (e) { console.error('Events error:', e); }
}, []);
const loadDeliveries = useCallback(async (webhookId: string, page = 1) => {
try {
const res = await api(`/admin/developer-tools/webhooks/${webhookId}/deliveries?page=${page}&page_size=20`);
if (res.code === 0) setDeliveries(res.data);
} catch (e) { console.error('Deliveries error:', e); }
}, []);
const loadSdkPackages = useCallback(async () => {
try {
const res = await api('/admin/developer-tools/sdk/packages');
if (res.code === 0) setSdkPackages(res.data.packages);
} catch (e) { console.error('SDK error:', e); }
}, []);
const loadApiDocs = useCallback(async () => {
try {
const res = await api('/admin/developer-tools/api-docs');
if (res.code === 0) setDocSections(res.data.sections);
} catch (e) { console.error('Docs error:', e); }
}, []);
useEffect(() => { if (activeTab === 'webhooks') { loadWebhooks(); loadWebhookEvents(); } }, [activeTab, loadWebhooks, loadWebhookEvents]);
useEffect(() => { if (activeTab === 'sdk') loadSdkPackages(); }, [activeTab, loadSdkPackages]);
useEffect(() => { if (activeTab === 'docs') loadApiDocs(); }, [activeTab, loadApiDocs]);
const handleCreateWebhook = async () => {
try {
const body = { name: webhookForm.name, url: webhookForm.url, events: webhookForm.events, headers: {} };
const res = await api('/admin/developer-tools/webhooks/', { method: 'POST', body: JSON.stringify(body) });
setShowWebhookModal(false);
setWebhookForm({ name: '', url: '', events: [] });
loadWebhooks();
if (res.data?.secret) {
alert(`Webhook created! Secret: ${res.data.secret}\nSave this secret for HMAC verification.`);
}
} catch (e: any) { alert(e.message); }
};
const handleDeleteWebhook = async (id: string) => {
if (!confirm('确定删除此 Webhook?')) return;
try {
await api(`/admin/developer-tools/webhooks/${id}`, { method: 'DELETE' });
loadWebhooks();
setSelectedWebhook(null);
} catch (e: any) { alert(e.message); }
};
const handleToggleWebhook = async (wh: Webhook) => {
const newStatus = wh.status === 'active' ? 'paused' : 'active';
try {
await api(`/admin/developer-tools/webhooks/${wh.id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) });
loadWebhooks();
} catch (e: any) { alert(e.message); }
};
const handleTestWebhook = async (id: string) => {
setTestResult(null);
try {
const res = await api(`/admin/developer-tools/webhooks/${id}/test`, {
method: 'POST', body: JSON.stringify({ event: 'test', payload: { message: 'Test delivery from admin' } })
});
setTestResult(res.data);
loadWebhooks();
} catch (e: any) { alert(e.message); }
};
const handleViewDeliveries = (wh: Webhook) => {
setSelectedWebhook(wh);
setDeliveries({ total: 0, page: 1, page_size: 20, items: [] });
loadDeliveries(wh.id);
};
const fmtTime = (t: string | null) => {
if (!t) return '-';
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
};
const statusBadge = (status: string) => {
const cls = status === 'active' ? 'badge-active' : status === 'paused' ? 'badge-disabled' : status === 'success' ? 'badge-active' : 'badge-revoked';
return <span className={`dev-badge ${cls}`}>{status}</span>;
};
const methodBadge = (method: string) => {
const colors: Record<string, string> = { GET: 'method-get', POST: 'method-post', PUT: 'method-put', DELETE: 'method-delete' };
return <span className={`dev-method ${colors[method] || ''}`}>{method}</span>;
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
alert('Copied to clipboard');
}).catch(() => alert('Copy failed'));
};
return (
<div className="admin-developer">
<div className="dev-header">
<h2>开发者工具</h2>
<p>Webhook 配置、SDK 下载中心、交互式 API 文档</p>
</div>
{/* Tab Navigation */}
<div className="dev-tabs">
<button className={`dev-tab ${activeTab === 'webhooks' ? 'active' : ''}`} onClick={() => setActiveTab('webhooks')}>Webhook 配置</button>
<button className={`dev-tab ${activeTab === 'sdk' ? 'active' : ''}`} onClick={() => setActiveTab('sdk')}>SDK 下载中心</button>
<button className={`dev-tab ${activeTab === 'docs' ? 'active' : ''}`} onClick={() => setActiveTab('docs')}>API 文档</button>
</div>
{/* Tab Content */}
<div className="dev-tab-content">
{loading && <div className="dev-loading">加载中...</div>}
{/* ===== Webhooks Tab ===== */}
{activeTab === 'webhooks' && !loading && (
<div>
{selectedWebhook ? (
/* Webhook Detail / Deliveries View */
<div>
<div className="dev-toolbar">
<button className="dev-btn" onClick={() => { setSelectedWebhook(null); setDeliveries({ total: 0, page: 1, page_size: 20, items: [] }); }}>
&larr; Back to list
</button>
<h3 style={{ margin: '0 16px' }}>{selectedWebhook.name} - Delivery History</h3>
<button className="dev-btn primary" onClick={() => handleTestWebhook(selectedWebhook.id)}>Send Test</button>
</div>
{testResult && (
<div className="dev-test-result" style={{ margin: '12px 0', padding: '12px', background: 'rgba(0,0,0,0.04)', borderRadius: '8px', border: `1px solid ${testResult.status === 'success' ? '#52c41a' : '#ff4d4f'}` }}>
<strong>Test Result: </strong>
<span style={{ color: testResult.status === 'success' ? '#52c41a' : '#ff4d4f' }}>{testResult.status}</span>
{testResult.response_code && <span> | HTTP {testResult.response_code}</span>}
{testResult.response_time_ms != null && <span> | {testResult.response_time_ms}ms</span>}
{testResult.error_message && <div style={{ color: '#ff4d4f', marginTop: '4px' }}>{testResult.error_message}</div>}
</div>
)}
<table className="dev-table">
<thead>
<tr><th>事件</th><th>状态</th><th>响应码</th><th>响应时间</th><th>尝试次数</th><th>时间</th><th>错误</th></tr>
</thead>
<tbody>
{deliveries.items.map(d => (
<tr key={d.id}>
<td className="mono-text">{d.event}</td>
<td>{statusBadge(d.status)}</td>
<td>{d.response_code || '-'}</td>
<td>{d.response_time_ms != null ? `${d.response_time_ms}ms` : '-'}</td>
<td>{d.attempts}</td>
<td>{fmtTime(d.created_at)}</td>
<td style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.error_message || '-'}</td>
</tr>
))}
{deliveries.items.length === 0 && <tr><td colSpan={7} className="dev-empty">No delivery records</td></tr>}
</tbody>
</table>
{deliveries.total > 20 && (
<div className="dev-pagination">
<button disabled={deliveries.page <= 1} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page - 1)}>Prev</button>
<span>Page {deliveries.page} / {Math.ceil(deliveries.total / 20)}</span>
<button disabled={deliveries.page >= Math.ceil(deliveries.total / 20)} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page + 1)}>Next</button>
</div>
)}
</div>
) : (
/* Webhook List View */
<div>
<div className="dev-toolbar">
<button className="dev-btn primary" onClick={() => setShowWebhookModal(true)}>+ New Webhook</button>
</div>
<table className="dev-table">
<thead>
<tr><th>名称</th><th>地址</th><th>事件</th><th>状态</th><th>投递数</th><th>成功率</th><th>最后触发</th><th>操作</th></tr>
</thead>
<tbody>
{webhooks.items.map(w => (
<tr key={w.id}>
<td>{w.name}</td>
<td className="mono-text" style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.url}</td>
<td>
{w.events.length > 0 ? (
<span>{w.events.length} events</span>
) : <span className="dev-muted">All events</span>}
</td>
<td>{statusBadge(w.status)}</td>
<td>{w.total_deliveries}</td>
<td>
<span style={{ color: w.success_rate >= 80 ? '#52c41a' : w.success_rate >= 50 ? '#faad14' : '#ff4d4f' }}>
{w.success_rate}%
</span>
<span className="dev-muted"> ({w.success_count}/{w.total_deliveries})</span>
</td>
<td>{fmtTime(w.last_triggered_at)}</td>
<td className="dev-actions">
<button className="dev-btn-sm" onClick={() => handleViewDeliveries(w)}>Deliveries</button>
<button className="dev-btn-sm" onClick={() => handleTestWebhook(w.id)}>测试</button>
<button className="dev-btn-sm" onClick={() => handleToggleWebhook(w)}>{w.status === 'active' ? 'Pause' : 'Resume'}</button>
<button className="dev-btn-sm danger" onClick={() => handleDeleteWebhook(w.id)}>删除</button>
</td>
</tr>
))}
{webhooks.items.length === 0 && <tr><td colSpan={8} className="dev-empty">No webhooks configured</td></tr>}
</tbody>
</table>
{webhooks.total > 20 && (
<div className="dev-pagination">
<button disabled={webhooks.page <= 1} onClick={() => loadWebhooks(webhooks.page - 1)}>Prev</button>
<span>Page {webhooks.page} / {Math.ceil(webhooks.total / 20)}</span>
<button disabled={webhooks.page >= Math.ceil(webhooks.total / 20)} onClick={() => loadWebhooks(webhooks.page + 1)}>Next</button>
</div>
)}
{/* 支持的事件 Info */}
{webhookEvents.length > 0 && (
<div style={{ marginTop: '20px', padding: '16px', background: 'rgba(0,0,0,0.02)', borderRadius: '8px' }}>
<h4 style={{ margin: '0 0 12px', color: '#1A1F36' }}>支持的事件</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '8px' }}>
{webhookEvents.map(ev => (
<div key={ev.value} style={{ padding: '8px 12px', background: 'rgba(0,0,0,0.04)', borderRadius: '6px' }}>
<code style={{ color: '#69b1ff' }}>{ev.value}</code>
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '2px' }}>{ev.desc}</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Create Webhook Modal */}
{showWebhookModal && (
<div className="dev-modal-overlay" onClick={() => setShowWebhookModal(false)}>
<div className="dev-modal" onClick={e => e.stopPropagation()}>
<h3>新建 Webhook</h3>
<div className="dev-form-group">
<label>名称</label>
<input className="dev-input" value={webhookForm.name} onChange={e => setWebhookForm({ ...webhookForm, name: e.target.value })} placeholder="My Webhook" />
</div>
<div className="dev-form-group">
<label>URL</label>
<input className="dev-input" value={webhookForm.url} onChange={e => setWebhookForm({ ...webhookForm, url: e.target.value })} placeholder="https://example.com/webhook" />
</div>
<div className="dev-form-group">
<label>Events (leave empty for all)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
{webhookEvents.map(ev => (
<label key={ev.value} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', padding: '4px 10px', background: webhookForm.events.includes(ev.value) ? 'rgba(22,119,255,0.2)' : 'rgba(0,0,0,0.04)', borderRadius: '6px', fontSize: '13px' }}>
<input type="checkbox" checked={webhookForm.events.includes(ev.value)} onChange={e => {
if (e.target.checked) setWebhookForm({ ...webhookForm, events: [...webhookForm.events, ev.value] });
else setWebhookForm({ ...webhookForm, events: webhookForm.events.filter(x => x !== ev.value) });
}} />
{ev.label}
</label>
))}
</div>
</div>
<div className="dev-modal-actions">
<button className="dev-btn" onClick={() => setShowWebhookModal(false)}>取消</button>
<button className="dev-btn primary" onClick={handleCreateWebhook} disabled={!webhookForm.name || !webhookForm.url}>创建</button>
</div>
</div>
</div>
)}
</div>
)}
{/* ===== SDK Download Center Tab ===== */}
{activeTab === 'sdk' && !loading && (
<div>
{/* Language Selector */}
<div className="dev-toolbar">
{sdkPackages.map(pkg => (
<button key={pkg.language} className={`dev-tab ${selectedSdk === pkg.language ? 'active' : ''}`} onClick={() => setSelectedSdk(pkg.language)}>
{pkg.name}
</button>
))}
</div>
{sdkPackages.filter(p => p.language === selectedSdk).map(pkg => (
<div key={pkg.language} style={{ padding: '20px', background: 'rgba(0,0,0,0.02)', borderRadius: '12px' }}>
{/* Package Info */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
<div>
<h3 style={{ margin: '0 0 4px' }}>{pkg.name} <span className="dev-badge badge-active">v{pkg.version}</span></h3>
<p style={{ margin: '0', color: '#5A6480' }}>{pkg.description}</p>
<p style={{ margin: '4px 0 0', fontSize: '13px', color: '#8892B0' }}>Requires: {pkg.min_version}</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<a href={pkg.download_url} target="_blank" rel="noopener noreferrer" className="dev-btn primary">Download</a>
{pkg.github_url && <a href={pkg.github_url} target="_blank" rel="noopener noreferrer" className="dev-btn">GitHub</a>}
</div>
</div>
{/* Install Command */}
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '4px', display: 'block' }}>安装方式</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<code style={{ flex: 1, padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '8px', fontFamily: 'monospace', fontSize: '14px' }}>
{pkg.install_cmd}
</code>
<button className="dev-btn-sm" onClick={() => copyToClipboard(pkg.install_cmd)}>复制</button>
</div>
</div>
{/* 功能特性 */}
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '8px', display: 'block' }}>功能特性</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{pkg.features.map(f => (
<span key={f} style={{ padding: '4px 12px', background: 'rgba(82,196,26,0.15)', borderRadius: '16px', fontSize: '13px', color: '#52c41a', border: '1px solid rgba(82,196,26,0.3)' }}>
{f}
</span>
))}
</div>
</div>
{/* Code Example */}
<div>
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '4px', display: 'block' }}>快速开始示例</label>
<div style={{ position: 'relative' }}>
<pre style={{ padding: '16px', background: 'rgba(0,0,0,0.4)', borderRadius: '8px', overflow: 'auto', fontSize: '13px', lineHeight: '1.6', maxHeight: '400px' }}>
<code>{pkg.code_example}</code>
</pre>
<button className="dev-btn-sm" style={{ position: 'absolute', top: '8px', right: '8px' }} onClick={() => copyToClipboard(pkg.code_example)}>复制</button>
</div>
</div>
</div>
))}
{sdkPackages.length === 0 && <div className="dev-empty">正在加载 SDK 包...</div>}
</div>
)}
{/* ===== Interactive API Docs Tab ===== */}
{activeTab === 'docs' && !loading && (
<div>
<div style={{ marginBottom: '16px', padding: '12px 16px', background: 'rgba(22,119,255,0.1)', borderRadius: '8px', border: '1px solid rgba(22,119,255,0.3)' }}>
<strong style={{ color: '#69b1ff' }}>Base URL:</strong>
<code style={{ marginLeft: '8px', color: '#1A1F36' }}>http://210.16.161.218:18080</code>
<span style={{ marginLeft: '16px', color: '#8892B0' }}>API Version: v1</span>
</div>
{docSections.map(section => {
const isExpanded = expandedSection === section.section;
return (
<div key={section.section} style={{ marginBottom: '8px', borderRadius: '8px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
{/* Section Header */}
<div
onClick={() => setExpandedSection(isExpanded ? null : section.section)}
style={{ padding: '14px 16px', background: 'rgba(0,0,0,0.04)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '18px' }}>
{section.icon === 'lock' ? '🔒' : section.icon === 'chat' ? '💬' : section.icon === 'book' ? '📚' : section.icon === 'robot' ? '🤖' : section.icon === 'workflow' ? '🔄' : section.icon === 'storage' ? '💾' : section.icon === 'webhook' ? '🔗' : '📋'}
</span>
<strong style={{ color: '#1A1F36' }}>{section.section}</strong>
<span style={{ color: '#8892B0', fontSize: '13px' }}>{section.endpoints.length} endpoints</span>
</div>
<span style={{ color: '#8892B0' }}>{isExpanded ? '▼' : '▶'}</span>
</div>
{/* Section Content */}
{isExpanded && (
<div style={{ background: 'rgba(0,0,0,0.2)' }}>
<p style={{ padding: '8px 16px', margin: '0', color: '#5A6480', fontSize: '13px' }}>{section.description}</p>
{section.endpoints.map(ep => {
const epKey = `${section.section}-${ep.path}`;
const isEpExpanded = expandedEndpoint === epKey;
return (
<div key={epKey} style={{ borderTop: '1px solid rgba(0,0,0,0.04)' }}>
<div
onClick={() => setExpandedEndpoint(isEpExpanded ? null : epKey)}
style={{ padding: '10px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '12px' }}
>
{methodBadge(ep.method)}
<code style={{ color: '#5A6480', fontSize: '13px' }}>{ep.path}</code>
<span style={{ color: '#5A6480', fontSize: '13px' }}>{ep.title}</span>
<span style={{ marginLeft: 'auto', color: '#8892B0' }}>{isEpExpanded ? '▼' : '▶'}</span>
</div>
{isEpExpanded && (
<div style={{ padding: '0 16px 16px 16px' }}>
<p style={{ color: '#5A6480', margin: '4px 0 12px', fontSize: '13px' }}>{ep.description}</p>
{/* 参数 */}
{ep.params.length > 0 && (
<div style={{ marginBottom: '12px' }}>
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '6px' }}>参数</label>
<table className="dev-table" style={{ fontSize: '13px' }}>
<thead>
<tr><th>名称</th><th>类型</th><th>必填</th><th>描述</th></tr>
</thead>
<tbody>
{ep.params.map(p => (
<tr key={p.name}>
<td className="mono-text">{p.name}</td>
<td>{p.type}</td>
<td>{p.required ? <span style={{ color: '#ff4d4f' }}>是</span> : <span style={{ color: '#8892B0' }}>否</span>}</td>
<td>{p.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 请求示例 */}
{ep.example_request && (
<div style={{ marginBottom: '12px' }}>
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '4px' }}>请求示例</label>
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
<code>{ep.example_request}</code>
</pre>
</div>
)}
{/* 响应示例 */}
{ep.example_response && (
<div>
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '4px' }}>响应示例</label>
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
<code>{ep.example_response}</code>
</pre>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
{docSections.length === 0 && <div className="dev-empty">正在加载 API 文档...</div>}
</div>
)}
</div>
</div>
);
}