]> acesimba.cloud Git - StationBuilder.git/commitdiff
领域方案库 Cube 页面支持选择页面样式库样式后生成(方案级,镜像站点机制)
authorroot <root@localhost.localdomain>
Thu, 20 Aug 2026 09:27:44 +0000 (17:27 +0800)
committerroot <root@localhost.localdomain>
Thu, 20 Aug 2026 09:27:44 +0000 (17:27 +0800)
- 后端 domain_templates 新增 style_id 列(ensureColumn 增量加列);handleDomainTemplateUpdate 支持写入/显式置空;hydrateDomainTemplateCtx 返回真实 style_id
- 前端领域方案详情「功能与菜单 → Cube 扩展页面」顶部新增「领域方案页面样式」选择器(复用 .style-picker 与样式库缩略图),选中即持久化,生成/AI 修改经共享 buildStylePrompt 自动注入所选样式主题
- 设计文档升 v0.21,补充 changelog 与交互说明

app/page.tsx
data/db/StationBuilder.sqlite
db-sidecar.mjs
public/design-doc.html

index 87ed1772c23e3591e9543a1023ff9723856912d2..a2c7c09b094f07071991bcb4e7e6a257099f6b4a 100644 (file)
@@ -500,7 +500,7 @@ export default function Home(){
     <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} viewCompletePrompt={viewDtCompletePrompt} viewCompleteAssetsPrompt={viewDtCompleteAssetsPrompt} viewCompleteCubesPrompt={viewDtCompleteCubesPrompt}/>
+        ? <DomainDetail id={openDomainTemplateId==="create"?null:openDomainTemplateId} go={go} notify={notify} onBack={()=>setOpenDomainTemplateId(null)} onCreated={setOpenDomainTemplateId} viewCompletePrompt={viewDtCompletePrompt} viewCompleteAssetsPrompt={viewDtCompleteAssetsPrompt} viewCompleteCubesPrompt={viewDtCompleteCubesPrompt} pageStyles={pageStyles}/>
         : <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)} domainFilter={domainFilter} domainOptions={domainOptions} onDomainDeleted={handleDomainDeleted}/>)}
       {page==="styleLibrary"&&<PageStyleLibrary pageStyles={pageStyles} loadPageStyles={loadPageStyles} notify={notify}/>}
       {page==="deviceTemplates"&&<DeviceTemplates notify={notify}/>}
