basic:{label:"站点基本属性",icon:"▤",subtitle:"描述站点业务背景,信息越完整,生成结果越符合预期"},
assets:{label:"站点资产设置",icon:"▧",subtitle:"通过快速配置或手动配置建立场站资产结构"},
pageConfig:{label:"页面配置",icon:"",subtitle:""},
- functions:{label:"功能与菜单",icon:"☷",subtitle:"选择 MU 标准页面,定义 Cube 扩展页面及菜单结构"},
+ functions:{label:"菜单设置",icon:"☷",subtitle:"选择 MU 标准页面,定义 Cube 扩展页面及菜单结构"},
generate:{label:"生成确认",icon:"↻",subtitle:"确认生成范围,调用 Cube Skill 生成页面和模拟数据"},
result:{label:"站点结果",icon:"◫",subtitle:"集中查看、预览、校验并下载站点交付物"},
preview:{label:"Cube 页面预览",icon:"▣",subtitle:"检查单个 Cube 页面效果,并通过反馈快速重新生成"},
id:number; name:string; domain:string; description:string; supplement:string; status:string;
style_id?: number | null;
parent_id?: number | null;
+ app_name?: string; app_icon?: string; app_icon_filename?: string;
created_at:string; updated_at:string;
assets: Asset[]; cubes: Cube[]; mu: string[]; menu: MenuItem[]; files: ReferenceFile[];
};
</tr>})}</tbody>
</table>
</Panel>
- <Panel title="资产层级预览"><div className="asset-tree-preview">
- <div className="tree-root">⌄ <b>{site.name || "未命名站点"}</b></div>
- {rows.filter(r=>r.type).map((row,i)=><div key={i} className="tree-branch">
- <div className="tree-node">⌄ {row.type} <span className="tree-count">{row.count}</span></div>
- {row.count>0&&<div className="tree-leaves">{formatInstances(row.rule, row.count)}</div>}
- </div>)}
- </div></Panel>
- </div>
- <div className="stack">
- <Panel title="填写建议"><ul className="tips"><li>列出站点可能涉及的主要设备类型(泵、风机、换热器、仪表等)。</li><li>为每种设备估算典型数量与命名规则。</li><li>命名规则用 <code>#</code> 表示序号,如 <code>CH-##</code> 生成 CH-01/CH-02;纯前缀如 <code>beng-</code> 会自动补 -01/-02。</li><li>资产层级按「站点 → 类型 → 实例」三级展开预览。</li></ul></Panel>
- <Panel title="AI 智能补全">
- <p className="hint">基于站点名称 / 领域 / 描述 / 补充要求,由 AI 分析并补充设备类型、数量与命名规则到资产配置列表(位于您已填项下方)。</p>
- <div style={{display:"flex",gap:"8px",alignItems:"center"}}>
- <button className="button" disabled={completing} onClick={runCompleteAssets}>{completing?"补全中…":"开始补全"}</button>
- <button className="text-button" onClick={onViewCompletePrompt}>查看提示词</button>
- </div>
- {(completing||completeLog)&&<div className="modal-console"><div className="modal-console-head">AI 智能补全</div><pre className="modal-console-body" ref={completeConsoleRef}>{completeLog||"(等待 AI 分析…)"}</pre></div>}
- </Panel>
- {saving&&<Panel><div className="empty-state">正在保存…</div></Panel>}
</div>
</div>}
</div>;
}
function Functions({site,muTree,onMuChange,onSiteChange,pageStyles,notify,onViewCompletePrompt}:{site:SiteDetail|null;muTree:MuNode[];onMuChange:(s:string[])=>void;onSiteChange:(s:SiteDetail)=>void;pageStyles:PageStyle[];notify:(m:string)=>void;onViewCompletePrompt:()=>void}){
- const [tab,setTab]=useState<"mu"|"cube"|"menu">("cube");
const leaves = useMemo(()=>buildMuLeaves(muTree),[muTree]);
const allMuKeys = useMemo(()=>leaves.map(l=>l.key),[leaves]);
const selectedMu = site?.mu || [];
- // 默认全选中:site.mu 为空时按全部叶子键显示,首次勾选才落地保存
const effSelected = selectedMu.length ? selectedMu : allMuKeys;
+ const [appName,setAppName]=useState(site?.app_name||"");
+ const [appIcon,setAppIcon]=useState<string|undefined>(site?.app_icon);
+ const [appIconFile,setAppIconFile]=useState<string|undefined>(site?.app_icon_filename);
if(!site) return <Panel><div className="empty-state">加载中…</div></Panel>;
- const updateCube=(idx:number, patch:Partial<Cube>)=>{
- const next = site.cubes.map((c,i)=>i===idx?{...c,...patch}:c);
- onSiteChange({...site, cubes:next});
- };
- const saveCube=async(idx:number)=>{
- const cube = site.cubes[idx];
- try{
- if(cube.id){
- await fetchDb(`/sites/${site.id}/cubes/${cube.id}`, {method:"PUT", headers:{"Content-Type":"application/json"}, body:JSON.stringify(cube)});
- } else {
- const created = await fetchDb(`/sites/${site.id}/cubes`, {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(cube)}) as Cube;
- const next = [...site.cubes]; next[idx] = {...cube, id:created.id!}; onSiteChange({...site, cubes:next});
- }
- notify("Cube 页面已保存");
- }catch(e:any){ notify("保存失败: "+e.message); }
- };
- const addCube=()=>onSiteChange({...site, cubes:[...site.cubes, {name:"",content:"",status:"pending",sort_order:site.cubes.length}]});
- const removeCube=async(idx:number)=>{
- const cube = site.cubes[idx];
- if(cube.id) try{ await fetchDb(`/sites/${site.id}/cubes/${cube.id}`, {method:"DELETE"}); }catch(e:any){ notify("删除失败: "+e.message); return; }
- onSiteChange({...site, cubes:site.cubes.filter((_,i)=>i!==idx)});
- };
- const selectStyle=async(id:number|null)=>{
- try{
- const refreshed:SiteDetail = await fetchDb(`/sites/${site.id}`, {method:"PUT", headers:{"Content-Type":"application/json"}, body:JSON.stringify({
- name:site.name, domain:site.domain, description:site.description, supplement:site.supplement, style_id: id
- })}) as SiteDetail;
- onSiteChange(refreshed);
- notify(id ? "已应用页面样式" : "已清除页面样式");
- }catch(e:any){ notify("应用样式失败: "+e.message); }
- };
- const generateCube=async(idx:number)=>{
- const cube = site.cubes[idx];
- if(!cube.id) await saveCube(idx);
- notify(`开始生成 ${cube.name}…`);
- try{
- await fetchDb(`/sites/${site.id}/cubes/${cube.id}/generate`, {method:"POST"});
- notify(`${cube.name} 生成完成`);
- const refreshed:SiteDetail = await fetchDb(`/sites/${site.id}`);
- onSiteChange(refreshed);
- }catch(e:any){ notify("生成失败: "+e.message); }
- };
- const downloadCube=(cube:Cube)=>{
- if(!cube.id) return;
- fetch(`/api/db/sites/${site.id}/cubes/${cube.id}/file`).then(r=>r.blob()).then(b=>downloadBlob(b, cube.gen_filename || `${cube.name}.json`)).catch(e=>notify("下载失败"));
- };
- const previewCube=(cube:Cube)=>{
- notify(`预览 ${cube.name}`);
+ const toggleMu=(key:string)=>{
+ const base = selectedMu.length ? selectedMu : allMuKeys;
+ onMuChange(base.includes(key)? base.filter(k=>k!==key) : [...base, key]);
};
-
- // ===== AI Cube 页面补全 =====
- const [completing,setCompleting]=useState(false);
- const [completeLog,setCompleteLog]=useState("");
- const completeConsoleRef=useRef<HTMLPreElement>(null);
- useEffect(()=>{ if(completeConsoleRef.current) completeConsoleRef.current.scrollTop=completeConsoleRef.current.scrollHeight; },[completeLog]);
- const appendCubes=(items:Array<{name:string;content:string}>)=>{
- const base = site?.cubes || [];
- const next = [...base, ...items.map((it,i)=>({name:it.name, content:it.content||"", status:"pending", sort_order: base.length+i}))];
- onSiteChange({...site, cubes:next});
- notify(`AI 已补充 ${items.length} 个 Cube 页面(位于您已填项下方)`);
+ const pickAppIcon=(e:React.ChangeEvent<HTMLInputElement>)=>{
+ const f=e.target.files?.[0]; if(!f) return;
+ if(!["image/png","image/svg+xml","image/jpeg"].includes(f.type)||f.size>100*1024){ notify("仅支持 png/svg 格式,且小于 100KB"); return; }
+ const reader=new FileReader(); reader.onload=()=>{ setAppIcon(reader.result as string); setAppIconFile(f.name); }; reader.readAsDataURL(f);
};
- const runCompleteCubes=async()=>{
- if(!site?.id || completing) return;
- setCompleting(true); setCompleteLog("");
- const watchdog=setTimeout(()=>{ setCompleting(false); notify("AI Cube 补全超时(>6 分钟),请重试"); }, 360000);
+ const saveAppInfo=async()=>{
try{
- const res=await fetch(`/api/db/sites/${site.id}/complete-cubes`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:site.name,domain:site.domain,description:site.description,supplement:site.supplement,assets:site.assets})});
- if(!res.ok||!res.body) throw new Error("HTTP "+res.status);
- const reader=res.body.getReader(); const dec=new TextDecoder(); let buf="";
- 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) setCompleteLog(prev=>prev+ev.log);
- if(Array.isArray(ev.result)) appendCubes(ev.result);
- }catch{}
- }
- }
- notify("AI Cube 页面补全已完成");
- }catch(e:any){ notify("AI Cube 页面补全失败:"+(e?.message||e)); }
- finally{ clearTimeout(watchdog); setCompleting(false); }
+ await fetchDb(`/sites/${site.id}`, {method:"PUT", headers:{"Content-Type":"application/json"}, body:JSON.stringify({
+ name:site.name, domain:site.domain, description:site.description, supplement:site.supplement,
+ style_id:site.style_id, parent_id:site.parent_id,
+ app_name: appName, app_icon: appIcon, app_icon_filename: appIconFile
+ })});
+ onSiteChange({...site, app_name: appName, app_icon: appIcon, app_icon_filename: appIconFile});
+ notify("应用信息已保存");
+ }catch(e:any){ notify("保存失败: "+e.message); }
};
- return <Panel><div className="tabs"><button className={tab==="cube"?"active":""} onClick={()=>setTab("cube")}>领域扩展页面 <i>{site.cubes.length}</i></button><button className={tab==="menu"?"active":""} onClick={()=>setTab("menu")}>菜单结构</button></div>
- {/* Cube 扩展页面:左右两栏,右栏为填写建议 + AI 智能补全 */}
- {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>{site.cubes.map((cube,i)=><tr key={i}>
- <td><input value={cube.name} maxLength={6} placeholder="例如:运行总览" onChange={e=>updateCube(i,{name:e.target.value})}/></td>
- <td><input value={cube.content} placeholder="描述该页面要展示的内容和指标" onChange={e=>updateCube(i,{content:e.target.value})}/></td>
- <td>{cube.status==="generated"? <button className="link" onClick={()=>downloadCube(cube)}>下载 {cube.gen_filename}</button> : <span className="muted">未生成</span>}</td>
- <td className="cube-actions"><button onClick={()=>previewCube(cube)}>预览</button><button className="btn-ai" onClick={()=>generateCube(i)}>AI生成</button><button onClick={()=>saveCube(i)}>保存</button><button className="trash" onClick={()=>removeCube(i)}>删除</button></td>
- </tr>)}</tbody></table><Button onClick={addCube}>+ 添加 Cube 页面</Button></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"+(!site.style_id?" active":"")} onClick={()=>selectStyle(null)}>
- <div className="style-pick-thumb none">无</div><span>不使用样式</span>
- </button>
- {pageStyles.map(s=><button key={s.id} className={"style-pick"+(site.style_id===s.id?" active":"")} onClick={()=>selectStyle(s.id)}>
- <div className="style-pick-thumb"><img src={`/api/db/page-styles/${s.id}/image?w=360`} alt={s.name} loading="lazy"/></div>
- <span>{s.name}</span>
- </button>)}
- </div>
+ // 设备运维页面(复用 pageConfig-设备运维 的 MU 表格)
+ const opLeaves = useMemo(()=>buildMuLeaves(muTree),[muTree]);
+ const opGrouped = useMemo(()=>{
+ const map=new Map<string,{parentMenu:string,items:typeof opLeaves}>();
+ for(const l of opLeaves){ if(!map.has(l.parentMenu)) map.set(l.parentMenu,{parentMenu:l.parentMenu,items:[]}); map.get(l.parentMenu)!.items.push(l); }
+ return Array.from(map.values());
+ },[opLeaves]);
+
+ // 仪表盘示例名称
+ const dashboardExamples = ["运行总览","能耗分析","设备健康度","报警事件"];
+ // 数字孪生场景示例名称
+ const twinExamples = ["3D 厂区漫游","设备数字孪生","管线可视化","实时数据大屏"];
+
+ return <Panel>
+ {/* 应用信息 */}
+ <div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:"16px",marginBottom:"16px"}}>
+ <Field label="应用名称"><input value={appName} placeholder="例如:智慧水务平台" onChange={e=>setAppName(e.target.value)} onBlur={saveAppInfo}/></Field>
+ <Field label="应用Icon" hint="支持 png、svg,小于 100KB">
+ <div style={{display:"flex",alignItems:"center",gap:"10px"}}>
+ {appIcon?<img src={appIcon} alt="应用图标" style={{width:40,height:40,borderRadius:6,objectFit:"cover",border:"1px solid #ddd"}}/>:<div style={{width:40,height:40,borderRadius:6,border:"1px dashed #ddd",display:"flex",alignItems:"center",justifyContent:"center",color:"#bbb",fontSize:"12px"}}>无</div>}
+ <label className="text-button" style={{cursor:"pointer"}}>选择文件<input type="file" accept=".png,.svg,.jpeg,image/png,image/svg+xml" hidden onChange={pickAppIcon}/></label>
+ {appIconFile&&<span className="muted">{appIconFile}</span>}
</div>
- </div>
- <div className="stack">
- <Panel title="填写建议"><ul className="tips"><li>从站点业务与设备资产出发,规划需要的监控维度(综合总览、能耗能效、设备健康、报警事件、环境监测、生产产量等)。</li><li>每个 Cube 页面写明:页面目标、关键指标/图表、数据来源与模拟数据构造思路。</li><li>页面名称简洁,不超过 6 个汉字。</li><li>AI 补充的页面会追加在您已创建页面下方,不会改动您原有的内容。</li></ul></Panel>
- <Panel title="AI 智能补全">
- <p className="hint">基于站点名称 / 领域 / 描述 / 补充要求,以及设备资产层级,由 AI 分析并补充站点所需的 Cube 扩展页面(页面名称 + 详细内容),追加在您已建页面下方。</p>
- <div style={{display:"flex",gap:"8px",alignItems:"center"}}>
- <button className="button" disabled={completing} onClick={runCompleteCubes}>{completing?"补全中…":"开始补全"}</button>
- <button className="text-button" onClick={onViewCompletePrompt}>查看提示词</button>
- </div>
- {(completing||completeLog)&&<div className="modal-console"><div className="modal-console-head">AI 智能补全</div><pre className="modal-console-body" ref={completeConsoleRef}>{completeLog||"(等待 AI 分析…)"}</pre></div>}
- </Panel>
- </div>
- </div>}
- {/* 菜单结构:只读默认预览(MU 在上,Cube 全部归入「综合监控」一级菜单) */}
- {tab==="menu"&&<div className="menu-preview">
- <p className="hint">默认菜单结构(展示用,无需编辑):MU 标准页面在上,所有 Cube 扩展页面归入「综合监控」一级菜单下。</p>
- <div className="menu-tree">
- <div className="menu-root">⌄ {site.name || "未命名站点"}</div>
- <div className="menu-section">
- <div className="menu-section-title">MU 标准页面</div>
- {leaves.filter(l=>effSelected.includes(l.key)).map(l=><div key={l.key} className="menu-leaf">· {l.parentMenu}/{l.label}</div>)}
- {leaves.filter(l=>effSelected.includes(l.key)).length===0&&<div className="menu-leaf muted">(未选择 MU 页面)</div>}
- </div>
- <div className="menu-section">
- <div className="menu-section-title">综合监控</div>
- {site.cubes.filter(c=>c.name&&c.name.trim()).map(c=><div key={c.id||c.name} className="menu-leaf cube">· {c.name}</div>)}
- {site.cubes.filter(c=>c.name&&c.name.trim()).length===0&&<div className="menu-leaf muted">(暂无 Cube 页面)</div>}
- </div>
- </div>
- </div>}
+ </Field>
+ </div>
+
+ {/* 设备运维页面 */}
+ <div style={{marginTop:"20px"}}>
+ <h3 style={{fontSize:"15px",fontWeight:600,marginBottom:"10px"}}>设备运维页面</h3>
+ <div className="mu-tree">{opGrouped.map(g=><div key={g.parentMenu} className="mu-group"><b>{g.parentMenu}</b><div className="mu-items">{g.items.map(item=><label key={item.key} className={item.disabled?"disabled":""}><input type="checkbox" disabled={item.disabled} checked={effSelected.includes(item.key)} onChange={()=>toggleMu(item.key)}/><span>{item.label}</span></label>)}</div></div>)}</div>
+ </div>
+
+ {/* 仪表盘页面 */}
+ <div style={{marginTop:"20px"}}>
+ <h3 style={{fontSize:"15px",fontWeight:600,marginBottom:"10px"}}>仪表盘页面</h3>
+ <p className="hint">以下为预设仪表盘模板示例(Demo),实际内容待配置。</p>
+ <div style={{display:"flex",flexWrap:"wrap",gap:"8px"}}>{dashboardExamples.map((name,i)=><div key={i} style={{padding:"8px 14px",border:"1px solid #d9dee2",borderRadius:"4px",fontSize:"13px",color:"#555"}}>{name}</div>)}</div>
+ </div>
+
+ {/* 数字孪生场景 */}
+ <div style={{marginTop:"20px"}}>
+ <h3 style={{fontSize:"15px",fontWeight:600,marginBlur:"10px"}}>数字孪生场景</h3>
+ <p className="hint">以下为预设数字孪生场景示例(Demo),实际内容待配置。</p>
+ <div style={{display:"flex",flexWrap:"wrap",gap:"8px"}}>{twinExamples.map((name,i)=><div key={i} style={{padding:"8px 14px",border:"1px solid #d9dee2",borderRadius:"4px",fontSize:"13px",color:"#555"}}>{name}</div>)}</div>
+ </div>
</Panel>;
}