// ─── Dashboard screen ─── Wires up real data from /api/instance, /api/instance/usage/*, /api/invite-codes function DashboardScreen({ setScreen, toast, instance, setInstance, onLogout }) { const [tab, setTab] = React.useState('overview'); const [daily, setDaily] = React.useState([]); const [llmEvents, setLlmEvents] = React.useState([]); const [searchEvents, setSearchEvents] = React.useState([]); const [invites, setInvites] = React.useState({ codes: [], quota: 3, created: 0, remaining: 3 }); const [actionLoading, setActionLoading] = React.useState(''); async function refreshAll() { try { const [inst, d, llm, se, inv] = await Promise.all([ apiRequest('/api/instance').catch(() => null), apiRequest('/api/instance/usage/daily').catch(() => []), apiRequest('/api/instance/usage/events?type=llm').catch(() => []), apiRequest('/api/instance/usage/events?type=search').catch(() => []), apiRequest('/api/invite-codes').catch(() => ({ codes: [], quota: 3, created: 0, remaining: 3 })), ]); if (inst) setInstance(inst); setDaily(Array.isArray(d) ? d : []); setLlmEvents(Array.isArray(llm) ? llm : []); setSearchEvents(Array.isArray(se) ? se : []); setInvites({ codes: Array.isArray(inv.codes) ? inv.codes : [], quota: typeof inv.quota === 'number' ? inv.quota : 3, created: typeof inv.created === 'number' ? inv.created : 0, remaining: typeof inv.remaining === 'number' ? inv.remaining : 0, }); } catch (ex) { if (ex.status === 401) onLogout(); } } React.useEffect(() => { refreshAll(); }, []); async function doAction(action) { setActionLoading(action); try { await apiRequest(`/api/instance/${action}`, { method: 'POST' }); toast(`操作成功: ${action}`); const inst = await apiRequest('/api/instance').catch(() => null); if (inst) setInstance(inst); if (action === 'restart' || action === 'start' || action === 'reset') { setScreen('booting'); } } catch (ex) { toast(errText(ex.code || ex.message), 'error'); } finally { setActionLoading(''); } } function openConsole() { if (instance?.dashboard_url) { window.open(instance.dashboard_url, '_blank'); } } async function generateInvite() { try { const data = await apiRequest('/api/invite-codes', { method: 'POST' }); setInvites(s => ({ ...s, codes: [{ code: data.code, created_at: data.created_at, used: false }, ...s.codes], created: s.created + 1, remaining: s.quota < 0 ? s.remaining : Math.max(0, s.remaining - 1), })); toast('已生成新邀请码'); } catch (ex) { toast(errText(ex.code || ex.message), 'error'); } } const ready = instance?.availability === 'ready'; const uptime = formatUptime(instance?.created_at); const stats = computeStats(daily, uptime); return (
{/* Marquee status strip */}
● {ready ? 'READY' : (instance?.availability || 'OFFLINE').toUpperCase()}  ·  节点 {instance?.id ? ('xx-' + String(instance.id).slice(0, 6)) : '—'}  ·  ★ 今日 LLM {stats.today.requests} · Token {formatNum(stats.today.tokens)} · 搜索 {stats.today.searches}  ·  ◆ 总计 LLM {stats.total.requests} · Token {formatNum(stats.total.tokens)}  ·  系统版本 v4.2.0 · {ready ? '运行正常' : '初始化中'}  ·  ● {ready ? 'READY' : 'INIT'}  ·  节点 {instance?.id ? ('xx-' + String(instance.id).slice(0, 6)) : '—'}  · 
{/* LEFT */}
{/* Instance window */}
my_instance
{ready ? '●LIVE' : '·' + (instance?.status || 'INIT').toUpperCase()}
云端电脑
xx-{String(instance?.id || '').slice(0, 6) || '------'}
{ready ? 'READY' : (instance?.availability || 'LOADING').toUpperCase()}

