import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
type PageKey = "login" | "projects" | "basic" | "assets" | "functions" | "generate" | "result" | "preview" | "domains" | "domainDetail" | "styleLibrary";
-type ModalKey = "" | "feedback" | "download" | "history" | "prompt";
+type ModalKey = "" | "feedback" | "download" | "history" | "prompt" | "completePrompt";
const meta: Record<PageKey, { label: string; icon: string; subtitle: string }> = {
login:{label:"内部登录",icon:"◇",subtitle:"使用公司账号登录"},
setModal("prompt");
}catch(e:any){ notify("读取提示词失败: "+e.message); }
};
-
+ const viewCompletePrompt=async()=>{
+ if(!site?.id) { notify("请先创建或打开一个站点"); return; }
+ try{
+ const qs=new URLSearchParams({name:site.name||"",domain:site.domain||"",description:site.description||""}).toString();
+ const data:{prompt:string}=await fetchDb(`/sites/${site.id}/complete-prompt?${qs}`);
+ setPromptData({name:"AI 智能补全", text:data.prompt});
+ setModal("completePrompt");
+ }catch(e:any){ notify("读取提示词失败: "+e.message); }
+ };
const openFeedback=(cube:Cube)=>{
if(!cube.id){ notify("请先生成该页面再提出修改意见"); return; }
setFeedbackCube(cube);
{page==="domains"&&<DomainLibrary go={go} notify={notify}/>}
{page==="domainDetail"&&<DomainDetail go={go} notify={notify}/>}
{page==="styleLibrary"&&<PageStyleLibrary pageStyles={pageStyles} loadPageStyles={loadPageStyles} notify={notify}/>}
- {page==="basic"&&<Basic site={site} domains={domains} onChange={saveSiteBasic} onAddDomain={addDomain} onUpload={uploadFile} saving={saving}/>}
+ {page==="basic"&&<Basic site={site} domains={domains} onChange={saveSiteBasic} onAddDomain={addDomain} onUpload={uploadFile} saving={saving} notify={notify} onViewCompletePrompt={viewCompletePrompt}/>}
{page==="assets"&&<Assets site={site} onChange={saveAssets} saving={saving}/>}
{page==="functions"&&<Functions site={site} muTree={muTree} onMuChange={saveMu} onSiteChange={setSite} pageStyles={pageStyles} notify={notify}/>}
{page==="generate"&&<Generate site={site} onSiteChange={setSite} notify={notify} viewPrompt={viewPrompt} viewSitePrompt={viewSitePrompt}/>}
return <><div className="summary-strip">{[[String(counts.all),"全部项目"],[String(counts.draft),"草稿"],[String(counts.running),"生成中"],[String(counts.done),"生成完成"],[String(counts.partial),"部分失败"]].map(x=><div key={x[1]}><b>{x[0]}</b><span>{x[1]}</span></div>)}</div><Panel><div className="filterbar"><label>⌕ <input placeholder="搜索站点名称"/></label><select><option>全部领域</option></select><select><option>全部状态</option></select><Button>Search</Button><span/>{selected.size>0 && <Button secondary onClick={batchDelete}>批量删除 ({selected.size})</Button>}<div className="view-switch"><button className={view==="table"?"active":""} onClick={()=>setView("table")}>☷</button><button className={view==="card"?"active":""} onClick={()=>setView("card")}>▦</button></div><button className="icon-button" onClick={()=>notify("列表已刷新")}>↻</button></div>{view==="table"?<table className="data-table"><thead><tr><th><input type="checkbox" checked={allSelected} onChange={toggleAll}/></th><th>站点名称 ↕</th><th>所属领域</th><th>设备数量</th><th>MU 页面</th><th>Cube 页面</th><th>状态</th><th>最近修改</th><th>操作</th></tr></thead><tbody>{sites.map(row=><tr key={row.id}><td><input type="checkbox" checked={selected.has(row.id)} onChange={()=>toggle(row.id)}/></td><td><button className="link" onClick={()=>go(row.status==="generated"?"result":"basic", row.id)}><span className="site-icon">⬡</span><b>{row.name || "未命名站点"}</b></button></td><td>{row.domain}</td><td>{row.device_count}</td><td>{row.mu_count}</td><td>{row.cube_count}</td><td><Status>{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}</Status></td><td>{row.updated_at}</td><td><button className="link danger" onClick={(e)=>{e.stopPropagation(); onDelete([row.id]);}}>删除</button></td></tr>)}</tbody></table>:<div className="project-grid">{sites.map(row=><article key={row.id} onClick={()=>go(row.status==="generated"?"result":"basic", row.id)}><div><span className="site-icon big">⬡</span><Status>{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}</Status></div><h3>{row.name || "未命名站点"}</h3><button className="card-del" onClick={(e)=>{e.stopPropagation(); onDelete([row.id]);}}>删除</button><p>{row.domain}</p><section><span><b>{row.device_count}</b> 设备</span><span><b>{row.mu_count}</b> MU</span><span><b>{row.cube_count}</b> Cube</span></section><small>更新于 {row.updated_at}</small></article>)}</div>}<div className="pagination"><span>共 {sites.length} 条</span><button>‹</button><button className="active">1</button><button>›</button><select><option>20 / page</option></select></div></Panel></>
}
-function Basic({site,domains,onChange,onAddDomain,onUpload,saving}:{site:SiteDetail|null;domains:string[];onChange:(p:Partial<SiteDetail>)=>void;onAddDomain:(n:string)=>void;onUpload:(f:File)=>void;saving:boolean}){
+function Basic({site,domains,onChange,onAddDomain,onUpload,saving,notify,onViewCompletePrompt}:{site:SiteDetail|null;domains:string[];onChange:(p:Partial<SiteDetail>)=>void;onAddDomain:(n:string)=>void;onUpload:(f:File)=>void;saving:boolean;notify:(m:string)=>void;onViewCompletePrompt:()=>void}){
const [newDomain,setNewDomain]=useState("");
const [domainInput,setDomainInput]=useState(site?.domain||"");
const [showAdd,setShowAdd]=useState(false);
useEffect(()=>{ if(site) setDomainInput(site.domain||""); },[site?.domain]);
+ 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 runComplete=async()=>{
+ if(!site?.id || completing) return;
+ setCompleting(true); setCompleteLog("");
+ const watchdog=setTimeout(()=>{ setCompleting(false); notify("AI 智能补全超时(>6 分钟),请重试"); }, 360000);
+ try{
+ const res=await fetch(`/api/db/sites/${site.id}/complete`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:site.name,domain:site.domain,description:site.description})});
+ 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(ev.result) onChange({supplement:ev.result});
+ }catch{}
+ }
+ }
+ notify("AI 智能补全已完成,已写入补充描述");
+ }catch(e:any){ notify("AI 智能补全失败:"+(e?.message||e)); }
+ finally{ clearTimeout(watchdog); setCompleting(false); }
+ };
if(!site) return <Panel><div className="empty-state">加载中…</div></Panel>;
const nameTooLong = countChars(site.name)>20;
const domainTooLong = newDomain.length>6;
{site.files.length>0&&<ul className="file-list">{site.files.map(f=><li key={f.id}><a href={`/api/db/sites/${site.id}/files/${f.id}`} download>{f.filename}</a> <small>({(f.size/1024).toFixed(1)} KB)</small></li>)}</ul>}
</Field>
</div></Panel>
- <div className="stack"><Panel title="填写建议"><ul className="tips"><li>站点主要承担什么业务?</li><li>哪些设备最重要?</li><li>管理人员最关注哪些指标?</li><li>希望首页首先看到什么?</li></ul></Panel>{saving&&<Panel><div className="empty-state">正在保存…</div></Panel>}</div></div>;
+ <div className="stack">
+ <Panel title="填写建议"><ul className="tips"><li>站点主要承担什么业务?</li><li>哪些设备最重要?</li><li>管理人员最关注哪些指标?</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={runComplete}>{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>;
}
function Assets({site,onChange,saving}:{site:SiteDetail|null;onChange:(assets:Asset[])=>void;saving:boolean}){
const [feedbackText,setFeedbackText]=useState("");
const refineConsoleRef=useRef<HTMLPreElement>(null);
useEffect(()=>{ if(refineConsoleRef.current) refineConsoleRef.current.scrollTop = refineConsoleRef.current.scrollHeight; }, [refineLog]);
- const title={feedback:`提出修改意见 · ${feedbackCube?.name||""}`,download:"下载站点交付物",history:"版本历史",prompt:`生成提示词 · ${promptData?.name||""}`}[type];
+ const title={feedback:`提出修改意见 · ${feedbackCube?.name||""}`,download:"下载站点交付物",history:"版本历史",prompt:`生成提示词 · ${promptData?.name||""}`,completePrompt:"AI 智能补全提示词"}[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><Field label="修改意见"><textarea autoFocus value={feedbackText} onChange={e=>setFeedbackText(e.target.value)} placeholder="例如:顶部增加设备在线率,将告警列表移到页面底部,趋势图改为最近七天。"/></Field><p className="hint">在上方输入框填写修改意见,提交后会在当前页面已生成的 JSON 基础上按意见修改,并保留完整提示词(含本次修改意见),可随时通过「提示词」按钮查看。</p><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>{(refining||refineLog)?<div className="modal-console"><div className="modal-console-head">AI 生成工作流</div><pre className="modal-console-body" ref={refineConsoleRef}>{refineLog||"(等待 AI 反馈…)"}</pre></div>:null}</>}{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 <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><Field label="修改意见"><textarea autoFocus value={feedbackText} onChange={e=>setFeedbackText(e.target.value)} placeholder="例如:顶部增加设备在线率,将告警列表移到页面底部,趋势图改为最近七天。"/></Field><p className="hint">在上方输入框填写修改意见,提交后会在当前页面已生成的 JSON 基础上按意见修改,并保留完整提示词(含本次修改意见),可随时通过「提示词」按钮查看。</p><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>{(refining||refineLog)?<div className="modal-console"><div className="modal-console-head">AI 生成工作流</div><pre className="modal-console-body" ref={refineConsoleRef}>{refineLog||"(等待 AI 反馈…)"}</pre></div>:null}</>}{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></>}{type==="completePrompt"&&<><p className="hint">这是「AI 智能补全」发送给分析模型的完整提示词。它基于站点名称、所属领域与站点基本情况描述,要求模型先输出分析思路,再以「【补充描述】」给出最终要写入「补充要求」的文本。</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>}
emit({ log: `✗ 重新生成失败:${e.message}\n` });
emit({ task: "系统", status: "failed", message: e.message });
res.end();
- } finally {
- genLocks.delete(`cube-${cubeId}`);
+ } finally {
+ genLocks.delete(`cube-${cubeId}`);
+ }
+ }
+
+ // ===== AI 智能补全:基于站点名称/领域/描述分析并生成「补充描述」 =====
+ function buildCompletePrompt({ name = "", domain = "", description = "" }) {
+ return `你是一名资深的工业站点数字化方案架构师,擅长把站点业务背景转化为大屏看板(Cube 仪表盘)可用的生成上下文。
+请根据以下站点信息,分析该站点在监控与运营方面的特点,并撰写一段「补充描述」,用于补充站点基本情况描述,为后续 Cube 仪表盘生成提供更充分的上下文。
+
+站点信息:
+- 站点名称:${name || "未命名站点"}
+- 所属领域:${domain || "未指定"}
+- 站点基本情况描述:${description || "无"}
+
+要求:
+1. 先简要说明你的分析思路(思考过程):从名称/领域/描述中推断业务重点、关键设备、管理关注点,以及建议呈现的指标类型。
+2. 思考过程结束后,另起一段,以「【补充描述】」开头,给出最终要填入的补充说明文本。该文本应是一段专业、具体、可直接作为生成提示词上下文的描述,聚焦监控目标、关键指标、设备与告警、管理诉求,避免空话套话。
+
+请直接输出你的分析与【补充描述】。`;
+ }
+
+ function extractSupplement(text) {
+ const marker = "【补充描述】";
+ const idx = text.lastIndexOf(marker);
+ const body = idx >= 0 ? text.slice(idx + marker.length) : text;
+ return body.replace(/^\s+|\s+$/g, "");
+ }
+
+ async function handleCompleteSite(req, res, siteId) {
+ res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
+ const emit = (obj) => streamEvent(res, obj);
+ const fail = (message) => { emit({ task: "系统", status: "failed", message }); res.end(); };
+ if (genLocks.has(`complete-${siteId}`)) return fail("AI 智能补全正在进行中");
+ genLocks.add(`complete-${siteId}`);
+ try {
+ const site = hydrateSite(siteId);
+ if (!site) return fail("站点不存在");
+ const body = await parseBody(req).catch(() => ({}));
+ // 优先使用前端传入的当前编辑值,缺省回退到库中站点
+ const ctx = {
+ name: (body.name ?? site.name) || "",
+ domain: (body.domain ?? site.domain) || "",
+ description: (body.description ?? site.description) || "",
+ };
+ const prompt = buildCompletePrompt(ctx);
+ emit({ prompt });
+ emit({ log: `\n▶ 开始 AI 智能补全:基于站点名称 / 领域 / 描述分析并生成补充描述…\n` });
+ emit({ task: "AI 智能补全", status: "running" });
+ const onEvent = (o) => {
+ if (o && o.type === "stream_event" && o.event?.type === "content_block_delta" && o.event.delta?.type === "text_delta" && o.event.delta.text) {
+ emit({ log: o.event.delta.text });
+ }
+ };
+ const text = await runCodebuddy(prompt, `stationbuilder-complete-${siteId}`, onEvent);
+ const supplement = extractSupplement(text);
+ emit({ log: `\n✓ 已生成补充描述(${supplement.length} 字)\n` });
+ emit({ result: supplement });
+ emit({ task: "AI 智能补全", status: "done" });
+ res.end();
+ } catch (e) {
+ emit({ log: `✗ AI 智能补全失败:${e.message}\n` });
+ emit({ task: "系统", status: "failed", message: e.message });
+ res.end();
+ } finally {
+ genLocks.delete(`complete-${siteId}`);
+ }
+ }
+
+ async function handleCompletePrompt(req, res, siteId) {
+ const site = hydrateSite(siteId);
+ if (!site) return respondError(res, 404, "站点不存在");
+ const u = new URL(req.url, "http://localhost");
+ const ctx = {
+ name: u.searchParams.get("name") ?? site.name ?? "",
+ domain: u.searchParams.get("domain") ?? site.domain ?? "",
+ description: u.searchParams.get("description") ?? site.description ?? "",
+ };
+ return respondJson(res, 200, { prompt: buildCompletePrompt(ctx) });
}
-}
async function handleGenerateAll(req, res, siteId) {
res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
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 mSiteComplete = path.match(/^\/api\/sites\/(\d+)\/complete$/);
+ const mSiteCompletePrompt = path.match(/^\/api\/sites\/(\d+)\/complete-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$/);
if (!site) return respondError(res, 404, "站点不存在");
return respondJson(res, 200, { prompt: buildSiteContextPrompt(site) });
}
+ if (mSiteComplete) return handleCompleteSite(req, res, Number(mSiteComplete[1]));
+ if (mSiteCompletePrompt) return handleCompletePrompt(req, res, Number(mSiteCompletePrompt[1]));
if (mSiteCubes) {
const siteId = Number(mSiteCubes[1]);
if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT c.*, c.prompt, gf.filename as gen_filename FROM cubes c LEFT JOIN gen_files gf ON gf.id=c.gen_file_id WHERE c.site_id=? ORDER BY c.sort_order,c.id").all(siteId));