From 51d70b327d843b167611c5cf1987a7fb642f92f5 Mon Sep 17 00:00:00 2001 From: "wang.yu" Date: Sat, 15 Aug 2026 18:36:05 +0800 Subject: [PATCH] =?utf8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B5=84=E4=BA=A7?= =?utf8?q?=E9=85=8D=E7=BD=AE=E5=B8=83=E5=B1=80=E3=80=81=E6=8F=90=E7=A4=BA?= =?utf8?q?=E8=AF=8D=E6=A0=BC=E5=BC=8F=E4=B8=8E=20Cube=20=E7=94=9F=E6=88=90?= =?utf8?q?=E5=8D=A1=E6=AD=BB/=E7=A9=BAJSON=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 本次提交修复三处问题,均已用真实接口验证: 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 | 11 ++++++++- app/page.tsx | 16 ++++++------ db-sidecar.mjs | 65 +++++++++++++++++++++++++++++++++++-------------- 3 files changed, 66 insertions(+), 26 deletions(-) diff --git a/app/globals.css b/app/globals.css index 8cb2947..394705d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -69,8 +69,17 @@ /* ===== 可编辑表格内的输入控件 ===== */ .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} diff --git a/app/page.tsx b/app/page.tsx index b864888..cb322d6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 ; } -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
{scope.map(x=>
{x[0]}{x[1]}{x[2]}
)}
站点级提示词(通用上下文)每个页面都会携带的站点背景与 Cube 规则,可先核对
{site.cubes.length?site.cubes.map(c=>
{c.name}{c.content&&{c.content}}
):
尚未添加 Cube 扩展页面(在「功能与菜单」中添加后,这里会列出每个页面的提示词)
}
{[ + return
{scope.map(x=>
{x[0]}{x[1]}{x[2]}
)}
站点级提示词(通用上下文)每个页面都会携带的站点背景与 Cube 规则,可先核对
{site.cubes.length?site.cubes.map(c=>
{c.name}{c.content&&{c.content}}
):
尚未添加 Cube 扩展页面(在「功能与菜单」中添加后,这里会列出每个页面的提示词)
}
{[ {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
{progress===100?"生成完成":running?"正在生成站点方案":"准备就绪"}{progress}%

{progress===100?"全部交付物已生成并通过校验。":running?"Wordbuddy Hy3 正在调用 Cube Skill 创建页面与模拟数据。":"Wordbuddy Hy3 已就绪,预计需要约 2 分钟。"}

{tasks.map((x,i)=>
{x.status==="done"?"✓":x.status==="running"?"↻":x.status==="failed"?"!":"·"}{x.name}{x.message&&{x.message}}{x.status==="done"?"成功":x.status==="running"?"生成中":x.status==="failed"?"失败":"等待"}
)}
{zipUrl&&}
; } -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
加载中…
; const generated = site.cubes.filter(c=>c.status==="generated"); return <>
⬡

{site.name || "未命名站点"}

{site.status==="generated"?"生成完成":"草稿"} · {site.updated_at}

{site.status==="generated"?"生成完成":"草稿"}
{[[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=>
{x[1]}{x[0]}{x[2]}
)}
-
setModal("feedback")}>✎ 提出修改意见}>
{site.cubes.map((cube,i)=>
{cube.status==="generated"?"校验通过":"未生成"}Cube

{cube.name}

{cube.content || "无描述"}

{cube.id&&}{cube.id&&}
)}
; +
setModal("feedback")}>✎ 提出修改意见}>
{site.cubes.map((cube,i)=>
{cube.status==="generated"?"校验通过":"未生成"}Cube

{cube.name}

{cube.content || "无描述"}

{cube.id&&}{cube.id&&}
)}
; } 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}){ diff --git a/db-sidecar.mjs b/db-sidecar.mjs index e6b600c..3b12d2b 100644 --- a/db-sidecar.mjs +++ b/db-sidecar.mjs @@ -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])); -- 2.43.0