]> acesimba.cloud Git - StationBuilder.git/commitdiff
提示框改为页面居中:用自定义弹窗替换原生 confirm/prompt
authorwang.yu <wang.yu@advantech.com>
Wed, 19 Aug 2026 07:14:32 +0000 (15:14 +0800)
committerwang.yu <wang.yu@advantech.com>
Wed, 19 Aug 2026 07:14:32 +0000 (15:14 +0800)
- 新增 useConfirm/usePrompt hook,返回 promise 化 confirm/prompt 及居中弹窗节点
- 替换全部原生 confirm(9处) 与 prompt(2处) 调用,组件根部渲染对应弹窗
- globals.css 新增 .dlg-backdrop 全屏遮罩 + .dlg-box 居中对话框(含输入框/Enter确认/Esc取消)
- 文件上传仍用原生文件控件(非提示框,无法CSS居中)

app/globals.css
app/page.tsx

index 046b344dae03bc68fcb136aa1ad8bf6017de8b17..a6aa1a6dd9ae9ca3041574f45377a94b35061921 100644 (file)
 .tpl-thumb{width:48px;height:34px;object-fit:cover;border:1px solid var(--line);border-radius:3px;display:block;background:#f2f5f7}
 .tpl-thumb-empty{color:#c2c8cc}
 
+/* ===== 自定义确认 / 输入弹窗(替代原生 confirm/prompt,居中显示) ===== */
+.dlg-backdrop{position:fixed;inset:0;z-index:90;background:#00000059;display:flex;align-items:center;justify-content:center;padding:20px}
+.dlg-box{background:#fff;border:1px solid #d2d6da;border-radius:8px;box-shadow:0 12px 36px #0003;width:min(420px,100%);max-width:100%;padding:20px 22px}
+.dlg-box p{margin:0 0 18px;font-size:13px;line-height:1.6;color:#333;white-space:pre-wrap;word-break:break-word}
+.dlg-box input{width:100%;border:1px solid var(--line);border-radius:4px;height:32px;padding:0 9px;font-size:13px}
+.dlg-box input:focus{outline:1px solid var(--blue)}
+.dlg-actions{display:flex;justify-content:flex-end;gap:10px}
+.dlg-actions .button{margin-left:0}
+
index fdf663755890ff740406506f4cf8ad8d0940aa56..81eea3ad316ee6e313f18ab29f90cf966f3fbdfd 100644 (file)
@@ -322,7 +322,7 @@ export default function Home(){
   };
   const deleteSites=async(ids:number[])=>{
     if(!ids.length) return;
-    if(!window.confirm(`确认删除选中的 ${ids.length} 个站点?该操作不可恢复,相关资产、页面与生成文件将一并删除。`)) return;
+    if(!(await confirm(`确认删除选中的 ${ids.length} 个站点?该操作不可恢复,相关资产、页面与生成文件将一并删除。`))) return;
     try{
       await fetchDb("/sites", {method:"DELETE", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ids})});
       setSites(prev=>prev.filter(s=>!ids.includes(s.id)));
@@ -505,12 +505,13 @@ function Projects({view,setView,sites,loading,go,loadSite,notify,onDelete}:{view
     return {all,draft,running,done,partial};
   },[sites]);
   const [selected,setSelected] = useState<Set<number>>(new Set());
+  const {confirm, el:confirmEl}=useConfirm();
   const toggle = (id:number)=> setSelected(prev=>{ const n=new Set(prev); if(n.has(id)) n.delete(id); else n.add(id); return n; });
   const allSelected = sites.length>0 && selected.size===sites.length;
   const toggleAll = ()=> setSelected(allSelected ? new Set() : new Set(sites.map(s=>s.id)));
   const batchDelete = async ()=>{ await onDelete([...selected]); setSelected(new Set()); };
   if(loading) return <Panel><div className="empty-state">加载中…</div></Panel>;
-  return <><div className="summary-strip">{[[String(counts.all),"全部项目"],[String(counts.draft),"草稿"],[String(counts.running),"生成中"],[String(counts.done),"生成完成"],[String(counts.partial),"部分失败"]].map(x=><div key={x[1]}><b>{x[0]}</b><span>{x[1]}</span></div>)}</div><Panel><div className="filterbar"><label>⌕ <input placeholder="搜索站点名称"/></label><select><option>全部领域</option></select><select><option>全部状态</option></select><Button>Search</Button><span/>{selected.size>0 && <Button secondary onClick={batchDelete}>批量删除 ({selected.size})</Button>}<div className="view-switch"><button className={view==="table"?"active":""} onClick={()=>setView("table")}>☷</button><button className={view==="card"?"active":""} onClick={()=>setView("card")}>▦</button></div><button className="icon-button" onClick={()=>notify("列表已刷新")}>↻</button></div>{view==="table"?<table className="data-table"><thead><tr><th><input type="checkbox" checked={allSelected} onChange={toggleAll}/></th><th>站点名称 ↕</th><th>所属领域</th><th>设备数量</th><th>MU 页面</th><th>Cube 页面</th><th>状态</th><th>最近修改</th><th>操作</th></tr></thead><tbody>{sites.map(row=><tr key={row.id}><td><input type="checkbox" checked={selected.has(row.id)} onChange={()=>toggle(row.id)}/></td><td><button className="link" onClick={()=>go(row.status==="generated"?"result":"basic", row.id)}><span className="site-icon">⬡</span><b>{row.name || "未命名站点"}</b></button></td><td>{row.domain}</td><td>{row.device_count}</td><td>{row.mu_count}</td><td>{row.cube_count}</td><td><Status>{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}</Status></td><td>{row.updated_at}</td><td><button className="link danger" onClick={(e)=>{e.stopPropagation(); onDelete([row.id]);}}>删除</button></td></tr>)}</tbody></table>:<div className="project-grid">{sites.map(row=><article key={row.id} onClick={()=>go(row.status==="generated"?"result":"basic", row.id)}><div><span className="site-icon big">⬡</span><Status>{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}</Status></div><h3>{row.name || "未命名站点"}</h3><button className="card-del" onClick={(e)=>{e.stopPropagation(); onDelete([row.id]);}}>删除</button><p>{row.domain}</p><section><span><b>{row.device_count}</b> 设备</span><span><b>{row.mu_count}</b> MU</span><span><b>{row.cube_count}</b> Cube</span></section><small>更新于 {row.updated_at}</small></article>)}</div>}<div className="pagination"><span>共 {sites.length} 条</span><button>‹</button><button className="active">1</button><button>›</button><select><option>20 / page</option></select></div></Panel></>
+  return <>{confirmEl}<div className="summary-strip">{[[String(counts.all),"全部项目"],[String(counts.draft),"草稿"],[String(counts.running),"生成中"],[String(counts.done),"生成完成"],[String(counts.partial),"部分失败"]].map(x=><div key={x[1]}><b>{x[0]}</b><span>{x[1]}</span></div>)}</div><Panel><div className="filterbar"><label>⌕ <input placeholder="搜索站点名称"/></label><select><option>全部领域</option></select><select><option>全部状态</option></select><Button>Search</Button><span/>{selected.size>0 && <Button secondary onClick={batchDelete}>批量删除 ({selected.size})</Button>}<div className="view-switch"><button className={view==="table"?"active":""} onClick={()=>setView("table")}>☷</button><button className={view==="card"?"active":""} onClick={()=>setView("card")}>▦</button></div><button className="icon-button" onClick={()=>notify("列表已刷新")}>↻</button></div>{view==="table"?<table className="data-table"><thead><tr><th><input type="checkbox" checked={allSelected} onChange={toggleAll}/></th><th>站点名称 ↕</th><th>所属领域</th><th>设备数量</th><th>MU 页面</th><th>Cube 页面</th><th>状态</th><th>最近修改</th><th>操作</th></tr></thead><tbody>{sites.map(row=><tr key={row.id}><td><input type="checkbox" checked={selected.has(row.id)} onChange={()=>toggle(row.id)}/></td><td><button className="link" onClick={()=>go(row.status==="generated"?"result":"basic", row.id)}><span className="site-icon">⬡</span><b>{row.name || "未命名站点"}</b></button></td><td>{row.domain}</td><td>{row.device_count}</td><td>{row.mu_count}</td><td>{row.cube_count}</td><td><Status>{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}</Status></td><td>{row.updated_at}</td><td><button className="link danger" onClick={(e)=>{e.stopPropagation(); onDelete([row.id]);}}>删除</button></td></tr>)}</tbody></table>:<div className="project-grid">{sites.map(row=><article key={row.id} onClick={()=>go(row.status==="generated"?"result":"basic", row.id)}><div><span className="site-icon big">⬡</span><Status>{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}</Status></div><h3>{row.name || "未命名站点"}</h3><button className="card-del" onClick={(e)=>{e.stopPropagation(); onDelete([row.id]);}}>删除</button><p>{row.domain}</p><section><span><b>{row.device_count}</b> 设备</span><span><b>{row.mu_count}</b> MU</span><span><b>{row.cube_count}</b> Cube</span></section><small>更新于 {row.updated_at}</small></article>)}</div>}<div className="pagination"><span>共 {sites.length} 条</span><button>‹</button><button className="active">1</button><button>›</button><select><option>20 / page</option></select></div></Panel></>
 }
 
 function Basic({site,domains,onChange,onAddDomain,onUpload,saving,notify,onViewCompletePrompt}:{site:SiteDetail|null;domains:string[];onChange:(p:Partial<SiteDetail>)=>void;onAddDomain:(n:string)=>void;onUpload:(f:File)=>void;saving:boolean;notify:(m:string)=>void;onViewCompletePrompt:()=>void}){
@@ -985,6 +986,37 @@ function Preview({site,tab,setTab,setModal,notify}:{site:SiteDetail|null;tab:"in
 
 function fileToBase64(file:File){return new Promise<{filename:string;mime:string;data:string}>((resolve,reject)=>{const r=new FileReader();r.onload=()=>{const buf=new Uint8Array(r.result as ArrayBuffer);let bin="";for(const b of buf)bin+=String.fromCharCode(b);resolve({filename:file.name,mime:file.type||"application/octet-stream",data:btoa(bin)});};r.onerror=reject;r.readAsArrayBuffer(file);});}
 
+function useConfirm(){
+  const [state,setState]=useState<{open:boolean;message:string;resolve?:(v:boolean)=>void}>({open:false,message:""});
+  const confirm=(message:string)=>new Promise<boolean>(resolve=>setState({open:true,message,resolve}));
+  const close=(v:boolean)=>{ state.resolve?.(v); setState(s=>({...s,open:false})); };
+  const el = state.open ? (
+    <div className="dlg-backdrop" onClick={()=>close(false)}>
+      <div className="dlg-box" onClick={e=>e.stopPropagation()}>
+        <p>{state.message}</p>
+        <div className="dlg-actions"><button className="button secondary" onClick={()=>close(false)}>取消</button><button className="button" onClick={()=>close(true)}>确定</button></div>
+      </div>
+    </div>
+  ) : null;
+  return { confirm, el };
+}
+
+function usePrompt(){
+  const [state,setState]=useState<{open:boolean;message:string;value:string;resolve?:(v:string|null)=>void}>({open:false,message:"",value:""});
+  const prompt=(message:string, defaultValue="")=>new Promise<string|null>(resolve=>setState({open:true,message,value:defaultValue,resolve}));
+  const close=(v:string|null)=>{ state.resolve?.(v); setState(s=>({...s,open:false})); };
+  const el = state.open ? (
+    <div className="dlg-backdrop" onClick={()=>close(null)}>
+      <div className="dlg-box" onClick={e=>e.stopPropagation()}>
+        <p>{state.message}</p>
+        <input autoFocus value={state.value} onChange={e=>setState(s=>({...s,value:e.target.value}))} onKeyDown={e=>{ if(e.key==="Enter") close(state.value); if(e.key==="Escape") close(null); }}/>
+        <div className="dlg-actions" style={{marginTop:14}}><button className="button secondary" onClick={()=>close(null)}>取消</button><button className="button" onClick={()=>close(state.value)}>确定</button></div>
+      </div>
+    </div>
+  ) : null;
+  return { prompt, el };
+}
+
 function DomainLibrary({go,notify,onOpen,domainFilter,domainOptions}:{go:(p:PageKey)=>void;notify:(m:string)=>void;onOpen:(id:number)=>void;domainFilter:number|null;domainOptions:{id:number;name:string}[]}){
   const [list,setList]=useState<any[]>([]);
   const [loading,setLoading]=useState(true);
@@ -995,7 +1027,8 @@ function DomainLibrary({go,notify,onOpen,domainFilter,domainOptions}:{go:(p:Page
   const [sort,setSort]=useState<{key:""|"domain"|"name"|"status"|"creator";dir:1|-1}>({key:"",dir:1});
   const load=async()=>{ try{ setList(await fetchDb("/domain-templates") as any); }catch(e:any){ notify("加载失败:"+e.message); } finally{ setLoading(false); } };
   useEffect(()=>{ load(); },[]);
-  const del=async(id:number)=>{ if(!confirm("确定删除该领域方案?此操作不可恢复")) return; try{ await fetchDb(`/domain-templates/${id}`,{method:"DELETE"}); notify("已删除"); load(); }catch(e:any){ notify("删除失败:"+e.message); } };
+  const {confirm, el:confirmEl}=useConfirm();
+  const del=async(id:number)=>{ if(!(await confirm("确定删除该领域方案?此操作不可恢复"))) return; try{ await fetchDb(`/domain-templates/${id}`,{method:"DELETE"}); notify("已删除"); load(); }catch(e:any){ notify("删除失败:"+e.message); } };
   const statusLabel=(s:string)=>s==="available"?"可用":"不可用";
   const toggleSort=(key:"domain"|"name"|"status"|"creator")=>setSort(s=>s.key===key?{key,dir:(s.dir*-1) as 1|-1}:{key,dir:1});
   const sortInd=(key:"domain"|"name"|"status"|"creator")=>sort.key===key?(<span className="sort-ind">{sort.dir===1?"▲":"▼"}</span>):null;
@@ -1014,6 +1047,7 @@ function DomainLibrary({go,notify,onOpen,domainFilter,domainOptions}:{go:(p:Page
       if(av<bv) return -1*sort.dir; if(av>bv) return 1*sort.dir; return 0;
     });
   return <>
+    {confirmEl}
     <div className="domain-hero"><div><span className="eyebrow">DOMAIN SOLUTION LIBRARY</span><h2>领域方案库</h2><p>沉淀各领域标准方案,供客户站点直接使用或快速微调。</p></div>
       <div className="domain-hero-stats">{domainOptions.map(d=><div key={d.id}><b>{list.filter(t=>t.domain_id===d.id).length}</b><span>{d.name}</span></div>)}</div>
     </div>
@@ -1104,15 +1138,16 @@ function DeviceTemplateCard({dev,notify,onChange,onBack}:{dev:any;notify:(m:stri
   const [imgPreview,setImgPreview]=useState<string|null>(dev.image_path?`/api/db/device-templates/${dev.id}/image`:null);
   const [imgB64,setImgB64]=useState<{filename:string;mime:string;data:string}|null>(null);
   const [cps,setCps]=useState<any[]>((dev.collection_points||[]).map((c:any)=>({...c,key:c.id,checked:false})));
+  const {confirm, el:confirmEl}=useConfirm();
   const onPick=async(f:File)=>{ setImgPreview(URL.createObjectURL(f)); const b=await fileToBase64(f); setImgB64({filename:b.filename,mime:b.mime,data:b.data}); };
   const save=async()=>{ const body:any={name,description}; if(imgB64) body.image=imgB64; await fetchDb(`/device-templates/${dev.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}); notify("设备已保存"); onChange(); };
-  const delDev=async()=>{ if(!confirm("删除该设备模板?")) return; await fetchDb(`/device-templates/${dev.id}`,{method:"DELETE"}); onChange(); onBack(); };
+  const delDev=async()=>{ if(!(await confirm("删除该设备模板?"))) return; await fetchDb(`/device-templates/${dev.id}`,{method:"DELETE"}); onChange(); onBack(); };
   const addCpRow=()=>setCps([...cps,{key:-(Date.now()),name:"",collect_note:"",unit:"",value_range:"",description:"",checked:false}]);
   const updCp=(k:number,field:string,val:any)=>setCps(cps.map(c=>c.key===k?{...c,[field]:val}:c));
   const toggleCp=(k:number)=>setCps(cps.map(c=>c.key===k?{...c,checked:!c.checked}:c));
   const allChecked=cps.length>0&&cps.every(c=>c.checked);
   const toggleAll=()=>setCps(cps.map(c=>({...c,checked:!allChecked})));
-  const delCpRows=async()=>{ const sel=cps.filter(c=>c.checked); if(sel.length===0){ notify("请先勾选要删除的采集点"); return; } if(!confirm(`确定删除选中的 ${sel.length} 个采集点?`)) return; for(const c of sel){ if(c.id) await fetchDb(`/collection-points/${c.id}`,{method:"DELETE"}); } setCps(cps.filter(c=>!c.checked)); onChange(); };
+  const delCpRows=async()=>{ const sel=cps.filter(c=>c.checked); if(sel.length===0){ notify("请先勾选要删除的采集点"); return; } if(!(await confirm(`确定删除选中的 ${sel.length} 个采集点?`))) return; for(const c of sel){ if(c.id) await fetchDb(`/collection-points/${c.id}`,{method:"DELETE"}); } setCps(cps.filter(c=>!c.checked)); onChange(); };
   const saveCpRows=async()=>{ try{ for(const c of cps){ const payload={name:c.name,collect_note:c.collect_note,unit:c.unit,value_range:c.value_range,description:c.description}; if(c.id){ await fetchDb(`/collection-points/${c.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)}); } else { await fetchDb(`/device-templates/${dev.id}/collection-points`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)}); } } notify("采集点已保存"); onChange(); }catch(e:any){ notify("保存失败:"+e.message); } };
   return <div className="device-card">
     <div className="device-card-head"><b>{name||"(未命名)"}</b><button className="text-button" onClick={delDev}>删除设备</button></div>
@@ -1145,6 +1180,7 @@ function DeviceTemplateCard({dev,notify,onChange,onBack}:{dev:any;notify:(m:stri
       </tbody></table>
     </div>
     <div style={{marginTop:8}}><Button onClick={save}>保存设备</Button></div>
+    {confirmEl}
   </div>;
 }
 
@@ -1170,8 +1206,10 @@ function DeviceTemplates({notify}:{notify:(m:string)=>void}){
   const load=async()=>{ try{ setList(await fetchDb("/device-templates") as any); }catch(e:any){ notify("加载失败:"+e.message); } finally{ setLoading(false); } };
   useEffect(()=>{ load(); },[]);
   const filtered=list.filter(d=>{ if(!q.trim()) return true; const s=q.toLowerCase(); return (d.name||"").toLowerCase().includes(s) || (d.description||"").toLowerCase().includes(s); });
-  const delDev=async(id:number)=>{ if(!confirm("删除该设备模板?")) return; await fetchDb(`/device-templates/${id}`,{method:"DELETE"}); load(); };
+  const {confirm, el:confirmEl}=useConfirm();
+  const delDev=async(id:number)=>{ if(!(await confirm("删除该设备模板?"))) return; await fetchDb(`/device-templates/${id}`,{method:"DELETE"}); load(); };
   if(openId===null) return <>
+    {confirmEl}
     <div className="domain-hero"><div><span className="eyebrow">DEVICE TEMPLATE LIBRARY</span><h2>设备模版</h2><p>独立维护设备模版及其采集点,编辑后可在领域方案中按需选用。</p></div></div>
     <Panel title="设备模版" tools={<Button onClick={()=>setOpenId("create")}>+ 新增设备模版</Button>}>
       <div className="list-toolbar"><input className="search" placeholder="搜索设备名称 / 说明" value={q} onChange={e=>setQ(e.target.value)}/></div>
@@ -1230,9 +1268,10 @@ function DeviceTemplatePicker({tmplId,go,selectedIds,notify,onChange}:{tmplId:nu
 function PageRow({p,notify,onChange}:{p:any;notify:(m:string)=>void;onChange:()=>void}){
   const [name,setName]=useState(p.name); const [description,setDesc]=useState(p.description||""); const [json_text,setJson]=useState(p.json_text);
   const [editing,setEditing]=useState(false);
+  const {confirm, el:confirmEl}=useConfirm();
   const save=async()=>{ try{ JSON.parse(json_text); }catch{ notify("Cube JSON 不是合法 JSON"); return; } await fetchDb(`/domain-pages/${p.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,json_text})}); notify("页面已保存"); setEditing(false); onChange(); };
-  const del=async()=>{ if(!confirm("删除该页面?")) return; await fetchDb(`/domain-pages/${p.id}`,{method:"DELETE"}); onChange(); };
-  return <div className="page-row"><div className="page-row-head"><b>{name}</b><span>{editing? <><Button onClick={save}>保存</Button> <button className="text-button" onClick={()=>setEditing(false)}>取消</button></> : <><button className="text-button" onClick={()=>setEditing(true)}>编辑</button> <button className="text-button" onClick={del}>删除</button></>}</span></div>
+  const del=async()=>{ if(!(await confirm("删除该页面?"))) return; await fetchDb(`/domain-pages/${p.id}`,{method:"DELETE"}); onChange(); };
+  return <div className="page-row">{confirmEl}<div className="page-row-head"><b>{name}</b><span>{editing? <><Button onClick={save}>保存</Button> <button className="text-button" onClick={()=>setEditing(false)}>取消</button></> : <><button className="text-button" onClick={()=>setEditing(true)}>编辑</button> <button className="text-button" onClick={del}>删除</button></>}</span></div>
     {editing && <div className="form-grid">
       <label>页面名称<input value={name} onChange={e=>setName(e.target.value)}/></label>
       <label>说明<input value={description} onChange={e=>setDesc(e.target.value)}/></label>
@@ -1248,6 +1287,8 @@ function DomainDetail({id,go,notify,onBack,onCreated}:{id:number|null;go:(p:Page
   const [t,setT]=useState<any>(null);
   const [loading,setLoading]=useState(false);
   const [m,setM]=useState<any>(null);
+  const {confirm, el:confirmEl}=useConfirm();
+  const {prompt, el:promptEl}=usePrompt();
   const loadDoms=async()=>{ try{ setDoms(await fetchDb("/domains") as any); }catch{} };
   const load=async(silent=false)=>{ if(id==null){ setT(null); return; } if(!silent) setLoading(true); try{ setT(await fetchDb(`/domain-templates/${id}`) as any); }catch(e:any){ notify("加载失败:"+e.message); } finally{ if(!silent) setLoading(false); } };
   useEffect(()=>{ loadDoms(); load(); },[id]);
@@ -1258,10 +1299,11 @@ function DomainDetail({id,go,notify,onBack,onCreated}:{id:number|null;go:(p:Page
   const saveMeta=async()=>{ await fetchDb(`/domain-templates/${id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)}); notify("已保存模板信息"); load(); };
   const addPage=async()=>{ await fetchDb(`/domain-templates/${id}/pages`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:"新页面",description:"",json_text:"{}"})}); load(); };
   const addMenu=async()=>{ await fetchDb(`/domain-templates/${id}/menu`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({label:"新菜单项"})}); load(); };
-  const addDoc=async()=>{ const nm=prompt("文档名称"); if(!nm) return; const link=confirm("是否为超链?(确定=超链,取消=上传文件)"); if(link){ const u=prompt("文档超链 URL"); if(!u) return; await fetchDb(`/domain-templates/${id}/docs`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:nm,doc_type:"link",url:u})}); } else { const f:any=await new Promise(res=>{ const input=document.createElement("input"); input.type="file"; input.onchange=()=>res(input.files?.[0]); input.click(); }); if(!f) return; const b=await fileToBase64(f); await fetchDb(`/domain-templates/${id}/docs`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:nm,doc_type:"file",file:{filename:b.filename,mime:b.mime,data:b.data}})}); } load(); };
+  const addDoc=async()=>{ const nm=await prompt("文档名称"); if(!nm) return; const link=await confirm("是否为超链?(确定=超链,取消=上传文件)"); if(link){ const u=await prompt("文档超链 URL"); if(!u) return; await fetchDb(`/domain-templates/${id}/docs`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:nm,doc_type:"link",url:u})}); } else { const f:any=await new Promise(res=>{ const input=document.createElement("input"); input.type="file"; input.onchange=()=>res(input.files?.[0]); input.click(); }); if(!f) return; const b=await fileToBase64(f); await fetchDb(`/domain-templates/${id}/docs`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:nm,doc_type:"file",file:{filename:b.filename,mime:b.mime,data:b.data}})}); } load(); };
   const delDoc=async(did:number)=>{ await fetchDb(`/knowledge-docs/${did}`,{method:"DELETE"}); load(); };
   const dlDoc=async(did:number)=>{ const r=await fetch(`/api/db/knowledge-docs/${did}/file`); const blob=await r.blob(); const d=t.docs.find((x:any)=>x.id===did); downloadBlob(blob, d?.name||"doc"); };
   return <>
