/* ===== 可编辑表格内的输入控件 ===== */
.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}
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); }
</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);
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(;;){
}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},
<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}){
const args = [
"--model", "hy3",
"--session-id", sessionId,
+ "--effort", "minimal",
"-p", prompt,
"--output-format", "stream-json",
"--include-partial-messages",
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 {
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);
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 = "") {
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) {
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+)$/);
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]));