import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
-type PageKey = "login" | "projects" | "basic" | "assets" | "functions" | "generate" | "result" | "preview" | "domains" | "domainDetail" | "styleLibrary";
+type PageKey = "login" | "projects" | "basic" | "assets" | "functions" | "generate" | "result" | "preview" | "domains" | "domainDetail" | "styleLibrary" | "deviceTemplates";
type ModalKey = "" | "feedback" | "download" | "history" | "prompt" | "completePrompt";
const meta: Record<PageKey, { label: string; icon: string; subtitle: string }> = {
result:{label:"站点结果",icon:"◫",subtitle:"集中查看、预览、校验并下载站点交付物"},
preview:{label:"Cube 页面预览",icon:"▣",subtitle:"检查单个 Cube 页面效果,并通过反馈快速重新生成"},
domains:{label:"领域方案库",icon:"◆",subtitle:"沉淀各领域标准方案,供客户站点直接使用或快速微调"},
- domainDetail:{label:"水务 · 自来水厂方案",icon:"◇",subtitle:"查看领域资产、运维流程、Cube 页面、菜单及知识内容"},
+ domainDetail:{label:"方案详情",icon:"◇",subtitle:"查看领域资产、运维流程、Cube 页面、菜单及知识内容"},
+ deviceTemplates:{label:"设备模版",icon:"⬡",subtitle:"独立管理设备模版与采集点,供领域方案选用"},
styleLibrary:{label:"页面样式库",icon:"❖",subtitle:"沉淀可复用的 Cube 仪表盘页面样式(配色与视觉主题),供站点页面快速套用"},
};
const [muTree,setMuTree]=useState<MuNode[]>([]);
const [pageStyles,setPageStyles]=useState<PageStyle[]>([]);
const [openDomainTemplateId,setOpenDomainTemplateId]=useState<number|"create"|null>(null);
+ const [domainFilter,setDomainFilter]=useState<number|null>(null);
+ const [domainOptions,setDomainOptions]=useState<{id:number;name:string}[]>([]);
const [view,setView]=useState<"table"|"card">("table");
const [functionTab,setFunctionTab]=useState<"mu"|"cube"|"menu">("mu");
setLoading(true);
Promise.all([
fetchDb("/sites") as Promise<SiteSummary[]>,
- fetchDb("/domains") as Promise<{name:string}[]>,
+ fetchDb("/domains") as Promise<{id:number;name:string}[]>,
fetchDb("/mu-tree") as Promise<MuNode[]>,
fetchDb("/page-styles") as Promise<PageStyle[]>,
]).then(([s,d,m,ps])=>{
setSites(s||[]);
setDomains((d||[]).map(x=>x.name));
+ setDomainOptions((d||[]).map(x=>({id:x.id,name:x.name})));
setMuTree(m||[]);
setPageStyles(ps||[]);
}).catch(e=>notify("加载失败: "+e.message)).finally(()=>setLoading(false));
return <div className="app-shell">
<header className="topbar"><button className="hamburger">☰</button><div className="brand-mark">⬡</div><strong>iStation</strong><span className="divider"/><span>iStation Team</span><span className="divider"/><span>StationBuilder⌄</span><div className="top-actions"><a className="design-link" href="/design-doc.html" target="_blank" rel="noopener">设计方案</a><button title="查看内部登录页" className="avatar avatar-button" onClick={()=>go("login")}>W</button><small>wang.yu@advantech.com<br/>iStation Team</small></div></header>
- <aside className="side-nav"><div className="module-switch"><button className={(["projects","basic","assets","functions","generate","result","preview"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("projects")}><b>▦</b><span>StationBuilder<small>站点生成工具</small></span></button><button className={(["domains","domainDetail"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("domains")}><b>◆</b><span>领域方案库<small>行业标准方案</small></span></button><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b><span>页面样式库<small>页面视觉样式</small></span></button></div>{page==="styleLibrary"?<><small>样式库导航</small><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b>全部页面样式</button></>:(["domains","domainDetail"] as PageKey[]).includes(page)?<><small>方案库导航</small><button className={page==="domains"?"active":""} onClick={()=>go("domains")}><b>▦</b>全部领域方案</button><button className={page==="domainDetail"?"active":""} onClick={()=>go("domainDetail")}><b>◇</b>方案详情</button><small>领域分类</small>{["燃气","水务 · 自来水厂","新能源","数据中心"].map((x,i)=><button key={x} className={i===1&&page==="domainDetail"?"active":""} onClick={()=>i===1?go("domainDetail"):notify(x+"方案预览已切换")}><b>{["♨","≋","☀","▥"][i]}</b>{x}</button>)}</>:<><small>站点列表</small><button className={page==="projects"?"active":""} onClick={()=>go("projects")}><b>▦</b>站点项目</button><small>站点详情</small><select className="site-switcher" value={siteId ?? ""} onChange={e=>{const v=e.target.value; if(v){ const t=(page==="projects"||["domains","domainDetail","styleLibrary"].includes(page))?"basic":page; go(t, Number(v)); }}}><option value="">选择站点…</option>{sites.map(s=><option key={s.id} value={s.id}>{s.name||"未命名站点"}</option>)}</select><small>生成向导</small>{(["basic","assets","functions","generate"] as PageKey[]).map((item,index)=><button key={item} className={page===item?"active":""} onClick={()=>go(item)}><em>{index+1}</em>{meta[item].label}</button>)}<small>方案交付</small><button className={page==="result"?"active":""} onClick={()=>go("result")}><b>◫</b>站点结果</button><button className={page==="preview"?"active":""} onClick={()=>go("preview")}><b>▣</b>页面预览</button></>}<div className="side-help">
+ <aside className="side-nav"><div className="module-switch"><button className={(["projects","basic","assets","functions","generate","result","preview"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("projects")}><b>▦</b><span>StationBuilder<small>站点生成工具</small></span></button><button className={(["domains","domainDetail","deviceTemplates"] as PageKey[]).includes(page)?"active":""} onClick={()=>{setDomainFilter(null); go("domains");}}><b>◆</b><span>领域方案库<small>行业标准方案</small></span></button><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b><span>页面样式库<small>页面视觉样式</small></span></button></div>{page==="styleLibrary"?<><small>样式库导航</small><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b>全部页面样式</button></>:(["domains","domainDetail","deviceTemplates"] as PageKey[]).includes(page)?<><small>方案库导航</small><button className={page==="domains"?"active":""} onClick={()=>{setDomainFilter(null); go("domains");}}><b>▦</b>领域方案</button><button className={page==="deviceTemplates"?"active":""} onClick={()=>go("deviceTemplates")}><b>⬡</b>设备模版</button><small>领域分类</small>{domainOptions.map((d:any)=><button key={d.id} className={(page==="domains"&&domainFilter===d.id)?"active":""} onClick={()=>{setDomainFilter(d.id); go("domains");}}><b>◆</b>{d.name}</button>)}</>:<><small>站点列表</small><button className={page==="projects"?"active":""} onClick={()=>go("projects")}><b>▦</b>站点项目</button><small>站点详情</small><select className="site-switcher" value={siteId ?? ""} onChange={e=>{const v=e.target.value; if(v){ const t=(page==="projects"||["domains","domainDetail","styleLibrary","deviceTemplates"].includes(page))?"basic":page; go(t, Number(v)); }}}><option value="">选择站点…</option>{sites.map(s=><option key={s.id} value={s.id}>{s.name||"未命名站点"}</option>)}</select><small>生成向导</small>{(["basic","assets","functions","generate"] as PageKey[]).map((item,index)=><button key={item} className={page===item?"active":""} onClick={()=>go(item)}><em>{index+1}</em>{meta[item].label}</button>)}<small>方案交付</small><button className={page==="result"?"active":""} onClick={()=>go("result")}><b>◫</b>站点结果</button><button className={page==="preview"?"active":""} onClick={()=>go("preview")}><b>▣</b>页面预览</button></>}<div className="side-help">
<b>内部工具 · v0.3</b>
<span className="model-trigger" onClick={() => setModelOpen(o => !o)}>
Wordbuddy AI · {model}<span className="caret">▾</span>
</>
)}
</div></aside>
- <main className="workspace"><div className="crumb">iStation StationBuilder / <b>{["domains","domainDetail","styleLibrary"].includes(page)?meta[page].label:page==="projects"?"站点列表":`站点详情 / ${meta[page].label}`}</b></div><div className="page-heading"><div><h1>{meta[page].label}</h1><p>{meta[page].subtitle}</p></div><div>{!["projects","domains","domainDetail","styleLibrary"].includes(page)&&<Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>}{page==="projects"&&<Button onClick={()=>go("basic","new")}>+ 新建站点</Button>}{(page==="domains"||page==="domainDetail")&&<Button onClick={()=>setOpenDomainTemplateId("create")}>+ 新增领域方案</Button>}{(page==="domains"||page==="domainDetail")&&openDomainTemplateId!=null&&<Button secondary onClick={()=>setOpenDomainTemplateId(null)}>← 返回方案列表</Button>}</div></div>{(["basic","assets","functions","generate"] as PageKey[]).includes(page)&&<Steps page={page} go={go}/>}<div className="content">
+ <main className="workspace"><div className="crumb">iStation StationBuilder / <b>{["domains","domainDetail","styleLibrary","deviceTemplates"].includes(page)?meta[page].label:page==="projects"?"站点列表":`站点详情 / ${meta[page].label}`}</b></div><div className="page-heading"><div><h1>{meta[page].label}</h1><p>{meta[page].subtitle}</p></div><div>{!["projects","domains","domainDetail","styleLibrary","deviceTemplates"].includes(page)&&<Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>}{page==="projects"&&<Button onClick={()=>go("basic","new")}>+ 新建站点</Button>}{page==="domains"&&<Button onClick={()=>setOpenDomainTemplateId("create")}>+ 新增领域方案</Button>}{page==="domains"&&openDomainTemplateId!=null&&<Button secondary onClick={()=>setOpenDomainTemplateId(null)}>← 返回方案列表</Button>}{domainFilter!=null&&page==="domains"&&<Button secondary onClick={()=>{setDomainFilter(null);}}>清除领域筛选:{domainOptions.find(d=>d.id===domainFilter)?.name||""}</Button>}</div></div>{(["basic","assets","functions","generate"] as PageKey[]).includes(page)&&<Steps page={page} go={go}/>}<div className="content">
{page==="projects"&&<Projects view={view} setView={setView} sites={sites} loading={loading} go={go} loadSite={loadSite} notify={notify} onDelete={deleteSites}/>}
{(page==="domains"||page==="domainDetail")&&(openDomainTemplateId!=null
? <DomainDetail id={openDomainTemplateId==="create"?null:openDomainTemplateId} go={go} notify={notify} onBack={()=>setOpenDomainTemplateId(null)} onCreated={setOpenDomainTemplateId}/>
- : <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)}/>)}
+ : <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)} domainFilter={domainFilter} domainOptions={domainOptions}/>)}
{page==="styleLibrary"&&<PageStyleLibrary pageStyles={pageStyles} loadPageStyles={loadPageStyles} notify={notify}/>}
+ {page==="deviceTemplates"&&<DeviceTemplates notify={notify} domainOptions={domainOptions}/>}
{page==="basic"&&<Basic site={site} domains={domains} onChange={saveSiteBasic} onAddDomain={addDomain} onUpload={uploadFile} saving={saving} notify={notify} onViewCompletePrompt={viewCompletePrompt}/>}
{page==="assets"&&<Assets site={site} onChange={saveAssets} saving={saving} notify={notify} onViewCompletePrompt={viewCompleteAssetsPrompt}/>}
{page==="functions"&&<Functions site={site} muTree={muTree} onMuChange={saveMu} onSiteChange={setSite} pageStyles={pageStyles} notify={notify} onViewCompletePrompt={viewCompleteCubesPrompt}/>}
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 DomainLibrary({go,notify,onOpen}:{go:(p:PageKey)=>void;notify:(m:string)=>void;onOpen:(id:number)=>void}){
+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);
+ const [view,setView]=useState<"list"|"card">("list");
const load=async()=>{ try{ setList(await fetchDb("/domain-templates") as any); }catch(e:any){ notify("加载失败:"+e.message); } finally{ setLoading(false); } };
useEffect(()=>{ load(); },[]);
+ const filtered=list.filter(t=>!domainFilter||t.domain_id===domainFilter);
const del=async(id:number)=>{ if(!confirm("确定删除该领域方案?此操作不可恢复")) return; try{ await fetchDb(`/domain-templates/${id}`,{method:"DELETE"}); notify("已删除"); load(); }catch(e:any){ notify("删除失败:"+e.message); } };
return <>
- <div className="domain-hero"><div><span className="eyebrow">DOMAIN SOLUTION LIBRARY</span><h2>领域方案库</h2><p>沉淀各领域标准方案,供客户站点直接使用或快速微调。</p></div></div>
- <Panel title="领域方案列表">
+ <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>
+ <Panel title={domainFilter!=null?`领域方案列表 · ${domainOptions.find(d=>d.id===domainFilter)?.name||""}`:"领域方案列表"} tools={<div className="view-toggle">
+ <button className={view==="list"?"active":""} onClick={()=>setView("list")}>▤ 列表</button>
+ <button className={view==="card"?"active":""} onClick={()=>setView("card")}>▦ 卡片</button>
+ </div>}>
{loading? <p>加载中…</p> :
- <table className="data-table"><thead><tr><th>模板名称</th><th>所属领域</th><th>版本</th><th>状态</th><th>创建人</th><th>设备/页面/文档</th><th>操作</th></tr></thead>
- <tbody>
- {list.length===0 && <tr><td colSpan={7} className="muted">暂无方案,点击右上角「+ 新增领域方案」创建</td></tr>}
- {list.map((t:any)=>(<tr key={t.id}>
- <td>{t.name}</td><td>{t.domain_name}</td><td>{t.version}</td>
- <td><Status>{t.status==="available"?"可用":"不可用"}</Status></td>
- <td>{t.creator||"—"}</td>
- <td>{t.device_count}/{t.page_count}/{t.doc_count}</td>
- <td><button className="text-button" onClick={()=>onOpen(t.id)}>编辑</button> <button className="text-button" onClick={()=>del(t.id)}>删除</button></td>
- </tr>))}
- </tbody></table>}
+ filtered.length===0? <p className="muted">暂无方案,点击右上角「+ 新增领域方案」创建</p> :
+ view==="list"? (
+ <table className="data-table"><thead><tr><th>方案名称</th><th>所属领域</th><th>版本</th><th>状态</th><th>创建人</th><th>设备/页面/文档</th><th>操作</th></tr></thead>
+ <tbody>
+ {filtered.map((t:any)=>(<tr key={t.id}>
+ <td>{t.name}</td><td>{t.domain_name}</td><td>{t.version}</td>
+ <td><Status>{t.status==="available"?"可用":"不可用"}</Status></td>
+ <td>{t.creator||"—"}</td>
+ <td>{t.device_count}/{t.page_count}/{t.doc_count}</td>
+ <td><button className="text-button" onClick={()=>onOpen(t.id)}>编辑</button> <button className="text-button" onClick={()=>del(t.id)}>删除</button></td>
+ </tr>))}
+ </tbody></table>
+ ) : (
+ <div className="domain-card-grid">{filtered.map((t:any)=>(
+ <article key={t.id} className="domain-card" onClick={()=>onOpen(t.id)}>
+ <header><h3>{t.name}</h3><Status>{t.status==="available"?"可用":"不可用"}</Status></header>
+ <span className="badge">{t.domain_name}</span>
+ <div className="meta"><span>版本 <b>{t.version}</b></span><span>创建人 <b>{t.creator||"—"}</b></span></div>
+ <div className="meta"><span>设备 <b>{t.device_count}</b></span><span>页面 <b>{t.page_count}</b></span><span>文档 <b>{t.doc_count}</b></span></div>
+ <footer className="card-actions"><button className="text-button" onClick={e=>{e.stopPropagation();onOpen(t.id);}}>编辑</button><button className="text-button" onClick={e=>{e.stopPropagation();del(t.id);}}>删除</button></footer>
+ </article>
+ ))}</div>
+ )}
</Panel>
</>;
}
</div></Panel>;
}
-function DeviceCard({dev,doms,tmplId,notify,onChange}:{dev:any;doms:any[];tmplId:number;notify:(m:string)=>void;onChange:()=>void}){
+function DeviceTemplateCard({dev,doms,notify,onChange}:{dev:any;doms:any[];notify:(m:string)=>void;onChange:()=>void}){
const [name,setName]=useState(dev.name); const [description,setDesc]=useState(dev.description||"");
const [domainIds,setDomainIds]=useState<number[]>(dev.domain_ids||[]);
const [cps,setCps]=useState<any[]>(dev.collection_points||[]);
- const imgChanged=async(e:any)=>{ const f=e.target.files?.[0]; if(!f) return; const b=await fileToBase64(f); await fetchDb(`/device-templates/${dev.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,domain_ids:domainIds,domain_template_ids:[tmplId],image:{filename:b.filename,mime:b.mime,data:b.data}})}); notify("已更新图片"); onChange(); };
- const save=async()=>{ await fetchDb(`/device-templates/${dev.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,domain_ids:domainIds,domain_template_ids:[tmplId]})}); notify("设备已保存"); onChange(); };
+ const imgChanged=async(e:any)=>{ const f=e.target.files?.[0]; if(!f) return; const b=await fileToBase64(f); await fetchDb(`/device-templates/${dev.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,domain_ids:domainIds,image:{filename:b.filename,mime:b.mime,data:b.data}})}); notify("已更新图片"); onChange(); };
+ const save=async()=>{ await fetchDb(`/device-templates/${dev.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,domain_ids:domainIds})}); notify("设备已保存"); onChange(); };
const delDev=async()=>{ if(!confirm("删除该设备模板?")) return; await fetchDb(`/device-templates/${dev.id}`,{method:"DELETE"}); onChange(); };
const addCp=async()=>{ const nm=prompt("采集点名称"); if(!nm) return; await fetchDb(`/device-templates/${dev.id}/collection-points`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:nm})}); onChange(); };
const delCp=async(id:number)=>{ await fetchDb(`/collection-points/${id}`,{method:"DELETE"}); onChange(); };
</div>;
}
-function AddDevice({tmplId,doms,notify,onChange}:{tmplId:number;doms:any[];notify:(m:string)=>void;onChange:()=>void}){
- const [name,setName]=useState(""); const [domainId,setDomainId]=useState<number>(doms[0]?.id||0);
- const add=async()=>{ if(!name.trim()){ notify("请填写设备名称"); return; } await fetchDb(`/device-templates`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,domain_ids:[domainId],domain_template_ids:[tmplId]})}); notify("已添加设备"); onChange(); };
- return <div className="add-device"><input placeholder="新设备模板名称" value={name} onChange={e=>setName(e.target.value)}/><select value={domainId} onChange={e=>setDomainId(Number(e.target.value))}>{doms.map(d=><option key={d.id} value={d.id}>{d.name}</option>)}</select><Button onClick={add}>+ 添加设备</Button></div>;
+function DeviceTemplateCreate({doms,notify,onDone,onCancel}:{doms:any[];notify:(m:string)=>void;onDone:(id:number)=>void;onCancel:()=>void}){
+ const [name,setName]=useState(""); const [description,setDesc]=useState("");
+ const [domainIds,setDomainIds]=useState<number[]>(doms[0]?[doms[0].id]:[]);
+ const add=async()=>{ if(!name.trim()){ notify("请填写设备名称"); return; } try{ const r:any=await fetchDb(`/device-templates`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,domain_ids:domainIds})}); notify("已创建设备模版"); onDone(r.id); }catch(e:any){ notify("创建失败:"+e.message); } };
+ return <Panel title="新增设备模版" tools={<button className="text-button" onClick={onCancel}>取消</button>}><div className="form-grid">
+ <label>设备名称<input value={name} onChange={e=>setName(e.target.value)} placeholder="如:PLC 控制器"/></label>
+ <label style={{gridColumn:"1 / -1"}}>说明<textarea value={description} onChange={e=>setDesc(e.target.value)} rows={2}/></label>
+ <label style={{gridColumn:"1 / -1"}}>所属领域(多选)
+ <div className="chk">{doms.map(d=><label key={d.id} className="chk-item"><input type="checkbox" checked={domainIds.includes(d.id)} onChange={e=>setDomainIds(e.target.checked?[...domainIds,d.id]:domainIds.filter(x=>x!==d.id))}/>{d.name}</label>)}</div>
+ </label>
+ <div style={{gridColumn:"1 / -1"}}><Button onClick={add}>创建</Button></div>
+ </div></Panel>;
+}
+
+function DeviceTemplates({notify,domainOptions}:{notify:(m:string)=>void;domainOptions:{id:number;name:string}[]}){
+ const [list,setList]=useState<any[]>([]);
+ const [loading,setLoading]=useState(true);
+ const [creating,setCreating]=useState(false);
+ const load=async()=>{ try{ setList(await fetchDb("/device-templates") as any); }catch(e:any){ notify("加载失败:"+e.message); } finally{ setLoading(false); } };
+ useEffect(()=>{ load(); },[]);
+ return <>
+ <div className="domain-hero"><div><span className="eyebrow">DEVICE TEMPLATE LIBRARY</span><h2>设备模版</h2><p>独立维护设备模版及其采集点,编辑后可在领域方案中按需选用。</p></div></div>
+ <Panel title="设备模版" tools={creating?null:<Button onClick={()=>setCreating(true)}>+ 新增设备模版</Button>}>
+ {creating && <DeviceTemplateCreate doms={domainOptions} notify={notify} onDone={()=>{setCreating(false);load();}} onCancel={()=>setCreating(false)}/>}
+ {loading? <p>加载中…</p> :
+ list.length===0 && !creating? <p className="muted">暂无设备模版,点击「+ 新增设备模版」创建。</p> :
+ <div className="device-list">{list.map((d:any)=><DeviceTemplateCard key={d.id} dev={d} doms={domainOptions} notify={notify} onChange={load}/>)}</div>}
+ </Panel>
+ </>;
+}
+
+function DeviceTemplatePicker({tmplId,go,selectedIds,notify,onChange}:{tmplId:number;go:(p:PageKey)=>void;selectedIds:number[];notify:(m:string)=>void;onChange:()=>void}){
+ const [all,setAll]=useState<any[]>([]);
+ const [sel,setSel]=useState<Set<number>>(new Set(selectedIds));
+ useEffect(()=>{ setSel(new Set(selectedIds)); },[selectedIds.join(",")]);
+ useEffect(()=>{ fetchDb("/device-templates").then((d:any)=>setAll(d||[])).catch(()=>{}); },[]);
+ const toggle=async(id:number,checked:boolean)=>{
+ const next=new Set(sel); checked?next.add(id):next.delete(id); setSel(next);
+ try{ await fetchDb(`/domain-templates/${tmplId}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({device_template_ids:[...next]})}); notify(checked?"已选用设备模版":"已移除设备模版"); onChange(); }
+ catch(e:any){ notify("保存失败:"+e.message); const back=new Set(sel); checked?back.delete(id):back.add(id); setSel(back); }
+ };
+ return <>
+ <div className="device-picker">
+ {all.length===0? <p className="muted">暂无设备模版,点击下方「+ 新增设备模版」创建后选用。</p> :
+ all.map((d:any)=>{ let doms:any[]=[]; try{ doms=JSON.parse(d.domain_names||"[]"); }catch{} return (
+ <label key={d.id} className={"picker-item"+(sel.has(d.id)?" on":"")}>
+ <input type="checkbox" checked={sel.has(d.id)} onChange={e=>toggle(d.id,e.target.checked)}/>
+ <span className="picker-name">{d.name}</span>
+ <span className="picker-doms">{(doms.length?doms.join("、"):"未归类")}</span>
+ </label>
+ )})}
+
+ </div>
+ <div style={{marginTop:10}}><a className="text-button" href="#" onClick={e=>{e.preventDefault();go("deviceTemplates");}}>+ 新增设备模版</a></div>
+ </>;
}
function PageRow({p,notify,onChange}:{p:any;notify:(m:string)=>void;onChange:()=>void}){
<label style={{gridColumn:"1 / -1"}}>领域描述<textarea value={m.description} onChange={e=>setM({...m,description:e.target.value})} rows={3}/></label>
<div style={{gridColumn:"1 / -1"}}><Button onClick={saveMeta}>保存模板信息</Button></div>
</div></Panel>
- <Panel title={`设备模板(${t.devices.length})`}><div className="device-list">
- {t.devices.map((d:any)=><DeviceCard key={d.id} dev={d} doms={doms} tmplId={id} notify={notify} onChange={load}/>)}
- <AddDevice tmplId={id} doms={doms} notify={notify} onChange={load}/>
- </div></Panel>
+ <Panel title={`选用设备模版(${t.devices.length})`}>
+ <DeviceTemplatePicker tmplId={id} go={go} selectedIds={t.devices.map((d:any)=>d.id)} notify={notify} onChange={load}/>
+ </Panel>
<Panel title={`领域页面(${t.pages.length})`}><div className="page-list">
{t.pages.map((p:any)=><PageRow key={p.id} p={p} notify={notify} onChange={load}/>)}
<div style={{marginTop:8}}><Button onClick={addPage}>+ 新增页面</Button></div>
h2,h3,h4{ scroll-margin-top:64px; }
@media (max-width:760px){ .doc-nav{ display:none; } }
@media print{ .doc-toolbar, .doc-nav{ display:none; } main#doc{ max-width:none; padding:0; } }
+ /* 页面截图(参考 MachineCube 手册撰写方式:小节标题 + 说明 + 下图 + 配图 + 图注)*/
+ body{ counter-reset: fig; }
+ .figure{ margin:14px 0; border:1px solid var(--line); background:#fbfcfd; }
+ .figure img{ display:block; width:100%; height:auto; border-bottom:1px solid var(--line); }
+ .figure figcaption{ counter-increment: fig; padding:9px 12px; font-size:11px; color:#555; line-height:1.6; }
+ .figure figcaption::before{ content:"图 " counter(fig) " "; font-weight:bold; color:#333; }
+ .figure.placeholder img{ min-height:180px; object-fit:cover; background:repeating-linear-gradient(45deg,#f1f3f5,#f1f3f5 12px,#e7eaed 12px,#e7eaed 24px); }
+ .shot-note{ font-size:10px; color:#999; }
+ .changelog{ width:100%; border-collapse:collapse; margin:6px 0 14px; }
+ .changelog th{ background:#f6f7f8; color:#4b4f53; text-align:left; font-size:11px; font-weight:bold; border-bottom:1px solid var(--line); padding:8px 10px; }
+ .changelog td{ border-bottom:1px solid #e3e5e7; padding:9px 10px; font-size:11px; vertical-align:top; }
+ .changelog td:first-child{ white-space:nowrap; font-weight:bold; color:#008ed2; }
</style>
</head>
<body>
<nav class="doc-nav"><div class="doc-nav-inner" id="toc"></div></nav>
<main id="doc">
- <p class="muted">文档版本:v0.3 最后更新:2026-08-18 状态:开发中(演示数据已标注)</p>
+ <p class="muted">文档版本:v0.6 最后更新:2026-08-18 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
<h1>StationBuilder 产品与交互设计方案</h1>
<h2>1. 文档说明</h2>
<ul>
<li><b>约定</b>:标注 <span class="todo">尚未实现</span> 的项表示已在界面或方案中设计、但当前版本尚未打通能力(多为演示 / 占位)。</li>
<li><b>维护</b>:本文件为单一 HTML 源(<code>public/design-doc.html</code>),后续功能改动需同步更新对应章节,未实现项持续标注,直到能力落地后移除标记。</li>
+ <li><b>截图</b>:可按 MachineCube 手册的撰写方式,在相关章节插入页面截图——小节标题 + 一句话说明 +「下图」引用 + 配图 + 图注。图片统一放在 <code>public/design-doc-assets/</code> 目录(PNG),在文中以 <code><figure><img src="design-doc-assets/xxx.png"><figcaption>…</figcaption></figure></code> 插入;图注会自动编号(图 1 / 图 2 …)。每次内容修改需同步升级文档小版本号并登记「版本变更记录」。</li>
<li><b>导出</b>:顶部按钮分别导出 PDF(调用浏览器打印)、Word(<code>.doc</code> 兼容格式)、Markdown(由本页内容实时转换)。</li>
<li><b>目录</b>:页面左侧为章节目录(自动生成),点击可跳转至对应章节;滚动阅读时自动高亮当前所在章节。</li>
</ul>
│ ├─ 站点结果 → result
│ └─ 页面预览 → preview
│
-├─ 【领域方案库模块时】(domains / domainDetail)
+├─ 【领域方案库模块时】(domains / domainDetail / deviceTemplates)
│ ├─ 方案库导航
-│ │ ├─ 全部领域方案 → domains(方案列表)
-│ │ └─ 方案详情 → domainDetail(同列表;在列表中点击某方案「编辑」打开详情编辑器)
-│ └─ 领域分类(当前以「水务 · 自来水厂」为示例激活项,仅作分组展示)
-│ ├─ 燃气 → 提示(占位)
-│ ├─ 水务 · 自来水厂 → domainDetail
-│ ├─ 新能源 → 提示(占位)
-│ └─ 数据中心 → 提示(占位)
+│ │ ├─ 领域方案 → domains(方案列表,支持列表/卡片切换;点击具体方案打开 domainDetail)
+│ │ └─ 设备模版 → deviceTemplates(独立页面,编辑设备模版与采集点,供方案选用)
+│ └─ 领域分类(动态二级菜单:来自 GET /api/domains;点击即按该领域筛选方案列表)
+│ ├─ (领域 A) → domains + domainFilter=领域A(自动列出该领域全部方案)
+│ ├─ (领域 B) → domains + domainFilter=领域B
+│ └─ …(按数据库 domains 表动态渲染)
│
└─ 【页面样式库模块时】(styleLibrary)
└─ 样式库导航
<tr><td>站点切换器 <select></td><td>StationBuilder / 站点详情</td><td>在已建站点间快速切换</td><td>GET /api/db/sites</td><td>选择后加载该站点并跳转(在列表页则进基本属性,在详情页则停留在当前子页)</td></tr>
<tr><td>1 基本属性 / 2 资产 / 3 功能与菜单 / 4 生成确认</td><td>StationBuilder / 生成向导</td><td>生成向导分步导航</td><td>前端 state</td><td>点击 go 对应页;当前步骤高亮;已完成步骤可回跳</td></tr>
<tr><td>站点结果 / 页面预览</td><td>StationBuilder / 方案交付</td><td>查看生成结果与预览</td><td>—</td><td>点击 go("result" / "preview");当前页高亮</td></tr>
- <tr><td>全部领域方案 / 方案详情</td><td>领域方案库 / 方案库导航</td><td>方案库列表与详情</td><td>GET /api/db/domain-templates、GET /api/domains</td><td>点击 go("domains" / "domainDetail");当前页高亮。列表页点击某方案「编辑」以同页内嵌编辑器打开详情(<code>openDomainTemplateId</code> state 控制),「+ 新增领域方案」切到新增表单</td></tr>
- <tr><td>领域分类四类</td><td>领域方案库 / 领域分类</td><td>按领域分组展示(非筛选)</td><td>前端硬编码(燃气/水务/新能源/数据中心)</td><td>水务进详情、其余提示占位;当前示例高亮「水务 · 自来水厂」;按领域筛选 <span class="todo">尚未实现</span></td></tr>
+ <tr><td>领域方案</td><td>领域方案库 / 方案库导航</td><td>方案库列表(列表/卡片切换)与详情</td><td>GET /api/db/domain-templates、GET /api/domains</td><td>点击 go("domains");列表页右上角可在「列表 / 卡片」间切换;点击某方案(列表「编辑」或卡片本身)以同页内嵌编辑器打开详情(<code>openDomainTemplateId</code> state 控制)。「+ 新增领域方案」切到新增表单;「清除领域筛选」返回全部</td></tr>
+ <tr><td>设备模版</td><td>领域方案库 / 方案库导航</td><td>独立页面,编辑设备模版与采集点</td><td>GET /api/db/device-templates</td><td>点击 go("deviceTemplates");列表展示全部设备模版,可新增 / 编辑 / 删除;每个设备模版维护名称 / 说明 / 图片 / 所属领域(多选) / 采集点。设备模版在此独立维护,方案详情中通过「选用设备模版」勾选引用</td></tr>
+ <tr><td>领域分类(动态)</td><td>领域方案库 / 领域分类</td><td>按领域筛选方案列表(二级菜单)</td><td>GET /api/domains(动态渲染)</td><td>点击某领域 → go("domains") 且 <code>domainFilter</code>=该领域 id,列表自动只显示该领域方案;当前领域高亮;「清除领域筛选」返回全部</td></tr>
<tr><td>全部页面样式</td><td>页面样式库 / 样式库导航</td><td>样式库列表</td><td>GET /api/db/page-styles</td><td>点击 go("styleLibrary");当前页高亮</td></tr>
</table>
</table>
<h3>5.9 领域方案库(domains)</h3>
- <p>领域方案库已从硬编码演示改为<strong>真实数据库驱动</strong>。列表与详情编辑器同一页面内通过 <code>openDomainTemplateId</code> state 切换:<code>null</code>=列表,<code>"create"</code>=新增表单,<code>number</code>=该方案详情编辑器。</p>
+ <p>领域方案库已从硬编码演示改为<strong>真实数据库驱动</strong>。列表与详情编辑器同一页面内通过 <code>openDomainTemplateId</code> state 切换:<code>null</code>=列表,<code>"create"</code>=新增表单,<code>number</code>=该方案详情编辑器。列表支持<strong>列表 / 卡片两种视图</strong>切换(右上角视图切换按钮,<code>view</code> state 控制);点击左侧「领域分类」下的某个领域会按 <code>domainFilter</code> 自动筛选该领域方案。</p>
+ <p>下图展示了领域方案库列表与「+ 新增领域方案」入口:</p>
+ <figure class="placeholder"><img src="design-doc-assets/placeholder.svg" alt="领域方案库列表"/><figcaption>领域方案库:方案列表与新增入口(示意图,待替换为实际截图)。</figcaption></figure>
<table>
<tr><th>元素</th><th>功能</th><th>数据来源</th><th>交互效果</th></tr>
<tr><td>领域方案列表表格</td><td>展示全部领域方案(模板名称 / 所属领域 / 版本 / 状态 / 创建人 / 设备·页面·文档数 / 操作)</td><td>GET /api/db/domain-templates(含 domain_name、device_count、page_count、doc_count)</td><td>每行「编辑」→ 打开该方案详情编辑器;「删除」→ 确认后 DELETE /api/db/domain-templates/:id(级联删除 device_template_domain_templates)并刷新;空列表提示「点击右上角新增」</td></tr>
<h3>5.10 方案详情(domainDetail / 内嵌编辑器)</h3>
<p>详情为同一页面内嵌编辑器,所有内容均可编辑,数据持久化到领域方案库相关表。各面板如下:</p>
+ <p>下图展示了一个领域方案的详情编辑器(模板信息 + 设备模板 + 领域页面等面板):</p>
+ <figure class="placeholder"><img src="design-doc-assets/placeholder.svg" alt="方案详情编辑器"/><figcaption>领域方案详情编辑器(示意图,待替换为实际截图)。</figcaption></figure>
<table>
<tr><th>面板 / 元素</th><th>功能</th><th>数据来源 / 接口</th><th>交互效果</th></tr>
<tr><td>模板信息</td><td>编辑所属领域 / 名称 / 版本 / 状态 / 创建人 / 描述</td><td>GET /api/db/domain-templates/:id 详情;PUT /api/db/domain-templates/:id 保存</td><td>「保存模板信息」写回并重新加载</td></tr>
- <tr><td>设备模板(DeviceCard + AddDevice)</td><td>管理设备模板:名称 / 说明 / 图片 / 所属领域(多选) / 采集点</td><td>详情 devices[](含 collection_points、domain_ids、domain_names);PUT /api/db/device-templates/:id(可带 image base64 或 domain_ids+domain_template_ids);DELETE /api/db/device-templates/:id;新增 POST /api/db/device-templates</td><td>图片以 base64 上传,由服务端落盘 <code>data/db/uploads/devices/</code>;所属领域为复选(与 domains、与 domain_templates 均为多对多);采集点增删见下</td></tr>
+ <tr><td>选用设备模版(DeviceTemplatePicker)</td><td>从「设备模版」页中勾选本方案要用的设备模版</td><td>详情 devices[](已选用的设备模版 id 列表);GET /api/db/device-templates(全部可选);PUT /api/db/domain-templates/:id(传 <code>device_template_ids</code>,服务端重写 device_template_domain_templates 关联)</td><td>勾选即保存:PUT 携带当前全部选中 id,服务端先 DELETE 旧关联再按 id 重建;未找到合适模版可点「+ 新增设备模版」跳到 deviceTemplates 页新建。设备模版的编辑(名称/说明/图片/所属领域/采集点)已移至独立的「设备模版」页,不再在本面板内联编辑</td></tr>
<tr><td>采集点</td><td>设备下的采集点(名称 / 说明 / 单位 / 数值范围 / 描述)</td><td>POST /api/db/device-templates/:id/collection-points(名称);DELETE /api/db/collection-points/:id</td><td>「+ 采集点」填名称新增;行内「删」移除</td></tr>
<tr><td>领域页面(PageRow)</td><td>编辑领域页面(=StationBuilder 的 Cube 扩展页面)</td><td>详情 pages[];POST /api/db/domain-templates/:id/pages;PUT / DELETE /api/db/domain-pages/:id</td><td>编辑态展示名称 / 说明 / Cube JSON(textarea);保存前校验 JSON 合法性;「+ 新增页面」建默认空页</td></tr>
<tr><td>模板菜单</td><td>编辑模板菜单(=StationBuilder 的 MU + Cube 菜单保存结构)</td><td>详情 menu[];POST /api/db/domain-templates/:id/menu;DELETE /api/db/template-menu-items/:id</td><td>表格展示菜单项 / 类型 / 父级 / 关联;「+ 新增菜单项」建占位项</td></tr>
<li>真实登录 / 账号体系(当前为演示登录) <span class="todo">尚未实现</span></li>
<li>领域方案库真实数据 CRUD(领域模板 / 设备模板 / 采集点 / 领域页面 / 模板菜单 / 知识文档)已落地;「直接使用 / 加入站点」将从方案一键建站 <span class="todo">尚未实现</span></li>
<li>运维流程内容编辑(当前仅占位记录) <span class="todo">尚未实现</span></li>
- <li>é¢\86å\9f\9fæ\96¹æ¡\88æ\8c\89é¢\86å\9f\9få\88\86ç±»ç\9bé\80\89ï¼\88å½\93å\89\8då\88\86ç±»ä»\85ä½\9cå\88\86ç»\84å±\95示ï¼\89 <span class="todo">å°\9aæ\9cªå®\9eç\8e°</span></li>
+ <li>é¢\86å\9f\9fæ\96¹æ¡\88æ\8c\89é¢\86å\9f\9få\88\86ç±»ç\9bé\80\89ï¼\88左侧ã\80\8cé¢\86å\9f\9få\88\86ç±»ã\80\8då\8a¨æ\80\81äº\8c级è\8f\9cå\8d\95ï¼\8cç\82¹å\87»å\8d³æ\8c\89 domainFilter ç\9bé\80\89æ\96¹æ¡\88å\88\97表ï¼\89</li>
<li>Asset Excel 生成与下载 <span class="todo">尚未实现</span></li>
<li>资产三级层级建模(当前为展示占位) <span class="todo">尚未实现</span></li>
<li>页面预览中「站点监控」真实数据接入(当前为静态演示) <span class="todo">尚未实现</span></li>
<h2>11. 维护与导出说明</h2>
- <h3>11.1 更新记录</h3>
- <ul>
- <li>2026-08-18 领域方案库(Domain Solution Library)从硬编码演示升级为真实数据库驱动的 CRUD:(1) Phase 0 移除领域方案库页的 Search 行(含「同步方案库」占位按钮);(2) db-sidecar 新增 schema——domain_templates(所属领域 FK、描述、状态、版本、创建人、创建时间)、device_templates + device_collection_points(采集点)、device_template_domains / device_template_domain_templates(设备↔领域、设备↔领域模板 两个多对多)、domain_pages(领域页面=Cube 扩展页 JSON)、template_menu_items(模板菜单=MU+Cube 菜单结构)、knowledge_docs(doc_type 区分超链/文件,文件落盘 data/db/uploads/docs/)、ops_flows(仅占位);(3) 全套 CRUD 接口:/api/domain-templates(列表/详情/增删改,详情聚合设备·采集点·页面·菜单·文档·流程)、/api/device-templates(含图片 base64 上传)、/api/collection-points、/api/domain-pages、/api/template-menu-items、/api/knowledge-docs(含 /file 下载)、/api/ops-flows;(4) 前端 DomainLibrary + DomainDetail 内嵌编辑器(模板信息 / 设备模板与采集点 / 领域页面 Cube JSON / 模板菜单 / 知识文档文件·超链 / 运维流程占位),通过 openDomainTemplateId state 在列表·新增·详情间切换。验证:端到端 CRUD 全绿(含设备 domain_ids 数组解析、级联删除、文档下载)。注意:代理层 app/api/db/[[...slug]] 会把浏览器 /api/db/x 改写为 /api/x 再转发 sidecar,故 sidecar 路由以 /api/ 为前缀(不带 /db)。</li>
- <li>2026-08-18 修复「北方污水厂」等项目整站生成大量失败(多页 300s 超时判失败)。根因:上一轮为修「导入空白」而加入的「逐项读取模板文件」提示词指令使单页提示词过胖并引入文件工具调用,复杂页面(综合总览/工艺监控等)生成耗时偶发超过 300s 硬上限被 SIGKILL。修复:(1) 撤销文件读取指令,改为在 DASHBOARD_SPEC 内联各面板类型的紧凑 custom 骨架(占位 COLOR_PALETTE),消除文件工具开销、把单页耗时拉回 300s 内;(2) extractJson 增强容错——先原生解析,失败再去除注释/尾逗号后重试;(3) runCodebuddy 超时阈值由 300s 提高到 480s,且 generateCubeJson 将「调用超时 / JSON 解析失败 / 结构校验失败」统一纳入最多 2 次重试,把确切错误(含问题位置附近原文)回传模型自我修正,避免一次瑕疵即整页失败;(4) 提示词加约束:不得添加 query/linkage/triggerByTimer/minW 等规范外字段;(5) fixDashboard/normalizePanelCustom 兜底保持不变。验证:北方污水厂 7 个 Cube 全部重新生成成功,面板 custom 结构正确(LineChart/Bar 扁平、PieChart 等 dataConfig+chartConfig),导入无空白。</li>
- <li>2026-08-18 修复右下角 AI 悬浮按钮拖动时常失效的问题:图标改为不可原生拖拽、事件直接落在按钮上,避免浏览器图片拖拽劫持 Pointer 拖拽手势;按下拖动时不再有 1px 移位。</li>
- <li>2026-08-18 修复「下载 Markdown」导入 Azure DevOps Wiki 后仅显示到 §4.2 的问题:代码块改以 4 空格缩进式代码块导出(不再用 ``` 围栏),避免围栏闭合异常吞掉后续章节;表格单元格内将 <code><</code> 与 <code>></code> 转义为 HTML 实体,避免 Azure 把 <code><select></code> 当成未闭合标签吞掉后续内容。</li>
- <li>2026-08-18 新增「AI 智能补全」功能:站点基本属性页「填写建议」下方新增面板,点击「开始补全」后基于站点名称 / 所属领域 / 站点基本情况描述,由 AI 流式分析并自动写入「补充要求」字段;「查看提示词」可查看完整组合提示词(后端 <code>POST /api/db/sites/:id/complete</code> 流式、<code>GET .../complete-prompt</code> 取提示词)。</li>
- <li>2026-08-18 扩展「AI 智能补全」至 Asset 资产设置页:右侧新增与基本属性同款的「填写建议 + AI 智能补全」栏;点击「开始补全」后基于站点名称 / 领域 / 描述 / 补充要求分析并补充设备类型、数量预设与命名规则,追加到用户已填项下方(不改动已有项),资产层级预览随之重算(后端 <code>POST /api/db/sites/:id/complete-assets</code> 流式、<code>GET .../complete-assets-prompt</code> 取提示词)。</li>
- <li>2026-08-18 重构「功能与菜单」页:① MU 标准页面改为树状上下结构、仅保留勾选框、默认全选中;② Cube 扩展页面右侧新增「填写建议 + AI 智能补全」栏,点击「开始补全」后基于站点属性与设备资产层级由 AI 推断并补充 Cube 扩展页面(名称 + 详尽内容),追加到用户已建页面下方(不改动用户内容)(后端 <code>POST /api/db/sites/:id/complete-cubes</code> 流式、<code>GET .../complete-cubes-prompt</code> 取提示词);③ 菜单结构改为只读默认预览:MU 页面在上,所有 Cube 页面归入「综合监控」一级菜单、二级为各 Cube 名称,仅展示无编辑。</li>
- <li>2026-08-18 修复整站生成超时:将「生成确认」页全站生成由无上限 <code>Promise.all</code> 并行改为最多 3 个并发(<code>mapWithConcurrency</code>),避免 9 个 Cube 同时抢占 CPU/模型 API 资源导致部分页面 300s 超时失败;超时阈值维持 300s 不变,单页失败不再自动重试,仅提示用户到「功能与菜单」对该页面单独点「生成」重试。</li>
- <li>2026-08-18 修复生成看板导入 Cube 后大面积空白(含「分辨率不是 1920×1080」的错觉,实为空白导致观感异常;JSON 内 pageWidth/pageHeight 始终为 1920/1080 正确)。根因:生成提示词 <code>DASHBOARD_SPEC</code> 仅含糊要求「include proper chartConfig」,模型据此把图表配置整体套一层 <code>custom.chartConfig</code> 外壳,且自造 StatusLight / ParameterCard / Table / GeoMap 的 custom 结构,而 Cube 工具要求的是 dashboard-json-generator 各面板模板的精确结构(Bar/LineChart/HeatMap 为扁平 <code>custom:{config,series,xAxis,yAxis,legend,grid,dataZoom}</code>;PieChart/RadarChart/ProgressRing/GeoMap 为 <code>custom:{dataConfig,chartConfig}</code> 等)。修复:(1) <code>DASHBOARD_SPEC</code> 改为强制要求每个面板逐项读取模板文件 <code>/root/.codebuddy/skills/dashboard-json-generator/assets/templates/<type>.json</code> 并原样照抄其 <code>custom</code> 结构,明确扁平 vs 包裹规则及 StatusLight/ParameterCard/Table/GeoMap 的字段;(2) <code>fixDashboard</code> 增加 <code>normalizePanelCustom</code> 兜底:把 <code>custom.chartConfig</code> 拍平为扁平 custom、StatusLight 的 statusMap→configs、ParameterCard 的 items→tableCnt/rowCnt/style、Table 的 columns 补成可渲染结构、GeoMap 补全 dataConfig 绑定并在存在 <code>data/db/china-geo.json</code> 时注入中国地图 geoJson。存量 gen_files 已用该兜底重写。</li>
- </ul>
+ <h3>11.1 版本变更记录(Changelog)</h3>
+ <table class="changelog">
+ <tr><th>版本</th><th>日期</th><th>修改人</th><th>说明</th></tr>
+ <tr><td>v0.6</td><td>2026-08-18</td><td>wang.yu</td><td>领域方案库重构为多菜单:列表页支持「列表 / 卡片」视图切换与按领域分类筛选;新增独立的「设备模版」页(前端 DeviceTemplates,独立维护设备模版与采集点);方案详情的设备面板改为「选用设备模版」(DeviceTemplatePicker,勾选即重写 device_template_domain_templates 关联,未找到可跳转新增);领域分类改为由 GET /api/domains 动态渲染的二级菜单。后端 domain-templates 更新支持 device_template_ids 重关联。同步更新 4.2 / 5.9 / 5.10 章节。</td></tr>
+ <tr><td>v0.5</td><td>2026-08-18</td><td>wang.yu</td><td>设计方案支持页面截图:新增 figure / 图注 样式(自动编号),撰写方式参考 MachineCube 手册(小节标题 + 说明 +「下图」引用 + 配图 + 图注);图片置于 public/design-doc-assets/。新增「版本变更记录」,每次修改升级小版本号并登记修改人 / 日期。</td></tr>
+ <tr><td>v0.4</td><td>2026-08-18</td><td>wang.yu</td><td>领域方案库升级为真实数据库驱动 CRUD:新增 schema(领域模板 / 设备模板 / 采集点 / 领域页面 / 模板菜单 / 知识文档 / 运维流程占位);全套接口(/api/domain-templates、/api/device-templates、/api/collection-points、/api/domain-pages、/api/template-menu-items、/api/knowledge-docs、/api/ops-flows);前端列表 + 内嵌编辑器,openDomainTemplateId 控制 列表·新增·详情 切换。</td></tr>
+ <tr><td>v0.3</td><td>2026-08-18</td><td>wang.yu</td><td>基线版本:整站生成超时修复(提示词精简 + 最多 2 次重试 + 超时 300s→480s)、AI 智能补全(基本属性 / 资产 / 功能与菜单三页)、功能与菜单重构、导入 Cube 后空白修复(flatten custom + normalizePanelCustom)、下载 Markdown 兼容 Azure DevOps Wiki。运维流程 / 领域分类筛选 / 从方案一键建站等仍标注「尚未实现」。</td></tr>
+ </table>
<ul>
<li>功能变更后请同步更新本文档对应章节,未实现项持续标注 <span class="todo">尚未实现</span>,落地后移除。</li>