]> acesimba.cloud Git - StationBuilder.git/commitdiff
fix: 修复资产配置布局、提示词格式与 Cube 生成卡死/空JSON 问题
authorwang.yu <wang.yu@advantech.com>
Sat, 15 Aug 2026 10:36:05 +0000 (18:36 +0800)
committerwang.yu <wang.yu@advantech.com>
Sat, 15 Aug 2026 10:36:05 +0000 (18:36 +0800)
本次提交修复三处问题,均已用真实接口验证:

1. 资产配置页输入框被压窄(布局变形)
   - .data-table.editable 改为 table-layout:fixed 并明确列宽
   - 设备类型/命名规则输入框稳定撑满单元格,删除按钮补样式

2. 生成提示词:格式不符预期 + 未保存 Cube 点不开
   - 站点级与页面级提示词改为中文指令格式(学习领域特点、
     生成可导入 Cube 的 JSON、含合理模拟数据),与用户预期一致
   - 新增 GET /api/sites/:id/cubes/prompt?name=&content= 端点,
     未保存(无 id)的 Cube 也能查看提示词,修复点击无反应

3. Cube 生成卡死 / 生成出空 JSON([])—— 两个真正根因
   - 根因A:generate-all 只读数据库 cubes 表,未保存的草稿 Cube
     不会生成,包里只剩 menu-config.json(即用户看到的 [])。
     修复:generate-all 读取请求体 cubes 并先 upsert 入库再生成;
     前端 start() 一并提交 site.cubes,无需先点保存。
   - 根因B:codebuddy 默认开启高强度思考,单页生成 >5 分钟仍不结束,
     Cube 永远 pending。修复:runCodebuddy 增加 --effort minimal
     (单页约 1 分钟产出合法 JSON)+ 300s 超时保护(超时即终止并
     报错,避免 UI 无限转圈)。

验证:未保存 Cube『设备监控』(无 id) 自动建库并生成成功;
已保存 Cube『运行总览』生成 39KB 合法仪表盘 JSON,状态 generated。
注:生成质量(布局/数据合理性)仍待人工复核,但已能端到端产出
有效 Cube JSON,解决了此前 commit 中记录的『AI 创建功能还没有成功』。

app/globals.css
app/page.tsx
db-sidecar.mjs

