]> acesimba.cloud Git - StationBuilder.git/commitdiff
feat: 新建站点向导 + 生成提示词可查 + 站点列表多选删除
authorwang.yu <wang.yu@advantech.com>
Sat, 15 Aug 2026 09:20:43 +0000 (17:20 +0800)
committerwang.yu <wang.yu@advantech.com>
Sat, 15 Aug 2026 09:20:43 +0000 (17:20 +0800)
新建站点全流程(基本属性 / 资产配置 / 功能与菜单 / 生成确认 / 结果 / 预览),
数据持久化到 SQLite(新增 db-sidecar 后端 + /api/db 代理路由):

- 新建站点向导:Basic 基本信息(名称≤20字、领域可扩充下拉)、Assets 设备资产配置
  (默认 4 行占位、资产层级预览)、Functions(MU 标准页树选 + Cube 扩展页编辑 +
  菜单结构)、Generate 确认(模型准备 + 各 Cube + 打包校验)、Result/Preview。
- 生成前可查看组合提示词:每个 Cube 页面 + 站点级通用上下文(站点背景 + 完整 Cube
  规则 spec),便于在点击“开始生成”前核对、定位生成质量问题。
- 站点列表:Asset 列改为“设备数量”(资产 count 求和);删除“创建人”列及对应筛选;
  “操作”列改为删除按钮;新增多选勾选 + 批量删除(DELETE /api/sites,级联清理
  资产/页面/生成文件并删除生成目录)。

已知状态 / AI 创建功能说明:
Cube JSON 生成已通过 codebuddy 调用 dashboard-json-generator Skill 实现,并带 JSON
结构自校验(最多 3 次自动修复重试)。但目前生成质量尚未达到预期(即“还没有成功”),
本次新增的“提示词可查”能力正是为此而加,用于排查与迭代;生成效果仍需进一步调优与
验证,不建议直接当作最终交付物使用。

app/api/db/[[...slug]]/route.ts [new file with mode: 0644]
app/globals.css
app/page.tsx
db-sidecar.mjs [new file with mode: 0644]

diff --git a/app/api/db/[[...slug]]/route.ts b/app/api/db/[[...slug]]/route.ts
new file mode 100644 (file)
index 0000000..21264c8
--- /dev/null
@@ -0,0 +1,39 @@
+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;
index 8fbf53e33a880ed74466d58d4f9aa889f4cc913e..8cb29473198aff72e6f7e03b955e2ffca6fdb7dc 100644 (file)
 .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)}
+
index 08fdab11209d6fe9aa81b8fe1c0525afb737a8fa..b8648883ba584a3b0789f85cf3310a3c2858f867 100644 (file)
@@ -2,37 +2,24 @@
 
 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" },
@@ -45,20 +32,229 @@ const MODELS = [
   { 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;
@@ -77,11 +273,13 @@ export default function Home(){
         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>
@@ -103,10 +301,18 @@ export default function Home(){
       </>
     )}
   </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>
@@ -124,15 +330,251 @@ export default function Home(){
 }
 
 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=[
@@ -158,33 +600,4 @@ function DomainDetail({go,notify}:{go:(p:PageKey)=>void;notify:(m:string)=>void}
       <Panel title="领域知识"><div className="knowledge-list">{[["▤","自来水厂工艺说明.pdf","PDF · 3.2 MB"],["△","水质报警规则集","18 条规则"],["⚒","水泵维修规则","12 条规则"],["↻","设备保养规则","16 条规则"]].map(x=><div key={x[1]}><i>{x[0]}</i><span><b>{x[1]}</b><small>{x[2]}</small></span><button onClick={()=>notify("已打开 "+x[1])}>查看</button></div>)}</div></Panel></div></div></>
 }
 
-function 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>}
diff --git a/db-sidecar.mjs b/db-sidecar.mjs
new file mode 100644 (file)
index 0000000..e6b600c
--- /dev/null
@@ -0,0 +1,762 @@
+// 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}`));