+    {confirmEl}{promptEl}
     <div className="domain-detail-head"><button className="text-button" onClick={onBack}>← 返回列表</button></div>
     <Panel title="模板信息"><div className="form-grid">
       <label>所属领域<select value={m.domain_id} onChange={e=>setM({...m,domain_id:Number(e.target.value)})}>{doms.map(d=><option key={d.id} value={d.id}>{d.name}</option>)}</select></label>
@@ -1294,14 +1336,15 @@ function DomainDetail({id,go,notify,onBack,onCreated}:{id:number|null;go:(p:Page
 function PageStyleLibrary({pageStyles, loadPageStyles, notify}:{pageStyles:PageStyle[]; loadPageStyles:()=>Promise<void>; notify:(m:string)=>void}){
   const [editor,setEditor]=useState<{open:boolean; id?:number}>({open:false});
   const [preview,setPreview]=useState<PageStyle|null>(null);
+  const {confirm, el:confirmEl}=useConfirm();
   const afterChange=async(msg:string)=>{ notify(msg); setEditor({open:false, id:undefined}); await loadPageStyles(); };
   const openEdit=async(s:PageStyle)=>{ setEditor({open:true, id:s.id}); };
   const remove=async(s:PageStyle)=>{
-    if(!confirm(`确定删除样式「${s.name}」?此操作不可恢复`)) return;
+    if(!(await confirm(`确定删除样式「${s.name}」?此操作不可恢复`))) return;
     try{ await fetchDb(`/page-styles/${s.id}`, {method:"DELETE"}); await afterChange(`已删除「${s.name}」`); }
     catch(e:any){ notify("删除失败: "+e.message); }
   };
-  return <>{preview&&<StylePreview style={preview} onClose={()=>setPreview(null)}/>}
+  return <>{confirmEl}{preview&&<StylePreview style={preview} onClose={()=>setPreview(null)}/>}
     <Panel title="全部页面样式" tools={<Button onClick={()=>setEditor({open:true})}>+ 新增样式</Button>}>
       {pageStyles.length===0
         ? <p className="hint">暂无页面样式,点击右上角「+ 新增样式」上传 JSON 与预览图即可创建。</p>
@@ -1329,6 +1372,7 @@ function StylePreview({style,onClose}:{style:PageStyle;onClose:()=>void}){
 }
 
 function StyleEditorModal({id,notify,afterChange,onClose}:{id?:number;notify:(m:string)=>void;afterChange:(msg:string)=>Promise<void>;onClose:()=>void}){
+  const {confirm, el:confirmEl}=useConfirm();
   const [name,setName]=useState("");
   const [jsonText,setJsonText]=useState("");
   const [image,setImage]=useState<{filename:string;data:string}|null>(null);
@@ -1358,11 +1402,11 @@ function StyleEditorModal({id,notify,afterChange,onClose}:{id?:number;notify:(m:
   };
   const remove=async()=>{
     if(!id) return;
-    if(!confirm("确定删除该样式?此操作不可恢复")) return;
+    if(!(await confirm("确定删除该样式?此操作不可恢复"))) return;
     try{ await fetchDb(`/page-styles/${id}`, {method:"DELETE"}); await afterChange("样式已删除"); }
     catch(e:any){ notify("删除失败: "+e.message); }
   };
-  return <div className="modal-backdrop" onMouseDown={onClose}><div className="modal" style={{width:"640px"}} onMouseDown={e=>e.stopPropagation()}>
+  return <div className="modal-backdrop" onMouseDown={onClose}>{confirmEl}<div className="modal" style={{width:"640px"}} onMouseDown={e=>e.stopPropagation()}>
     <header><h2>{id?"编辑页面样式":"新增页面样式"}</h2><button onClick={onClose}>×</button></header>
     <div className="modal-body">
       <div className="modal-note">页面样式包含一份 Cube 仪表盘 JSON(配色与视觉主题)和一张预览图。名称用于在样式库中展示。</div>