index 8cb29473198aff72e6f7e03b955e2ffca6fdb7dc..394705d4c01ab9f6e9154bb72b01531e3124203e 100644 (file)
 /* ===== 可编辑表格内的输入控件 ===== */
 .data-table input,.data-table select,.data-table textarea{border:1px solid #d9dee2;border-radius:3px;height:28px;padding:0 8px;width:100%;background:#fff;font-size:12px}
 .data-table input:focus,.data-table select:focus,.data-table textarea:focus{border-color:var(--blue);outline:none}
-.data-table .number{width:90px;text-align:right}
+.data-table .number{width:100%;text-align:right}
 .data-table input::placeholder{color:#b9bfc3}
+/* 可编辑表格使用固定列宽,避免输入框在自动布局下被压窄(设备类型框变窄的 BUG) */
+.data-table.editable{table-layout:fixed}
+.data-table.editable th:nth-child(1){width:40%}
+.data-table.editable th:nth-child(2){width:92px}
+.data-table.editable th:nth-child(3){width:34%}
+.data-table.editable th:nth-child(4){width:44px}
+.data-table.editable input,.data-table.editable select,.data-table.editable textarea{min-width:0;width:100%}
+.data-table.editable .trash{border:0;background:transparent;color:var(--red);font-size:15px;cursor:pointer;line-height:1}
+.data-table.editable .trash:hover{color:#d63723}
 
 /* ===== Assets 资产配置 ===== */
 .empty-row td{background:#fafbfc}
index b8648883ba584a3b0789f85cf3310a3c2858f867..cb322d6bcc6af262558aa2cb02444729b466e569 100644 (file)
@@ -129,11 +129,13 @@ export default function Home(){
 
   const notify=(m:string)=>{setToast(m);window.setTimeout(()=>setToast(""),2200);};
 
-  const viewPrompt=async(cubeId:number,name:string)=>{
+  const viewPrompt=async(cubeId:number|undefined,name:string,content?:string)=>{
     const sid = site?.id ?? siteId;
     if(sid==null) { notify("请先创建或打开一个站点"); return; }
     try{
-      const data:{prompt:string} = await fetchDb(`/sites/${sid}/cubes/${cubeId}/prompt`);
+      const data:{prompt:string} = cubeId!=null
+        ? await fetchDb(`/sites/${sid}/cubes/${cubeId}/prompt`)
+        : await fetchDb(`/sites/${sid}/cubes/prompt?${new URLSearchParams({name:name||"", content:content||""}).toString()}`);
       setPromptData({name, text:data.prompt});
       setModal("prompt");
     }catch(e:any){ notify("读取提示词失败: "+e.message); }
@@ -501,7 +503,7 @@ function Functions({site,muTree,onMuChange,onSiteChange,notify}:{site:SiteDetail
   </Panel>;
 }
 
-function Generate({site,onSiteChange,notify,viewPrompt,viewSitePrompt}:{site:SiteDetail|null;onSiteChange:(s:SiteDetail)=>void;notify:(m:string)=>void;viewPrompt:(cubeId:number,name:string)=>void;viewSitePrompt:()=>void}){
+function Generate({site,onSiteChange,notify,viewPrompt,viewSitePrompt}:{site:SiteDetail|null;onSiteChange:(s:SiteDetail)=>void;notify:(m:string)=>void;viewPrompt:(cubeId:number|undefined,name:string,content?:string)=>void;viewSitePrompt:()=>void}){
   const [running,setRunning]=useState(false);
   const [tasks,setTasks]=useState<{name:string;status:"pending"|"running"|"done"|"failed";message?:string}[]>([]);
   const [progress,setProgress]=useState(0);
@@ -520,7 +522,7 @@ function Generate({site,onSiteChange,notify,viewPrompt,viewSitePrompt}:{site:Sit
     totalRef.current = initial.length;
     setTasks(initial); setProgress(0);
     try{
-      const res = await fetch(`/api/db/sites/${site.id}/generate-all`, {method:"POST"});
+      const res = await fetch(`/api/db/sites/${site.id}/generate-all`, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({ cubes: site.cubes.map(c=>({id:c.id, name:c.name, content:c.content})) }) });
       if(!res.ok||!res.body) throw new Error("HTTP "+res.status);
       const reader = res.body.getReader(); const dec = new TextDecoder(); let buf="";
       for(;;){
@@ -551,7 +553,7 @@ function Generate({site,onSiteChange,notify,viewPrompt,viewSitePrompt}:{site:Sit
     }catch(e:any){ notify("生成失败: "+e.message); }
     finally{ setRunning(false); }
   };
-  return <div className="generate-layout"><div><Panel title="生成范围确认"><div className="confirm-grid">{scope.map(x=><div key={x[0]}><span>{x[0]}</span><b>{x[1]}</b><small>{x[2]}</small><button>{"返回修改"}</button></div>)}</div></Panel><Panel title="页面生成提示词(生成前可核对)"><div className="prompt-list"><div className="prompt-row"><span><b>站点级提示词(通用上下文)</b><small>每个页面都会携带的站点背景与 Cube 规则,可先核对</small></span><button className="text-button" onClick={viewSitePrompt}>查看提示词 →</button></div>{site.cubes.length?site.cubes.map(c=><div key={c.id} className="prompt-row"><span><b>{c.name}</b>{c.content&&<small>{c.content}</small>}</span><button className="text-button" onClick={()=>c.id&&viewPrompt(c.id!, c.name)}>查看提示词 →</button></div>):<div className="empty-state">尚未添加 Cube 扩展页面(在「功能与菜单」中添加后,这里会列出每个页面的提示词)</div>}</div></Panel><Panel title="生成选项"><div className="option-list">{[
+  return <div className="generate-layout"><div><Panel title="生成范围确认"><div className="confirm-grid">{scope.map(x=><div key={x[0]}><span>{x[0]}</span><b>{x[1]}</b><small>{x[2]}</small><button>{"返回修改"}</button></div>)}</div></Panel><Panel title="页面生成提示词(生成前可核对)"><div className="prompt-list"><div className="prompt-row"><span><b>站点级提示词(通用上下文)</b><small>每个页面都会携带的站点背景与 Cube 规则,可先核对</small></span><button className="text-button" onClick={viewSitePrompt}>查看提示词 →</button></div>{site.cubes.length?site.cubes.map(c=><div key={c.id} className="prompt-row"><span><b>{c.name}</b>{c.content&&<small>{c.content}</small>}</span><button className="text-button" onClick={()=>viewPrompt(c.id, c.name, c.content)}>查看提示词 →</button></div>):<div className="empty-state">尚未添加 Cube 扩展页面(在「功能与菜单」中添加后,这里会列出每个页面的提示词)</div>}</div></Panel><Panel title="生成选项"><div className="option-list">{[
       {label:"生成 Asset Excel", disabled:true, note:"未开发"},
       {label:"生成菜单配置", disabled:false},
       {label:"生成 Cube JSON", disabled:false},
@@ -562,12 +564,12 @@ function Generate({site,onSiteChange,notify,viewPrompt,viewSitePrompt}:{site:Sit
     <Panel title="生成任务" className="task-panel"><div className="overall"><div><b>{progress===100?"生成完成":running?"正在生成站点方案":"准备就绪"}</b><span>{progress}%</span></div><div className="meter"><i style={{width:progress+"%"}}/></div><p>{progress===100?"全部交付物已生成并通过校验。":running?"Wordbuddy Hy3 正在调用 Cube Skill 创建页面与模拟数据。":"Wordbuddy Hy3 已就绪,预计需要约 2 分钟。"}</p></div><div className="task-list">{tasks.map((x,i)=><div key={i}><i className={x.status==="done"?"done":x.status==="running"?"spin":x.status==="failed"?"bad":""}>{x.status==="done"?"✓":x.status==="running"?"↻":x.status==="failed"?"!":"·"}</i><span><b>{x.name}</b>{x.message&&<small>{x.message}</small>}</span><Status>{x.status==="done"?"成功":x.status==="running"?"生成中":x.status==="failed"?"失败":"等待"}</Status></div>)}</div><Button disabled={running} onClick={start}>{progress===100?"重新生成":running?"正在生成…":"开始生成"}</Button>{zipUrl&&<Button secondary onClick={()=>window.open(zipUrl,"_blank")}>⇩ 下载 ZIP</Button>}</Panel></div>;
 }
 
-function Result({site,go,setModal,viewPrompt}:{site:SiteDetail|null;go:(p:PageKey)=>void;setModal:(m:ModalKey)=>void;viewPrompt:(cubeId:number,name:string)=>void}){
+function Result({site,go,setModal,viewPrompt}:{site:SiteDetail|null;go:(p:PageKey)=>void;setModal:(m:ModalKey)=>void;viewPrompt:(cubeId:number|undefined,name:string,content?:string)=>void}){
   if(!site) return <Panel><div className="empty-state">加载中…</div></Panel>;
   const generated = site.cubes.filter(c=>c.status==="generated");
   return <><div className="result-header"><div><span className="site-icon big">⬡</span><section><h2>{site.name || "未命名站点"}</h2><p>{site.status==="generated"?"生成完成":"草稿"} · {site.updated_at}</p></section><Status>{site.status==="generated"?"生成完成":"草稿"}</Status></div><div><Button secondary onClick={()=>setModal("history")}>版本历史</Button><Button secondary onClick={()=>go("assets")}>修改配置</Button><Button onClick={()=>window.open(`/api/db/sites/${site.id}/package`,`_blank`)}>⇩ 下载全部</Button></div></div>
   <div className="kpi-row">{[[site.assets.filter(a=>a.type).length,"Asset 资产","类设备"],[site.mu.length,"MU 页面","已配置"],[generated.length,"Cube 页面",generated.length===site.cubes.length?"校验全部通过":"部分未生成"],[generated.length+1,"交付文件","JSON+ZIP"]].map(x=><div key={x[1]}><span>{x[1]}</span><b>{x[0]}</b><small>{x[2]}</small></div>)}</div>
-  <div className="result-layout"><div><Panel title="页面结果" tools={<Button secondary onClick={()=>setModal("feedback")}>✎ 提出修改意见</Button>}><div className="result-pages">{site.cubes.map((cube,i)=><article key={i}><div className={"dashboard-thumb d"+(i%3)}><i/><header/><section><b/><b/><b/></section><footer><em/><em/><em/><em/><em/></footer><span/></div><div><header><Status>{cube.status==="generated"?"校验通过":"未生成"}</Status><small>Cube</small></header><h3>{cube.name}</h3><p>{cube.content || "无描述"}</p><footer><Button secondary onClick={()=>{}}>预览</Button>{cube.id&&<Button secondary onClick={()=>fetch(`/api/db/sites/${site.id}/cubes/${cube.id}/file`).then(r=>r.blob()).then(b=>downloadBlob(b, cube.gen_filename||`${cube.name}.json`))}>⇩ JSON</Button>}{cube.id&&<Button secondary onClick={()=>viewPrompt(cube.id!, cube.name)}>提示词</Button>}</footer></div></article>)}</div></Panel></div></div></>;
+  <div className="result-layout"><div><Panel title="页面结果" tools={<Button secondary onClick={()=>setModal("feedback")}>✎ 提出修改意见</Button>}><div className="result-pages">{site.cubes.map((cube,i)=><article key={i}><div className={"dashboard-thumb d"+(i%3)}><i/><header/><section><b/><b/><b/></section><footer><em/><em/><em/><em/><em/></footer><span/></div><div><header><Status>{cube.status==="generated"?"校验通过":"未生成"}</Status><small>Cube</small></header><h3>{cube.name}</h3><p>{cube.content || "无描述"}</p><footer><Button secondary onClick={()=>{}}>预览</Button>{cube.id&&<Button secondary onClick={()=>fetch(`/api/db/sites/${site.id}/cubes/${cube.id}/file`).then(r=>r.blob()).then(b=>downloadBlob(b, cube.gen_filename||`${cube.name}.json`))}>⇩ JSON</Button>}{cube.id&&<Button secondary onClick={()=>viewPrompt(cube.id, cube.name, cube.content)}>提示词</Button>}</footer></div></article>)}</div></Panel></div></div></>;
 }
 
 function Preview({site,tab,setTab,setModal,notify}:{site:SiteDetail|null;tab:"info"|"json"|"feedback";setTab:(t:"info"|"json"|"feedback")=>void;setModal:(m:ModalKey)=>void;notify:(m:string)=>void}){
index e6b600c3fb4a556b575b6450270bcaf03fc4d66a..3b12d2b0358e3317b45d011344a081a6f8fa1458 100644 (file)
@@ -326,6 +326,7 @@ function runCodebuddy(prompt, sessionId) {
     const args = [
       "--model", "hy3",
       "--session-id", sessionId,
+      "--effort", "minimal",
       "-p", prompt,
       "--output-format", "stream-json",
       "--include-partial-messages",
@@ -335,6 +336,10 @@ function runCodebuddy(prompt, sessionId) {
     const child = spawn(CODEBUDDY, args, { env: process.env, cwd: CWD });
     let buf = "";
     let fullText = "";
+    const timer = setTimeout(() => {
+      child.kill("SIGKILL");
+      reject(new Error("codebuddy 生成超时(>300s)"));
+    }, 300000);
     const push = (line) => {
       if (!line.trim()) return;
       try {
@@ -355,6 +360,7 @@ function runCodebuddy(prompt, sessionId) {
     child.stderr.on("data", c => push(c.toString("utf-8")));
     child.on("error", reject);
     child.on("close", code => {
+      clearTimeout(timer);
       if (buf.trim()) push(buf);
       if (code && code !== 0) return reject(new Error("codebuddy exit " + code));
       resolve(fullText);
@@ -374,34 +380,34 @@ async function extractJson(text) {
   return JSON.parse(text);
 }
 
-function buildCubePrompt(site, cube) {
-  const assetDesc = (site.assets || []).filter(a => a.type).map(a => `${a.type} × ${a.count}${a.rule ? ` (${a.rule})` : ""}`).join(";") || "无";
-  return `Generate a Cube dashboard JSON for the following page.
-
-Site context:
+function assetLine(site) {
+  return (site.assets || []).filter(a => a.type).map(a => `${a.type} × ${a.count}${a.rule ? ` (${a.rule})` : ""}`).join(";") || "无";
+}
+function siteContextText(site) {
+  return `请根据以下站点信息,结合 dashboard-json-generator Skill 的能力,生成一组 Cube 页面。
+站点信息如下:
+Site context (所有页面通用):
 - 站点名称: ${site.name || "未命名站点"}
 - 所属领域: ${site.domain || "未指定"}
 - 站点描述: ${site.description || "无"}
 - 补充要求: ${site.supplement || "无"}
-- 资产配置: ${assetDesc}
+- 资产配置: ${assetLine(site)}
 
-Page to generate:
-- 页面名称: ${cube.name}
-- 页面内容/要求: ${cube.content || "总览型页面"}
+请根据这些信息,学习对应领域的仪表盘特点,需要什么元素,体现什么信息,如何进行统计和呈现。并通过 dashboard-json-generator Skill 中的 Cube 工具的规则,生成一个 JSON 文件。这个 JSON 文件,我可以导入到 Cube 工具中,呈现出你设计的仪表盘。其中需要包括合理的模拟数据,如果是多个 Cube 页面,则数据之间关系合理。
 
-${DASHBOARD_SPEC}`;
+请生成对应的 JSON 文件。`;
 }
 function buildSiteContextPrompt(site) {
-  const assetDesc = (site.assets || []).filter(a => a.type).map(a => `${a.type} × ${a.count}${a.rule ? ` (${a.rule})` : ""}`).join(";") || "无";
-  return `Generate a Cube dashboard JSON for the following page.
+  return siteContextText(site);
+}
+function buildCubePrompt(site, cube) {
+  return `${siteContextText(site)}
 
-Site context (所有页面通用):
-- 站点名称: ${site.name || "未命名站点"}
-- 所属领域: ${site.domain || "未指定"}
-- 站点描述: ${site.description || "无"}
-- 补充要求: ${site.supplement || "无"}
-- 资产配置: ${assetDesc}
+本次需要生成的页面:
+- 页面名称: ${cube.name || "未命名页面"}
+- 页面内容/要求: ${cube.content || "(未填写,请按总览型页面设计)"}
 
+请针对以上页面,生成对应的 Cube 仪表盘 JSON 文件(只输出该页面)。模拟数据需与上方站点资产配置保持一致;若属于同一站点的多个 Cube 页面,请保持各页面之间的数据关系合理。该 JSON 必须符合 dashboard-json-generator Skill 中 Cube 工具的规则,结构如下:
 ${DASHBOARD_SPEC}`;
 }
 async function generateCubeJson(site, cube, attempt = 1, previousErrors = "") {
@@ -495,6 +501,19 @@ async function handleGenerateAll(req, res, siteId) {
   try {
     const site = hydrateSite(siteId);
     if (!site) { streamEvent(res, { task: "系统", status: "failed", message: "站点不存在" }); return res.end(); }
+    // 先持久化前端传入的 Cube(含未保存的草稿),确保 generate-all 不会漏掉
+    const body = await parseBody(req).catch(() => ({}));
+    if (Array.isArray(body.cubes)) {
+      for (const cb of body.cubes) {
+        if (!cb || !cb.name) continue;
+        if (cb.id) {
+          db.prepare("UPDATE cubes SET name=?, content=? WHERE id=? AND site_id=?").run(cb.name, cb.content || "", cb.id, siteId);
+        } else {
+          const maxSort = db.prepare("SELECT MAX(sort_order) as m FROM cubes WHERE site_id=?").get(siteId).m || 0;
+          db.prepare("INSERT INTO cubes (site_id, name, content, sort_order) VALUES (?,?,?,?)").run(siteId, cb.name, cb.content || "", maxSort + 1);
+        }
+      }
+    }
     const cubes = db.prepare("SELECT * FROM cubes WHERE site_id = ? ORDER BY sort_order,id").all(siteId);
     streamEvent(res, { task: "模型准备", status: "done" });
     for (const cube of cubes) {
@@ -586,6 +605,7 @@ async function route(req, res) {
   const mCubeGenerate = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)\/generate$/);
   const mCubeFile = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)\/file$/);
   const mCubePrompt = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)\/prompt$/);
+  const mCubePromptNew = path.match(/^\/api\/sites\/(\d+)\/cubes\/prompt$/);
   const mSiteMenu = path.match(/^\/api\/sites\/(\d+)\/menu$/);
   const mSiteFiles = path.match(/^\/api\/sites\/(\d+)\/files$/);
   const mSiteFile = path.match(/^\/api\/sites\/(\d+)\/files\/(\d+)$/);
@@ -739,6 +759,15 @@ async function route(req, res) {
       if (!cube) return respondError(res, 404, "Cube 页面不存在");
       return respondJson(res, 200, { prompt: buildCubePrompt(site, cube) });
     }
+    if (mCubePromptNew) {
+      const siteId = Number(mCubePromptNew[1]);
+      const site = hydrateSite(siteId);
+      if (!site) return respondError(res, 404, "站点不存在");
+      const u = new URL(req.url, "http://localhost");
+      const name = u.searchParams.get("name") || "";
+      const content = u.searchParams.get("content") || "";
+      return respondJson(res, 200, { prompt: buildCubePrompt(site, { name, content, sort_order: 0 }) });
+    }
     if (mCubeFile) return handleCubeFileDownload(res, Number(mCubeFile[2]));
     if (mSiteGenerate) return handleGenerateAll(req, res, Number(mSiteGenerate[1]));
     if (mSitePackage) return handlePackageDownload(res, Number(mSitePackage[1]));