type MuNode = { key:string; children?:MuNode[]; tabs?:MuTab[] };
type PageStyle = { id:number; name:string; created_at?:string };
type DomainAsset = { id?:number; device_template_id:number; device_name:string; count:number; rule:string; sort_order:number };
-type DomainPage = { id:number; name:string; description:string; json_text:string; status:string; gen_filename?:string|null; prompt?:string|null; sort_order:number };
+type DomainPage = { id:number; name:string; description:string; json_text:string; status:string; gen_filename?:string|null; prompt?:string|null; source?:string|null; sort_order:number };
type DomainStepKey = "basic" | "assets" | "functions" | "opsflow" | "knowledge" | "generate" | "expert";
// ---- helpers ----
-function Status({children}:{children:React.ReactNode}){const v=String(children);const tone=/完成|正常|成功|通过|推荐|generated/.test(v)?"ok":/生成中|校验中|告警|running/.test(v)?"warn":/失败|异常|failed/.test(v)?"bad":"muted";return <span className={"status "+tone}>{children}</span>}
+function Status({children}:{children:React.ReactNode}){const v=String(children);const tone=/完成|正常|成功|通过|推荐|已生成|已上传|generated/.test(v)?"ok":/生成中|校验中|告警|running/.test(v)?"warn":/失败|异常|failed/.test(v)?"bad":"muted";return <span className={"status "+tone}>{children}</span>}
function Button({children,secondary,onClick,disabled,title}:{children:React.ReactNode;secondary?:boolean;onClick?:()=>void;disabled?:boolean;title?:string}){return <button title={title} disabled={disabled} onClick={onClick} className={"button "+(secondary?"secondary":"")}>{children}</button>}
function Panel({title,tools,children,className=""}:{title?:string;tools?:React.ReactNode;children:React.ReactNode;className?:string}){return <section className={"panel "+className}>{title&&<div className="panel-head"><strong>{title}</strong><div>{tools}</div></div>}<div className="panel-body">{children}</div></section>}
function Field({label,required,hint,children}:{label:string;required?:boolean;hint?:string;children:React.ReactNode}){return <label className="field"><span>{required&&<i>*</i>}{label}</span>{children}{hint&&<small>{hint}</small>}</label>}
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||""});
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,sort_order:p.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 : []);
}catch(e:any){ notify("加载失败:"+e.message); }
finally{ if(!silent) setLoading(false); }
const onDevCreated=async(nid:number)=>{ await loadAllDevices(); setDevCreateOpen(false); if(pendingDevRow!=null){ const dev=allDevices.concat([{id:nid}]).find(d=>d.id===nid); updateAsset(pendingDevRow,{device_template_id:nid,device_name:dev?.name||""}); setPendingDevRow(null); } };
// ===== 页面操作 =====
- const addPage=async()=>{ await fetchDb(`/domain-templates/${id}/pages`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:"新页面",description:"",json_text:"{}"})}); await load(true); notify("已新增页面"); };
+ const addPage=async()=>{ await fetchDb(`/domain-templates/${id}/pages`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:"新页面",description:"",json_text:"{}",source:"manual"})}); await load(true); notify("已新增页面"); };
const uploadPageJson=async(file:File, replaceId?:number)=>{
const text=await file.text();
try{ JSON.parse(text); }catch{ notify("不是合法 JSON 文件"); return; }
const name=file.name.replace(/\.json$/i,"");
try{
if(replaceId){ await fetchDb(`/domain-pages/${replaceId}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({json_text:text})}); notify("已更新页面 JSON"); }
- else { await fetchDb(`/domain-templates/${id}/pages`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description:"",json_text:text})}); notify("已上传页面 JSON"); }
+ else { await fetchDb(`/domain-templates/${id}/pages`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description:"",json_text:text,source:"upload",gen_filename:file.name,status:"uploaded"})}); notify("已上传页面 JSON"); }
await load(true);
}catch(e:any){ notify("上传失败:"+e.message); }
};
}
function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,onUploadPageJson,onSavePageMeta,onDeletePage,onGeneratePage,onDownloadPage,onPreviewPage,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;cubesAi:ReturnType<typeof useStreamComplete>;onRunCubesComplete:()=>Promise<void>;onViewCompleteCubesPrompt:()=>void;updatePageField:(i:number,patch:Partial<DomainPage>)=>void;tName:string;saving:boolean;notify:(m:string)=>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>;
+ return <span className="badge src-manual">手动</span>;
+ };
const [tab,setTab]=useState<"mu"|"cube"|"menu">("mu");
return <Panel><div className="tabs"><button className={tab==="mu"?"active":""} onClick={()=>setTab("mu")}>MU 标准页面 <i>{effSelected.length}</i></button><button className={tab==="cube"?"active":""} onClick={()=>setTab("cube")}>Cube 扩展页面 <i>{pages.length}</i></button><button className={tab==="menu"?"active":""} onClick={()=>setTab("menu")}>菜单结构</button></div>
{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="cube-editor"><table className="data-table editable cube-editor-table"><thead><tr><th>页面名称 <small>最多6字</small></th><th>页面描述</th><th>生成文件</th><th>操作</th></tr></thead><tbody>{pages.map((p,i)=><tr key={p.id}>
+ <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>
- <td>{p.status==="generated"? <button className="link" onClick={()=>onDownloadPage(p)}>下载 {p.gen_filename}</button> : <span className="muted">未生成</span>}</td>
+ <td>{sourceBadge(p.source)}</td>
+ <td>{p.gen_filename? <button className="link" onClick={()=>onDownloadPage(p)}>下载 {p.gen_filename}</button> : <span className="muted">未生成</span>}</td>
<td className="cube-actions"><button onClick={()=>onPreviewPage(p)}>预览</button><button onClick={()=>onGeneratePage(p)}>生成</button><button onClick={()=>onSavePageMeta(p)}>保存</button><button className="trash" onClick={()=>onDeletePage(p)}>删除</button></td>
</tr>)}</tbody></table></div>
<div style={{display:"flex",gap:8,marginTop:8}}><Button onClick={onAddPage}>+ 添加 Cube 页面</Button><label className="button secondary" style={{cursor:"pointer"}}>↑ 上传页面 Json<input type="file" accept=".json,application/json" hidden onChange={e=>{const f=e.target.files?.[0]; if(f) onUploadPageJson(f); e.target.value="";}}/></label></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"><DomainPageThumb jsonText={p.json_text} emptyLabel="未生成"/><div><Status>{p.status==="generated"?"已生成":"未生成"}</Status><h4>{p.name}</h4></div></article>)}</div>
+ <div className="domain-review-pages">{pages.length===0? <div className="muted">暂无 Cube 页面</div> : pages.map(p=><article key={p.id} className="domain-review-page"><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></div></article>)}</div>
<div style={{display:"flex",gap:8,marginTop:12}}><Button secondary onClick={onBack}>返回上一步</Button><Button onClick={onConfirm}>确认方案</Button></div>
</Panel>
</div></div>;
ensureColumn("domain_pages", "status", "TEXT DEFAULT 'pending'");
ensureColumn("domain_pages", "gen_filename", "TEXT");
ensureColumn("domain_pages", "prompt", "TEXT");
+ensureColumn("domain_pages", "source", "TEXT");
// GeoMap 中国地图底图:若 data/db/china-geo.json 存在(中国 34 省 + 九段线 FeatureCollection),
// 则在 fixDashboard 中自动注入 GeoMap 面板的 custom.dataConfig.geoJson,避免地图气泡不渲染。
const { json, prompt } = await generateCubeJson(ctx, cube, onEvent, (t) => emit({ log: t }));
const filename = `${safeFilename(page.name)}.json`;
const jsonText = JSON.stringify(json, null, 2);
- db.prepare("UPDATE domain_pages SET json_text=?, status='generated', gen_filename=?, prompt=? WHERE id=?").run(jsonText, filename, prompt, page.id);
+ db.prepare("UPDATE domain_pages SET json_text=?, status='generated', gen_filename=?, prompt=?, source='ai' WHERE id=?").run(jsonText, filename, prompt, page.id);
emit({ log: `✓ 「${page.name}」生成完成\n` });
emit({ task: page.name, status: "done" });
res.end();
emit({ task: `修改「${page.name}」`, status: "running" });
const { json } = await refineCubeJson(ctx, cube, currentJson, feedback, onEvent, (t) => emit({ log: t }));
const jsonText = JSON.stringify(json, null, 2);
- db.prepare("UPDATE domain_pages SET json_text=?, status='generated' WHERE id=?").run(jsonText, page.id);
+ db.prepare("UPDATE domain_pages SET json_text=?, status='generated', source='ai' WHERE id=?").run(jsonText, page.id);
emit({ log: `✓ 「${page.name}」修改完成\n` });
emit({ task: `修改「${page.name}」`, status: "done" });
res.end();
if (!name) return respondError(res, 400, "页面名称不能为空");
if (!body.json_text) return respondError(res, 400, "Cube JSON 不能为空");
try { JSON.parse(body.json_text); } catch { return respondError(res, 400, "Cube JSON 不是合法 JSON"); }
- const info = db.prepare("INSERT INTO domain_pages (domain_template_id,name,description,json_text) VALUES (?,?,?,?)").run(tmplId, name, body.description || "", body.json_text);
+ const source = body.source || "manual";
+ const gen_filename = body.gen_filename || null;
+ const status = body.status || (source === "upload" ? "uploaded" : "pending");
+ const info = db.prepare("INSERT INTO domain_pages (domain_template_id,name,description,json_text,status,gen_filename,source) VALUES (?,?,?,?,?,?,?)").run(tmplId, name, body.description || "", body.json_text, status, gen_filename, source);
respondJson(res, 200, { ok: true, id: info.lastInsertRowid });
}
<tr><td>模板信息</td><td>编辑所属领域 / 名称 / 版本 / 状态 / 创建人 / 描述</td><td>GET /api/db/domain-templates/:id 详情;PUT /api/db/domain-templates/:id 保存</td><td>「保存模板信息」写回并重新加载</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>领域页面(Cube 扩展页面)</td><td>编辑领域页面(=StationBuilder 的 Cube 扩展页面)</td><td>详情 pages[];POST /api/db/domain-templates/:id/pages;PUT / DELETE /api/db/domain-pages/:id;POST /api/db/domain-templates/:id/pages/:pid/generate、/refine(流式)</td><td>「功能与菜单 → Cube 扩展页面」表格逐行管理,列:页面名称(≤6 字)/ 页面描述 / 来源 / Json文件 / 操作;来源以徽标区分「↑ 上传 / ✦ AI生成 / 手动」;Json文件列对已上传或已生成的页面提供「下载 文件名」按钮(上传=原始 .json 文件本身,AI生成=生成的 Cube .json),未生成显示"未生成";支持行内编辑名称/描述、预览/生成/保存/删除、+ 添加页面、↑ 上传页面 Json;保存前校验 JSON 合法性;生成确认页对每页显示「AI已生成 / 已上传 / 未生成 · 来源」</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>
<tr><td>知识文档</td><td>管理知识文档(名称 + 本体)</td><td>详情 docs[];POST /api/db/domain-templates/:id/docs(doc_type=link 传 url,或 doc_type=file 传 base64 文件);GET /api/db/knowledge-docs/:id/file 下载;DELETE /api/db/knowledge-docs/:id</td><td>新增时选择「超链」(填 URL) 或「文件」(上传,落盘 <code>data/db/uploads/docs/</code>);列表可下载 / 删除</td></tr>
<tr><td>运维流程(OpsFlowsViewer)</td><td>查看运维流程(流程名称 + 内容 JSON 串)</td><td>GET /api/ops-flows</td><td>仅展示占位记录,本功能 <span class="todo">尚未实现</span></td></tr>
<tr><th>面板 / 区域</th><th>数据来源 / 接口</th><th>是否可编辑</th><th>编辑类型</th><th>上线范围</th></tr>
<tr><td>模板信息</td><td>GET/PUT /api/db/domain-templates/:id</td><td>可编辑</td><td>所属领域(select) / 模板名称 / 版本 / 状态(select) / 创建人 / 领域描述(textarea)</td><td>方案元信息,被站点方案引用</td></tr>
<tr><td>选用设备模版(DeviceTemplatePicker)</td><td>详情 <code>devices[]</code>;GET /api/db/device-templates;PUT /api/db/domain-templates/:id(<code>device_template_ids</code>,服务端重写关联)</td><td>可编辑(勾选即保存)</td><td>复选框列表</td><td>该方案选用的设备模版集合(供站点方案引用)</td></tr>
- <tr><td>领域页面(PageRow)</td><td>详情 <code>pages[]</code>;POST /api/db/domain-templates/:id/pages;PUT/DELETE /api/db/domain-pages/:id</td><td>可编辑</td><td>名称 / 说明 / Cube JSON(保存前校验合法 JSON)</td><td>方案内置页面(=StationBuilder 的 Cube)</td></tr>
+ <tr><td>领域页面(Cube 扩展页面)</td><td>详情 <code>pages[]</code>;POST /api/db/domain-templates/:id/pages;PUT/DELETE /api/db/domain-pages/:id;POST .../:pid/generate、/refine</td><td>可编辑</td><td>表格逐列:页面名称(≤6字)/ 页面描述 / 来源(徽标:上传·AI生成·手动)/ Json文件(已上传或已生成→「下载 文件名」;否则"未生成")/ 操作(预览·生成·保存·删除)</td><td>方案内置页面(=StationBuilder 的 Cube);source 字段区分来源,gen_filename 为下载文件名</td></tr>
<tr><td>模板菜单</td><td>详情 <code>menu[]</code>;POST /api/db/domain-templates/:id/menu;DELETE /api/db/template-menu-items/:id</td><td>可编辑(新增占位项)</td><td>表格(菜单项/类型/父级/关联)+ 删除</td><td>方案菜单结构</td></tr>
<tr><td>知识文档</td><td>详情 <code>docs[]</code>;POST /api/db/domain-templates/:id/docs;GET /api/db/knowledge-docs/:id/file;DELETE</td><td>可编辑</td><td>新增选「超链」(填 URL) 或「文件」(上传 base64);列表可下载/删</td><td>方案知识库</td></tr>
<tr><td>运维流程(OpsFlowsViewer)</td><td>GET /api/ops-flows</td><td>只读</td><td>占位展示</td><td><span class="todo">尚未实现</span></td></tr>
<table class="changelog">
<tr><th>版本</th><th>日期</th><th>修改人</th><th>说明</th></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>
+ <tr><td>v0.18</td><td>2026-08-20</td><td>wang.yu</td><td>领域方案「功能与菜单 → Cube 扩展页面」表格增强:① 新增「来源」列,以徽标区分 上传 / AI生成 / 手动;② 原「生成文件」列更名为「Json文件」,对上传页面提供原始 .json 文件名下载、对 AI 生成页面提供生成的 Cube .json 下载(未生成显示"未生成");③ domain_pages 新增 source 字段,上传/生成时分别写入 source=upload/ai 与 gen_filename;④ 生成确认页状态徽标区分「AI已生成 / 已上传 / 未生成 · 来源」。同步更新 5.10 章节。</td></tr>
<tr><td>v0.16</td><td>2026-08-19</td><td>wang.yu</td><td>设计方案文档修订:① 左侧目录改为任意宽度常驻可见(移除窄屏 <code>display:none</code>,窄屏下列表转为顶部可滚动常驻栏),点击章节后目录不再消失;② 移除「内部登录」章节(5.1)及 5.13 登录截图,可达性说明去掉登录;③ 新增 AI 智能补全子章(5.3.2 / 5.4.2 / 5.5.2),附 AI 流式输出终端截图(ai-complete-basic/assets/functions.png);④ 修复 PDF 导出图片被分页截断(<code>@media print</code> 增加 <code>page-break-inside:avoid</code> 与图片适配);⑤ 修复 Word 导出无图片(<code>downloadWord</code> 将图片转为 base64 data URI 内嵌)。</td></tr>
<tr><td>v0.15</td><td>2026-08-19</td><td>wang.yu</td><td>领域方案库列表增强:列顺序调整为「所属领域 → 方案名称 → 版本 → 状态 → 创建人 → 设备/页面/文档 → 操作」,版本/状态列收窄、所属领域/方案名称列加宽;新增筛选栏(查找 方案名称/创建人 + 所属领域下拉 + 状态下拉 + 条数)与可排序列头(所属领域/方案名称/状态/创建人,升降切换带 ▲▼),卡片视图复用同结果。</td></tr>
<tr><td>v0.14</td><td>2026-08-19</td><td>wang.yu</td><td>左侧目录(TOC)贴齐浏览器最左侧:<code>.doc-layout</code> 取消 <code>margin:0 auto</code> 自动居中(改为 <code>margin:0; padding-left:12px</code>),宽屏下不再左右留白,目录紧贴视口左缘。</td></tr>