STATUS{instance?.status || '—'}
HEALTH{instance?.health_status || '—'}
UPTIME{uptime.label}
CREATED{formatDate(instance?.created_at)}
{/* Bindings */}
connections
{countConnected(instance)}/2
{/* Invite codes */}
invites.my
{invites.created}/{invites.quota < 0 ? '∞' : invites.quota}
剩余名额 {invites.quota < 0 ? '∞' : invites.remaining} / {invites.quota < 0 ? '∞' : invites.quota}
{invites.codes.length === 0 && (
{'// 还没生成过邀请码'}
)} {invites.codes.map(ic => ( ))}
{/* RIGHT */}
usage.log
LAST {daily.length}D
{[['overview', '每日概览'], ['llm', 'LLM 事件'], ['search', '搜索事件']].map(([k, label]) => ( ))}
{tab === 'overview' && } {tab === 'llm' && } {tab === 'search' && }
setScreen('files')} disabled={!ready}/> setScreen('term')} disabled={!ready || instance?.terminal_enabled === false}/>
); } // ─── Binding row (WeChat/Feishu) ─── Handles QR flow with polling function BindingRow({ kind, label, instance, setInstance, ready, toast, color }) { const [loading, setLoading] = React.useState(false); const [qrSrc, setQrSrc] = React.useState(''); const [state, setState] = React.useState({ status: (instance?.[`${kind}_status`]) || 'disconnected', message: '', session_key: '', qr_code_url: '', }); const pollRef = React.useRef(0); // Sync from instance when it updates React.useEffect(() => { setState(s => ({ ...s, status: instance?.[`${kind}_status`] || 'disconnected' })); }, [instance, kind]); async function fetchStatus() { try { const data = await apiRequest(`/api/instance/${kind}/status`); setState(data); if (data.status === 'connecting' && data.session_key) startPolling(data.session_key); } catch { /* ignore */ } } async function start() { setLoading(true); try { const data = await apiRequest(`/api/instance/${kind}/login/start`, { method: 'POST' }); setState({ ...data, status: 'connecting' }); startPolling(data.session_key); } catch (ex) { toast(errText(ex.code || ex.message), 'error'); } finally { setLoading(false); } } async function reconnect() { setLoading(true); try { const data = await apiRequest(`/api/instance/${kind}/reconnect`, { method: 'POST' }); setState({ ...data, status: 'connecting' }); startPolling(data.session_key); } catch (ex) { toast(errText(ex.code || ex.message), 'error'); } finally { setLoading(false); } } async function disconnect() { setLoading(true); try { await apiRequest(`/api/instance/${kind}/disconnect`, { method: 'POST' }); setState({ status: 'disconnected', message: '', session_key: '', qr_code_url: '' }); setQrSrc(''); toast(kind === 'weixin' ? '微信已断开' : '飞书已断开'); const inst = await apiRequest('/api/instance').catch(() => null); if (inst) setInstance(inst); } catch (ex) { toast(errText(ex.code || ex.message), 'error'); } finally { setLoading(false); } } function startPolling(sessionKey) { const myToken = ++pollRef.current; const delay = kind === 'weixin' ? 800 : 2000; const poll = async () => { if (myToken !== pollRef.current) return; try { const data = await apiRequest(`/api/instance/${kind}/login/wait`, { method: 'POST', body: JSON.stringify({ session_key: sessionKey }), }); if (myToken !== pollRef.current) return; if (data.status === 'connected') { setState(s => ({ ...s, status: 'connected', qr_code_url: '' })); setQrSrc(''); toast(kind === 'weixin' ? '微信连接成功' : '飞书连接成功'); const inst = await apiRequest('/api/instance').catch(() => null); if (inst) setInstance(inst); return; } if (data.status === 'error') { setState(s => ({ ...s, status: 'error', message: data.message || '' })); toast(kind === 'weixin' ? '微信连接失败' : '飞书连接失败', 'error'); return; } } catch { /* keep polling */ } setTimeout(poll, delay); }; setTimeout(poll, 300); } // Render QR when qr_code_url changes React.useEffect(() => { if (!state.qr_code_url) { setQrSrc(''); return; } if (!window.QRCode?.toDataURL) return; window.QRCode.toDataURL(state.qr_code_url, { width: 220, margin: 2, color: { dark: '#16161d', light: '#f5efd8' }, }).then(setQrSrc).catch(() => setQrSrc('')); }, [state.qr_code_url]); // Clean up polling on unmount React.useEffect(() => () => { pollRef.current++; }, []); const connected = state.status === 'connected'; return ( <>
{label}
{connected ? (state.account_id ? `account: ${state.account_id}` : '已连接') : (state.status === 'connecting' ? '扫码中...' : '未连接')}
{!ready ? ( 未就绪 ) : connected ? (
) : state.status === 'connecting' ? ( ) : ( )}
{state.status === 'connecting' && ready && (
请使用{kind === 'weixin' ? '微信' : '飞书'}扫描下方二维码
{qrSrc ? ( QR ) : (
生成二维码中...
)}
等待扫码确认中
)} ); } // ─── Invite row ─── function InviteRow({ item, toast }) { function copy() { const v = item.code; if (navigator.clipboard?.writeText) navigator.clipboard.writeText(v).then(()=>toast('已复制')); else { const el = document.createElement('textarea'); el.value = v; document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el); toast('已复制'); } } return (
{item.code} {item.used ? `已被 ${item.used_by_email || '他人'} 使用` : '未使用'}
{item.used ? 'USED' : 'OPEN'} {!item.used && ( )}
); } function StatTile({ label, value, unit, sparks, variant }) { const cls = 'stat' + (variant ? ` stat--${variant}` : ''); const max = Math.max(1, ...(sparks || [1])); return (
{label}
{value} {unit && {unit}}
{(sparks || []).map((v, i) => )}
); } function QuickCard({ icon, title, desc, onClick, disabled }) { return ( ); } function ChartOverview({ daily }) { // Daily is sorted server-side; show up to last 14 const data = (daily || []).slice(-14).map(d => ({ d: (d.date || '').slice(5), // "MM-DD" llm: d.llm_requests || 0, tok: Math.round((d.llm_total_tokens || 0) / 1000), s: (d.search_requests || 0) + (d.search_extract_requests || 0), })); if (data.length === 0) { return
{'// 暂无数据'}
; } const max = Math.max(1, ...data.map(d => Math.max(d.llm, d.tok, d.s))); return (
{data.map((d, i) => (
))}
{data.map((d, i) =>
{d.d}
)}
); } function LegendDot({ color, label }) { return (
{label}
); } function LlmEventTable({ rows }) { if (!rows || rows.length === 0) { return
{'// 暂无事件'}
; } return ( {rows.map(r => ( ))}
时间模型状态延迟Token
{formatTime(r.created_at)} {r.model || '—'} {r.status === 'success' ? ● OK : ● {r.status}} {r.latency_ms}ms {r.total_tokens || 0}
); } function SearchEventTable({ rows }) { if (!rows || rows.length === 0) { return
{'// 暂无事件'}
; } return ( {rows.map(r => ( ))}
时间提供方操作结果数状态
{formatTime(r.created_at)} {r.provider || '—'} {r.operation} {r.result_count || 0} {r.status === 'success' ? ● OK : ● {r.status}}
); } // ─── helpers ─── function formatUptime(iso) { if (!iso) return { label: '—', short: '—' }; const start = new Date(iso).getTime(); if (Number.isNaN(start)) return { label: '—', short: '—' }; const ms = Date.now() - start; const d = Math.floor(ms / 86400000); const h = Math.floor((ms % 86400000) / 3600000); const m = Math.floor((ms % 3600000) / 60000); return { label: `${d}d ${h}h ${String(m).padStart(2, '0')}m`, short: d > 0 ? `${d}d ${h}h` : `${h}h ${m}m`, }; } function formatDate(iso) { if (!iso) return '—'; const dt = new Date(iso); if (Number.isNaN(dt.getTime())) return '—'; const pad = n => String(n).padStart(2, '0'); return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; } function formatTime(iso) { if (!iso) return '—'; const dt = new Date(iso); if (Number.isNaN(dt.getTime())) return '—'; const pad = n => String(n).padStart(2, '0'); return `${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`; } function formatNum(n) { const v = Number(n || 0); if (v >= 1_000_000) return (v / 1_000_000).toFixed(1) + 'M'; if (v >= 1_000) return (v / 1_000).toFixed(1) + 'k'; return String(v); } function countConnected(instance) { let c = 0; if (instance?.weixin_status === 'connected') c++; if (instance?.feishu_status === 'connected') c++; return c; } function computeStats(daily, uptime) { const arr = daily || []; const total = { requests: arr.reduce((s, d) => s + (d.llm_requests || 0), 0), tokens: arr.reduce((s, d) => s + (d.llm_total_tokens || 0), 0), searches: arr.reduce((s, d) => s + (d.search_requests || 0) + (d.search_extract_requests || 0), 0), }; const today = arr[arr.length - 1] || {}; // Sparklines: last 12 days, or pad with zeros const slice = arr.slice(-12); const pad = Array(Math.max(0, 12 - slice.length)).fill({}); const series = [...pad, ...slice]; return { total, today: { requests: today.llm_requests || 0, tokens: today.llm_total_tokens || 0, searches: (today.search_requests || 0) + (today.search_extract_requests || 0), }, sparks: { requests: series.map(d => d.llm_requests || 0), tokens: series.map(d => Math.round((d.llm_total_tokens || 0) / 1000)), searches: series.map(d => (d.search_requests || 0) + (d.search_extract_requests || 0)), uptime: Array(12).fill(1).map((_, i) => i + 1), // monotonic fake since uptime grows linearly }, }; } window.DashboardScreen = DashboardScreen;