const [view,setView]=useState<"table"|"card">("table");
const [functionTab,setFunctionTab]=useState<"mu"|"cube"|"menu">("mu");
const [previewTab,setPreviewTab]=useState<"info"|"json"|"feedback">("info");
+ const [feedbackCube,setFeedbackCube]=useState<Cube|null>(null);
+ const [refining,setRefining]=useState(false);
const notify=(m:string)=>{setToast(m);window.setTimeout(()=>setToast(""),2200);};
}catch(e:any){ notify("读取提示词失败: "+e.message); }
};
+ const openFeedback=(cube:Cube)=>{
+ if(!cube.id){ notify("请先生成该页面再提出修改意见"); return; }
+ setFeedbackCube(cube);
+ setModal("feedback");
+ };
+ const refineCube=async(cube:Cube, feedback:string)=>{
+ if(!site?.id || !cube.id) return;
+ if(!feedback.trim()){ notify("请填写修改意见"); return; }
+ setRefining(true);
+ try{
+ await fetchDb(`/sites/${site.id}/cubes/${cube.id}/refine`, {
+ method:"POST",
+ headers:{"Content-Type":"application/json"},
+ body:JSON.stringify({feedback}),
+ });
+ notify(`已根据修改意见重新生成「${cube.name}」`);
+ const refreshed:SiteDetail = await fetchDb(`/sites/${site.id}`);
+ setSite(refreshed);
+ setSiteId(site.id);
+ setModal("");
+ setFeedbackCube(null);
+ }catch(e:any){ notify("重新生成失败:"+(e?.message||e)); }
+ finally{ setRefining(false); }
+ };
+
useEffect(()=>{ if(aiEndRef.current) aiEndRef.current.scrollIntoView({block:"end"}); }, [aiMessages, aiOpen]);
// load sites and domains
{page==="assets"&&<Assets site={site} onChange={saveAssets} saving={saving}/>}
{page==="functions"&&<Functions site={site} muTree={muTree} onMuChange={saveMu} onSiteChange={setSite} notify={notify}/>}
{page==="generate"&&<Generate site={site} onSiteChange={setSite} notify={notify} viewPrompt={viewPrompt} viewSitePrompt={viewSitePrompt}/>}
- {page==="result"&&<Result site={site} go={go} setModal={setModal} viewPrompt={viewPrompt} notify={notify}/>}
+ {page==="result"&&<Result site={site} go={go} setModal={setModal} viewPrompt={viewPrompt} notify={notify} openFeedback={openFeedback} viewSitePrompt={viewSitePrompt}/>}
{page==="preview"&&<Preview site={site} tab={previewTab} setTab={setPreviewTab} setModal={setModal} notify={notify}/>}
</div>{(["basic","assets","functions"] as PageKey[]).includes(page)&&<div className="sticky-actions"><Button secondary onClick={()=>go(page==="basic"?"projects":page==="assets"?"basic":"assets")}>上一步</Button><div><Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button><Button onClick={()=>go(page==="basic"?"assets":page==="assets"?"functions":"generate")}>下一步</Button></div></div>}</main>
- {toast&&<div className="toast"><b>✓</b>{toast}</div>}{modal&&<Modal type={modal} close={()=>setModal("")} notify={notify} promptData={promptData}/>}
+ {toast&&<div className="toast"><b>✓</b>{toast}</div>}{modal&&<Modal type={modal} close={()=>setModal("")} notify={notify} promptData={promptData} feedbackCube={feedbackCube} refineCube={refineCube} refining={refining}/>}
{!aiOpen&&<button ref={fabRef} className="ai-fab" title="AI 助手 · 拖动可移动位置,双击复位" aria-label="AI 助手"
style={fabPos?{left:fabPos.x,top:fabPos.y,right:"auto",bottom:"auto"}:undefined}
onPointerDown={onFabPointerDown} onPointerMove={onFabPointerMove} onPointerUp={onFabPointerUp} onDoubleClick={resetFab}>
}
})();
return ()=>{cancelled=true;};
- },[siteId, cube.id]);
+ },[siteId, cube.id, cube.generated_at]);
if(!cube.id) return <div className="dashboard-thumb-live"><div className="thumb-empty">未生成</div></div>;
return <div ref={outer} className="dashboard-thumb-live" style={{overflow:"hidden"}}><div ref={inner} style={{width:1920, height:1080, position:"relative"}}/></div>;
}
-function Result({site,go,setModal,viewPrompt,notify}:{site:SiteDetail|null;go:(p:PageKey)=>void;setModal:(m:ModalKey)=>void;viewPrompt:(cubeId:number|undefined,name:string,content?:string)=>void;notify:(m:string)=>void}){
+function Result({site,go,setModal,viewPrompt,notify,openFeedback,viewSitePrompt}:{site:SiteDetail|null;go:(p:PageKey)=>void;setModal:(m:ModalKey)=>void;viewPrompt:(cubeId:number|undefined,name:string,content?:string)=>void;notify:(m:string)=>void;openFeedback:(cube:Cube)=>void;viewSitePrompt:()=>void}){
if(!site) return <Panel><div className="empty-state">加载中…</div></Panel>;
const generated = site.cubes.filter(c=>c.status==="generated");
const openPreview=async(cube:Cube)=>{
};
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}><DashboardThumb siteId={site.id} cube={cube} /><div><header><Status>{cube.status==="generated"?"校验通过":"未生成"}</Status><small>Cube</small></header><h3>{cube.name}</h3><p>{cube.content || "无描述"}</p><footer><Button secondary onClick={()=>openPreview(cube)}>预览</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></>;
+ <div className="result-layout"><div><Panel title="页面结果" tools={<Button secondary onClick={()=>viewSitePrompt()}>查看站点提示词</Button>}><div className="result-pages">{site.cubes.map((cube,i)=><article key={i}><DashboardThumb siteId={site.id} cube={cube} /><div><header><Status>{cube.status==="generated"?"校验通过":"未生成"}</Status><small>Cube</small></header><h3>{cube.name}</h3><p>{cube.content || "无描述"}</p><footer><Button secondary onClick={()=>openPreview(cube)}>预览</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>}{cube.status==="generated"&&<Button secondary onClick={()=>openFeedback(cube)}>✎ 修改意见</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}){
<Panel title="领域知识"><div className="knowledge-list">{[["▤","自来水厂工艺说明.pdf","PDF · 3.2 MB"],["△","水质报警规则集","18 条规则"],["⚒","水泵维修规则","12 条规则"],["↻","设备保养规则","16 条规则"]].map(x=><div key={x[1]}><i>{x[0]}</i><span><b>{x[1]}</b><small>{x[2]}</small></span><button onClick={()=>notify("已打开 "+x[1])}>查看</button></div>)}</div></Panel></div></div></>
}
-function Modal({type,close,notify,promptData}:{type:Exclude<ModalKey,"">;close:()=>void;notify:(m:string)=>void;promptData?:{name:string;text:string}|null}){const title={feedback:"确认修改影响",download:"下载站点交付物",history:"版本历史",prompt:`生成提示词 · ${promptData?.name||""}`}[type];return <div className="modal-backdrop" onMouseDown={close}><div className="modal" onMouseDown={e=>e.stopPropagation()}><header><h2>{title}</h2><button onClick={close}>×</button></header><div className="modal-body">{type==="feedback"&&<><div className="impact-title">系统已识别本次修改范围</div><ul className="impact-list"><li><b>当前页面 JSON</b><span>将重新生成</span></li><li><b>页面模拟数据</b><span>将同步更新</span></li><li><b>Asset Excel</b><span>不受影响</span></li><li><b>其他 Cube 页面</b><span>不受影响</span></li></ul><Field label="修改意见"><textarea defaultValue="顶部增加设备在线率,将告警列表移到页面底部,趋势图改为最近七天。"/></Field></>}{type==="download"&&<div className="download-list">{["Cube 页面 JSON","站点菜单配置","生成及校验报告"].map(x=><label key={x}><input type="checkbox" defaultChecked/><span>{x}</span></label>)}<div><b>station-package.zip</b><span>包含全部生成产物</span></div></div>}{type==="history"&&<div className="history-list">{[["V1","首次生成",new Date().toLocaleString()]].map((x,i)=><div key={x[0]}><i>{x[0]}</i><span><b>{x[1]}</b><small>{x[2]} · 王宇</small></span><Status>当前版本</Status></div>)}</div>}{type==="prompt"&&<><p className="hint">这是发送给 Cube 生成模型的完整组合提示词。在点击「开始生成」前可在此核对站点上下文、页面要求与 Cube 规则是否准确,便于定位生成质量问题。</p><pre className="prompt-view">{promptData?.text||"(暂无提示词)"}</pre></>}</div><footer><Button secondary onClick={close}>关闭</Button>{type==="prompt"&&<Button onClick={()=>{ if(promptData?.text){ navigator.clipboard?.writeText(promptData.text).then(()=>notify("提示词已复制到剪贴板")).catch(()=>notify("复制失败,请手动选择")); } }}>复制提示词</Button>}</footer></div></div>}
+function Modal({type,close,notify,promptData,feedbackCube,refineCube,refining}:{type:Exclude<ModalKey,"">;close:()=>void;notify:(m:string)=>void;promptData?:{name:string;text:string}|null;feedbackCube?:Cube|null;refineCube?:(cube:Cube, feedback:string)=>void;refining?:boolean;}){
+ const [feedbackText,setFeedbackText]=useState("");
+ const title={feedback:`提出修改意见 · ${feedbackCube?.name||""}`,download:"下载站点交付物",history:"版本历史",prompt:`生成提示词 · ${promptData?.name||""}`}[type];
+ const submitFeedback=()=>{
+ if(!feedbackCube){ notify("未选择目标 Cube 页面,请关闭后重试"); close(); return; }
+ if(!refineCube){ notify("反馈功能未正确初始化"); return; }
+ refineCube(feedbackCube, feedbackText);
+ };
+ return <div className="modal-backdrop" onMouseDown={close}><div className="modal" onMouseDown={e=>e.stopPropagation()}><header><h2>{title}</h2><button onClick={close}>×</button></header><div className="modal-body">{type==="feedback"&&<><div className="impact-title">系统已识别本次修改范围(仅针对「{feedbackCube?.name}」页面)</div><ul className="impact-list"><li><b>当前页面 JSON</b><span>将依据修改意见重新生成</span></li><li><b>页面模拟数据</b><span>将同步更新</span></li><li><b>Asset Excel</b><span>不受影响</span></li><li><b>其他 Cube 页面</b><span>不受影响</span></li></ul><Field label="修改意见"><textarea value={feedbackText} onChange={e=>setFeedbackText(e.target.value)} placeholder="例如:顶部增加设备在线率,将告警列表移到页面底部,趋势图改为最近七天。"/></Field><p className="hint">提交后会在当前页面已生成的 JSON 基础上按意见修改,并保留完整提示词(含本次修改意见),可随时通过「提示词」按钮查看。</p></>}{type==="download"&&<div className="download-list">{["Cube 页面 JSON","站点菜单配置","生成及校验报告"].map(x=><label key={x}><input type="checkbox" defaultChecked/><span>{x}</span></label>)}<div><b>station-package.zip</b><span>包含全部生成产物</span></div></div>}{type==="history"&&<div className="history-list">{[["V1","首次生成",new Date().toLocaleString()]].map((x,i)=><div key={x[0]}><i>{x[0]}</i><span><b>{x[1]}</b><small>{x[2]} · 王宇</small></span><Status>当前版本</Status></div>)}</div>}{type==="prompt"&&<><p className="hint">这是发送给 Cube 生成模型的完整组合提示词。在点击「开始生成」前可在此核对站点上下文、页面要求与 Cube 规则是否准确,便于定位生成质量问题。</p><pre className="prompt-view">{promptData?.text||"(暂无提示词)"}</pre></>}</div><footer><Button secondary onClick={close}>关闭</Button>{type==="feedback"&&<Button disabled={refining} onClick={submitFeedback}>{refining?"重新生成中…":"提交并重新生成"}</Button>}{type==="prompt"&&<Button onClick={()=>{ if(promptData?.text){ navigator.clipboard?.writeText(promptData.text).then(()=>notify("提示词已复制到剪贴板")).catch(()=>notify("复制失败,请手动选择")); } }}>复制提示词</Button>}</footer></div></div>}
return { json, prompt: promptBase };
}
+// 取面板展示标题(适配不同位置的标题字段)
+function panelTitle(p) {
+ return (
+ p.custom?.title?.label ||
+ (typeof p.custom?.title === "string" ? p.custom.title : "") ||
+ p.style?.title?.label ||
+ p.title ||
+ p.custom?.name ||
+ p.name ||
+ ""
+ );
+}
+
+// 若反馈要求删除某面板,校验该面板在生成结果中确实已被移除;否则返回错误信息用于重试。
+function checkDeletionFeedback(feedback, beforeJson, afterJson) {
+ const m = feedback.match(/(?:删除|移除|去掉|删掉|去除)\s*[::]?\s*["']?([^\s,。、;\n"']+)["']?/);
+ if (!m) return null;
+ const name = m[1].trim();
+ if (name.length < 2) return null;
+ const before = (beforeJson.panels || []).filter(p => {
+ const t = panelTitle(p);
+ return t && t.includes(name);
+ });
+ if (before.length === 0) return null; // 没找到可删除的面板,不强制
+ const stillThere = (afterJson.panels || []).some(p => {
+ const t = panelTitle(p);
+ return t && t.includes(name);
+ });
+ if (stillThere) {
+ return `反馈要求删除包含“${name}”的面板,但输出中该面板仍然存在。请把它整个对象(含 data/custom/gridPos 等全部内容)从 panels 数组中彻底移除,再输出完整 JSON。当前应被删除的面板标题类似:${before.map(p => panelTitle(p)).join("、")}`;
+ }
+ return null;
+}
+
+// 基于“当前已生成的 JSON + 修改意见”进行修改,保持 Cube 结构不变。
+async function refineCubeJson(site, cube, currentJson, feedback, attempt = 1, previousErrors = "") {
+ const panels = Array.isArray(currentJson.panels) ? currentJson.panels : [];
+ const titleList = panels
+ .map((p, i) => ` ${i + 1}. [${p.type}] ${panelTitle(p) || "(无标题)"}`)
+ .join("\n");
+ const refineInstruction = `你正在修改一个已生成的 Cube 仪表盘 JSON(可导入名为 Cube 的可视化工具)。下面给出当前页面的【完整 JSON】、页面内现有面板清单,以及用户提出的修改意见。
+
+现有面板清单(编号 / 类型 / 标题):
+${titleList}
+
+要求:
+1) 严格按照修改意见调整这份 JSON;
+2) 保持 dashboard-json-generator Skill 中 Cube 工具的结构与字段规则完全不变(顶层必须包含 category_id/name/id/grid/panels;grid 必须包含 theme/bgColor/panelBgColor;首面板必须是 type="Title" 且 gridPos {w:24,h:2,x:0,y:0};其余面板 y>=2;24 列网格,x+w 不超过 24,面板不可重叠;panel type 必须属于 ${[...VALID_PANEL_TYPES].join(", ")});
+3) 关于修改意见中的各类操作,请严格执行:
+ - 删除 / 移除 / 去掉某面板:先在“现有面板清单”中找到标题包含该名称的面板,然后把该面板【整个对象】(含 data、custom、gridPos 等全部内容)从 panels 数组中移除,不要再保留它;
+ - 移动 / 调整位置:只修改该面板的 gridPos;
+ - 新增面板:按规则新增一个完整面板对象,并安排不重叠的 gridPos;
+ - 修改文字 / 数据 / 配色:只改对应字段,其余保持不变;
+4) 不要删除或改动用户未要求的面板,不要改变主题(light/darkBlue 不可混用),模拟数据保持合理;
+5) 只输出修改后的【完整】JSON 对象,不要省略任何面板,也不要包含 markdown 代码块或解释文字。
+
+修改意见:
+${feedback}
+
+当前 JSON:
+${JSON.stringify(currentJson, null, 2)}`;
+ const prompt = previousErrors
+ ? `The previous output failed validation:\n${previousErrors}\n\n${refineInstruction}\n\nPlease fix the errors and output ONLY the corrected raw dashboard JSON.`
+ : refineInstruction;
+ const sessionId = `stationbuilder-refine-${site.id}-${cube.id || 0}-${attempt}`;
+ const text = await runCodebuddy(prompt, sessionId);
+ let json;
+ try { json = await extractJson(text); }
+ catch (e) { throw new Error("JSON parse failed: " + e.message); }
+ const errors = validateDashboard(json);
+ const delErr = checkDeletionFeedback(feedback, currentJson, json);
+ if (delErr) errors.push(delErr);
+ if (errors.length > 0) {
+ if (attempt >= 3) throw new Error("Refine failed after 3 attempts: " + errors.join("; "));
+ return refineCubeJson(site, cube, currentJson, feedback, attempt + 1, errors.join("\n"));
+ }
+ return { json };
+}
+
async function createSiteZip(siteId) {
const siteDir = join(GEN_DIR, String(siteId));
ensureDir(siteDir);
}
}
+// 根据修改意见,在已有生成结果上重新生成(修改 JSON 并保留/累积完整提示词)
+async function handleRefineCube(req, res, siteId, cubeId) {
+ if (genLocks.has(`cube-${cubeId}`)) return respondError(res, 429, "该页面正在生成中");
+ genLocks.add(`cube-${cubeId}`);
+ try {
+ const site = hydrateSite(siteId);
+ if (!site) return respondError(res, 404, "站点不存在");
+ const cube = site.cubes.find(c => c.id === Number(cubeId));
+ if (!cube) return respondError(res, 404, "Cube 页面不存在");
+ const body = await parseBody(req).catch(() => ({}));
+ const feedback = (body.feedback || "").toString().trim();
+ if (!feedback) return respondError(res, 400, "修改意见不能为空");
+ const row = db.prepare("SELECT json_text FROM gen_files WHERE cube_id=? ORDER BY id DESC LIMIT 1").get(cubeId);
+ if (!row) return respondError(res, 400, "该页面尚未生成,无法在此基础上修改,请先生成");
+ let currentJson;
+ try { currentJson = JSON.parse(row.json_text); } catch { return respondError(res, 500, "当前 JSON 解析失败"); }
+ const { json } = await refineCubeJson(site, cube, currentJson, feedback);
+ const filename = `${safeFilename(cube.name)}.json`;
+ const jsonText = JSON.stringify(json, null, 2);
+ if (cube.gen_file_id) {
+ db.prepare("UPDATE gen_files SET filename=?, json_text=?, created_at=CURRENT_TIMESTAMP WHERE id=?").run(filename, jsonText, cube.gen_file_id);
+ } else {
+ const info = db.prepare("INSERT INTO gen_files (site_id, cube_id, filename, json_text, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)").run(siteId, cube.id, filename, jsonText);
+ db.prepare("UPDATE cubes SET gen_file_id=? WHERE id=?").run(info.lastInsertRowid, cube.id);
+ }
+ const existingPrompt = cube.prompt || buildCubePrompt(site, cube);
+ const storedPrompt = `${existingPrompt}\n\n--- 修改意见(${new Date().toLocaleString("zh-CN")})---\n${feedback}`;
+ db.prepare("UPDATE cubes SET status='generated', prompt=? WHERE id=?").run(storedPrompt, cube.id);
+ respondJson(res, 200, { ok: true, cubeId: cube.id, filename });
+ } catch (e) {
+ db.prepare("UPDATE cubes SET status='failed' WHERE id=?").run(cubeId);
+ respondError(res, 500, e.message);
+ } finally {
+ genLocks.delete(`cube-${cubeId}`);
+ }
+}
+
async function handleGenerateAll(req, res, siteId) {
res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
if (genLocks.has(`site-${siteId}`)) {
const mSitePrompt = path.match(/^\/api\/sites\/(\d+)\/prompt$/);
const mCubeGenerate = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)\/generate$/);
const mCubeFile = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)\/file$/);
+ const mCubeRefine = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)\/refine$/);
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$/);
if (!site) return respondError(res, 404, "站点不存在");
const cube = site.cubes.find(c => c.id === cubeId);
if (!cube) return respondError(res, 404, "Cube 页面不存在");
- return respondJson(res, 200, { prompt: buildCubePrompt(site, cube) });
+ // 返回已存储的完整提示词(含历史修改意见累积),若尚未生成则回退到实时构建
+ const stored = cube.prompt || buildCubePrompt(site, cube);
+ return respondJson(res, 200, { prompt: stored });
}
if (mCubePromptNew) {
const siteId = Number(mCubePromptNew[1]);
return respondJson(res, 200, { prompt: buildCubePrompt(site, { name, content, sort_order: 0 }) });
}
if (mCubeFile) return handleCubeFileDownload(res, Number(mCubeFile[2]));
+ if (mCubeRefine) return handleRefineCube(req, res, Number(mCubeRefine[1]), Number(mCubeRefine[2]));
if (mSiteGenerate) return handleGenerateAll(req, res, Number(mSiteGenerate[1]));
if (mSitePackage) return handlePackageDownload(res, Number(mSitePackage[1]));