// ─── 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 ? (

) : (
生成二维码中...
)}
等待扫码确认中
)}
>
);
}
// ─── 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 (
| 时间 | 模型 | 状态 | 延迟 | Token |
{rows.map(r => (
| {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;