Add monitoring panel return controls

This commit is contained in:
2026-09-10 19:25:05 +08:00
parent 1cc310161e
commit 0db0d06787
@@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
type Settings={enabled:boolean;minSpend:number;minOrders:number;minImpressions:number;staleHours:number;fatigueCtrDrop:number;fatigueViewDrop:number;fatigueMinClicks:number};
type Account={id:string;tenant:string;name:string;currency:string;timezone:string;period?:string;count:number;excluded:number;error?:string;fatigueError?:string;fatigueCount?:number;fatigueExcluded?:number};
type FatigueRow={key:string;id:string;campaignId:string;variant:string;state:string;reason:string;suggestions:string[];handled:boolean;period:string;account:Account;ctrDrop:number|null;viewDrop:number|null;recent:{ctr:number|null;impressions:number;clicks:number;days:number};baseline:{ctr:number|null;impressions:number;clicks:number;days:number}};
type Row={key:string;id:string;name:string;target:number|null;roi:number|null;cpa:number|null;ctr:number|null;state:string;reason:string;suggestions:string[];handled:boolean;account?:Account;period?:string;metrics?:{spend:number;orders:number;revenue:number}};
type State={settings:Settings;accounts?:Account[];lastCheck?:string;period?:string;lastSuccess?:string;error?:string;rows:Row[];fatigueRows?:FatigueRow[];newAlerts:number;busy?:boolean};
const labels:Record<string,string>={potential:"高潜候选",under_target:"不达标",no_orders:"消耗无订单",creative_review:"效率预警",observe:"待观察",data_issue:"数据待核实",target_missing:"目标缺失",normal:"正常",inactive:"未启用"};
export function Monitoring({onClose}:{onClose:()=>void}) {
const [accountFilter,setAccountFilter]=useState("");
const [view,setView]=useState("campaign");
const [fatigueState,setFatigueState]=useState("");
const [visibleCount,setVisibleCount]=useState(50);
const [state,setState]=useState<State>();const [error,setError]=useState("");const [busy,setBusy]=useState(false);const [settings,setSettings]=useState<Settings>();
async function request(url:string,method="GET",body?:unknown){const r=await fetch(url,{method,headers:body===undefined?undefined:{"content-type":"application/json"},body:body===undefined?undefined:JSON.stringify(body)});if(!r.ok)throw new Error("操作失败,请稍后重试");return await r.json() as State;}
useEffect(()=>{let live=true;const load=()=>request("/api/monitoring").then(s=>{if(live){setState(s);setSettings(old=>old??s.settings);}}).catch(()=>{if(live)setError("预警服务连接失败");});void load();const timer=setInterval(()=>void load(),30000);return()=>{live=false;clearInterval(timer);};},[]);
useEffect(()=>{const close=(event:KeyboardEvent)=>{if(event.key==="Escape")onClose();};window.addEventListener("keydown",close);return()=>window.removeEventListener("keydown",close);},[onClose]);
async function action(url:string,method="POST",body?:unknown){setBusy(true);setError("");try{setState(await request(url,method,body));}catch(e){setError(String(e));}finally{setBusy(false);}}
const num=(n:number|null|undefined)=>n==null?"—":n.toFixed(2);
const fatigueRows=state?.fatigueRows?.filter(r=>(!accountFilter||`${r.account.tenant}:${r.account.id}`===accountFilter)&&(!fatigueState||r.state===fatigueState))??[];
return <div className="monitor-overlay" onMouseDown={(event)=>{if(event.target===event.currentTarget)onClose();}}>
<section className="monitor-dialog" role="dialog" aria-modal="true" aria-label="SW Ads 预警中心">
<button className="monitor-back" onClick={onClose} aria-label="返回工作台"> </button><h1>SW Ads · </h1>
<p> · · ROI = ÷ 广</p>
<p>访 TikTok {state?.accounts?.length??0} Campaign </p>
<p>{state?.lastCheck?new Date(state.lastCheck).toLocaleString():"尚未检查"} · {state?.lastSuccess?new Date(state.lastSuccess).toLocaleString():"—"}</p>
{(error||state?.error)&&<p role="alert" style={{color:"#b42318"}}>{error||state?.error}</p>}
<button disabled={busy||state?.busy} onClick={()=>void action("/api/monitoring/check")}>{busy||state?.busy?"检查中…":"立即检查全部 TikTok 账户"}</button>
{state?.accounts?.map(a=><article key={`${a.tenant}:${a.id}`} style={{padding:12,marginTop:10,border:"1px solid #dde3e6"}}><strong>{a.name}</strong><p>{a.id} · {a.currency} · {a.timezone} · {a.period??"未完成"} · Campaign {a.count} </p>{a.error?<p role="alert" style={{color:"#b42318"}}>{a.error}</p>:<p>{a.count?"已完成读取,详见下方预警":"没有可评估的电商 Campaign,不代表账户投放正常"}{a.excluded>0?`;另有 ${a.excluded} 条非电商记录未使用电商 ROI 规则评估`:""}</p>}</article>)}
{settings&&<fieldset style={{margin:"16px 0"}}><legend> Campaign ROI</legend>
<label><input type="checkbox" checked={settings.enabled} onChange={e=>setSettings({...settings,enabled:e.target.checked})}/></label>
{([ ["minSpend","最低消耗(账户币种)"],["minOrders","最低订单数"],["minImpressions","CTR最低曝光"],["staleHours","同步有效期(小时)"],["fatigueCtrDrop","素材 CTR 相对降幅(%"],["fatigueViewDrop","6秒观看率相对降幅(%"],["fatigueMinClicks","素材基线最低点击"]] as const).map(([key,label])=><label key={key} style={{display:"inline-block",margin:10}}>{label}<input type="number" min={1} value={settings[key]} onChange={e=>setSettings({...settings,[key]:Number(e.target.value)})} style={{width:90,marginLeft:8}}/></label>)}
<button disabled={busy} onClick={()=>void action("/api/monitoring/settings","PUT",settings)}></button>
</fieldset>}
<p>{state?.newAlerts??0}Campaign广</p>
<label> <select value={accountFilter} onChange={e=>setAccountFilter(e.target.value)}><option value=""> TikTok </option>{state?.accounts?.map(a=><option key={`${a.tenant}:${a.id}`} value={`${a.tenant}:${a.id}`}>{a.name}</option>)}</select></label>
<p><button aria-pressed={view==="campaign"} onClick={()=>setView("campaign")}>Campaign </button> <button aria-pressed={view==="fatigue"} onClick={()=>setView("fatigue")}></button></p>
{view==="fatigue"&&<section aria-label="素材疲劳预警">
<p>37CampaignID及投放口径分别判断CTR按总点击÷</p>
<p>6退ADS_AND_ORGANIC 广</p>
{state?.accounts?.filter(a=>!accountFilter||`${a.tenant}:${a.id}`===accountFilter).map(a=><p key={a.id} role={a.fatigueError?"alert":undefined}>{a.name}{a.error||a.fatigueError||`${a.fatigueCount??0} 条素材结果;排除 ${a.fatigueExcluded??0} 条非视频日记录或未匹配素材`}</p>)}
{!state?.fatigueRows?.length&&<p></p>}
<label> <select value={fatigueState} onChange={e=>{setFatigueState(e.target.value);setVisibleCount(50);}}>{[["","全部状态"],["suspected_fatigue","疑似疲劳"],["creative_decline","素材衰退"],["observe","待观察"],["data_issue","数据待核实"],["normal","未触发规则"]].map(([value,label])=><option key={value} value={value}>{label}</option>)}</select></label>
<p> {fatigueRows.length} {Math.min(visibleCount,fatigueRows.length)} </p>
{fatigueRows.slice(0,visibleCount).map(r=><article key={r.key} style={{padding:16,margin:"12px 0",border:"1px solid #dde3e6",borderLeft:`5px solid ${r.state==="suspected_fatigue"?"#c24c35":r.state==="creative_decline"?"#c08722":"#899ba5"}`}}>
<h3> {r.id} · {r.state==="suspected_fatigue"?"疑似疲劳":r.state==="creative_decline"?"素材衰退 / 疲劳证据不足":labels[r.state]??r.state}{r.handled?" · 已处理":""}</h3>
<p>{r.account.name} · Campaign {r.campaignId} · {r.variant||"视频"} · {r.period}</p>
<p>线 CTR {num(r.baseline.ctr===null?null:r.baseline.ctr*100)}% {num(r.recent.ctr===null?null:r.recent.ctr*100)}% · {num(r.ctrDrop)}% · 6 {r.viewDrop===null?"无有效数据":`${num(r.viewDrop)}%`}</p>
<p>线{r.baseline.impressions} / {r.baseline.clicks} / {r.baseline.days} {r.recent.impressions} / {r.recent.clicks} / {r.recent.days} </p>
<p>{r.reason}</p><ul>{r.suggestions.map(s=><li key={s}>{s}</li>)}</ul><button disabled={r.handled||busy||state?.busy} onClick={()=>void action("/api/monitoring/handled","POST",{key:r.key})}></button>
</article>)}
{visibleCount<fatigueRows.length&&<button onClick={()=>setVisibleCount(visibleCount+50)}>显示更多素材50条</button>}
</section>}
{view==="campaign"&&<>
{!state?.rows?.length&&<p>暂无结果请先检查空结果不代表所有广告正常</p>}
{state?.rows?.filter(r=>!accountFilter||`${r.account?.tenant}:${r.account?.id}`===accountFilter).map(r=><article key={r.key} style={{padding:16,margin:"12px 0",border:"1px solid #dde3e6",borderLeft:`5px solid ${r.state==="potential"?"#198754":r.state==="under_target"?"#c24c35":"#899ba5"}`,borderRadius:8}}>
<h3>{r.name} <small>{labels[r.state]??r.state}{r.handled?" · 已处理":""}</small></h3><small>{r.id}</small>
<p>{r.account?.name??"旧版结果,请重新检查"} · {r.account?.currency??"—"} · {r.period??"—"}</p>
<p> ROI {num(r.target)} · ROI {num(r.roi)} · {num(r.metrics?.spend)} · {num(r.metrics?.revenue)} · {r.metrics?.orders??"—"} · CPA {num(r.cpa)} · CTR {r.ctr==null?"—":`${num(r.ctr*100)}%`}</p>
<p>{r.reason}</p><ul>{r.suggestions.map(s=><li key={s}>{s}</li>)}</ul>
<button disabled={r.handled||busy} onClick={()=>void action("/api/monitoring/handled","POST",{key:r.key})}></button>
</article>)}
</>}
</section></div>;
}