]> acesimba.cloud Git - StationBuilder.git/commitdiff
v0.32 领域方案「方案打包」步骤重构:方案概览多标签总览 + 扩展页面预览整页修复 + 打包下载示意弹窗
authorroot <root@localhost.localdomain>
Fri, 21 Aug 2026 09:13:29 +0000 (17:13 +0800)
committerroot <root@localhost.localdomain>
Fri, 21 Aug 2026 09:13:29 +0000 (17:13 +0800)
app/globals.css
app/page.tsx
public/design-doc.html

index a28cbec4fc0c3974e0d963ef33736a5cb8c3f5b5..4864e9929643a0006b3afa7428eb21add28e7c79 100644 (file)
 .badge.src-upload{display:inline-flex;align-items:center;gap:2px;background:#e7f5fc;color:#007cc3;border:1px solid #b9e0f4;border-radius:10px;padding:2px 9px;font-size:10px}
 .badge.src-ai{display:inline-flex;align-items:center;gap:2px;background:#f3e8ff;color:#7a3cc0;border:1px solid #dcc4f3;border-radius:10px;padding:2px 9px;font-size:10px}
 .badge.src-manual{display:inline-flex;align-items:center;gap:2px;background:#f1f2f3;color:#6b7177;border:1px solid #dfe2e4;border-radius:10px;padding:2px 9px;font-size:10px}
-.domain-preview-canvas{background:#1e2630;min-height:430px;border-radius:6px;overflow:hidden}
-.domain-preview-canvas .dashboard-thumb-live{min-height:430px}
+.domain-preview-canvas{background:#1e2630;height:64vh;border-radius:6px;overflow:auto}
+.domain-preview-canvas .dashboard-thumb-live{min-height:0}
 .domain-preview-feedback{margin-top:14px}
 .domain-preview-feedback textarea{width:100%;border:1px solid #cfd3d7;border-radius:3px;padding:9px;resize:vertical;line-height:1.6;margin-top:6px}
 .domain-review-pages{display:grid;grid-template-columns:1fr;gap:12px}
 .domain-nav-input{flex:1;min-width:0;margin:0 4px;height:28px;border:1px solid #cfd4d9;border-radius:3px;padding:0 8px;font-size:12px;background:#fff}
 .domain-nav-input:focus{outline:1px solid var(--blue);border-color:var(--blue)}
 
+/* ===== 方案打包 / 方案概览(v0.32) ===== */
+.gen-overview{display:block}
+.gen-overview .panel{margin-bottom:0}
+.overview-info{display:grid;grid-template-columns:1fr 1fr;gap:10px 18px;padding:4px 2px 14px;border-bottom:1px solid var(--line);margin-bottom:12px}
+.overview-info>div{display:flex;flex-direction:column;gap:3px}
+.overview-info span{font-size:10px;color:#8b9094;font-weight:bold;letter-spacing:.3px}
+.overview-info b{font-size:14px;color:#2b2f33}
+.overview-info .overview-desc{grid-column:1 / -1}
+.overview-info .overview-desc p{margin:0;font-size:12px;color:#555;line-height:1.6;background:#f8fafb;border:1px solid #eef1f3;border-radius:4px;padding:7px 9px}
+.overview-tabs{margin:0 0 12px}
+.overview-tabs button{padding:0 14px;font-size:12px}
+.overview-body{min-height:120px}
+.overview-body .muted{padding:10px 2px}
+.dev-list{display:flex;flex-direction:column;gap:8px}
+.dev-row{border:1px solid var(--line);border-radius:6px;overflow:hidden;background:#fff}
+.dev-row-head{width:100%;border:0;background:transparent;display:flex;align-items:center;gap:10px;padding:11px 13px;text-align:left;cursor:pointer}
+.dev-row-head:hover{background:#f6fafe}
+.dev-row-head b{font-size:13px;flex:1}
+.dev-count{font-size:11px;color:#008ed2;background:#e7f3f8;border-radius:10px;padding:1px 9px}
+.dev-caret{color:#9aa1a5;font-size:11px}
+.dev-detail{border-top:1px solid #eef1f3;padding:11px 13px;background:#fbfcfd}
+.dev-desc{margin:0 0 8px;font-size:12px;color:#555;line-height:1.7}
+.dev-cp b{font-size:11px;color:#333;display:block;margin-bottom:4px}
+.dev-cp ul{margin:0;padding-left:18px;color:#666;font-size:11px;line-height:1.8}
+.doc-list{display:flex;flex-direction:column;gap:7px}
+.doc-item{display:flex;align-items:baseline;gap:10px;padding:9px 11px;border:1px solid var(--line);border-radius:6px;background:#fff}
+.doc-item b{font-size:13px;flex:1}
+.doc-item .muted{font-size:11px;white-space:nowrap}
+.ops-placeholder{display:flex;flex-direction:column;gap:10px;padding:18px;border:1px dashed #cfd6da;border-radius:8px;background:#fafbfc}
+.ops-placeholder .status{align-self:flex-start}
+.ops-placeholder p{margin:0;font-size:12px;color:#777;line-height:1.7}
+/* ===== 方案打包示意图弹窗(v0.32) ===== */
+.pkg-modal{width:560px}
+.pkg-body{padding:18px}
+.pkg-body .hint{margin:0 0 14px}
+.pkg-box{display:flex;justify-content:center;padding:6px 0 16px}
+.pkg-svg{width:240px;height:auto;filter:drop-shadow(0 8px 16px rgba(120,80,20,.18))}
+.pkg-items{display:grid;grid-template-columns:1fr 1fr;gap:10px}
+.pkg-item{display:flex;align-items:flex-start;gap:10px;padding:11px 12px;border:1px solid var(--line);border-radius:8px;background:#fff;box-shadow:0 1px 3px #0000000a}
+.pkg-item:hover{border-color:#9ed3ec;box-shadow:0 2px 8px #008fd21f}
+.pkg-icon{width:34px;height:34px;flex:0 0 auto;border-radius:7px;background:linear-gradient(135deg,#e8f4fb,#d6ecf7);color:#008ed2;display:grid;place-items:center;font-size:17px}
+.pkg-item-text{display:flex;flex-direction:column;gap:3px;min-width:0}
+.pkg-item-text b{font-size:13px;color:#2b2f33}
+.pkg-item-text small{font-size:10px;color:#888;line-height:1.5}
+
index 4c8d99808e166b74542e1503879e8204dc12aafd..210b016bc59863e0e834d94a585b2e5eeb182395 100644 (file)
@@ -1392,7 +1392,7 @@ const DOMAIN_STEPS:{key:DomainStepKey;label:string}[]=[
   {key:"assets",label:"资产设置"},
   {key:"functions",label:"功能与菜单"},
   {key:"opsflow",label:"运维流程"},
-  {key:"generate",label:"生成保存"},
+  {key:"generate",label:"方案打包"},
   {key:"expert",label:"专家确认"},
 ];
 
@@ -1419,7 +1419,7 @@ async function streamInto(res:Response, setLog:(f:(p:string)=>string)=>void, onE
   for(;;){ const {done,value}=await reader.read(); if(done) break; buf+=dec.decode(value,{stream:true}); const lines=buf.split("\n"); buf=lines.pop()||""; for(const line of lines){ if(!line.trim()) continue; try{ const ev=JSON.parse(line); if(ev.log) setLog(prev=>prev+ev.log); onEv(ev); }catch{} } }
 }
 
-function DomainPageThumb({jsonText,emptyLabel="未生成"}:{jsonText:string; emptyLabel?:string}){
+function DomainPageThumb({jsonText,emptyLabel="未生成",fit=false}:{jsonText:string; emptyLabel?:string; fit?:boolean}){
   const outer=useRef<HTMLDivElement>(null);
   const inner=useRef<HTMLDivElement>(null);
   useEffect(()=>{
@@ -1428,22 +1428,24 @@ function DomainPageThumb({jsonText,emptyLabel="未生成"}:{jsonText:string; emp
     (async()=>{
       try{
         const json=JSON.parse(jsonText);
-        if(cancelled||!inner.current) return;
+        if(cancelled||!inner.current||!outer.current) return;
         const renderDashboard:any=await loadRenderer();
         renderDashboard(json, inner.current, {});
-        const w=outer.current?.clientWidth||300;
+        const w=outer.current.clientWidth||300;
         const pw=json.grid?.pageWidth||1920;
         const rootEl=inner.current.firstElementChild as HTMLElement|null;
         const rh=rootEl? rootEl.offsetHeight : (json.grid?.pageHeight||1080);
-        const s=Math.max(0.02, w/pw);
+        // fit 模式:同时按宽、高缩放,整页一次可见(修复“只能预览上半部分”)
+        const availH = fit ? (outer.current.parentElement?.clientHeight || rh) : Infinity;
+        const s = fit ? Math.max(0.02, Math.min(w/pw, availH/rh)) : Math.max(0.02, w/pw);
         inner.current.style.height=`${rh}px`;
         inner.current.style.transform=`scale(${s})`;
         inner.current.style.transformOrigin="top left";
-        if(outer.current) outer.current.style.height=`${rh*s}px`;
+        outer.current.style.height=`${rh*s}px`;
       }catch(e){ if(inner.current) inner.current.innerHTML='<div class="thumb-empty">预览不可用</div>'; }
     })();
     return ()=>{cancelled=true;};
-  },[jsonText, emptyLabel]);
+  },[jsonText, emptyLabel, fit]);
   return <div ref={outer} className="dashboard-thumb-live" style={{overflow:"hidden"}}><div ref={inner} style={{width:1920, height:1080, position:"relative"}}/></div>;
 }
 
@@ -1470,7 +1472,7 @@ function DomainPagePreviewModal({page,tmplId,notify,onClose}:{page:DomainPage;tm
   return <div className="modal-backdrop" onMouseDown={()=>onClose(false)}><div className="modal" style={{width:"min(1100px,94vw)",maxWidth:"94vw"}} onMouseDown={e=>e.stopPropagation()}>
     <header><h2>{page.name||"页面预览"}</h2><button onClick={()=>onClose(false)}>×</button></header>
     <div className="modal-body">
-      <div className="domain-preview-canvas">{hasJson? <DomainPageThumb jsonText={page.json_text}/> : <div className="thumb-empty">尚未生成,请点击「生成」后再预览</div>}</div>
+      <div className="domain-preview-canvas">{hasJson? <DomainPageThumb jsonText={page.json_text} fit/> : <div className="thumb-empty">尚未生成,请点击「生成」后再预览</div>}</div>
       <div className="domain-preview-feedback">
         <h4>修改意见</h4>
         <p className="hint">输入对当前页面的修改意见,AI 将据此重新生成该页面。</p>
@@ -1496,6 +1498,7 @@ function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteA
   const [devCreateOpen,setDevCreateOpen]=useState(false);
   const [pendingDevRow,setPendingDevRow]=useState<number|null>(null);
   const [previewPage,setPreviewPage]=useState<DomainPage|null>(null);
+  const [pkgOpen,setPkgOpen]=useState(false);
   const basicAi=useStreamComplete();
   const assetsAi=useStreamComplete();
   const cubesAi=useStreamComplete();
@@ -1742,10 +1745,11 @@ function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteA
         pageStyles={pageStyles} styleId={m.style_id??null} onSelectStyle={selectStyle} />}
 
       {step==="opsflow" && <PlaceholderStep title="运维流程" note="运维流程编辑功能待开发,后续版本将支持在方案中配置标准运维 SOP 与告警处理流程。"/>}
-      {step==="generate" && <DomainGenerateReview tName={m.name} assets={assets} effSelected={effSelected} grouped={grouped} pages={pages} docs={m.docs||[]} onBack={()=>goStep("functions")} onKnowledge={()=>goStep("knowledge")} onConfirm={()=>notify("方案已生成并保存,可返回上一步继续调整")} onPreviewPage={setPreviewPage}/>}
+      {step==="generate" && <DomainGenerateReview m={m} domainName={domainName} assets={assets} effSelected={effSelected} grouped={grouped} pages={pages} docs={m.docs||[]} onBack={()=>goStep("opsflow")} onPreviewPage={setPreviewPage}/>}
       {step==="expert" && <PlaceholderStep title="专家确认" note="专家确认流程待开发,后续版本将支持领域专家在线评审并签署方案。"/>}
     </div>
-    <div className="sticky-actions"><Button secondary onClick={()=>curIdx>0?goStep(DOMAIN_STEPS[curIdx-1].key):onBack()}>{curIdx>0?"上一步":"返回列表"}</Button><div><Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>{curIdx<DOMAIN_STEPS.length-1 && <Button onClick={()=>goStep(DOMAIN_STEPS[curIdx+1].key)}>下一步</Button>}{curIdx===DOMAIN_STEPS.length-1 && <Button onClick={()=>notify("方案已提交专家确认")}>提交确认</Button>}</div></div>
+    <div className="sticky-actions"><Button secondary onClick={()=>curIdx>0?goStep(DOMAIN_STEPS[curIdx-1].key):onBack()}>{curIdx>0?"上一步":"返回列表"}</Button><div><Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>{step==="generate" && <Button onClick={()=>setPkgOpen(true)}>📦 打包下载</Button>}{step!=="generate" && curIdx<DOMAIN_STEPS.length-1 && <Button onClick={()=>goStep(DOMAIN_STEPS[curIdx+1].key)}>下一步</Button>}{curIdx===DOMAIN_STEPS.length-1 && <Button onClick={()=>notify("方案已提交专家确认")}>提交确认</Button>}</div></div>
+    {pkgOpen && <PackageModal name={m?.name} onClose={()=>setPkgOpen(false)} notify={notify}/>}
   </>;
 }
 
@@ -1821,34 +1825,102 @@ function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,on
   </Panel>;
 }
 
-function DomainGenerateReview({tName,assets,effSelected,grouped,pages,docs,onBack,onKnowledge,onConfirm,onPreviewPage}:{tName:string;assets:DomainAsset[];effSelected:string[];grouped:any[];pages:DomainPage[];docs:any[];onBack:()=>void;onKnowledge?:()=>void;onConfirm:()=>void;onPreviewPage?:(p:DomainPage)=>void}){
-  const reviewRows:[string,string,string,string?,(()=>void)?][]=[
-    ["方案",tName,"","返回修改",onBack],
-    ["资产",`${assets.filter(a=>a.device_template_id).length} 类 / ${assets.reduce((n,a)=>n+Number(a.count||0),0)} 个设备`,"3 层资产结构",""],
-    ["MU 页面",`${effSelected.length} 个标准页面`,"复用并配置菜单",""],
-    ["Cube 页面",`${pages.length} 个扩展页面`,`已生成 ${pages.filter(p=>p.status==="generated").length} 个`,""],
-    ["菜单结构","MU + 综合监控",`共 ${effSelected.length+pages.length} 项`,""],
-    ["领域知识", docs.length?`${docs.length} 份(${docs.slice(0,3).map((d:any)=>d.name).join("、")}${docs.length>3?" 等":""})`:"暂无知识", docs.length?"已沉淀领域知识,将作为 AI 补全优先参考":"可上传文档 / 配置站点", "返回领域知识", onKnowledge],
-    ["运维流程","标准 SOP","(本期占位,待开发)",""],
+function DomainGenerateReview({m,domainName,assets,effSelected,grouped,pages,docs,onBack,onPreviewPage}:{
+  m:any;domainName:string;assets:DomainAsset[];effSelected:string[];grouped:any[];pages:DomainPage[];docs:any[];onBack:()=>void;onPreviewPage?:(p:DomainPage)=>void
+}){
+  const [tab,setTab]=useState<string>("dev");
+  const [devOpen,setDevOpen]=useState<number|null>(null);
+  const [devDetail,setDevDetail]=useState<any>(null);
+  const [devLoading,setDevLoading]=useState(false);
+  const openDev=async(id:number)=>{
+    if(devOpen===id){ setDevOpen(null); return; }
+    setDevOpen(id); setDevLoading(true); setDevDetail(null);
+    try{ const d=await fetchDb(`/device-templates/${id}`) as any; setDevDetail(d); }catch(e:any){} finally{ setDevLoading(false); }
+  };
+  const devList=Array.from(new Map(assets.filter(a=>a.device_template_id).map(a=>[a.device_template_id as number,a])).values());
+  const assetGroups=assets.filter(a=>a.device_template_id);
+  const muItems=grouped.flatMap((g:any)=>g.items).filter((l:any)=>effSelected.includes(l.key));
+  const tabs:{key:string;label:string}[]=[
+    {key:"dev",label:`设备模板 ${devList.length}`},
+    {key:"asset",label:`资产结构 ${assetGroups.length}`},
+    {key:"mu",label:`MU页面 ${effSelected.length}`},
+    {key:"cube",label:`扩展页面 ${pages.length}`},
+    {key:"knowledge",label:`领域知识 ${docs.length}`},
+    {key:"ops",label:"运维流程"},
   ];
-  return <div className="generate-layout"><div>
-    <Panel title="生成范围确认"><div className="confirm-grid">
-      {reviewRows.map(r=><div key={r[0]}><span>{r[0]}</span><b>{r[1]}</b><small>{r[2]}</small>{r[3]&&<button onClick={r[4]||onBack}>{r[3]}</button>}</div>)}
-    </div></Panel>
-    <Panel title="资产层级预览"><div className="asset-tree-preview"><div className="tree-root">⌄ <b>{tName||"未命名方案"}</b></div>{assets.filter(a=>a.device_template_id).map((row,i)=><div key={i} className="tree-branch"><div className="tree-node">⌄ {row.device_name}<span className="tree-count">{row.count}</span></div>{row.count>0&&<div className="tree-leaves">{formatInstances(row.rule,row.count)}</div>}</div>)}</div></Panel>
-    <Panel title="菜单结构预览"><div className="menu-tree"><div className="menu-root">⌄ {tName||"未命名方案"}</div><div className="menu-section"><div className="menu-section-title">MU 标准页面</div>{effSelected.length===0?<div className="menu-leaf muted">(未选择)</div>:grouped.flatMap((g:any)=>g.items).filter((l:any)=>effSelected.includes(l.key)).map((l:any)=><div key={l.key} className="menu-leaf">· {l.parentMenu}/{l.label}</div>)}</div><div className="menu-section"><div className="menu-section-title">综合监控</div>{pages.filter(p=>p.name).map(p=><div key={p.id} className="menu-leaf cube">· {p.name}</div>)}</div></div></Panel>
-  </div>
-  <div className="gen-right">
-    <Panel title="页面总览" className="task-panel">
-      <p className="hint">以下为方案中已配置的 Cube 扩展页面预览(点击可放大查看)。如需调整,请返回「功能与菜单」。</p>
-      <div className="domain-review-pages">{pages.length===0? <div className="muted">暂无 Cube 页面</div> : pages.map(p=><article key={p.id} className="domain-review-page" style={{cursor:"pointer"}} onClick={()=>onPreviewPage&&onPreviewPage(p)}><DomainPageThumb jsonText={p.json_text} emptyLabel="未生成"/><div><Status>{p.status==="generated"?"AI已生成":p.status==="uploaded"?"已上传":"未生成"}{p.source?` · ${p.source==="ai"?"AI":p.source==="upload"?"上传":"手动"}`:""}</Status><h4>{p.name}</h4><span className="review-zoom">⛶ 点击放大</span></div></article>)}</div>
-      <div style={{display:"flex",gap:8,marginTop:12}}><Button secondary onClick={onBack}>返回上一步</Button><Button onClick={onConfirm}>确认方案</Button></div>
+  return <div className="gen-overview">
+    <Panel title="方案概览">
+      <div className="overview-info">
+        <div><span>所属领域</span><b>{domainName||"—"}</b></div>
+        <div><span>方案名称</span><b>{m.name||"—"}</b></div>
+        <div className="overview-desc"><span>方案描述</span><p>{m.description||"—"}</p></div>
+        <div className="overview-desc"><span>补充描述</span><p>{m.supplement||"—"}</p></div>
+      </div>
+      <div className="tabs overview-tabs">
+        {tabs.map(t=><button key={t.key} className={tab===t.key?"active":""} onClick={()=>setTab(t.key)}>{t.label}</button>)}
+      </div>
+      <div className="overview-body">
+        {tab==="dev" && <div className="dev-list">{devList.length===0? <p className="muted">本方案未选用设备模板。</p> :
+          devList.map((a:any)=>(
+            <div key={a.device_template_id} className="dev-row">
+              <button className="dev-row-head" onClick={()=>openDev(a.device_template_id as number)}>
+                <b>{a.device_name}</b><span className="dev-count">{a.count} 台</span><span className="dev-caret">{devOpen===a.device_template_id?"▾":"▸"}</span>
+              </button>
+              {devOpen===a.device_template_id && <div className="dev-detail">{devLoading? <p className="muted">加载中…</p> :
+                devDetail? <><p className="dev-desc">{devDetail.description||"(无说明)"}</p>
+                  <div className="dev-cp"><b>采集点({(devDetail.collection_points||[]).length})</b>{(devDetail.collection_points||[]).length===0? <span className="muted"> 无</span> :
+                    <ul>{(devDetail.collection_points||[]).slice(0,40).map((cp:any,i:number)=><li key={i}>{cp.name}{cp.unit?`(${cp.unit})`:""}</li>)}</ul>}</div></> :
+                <p className="muted">无法加载详情。</p>}</div>}
+            </div>
+          ))}</div>}
+        {tab==="asset" && <div className="asset-tree-preview"><div className="tree-root">⌄ <b>{m.name||"未命名方案"}</b></div>{assetGroups.map((row,i)=><div key={i} className="tree-branch"><div className="tree-node">⌄ {row.device_name}<span className="tree-count">{row.count}</span></div>{row.count>0&&<div className="tree-leaves">{formatInstances(row.rule,row.count)}</div>}</div>)}</div>}
+        {tab==="mu" && <div className="mu-tree">{grouped.length===0? <p className="muted">(未加载 MU 树)</p> :
+          grouped.map(g=><div key={g.parentMenu} className="mu-group"><b>{g.parentMenu}</b><div className="mu-items">{g.items.map((item:any)=><label key={item.key} className="disabled"><input type="checkbox" disabled checked={effSelected.includes(item.key)} onChange={()=>{}}/><span>{item.label}</span></label>)}</div></div>)}</div>}
+        {tab==="cube" && <div className="domain-review-pages">{pages.length===0? <div className="muted">暂无扩展页面,返回「功能与菜单 → 领域扩展页面」生成。</div> :
+          pages.map(p=><article key={p.id} className="domain-review-page" style={{cursor:"pointer"}} onClick={()=>onPreviewPage&&onPreviewPage(p)}><DomainPageThumb jsonText={p.json_text} emptyLabel="未生成"/><div><Status>{p.status==="generated"?"AI已生成":p.status==="uploaded"?"已上传":"未生成"}{p.source?` · ${p.source==="ai"?"AI":p.source==="upload"?"上传":"手动"}`:""}</Status><h4>{p.name}</h4><span className="review-zoom">⛶ 点击放大</span></div></article>)}</div>}
+        {tab==="knowledge" && <div className="doc-list">{docs.length===0? <p className="muted">未上传领域知识,返回「领域知识」步骤上传。</p> :
+          docs.map((d:any,i:number)=><div key={i} className="doc-item"><b>{d.name}</b><span className="muted">{d.type==="link"?"参考站点":(d.mime||"文档")}{(d.size||d.file_size)?` · ${(Number(d.size||d.file_size)/1024).toFixed(1)}KB`:""}</span></div>)}</div>}
+        {tab==="ops" && <div className="ops-placeholder"><span className="status muted">待开发</span><p>运维流程编辑功能待开发,后续版本将支持在方案中配置标准运维 SOP 与告警处理流程。</p></div>}
+      </div>
     </Panel>
-  </div></div>;
+  </div>;
 }
 
 
 
+function PackageModal({name,onClose,notify}:{name?:string;onClose:()=>void;notify:(m:string)=>void}){
+  const items=[
+    {icon:"⬡",label:"设备模板",desc:"本方案选用的设备模版及采集点"},
+    {icon:"▦",label:"资产结构",desc:"默认资产组与设备实例层级"},
+    {icon:"❖",label:"扩展页面文件",desc:"已生成的 Cube 扩展页面文件"},
+    {icon:"☰",label:"默认菜单",desc:"MU + 综合监控 菜单结构"},
+    {icon:"📄",label:"领域知识",desc:"上传的领域知识文档"},
+  ];
+  return <div className="modal-backdrop" onMouseDown={onClose}><div className="modal pkg-modal" onMouseDown={e=>e.stopPropagation()}>
+    <header><h2>方案打包</h2><button onClick={onClose}>×</button></header>
+    <div className="modal-body pkg-body">
+      <p className="hint">即将把方案「{name||"未命名方案"}」打包为可交付成果,包含以下组成部分:</p>
+      <div className="pkg-box">
+        <svg viewBox="0 0 260 150" className="pkg-svg" role="img" aria-label="方案打包箱">
+          <polygon points="50,70 130,52 210,70 130,88" fill="#d9a05b" stroke="#b9823f" strokeWidth="1.5"/>
+          <polygon points="50,70 50,118 130,136 130,88" fill="#c98f4c" stroke="#b9823f" strokeWidth="1.5"/>
+          <polygon points="210,70 210,118 130,136 130,88" fill="#e0aa63" stroke="#b9823f" strokeWidth="1.5"/>
+          <polygon points="50,70 130,52 130,30 50,48" fill="#e7b877" stroke="#b9823f" strokeWidth="1.5"/>
+          <polygon points="210,70 130,52 130,30 210,48" fill="#e7b877" stroke="#b9823f" strokeWidth="1.5"/>
+          <rect x="92" y="60" width="76" height="10" rx="2" fill="#fff" opacity=".9"/>
+          <rect x="96" y="50" width="68" height="10" rx="2" fill="#eef7fb" opacity=".95"/>
+          <rect x="100" y="40" width="60" height="10" rx="2" fill="#fff" opacity=".9"/>
+          <rect x="104" y="31" width="52" height="9" rx="2" fill="#e6f3fb" opacity=".95"/>
+        </svg>
+      </div>
+      <div className="pkg-items">
+        {items.map(it=><div key={it.label} className="pkg-item"><span className="pkg-icon">{it.icon}</span><div className="pkg-item-text"><b>{it.label}</b><small>{it.desc}</small></div></div>)}
+      </div>
+    </div>
+    <footer><Button secondary onClick={onClose}>取消</Button><Button onClick={()=>{ notify("方案已打包(示意),可交付给实施团队"); onClose(); }}>确认打包下载</Button></footer>
+  </div></div>;
+}
+
 function KnowledgeStep({id,docs,notify,reload,confirm}:{id:number;docs:any[];notify:(m:string)=>void;reload:()=>void;confirm:(msg:string)=>Promise<boolean>}){
   const [busy,setBusy]=useState(false);
   const [urlName,setUrlName]=useState("");
index 1923eee7c8a873ca9438b6b96ddd2bb4ee42a676..1b95cfb1251f2da1cbbf2deca5c033c7b5b51e77 100644 (file)
   <nav class="doc-nav"><div class="doc-nav-inner" id="toc"></div></nav>
 
 <main id="doc">
-  <p class="muted">文档版本:v0.31 最后更新:2026-08-21 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
+  <p class="muted">文档版本:v0.32 最后更新:2026-08-21 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
   <h1>StationBuilder 产品与交互设计方案</h1>
 
   <h2>1. 文档说明</h2>
   <h3>10.1 版本变更记录(Changelog)</h3>
   <table class="changelog">
     <tr><th>版本</th><th>日期</th><th>修改人</th><th>说明</th></tr>
+    <tr><td>v0.32</td><td>2026-08-21</td><td>wang.yu</td><td>领域方案「生成保存」步骤重构为「方案打包」:① <b>步骤改名</b>:<code>DOMAIN_STEPS</code> 末步标签「生成保存」→「方案打包」;该步骤不再提供「下一步」,仅保留「保存草稿」与本步专属「📦 打包下载」按钮(末步另保留「提交确认」以提交专家确认)。② <b>「生成范围确认」→「方案概览」多标签总览</b>:<code>DomainGenerateReview</code> 重写为单 Panel「方案概览」+ 六标签(标签上带数量徽标):<b>设备模板 N</b>(方案去重用到的设备模板列表,点击行内展开详情——调 <code>GET /api/db/device-templates/:id</code> 显示说明与采集点清单)、<b>资产结构 N</b>(按设备模板聚合的默认资产组树状预览,使用 <code>formatInstances(rule,count)</code> 展开实例)、<b>MU页面 N</b>(复用功能与菜单的 MU 树,禁用态勾选框展示已选页面)、<b>扩展页面 N</b>(已生成 Cube 页面卡片,点击整卡经 <code>onPreviewPage</code> 打开全屏预览)、<b>领域知识 N</b>(列举本方案上传的知识文档与 URL 站点)、<b>运维流程</b>(占位「待开发」+ 固定文案「运维流程编辑功能待开发,后续版本将支持在方案中配置标准运维 SOP 与告警处理流程。」)。概览头部固定展示所属领域 / 方案名称 / 方案描述 / 补充描述。③ <b>修复扩展页面预览「只能看到上半部分」</b>:<code>DomainPageThumb</code> 新增 <code>fit</code> 模式——由「仅按宽度缩放」改为「按宽高较小比例缩放」<code>s = min(w/pw, availH/rh)</code>(availH 取预览 canvas 父容器高度),canvas 高度由固定 430px 改为 <code>height:64vh; overflow:auto</code>,整页一次可见不再裁切。④ <b>「📦 打包下载」示意弹窗</b>:新增 <code>PackageModal</code>——SVG 立体包装箱(堆叠文档矩形,带投影)+ 5 张打包组成卡片(设备模板 / 资产结构 / 扩展页面文件 / 默认菜单 / 领域知识),「确认打包下载」提示「方案已打包(示意),可交付给实施团队」后关闭(本期为示意,真实文件导出待后续)。<code>globals.css</code> 增补 <code>.gen-overview / .overview-info / .overview-tabs / .dev-list / .dev-detail / .asset-tree-preview / .mu-tree / .doc-list / .ops-placeholder / .pkg-modal / .pkg-svg / .pkg-items</code> 等样式。Playwright E2E(污水厂方案,34 设备 / 11 页面):步骤标签=方案打包、Panel=方案概览、6 标签徽标正确、设备行展开显示 12 个采集点、资产树 / MU 树 / 页面卡片渲染、预览 canvas 渲染高度 576px = 缩放后整页高度(证明整页可见,不再上半部裁切)、知识 / 运维标签文案正确、本步无「下一步」、打包弹窗含 SVG + 5 组成项,0 控制台报错;另以数字机房方案验证领域知识列表确实展示已上传文档。</td></tr>
     <tr><td>v0.31</td><td>2026-08-21</td><td>wang.yu</td><td>UI 风格对齐 KB Insight 门户(Ant Design):参照 https://portal-kbinsight-wiseiot-ensaas.practice.cloud.advantech.com.cn/kb/homepage 调整 ① <b>字体</b>:由 <code>Arial,"Microsoft YaHei"</code> 改为 AntD 系统字体栈 <code>-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Microsoft YaHei"</code>,与门户一致(中文保留雅黑 / Noto)。② <b>顶部 Header</b>:高度 42px→56px,去除 4px 实色顶边,改为 3px 蓝白渐变顶线(模拟门户 <code>.bd-linear</code>,用 <code>background-clip:padding-box,border-box</code> 双层背景实现);同步 <code>.app-shell</code> 顶部内边距与 <code>.side-nav</code> 顶部偏移 42px→56px,避免布局错位。③ <b>左侧菜单选中/悬停</b>对齐 AntD:选中背景由 <code>#dff1fa</code>/<code>#e9f6fc</code> 改为 <code>rgba(0,140,214,.1)</code>(对应门户 <code>--color-blue-tree</code>/<code>--color-menu-bk</code>),选中文字 <code>#008cd6</code>,悬停浅灰 <code>rgba(0,0,0,.04)</code>(对应 <code>--color-hover-gray</code>);覆盖 <code>.side-nav&gt;button</code>、<code>.module-switch&gt;button</code>、<code>.domain-nav-row</code> 三处,菜单字号提到 14px;修复 <code>.domain-nav-row&gt;button:not()</code> 高优先级规则覆盖选中态的问题(给选中规则补 <code>:not(.domain-nav-del):not(.domain-nav-ok)</code> 提权)。主蓝 token 维持 <code>#009fe3</code>(与目标 <code>#008CD6</code> 肉眼无差,避免大范围改色不一致)。Playwright 断言:body 字体栈含 Segoe UI/Roboto、header 高度 56 且 backgroundImage 为双层渐变、三类菜单选中背景均为 <code>rgba(0,140,214,.1)</code>、0 控制台报错。</td></tr>
     <tr><td>v0.30</td><td>2026-08-21</td><td>wang.yu</td><td>修复领域方案「生成保存」页面预览全部空白:根因为 <code>public/dashboard-renderer.js</code> 的 <code>renderDashboard</code> 每次渲染开头执行 <code>renderDashboard._instances.forEach(i=&gt;i.dispose())</code> 把<b>全部全局 echarts 实例销毁</b>——同一方案 11 张页面缩略图顺序渲染时,后一张的渲染把前一张的图表实例 dispose 掉,仅最后一张卡片保留图表、其余均为空白(DOM 有内容但 0 个 <code>&lt;canvas&gt;</code>)。修复:废弃全局实例数组,改为<b>按容器隔离</b>——每个容器记录自身 <code>container.__dashInst</code>,渲染前仅销毁本容器旧实例并清空 <code>innerHTML</code>,渲染后回挂本容器实例;新增模块级 <code>_curInst</code> 收集本次渲染的实例。现已验证污水厂(橙色风格)方案 11 张卡片全部渲染出图表(共 43 个 canvas,每卡 ≥0)。附带增强:① 生成确认页「领域方案页面」列表卡片改为整宽单列大图(<code>.domain-review-pages</code> 由两列改 1 列,<code>.dashboard-thumb-live</code> 最小高度 115px→240px),并整体可点击——点击卡片经新增 <code>onPreviewPage</code> 入参打开 <code>DomainPagePreviewModal</code> 全屏预览(含图表),卡片右下角加「⛶ 点击放大」提示。② globals.css 增补 <code>.domain-review-page</code> hover 描边与 <code>.review-zoom</code> 提示样式。Playwright E2E:11 卡全部有 canvas、点开预览 modal 含 2 个 canvas + 361 字正文,0 控制台报错。</td></tr>
     <tr><td>v0.29</td><td>2026-08-21</td><td>wang.yu</td><td>领域方案库 / 站点「功能与菜单 → Cube 扩展页面」遗留问题修复与体验提升:① <b>页签改名与排序</b>:站点与领域两处「Cube 扩展页面」页签统一更名为「领域扩展页面」,并调整顺序置于「MU 标准页面」之前(默认进入该页签);文档同步。② <b>生成按钮醒目化</b>:两处扩展页面表格的「生成」按钮文案升级为「AI生成」、生成中显示「AI生成中」,并采用醒目渐变蓝紫样式(<code>.btn-ai</code>,覆盖 <code>.cube-actions button</code> 白底默认态)。③ <b>统一生成</b>:领域方案「领域扩展页面」表格上方新增「⚡ AI 统一生成全部(N)」按钮(N = 未生成页面数),一键对所有「未生成」且未在生成中的页面并发触发 <code>generatePage</code>;后端按每页独立锁 + <code>AI_CONCURRENCY=2</code> 信号量排队,各自页面按钮同步显示「AI生成中」,免去逐个点击(仅保留单页生成)。④ <b>图片加载提速</b>:后端 db-sidecar 的 <code>handlePageStyleImage</code> / <code>handleDeviceTemplateImage</code> 新增 <code>?w=</code> 宽度参数,经 sharp 生成 WebP 缩略图并落盘缓存(mtime 校验失效重生成),原图约 1.4MB → 缩略图约 9KB(提速约 150 倍);前端样式选择器(?<w=360)、页面样式库列表(?<w=560)、编辑预览(?<w=640)均改用缩略图,大图灯箱仍取原图。需 <code>systemctl restart stationbuilder-db</code> 生效(新增 sharp 依赖,Node 原生模块已就绪)。Playwright E2E:领域方案进入「领域扩展页面」页签确认置首、10 个页面按钮显示「AI生成」、统一按钮计数正确;新增待生成页面后点「AI 统一生成全部」其按钮即时翻为「AI生成中」、其余已生成页保持「AI生成」,0 控制台报错;页面样式库 5 张缩略图均带 <code>?w=</code>、缩略图接口实测 1355KB→8.9KB。</td></tr>