]> acesimba.cloud Git - StationBuilder.git/commitdiff
feat: AI 助手会话隔离与 StationBuilder 概览接入;fix: 点击站点卡片误建空站点
authorwang.yu <wang.yu@advantech.com>
Sun, 16 Aug 2026 13:56:51 +0000 (21:56 +0800)
committerwang.yu <wang.yu@advantech.com>
Sun, 16 Aug 2026 13:56:51 +0000 (21:56 +0800)
## AI 助手:会话隔离 + 基于注入上下文回答 StationBuilder 自身信息
- ai-sidecar: 每次会话使用独立 sessionId,配合 --no-session-persistence 与 --tools "",
  实现会话隔离(不跨会话、不查其他会话、不触碰服务器/文件/命令);
  守卫明确仅允许基于前端注入的只读上下文作答 StationBuilder 配置与已生成内容。
- 前端在 /api/chat 请求中注入 overview(站点总数 + 每站点的领域/状态/设备数/Cube 数/
  已生成数)与当前站点的已生成文件名;db-sidecar siteList 增加 gen_count 列。
- 会话上下文前端持久化到 sessionStorage,刷新不丢失。

## fix: 点击站点卡片误建空站点(未命名站点)
根因:go() 在卡片点击后读取过期的 siteId 闭包(首次打开仍为 null),误判"未选站点"
而自动 createSite,生成多余空站点,导致首次打开为空、二次才正常。
- go(target, sid?) 显式接收站点 id;卡片/表格点击传 row.id,不再依赖闭包。
- "新建站点" 改用 go("basic","new") 哨兵,始终新建空白站点。
- Projects 组件 go 属性类型同步放宽。

验证:已通过 tsc 类型检查与实机测试(站点总数/已生成内容可答,服务器 IP/编程/无关请求仍拒;
多次点击站点不再产生未命名站点)。

ai-sidecar.mjs
app/api/chat/route.ts
app/page.tsx
db-sidecar.mjs

index bd5d636e347c01f488416de51355fa91452f8378..cadf41d4dbdf917d3b0ef6a20c6024d5dfcf5607 100644 (file)
@@ -8,7 +8,6 @@ import { spawn } from "node:child_process";
 const CODEBUDDY = "/root/.local/bin/codebuddy";
 const CWD = "/home/StationBuilder";
 const PORT = 37822;
