Add monitoring panel return controls
This commit is contained in:
@@ -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>最近3个完整业务日,对比此前7日;按账户、Campaign、素材ID及投放口径分别判断。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>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user