// Page 2: 品牌列表 + drilldown + 方案散布圖 function PageBrands({ filters, density, drilldownId, setDrilldownId }) { const { BRANDS } = window.MOCK; if (drilldownId) { const brand = BRANDS.find(b => b.id === drilldownId); if (brand) return setDrilldownId(null)} range={filters.range} />; } const sorted = [...BRANDS].sort((a, b) => b.totalCost - a.totalCost); return (
b.status === 'active').length} 個使用中`} /> 方案內 超過上限
} />
{(w, h) => }
匯出 CSV} /> setDrilldownId(b.id)} /> ); } function PlanTierScatter({ brands, onPick, w = 920, h = 320 }) { const padL = 60, padR = 20, padT = 14, padB = 36; const tierOrder = ['trial', 'starter', 'growth', 'enterprise']; const tierLabel = { trial: '試用', starter: '入門版', growth: '成長版', enterprise: '企業版' }; const tierX = { trial: 0.15, starter: 0.4, growth: 0.65, enterprise: 0.9 }; const innerW = w - padL - padR; const innerH = h - padT - padB; const maxCost = Math.max(...brands.map(b => b.totalCost)) * 1.05; const yScale = (v) => padT + innerH - (v / maxCost) * innerH; const jittered = brands.map(b => { const hash = b.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0); const jitter = ((hash * 31) % 100) / 100 * 0.18 - 0.09; return { ...b, x: padL + (tierX[b.tier] + jitter) * innerW, y: yScale(b.totalCost), r: Math.max(3.5, Math.min(14, Math.sqrt(b.totalConvos) * 0.45)), overCeiling: b.totalCost > b.plan_ceiling_usd, }; }); const ceilings = { trial: 100, starter: 400, growth: 1500, enterprise: 5000 }; return ( {[0, 0.25, 0.5, 0.75, 1].map(t => { const y = padT + innerH - t * innerH; return ( ${fmtCompact(maxCost * t)} ); })} {tierOrder.map((t) => { const x = padL + tierX[t] * innerW; const ceilY = yScale(ceilings[t]); return ( 上限 ${ceilings[t]} {tierLabel[t]} {brands.filter(b => b.tier === t).length} 個品牌 ); })} {jittered.map(b => ( onPick(b.id)}> {`${b.name}(idx=${b.id}) · ${fmtUSD(b.totalCost, 2)} · ${b.totalConvos} 場`} {b.overCeiling && ( {b.name.split(' ')[0]} )} ))} ); } function BrandDrilldown({ brand, onBack, range }) { const [tab, setTab] = React.useState('activity'); const [summary, setSummary] = React.useState(null); // { total_*, delta_*, ... } from /summary React.useEffect(() => { let alive = true; if (!brand || !brand.id) return; setSummary(null); window.AdminAPI.brands.summary(brand.id, range || '30d') .then((s) => { if (alive) setSummary(s || {}); }) .catch((e) => { if (alive) { console.warn('brand summary failed', e); setSummary({}); } }); return () => { alive = false; }; }, [brand && brand.id, range]); // delta 拿 summary 的真實值;summary 沒回時退回 0(不再用編造的 +12% / -2%) const dCost = (summary && summary.delta_total_cost_usd) || 0; const dConvos = (summary && summary.delta_total_convos) || 0; const dTokens = (summary && summary.delta_total_tokens) || 0; const dCache = (summary && summary.delta_cache_hit_rate) || 0; const cacheHit = summary && typeof summary.cache_hit_rate === 'number' ? summary.cache_hit_rate : (brand.cacheHit || 0); return (
/ {brand.name}
{brand.name} {brand.isAnomalous && ⚠ 異常 · {brand.anomalyRatio}× 基準}
brand_idx={brand.id} {brand.lastActivityHrs < 9999 && · 最後活躍 {brand.lastActivityHrs} 小時前}
c * 220000)} sparkColor="#6D28D9" inverse />
{[ { id: 'activity', label: '活動趨勢' }, { id: 'users', label: '成員' }, { id: 'sessions', label: '對話紀錄' }, ].map(t => ( ))}
{tab === 'activity' && } {tab === 'users' && } {tab === 'sessions' && }
); } function BrandActivityChart({ brand }) { // 從 BRANDS 拿不到日期 array → 用 DAILY_AGG.date 對齊(兩者都用 fullDates) const dates = ((window.MOCK && window.MOCK.DAILY_AGG) || []).map((d) => d.date); return ( {(w, h) => } ); } function BrandActivityChartImpl({ brand, dates, w, h }) { const padL = 50, padR = 50, padT = 14, padB = 24; const innerW = Math.max(0, w - padL - padR); const innerH = h - padT - padB; const dailyCost = brand.dailyCost || []; const dailyConvos = brand.dailyConvos || []; const n = Math.max(1, dailyCost.length); const stepX = n > 1 ? innerW / (n - 1) : innerW; const maxCost = Math.max(0.0001, ...dailyCost) * 1.1; const maxConvo = Math.max(1, ...dailyConvos) * 1.1; const costPath = dailyCost.map((v, i) => { const x = padL + i * stepX; const y = padT + innerH - (v / maxCost) * innerH; return (i === 0 ? 'M' : 'L') + x.toFixed(1) + ',' + y.toFixed(1); }).join(' '); return (
{[0, 0.25, 0.5, 0.75, 1].map((t) => { const y = padT + innerH - t * innerH; return ( ${fmtCompact(maxCost * t)} {fmtCompact(maxConvo * t)} ); })} {dailyConvos.map((v, i) => { const barH = (v / maxConvo) * innerH; const x = padL + i * stepX - 3; const y = padT + innerH - barH; return ; })} {dailyCost.length > 1 && ( <> )} {dailyCost.map((v, i) => ( {`${dates[i] || `day ${i + 1}`} · ${fmtUSD(v, 4)} · ${dailyConvos[i] || 0} 場`} ))} {/* X 軸標籤:頭、中、尾三個 */} {dates.length > 0 && [0, Math.floor((n - 1) / 2), n - 1].filter((i, idx, arr) => arr.indexOf(i) === idx).map((i) => ( {(dates[i] || '').slice(5)} ))}
每日費用 (USD) 對話數
); } function BrandUsersTable({ brand }) { const [users, setUsers] = React.useState(null); // null = loading const [error, setError] = React.useState(null); React.useEffect(() => { let alive = true; setUsers(null); setError(null); if (!brand || !brand.id) return; window.AdminAPI.brands.users(brand.id, 50) .then((data) => { if (!alive) return; setUsers((data && data.users) || []); }) .catch((e) => { if (!alive) return; setError(e.message || '載入失敗'); setUsers([]); }); return () => { alive = false; }; }, [brand && brand.id]); if (users == null) { return
載入成員中...
; } if (error) { return
{error}
; } if (users.length === 0) { return
此品牌目前沒有任何成員操作過 agent。
; } const formatLastActive = (iso) => { if (!iso) return '—'; const ms = Date.now() - new Date(iso).getTime(); const mins = Math.floor(ms / 60000); if (mins < 1) return '剛剛'; if (mins < 60) return `${mins} 分鐘前`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs} 小時前`; const days = Math.floor(hrs / 24); if (days < 30) return `${days} 天前`; return iso.slice(0, 10); }; return ( {users.map((u) => ( ))}
使用者 對話數 Token 費用 最後活躍
{u.user_admin_name || `admin#${u.user_admin_id}`}
idx={u.user_admin_id}
{u.total_convos} {fmtCompact(u.total_tokens)} {fmtUSD(u.total_cost_usd, 4)} {formatLastActive(u.last_activity_at)}
); } function BrandSessionsList({ brand }) { // 之前讀 window.MOCK.CONVERSATIONS(brands route 不會 load 對話列表,永遠空)。 // 改成 useEffect 直接拉 /api/conversations?brand_id=... const [list, setList] = React.useState(null); const [error, setError] = React.useState(null); React.useEffect(() => { let alive = true; setList(null); setError(null); if (!brand || !brand.id) return; window.AdminAPI.conversations.list({ brandId: brand.id, limit: 30 }) .then((data) => { if (!alive) return; const rows = (data && data.conversations) || []; setList(rows); }) .catch((e) => { if (alive) { setError(e.message || '載入失敗'); setList([]); } }); return () => { alive = false; }; }, [brand && brand.id]); if (list == null) return
載入對話列表中...
; if (error) return
{error}
; if (list.length === 0) return
此品牌目前沒有對話紀錄。
; const onOpenThread = (threadId) => { window.location.hash = '#/conversations/' + encodeURIComponent(threadId); }; return ( {list.map((s) => { const updated = s.updated_at ? new Date(s.updated_at) : null; return ( onOpenThread(s.thread_id)} style={{ borderBottom: '1px solid #EEEEE9', height: 32, cursor: 'pointer' }} onMouseEnter={(e) => e.currentTarget.style.background = '#FFFAB8'} onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}> ); })}
更新時間標題使用者 訊息 工具 費用
{updated ? updated.toISOString().slice(5, 16).replace('T', ' ') : '—'} {s.title || '新對話'} {s.user_admin_name || (s.user_admin_id ? `admin#${s.user_admin_id}` : '—')} {s.message_count || 0} {s.total_tool_calls || 0} {fmtUSD(s.total_cost_usd || 0, 4)}
); } window.PageBrands = PageBrands;