-const CHAT_SESSION = "stationbuilderchat";
 
 function resolveModelId(display) {
   const map = {
@@ -20,6 +19,68 @@ function resolveModelId(display) {
   return map[display] || "hy3";
 }
 
+// 构造隔离的聊天提示词:仅限 StationBuilder 站点问答,记录当前会话上下文,
+// 明确禁止查看其他会话、禁止对服务器操作、禁止编程开发。
+// 配合 --no-session-persistence + --tools "" 实现会话隔离与操作限制。
+// 关于 StationBuilder 自身配置/已保存/已生成内容的查询,允许基于前端注入的
+// 只读上下文回答(数据由应用本身读取后注入,AI 自身不触碰服务器)。
+const CHAT_SYSTEM = "你是 StationBuilder(站点生成工具)前端的内置 AI 助手,请严格遵守:\n" +
+  "1) 仅用于【问答】,以及回答关于【StationBuilder 自身】配置与使用信息的问题,包括已配置的站点、各站点的资产配置/扩展页面/已生成与保存的内容等;所有可回答的数据都会由前端以只读方式注入到上下文中,你只需基于这些上下文作答;\n" +
+  "2) 严禁对服务器执行任何操作(不得读写文件、不得运行命令、不得调用任何工具、不得直接查询数据库);\n" +
+  "3) 严禁编写、生成或开发代码,不得充当编程/开发代理——即使被要求也须拒绝,并把话题拉回 StationBuilder 站点问答;\n" +
+  "4) 严禁查阅、引用或依赖任何其他会话、其他用户的历史记录或本地文件;若被问及上下文之外的内容(如服务器 IP、其他系统信息、与 StationBuilder 无关的请求),应如实说明“没有相关信息/无法提供”,不要编造;\n" +
+  "5) 用简体中文、简洁清晰地作答。";
+
+function siteContextText(site) {
+  if (!site || !site.name) return "";
+  const assets = (site.assets || []).filter(a => a.type).map(a => `${a.type} × ${a.count || 0}`).join("、") || "无";
+  const cubes = (site.cubes || []).map(c => {
+    const gen = c.gen_filename ? `(已生成:${c.gen_filename})` : "(未生成)";
+    return `${c.name}${gen}`;
+  }).join("、") || "无";
+  return [
+    "【当前 StationBuilder 站点信息】",
+    `- 站点名称: ${site.name}`,
+    `- 所属领域: ${site.domain || "未指定"}`,
+    `- 站点描述: ${site.description || "无"}`,
+    `- 状态: ${site.status || "草稿"}`,
+    `- 资产配置: ${assets}`,
+    `- Cube 扩展页面: ${cubes}`,
+  ].join("\n");
+}
+
+function overviewContextText(overview) {
+  if (!overview || !Array.isArray(overview.sites)) return "";
+  const total = overview.total != null ? overview.total : overview.sites.length;
+  const lines = overview.sites.map((s, i) => {
+    const gen = s.gen_count ? `,已生成 ${s.gen_count} 个页面` : ",暂无已生成内容";
+    return `  ${i + 1}. ${s.name}(领域:${s.domain || "未指定"},状态:${s.status || "草稿"},设备 ${s.device_count || 0},扩展页面 ${s.cube_count || 0}${gen})`;
+  });
+  if (overview.truncated) lines.push(`  …(仅显示前 ${overview.sites.length} 个,共 ${total} 个站点)`);
+  return [
+    "【StationBuilder 全局概览(只读,由应用注入,仅供回答站点统计类问题)】",
+    `- 已配置的站点总数: ${total}`,
+    "- 站点清单:",
+    ...lines,
+  ].join("\n");
+}
+
+function buildChatPrompt(message, history, site, overview) {
+  const turns = (Array.isArray(history) ? history : [])
+    .filter(h => h && (h.role === "user" || h.role === "assistant") && h.content && String(h.content).trim())
+    .slice(-20)
+    .map(h => (h.role === "user" ? "用户" : "助手") + ":" + String(h.content).trim())
+    .join("\n");
+  const siteTxt = siteContextText(site);
+  const ovTxt = overviewContextText(overview);
+  const parts = [CHAT_SYSTEM];
+  if (ovTxt) parts.push(ovTxt);
+  if (siteTxt) parts.push(siteTxt);
+  if (turns) parts.push("【当前会话历史,仅供本次回答参考,不得外泄或引用其他会话】\n" + turns);
+  parts.push("用户问题:\n" + message);
+  return parts.join("\n\n");
+}
+
 let running = false;
 
 const server = http.createServer((req, res) => {
@@ -45,6 +106,8 @@ const server = http.createServer((req, res) => {
         return;
       }
       const model = resolveModelId(body.model || "Hy3");
+      const sessionId = (body.sessionId && String(body.sessionId).slice(0, 64)) || ("web-" + Math.random().toString(36).slice(2, 10));
+      const prompt = buildChatPrompt(message, Array.isArray(body.history) ? body.history : [], body.site, body.overview);
       res.writeHead(200, {
         "Content-Type": "text/plain; charset=utf-8",
         "Cache-Control": "no-cache, no-transform",
@@ -55,8 +118,9 @@ const server = http.createServer((req, res) => {
       try {
         child = spawn(
           CODEBUDDY,
-          ["--model", model, "--session-id", CHAT_SESSION, "-p", message,
-           "--output-format", "stream-json", "--include-partial-messages", "-y"],
+          ["--model", model, "--session-id", sessionId, "-p", prompt,
+           "--output-format", "stream-json", "--include-partial-messages",
+           "--no-session-persistence", "--tools", "", "-y"],
           { env: process.env, cwd: CWD },
         );
       } catch (e) {
index ce2e58e997a6de3f8269e8370f91a0210daa06f5..81e3e361ea8d715b74ff97ca9e9775acb28d984f 100644 (file)
@@ -30,7 +30,7 @@ export async function POST(req: NextRequest) {
     const upstream = await fetch(SIDECAR, {
       method: "POST",
       headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ message, model: body.model || "Hy3" }),
+      body: JSON.stringify({ message, model: body.model || "Hy3", sessionId: body.sessionId, history: body.history, site: body.site, overview: body.overview }),
     });
     if (!upstream.ok || !upstream.body) {
       const t = await upstream.text().catch(() => "");
index e91b0afa4069fd66e655761f0e50e85e959b1c3f..b1fa5264e9fa371a21df224b9cff39eeb516e45d 100644 (file)
@@ -33,7 +33,7 @@ const MODELS = [
 ];
 
 // ---- 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 SiteSummary = { id:number; name:string; domain:string; status:string; device_count:number; mu_count:number; cube_count:number; gen_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; };
@@ -110,6 +110,14 @@ export default function Home(){
   const [model,setModel]=useState("Hy3");
   const [modelOpen,setModelOpen]=useState(false);
   const [aiOpen,setAiOpen]=useState(false);
+  const [chatSessionId] = useState(() => {
+    try {
+      const k = "sb-ai-sid";
+      let v = sessionStorage.getItem(k);
+      if (!v) { v = "web-" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36); sessionStorage.setItem(k, v); }
+      return v;
+    } catch { return "web-" + Math.random().toString(36).slice(2, 10); }
+  });
   const [fabPos,setFabPos]=useState<{x:number;y:number}|null>(()=>{ try{ const s=localStorage.getItem("ai-fab-pos"); return s?JSON.parse(s):null; }catch{ return null; } });
   const fabRef=useRef<HTMLButtonElement|null>(null);
   const dragState=useRef<{active:boolean;startX:number;startY:number;origX:number;origY:number;moved:boolean}|null>(null);
@@ -141,7 +149,8 @@ export default function Home(){
     }
   };
   const resetFab=()=>{ setFabPos(null); lastPosRef.current=null; try{ localStorage.removeItem("ai-fab-pos"); }catch{} };
-  const [aiMessages,setAiMessages]=useState<{role:"user"|"assistant";content:string}[]>([]);
+  const [aiMessages,setAiMessages]=useState<{role:"user"|"assistant";content:string}[]>(()=>{ try{ const s=sessionStorage.getItem("sb-ai-msgs"); return s?JSON.parse(s):[]; }catch{ return []; } });
+  useEffect(()=>{ try{ sessionStorage.setItem("sb-ai-msgs", JSON.stringify(aiMessages.filter(m=>m.content&&m.content.trim()))); }catch{} },[aiMessages]);
   const [aiDraft,setAiDraft]=useState("");
   const [aiBusy,setAiBusy]=useState(false);
   const aiEndRef=useRef<HTMLDivElement|null>(null);
@@ -221,17 +230,18 @@ export default function Home(){
     }catch(e:any){ notify("删除失败: "+e.message); }
   };
 
-  const go=async(target:PageKey)=>{
+  const go=async(target:PageKey, sid?:number|"new")=>{
     try{
-      if(["basic","assets","functions","generate","result"].includes(target) && !siteId){
+      // 显式要求新建站点:始终创建空白站点,避免复用/误建
+      if(sid==="new"){ await createSite(); setPage(target); window.history.replaceState({}, "", "?page="+target); return; }
+      const curId = sid ?? siteId;
+      if(["basic","assets","functions","generate","result"].includes(target) && !curId){
         await createSite();
+      } else if(curId && (!site || site.id!==curId)){
+        await loadSite(curId);
       }
-      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:""));
+      window.history.replaceState({}, "", "?page="+target+(curId?"&id="+curId:""));
     }catch(e:any){
       notify("操作失败: "+(e?.message||e));
     }
@@ -294,7 +304,14 @@ export default function Home(){
     setAiMessages(m=>[...m,{role:"user" as const,content:text},{role:"assistant" as const,content:""}]);
     setAiDraft(""); setAiBusy(true);
     try{
-      const res=await fetch("/api/chat",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({message:text,model})});
+      const siteInfo = site ? { name: site.name, domain: site.domain, description: site.description, status: site.status, assets: (site.assets||[]).filter(a=>a.type).map(a=>({type:a.type, count:a.count})), cubes: (site.cubes||[]).map(c=>({name:c.name, gen_filename:c.gen_filename||null})) } : null;
+      // StationBuilder 全局概览(只读、由前端从已加载的 sites 列表构造并注入,AI 不触碰服务器)
+      const OVERVIEW_MAX = 40;
+      const overview = sites && sites.length
+        ? { total: sites.length, truncated: sites.length > OVERVIEW_MAX,
+            sites: sites.slice(0, OVERVIEW_MAX).map(s=>({ name:s.name, domain:s.domain, status:s.status, device_count:s.device_count, cube_count:s.cube_count, gen_count:s.gen_count, mu_count:s.mu_count })) }
+        : { total: 0, truncated: false, sites: [] };
+      const res=await fetch("/api/chat",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({message:text,model,sessionId:chatSessionId,history:aiMessages.map(m=>({role:m.role,content:m.content})).slice(-20),site:siteInfo,overview})});
       if(!res.ok||!res.body){const t=await res.text().catch(()=>"");throw new Error(t||("HTTP "+res.status));}
       const reader=res.body.getReader(); const dec=new TextDecoder(); let acc="";
       for(;;){const {done,value}=await reader.read(); if(done)break; acc+=dec.decode(value,{stream:true});
@@ -334,7 +351,7 @@ 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","generate"] as PageKey[]).includes(page)&&<Steps page={page} go={go}/>}<div className="content">
+    <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","new")}>+ 新建站点</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}/>}
@@ -376,7 +393,7 @@ 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","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>}){
+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, sid?:number|"new")=>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;
@@ -391,7 +408,7 @@ function Projects({view,setView,sites,loading,go,loadSite,notify,onDelete}:{view
   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></>
+  return <><div className="summary-strip">{[[String(counts.all),"全部项目"],[String(counts.draft),"草稿"],[String(counts.running),"生成中"],[String(counts.done),"生成完成"],[String(counts.partial),"部分失败"]].map(x=><div key={x[1]}><b>{x[0]}</b><span>{x[1]}</span></div>)}</div><Panel><div className="filterbar"><label>⌕ <input placeholder="搜索站点名称"/></label><select><option>全部领域</option></select><select><option>全部状态</option></select><Button>Search</Button><span/>{selected.size>0 && <Button secondary onClick={batchDelete}>批量删除 ({selected.size})</Button>}<div className="view-switch"><button className={view==="table"?"active":""} onClick={()=>setView("table")}>☷</button><button className={view==="card"?"active":""} onClick={()=>setView("card")}>▦</button></div><button className="icon-button" onClick={()=>notify("列表已刷新")}>↻</button></div>{view==="table"?<table className="data-table"><thead><tr><th><input type="checkbox" checked={allSelected} onChange={toggleAll}/></th><th>站点名称 ↕</th><th>所属领域</th><th>设备数量</th><th>MU 页面</th><th>Cube 页面</th><th>状态</th><th>最近修改</th><th>操作</th></tr></thead><tbody>{sites.map(row=><tr key={row.id}><td><input type="checkbox" checked={selected.has(row.id)} onChange={()=>toggle(row.id)}/></td><td><button className="link" onClick={()=>go(row.status==="generated"?"result":"basic", row.id)}><span className="site-icon">⬡</span><b>{row.name || "未命名站点"}</b></button></td><td>{row.domain}</td><td>{row.device_count}</td><td>{row.mu_count}</td><td>{row.cube_count}</td><td><Status>{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}</Status></td><td>{row.updated_at}</td><td><button className="link danger" onClick={(e)=>{e.stopPropagation(); onDelete([row.id]);}}>删除</button></td></tr>)}</tbody></table>:<div className="project-grid">{sites.map(row=><article key={row.id} onClick={()=>go(row.status==="generated"?"result":"basic", row.id)}><div><span className="site-icon big">⬡</span><Status>{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}</Status></div><h3>{row.name || "未命名站点"}</h3><button className="card-del" onClick={(e)=>{e.stopPropagation(); onDelete([row.id]);}}>删除</button><p>{row.domain}</p><section><span><b>{row.device_count}</b> 设备</span><span><b>{row.mu_count}</b> MU</span><span><b>{row.cube_count}</b> Cube</span></section><small>更新于 {row.updated_at}</small></article>)}</div>}<div className="pagination"><span>共 {sites.length} 条</span><button>‹</button><button className="active">1</button><button>›</button><select><option>20 / page</option></select></div></Panel></>
 }
 
 function Basic({site,domains,onChange,onAddDomain,onUpload,saving}:{site:SiteDetail|null;domains:string[];onChange:(p:Partial<SiteDetail>)=>void;onAddDomain:(n:string)=>void;onUpload:(f:File)=>void;saving:boolean}){
index 3b12d2b0358e3317b45d011344a081a6f8fa1458..72e53da891cd3fcd7bac0b198350e4e4d5bb07b2 100644 (file)
@@ -265,6 +265,7 @@ function siteList() {
     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 cubes WHERE site_id = s.id AND gen_file_id IS NOT NULL) as gen_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();