@@ -1338,13 +1338,13 @@ function PageRow({p,notify,onChange}:{p:any;notify:(m:string)=>void;onChange:()=
 
 function OpsFlowsViewer(){ const [flows,setFlows]=useState<any[]>([]); useEffect(()=>{ fetchDb("/ops-flows").then((d:any)=>setFlows(d||[])).catch(()=>{}); },[]); return <Panel title="运维流程(本期为占位,后续实现)"><table className="data-table"><thead><tr><th>流程名称</th><th>内容</th></tr></thead><tbody>{flows.length===0? <tr><td colSpan={2} className="muted">暂无</td></tr> : flows.map((f:any)=><tr key={f.id}><td>{f.name}</td><td style={{maxWidth:480,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{f.content}</td></tr>)}</tbody></table><p className="muted">运维流程功能尚未实现,当前仅保留占位记录。</p></Panel>; }
 
-function DomainDetail({id,go,notify,onBack,onCreated,viewCompletePrompt,viewCompleteAssetsPrompt,viewCompleteCubesPrompt}:{id:number|null;go:(p:PageKey)=>void;notify:(m:string)=>void;onBack:()=>void;onCreated:(nid:number)=>void;viewCompletePrompt:(id:number,name:string,domain:string,description:string)=>void;viewCompleteAssetsPrompt:(id:number,name:string,domain:string,description:string,supplement:string)=>void;viewCompleteCubesPrompt:(id:number,name:string,domain:string,description:string,supplement:string,assets:any[])=>void}){
+function DomainDetail({id,go,notify,onBack,onCreated,viewCompletePrompt,viewCompleteAssetsPrompt,viewCompleteCubesPrompt,pageStyles}:{id:number|null;go:(p:PageKey)=>void;notify:(m:string)=>void;onBack:()=>void;onCreated:(nid:number)=>void;viewCompletePrompt:(id:number,name:string,domain:string,description:string)=>void;viewCompleteAssetsPrompt:(id:number,name:string,domain:string,description:string,supplement:string)=>void;viewCompleteCubesPrompt:(id:number,name:string,domain:string,description:string,supplement:string,assets:any[])=>void;pageStyles:PageStyle[]}){
   const [doms,setDoms]=useState<any[]>([]);
   const [loading,setLoading]=useState(false);
   const loadDoms=async()=>{ try{ setDoms(await fetchDb("/domains") as any); }catch{} };
   useEffect(()=>{ loadDoms(); },[]);
   if(id==null) return <CreateTemplate doms={doms} notify={notify} onCreated={onCreated}/>;
-  return <DomainWizard id={id} doms={doms} go={go} notify={notify} onBack={onBack} viewCompletePrompt={viewCompletePrompt} viewCompleteAssetsPrompt={viewCompleteAssetsPrompt} viewCompleteCubesPrompt={viewCompleteCubesPrompt}/>;
+  return <DomainWizard id={id} doms={doms} go={go} notify={notify} onBack={onBack} viewCompletePrompt={viewCompletePrompt} viewCompleteAssetsPrompt={viewCompleteAssetsPrompt} viewCompleteCubesPrompt={viewCompleteCubesPrompt} pageStyles={pageStyles}/>;
 }
 
 const DOMAIN_STEPS:{key:DomainStepKey;label:string}[]=[
@@ -1438,7 +1438,7 @@ function DomainPagePreviewModal({page,tmplId,notify,onClose}:{page:DomainPage;tm
   </div></div>;
 }
 
-function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteAssetsPrompt,viewCompleteCubesPrompt}:{id:number;doms:any[];go:(p:PageKey)=>void;notify:(m:string)=>void;onBack:()=>void;viewCompletePrompt:(id:number,name:string,domain:string,description:string)=>void;viewCompleteAssetsPrompt:(id:number,name:string,domain:string,description:string,supplement:string)=>void;viewCompleteCubesPrompt:(id:number,name:string,domain:string,description:string,supplement:string,assets:any[])=>void}){
+function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteAssetsPrompt,viewCompleteCubesPrompt,pageStyles}:{id:number;doms:any[];go:(p:PageKey)=>void;notify:(m:string)=>void;onBack:()=>void;viewCompletePrompt:(id:number,name:string,domain:string,description:string)=>void;viewCompleteAssetsPrompt:(id:number,name:string,domain:string,description:string,supplement:string)=>void;viewCompleteCubesPrompt:(id:number,name:string,domain:string,description:string,supplement:string,assets:any[])=>void;pageStyles:PageStyle[]}){
   const [t,setT]=useState<any>(null);
   const [m,setM]=useState<any>(null);
   const [assets,setAssets]=useState<DomainAsset[]>([]);
@@ -1471,7 +1471,7 @@ function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteA
       const [d, mt]=await Promise.all([ fetchDb(`/domain-templates/${id}`) as any, fetchDb(`/mu-tree`) as Promise<MuNode[]> ]);
       setMuTreeLocal(mt);
       setT(d);
-      setM({domain_id:d.domain_id,name:d.name,description:d.description||"",status:d.status,version:d.version,creator:d.creator||"",supplement:d.supplement||""});
+      setM({domain_id:d.domain_id,name:d.name,description:d.description||"",status:d.status,version:d.version,creator:d.creator||"",supplement:d.supplement||"",style_id:d.style_id!=null?Number(d.style_id):null});
       setAssets((d.assets||[]).map((a:any)=>({id:a.id,device_template_id:a.device_template_id,device_name:a.device_name||"",count:Number(a.count)||0,rule:a.rule||"",sort_order:a.sort_order||0})));
       setPages((d.pages||[]).map((p:any)=>({id:p.id,name:p.name,description:p.description||"",json_text:p.json_text||"",status:p.status||"pending",gen_filename:p.gen_filename||null,prompt:p.prompt||null,source:p.source||null,sort_order:p.sort_order||0})));
       setMu(d.mu && d.mu.length ? d.mu : []);
@@ -1491,6 +1491,15 @@ function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteA
   const saveMu=async(next?:string[])=>{ const sel=next??mu; setMu(sel); await patch({mu:sel}, "MU 选择已保存"); };
   const toggleMu=(key:string)=> saveMu(effSelected.includes(key)? effSelected.filter(k=>k!==key) : [...effSelected, key]);
 
+  // ===== 领域方案页面样式(镜像站点侧 style_id 机制)=====
+  const selectStyle=async(sid:number|null)=>{
+    try{
+      await fetchDb(`/domain-templates/${id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({style_id:sid})});
+      setM((prev:any)=>({...prev,style_id:sid}));
+      notify(sid?"已应用页面样式":"已清除页面样式");
+    }catch(e:any){ notify("应用样式失败:"+e.message); }
+  };
+
   // ===== Step1 AI 智能补全(写入补充描述)=====
   const runBasicComplete=async()=>{
     if(basicAi.completing) return;
@@ -1662,7 +1671,8 @@ function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteA
         genIds={genIds} genLog={genLog}
         cubesAi={cubesAi} onRunCubesComplete={runCubesComplete} onViewCompleteCubesPrompt={()=>viewCompleteCubesPrompt(id,m.name,domainName,m.description,m.supplement||"",assets.map(a=>({type:a.device_name,count:a.count,rule:a.rule})))}
         updatePageField={(i,patchRow)=>setPages(prev=>prev.map((p,idx)=>idx===i?{...p,...patchRow}:p))}
-        tName={m.name} saving={saving} notify={notify} />}
+        tName={m.name} saving={saving} notify={notify}
+        pageStyles={pageStyles} styleId={m.style_id??null} onSelectStyle={selectStyle} />}
 
       {step==="opsflow" && <PlaceholderStep title="运维流程" note="运维流程编辑功能待开发,后续版本将支持在方案中配置标准运维 SOP 与告警处理流程。"/>}
       {step==="knowledge" && <PlaceholderStep title="领域知识" note="领域知识(知识文档、标准规范、参考资料)编辑功能待开发,后续版本将支持在方案中沉淀领域知识库。"/>}
@@ -1677,7 +1687,7 @@ function PlaceholderStep({title,note}:{title:string;note:string}){
   return <Panel title={title}><div className="placeholder-block"><span className="badge todo">待开发</span><p>{note}</p></div></Panel>;
 }
 
-function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,onUploadPageJson,onSavePageMeta,onDeletePage,onGeneratePage,onDownloadPage,onPreviewPage,genIds,genLog,cubesAi,onRunCubesComplete,onViewCompleteCubesPrompt,updatePageField,tName,saving,notify}:{muTree:MuNode[];grouped:any[];effSelected:string[];onToggleMu:(k:string)=>void;pages:DomainPage[];onAddPage:()=>Promise<void>;onUploadPageJson:(f:File)=>Promise<void>;onSavePageMeta:(p:DomainPage)=>Promise<void>;onDeletePage:(p:DomainPage)=>Promise<void>;onGeneratePage:(p:DomainPage)=>Promise<void>;onDownloadPage:(p:DomainPage)=>void;onPreviewPage:(p:DomainPage)=>void;genIds:Set<number>;genLog:string;cubesAi:ReturnType<typeof useStreamComplete>;onRunCubesComplete:()=>Promise<void>;onViewCompleteCubesPrompt:()=>void;updatePageField:(i:number,patch:Partial<DomainPage>)=>void;tName:string;saving:boolean;notify:(m:string)=>void}){
+function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,onUploadPageJson,onSavePageMeta,onDeletePage,onGeneratePage,onDownloadPage,onPreviewPage,genIds,genLog,cubesAi,onRunCubesComplete,onViewCompleteCubesPrompt,updatePageField,tName,saving,notify,pageStyles,styleId,onSelectStyle}:{muTree:MuNode[];grouped:any[];effSelected:string[];onToggleMu:(k:string)=>void;pages:DomainPage[];onAddPage:()=>Promise<void>;onUploadPageJson:(f:File)=>Promise<void>;onSavePageMeta:(p:DomainPage)=>Promise<void>;onDeletePage:(p:DomainPage)=>Promise<void>;onGeneratePage:(p:DomainPage)=>Promise<void>;onDownloadPage:(p:DomainPage)=>void;onPreviewPage:(p:DomainPage)=>void;genIds:Set<number>;genLog:string;cubesAi:ReturnType<typeof useStreamComplete>;onRunCubesComplete:()=>Promise<void>;onViewCompleteCubesPrompt:()=>void;updatePageField:(i:number,patch:Partial<DomainPage>)=>void;tName:string;saving:boolean;notify:(m:string)=>void;pageStyles:PageStyle[];styleId:number|null;onSelectStyle:(id:number|null)=>void}){
   const sourceBadge=(src?:string|null)=>{
     if(src==="upload") return <span className="badge src-upload">↑ 上传</span>;
     if(src==="ai") return <span className="badge src-ai">✦ AI生成</span>;
@@ -1690,6 +1700,19 @@ function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,on
     {tab==="mu" && <div className="mu-tree">{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={item.disabled?"disabled":""}><input type="checkbox" disabled={item.disabled} checked={effSelected.includes(item.key)} onChange={()=>onToggleMu(item.key)}/><span>{item.label}</span></label>)}</div></div>)}</div>}
     {tab==="cube" && <div className="two-column">
       <div>
+        <div className="style-picker">
+          <h4>领域方案页面样式 <small>方案级 · 本方案全部 Cube 页面共用</small></h4>
+          <p className="hint">选择一个样式库方案作为本方案所有 Cube 页面的视觉风格(生成时参考其 JSON 主题参数)。</p>
+          <div className="style-pick-list">
+            <button className={"style-pick"+(!styleId?" active":"")} onClick={()=>onSelectStyle(null)}>
+              <div className="style-pick-thumb none">无</div><span>不使用样式</span>
+            </button>
+            {pageStyles.map(s=><button key={s.id} className={"style-pick"+(styleId===s.id?" active":"")} onClick={()=>onSelectStyle(s.id)}>
+              <div className="style-pick-thumb"><img src={`/api/db/page-styles/${s.id}/image`} alt={s.name} loading="lazy"/></div>
+              <span>{s.name}</span>
+            </button>)}
+          </div>
+        </div>
         <div className="cube-editor"><table className="data-table editable cube-editor-table"><thead><tr><th>页面名称 <small>最多6字</small></th><th>页面描述</th><th>来源</th><th>Json文件</th><th>操作</th></tr></thead><tbody>{pages.map((p,i)=><tr key={p.id}>
           <td><input value={p.name} maxLength={6} placeholder="例如:运行总览" onChange={e=>updatePageField(i,{name:e.target.value})}/></td>
           <td><input value={p.description} placeholder="描述该页面要展示的内容与指标" onChange={e=>updatePageField(i,{description:e.target.value})}/></td>
index 4dab6bc5924dce30c82ed8decfed7d20b8fe8fed..2ea3a88066e67f590e795e72623ad8718c544a4e 100644 (file)
Binary files a/data/db/StationBuilder.sqlite and b/data/db/StationBuilder.sqlite differ
index 93478fb84c12b25482824b9fc654fc2dfe8943b8..a7109b99aa29445bd6566cfb9ff79088bc204390 100644 (file)
@@ -339,6 +339,7 @@ function ensureColumn(table, column, def) {
 ensureColumn("cubes", "prompt", "TEXT");
 ensureColumn("sites", "style_id", "INTEGER");
 ensureColumn("domain_templates", "supplement", "TEXT");
+ensureColumn("domain_templates", "style_id", "INTEGER");
 ensureColumn("domain_pages", "status", "TEXT DEFAULT 'pending'");
 ensureColumn("domain_pages", "gen_filename", "TEXT");
 ensureColumn("domain_pages", "prompt", "TEXT");
@@ -1682,8 +1683,9 @@ async function handleDomainTemplateUpdate(req, res, id) {
   const version = body.version != null ? body.version.toString() : row.version;
   const creator = body.creator != null ? body.creator.toString() : row.creator;
   const supplement = body.supplement != null ? body.supplement : row.supplement;
+  const style_id = body.style_id !== undefined ? (body.style_id != null ? Number(body.style_id) : null) : row.style_id;
   if (!name) return respondError(res, 400, "模板名称不能为空");
-  db.prepare("UPDATE domain_templates SET domain_id=?,name=?,description=?,status=?,version=?,creator=?,supplement=? WHERE id=?").run(domain_id, name, description, status, version, creator, supplement, id);
+  db.prepare("UPDATE domain_templates SET domain_id=?,name=?,description=?,status=?,version=?,creator=?,supplement=?,style_id=? WHERE id=?").run(domain_id, name, description, status, version, creator, supplement, style_id, id);
   if (body.device_template_ids !== undefined) {
     db.prepare("DELETE FROM device_template_domain_templates WHERE domain_template_id=?").run(id);
     for (const devId of Array.isArray(body.device_template_ids) ? body.device_template_ids : []) {
@@ -1720,7 +1722,7 @@ async function hydrateDomainTemplateCtx(tmplId) {
   const dt = db.prepare("SELECT dt.*, d.name AS domain_name FROM domain_templates dt JOIN domains d ON d.id=dt.domain_id WHERE dt.id=?").get(tmplId);
   if (!dt) return null;
   const assets = db.prepare("SELECT a.device_template_id, dt.name AS type, a.count, a.rule FROM domain_template_assets a JOIN device_templates dt ON dt.id=a.device_template_id WHERE a.domain_template_id=?").all(tmplId);
-  return { id: dt.id, name: dt.name, domain: dt.domain_name, description: dt.description || "", supplement: dt.supplement || "", assets, style_id: null, cubes: [] };
+  return { id: dt.id, name: dt.name, domain: dt.domain_name, description: dt.description || "", supplement: dt.supplement || "", assets, style_id: dt.style_id != null ? dt.style_id : null, cubes: [] };
 }
 
 async function resolveDeviceTemplate(name, domainId, tmplId) {
index 4e4bf061bffc48e5a5cdc4f9a0968baa09050d18..52f4f8763202f764fd65d4113fdd80aa4f84edef 100644 (file)
   <nav class="doc-nav"><div class="doc-nav-inner" id="toc"></div></nav>
 
 <main id="doc">
-  <p class="muted">文档版本:v0.20 最后更新:2026-08-20 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
+  <p class="muted">文档版本:v0.21 最后更新:2026-08-20 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
   <h1>StationBuilder 产品与交互设计方案</h1>
 
   <h2>1. 文档说明</h2>
     <tr><td>筛选栏(查找 / 所属领域 / 状态 / 条数)</td><td>列表实时筛选与计数</td><td>本地 state(<code>q</code> / <code>domainSel</code> / <code>statusSel</code>),作用于 GET /api/db/domain-templates 结果</td><td>查找框按 方案名称/创建人 模糊匹配;所属领域、状态下拉精确过滤;右上角显示「共 N 条」</td></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>
     <tr><td>管理领域面板</td><td>领域分类管理:列出全部领域及方案数,支持删除领域</td><td>GET /api/domains(领域列表);删除调用 DELETE /api/domains/:id</td><td>「其他」标注「(默认保留)」且无删除按钮;其余领域显示「删除」按钮 → 自定义确认弹窗二次确认 → 调用 DELETE /api/domains/:id(后端先将其下 domain_templates / device_template_domains 迁移至「其他」,再删除领域)→ 前端刷新领域列表、若当前筛选命中该领域则清除筛选</td></tr>
+    <tr><td>领域方案页面样式(Cube 扩展页面顶部)</td><td>为方案选择页面样式库样式,作用于本方案全部 Cube 页面</td><td>GET /api/page-styles(样式列表 + 缩略图);选中 PUT /api/db/domain-templates/:id(<code>style_id</code>)持久化;生成经共享 buildStylePrompt 注入样式主题</td><td>「功能与菜单 → Cube 扩展页面」顶部 <code>.style-picker</code>:含「不使用样式」与全部页面样式缩略图按钮,当前选中高亮;点击即写回 domain_templates.style_id,生成/AI 修改时自动带入该样式视觉主题(与站点侧机制一致)</td></tr>
     <tr><td>「+ 新增领域方案」</td><td>新建领域方案,进入新增表单</td><td>—</td><td>同页切到 CreateTemplate 表单:所属领域(select 来自 GET /api/domains)、模板名称、版本、状态(可用/不可用)、创建人、领域描述;POST /api/db/domain-templates 成功后自动打开该方案详情编辑器</td></tr>
     <tr><td>搜索 / 筛选 / 同步方案库</td><td>(历史功能)</td><td>—</td><td>已按计划 Phase 0 移除整行(含「同步方案库」占位按钮) <span class="todo">已移除</span></td></tr>
   </table>
   <h3>11.1 版本变更记录(Changelog)</h3>
   <table class="changelog">
     <tr><th>版本</th><th>日期</th><th>修改人</th><th>说明</th></tr>
+    <tr><td>v0.21</td><td>2026-08-20</td><td>wang.yu</td><td>领域方案库 Cube 扩展页面支持选择页面样式库样式后生成(镜像站点侧 <code>sites.style_id</code> 机制,方案级):① 后端 <code>domain_templates</code> 新增 <code>style_id</code> 列(<code>ensureColumn</code> 增量加列);<code>handleDomainTemplateUpdate</code> 支持写入/显式置空;<code>hydrateDomainTemplateCtx</code> 返回真实 <code>style_id</code>(此前硬编码 null)。② 前端领域方案详情「功能与菜单 → Cube 扩展页面」顶部新增「领域方案页面样式」选择器(复用 <code>.style-picker</code> 与页面样式库缩略图),选中即 PUT <code>/domain-templates/:id</code> 持久化;生成/AI 修改经共享的 <code>buildStylePrompt</code> 自动注入所选样式的 JSON 主题,无需改动提示词构造。</td></tr>
     <tr><td>v0.20</td><td>2026-08-20</td><td>wang.yu</td><td>领域分类管理增强:① 默认分类调整——移除「工业制造 / 工业园区 / 智慧楼宇」,新增「其他」(<code>DEFAULT_DOMAINS</code> 同步更新,重启不再复活被删分类);② 领域方案库新增「管理领域」面板,列出全部领域及其方案数,「其他」标注「默认保留」且不可删除,其余领域提供「删除」按钮,经自定义确认弹窗二次确认后调用 <code>DELETE /api/domains/:id</code>;③ 后端新增 <code>DELETE /api/domains/:id</code>:禁止删除「其他」,删除前将待删领域的 <code>domain_templates</code> 与 <code>device_template_domains</code> 关联迁移至「其他」(避免 <code>ON DELETE CASCADE</code> 误删方案),再删除领域;④ 删除后前端刷新领域列表,并在当前筛选命中该领域时自动清除筛选。</td></tr>
     <tr><td>v0.19</td><td>2026-08-20</td><td>wang.yu</td><td>修复领域方案「功能与菜单 → Cube 扩展页面」两处问题:①「生成」按钮改为流式请求(消费 NDJSON 响应体以保持连接),生成期间按钮显示「生成中…」并禁用、底部「AI 生成页面」控制台实时输出进度并据服务端 <code>{status:failed}</code> 事件弹失败提示;此前前端用普通 <code>fetch</code> 不读取响应体,连接被提前关闭导致服务端 AI 生成中断(<code>status='failed'</code>)、Json 未写入。② AI 智能补全(complete-cubes)由「逐条静默 <code>.catch(()=>{})</code> 插入」改为「收集结果后逐个 <code>await</code> 插入、失败显式 <code>notify</code>」,并捕获服务端 <code>{status:failed}</code> 事件弹失败提示,避免补全内容静默丢失、列表无变化。</td></tr>
     <tr><td>v0.17</td><td>2026-08-19</td><td>wang.yu</td><td>修复左侧目录(TOC)点击章节后消失:根因为顶部版本说明段落含全角空格不换行,在 flex 布局下把 <code>main#doc</code> 撑到约 2238px 宽(flex item 默认 <code>min-width:auto</code> 不收缩),点击锚点时浏览器水平滚动将 sticky 目录推出左边界。给 <code>main#doc</code> 增加 <code>min-width:0</code> 使其可收缩、内容正常折行,消除水平滚动,目录任意宽度常驻可见。</td></tr>