--- /dev/null
+import { type NextRequest } from "next/server";
+
+const SIDECAR = "http://127.0.0.1:37823";
+
+async function proxy(req: NextRequest, { params }: { params: Promise<{ slug?: string[] }> }) {
+ const { slug } = await params;
+ const suffix = slug ? "/" + slug.join("/") : "";
+ const target = `${SIDECAR}/api${suffix}${req.nextUrl.search}`;
+
+ const headers = new Headers();
+ const ct = req.headers.get("content-type");
+ if (ct) headers.set("Content-Type", ct);
+
+ const upstream = await fetch(target, {
+ method: req.method,
+ headers,
+ body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined,
+ // @ts-expect-error duplex is required when forwarding ReadableStream bodies
+ duplex: "half",
+ });
+
+ const responseHeaders = new Headers();
+ const contentType = upstream.headers.get("content-type");
+ if (contentType) responseHeaders.set("Content-Type", contentType);
+ const cd = upstream.headers.get("content-disposition");
+ if (cd) responseHeaders.set("Content-Disposition", cd);
+
+ return new Response(upstream.body, {
+ status: upstream.status,
+ statusText: upstream.statusText,
+ headers: responseHeaders,
+ });
+}
+
+export const GET = proxy;
+export const POST = proxy;
+export const PUT = proxy;
+export const DELETE = proxy;
+export const OPTIONS = proxy;
.ai-dialog .ai-send:disabled{opacity:.5;cursor:not-allowed}
.ai-dialog .ai-send:not(:disabled):hover{background:#008ac4}
+/* ===== 新建站点向导:基本属性 ===== */
+.domain-select{display:flex;align-items:center;gap:8px}
+.domain-select input{flex:1;border:1px solid var(--line);border-radius:3px;height:30px;padding:0 9px;background:#fff}
+.domain-select input:focus{border-color:var(--blue);outline:none}
+.domain-add{display:flex;align-items:center;gap:8px;margin-top:9px}
+.domain-add input{flex:1;border:1px solid var(--line);border-radius:3px;height:30px;padding:0 9px;background:#fff}
+.domain-add input:focus{border-color:var(--blue);outline:none}
+.file-list{list-style:none;margin:10px 0 0;padding:0;display:flex;flex-direction:column;gap:6px}
+.file-list li{display:flex;align-items:center;gap:6px;background:var(--soft);border:1px solid var(--line);border-radius:4px;padding:6px 9px;font-size:12px}
+.file-list a{color:var(--blue);text-decoration:none}
+.file-list a:hover{text-decoration:underline}
+.file-list small{color:var(--muted);margin-left:auto}
+.invalid{border-color:var(--red)!important;background:#fff4f2!important}
+.hint{color:var(--muted);font-size:11px;margin:0 0 10px;line-height:1.6}
+
+/* ===== 可编辑表格内的输入控件 ===== */
+.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 input::placeholder{color:#b9bfc3}
+
+/* ===== Assets 资产配置 ===== */
+.empty-row td{background:#fafbfc}
+.empty-row input{color:#b7bcc0;background:#fafbfc}
+.empty-row input::placeholder{color:#c3c8cc}
+.asset-tree-preview{background:#f5f8f9;border:1px solid #e7ebed;border-radius:6px;padding:12px 14px;font-size:12px}
+.tree-root{font-weight:bold;color:#33414a;margin-bottom:8px;display:flex;align-items:center;gap:5px}
+.tree-branch{margin:4px 0 4px 14px;padding-left:12px;border-left:1px dashed #cdd5d9}
+.tree-node{display:flex;align-items:center;gap:6px;font-weight:bold;color:#3a4750}
+.tree-count{background:var(--pale);color:var(--blue2);border-radius:9px;padding:0 7px;font-size:10px;font-weight:normal}
+.tree-leaves{color:#788187;font-size:11px;line-height:1.7;margin:3px 0 0 20px;word-break:break-all}
+
+/* ===== Functions 功能与菜单 ===== */
+.mu-tree{display:flex;flex-direction:column;gap:14px}
+.mu-group>b{display:block;color:var(--blue2);font-size:13px;margin-bottom:6px}
+.mu-items{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;padding-left:4px}
+.mu-items label{display:flex;align-items:flex-start;gap:7px;border:1px solid var(--line);border-radius:5px;padding:7px 9px;background:#fff;cursor:pointer;font-size:12px}
+.mu-items label:hover{border-color:#9ed3ec;background:#f4fafe}
+.mu-items label.disabled{opacity:.5;cursor:not-allowed;background:#f6f7f8}
+.mu-items label input{margin-top:2px;accent-color:var(--blue)}
+.mu-items label span{display:flex;flex-direction:column;line-height:1.3}
+.mu-items label small{color:var(--muted);font-size:10px}
+.cube-editor table{width:100%}
+.cube-editor input{width:100%}
+.cube-actions{display:flex;gap:5px;white-space:nowrap}
+.cube-actions button{border:1px solid var(--line);background:#fff;border-radius:4px;height:26px;padding:0 9px;font-size:11px;color:#444}
+.cube-actions button:hover{border-color:var(--blue);color:var(--blue)}
+.cube-actions .trash{border-color:#f0c4bd;color:var(--red)}
+.cube-actions .trash:hover{background:#fdeae6;border-color:var(--red)}
+.menu-editor{display:flex;flex-direction:column;gap:10px}
+.menu-row{display:grid;grid-template-columns:1.4fr 1fr 1.4fr auto;gap:8px;align-items:center}
+.menu-row input,.menu-row select{border:1px solid var(--line);border-radius:3px;height:30px;padding:0 8px;background:#fff;width:100%}
+.menu-row .trash{border:1px solid #f0c4bd;background:#fff;color:var(--red);border-radius:4px;height:30px;width:34px;font-size:15px}
+.menu-row .trash:hover{background:#fdeae6;border-color:var(--red)}
+
+/* ===== Generate 预览 line 面板 ===== */
+.line-panel{background:#fff;border:1px solid var(--line);border-radius:6px;padding:11px 13px;display:flex;flex-direction:column;gap:8px}
+.line-panel h3{margin:0;font-size:13px;display:flex;align-items:center;justify-content:space-between}
+.line-panel h3 span{color:var(--muted);font-size:10px;font-weight:normal}
+
+/* ===== 生成提示词查看 ===== */
+.prompt-list{display:flex;flex-direction:column;gap:8px}
+.prompt-row{display:flex;align-items:center;justify-content:space-between;gap:10px;border:1px solid var(--line);border-radius:5px;padding:8px 11px;background:#fff}
+.prompt-row span{display:flex;flex-direction:column;line-height:1.35;min-width:0}
+.prompt-row span small{color:var(--muted);font-size:10px;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:420px}
+.prompt-row b{font-size:13px}
+.prompt-view{margin:0;background:#0f1115;color:#e6edf3;border:1px solid #2a2f36;border-radius:6px;padding:14px;font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;font-size:11.5px;line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:60vh;overflow:auto}
+
+/* ===== 站点列表:多选与删除 ===== */
+.data-table input[type=checkbox]{width:16px;height:16px;accent-color:var(--blue);cursor:pointer;vertical-align:middle}
+.data-table thead th:first-child{width:34px;text-align:center}
+.link.danger{color:var(--red)}
+.link.danger:hover{text-decoration:underline}
+.project-grid article{position:relative}
+.card-del{position:absolute;top:8px;right:8px;border:1px solid #f0c4bd;background:#fff;color:var(--red);border-radius:4px;height:26px;padding:0 9px;font-size:11px;cursor:pointer}
+.card-del:hover{background:#fdeae6;border-color:var(--red)}
+
import { useEffect, useMemo, useRef, useState } from "react";
-type PageKey = "login" | "projects" | "basic" | "assets" | "functions" | "ai" | "generate" | "result" | "preview" | "domains" | "domainDetail";
-type ModalKey = "" | "ai" | "feedback" | "download" | "history";
+type PageKey = "login" | "projects" | "basic" | "assets" | "functions" | "generate" | "result" | "preview" | "domains" | "domainDetail";
+type ModalKey = "" | "feedback" | "download" | "history" | "prompt";
+
const meta: Record<PageKey, { label: string; icon: string; subtitle: string }> = {
login:{label:"内部登录",icon:"◇",subtitle:"使用公司账号登录"},
projects:{label:"站点项目",icon:"▦",subtitle:"创建、查找和管理站点生成项目"},
basic:{label:"站点基本属性",icon:"▤",subtitle:"描述站点业务背景,信息越完整,生成结果越符合预期"},
assets:{label:"Asset 资产设置",icon:"▧",subtitle:"配置设备类型、数量和资产层级,为页面和模拟数据提供基础"},
functions:{label:"功能与菜单",icon:"☷",subtitle:"选择 MU 标准页面,定义 Cube 扩展页面及菜单结构"},
- ai:{label:"AI 配置",icon:"✦",subtitle:"选择 Wordbuddy 模型并配置站点生成策略"},
generate:{label:"生成确认",icon:"↻",subtitle:"确认生成范围,调用 Cube Skill 生成页面和模拟数据"},
result:{label:"站点结果",icon:"◫",subtitle:"集中查看、预览、校验并下载站点交付物"},
preview:{label:"Cube 页面预览",icon:"▣",subtitle:"检查单个 Cube 页面效果,并通过反馈快速重新生成"},
domains:{label:"领域方案库",icon:"◆",subtitle:"沉淀各领域标准方案,供客户站点直接使用或快速微调"},
domainDetail:{label:"水务 · 自来水厂方案",icon:"◇",subtitle:"查看领域资产、运维流程、Cube 页面、菜单及知识内容"},
};
-const nav: PageKey[] = ["projects","domains","basic","assets","functions","ai","generate","result","preview"];
-const projects = [
- ["苏州工业园能源站","工业园区","25","6","3","生成完成","2026/08/14 11:20","王宇"],
- ["上海数据中心 A 区","数据中心","48","8","4","草稿","2026/08/14 09:42","陈晨"],
- ["滨江智慧楼宇","智慧楼宇","32","5","2","生成中","2026/08/13 17:08","李静"],
- ["无锡装备工厂","工业制造","16","6","3","部分失败","2026/08/13 15:31","周扬"],
- ["青岛水务示范站","智慧水务","61","7","5","生成完成","2026/08/12 16:03","王宇"],
-];
-const startAssets = [
- {type:"冷水机组",count:3,rule:"CH-##",parent:"制冷系统",area:"动力中心"},
- {type:"循环水泵",count:6,rule:"PUMP-##",parent:"水循环系统",area:"泵房"},
- {type:"配电柜",count:4,rule:"PDC-##",parent:"配电系统",area:"一层配电室"},
- {type:"智能电表",count:12,rule:"METER-##",parent:"配电系统",area:"各用电区域"},
-];
-// Wordbuddy 支持的模型。只有 rate 为 "0.00x"(免费) 的模型可选,其余灰显。
+const nav: PageKey[] = ["projects","domains","basic","assets","functions","generate","result","preview"];
+
const MODELS = [
{ name: "Auto", tag: "自动选择", rate: "—" },
{ name: "Hy3", tag: "免费", rate: "0.00x" },
{ name: "Kimi-K3", tag: "订阅优先", rate: "1.62x" },
];
-function Status({children}:{children:React.ReactNode}){const v=String(children);const tone=/完成|正常|成功|通过|推荐/.test(v)?"ok":/生成中|校验中|告警/.test(v)?"warn":/失败|异常/.test(v)?"bad":"muted";return <span className={"status "+tone}>{children}</span>}
-function Button({children,secondary,onClick,disabled}:{children:React.ReactNode;secondary?:boolean;onClick?:()=>void;disabled?:boolean}){return <button disabled={disabled} onClick={onClick} className={"button "+(secondary?"secondary":"")}>{children}</button>}
+// ---- types ----
+type SiteSummary = { id:number; name:string; domain:string; status:string; device_count:number; mu_count:number; cube_count:number; updated_at:string; created_at:string; };
+type Asset = { id?:number; type:string; count:number; rule:string; sort_order:number; };
+type Cube = { id?:number; name:string; content:string; status?:string; gen_file_id?:number|null; gen_filename?:string; generated_at?:string; prompt?:string; sort_order:number; };
+type MenuItem = { id?:number; parent_id:number|null; label:string; page_type:"mu"|"cube"|""; ref_id?:number|string; sort_order:number; };
+type ReferenceFile = { id:number; filename:string; mime:string; size:number; created_at:string; };
+type SiteDetail = {
+ id:number; name:string; domain:string; description:string; supplement:string; status:string;
+ created_at:string; updated_at:string;
+ assets: Asset[]; cubes: Cube[]; mu: string[]; menu: MenuItem[]; files: ReferenceFile[];
+};
+type MuTab = string | { label:string; disabled?:boolean; note?:string };
+type MuNode = { key:string; children?:MuNode[]; tabs?:MuTab[] };
+
+// ---- helpers ----
+function Status({children}:{children:React.ReactNode}){const v=String(children);const tone=/完成|正常|成功|通过|推荐|generated/.test(v)?"ok":/生成中|校验中|告警|running/.test(v)?"warn":/失败|异常|failed/.test(v)?"bad":"muted";return <span className={"status "+tone}>{children}</span>}
+function Button({children,secondary,onClick,disabled,title}:{children:React.ReactNode;secondary?:boolean;onClick?:()=>void;disabled?:boolean;title?:string}){return <button title={title} disabled={disabled} onClick={onClick} className={"button "+(secondary?"secondary":"")}>{children}</button>}
function Panel({title,tools,children,className=""}:{title?:string;tools?:React.ReactNode;children:React.ReactNode;className?:string}){return <section className={"panel "+className}>{title&&<div className="panel-head"><strong>{title}</strong><div>{tools}</div></div>}<div className="panel-body">{children}</div></section>}
function Field({label,required,hint,children}:{label:string;required?:boolean;hint?:string;children:React.ReactNode}){return <label className="field"><span>{required&&<i>*</i>}{label}</span>{children}{hint&&<small>{hint}</small>}</label>}
+async function fetchDb(path:string, opts?:RequestInit){
+ const res = await fetch(`/api/db${path}`, opts);
+ if(!res.ok){
+ const text = await res.text().catch(()=>"");
+ throw new Error(text || `HTTP ${res.status}`);
+ }
+ const ct = res.headers.get("content-type") || "";
+ if(ct.includes("application/json")) return res.json();
+ return res;
+}
+
+function downloadBlob(blob:Blob, filename:string){
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url; a.download = filename; a.click();
+ URL.revokeObjectURL(url);
+}
+
+function formatInstances(rule:string, count:number){
+ if(!rule || count<=0) return "";
+ const pad = Math.max(2, String(count).length);
+ const parts:string[] = [];
+ for(let i=1;i<=count;i++){
+ const num = String(i).padStart(pad,"0");
+ parts.push(rule.replace(/#/g, num));
+ }
+ return parts.join("、");
+}
+
+function buildMuLeaves(nodes:MuNode[], parent=""): {key:string, label:string, disabled?:boolean, note?:string, parentMenu:string}[] {
+ const out:{key:string,label:string,disabled?:boolean,note?:string,parentMenu:string}[] = [];
+ for(const n of nodes){
+ const myParent = parent ? `${parent}/${n.key}` : n.key;
+ if(n.tabs && n.tabs.length){
+ for(const t of n.tabs){
+ if(typeof t === "string") out.push({key:`${myParent}/${t}`, label:t, parentMenu:parent||n.key});
+ else out.push({key:`${myParent}/${t.label}`, label:t.label, disabled:t.disabled, note:t.note, parentMenu:parent||n.key});
+ }
+ } else if(n.children){
+ out.push(...buildMuLeaves(n.children, myParent));
+ } else {
+ out.push({key:myParent, label:n.key, parentMenu:parent||""});
+ }
+ }
+ return out;
+}
+
+function countChars(v:string){ return Array.from(v||"").length; }
+
+// ---- main ----
export default function Home(){
- const [page,setPage]=useState<PageKey>("projects"); const [toast,setToast]=useState(""); const [modal,setModal]=useState<ModalKey>(""); const [model,setModel]=useState("Hy3"); const [modelOpen,setModelOpen]=useState(false);
- const [aiOpen,setAiOpen]=useState(false); const [aiMessages,setAiMessages]=useState<{role:"user"|"assistant";content:string}[]>([]); const [aiDraft,setAiDraft]=useState(""); const [aiBusy,setAiBusy]=useState(false); const aiEndRef=useRef<HTMLDivElement|null>(null);
- const [view,setView]=useState<"table"|"card">("table"); const [assets,setAssets]=useState(startAssets); const [functionTab,setFunctionTab]=useState<"mu"|"cube"|"menu">("mu");
- const [previewTab,setPreviewTab]=useState<"info"|"json"|"feedback">("info"); const [selectedMu,setSelectedMu]=useState(["设备总览","实时监控","告警中心","能耗分析"]); const [progress,setProgress]=useState(0);
- const totalAssets=useMemo(()=>assets.reduce((n,item)=>n+Number(item.count),0),[assets]);
- const go=(target:PageKey)=>{setPage(target);window.history.replaceState({},"","?page="+target)}; const notify=(m:string)=>{setToast(m);window.setTimeout(()=>setToast(""),2200)};
- const generate=()=>{setProgress(10);let v=10;const timer=window.setInterval(()=>{v+=15;setProgress(Math.min(v,100));if(v>=100){window.clearInterval(timer);notify("站点方案生成完成")}},360)};
+ const [page,setPage]=useState<PageKey>("projects");
+ const [toast,setToast]=useState("");
+ const [modal,setModal]=useState<ModalKey>("");
+ const [promptData,setPromptData]=useState<{name:string;text:string}|null>(null);
+ const [model,setModel]=useState("Hy3");
+ const [modelOpen,setModelOpen]=useState(false);
+ const [aiOpen,setAiOpen]=useState(false);
+ const [aiMessages,setAiMessages]=useState<{role:"user"|"assistant";content:string}[]>([]);
+ const [aiDraft,setAiDraft]=useState("");
+ const [aiBusy,setAiBusy]=useState(false);
+ const aiEndRef=useRef<HTMLDivElement|null>(null);
+
+ const [sites,setSites]=useState<SiteSummary[]>([]);
+ const [siteId,setSiteId]=useState<number|null>(null);
+ const [site,setSite]=useState<SiteDetail|null>(null);
+ const [loading,setLoading]=useState(true);
+ const [saving,setSaving]=useState(false);
+ const [domains,setDomains]=useState<string[]>([]);
+ const [muTree,setMuTree]=useState<MuNode[]>([]);
+
+ const [view,setView]=useState<"table"|"card">("table");
+ const [functionTab,setFunctionTab]=useState<"mu"|"cube"|"menu">("mu");
+ const [previewTab,setPreviewTab]=useState<"info"|"json"|"feedback">("info");
+
+ const notify=(m:string)=>{setToast(m);window.setTimeout(()=>setToast(""),2200);};
+
+ const viewPrompt=async(cubeId:number,name:string)=>{
+ const sid = site?.id ?? siteId;
+ if(sid==null) { notify("请先创建或打开一个站点"); return; }
+ try{
+ const data:{prompt:string} = await fetchDb(`/sites/${sid}/cubes/${cubeId}/prompt`);
+ setPromptData({name, text:data.prompt});
+ setModal("prompt");
+ }catch(e:any){ notify("读取提示词失败: "+e.message); }
+ };
+ const viewSitePrompt=async()=>{
+ const sid = site?.id ?? siteId;
+ if(sid==null) { notify("请先创建或打开一个站点"); return; }
+ try{
+ const data:{prompt:string} = await fetchDb(`/sites/${sid}/prompt`);
+ setPromptData({name:"站点级提示词(通用上下文)", text:data.prompt});
+ setModal("prompt");
+ }catch(e:any){ notify("读取提示词失败: "+e.message); }
+ };
+
useEffect(()=>{ if(aiEndRef.current) aiEndRef.current.scrollIntoView({block:"end"}); }, [aiMessages, aiOpen]);
+
+ // load sites and domains
+ useEffect(()=>{
+ setLoading(true);
+ Promise.all([
+ fetchDb("/sites") as Promise<SiteSummary[]>,
+ fetchDb("/domains") as Promise<{name:string}[]>,
+ fetchDb("/mu-tree") as Promise<MuNode[]>,
+ ]).then(([s,d,m])=>{
+ setSites(s||[]);
+ setDomains((d||[]).map(x=>x.name));
+ setMuTree(m||[]);
+ }).catch(e=>notify("加载失败: "+e.message)).finally(()=>setLoading(false));
+ },[]);
+
+ const loadSite=async(id:number)=>{
+ const data:SiteDetail = await fetchDb(`/sites/${id}`);
+ setSite(data);
+ setSiteId(id);
+ };
+
+ const createSite=async()=>{
+ const data:SiteDetail = await fetchDb("/sites", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({name:"",domain:"",description:"",supplement:""})});
+ setSites(prev=>[data as any, ...prev]);
+ setSite(data);
+ setSiteId(data.id);
+ return data.id;
+ };
+ const deleteSites=async(ids:number[])=>{
+ if(!ids.length) return;
+ if(!window.confirm(`确认删除选中的 ${ids.length} 个站点?该操作不可恢复,相关资产、页面与生成文件将一并删除。`)) return;
+ try{
+ await fetchDb("/sites", {method:"DELETE", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ids})});
+ setSites(prev=>prev.filter(s=>!ids.includes(s.id)));
+ if(siteId!=null && ids.includes(siteId)){ setSite(null); setSiteId(null); }
+ notify(`已删除 ${ids.length} 个站点`);
+ }catch(e:any){ notify("删除失败: "+e.message); }
+ };
+
+ const go=async(target:PageKey)=>{
+ try{
+ if(["basic","assets","functions","generate","result"].includes(target) && !siteId){
+ await createSite();
+ }
+ if(target==="assets" && siteId && (!site || site.id!==siteId)) await loadSite(siteId);
+ if(target==="functions" && siteId && (!site || site.id!==siteId)) await loadSite(siteId);
+ if(target==="generate" && siteId && (!site || site.id!==siteId)) await loadSite(siteId);
+ if(target==="result" && siteId && (!site || site.id!==siteId)) await loadSite(siteId);
+ setPage(target);
+ window.history.replaceState({}, "", "?page="+target+(siteId?"&id="+siteId:""));
+ }catch(e:any){
+ notify("操作失败: "+(e?.message||e));
+ }
+ };
+
+ const saveSiteBasic=async(patch:Partial<SiteDetail>)=>{
+ if(!site) return;
+ const next = {...site, ...patch};
+ setSite(next);
+ setSaving(true);
+ try{
+ await fetchDb(`/sites/${site.id}`, {method:"PUT", headers:{"Content-Type":"application/json"}, body:JSON.stringify(next)});
+ setSites(prev=>prev.map(s=>s.id===site.id?{...s, name:next.name, domain:next.domain, status:next.status}:s));
+ }catch(e:any){ notify("保存失败: "+e.message); }
+ finally{ setSaving(false); }
+ };
+
+ const saveAssets=async(assets:Asset[])=>{
+ if(!site) return;
+ setSite({...site, assets});
+ setSaving(true);
+ try{
+ await fetchDb(`/sites/${site.id}/assets`, {method:"PUT", headers:{"Content-Type":"application/json"}, body:JSON.stringify({assets})});
+ }catch(e:any){ notify("资产保存失败: "+e.message); }
+ finally{ setSaving(false); }
+ };
+
+ const saveMu=async(selected:string[])=>{
+ if(!site) return;
+ setSite({...site, mu:selected});
+ try{
+ await fetchDb(`/sites/${site.id}/mu`, {method:"PUT", headers:{"Content-Type":"application/json"}, body:JSON.stringify({selected})});
+ }catch(e:any){ notify("MU 选择保存失败: "+e.message); }
+ };
+
+ const addDomain=async(name:string)=>{
+ if(!name || name.length>6) return;
+ try{
+ await fetchDb("/domains", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({name})});
+ setDomains(prev=>[...new Set([...prev, name])]);
+ }catch(e:any){ notify("添加领域失败: "+e.message); }
+ };
+
+ const uploadFile=async(file:File)=>{
+ if(!site) return;
+ const reader = new FileReader();
+ reader.readAsDataURL(file);
+ const data = await new Promise<string>(res=>reader.onload=()=>res(String(reader.result)));
+ const base64 = data.split(",")[1];
+ try{
+ const result = await fetchDb(`/sites/${site.id}/files`, {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({filename:file.name, mime:file.type, data:base64})}) as ReferenceFile;
+ setSite({...site, files:[...site.files, result]});
+ notify("已上传 "+file.name);
+ }catch(e:any){ notify("上传失败: "+e.message); }
+ };
+
const sendAi=async()=>{
const text=aiDraft.trim();
if(!text||aiBusy)return;
else c.push({role:"assistant",content:"[连接出错] "+(e?.message||e)}); return c;});
}finally{ setAiBusy(false); }
};
+
if(page==="login")return <Login onLogin={()=>go("projects")}/>;
+
return <div className="app-shell">
<header className="topbar"><button className="hamburger">☰</button><div className="brand-mark">⬡</div><strong>iStation</strong><span className="divider"/><span>iStation Team</span><span className="divider"/><span>StationBuilder⌄</span><div className="top-actions"><span>⌕</span><span>♧<i/></span><span>?</span><span>▦</span><button title="查看内部登录页" className="avatar avatar-button" onClick={()=>go("login")}>W</button><small>wang.yu@advantech.com<br/>iStation Team</small></div></header>
<aside className="icon-rail">{nav.map(item=><button title={meta[item].label} key={item} onClick={()=>go(item)} className={page===item?"active":""}>{meta[item].icon}</button>)}<button className="rail-bottom">»</button></aside>
- <aside className="side-nav"><div className="module-switch"><button className={!(["domains","domainDetail"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("projects")}><b>▦</b><span>StationBuilder<small>站点生成工具</small></span></button><button className={(["domains","domainDetail"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("domains")}><b>◆</b><span>领域方案库<small>行业标准方案</small></span></button></div>{(["domains","domainDetail"] as PageKey[]).includes(page)?<><small>方案库导航</small><button className={page==="domains"?"active":""} onClick={()=>go("domains")}><b>▦</b>全部领域方案</button><button className={page==="domainDetail"?"active":""} onClick={()=>go("domainDetail")}><b>◇</b>方案详情</button><small>领域分类</small>{["燃气","水务 · 自来水厂","新能源","数据中心"].map((x,i)=><button key={x} className={i===1&&page==="domainDetail"?"active":""} onClick={()=>i===1?go("domainDetail"):notify(x+"方案预览已切换")}><b>{["♨","≋","☀","▥"][i]}</b>{x}</button>)}</>:<><small>站点管理</small><button className={page==="projects"?"active":""} onClick={()=>go("projects")}><b>▦</b>站点项目</button><small>生成向导</small>{(["basic","assets","functions","ai","generate"] as PageKey[]).map((item,index)=><button key={item} className={page===item?"active":""} onClick={()=>go(item)}><em>{index+1}</em>{meta[item].label}</button>)}<small>方案交付</small><button className={page==="result"?"active":""} onClick={()=>go("result")}><b>◫</b>站点结果</button><button className={page==="preview"?"active":""} onClick={()=>go("preview")}><b>▣</b>页面预览</button></>}<div className="side-help">
+ <aside className="side-nav"><div className="module-switch"><button className={!(["domains","domainDetail"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("projects")}><b>▦</b><span>StationBuilder<small>站点生成工具</small></span></button><button className={(["domains","domainDetail"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("domains")}><b>◆</b><span>领域方案库<small>行业标准方案</small></span></button></div>{(["domains","domainDetail"] as PageKey[]).includes(page)?<><small>方案库导航</small><button className={page==="domains"?"active":""} onClick={()=>go("domains")}><b>▦</b>全部领域方案</button><button className={page==="domainDetail"?"active":""} onClick={()=>go("domainDetail")}><b>◇</b>方案详情</button><small>领域分类</small>{["燃气","水务 · 自来水厂","新能源","数据中心"].map((x,i)=><button key={x} className={i===1&&page==="domainDetail"?"active":""} onClick={()=>i===1?go("domainDetail"):notify(x+"方案预览已切换")}><b>{["♨","≋","☀","▥"][i]}</b>{x}</button>)}</>:<><small>站点管理</small><button className={page==="projects"?"active":""} onClick={()=>go("projects")}><b>▦</b>站点项目</button><small>生成向导</small>{(["basic","assets","functions","generate"] as PageKey[]).map((item,index)=><button key={item} className={page===item?"active":""} onClick={()=>go(item)}><em>{index+1}</em>{meta[item].label}</button>)}<small>方案交付</small><button className={page==="result"?"active":""} onClick={()=>go("result")}><b>◫</b>站点结果</button><button className={page==="preview"?"active":""} onClick={()=>go("preview")}><b>▣</b>页面预览</button></>}<div className="side-help">
<b>内部工具 · v0.3</b>
<span className="model-trigger" onClick={() => setModelOpen(o => !o)}>
Wordbuddy AI · {model}<span className="caret">▾</span>
</>
)}
</div></aside>
- <main className="workspace"><div className="crumb">iStation StationBuilder / <b>{meta[page].label}</b></div><div className="page-heading"><div><h1>{meta[page].label}</h1><p>{meta[page].subtitle}</p></div><div>{!["projects","domains","domainDetail"].includes(page)&&<Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>}{page==="projects"&&<Button onClick={()=>go("basic")}>+ 新建站点</Button>}{page==="domains"&&<Button onClick={()=>notify("已打开新增领域方案向导")}>+ 新增领域方案</Button>}{page==="domainDetail"&&<Button onClick={()=>{notify("已应用水务方案模板");go("basic")}}>使用此方案创建站点</Button>}</div></div>{(["basic","assets","functions","ai","generate"] as PageKey[]).includes(page)&&<Steps page={page} go={go}/>}<div className="content">
- {page==="projects"&&<Projects view={view} setView={setView} go={go} notify={notify}/>} {page==="domains"&&<DomainLibrary go={go} notify={notify}/>} {page==="domainDetail"&&<DomainDetail go={go} notify={notify}/>} {page==="basic"&&<Basic openAi={()=>setModal("ai")}/>} {page==="assets"&&<Assets rows={assets} setRows={setAssets} total={totalAssets} notify={notify}/>} {page==="functions"&&<Functions tab={functionTab} setTab={setFunctionTab} selected={selectedMu} setSelected={setSelectedMu} notify={notify}/>} {page==="ai"&&<AIConfig notify={notify}/>} {page==="generate"&&<Generate progress={progress} generate={generate}/>} {page==="result"&&<Result go={go} setModal={setModal}/>} {page==="preview"&&<Preview tab={previewTab} setTab={setPreviewTab} setModal={setModal} notify={notify}/>}
- </div>{(["basic","assets","functions","ai"] as PageKey[]).includes(page)&&<div className="sticky-actions"><Button secondary onClick={()=>go(page==="basic"?"projects":page==="assets"?"basic":page==="functions"?"assets":"functions")}>上一步</Button><div><Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button><Button onClick={()=>go(page==="basic"?"assets":page==="assets"?"functions":page==="functions"?"ai":"generate")}>下一步</Button></div></div>}</main>
- {toast&&<div className="toast"><b>✓</b>{toast}</div>}{modal&&<Modal type={modal} close={()=>setModal("")} notify={notify}/>}
+ <main className="workspace"><div className="crumb">iStation StationBuilder / <b>{meta[page].label}</b></div><div className="page-heading"><div><h1>{meta[page].label}</h1><p>{meta[page].subtitle}</p></div><div>{!["projects","domains","domainDetail"].includes(page)&&<Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>}{page==="projects"&&<Button onClick={()=>go("basic")}>+ 新建站点</Button>}{page==="domains"&&<Button onClick={()=>notify("已打开新增领域方案向导")}>+ 新增领域方案</Button>}{page==="domainDetail"&&<Button onClick={()=>{notify("已应用水务方案模板");go("basic")}}>使用此方案创建站点</Button>}</div></div>{(["basic","assets","functions","generate"] as PageKey[]).includes(page)&&<Steps page={page} go={go}/>}<div className="content">
+ {page==="projects"&&<Projects view={view} setView={setView} sites={sites} loading={loading} go={go} loadSite={loadSite} notify={notify} onDelete={deleteSites}/>}
+ {page==="domains"&&<DomainLibrary go={go} notify={notify}/>}
+ {page==="domainDetail"&&<DomainDetail go={go} notify={notify}/>}
+ {page==="basic"&&<Basic site={site} domains={domains} onChange={saveSiteBasic} onAddDomain={addDomain} onUpload={uploadFile} saving={saving}/>}
+ {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}/>}
+ {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}/>}
{!aiOpen&&<button className="ai-fab" title="AI 会话" onClick={()=>setAiOpen(true)}>✦</button>}
{aiOpen&&<div className="ai-dialog">
<header><span className="ai-dialog-logo">✦</span><div className="ai-dialog-title"><b>StationBuilder AI</b><small>模型 · {model}</small></div><button className="ai-close" onClick={()=>setAiOpen(false)}>×</button></header>
}
function Login({onLogin}:{onLogin:()=>void}){return <div className="login-page"><div className="login-top"><div className="brand-mark">⬡</div><b>iStation</b><span>StationBuilder</span></div><div className="login-card"><div className="login-logo">⬡</div><h1>iStation StationBuilder</h1><p>面向 iStation 团队的 AI 站点与领域方案生成工具</p><button className="sso-button" onClick={onLogin}><span className="avatar">W</span><b>使用公司账号登录</b><i>→</i></button><small>仅限 iStation 团队内部成员访问</small></div><footer>© 2026 Advantech · iStation Team · v0.3</footer></div>}
-function Steps({page,go}:{page:PageKey;go:(p:PageKey)=>void}){const list:PageKey[]=["basic","assets","functions","ai","generate"];const current=list.indexOf(page);return <div className="steps">{list.map((item,index)=><button key={item} onClick={()=>index<=current&&go(item)} className={index===current?"active":index<current?"done":""}><i>{index<current?"✓":index+1}</i><span><small>STEP {index+1}</small>{meta[item].label}</span>{index<4&&<b>›</b>}</button>)}</div>}
+function Steps({page,go}:{page:PageKey;go:(p:PageKey)=>void}){const list:PageKey[]=["basic","assets","functions","generate"];const current=list.indexOf(page);return <div className="steps">{list.map((item,index)=><button key={item} onClick={()=>index<=current&&go(item)} className={index===current?"active":index<current?"done":""}><i>{index<current?"✓":index+1}</i><span><small>STEP {index+1}</small>{meta[item].label}</span>{index<3&&<b>›</b>}</button>)}</div>}
+
+function Projects({view,setView,sites,loading,go,loadSite,notify,onDelete}:{view:"table"|"card";setView:(v:"table"|"card")=>void;sites:SiteSummary[];loading:boolean;go:(p:PageKey)=>void;loadSite:(id:number)=>Promise<void>;notify:(m:string)=>void;onDelete:(ids:number[])=>Promise<void>}){
+ const counts = useMemo(()=>{
+ const all = sites.length;
+ const draft = sites.filter(s=>s.status==="draft").length;
+ const running = sites.filter(s=>s.status==="running").length;
+ const done = sites.filter(s=>s.status==="generated").length;
+ const partial = sites.filter(s=>s.status==="partial").length;
+ return {all,draft,running,done,partial};
+ },[sites]);
+ const [selected,setSelected] = useState<Set<number>>(new Set());
+ const toggle = (id:number)=> setSelected(prev=>{ const n=new Set(prev); if(n.has(id)) n.delete(id); else n.add(id); return n; });
+ const allSelected = sites.length>0 && selected.size===sites.length;
+ const toggleAll = ()=> setSelected(allSelected ? new Set() : new Set(sites.map(s=>s.id)));
+ const batchDelete = async ()=>{ await onDelete([...selected]); setSelected(new Set()); };
+ if(loading) return <Panel><div className="empty-state">加载中…</div></Panel>;
+ 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={async()=>{await loadSite(row.id); go(row.status==="generated"?"result":"basic");}}><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={async()=>{await loadSite(row.id); go(row.status==="generated"?"result":"basic");}}><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}){
+ const [newDomain,setNewDomain]=useState("");
+ const [domainInput,setDomainInput]=useState(site?.domain||"");
+ const [showAdd,setShowAdd]=useState(false);
+ useEffect(()=>{ if(site) setDomainInput(site.domain||""); },[site?.domain]);
+ if(!site) return <Panel><div className="empty-state">加载中…</div></Panel>;
+ const nameTooLong = countChars(site.name)>20;
+ const domainTooLong = newDomain.length>6;
+ return <div className="two-column basic-layout"><Panel title="站点基本信息"><div className="form-grid">
+ <Field label="站点名称" required hint="最多 20 个汉字"><input value={site.name} maxLength={20} onChange={e=>onChange({name:e.target.value})} className={nameTooLong?"invalid":""}/></Field>
+ <Field label="所属领域" required>
+ <div className="domain-select">
+ <input list="domain-list" value={domainInput} onChange={e=>{setDomainInput(e.target.value); onChange({domain:e.target.value});}} placeholder="选择或输入新领域"/>
+ <datalist id="domain-list">{domains.map(d=><option key={d} value={d}/>)}</datalist>
+ <button className="text-button" onClick={()=>setShowAdd(!showAdd)}>{showAdd?"收起":"+ 新增领域"}</button>
+ </div>
+ {showAdd&&<div className="domain-add">
+ <input value={newDomain} maxLength={6} placeholder="新领域名称(最多6字)" onChange={e=>setNewDomain(e.target.value)}/>
+ <Button secondary onClick={()=>{onAddDomain(newDomain); setNewDomain(""); setShowAdd(false);}} disabled={!newDomain || domainTooLong}>保存</Button>
+ </div>}
+ </Field>
+ <Field label="站点基本情况描述" required hint="建议包含站点用途、重点设备、管理目标及关注指标"><textarea value={site.description} onChange={e=>onChange({description:e.target.value})}/></Field>
+ <Field label="补充要求"><textarea className="small" value={site.supplement} onChange={e=>onChange({supplement:e.target.value})}/></Field>
+ <Field label="参考资料">
+ <label className="upload">
+ <input type="file" onChange={e=>{const f=e.target.files?.[0]; if(f) onUpload(f);}} hidden/>
+ <b>+</b><span>点击或拖拽上传需求文档、设备清单或参考图片</span><small>支持 PDF、DOCX、XLSX、PNG,单个文件不超过 20 MB</small>
+ </label>
+ {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>;
+}
+
+function Assets({site,onChange,saving}:{site:SiteDetail|null;onChange:(assets:Asset[])=>void;saving:boolean}){
+ if(!site) return <Panel><div className="empty-state">加载中…</div></Panel>;
+ const rows = site.assets.length ? site.assets : Array.from({length:4},(_,i)=>({type:"",count:0,rule:"",sort_order:i}));
+ const total = rows.reduce((n,a)=>n+Number(a.count||0),0);
+ const update=(i:number, patch:Partial<Asset>)=>{
+ const next = rows.map((r,idx)=>idx===i?{...r,...patch}:r);
+ onChange(next);
+ };
+ const add=()=>onChange([...rows,{type:"",count:0,rule:"",sort_order:rows.length}]);
+ const remove=(i:number)=>onChange(rows.filter((_,idx)=>idx!==i));
+ return <><div className="kpi-row">{[[rows.length,"设备类型","类"],[total,"设备实例","个"],[Math.max(2, rows.filter(r=>r.type).length+1),"资产层级","层"]].map(x=><div key={x[1]}><span>{x[1]}</span><b>{x[0]}</b><small>{x[2]}</small></div>)}{saving&&<div><span>保存</span><Status>保存中…</Status></div>}</div>
+ <Panel title="设备资产配置" tools={<Button onClick={add}>+ 添加设备类型</Button>}>
+ <table className="data-table editable">
+ <thead><tr><th>设备类型 *</th><th>数量 *</th><th>命名规则</th><th>操作</th></tr></thead>
+ <tbody>{rows.map((row,i)=><tr key={i} className={!row.type?"empty-row":""}>
+ <td><input value={row.type} placeholder="例如:冷水机组" onChange={e=>update(i,{type:e.target.value})}/></td>
+ <td><input className="number" type="number" min={0} value={row.count||""} placeholder="0" onChange={e=>update(i,{count:Number(e.target.value)})}/></td>
+ <td><input value={row.rule} placeholder="例如:CH-##" onChange={e=>update(i,{rule:e.target.value})}/></td>
+ <td><button className="trash" onClick={()=>remove(i)}>⌫</button></td>
+ </tr>)}</tbody>
+ </table>
+ </Panel>
+ <Panel title="资产层级预览"><div className="asset-tree-preview">
+ <div className="tree-root">⌄ <b>{site.name || "未命名站点"}</b></div>
+ {rows.filter(r=>r.type).map((row,i)=><div key={i} className="tree-branch">
+ <div className="tree-node">⌄ {row.type} <span className="tree-count">{row.count}</span></div>
+ {row.count>0&&<div className="tree-leaves">{formatInstances(row.rule, row.count)}</div>}
+ </div>)}
+ </div></Panel>
+ </>;
+}
+
+function Functions({site,muTree,onMuChange,onSiteChange,notify}:{site:SiteDetail|null;muTree:MuNode[];onMuChange:(s:string[])=>void;onSiteChange:(s:SiteDetail)=>void;notify:(m:string)=>void}){
+ const [tab,setTab]=useState<"mu"|"cube"|"menu">("mu");
+ const selectedMu = site?.mu || [];
+ const leaves = useMemo(()=>buildMuLeaves(muTree),[muTree]);
+ const grouped = useMemo(()=>{
+ const map = new Map<string,{parentMenu:string, items:typeof leaves}>();
+ for(const leaf of leaves){
+ if(!map.has(leaf.parentMenu)) map.set(leaf.parentMenu, {parentMenu:leaf.parentMenu, items:[]});
+ map.get(leaf.parentMenu)!.items.push(leaf);
+ }
+ return Array.from(map.values());
+ },[leaves]);
+ if(!site) return <Panel><div className="empty-state">加载中…</div></Panel>;
+ const toggleMu=(key:string)=>{
+ onMuChange(selectedMu.includes(key)?selectedMu.filter(k=>k!==key):[...selectedMu,key]);
+ };
+ const updateCube=(idx:number, patch:Partial<Cube>)=>{
+ const next = site.cubes.map((c,i)=>i===idx?{...c,...patch}:c);
+ onSiteChange({...site, cubes:next});
+ };
+ const saveCube=async(idx:number)=>{
+ const cube = site.cubes[idx];
+ try{
+ if(cube.id){
+ await fetchDb(`/sites/${site.id}/cubes/${cube.id}`, {method:"PUT", headers:{"Content-Type":"application/json"}, body:JSON.stringify(cube)});
+ } else {
+ const created = await fetchDb(`/sites/${site.id}/cubes`, {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(cube)}) as Cube;
+ const next = [...site.cubes]; next[idx] = {...cube, id:created.id!}; onSiteChange({...site, cubes:next});
+ }
+ notify("Cube 页面已保存");
+ }catch(e:any){ notify("保存失败: "+e.message); }
+ };
+ const addCube=()=>onSiteChange({...site, cubes:[...site.cubes, {name:"",content:"",status:"pending",sort_order:site.cubes.length}]});
+ const removeCube=async(idx:number)=>{
+ const cube = site.cubes[idx];
+ if(cube.id) try{ await fetchDb(`/sites/${site.id}/cubes/${cube.id}`, {method:"DELETE"}); }catch(e:any){ notify("删除失败: "+e.message); return; }
+ onSiteChange({...site, cubes:site.cubes.filter((_,i)=>i!==idx)});
+ };
+ const generateCube=async(idx:number)=>{
+ const cube = site.cubes[idx];
+ if(!cube.id) await saveCube(idx);
+ notify(`开始生成 ${cube.name}…`);
+ try{
+ await fetchDb(`/sites/${site.id}/cubes/${cube.id}/generate`, {method:"POST"});
+ notify(`${cube.name} 生成完成`);
+ const refreshed:SiteDetail = await fetchDb(`/sites/${site.id}`);
+ onSiteChange(refreshed);
+ }catch(e:any){ notify("生成失败: "+e.message); }
+ };
+ const downloadCube=(cube:Cube)=>{
+ if(!cube.id) return;
+ fetch(`/api/db/sites/${site.id}/cubes/${cube.id}/file`).then(r=>r.blob()).then(b=>downloadBlob(b, cube.gen_filename || `${cube.name}.json`)).catch(e=>notify("下载失败"));
+ };
+ const previewCube=(cube:Cube)=>{
+ notify(`预览 ${cube.name}`);
+ };
-function Projects({view,setView,go,notify}:{view:"table"|"card";setView:(v:"table"|"card")=>void;go:(p:PageKey)=>void;notify:(m:string)=>void}){return <><div className="summary-strip">{[["12","全部项目"],["3","草稿"],["1","生成中"],["7","生成完成"],["1","部分失败"]].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><select><option>全部创建人</option></select><Button>Search</Button><span/><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>站点名称 ↕</th><th>所属领域</th><th>Asset</th><th>MU 页面</th><th>Cube 页面</th><th>状态</th><th>最近修改</th><th>创建人</th><th>操作</th></tr></thead><tbody>{projects.map((row,i)=><tr key={row[0]}><td><button className="link" onClick={()=>go(i===0?"result":"basic")}><span className="site-icon">⬡</span><b>{row[0]}</b></button></td>{row.slice(1,5).map((v,n)=><td key={n}>{v}</td>)}<td><Status>{row[5]}</Status></td><td>{row[6]}</td><td>{row[7]}</td><td><button className="dots">⋯</button></td></tr>)}</tbody></table>:<div className="project-grid">{projects.map((row,i)=><article key={row[0]} onClick={()=>go(i===0?"result":"basic")}><div><span className="site-icon big">⬡</span><Status>{row[5]}</Status></div><h3>{row[0]}</h3><p>{row[1]} · 由 {row[7]} 创建</p><section><span><b>{row[2]}</b> Asset</span><span><b>{row[3]}</b> MU</span><span><b>{row[4]}</b> Cube</span></section><small>更新于 {row[6]}</small></article>)}</div>}<div className="pagination"><span>共 12 条</span><button>‹</button><button className="active">1</button><button>2</button><button>3</button><button>›</button><select><option>20 / page</option></select></div></Panel></>}
+ // menu editor state
+ const [menu, setMenu] = useState<MenuItem[]>([]);
+ useEffect(()=>{ setMenu(site.menu.length?site.menu:[{parent_id:null,label:"站点监控",page_type:"",ref_id:"",sort_order:0}]); },[site?.menu]);
+ const saveMenu=async()=>{
+ try{
+ await fetchDb(`/sites/${site.id}/menu`, {method:"PUT", headers:{"Content-Type":"application/json"}, body:JSON.stringify({menu})});
+ onSiteChange({...site, menu});
+ notify("菜单结构已保存");
+ }catch(e:any){ notify("菜单保存失败: "+e.message); }
+ };
+
+ return <Panel><div className="tabs"><button className={tab==="mu"?"active":""} onClick={()=>setTab("mu")}>MU 标准页面 <i>{selectedMu.length}</i></button><button className={tab==="cube"?"active":""} onClick={()=>setTab("cube")}>Cube 扩展页面 <i>{site.cubes.length}</i></button><button className={tab==="menu"?"active":""} onClick={()=>setTab("menu")}>菜单结构</button></div>
+ {tab==="mu"&&<div className="mu-tree">{grouped.map(g=><div key={g.parentMenu} className="mu-group"><b>{g.parentMenu}</b><div className="mu-items">{g.items.map(item=><label key={item.key} className={item.disabled?"disabled":""} title={item.note}><input type="checkbox" disabled={item.disabled} checked={selectedMu.includes(item.key)} onChange={()=>toggleMu(item.key)}/><span>{item.label}{item.note&&<small>{item.note}</small>}</span></label>)}</div></div>)}</div>}
+ {tab==="cube"&&<div className="cube-editor"><table className="data-table editable"><thead><tr><th>页面名称 <small>最多6字</small></th><th>页面内容</th><th>生成文件</th><th>操作</th></tr></thead><tbody>{site.cubes.map((cube,i)=><tr key={i}>
+ <td><input value={cube.name} maxLength={6} placeholder="例如:运行总览" onChange={e=>updateCube(i,{name:e.target.value})}/></td>
+ <td><input value={cube.content} placeholder="描述该页面要展示的内容和指标" onChange={e=>updateCube(i,{content:e.target.value})}/></td>
+ <td>{cube.status==="generated"? <button className="link" onClick={()=>downloadCube(cube)}>下载 {cube.gen_filename}</button> : <span className="muted">未生成</span>}</td>
+ <td className="cube-actions"><button onClick={()=>previewCube(cube)}>预览</button><button onClick={()=>generateCube(i)}>生成</button><button onClick={()=>saveCube(i)}>保存</button><button className="trash" onClick={()=>removeCube(i)}>删除</button></td>
+ </tr>)}</tbody></table><Button onClick={addCube}>+ 添加 Cube 页面</Button></div>}
+ {tab==="menu"&&<div className="menu-editor"><p className="hint">配置站点菜单结构,可选择引用 MU 标准页或 Cube 扩展页。</p>{menu.map((m,i)=><div key={i} className="menu-row"><input value={m.label} placeholder="菜单名称" onChange={e=>{const next=[...menu]; next[i]={...next[i],label:e.target.value}; setMenu(next);}}/>
+ <select value={m.page_type||""} onChange={e=>{const next=[...menu]; next[i]={...next[i],page_type:e.target.value as any}; setMenu(next);}}><option value="">分组</option><option value="mu">MU 页面</option><option value="cube">Cube 页面</option></select>
+ {m.page_type==="mu"&&<select value={String(m.ref_id)} onChange={e=>{const next=[...menu]; next[i]={...next[i],ref_id:e.target.value}; setMenu(next);}}><option value="">选择 MU 页面</option>{leaves.filter(l=>!l.disabled).map(l=><option key={l.key} value={l.key}>{l.parentMenu}/{l.label}</option>)}</select>}
+ {m.page_type==="cube"&&<select value={String(m.ref_id)} onChange={e=>{const next=[...menu]; next[i]={...next[i],ref_id:Number(e.target.value)}; setMenu(next);}}><option value="">选择 Cube 页面</option>{site.cubes.map(c=><option key={c.id||c.name} value={c.id||""}>{c.name}</option>)}</select>}
+ <button className="trash" onClick={()=>setMenu(menu.filter((_,idx)=>idx!==i))}>删除</button>
+ </div>)}<Button secondary onClick={()=>setMenu([...menu,{parent_id:null,label:"",page_type:"",ref_id:"",sort_order:menu.length}])}>+ 添加菜单项</Button><Button onClick={saveMenu}>保存菜单</Button></div>}
+ </Panel>;
+}
-function Basic({openAi}:{openAi:()=>void}){return <div className="two-column basic-layout"><Panel title="站点基本信息"><div className="form-grid"><Field label="站点名称" required><input defaultValue="苏州工业园能源站"/></Field><Field label="所属领域" required><select defaultValue="工业园区"><option>工业园区</option><option>数据中心</option><option>智慧楼宇</option></select></Field><Field label="站点基本情况描述" required hint="建议包含站点用途、重点设备、管理目标及关注指标"><textarea defaultValue="该站点是苏州工业园区的综合能源管理示范站,包含制冷、配电和水循环系统。管理人员重点关注设备在线率、实时负载、能源消耗、异常告警和节能空间,希望首页可以快速掌握全站运行状态。"/></Field><Field label="补充要求"><textarea className="small" defaultValue="页面采用浅色工业风格,重点异常使用醒目的状态颜色;模拟数据覆盖正常、离线和告警设备。"/></Field><Field label="参考资料"><div className="upload"><b>+</b><span>点击或拖拽上传需求文档、设备清单或参考图片</span><small>支持 PDF、DOCX、XLSX、PNG,单个文件不超过 20 MB</small></div></Field></div></Panel><div className="stack"><Panel title="AI 描述助手"><div className="ai-box"><span>AI</span><h3>当前描述完整度 82%</h3><div className="meter"><i style={{width:"82%"}}/></div><p>信息已经较完整。建议补充主要使用角色和希望重点展示的能耗周期。</p><Button onClick={openAi}>✦ 帮我完善描述</Button></div></Panel><Panel title="填写建议"><ul className="tips"><li>站点主要承担什么业务?</li><li>哪些设备最重要?</li><li>管理人员最关注哪些指标?</li><li>希望首页首先看到什么?</li></ul></Panel></div></div>}
+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}){
+ const [running,setRunning]=useState(false);
+ const [tasks,setTasks]=useState<{name:string;status:"pending"|"running"|"done"|"failed";message?:string}[]>([]);
+ const [progress,setProgress]=useState(0);
+ const [zipUrl,setZipUrl]=useState("");
+ const totalRef = useRef(0);
+ if(!site) return <Panel><div className="empty-state">加载中…</div></Panel>;
+ const scope=[
+ ["站点",site.name||"未命名站点",site.domain],
+ ["Asset 资产",`${site.assets.filter(a=>a.type).length} 类 / ${site.assets.reduce((n,a)=>n+Number(a.count||0),0)} 个设备`,`3 层资产结构(未开发)`],
+ ["MU 页面",`${site.mu.length} 个标准页面`,`直接复用并配置菜单(未开发)`],
+ ["Cube 页面",`${site.cubes.length} 个扩展页面`,`生成 ${site.cubes.length} 个独立 JSON`],
+ ];
+ const start=async()=>{
+ setRunning(true); setZipUrl("");
+ const initial = [{name:"模型准备",status:"pending" as const}, ...site.cubes.map(c=>({name:c.name,status:"pending" as const})), {name:"打包与检查校验",status:"pending" as const}];
+ totalRef.current = initial.length;
+ setTasks(initial); setProgress(0);
+ try{
+ const res = await fetch(`/api/db/sites/${site.id}/generate-all`, {method:"POST"});
+ 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.task){
+ setTasks(prev=>{
+ const idx = prev.findIndex(t=>t.name===ev.task);
+ const next = [...prev];
+ if(idx>=0) next[idx] = {name:ev.task, status:ev.status, message:ev.message};
+ const doneCount = next.filter(t=>t.status==="done").length;
+ setProgress(Math.round((doneCount / totalRef.current) * 100));
+ return next;
+ });
+ }
+ if(ev.done){ setZipUrl(ev.zipUrl); setProgress(100); }
+ }catch{}
+ }
+ }
+ const refreshed:SiteDetail = await fetchDb(`/sites/${site.id}`);
+ onSiteChange(refreshed);
+ notify("生成完成");
+ }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">{[
+ {label:"生成 Asset Excel", disabled:true, note:"未开发"},
+ {label:"生成菜单配置", disabled:false},
+ {label:"生成 Cube JSON", disabled:false},
+ {label:"创建合理的模拟数据", disabled:false},
+ {label:"生成页面预览", disabled:false},
+ {label:"执行 Cube JSON 校验", disabled:false},
+ ].map(x=><label key={x.label} className={x.disabled?"disabled":""}><input type="checkbox" defaultChecked={!x.disabled} disabled={x.disabled}/><span>{x.label}{x.note&&<small>{x.note}</small>}</span></label>)}</div></Panel></div>
+ <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 Assets({rows,setRows,total,notify}:{rows:typeof startAssets;setRows:(r:typeof startAssets)=>void;total:number;notify:(m:string)=>void}){return <><div className="kpi-row">{[[rows.length,"设备类型","类"],[total,"设备实例","个"],[3,"资产层级","层"]].map(x=><div key={x[1]}><span>{x[1]}</span><b>{x[0]}</b><small>{x[2]}</small></div>)}<div><span>数据检查</span><Status>全部通过</Status></div></div><Panel title="设备类型配置" tools={<><Button secondary onClick={()=>notify("已生成设备建议")}>✦ AI 推荐设备</Button><Button onClick={()=>setRows([...rows,{type:"新设备类型",count:1,rule:"DEVICE-##",parent:"站点",area:"待配置"}])}>+ 添加设备类型</Button></>}><table className="data-table editable"><thead><tr><th>设备类型 *</th><th>数量 *</th><th>命名规则</th><th>上级资产</th><th>所属区域</th><th>实例预览</th><th>操作</th></tr></thead><tbody>{rows.map((row,i)=><tr key={i}><td><input value={row.type} onChange={e=>setRows(rows.map((r,n)=>n===i?{...r,type:e.target.value}:r))}/></td><td><input className="number" type="number" value={row.count} onChange={e=>setRows(rows.map((r,n)=>n===i?{...r,count:Number(e.target.value)}:r))}/></td><td><input defaultValue={row.rule}/></td><td><select defaultValue={row.parent}><option>{row.parent}</option><option>站点</option></select></td><td><input defaultValue={row.area}/></td><td><button className="link" onClick={()=>notify("已打开设备实例预览")}>查看 {row.count} 个实例</button></td><td><button className="trash" onClick={()=>setRows(rows.filter((_,n)=>n!==i))}>⌫</button></td></tr>)}</tbody></table></Panel><div className="two-column equal"><Panel title="资产层级预览"><div className="tree"><div>⌄ <b>苏州工业园能源站</b></div><div className="l1">⌄ 制冷系统 <span>3</span></div><div className="l2">冷水机组 CH-01 ~ CH-03</div><div className="l1">⌄ 水循环系统 <span>6</span></div><div className="l2">循环水泵 PUMP-01 ~ PUMP-06</div><div className="l1">⌄ 配电系统 <span>16</span></div><div className="l2">配电柜及智能电表</div></div></Panel><Panel title="Asset Excel" tools={<Button secondary onClick={()=>notify("已打开 Excel 预览")}>预览文件</Button>}><div className="file-card"><span className="excel">X</span><div><b>asset-import-template.xlsx</b><p>25 条资产记录 · 8 个字段 · 数据检查通过</p></div><Button onClick={()=>notify("测试版 Excel 已下载")}>下载测试版</Button></div><div className="checks"><span>✓ Asset ID 唯一</span><span>✓ 设备名称无重复</span><span>✓ 资产层级完整</span></div></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}){
+ 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></>;
+}
-function Functions({tab,setTab,selected,setSelected,notify}:{tab:"mu"|"cube"|"menu";setTab:(t:"mu"|"cube"|"menu")=>void;selected:string[];setSelected:(s:string[])=>void;notify:(m:string)=>void}){const mu=[["设备总览","查看站点全部设备及状态分布"],["实时监控","查看设备实时参数与运行趋势"],["告警中心","统一查看实时及历史告警"],["能耗分析","查看能耗趋势和设备能耗排行"],["维修任务","管理设备维修任务和处理流程"],["维护计划","创建周期性设备维护计划"]];const cubes=[["站点运行总览","总览型","设备状态、在线率、今日告警、能源消耗及运行趋势","首页"],["能源效率分析","分析型","设备能效、能耗趋势、分项占比及节能空间","能源管理"],["设备健康度","监控型","设备健康评分、风险分布和异常设备排行","设备管理"]];return <Panel><div className="tabs"><button className={tab==="mu"?"active":""} onClick={()=>setTab("mu")}>MU 标准页面 <i>{selected.length}</i></button><button className={tab==="cube"?"active":""} onClick={()=>setTab("cube")}>Cube 扩展页面 <i>3</i></button><button className={tab==="menu"?"active":""} onClick={()=>setTab("menu")}>菜单结构</button></div><div className="section-toolbar"><div><b>{tab==="mu"?"选择可复用的 MU 标准页面":tab==="cube"?"定义需要 AI 生成的 Cube 扩展页面":"配置最终站点菜单结构"}</b><p>{tab==="mu"?"标准页面不重复生成,只配置页面引用和菜单位置":tab==="cube"?"每个扩展页面生成一个可导入 Cube 的 JSON":"拖拽页面调整菜单位置和顺序"}</p></div><Button onClick={()=>notify("AI 推荐方案已更新")}>✦ AI 推荐</Button></div>{tab==="mu"&&<div className="mu-grid">{mu.map((item,i)=>{const checked=selected.includes(item[0]);return <article className={checked?"selected":""} key={item[0]} onClick={()=>setSelected(checked?selected.filter(x=>x!==item[0]):[...selected,item[0]])}><div className={"mu-thumb t"+i}><b>{i%2?"▥":"▦"}</b></div><div><header><input readOnly type="checkbox" checked={checked}/><h3>{item[0]}</h3>{i<4&&<Status>推荐</Status>}</header><p>{item[1]}</p><button onClick={e=>{e.stopPropagation();notify("正在预览 "+item[0])}}>预览页面 →</button></div></article>})}</div>}{tab==="cube"&&<table className="data-table"><thead><tr><th>页面名称</th><th>页面类型</th><th>页面内容</th><th>所属菜单</th><th>生成文件</th><th>操作</th></tr></thead><tbody>{cubes.map((row,i)=><tr key={row[0]}><td><b className="blue">{row[0]}</b></td><td><span className="type-tag">{row[1]}</span></td><td className="wide-cell">{row[2]}</td><td>{row[3]}</td><td>page-0{i+1}.json</td><td><button className="dots">编辑 ⋯</button></td></tr>)}</tbody></table>}{tab==="menu"&&<div className="menu-builder"><div className="page-pool"><h3>可用页面</h3><p>拖拽页面到右侧菜单</p><div>⠿ 维修任务 · MU</div><div>⠿ 维护计划 · MU</div></div><div className="menu-tree"><h3>站点菜单</h3>{[["首页",["设备总览","站点运行总览"]],["设备管理",["实时监控","设备健康度"]],["能源管理",["能耗分析","能源效率分析"]]].map(x=><section key={String(x[0])}><b>⌄ {x[0]}</b><span>⋯</span>{(x[1] as string[]).map((n,i)=><div key={n}><i className={i?"cube":""}>{i?"C":"MU"}</i>{n}</div>)}</section>)}<section><b>· 告警中心</b><span>MU</span></section><button>+ 新增一级菜单</button></div><div className="menu-preview"><h3>菜单预览</h3><aside><strong>苏州工业园能源站</strong><button className="active">▦ 首页</button><button>▧ 设备管理 ⌄</button><button>▥ 能源管理 ⌄</button><button>△ 告警中心</button></aside></div></div>}</Panel>}
+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 [cubeIdx,setCubeIdx]=useState(0);
+ const cube = site?.cubes?.[cubeIdx];
+ return <div className="preview-shell"><div className="preview-toolbar"><div><select value={cubeIdx} onChange={e=>setCubeIdx(Number(e.target.value))}>{(site?.cubes||[]).map((c,i)=><option key={i} value={i}>{c.name}</option>)}</select><Status>{cube?.status==="generated"?"校验通过":"未生成"}</Status></div><div><button>1440 × 900⌄</button><button>☼ 浅色</button><button onClick={()=>notify("模拟数据已刷新")}>↻ 刷新数据</button><button>⛶ 全屏</button></div></div><div className="preview-main"><div className="cube-canvas"><div className="cube-top"><b>⬡ iStation</b><span>{site?.name || "未命名站点"}</span><i>⌕ ♧ ? ▦ W</i></div><aside className="cube-side"><strong>站点监控</strong><button className="active">▦ {cube?.name || "运行总览"}</button><button>▧ 设备管理</button><button>▥ 能源管理</button><button>△ 告警中心</button><button>⚙ 系统设置</button></aside><div className="cube-content"><header><div><small>站点监控 / {cube?.name}</small><h2>{site?.name || "未命名站点"}</h2></div><span>最后更新:刚刚 ↻</span></header><div className="cube-kpis">{[["设备在线率","92%","23 / 25"],["今日总能耗","12,480","kWh"],["未处理告警","7","高危 2"],["平均负载率","68%","较昨日 +3%"]].map((x,i)=><div key={x[0]}><span>{x[0]}</span><b>{x[1]}</b><small>{x[2]}</small><i className={"spark s"+i}/></div>)}</div><div className="cube-grid"><section className="line-panel"><h3>近 7 日能源消耗 <span>本周⌄</span></h3><div className="line-chart"><div/><svg viewBox="0 0 500 150" preserveAspectRatio="none"><polyline points="0,112 65,92 130,103 200,56 270,75 335,45 400,62 500,24" fill="none" stroke="#009fe3" strokeWidth="3"/><polyline points="0,132 65,118 130,121 200,98 270,102 335,82 400,91 500,68" fill="none" stroke="#8ecfee" strokeWidth="2"/></svg><footer>{["08/08","08/09","08/10","08/11","08/12","08/13","今天"].map(x=><span key={x}>{x}</span>)}</footer></div></section><section className="donut-panel"><h3>设备状态分布</h3><div className="donut"><svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="none" stroke="#E8E8E8" strokeWidth="14"/><circle cx="50" cy="50" r="40" fill="none" stroke="#009fe3" strokeWidth="14" strokeDasharray="180 251"/></svg><b>72%</b><span>在线</span></div></section></div></div></div></div></div>
+}
function DomainLibrary({go,notify}:{go:(p:PageKey)=>void;notify:(m:string)=>void}){
const domains=[
<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 AIConfig({notify}:{notify:(m:string)=>void}){
- const models=[
- {name:"Auto",tag:"自动选择",rate:"—",desc:"按任务类型自动选择最合适模型"},
- {name:"Hy3",tag:"夜间加速 · 限时免费",rate:"0.00x",desc:"当前推荐,适合复杂站点页面与 JSON 生成"},
- {name:"GLM-5.3",tag:"订阅优先",rate:"0.79x",desc:"综合能力强,适合页面规划与长文本"},
- {name:"GLM-5.2",tag:"夜间折扣",rate:"0.79x",desc:"稳定均衡,适合批量站点生成"},
- {name:"GLM-5.1",tag:"",rate:"0.79x",desc:"通用生成模型"},
- {name:"GLM-5v-Turbo",tag:"视觉理解",rate:"0.95x",desc:"适合结合参考截图生成页面"},
- {name:"MiniMax-M3",tag:"高性价比",rate:"0.25x",desc:"适合轻量内容补全"},
- {name:"Kimi-K3",tag:"订阅优先",rate:"1.62x",desc:"适合超长领域文档理解"},
- ];
- const [selected,setSelected]=useState("Hy3"); const [max,setMax]=useState(false);
- return <div className="ai-config-layout"><Panel title="Wordbuddy AI 模型"><div className="wordbuddy-card"><header><div><span className="wb-logo">W</span><section><b>Wordbuddy 模型服务</b><small>已连接 · 企业订阅</small></section></div><label>Max 模式 <input type="checkbox" checked={max} onChange={e=>setMax(e.target.checked)}/></label></header><div className="model-list">{models.map(m=><button key={m.name} className={selected===m.name?"selected":""} onClick={()=>setSelected(m.name)}><i>{m.name==="Auto"?"A":m.name==="Hy3"?"◉":"Z"}</i><span><b>{m.name}</b>{m.tag&&<small>{m.tag}</small>}<em>{m.desc}</em></span><strong>{m.rate}</strong>{selected===m.name&&<u>✓</u>}</button>)}</div><footer><button onClick={()=>notify("已打开自定义模型配置")}>✎ 配置自定义模型</button></footer></div></Panel>
- <div className="stack"><Panel title="当前模型"><div className="selected-model"><span>AI</span><section><small>已选择</small><h3>{selected}</h3><p>{models.find(x=>x.name===selected)?.desc}</p></section><Status>{max?"Max 模式":"标准模式"}</Status></div><dl className="model-facts"><dt>服务提供方</dt><dd>Wordbuddy</dd><dt>倍率</dt><dd>{models.find(x=>x.name===selected)?.rate}</dd><dt>上下文策略</dt><dd>自动加载站点与领域方案</dd><dt>失败回退</dt><dd>GLM-5.2</dd></dl></Panel>
- <Panel title="生成策略"><div className="strategy-list"><label><input type="radio" name="strategy" defaultChecked/><span><b>质量优先</b><small>更充分地规划页面、指标和模拟数据</small></span></label><label><input type="radio" name="strategy"/><span><b>速度优先</b><small>减少规划轮次,加快批量生成</small></span></label></div><div className="setting-row"><span><b>自动使用领域方案上下文</b><small>匹配领域库中的资产、页面和规则</small></span><input type="checkbox" defaultChecked/></div><div className="setting-row"><span><b>生成前进行需求补全</b><small>信息不足时由 AI 自动补充合理假设</small></span><input type="checkbox" defaultChecked/></div><div className="setting-row"><span><b>自动修复 Cube JSON</b><small>校验失败后最多自动修复 3 次</small></span><input type="checkbox" defaultChecked/></div></Panel>
- <Panel title="本次生成上下文"><div className="context-list"><span><i>▤</i><b>站点描述</b><em>已加载</em></span><span><i>▧</i><b>25 个 Asset 实例</b><em>已加载</em></span><span><i>◆</i><b>工业园区领域方案</b><em>已匹配</em></span><span><i>▣</i><b>3 个 Cube 页面需求</b><em>已加载</em></span><span><i>☷</i><b>站点菜单结构</b><em>已加载</em></span></div><div className="token-estimate"><span>预计生成任务</span><b>3 个页面 · 6 个模拟数据源 · 约 2 分钟</b></div></Panel>
- </div></div>
-}
-
-function Generate({progress,generate}:{progress:number;generate:()=>void}){
- const tasks=[["Wordbuddy 模型准备","Hy3 · 质量优先",12],["Asset Excel","asset-import-template.xlsx",25],["菜单配置","menu-config.json",38],["站点运行总览","page-01.json",55],["能源效率分析","page-02.json",72],["设备健康度","page-03.json",88],["结果打包与校验","station-package.zip",100]];
- const scope=[["站点","苏州工业园能源站","工业园区"],["Asset 资产","4 类 / 25 个设备","3 层资产结构"],["MU 页面","4 个标准页面","直接复用并配置菜单"],["Cube 页面","3 个扩展页面","生成 3 个独立 JSON"],["AI 配置","Wordbuddy · Hy3","质量优先 · 自动修复"]];
- 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>{x[0]==="AI 配置"?"修改模型":"返回修改"}</button></div>)}</div></Panel><Panel title="生成选项"><div className="option-list">{["生成 Asset Excel","生成菜单配置","生成 Cube JSON","创建合理的模拟数据","生成页面预览","执行 Cube JSON 校验"].map(x=><label key={x}><input type="checkbox" defaultChecked/><span>{x}</span></label>)}</div></Panel></div><Panel title="生成任务" className="task-panel"><div className="overall"><div><b>{progress===0?"准备就绪":progress===100?"生成完成":"正在生成站点方案"}</b><span>{progress}%</span></div><div className="meter"><i style={{width:progress+"%"}}/></div><p>{progress===0?"Wordbuddy Hy3 已就绪,预计需要约 2 分钟。":progress===100?"全部交付物已生成并通过校验。":"Wordbuddy Hy3 正在调用 Cube Skill 创建页面与模拟数据。"}</p></div><div className="task-list">{tasks.map(x=>{const done=progress>=Number(x[2]);const active=!done&&progress>0&&progress>=Number(x[2])-18;return <div key={x[0]}><i className={done?"done":active?"spin":""}>{done?"✓":active?"↻":"·"}</i><span><b>{x[0]}</b><small>{x[1]}</small></span><Status>{done?"成功":active?"生成中":"等待"}</Status></div>})}</div><Button disabled={progress>0&&progress<100} onClick={generate}>{progress===100?"重新生成":progress>0?"正在生成…":"开始生成"}</Button></Panel></div>
-}
-
-function Result({go,setModal}:{go:(p:PageKey)=>void;setModal:(m:ModalKey)=>void}){return <><div className="result-header"><div><span className="site-icon big">⬡</span><section><h2>苏州工业园能源站</h2><p>版本 V3 · 生成于 2026/08/14 11:20 · 王宇</p></section><Status>生成完成</Status></div><div><Button secondary onClick={()=>setModal("history")}>版本历史</Button><Button secondary onClick={()=>go("assets")}>修改配置</Button><Button onClick={()=>setModal("download")}>⇩ 下载全部</Button></div></div><div className="kpi-row">{[[25,"Asset 资产","4 类设备"],[4,"MU 页面","全部已配置"],[3,"Cube 页面","校验全部通过"],[7,"交付文件","4.8 MB"]].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">{[["站点运行总览","首页","12 个组件","page-01.json"],["能源效率分析","能源管理","9 个组件","page-02.json"],["设备健康度","设备管理","11 个组件","page-03.json"]].map((row,i)=><article key={row[0]}><div className={"dashboard-thumb d"+i}><i/><header/><section><b/><b/><b/></section><footer><em/><em/><em/><em/><em/></footer><span/></div><div><header><Status>校验通过</Status><small>Cube</small></header><h3>{row[0]}</h3><p>{row[1]} · {row[2]}</p><small>{row[3]}</small><footer><Button secondary onClick={()=>go("preview")}>预览</Button><Button secondary>⇩ JSON</Button></footer></div></article>)}</div></Panel><Panel title="Asset 交付物"><div className="file-card"><span className="excel">X</span><div><b>asset-import-template.xlsx</b><p>25 条资产记录 · 数据校验通过 · 128 KB</p></div><Button secondary>预览</Button><Button>下载</Button></div></Panel></div><div className="stack"><Panel title="站点菜单"><div className="result-menu">{[["▦ 首页",2],["▧ 设备管理",2],["▥ 能源管理",2],["△ 告警中心",1]].map(x=><div key={x[0]}><b>{x[0]}</b><span>{x[1]}</span></div>)}</div><button className="text-button">查看完整菜单配置 →</button></Panel><Panel title="校验报告"><div className="score"><b>100</b><span>全部通过</span></div><ul className="validation"><li>✓ JSON Schema</li><li>✓ 组件属性</li><li>✓ 模拟数据绑定</li><li>✓ Asset 数据一致性</li><li>✓ 页面 ID 唯一性</li></ul><button className="text-button">查看完整报告 →</button></Panel></div></div></>}
-
-function Preview({tab,setTab,setModal,notify}:{tab:"info"|"json"|"feedback";setTab:(t:"info"|"json"|"feedback")=>void;setModal:(m:ModalKey)=>void;notify:(m:string)=>void}){const json={pageId:"station-overview",title:"站点运行总览",layout:{type:"grid",columns:24,gap:12},dataSources:[{id:"asset-summary",type:"simulate"}],components:[{type:"kpi",title:"设备在线率"}]};return <div className="preview-shell"><div className="preview-toolbar"><div><select><option>站点运行总览</option><option>能源效率分析</option><option>设备健康度</option></select><Status>校验通过</Status></div><div><button>1440 × 900⌄</button><button>☼ 浅色</button><button onClick={()=>notify("模拟数据已刷新")}>↻ 刷新数据</button><button>⛶ 全屏</button></div></div><div className="preview-main"><div className="cube-canvas"><div className="cube-top"><b>⬡ iStation</b><span>苏州工业园能源站</span><i>⌕ ♧ ? ▦ W</i></div><aside className="cube-side"><strong>站点监控</strong><button className="active">▦ 运行总览</button><button>▧ 设备管理</button><button>▥ 能源管理</button><button>△ 告警中心</button><button>⚙ 系统设置</button></aside><div className="cube-content"><header><div><small>站点监控 / 运行总览</small><h2>苏州工业园能源站</h2></div><span>最后更新:刚刚 ↻</span></header><div className="cube-kpis">{[["设备在线率","92%","23 / 25"],["今日总能耗","12,480","kWh"],["未处理告警","7","高危 2"],["平均负载率","68%","较昨日 +3%"]].map((x,i)=><div key={x[0]}><span>{x[0]}</span><b>{x[1]}</b><small>{x[2]}</small><i className={"spark s"+i}/></div>)}</div><div className="cube-grid"><section className="line-panel"><h3>近 7 日能源消耗 <span>本周⌄</span></h3><div className="line-chart"><div/><svg viewBox="0 0 500 150" preserveAspectRatio="none"><polyline points="0,112 65,92 130,103 200,56 270,75 335,45 400,62 500,24" fill="none" stroke="#009fe3" strokeWidth="3"/><polyline points="0,132 65,118 130,121 200,98 270,102 335,82 400,91 500,68" fill="none" stroke="#8ecfee" strokeWidth="2"/></svg><footer>{["08/08","08/09","08/10","08/11","08/12","08/13","今天"].map(x=><span key={x}>{x}</span>)}</footer></div></section><section className="donut-panel"><h3>设备状态分布</h3><div className="donut"><b>25<small>设备总数</small></b></div><footer><span><i className="green"/>正常 20</span><span><i className="orange"/>告警 3</span><span><i className="gray"/>离线 2</span></footer></section><section className="alarm-panel"><h3>实时告警 <button>查看全部 →</button></h3><table><tbody>{[["冷水机组 CH-02","出水温度偏高","异常","11:18:22"],["循环水泵 PUMP-05","运行电流异常","告警","11:12:08"],["配电柜 PDC-03","柜内温度预警","告警","10:56:31"]].map(x=><tr key={x[0]}><td><b>{x[0]}</b></td><td>{x[1]}</td><td><Status>{x[2]}</Status></td><td>{x[3]}</td></tr>)}</tbody></table></section></div></div></div><aside className="inspector"><nav>{(["info","json","feedback"] as const).map((x,i)=><button key={x} className={tab===x?"active":""} onClick={()=>setTab(x)}>{["页面信息","JSON","修改反馈"][i]}</button>)}</nav>{tab==="info"&&<div className="inspector-body"><h3>站点运行总览</h3><p>展示全站设备状态、能源消耗、实时告警及运行趋势。</p><dl><dt>页面类型</dt><dd>总览型</dd><dt>所属菜单</dt><dd>首页</dd><dt>组件数量</dt><dd>12</dd><dt>数据源</dt><dd>6 个模拟数据源</dd><dt>Schema</dt><dd>Cube Skill v2.4</dd></dl><h4>组件列表</h4>{["KPI × 4","折线图 × 1","环形图 × 1","告警表格 × 1","设备地图 × 1"].map(x=><div className="component" key={x}>▦ {x}<span>›</span></div>)}</div>}{tab==="json"&&<div className="inspector-body code-body"><header><button onClick={()=>notify("JSON 已复制")}>复制</button><button>下载</button></header><pre>{JSON.stringify(json,null,2)}</pre><Button secondary onClick={()=>notify("JSON 校验通过")}>重新校验</Button></div>}{tab==="feedback"&&<div className="inspector-body"><h3>修改当前页面</h3><p>用自然语言描述需要修改的内容,AI 只会重新生成当前页面。</p><textarea placeholder="例如:顶部增加设备在线率,将告警列表移到页面底部……"/><h4>快捷建议</h4>{["增加设备健康度 KPI","突出显示异常设备","趋势图改为最近 30 天"].map(x=><button className="suggestion" key={x}>{x}</button>)}<Button onClick={()=>setModal("feedback")}>分析修改影响</Button></div>}</aside></div></div>}
-
-function Modal({type,close,notify}:{type:Exclude<ModalKey,"">;close:()=>void;notify:(m:string)=>void}){const title={ai:"AI 描述优化",feedback:"确认修改影响",download:"下载站点交付物",history:"版本历史"}[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==="ai"&&<><div className="modal-note">✦ AI 补充了使用角色、能耗周期及核心目标。</div><Field label="优化后的站点描述"><textarea defaultValue="该站点是苏州工业园区的综合能源管理示范站,服务对象包括能源管理人员、设备运维人员和园区管理者。站点包含制冷、配电和水循环系统,重点关注设备在线率、实时负载、日/月能源消耗、异常告警及节能空间。"/></Field></>}{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(3 个)","Asset Excel 导入模板","MU 页面选择配置","站点菜单配置","生成及校验报告"].map(x=><label key={x}><input type="checkbox" defaultChecked/><span>{x}</span></label>)}<div><b>istation-suzhou-energy-v3.zip</b><span>预计大小 4.8 MB</span></div></div>}{type==="history"&&<div className="history-list">{[["V3","调整设备数量并优化首页","2026/08/14 11:20"],["V2","增加能源效率分析页面","2026/08/14 10:52"],["V1","首次生成","2026/08/14 10:16"]].map((x,i)=><div key={x[0]}><i>{x[0]}</i><span><b>{x[1]}</b><small>{x[2]} · 王宇</small></span>{i===0?<Status>当前版本</Status>:<button>查看</button>}</div>)}</div>}</div><footer><Button secondary onClick={close}>取消</Button><Button onClick={()=>{close();notify(type==="download"?"交付物已开始下载":type==="history"?"已打开所选版本":"已创建新的生成任务")}}>{type==="download"?"下载 ZIP":type==="history"?"关闭":"确认并应用"}</Button></footer></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>}
--- /dev/null
+// StationBuilder DB 旁路服务(真实 Node 运行时)
+// 使用 Node 内置 node:sqlite 管理本机 SQLite 数据库 /var/lib/stationbuilder/StationBuilder.sqlite
+// 为 Worker 路由 /api/db/* 提供 REST API:站点、资产、MU、Cube、菜单、文件、AI 生成与校验。
+import http from "node:http";
+import { spawn } from "node:child_process";
+import { DatabaseSync } from "node:sqlite";
+import { writeFile, readFile, rm, readdir } from "node:fs/promises";
+import { existsSync, mkdirSync } from "node:fs";
+import { dirname, join, basename } from "node:path";
+import { randomUUID } from "node:crypto";
+
+const PORT = 37823;
+const DB_DIR = "/var/lib/stationbuilder";
+const DB_PATH = join(DB_DIR, "StationBuilder.sqlite");
+const UPLOAD_DIR = join(DB_DIR, "uploads");
+const GEN_DIR = join(DB_DIR, "generated");
+const CODEBUDDY = "/root/.local/bin/codebuddy";
+const CWD = "/home/StationBuilder";
+
+const DEFAULT_DOMAINS = [
+ "工业园区", "数据中心", "智慧楼宇", "智慧水务", "工业制造",
+ "燃气", "水务 · 自来水厂", "新能源",
+];
+
+const MU_TREE = [
+ {
+ key: "设备监控",
+ children: [
+ { key: "设备总览", tabs: ["列表式", "卡片式"] },
+ {
+ key: "设备详情",
+ tabs: [
+ "设备工况", "告警记录",
+ { label: "维修记录", disabled: true, note: "第一阶段暂不开发" },
+ { label: "保养记录", disabled: true, note: "第一阶段暂不开发" },
+ { label: "巡检记录", disabled: true, note: "第一阶段暂不开发" },
+ "上下线记录", "设备文档", "区域监控",
+ { label: "设备视频", disabled: true, note: "第一阶段暂不开发" },
+ "远程快速控制", "远程控制计划", "设备轨迹",
+ ],
+ },
+ ],
+ },
+ {
+ key: "数据分析",
+ children: [
+ { key: "数据分析" },
+ { key: "报表分析" },
+ { key: "用量分析" },
+ ],
+ },
+ {
+ key: "智能控制",
+ children: [
+ { key: "远程控制计划" },
+ { key: "执行记录" },
+ { key: "控制模板" },
+ ],
+ },
+];
+
+const DASHBOARD_SPEC = `
+You are generating an importable dashboard JSON for the Cube visualization tool.
+Top-level structure (MUST include exactly these keys):
+{
+ "category_id": null,
+ "name": "<dashboard name>",
+ "id": <positive integer>,
+ "grid": { "theme": "light", "bgColor": "rgb(237,246,251)", "panelBgColor": "#F5F5F5", "panel": { "bgColor": "#FFFFFF", "titleColor": "#323232" }, "borderColor": "#C8C8C8", "borderWidth": 1, "borderRadius": 4, "gap": 6, "pageType": "custom", "pageWidth": 1920, "pageHeight": 1080, "showBorder": true, "bgType": "color", "bgImgType": "cover", "linearGradientParam1": "default", "linearGradientParam2": "#008cd633", "linearGradientParam3": "#008cd6", "linearGradientParamDeg": 45, "radialGradientParam1": "default", "radialGradientParam2": "#008cd633", "radialGradientParam3": "#008cd6" },
+ "panels": [...]
+}
+
+Layout rules:
+- 24-column grid, page 1920x1080.
+- First panel MUST be type "Title" at gridPos {w:24,h:2,x:0,y:0}, custom.title.label = dashboard name, fontSize 40, fontWeight "bold".
+- All other panels must have y >= 2.
+- Do NOT place panels that overlap or exceed x+w > 24.
+- Fill roughly 25 rows total (2 title + KPI rows + chart rows).
+
+Panel structure:
+{ "id": "<uuid>", "type": "<TYPE>", "category": "Widget"|"Chart", "gridPos": {w,h,x,y,"content":"","id":"<uuid>"}, "data": {...}, "custom": {...}, "query": {time:{enable:true,timeType:"Relative",dtType:"Last7Day",dtRange:[],map:{startTs:"",endTs:""}}}, "linkage": {enable:true,list:[]}, "style": {title:{show:false|true,label:"",color:"#323232",fontSize:18,fontWeight:700,align:"left",showBorder:false}, border:{show:true,color:"#E8E8E8",style:"solid",width:1,radius:4}, background:{color:"",padding:0,radius:0,show:true}, content:{isDefault:true,padding:10}}, "minW": 1 (only for KPIIndicator), "triggerByTimer": true }
+
+Allowed component TYPEs: KPIIndicator, LineChart, PieChart, Bar, Table, StatusLight, ParameterCard, Title, RichText, Gauge, ProgressBar, ProgressRing, RadarChart, HeatMap, Liquidfill, GeoMap, Image, Container.
+
+Datasource: use "simulate" unless the user explicitly asks for real-time IoT. For simulate:
+- data.datasource = "simulate"
+- data.config.type = ""
+- data.config.data.columns = [{"key":"column_name","type":""}]
+- data.config.data.rows = [{"column_name":"sample_value"}]
+- data.params = {}
+- data.id = null
+- data.direction = "column"
+- data.calc = true for KPIIndicator and GeoMap; false for RadarChart; omitted for charts
+- data.dim = {"dim1":"","dim2":""} (for KPIIndicator dim1 MUST be "")
+- data.targets = [{"id":"A","key":"metric_column","displayName":"","unit":"","dataType":""|"sum"|"first","compute":{"type":"formula","formula":""},"precision":null,"hide":false}]
+- For KPIIndicator targets[0].dataType MUST be "sum".
+- For LineChart/PieChart/Bar/RadarChart/Table/StatusLight targets[0].dataType MUST be "".
+- For GeoMap targets[0..3].dataType MUST be "first" (lowercase).
+
+KPIIndicator panel specifics:
+- data.dim.dim1 = ""
+- data.calc = true
+- data.config.data.columns = [{"key":"指标","type":""}, {"key":"<metric_key>","type":""}]
+- data.config.data.rows = [{"指标":"今日","<metric_key>":<value>}]
+- data.targets[0].dataType = "sum"
+- custom can include icon, thresholds, compare.
+
+Charts: include proper chartConfig, series, axes, colors from palette #0062CC,#00B900,#FF9300,#EBC147,#D73C74,#8048C8,#6D84C9,#34A0D5.
+
+Theme: use light theme (grid.theme="light", bgColor rgb(237,246,251), panelBgColor #F5F5F5, panel.bgColor #FFFFFF, titleColor #323232, borderColor #C8C8C8). Do not mix darkBlue with light background.
+
+Output ONLY the raw JSON object. No markdown code fences, no explanation.
+`;
+
+const VALID_PANEL_TYPES = new Set([
+ "KPIIndicator", "LineChart", "PieChart", "Bar", "Table", "StatusLight",
+ "ParameterCard", "Title", "RichText", "Gauge", "ProgressBar", "ProgressRing",
+ "RadarChart", "HeatMap", "Liquidfill", "GeoMap", "Image", "Container",
+]);
+
+function ensureDir(p) {
+ if (!existsSync(p)) mkdirSync(p, { recursive: true });
+}
+ensureDir(DB_DIR);
+ensureDir(UPLOAD_DIR);
+ensureDir(GEN_DIR);
+
+const db = new DatabaseSync(DB_PATH);
+db.exec("PRAGMA foreign_keys = ON;");
+
+function initSchema() {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS domains (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS sites (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ domain TEXT NOT NULL,
+ description TEXT,
+ supplement TEXT,
+ status TEXT DEFAULT 'draft',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+ CREATE TABLE IF NOT EXISTS reference_files (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ site_id INTEGER NOT NULL,
+ filename TEXT NOT NULL,
+ mime TEXT,
+ size INTEGER,
+ path TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE
+ );
+ CREATE TABLE IF NOT EXISTS assets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ site_id INTEGER NOT NULL,
+ type TEXT NOT NULL,
+ count INTEGER DEFAULT 0,
+ rule TEXT,
+ sort_order INTEGER DEFAULT 0,
+ FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE
+ );
+ CREATE TABLE IF NOT EXISTS mu_selections (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ site_id INTEGER NOT NULL,
+ page_key TEXT NOT NULL,
+ selected INTEGER DEFAULT 1,
+ FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE,
+ UNIQUE(site_id, page_key)
+ );
+ CREATE TABLE IF NOT EXISTS cubes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ site_id INTEGER NOT NULL,
+ name TEXT NOT NULL,
+ content TEXT,
+ gen_file_id INTEGER,
+ status TEXT DEFAULT 'pending',
+ sort_order INTEGER DEFAULT 0,
+ FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE,
+ FOREIGN KEY(gen_file_id) REFERENCES gen_files(id) ON DELETE SET NULL
+ );
+ CREATE TABLE IF NOT EXISTS menu_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ site_id INTEGER NOT NULL,
+ parent_id INTEGER,
+ label TEXT NOT NULL,
+ page_type TEXT,
+ ref_id INTEGER,
+ sort_order INTEGER DEFAULT 0,
+ FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE
+ );
+ CREATE TABLE IF NOT EXISTS gen_files (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ site_id INTEGER NOT NULL,
+ cube_id INTEGER,
+ filename TEXT NOT NULL,
+ json_text TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE,
+ FOREIGN KEY(cube_id) REFERENCES cubes(id) ON DELETE SET NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_sites_updated ON sites(updated_at);
+ CREATE INDEX IF NOT EXISTS idx_sites_status ON sites(status);
+ `);
+ const insertDomain = db.prepare("INSERT OR IGNORE INTO domains (name) VALUES (?)");
+ for (const d of DEFAULT_DOMAINS) insertDomain.run(d);
+}
+initSchema();
+
+function ensureColumn(table, column, def) {
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all().map(r => r.name);
+ if (!cols.includes(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${def}`);
+}
+ensureColumn("cubes", "prompt", "TEXT");
+
+
+function respondJson(res, status, obj) {
+ const body = JSON.stringify(obj);
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(body) });
+ res.end(body);
+}
+function respondText(res, status, text, type = "text/plain; charset=utf-8") {
+ const body = Buffer.from(text || "");
+ res.writeHead(status, { "Content-Type": type, "Content-Length": body.length });
+ res.end(body);
+}
+function respondError(res, status, msg) {
+ respondJson(res, status, { error: msg });
+}
+function parseBody(req) {
+ return new Promise((resolve, reject) => {
+ let raw = "";
+ req.on("data", c => (raw += c));
+ req.on("end", () => {
+ try { resolve(raw ? JSON.parse(raw) : {}); }
+ catch (e) { reject(e); }
+ });
+ });
+}
+function streamEvent(res, obj) {
+ if (!res.writableEnded) res.write(JSON.stringify(obj) + "\n");
+}
+
+function getSite(id) {
+ const row = db.prepare("SELECT * FROM sites WHERE id = ?").get(id);
+ if (!row) return null;
+ return row;
+}
+function hydrateSite(id) {
+ const site = getSite(id);
+ if (!site) return null;
+ site.assets = db.prepare("SELECT id,type,count,rule,sort_order FROM assets WHERE site_id = ? ORDER BY sort_order,id").all(id);
+ site.cubes = db.prepare("SELECT c.id,c.name,c.content,c.status,c.sort_order,c.prompt,gf.id as gen_file_id,gf.filename as gen_filename,gf.created_at as generated_at 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(id);
+ site.mu = db.prepare("SELECT page_key,selected FROM mu_selections WHERE site_id = ?").all(id).map(r => r.page_key);
+ site.menu = db.prepare("SELECT id,parent_id,label,page_type,ref_id,sort_order FROM menu_items WHERE site_id = ? ORDER BY sort_order,id").all(id);
+ site.files = db.prepare("SELECT id,filename,mime,size,created_at FROM reference_files WHERE site_id = ?").all(id);
+ return site;
+}
+function siteList() {
+ return db.prepare(`
+ SELECT s.*,
+ (SELECT COALESCE(SUM(count),0) FROM assets WHERE site_id = s.id) as device_count,
+ (SELECT COUNT(*) FROM cubes WHERE site_id = s.id) as cube_count,
+ (SELECT COUNT(*) FROM mu_selections WHERE site_id = s.id AND selected = 1) as mu_count
+ FROM sites s ORDER BY s.updated_at DESC
+ `).all();
+}
+
+function validateDashboard(json) {
+ const errors = [];
+ if (!json || typeof json !== "object") { errors.push("root is not an object"); return errors; }
+ if (json.category_id !== null && typeof json.category_id !== "number") errors.push("category_id must be null or number");
+ if (!json.name || typeof json.name !== "string") errors.push("name missing");
+ if (typeof json.id !== "number") errors.push("id must be number");
+ if (!json.grid || typeof json.grid !== "object") errors.push("grid missing");
+ else {
+ if (!json.grid.theme) errors.push("grid.theme missing");
+ if (!json.grid.bgColor) errors.push("grid.bgColor missing");
+ if (!json.grid.panelBgColor) errors.push("grid.panelBgColor missing");
+ }
+ if (!Array.isArray(json.panels) || json.panels.length === 0) errors.push("panels must be a non-empty array");
+ else {
+ const title = json.panels[0];
+ if (title.type !== "Title") errors.push("first panel must be Title");
+ if (!title.gridPos || title.gridPos.w !== 24 || title.gridPos.h !== 2 || title.gridPos.x !== 0 || title.gridPos.y !== 0)
+ errors.push("Title panel must occupy gridPos {w:24,h:2,x:0,y:0}");
+ const occupied = new Set();
+ for (let i = 0; i < json.panels.length; i++) {
+ const p = json.panels[i];
+ if (!p.id || typeof p.id !== "string") errors.push(`panel[${i}] missing id`);
+ if (!VALID_PANEL_TYPES.has(p.type)) errors.push(`panel[${i}] invalid type ${p.type}`);
+ if (!p.gridPos || typeof p.gridPos !== "object") { errors.push(`panel[${i}] missing gridPos`); continue; }
+ const { w, h, x, y } = p.gridPos;
+ if (!Number.isInteger(w) || w < 1 || w > 24) errors.push(`panel[${i}] gridPos.w invalid`);
+ if (!Number.isInteger(h) || h < 1) errors.push(`panel[${i}] gridPos.h invalid`);
+ if (!Number.isInteger(x) || x < 0 || x + w > 24) errors.push(`panel[${i}] gridPos.x out of bounds`);
+ if (!Number.isInteger(y) || y < 0 || (i > 0 && y < 2)) errors.push(`panel[${i}] gridPos.y invalid`);
+ for (let gx = x; gx < x + w; gx++) for (let gy = y; gy < y + h; gy++) {
+ const k = `${gx},${gy}`;
+ if (occupied.has(k)) errors.push(`panel[${i}] overlaps at ${k}`);
+ occupied.add(k);
+ }
+ if (p.type === "KPIIndicator") {
+ if (p.data?.calc !== true) errors.push(`panel[${i}] KPIIndicator data.calc must be true`);
+ if (p.data?.dim?.dim1 !== "") errors.push(`panel[${i}] KPIIndicator data.dim.dim1 must be ""`);
+ if (!p.data?.targets?.length) errors.push(`panel[${i}] KPIIndicator missing targets`);
+ else if (p.data.targets[0].dataType !== "sum") errors.push(`panel[${i}] KPIIndicator targets[0].dataType must be "sum"`);
+ }
+ if (["LineChart", "PieChart", "Bar", "Table", "StatusLight", "ParameterCard"].includes(p.type)) {
+ if (p.data?.targets?.length && p.data.targets[0].dataType !== "") errors.push(`panel[${i}] ${p.type} targets[0].dataType must be ""`);
+ }
+ if (p.type === "RadarChart") {
+ if (p.data?.calc !== false) errors.push(`panel[${i}] RadarChart data.calc must be false`);
+ }
+ }
+ }
+ return errors;
+}
+
+function runCodebuddy(prompt, sessionId) {
+ return new Promise((resolve, reject) => {
+ const args = [
+ "--model", "hy3",
+ "--session-id", sessionId,
+ "-p", prompt,
+ "--output-format", "stream-json",
+ "--include-partial-messages",
+ "--tools", "",
+ "-y",
+ ];
+ const child = spawn(CODEBUDDY, args, { env: process.env, cwd: CWD });
+ let buf = "";
+ let fullText = "";
+ const push = (line) => {
+ if (!line.trim()) return;
+ try {
+ const o = JSON.parse(line);
+ if (o.type === "stream_event" && o.event?.type === "content_block_delta" && o.event.delta?.type === "text_delta" && o.event.delta.text) {
+ fullText += o.event.delta.text;
+ }
+ } catch {}
+ };
+ child.stdout.on("data", c => {
+ buf += c.toString("utf-8");
+ let nl;
+ while ((nl = buf.indexOf("\n")) >= 0) {
+ push(buf.slice(0, nl));
+ buf = buf.slice(nl + 1);
+ }
+ });
+ child.stderr.on("data", c => push(c.toString("utf-8")));
+ child.on("error", reject);
+ child.on("close", code => {
+ if (buf.trim()) push(buf);
+ if (code && code !== 0) return reject(new Error("codebuddy exit " + code));
+ resolve(fullText);
+ });
+ });
+}
+
+async function extractJson(text) {
+ // strip markdown fences
+ const m = text.match(/```(?:json)?\s*([\s\S]*?)```/);
+ if (m) text = m[1];
+ text = text.trim();
+ // find outermost { ... }
+ let start = text.indexOf("{");
+ let end = text.lastIndexOf("}");
+ if (start >= 0 && end > start) text = text.slice(start, end + 1);
+ 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:
+- 站点名称: ${site.name || "未命名站点"}
+- 所属领域: ${site.domain || "未指定"}
+- 站点描述: ${site.description || "无"}
+- 补充要求: ${site.supplement || "无"}
+- 资产配置: ${assetDesc}
+
+Page to generate:
+- 页面名称: ${cube.name}
+- 页面内容/要求: ${cube.content || "总览型页面"}
+
+${DASHBOARD_SPEC}`;
+}
+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.
+
+Site context (所有页面通用):
+- 站点名称: ${site.name || "未命名站点"}
+- 所属领域: ${site.domain || "未指定"}
+- 站点描述: ${site.description || "无"}
+- 补充要求: ${site.supplement || "无"}
+- 资产配置: ${assetDesc}
+
+${DASHBOARD_SPEC}`;
+}
+async function generateCubeJson(site, cube, attempt = 1, previousErrors = "") {
+ const promptBase = buildCubePrompt(site, cube);
+ const prompt = previousErrors
+ ? `The previous output failed validation:\n${previousErrors}\n\n${promptBase}\n\nPlease fix the errors and output ONLY the corrected raw dashboard JSON.`
+ : promptBase;
+ const sessionId = `stationbuilder-gen-${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);
+ if (errors.length > 0) {
+ if (attempt >= 3) throw new Error("Validation failed after 3 attempts: " + errors.join("; "));
+ return generateCubeJson(site, cube, attempt + 1, errors.join("\n"));
+ }
+ return { json, prompt: promptBase };
+}
+
+async function createSiteZip(siteId) {
+ const siteDir = join(GEN_DIR, String(siteId));
+ ensureDir(siteDir);
+ // clean old files except zip
+ for (const f of await readDirSafe(siteDir)) {
+ if (f !== "station-package.zip") await rm(join(siteDir, f), { force: true });
+ }
+ const files = db.prepare("SELECT gf.filename,gf.json_text,c.name FROM gen_files gf JOIN cubes c ON c.id = gf.cube_id WHERE gf.site_id = ?").all(siteId);
+ for (const f of files) {
+ const filename = f.filename || `${f.name}.json`;
+ await writeFile(join(siteDir, filename), f.json_text, "utf-8");
+ }
+ // also write menu json
+ const site = hydrateSite(siteId);
+ await writeFile(join(siteDir, "menu-config.json"), JSON.stringify(site.menu, null, 2), "utf-8");
+ const zipPath = join(siteDir, "station-package.zip");
+ await new Promise((resolve, reject) => {
+ const proc = spawn("zip", ["-r", zipPath, "."], { cwd: siteDir, stdio: "ignore" });
+ proc.on("error", reject);
+ proc.on("close", code => (code === 0 ? resolve() : reject(new Error("zip exit " + code))));
+ });
+ return zipPath;
+}
+
+async function readDirSafe(p) {
+ try { return await readdir(p); }
+ catch { return []; }
+}
+
+function safeFilename(name) {
+ return name.replace(/[^\u4e00-\u9fa5a-zA-Z0-9_.-]/g, "_").slice(0, 120);
+}
+
+const genLocks = new Set();
+
+async function handleGenerateCube(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 { json, prompt } = await generateCubeJson(site, cube);
+ const filename = `${safeFilename(cube.name)}.json`;
+ const jsonText = JSON.stringify(json, null, 2);
+ // upsert gen_files
+ if (cube.gen_file_id) {
+ db.prepare("UPDATE gen_files SET filename=?, json_text=?, cube_id=? WHERE id=?").run(filename, jsonText, cube.id, cube.gen_file_id);
+ } else {
+ const info = db.prepare("INSERT INTO gen_files (site_id, cube_id, filename, json_text) VALUES (?,?,?,?)").run(siteId, cube.id, filename, jsonText);
+ db.prepare("UPDATE cubes SET gen_file_id=?, status='generated' WHERE id=?").run(info.lastInsertRowid, cube.id);
+ }
+ db.prepare("UPDATE cubes SET status='generated', prompt=? WHERE id=?").run(prompt, 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}`)) {
+ streamEvent(res, { task: "系统", status: "failed", message: "已有生成任务在进行中" });
+ return res.end();
+ }
+ genLocks.add(`site-${siteId}`);
+ try {
+ const site = hydrateSite(siteId);
+ if (!site) { streamEvent(res, { task: "系统", status: "failed", message: "站点不存在" }); return res.end(); }
+ 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) {
+ streamEvent(res, { task: cube.name, status: "running" });
+ try {
+ const { json, prompt } = await generateCubeJson(site, cube);
+ 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=? 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) VALUES (?,?,?,?)").run(siteId, cube.id, filename, jsonText);
+ db.prepare("UPDATE cubes SET gen_file_id=?, status='generated' WHERE id=?").run(info.lastInsertRowid, cube.id);
+ }
+ db.prepare("UPDATE cubes SET status='generated', prompt=? WHERE id=?").run(prompt, cube.id);
+ streamEvent(res, { task: cube.name, status: "done" });
+ } catch (e) {
+ db.prepare("UPDATE cubes SET status='failed' WHERE id=?").run(cube.id);
+ streamEvent(res, { task: cube.name, status: "failed", message: e.message });
+ }
+ }
+ streamEvent(res, { task: "打包与检查校验", status: "running" });
+ const zipPath = await createSiteZip(siteId);
+ db.prepare("UPDATE sites SET status='generated', updated_at=CURRENT_TIMESTAMP WHERE id=?").run(siteId);
+ streamEvent(res, { task: "打包与检查校验", status: "done" });
+ streamEvent(res, { done: true, zipUrl: `/api/db/sites/${siteId}/package`, zipPath });
+ } catch (e) {
+ streamEvent(res, { task: "系统", status: "failed", message: e.message });
+ } finally {
+ genLocks.delete(`site-${siteId}`);
+ if (!res.writableEnded) res.end();
+ }
+}
+
+async function handleFileUpload(siteId, body) {
+ const { filename, mime, data } = body;
+ if (!filename || !data) throw new Error("filename and data required");
+ const siteUploadDir = join(UPLOAD_DIR, String(siteId));
+ ensureDir(siteUploadDir);
+ const safe = safeFilename(filename);
+ const path = join(siteUploadDir, safe);
+ const buf = Buffer.from(data, "base64");
+ await writeFile(path, buf);
+ const info = db.prepare("INSERT INTO reference_files (site_id, filename, mime, size, path) VALUES (?,?,?,?,?)").run(siteId, filename, mime || "application/octet-stream", buf.length, path);
+ return { id: info.lastInsertRowid, filename, size: buf.length };
+}
+
+async function handleFileDownload(res, fileId) {
+ const row = db.prepare("SELECT * FROM reference_files WHERE id=?").get(fileId);
+ if (!row) return respondError(res, 404, "file not found");
+ const buf = await readFile(row.path);
+ res.writeHead(200, { "Content-Type": row.mime || "application/octet-stream", "Content-Disposition": `attachment; filename="${encodeURIComponent(row.filename)}"`, "Content-Length": buf.length });
+ res.end(buf);
+}
+
+async function handleCubeFileDownload(res, cubeId) {
+ const row = db.prepare("SELECT gf.json_text,gf.filename,c.name FROM gen_files gf JOIN cubes c ON c.id=gf.cube_id WHERE gf.cube_id=?").get(cubeId);
+ if (!row) return respondError(res, 404, "generated file not found");
+ const filename = row.filename || `${row.name}.json`;
+ const buf = Buffer.from(row.json_text, "utf-8");
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`, "Content-Length": buf.length });
+ res.end(buf);
+}
+
+async function handlePackageDownload(res, siteId) {
+ const zipPath = join(GEN_DIR, String(siteId), "station-package.zip");
+ if (!existsSync(zipPath)) {
+ try { await createSiteZip(siteId); }
+ catch (e) { return respondError(res, 500, "zip 生成失败: " + e.message); }
+ }
+ if (!existsSync(zipPath)) return respondError(res, 404, "zip not generated");
+ const buf = await readFile(zipPath);
+ res.writeHead(200, { "Content-Type": "application/zip", "Content-Disposition": `attachment; filename="${encodeURIComponent("station-package.zip")}"`, "Content-Length": buf.length });
+ res.end(buf);
+}
+
+async function route(req, res) {
+ const parsed = new URL(req.url, `http://${req.headers.host}`);
+ const path = parsed.pathname;
+ const mSites = path.match(/^\/api\/sites(?:\/(\d+))?$/);
+ const mDomains = path === "/api/domains";
+ const mHealth = path === "/api/health";
+ const mMuTree = path === "/api/mu-tree";
+ const mSiteAssets = path.match(/^\/api\/sites\/(\d+)\/assets$/);
+ const mSiteMu = path.match(/^\/api\/sites\/(\d+)\/mu$/);
+ const mSiteCubes = path.match(/^\/api\/sites\/(\d+)\/cubes$/);
+ const mSiteCube = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)$/);
+ 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 mCubePrompt = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)\/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+)$/);
+ const mSiteGenerate = path.match(/^\/api\/sites\/(\d+)\/generate-all$/);
+ const mSitePackage = path.match(/^\/api\/sites\/(\d+)\/package$/);
+
+ try {
+ if (mHealth && req.method === "GET") return respondJson(res, 200, { ok: true });
+ if (mMuTree && req.method === "GET") return respondJson(res, 200, MU_TREE);
+ if (mDomains) {
+ if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT * FROM domains ORDER BY name").all());
+ if (req.method === "POST") {
+ const body = await parseBody(req);
+ if (!body.name || body.name.length > 6) return respondError(res, 400, "领域最多6个字符");
+ try { const info = db.prepare("INSERT INTO domains (name) VALUES (?)").run(body.name); return respondJson(res, 200, { id: info.lastInsertRowid, name: body.name }); }
+ catch (e) { return respondError(res, 409, "领域已存在"); }
+ }
+ }
+ if (mSites && !mSites[1]) {
+ if (req.method === "GET") return respondJson(res, 200, siteList());
+ if (req.method === "POST") {
+ const body = await parseBody(req);
+ const info = db.prepare("INSERT INTO sites (name, domain, description, supplement, status) VALUES (?,?,?,?,'draft')").run(body.name || "", body.domain || "", body.description || "", body.supplement || "");
+ // insert default empty assets
+ const siteId = info.lastInsertRowid;
+ const insertAsset = db.prepare("INSERT INTO assets (site_id, type, count, rule, sort_order) VALUES (?,?,?,?,?)");
+ for (let i = 0; i < 4; i++) insertAsset.run(siteId, "", 0, "", i);
+ return respondJson(res, 200, hydrateSite(siteId));
+ }
+ if (req.method === "DELETE") {
+ const body = await parseBody(req);
+ const ids = Array.isArray(body.ids) ? body.ids.map(Number).filter(n => Number.isInteger(n) && n > 0) : [];
+ for (const id of ids) {
+ db.prepare("DELETE FROM sites WHERE id=?").run(id);
+ await rm(join(GEN_DIR, String(id)), { recursive: true, force: true });
+ }
+ return respondJson(res, 200, { ok: true, deleted: ids.length });
+ }
+ }
+ if (mSites && mSites[1]) {
+ const siteId = Number(mSites[1]);
+ if (req.method === "GET") {
+ const site = hydrateSite(siteId);
+ if (!site) return respondError(res, 404, "站点不存在");
+ return respondJson(res, 200, site);
+ }
+ if (req.method === "PUT") {
+ const body = await parseBody(req);
+ db.prepare("UPDATE sites SET name=?, domain=?, description=?, supplement=?, updated_at=CURRENT_TIMESTAMP WHERE id=?").run(
+ body.name ?? "", body.domain ?? "", body.description ?? "", body.supplement ?? "", siteId
+ );
+ return respondJson(res, 200, hydrateSite(siteId));
+ }
+ if (req.method === "DELETE") {
+ db.prepare("DELETE FROM sites WHERE id=?").run(siteId);
+ // clean generated files
+ await rm(join(GEN_DIR, String(siteId)), { recursive: true, force: true });
+ return respondJson(res, 200, { ok: true });
+ }
+ }
+ if (mSiteAssets) {
+ const siteId = Number(mSiteAssets[1]);
+ if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT * FROM assets WHERE site_id=? ORDER BY sort_order,id").all(siteId));
+ if (req.method === "PUT") {
+ const body = await parseBody(req);
+ db.prepare("DELETE FROM assets WHERE site_id=?").run(siteId);
+ const insert = db.prepare("INSERT INTO assets (site_id, type, count, rule, sort_order) VALUES (?,?,?,?,?)");
+ for (let i = 0; i < (body.assets || []).length; i++) {
+ const a = body.assets[i];
+ insert.run(siteId, a.type || "", Number(a.count) || 0, a.rule || "", i);
+ }
+ db.prepare("UPDATE sites SET updated_at=CURRENT_TIMESTAMP WHERE id=?").run(siteId);
+ return respondJson(res, 200, db.prepare("SELECT * FROM assets WHERE site_id=? ORDER BY sort_order,id").all(siteId));
+ }
+ }
+ if (mSiteMu) {
+ const siteId = Number(mSiteMu[1]);
+ if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT page_key,selected FROM mu_selections WHERE site_id=?").all(siteId));
+ if (req.method === "PUT") {
+ const body = await parseBody(req);
+ db.prepare("DELETE FROM mu_selections WHERE site_id=?").run(siteId);
+ const insert = db.prepare("INSERT INTO mu_selections (site_id, page_key, selected) VALUES (?,?,?)");
+ for (const key of body.selected || []) insert.run(siteId, key, 1);
+ db.prepare("UPDATE sites SET updated_at=CURRENT_TIMESTAMP WHERE id=?").run(siteId);
+ return respondJson(res, 200, { ok: true });
+ }
+ }
+ if (mSiteMenu) {
+ const siteId = Number(mSiteMenu[1]);
+ if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT * FROM menu_items WHERE site_id=? ORDER BY sort_order,id").all(siteId));
+ if (req.method === "PUT") {
+ const body = await parseBody(req);
+ db.prepare("DELETE FROM menu_items WHERE site_id=?").run(siteId);
+ const insert = db.prepare("INSERT INTO menu_items (site_id, parent_id, label, page_type, ref_id, sort_order) VALUES (?,?,?,?,?,?)");
+ for (let i = 0; i < (body.menu || []).length; i++) {
+ const m = body.menu[i];
+ insert.run(siteId, m.parent_id || null, m.label, m.page_type || null, m.ref_id || null, i);
+ }
+ db.prepare("UPDATE sites SET updated_at=CURRENT_TIMESTAMP WHERE id=?").run(siteId);
+ return respondJson(res, 200, { ok: true });
+ }
+ }
+ if (mSiteFiles) {
+ const siteId = Number(mSiteFiles[1]);
+ if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT id,filename,mime,size,created_at FROM reference_files WHERE site_id=?").all(siteId));
+ if (req.method === "POST") {
+ const body = await parseBody(req);
+ const result = await handleFileUpload(siteId, body);
+ return respondJson(res, 200, result);
+ }
+ }
+ if (mSiteFile) {
+ const fileId = Number(mSiteFile[2]);
+ if (req.method === "GET") return handleFileDownload(res, fileId);
+ }
+ if (mSitePrompt) {
+ const siteId = Number(mSitePrompt[1]);
+ const site = hydrateSite(siteId);
+ if (!site) return respondError(res, 404, "站点不存在");
+ return respondJson(res, 200, { prompt: buildSiteContextPrompt(site) });
+ }
+ 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));
+ if (req.method === "POST") {
+ const body = await parseBody(req);
+ if (!body.name) return respondError(res, 400, "name required");
+ const maxSort = db.prepare("SELECT MAX(sort_order) as m FROM cubes WHERE site_id=?").get(siteId).m || 0;
+ const info = db.prepare("INSERT INTO cubes (site_id, name, content, sort_order) VALUES (?,?,?,?)").run(siteId, body.name, body.content || "", maxSort + 1);
+ return respondJson(res, 200, { id: info.lastInsertRowid, name: body.name, content: body.content || "", status: "pending" });
+ }
+ }
+ if (mSiteCube) {
+ const cubeId = Number(mSiteCube[2]);
+ if (req.method === "PUT") {
+ const body = await parseBody(req);
+ db.prepare("UPDATE cubes SET name=?, content=? WHERE id=?").run(body.name, body.content || "", cubeId);
+ return respondJson(res, 200, { ok: true });
+ }
+ if (req.method === "DELETE") {
+ db.prepare("DELETE FROM cubes WHERE id=?").run(cubeId);
+ return respondJson(res, 200, { ok: true });
+ }
+ }
+ if (mCubeGenerate) return handleGenerateCube(req, res, Number(mCubeGenerate[1]), Number(mCubeGenerate[2]));
+ if (mCubePrompt) {
+ const siteId = Number(mCubePrompt[1]); const cubeId = Number(mCubePrompt[2]);
+ const site = hydrateSite(siteId);
+ 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) });
+ }
+ 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]));
+
+ respondError(res, 404, "not found");
+ } catch (e) {
+ console.error("[db-sidecar error]", e);
+ respondError(res, 500, e.message || "internal error");
+ }
+}
+
+const server = http.createServer((req, res) => {
+ // CORS headers for local dev
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
+ if (req.method === "OPTIONS") return res.writeHead(204).end();
+ route(req, res);
+});
+
+server.listen(PORT, "127.0.0.1", () => console.log(`[db-sidecar] SQLite=${DB_PATH} listening on http://127.0.0.1:${PORT}`));