]> acesimba.cloud Git - StationBuilder.git/commitdiff
站点列表支持新建子站点(递归)与折叠收起,后端 sites 增加 parent_id
authorroot <root@localhost.localdomain>
Fri, 11 Sep 2026 03:56:08 +0000 (11:56 +0800)
committerroot <root@localhost.localdomain>
Fri, 11 Sep 2026 03:56:08 +0000 (11:56 +0800)
app/page.tsx
data/db/StationBuilder.sqlite
db-sidecar.mjs

index 9e845e959442cbefa74646107678a9a85743f4d1..67135cf42428e3e1be0a858af99c8de3b0ba19a2 100644 (file)
@@ -47,7 +47,7 @@ const MODELS = [
 ];
 
 // ---- types ----
-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 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; parent_id?:number|null; };
 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; };
@@ -403,6 +403,14 @@ export default function Home(){
     setSiteId(data.id);
     return data.id;
   };
+  const createSubSite=async(parentId:number)=>{
+    const parent = sites.find(s=>s.id===parentId);
+    const data:SiteDetail = await fetchDb("/sites", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({name:"", domain:parent?.domain||"", description:"", supplement:"", parent_id:parentId})});
+    setSites(prev=>[data as any, ...prev]);
+    setSite(data);
+    setSiteId(data.id);
+    setPage("basic"); window.history.replaceState({}, "", "?page=basic&id="+data.id);
+  };
   const deleteSites=async(ids:number[])=>{
     if(!ids.length) return;
     if(!(await confirm(`确认删除选中的 ${ids.length} 个站点?该操作不可恢复,相关资产、页面与生成文件将一并删除。`))) return;
@@ -644,7 +652,7 @@ export default function Home(){
     )}
     </div></aside>
     <main className="workspace"><div className="crumb">iStation StationBuilder / <b>{["domains","domainDetail","styleLibrary","deviceTemplates","toolSettings","domainSettings"].includes(page)?meta[page].label:page==="projects"?"站点列表":page==="siteEdit"?`站点管理 / 站点编辑 / ${siteEditMeta.label}`:`站点详情 / ${meta[page].label}`}</b></div><div className="page-heading"><div><h1>{page==="siteEdit"?siteEditMeta.label:meta[page].label}</h1><p>{page==="siteEdit"?siteEditMeta.subtitle:meta[page].subtitle}</p></div><div>{page==="projects"&&<Button onClick={()=>go("basic","new")}>+ 新建站点</Button>}{page==="siteEdit"&&<label className="site-edit-switcher"><span>当前站点</span><select value={siteId??""} onChange={async e=>{const id=Number(e.target.value);await loadSite(id);window.history.replaceState({},"",`?page=siteEdit&id=${id}`);}}>{sites.map(item=><option key={item.id} value={item.id}>{item.name||"未命名站点"}</option>)}</select></label>}{page==="domains"&&openDomainTemplateId==null&&<Button onClick={()=>setOpenDomainTemplateId("create")}>+ 新增领域方案</Button>}{page==="domains"&&openDomainTemplateId!=null&&<Button secondary onClick={()=>setOpenDomainTemplateId(null)}>← 返回方案列表</Button>}{domainFilter!=null&&page==="domains"&&openDomainTemplateId==null&&<Button secondary onClick={()=>{setDomainFilter(null);}}>清除领域筛选:{domainOptions.find(d=>d.id===domainFilter)?.name||""}</Button>}{page==="domainSettings"&&<Button onClick={()=>{setNewDomainName("");setAddingDomain(true);}}>+ 新增领域</Button>}</div></div>{page==="siteEdit"&&<SiteEditTabs section={siteEditSection} onChange={setSiteEditSection}/>} {SITE_EDIT_STEPS.includes(page as SiteEditSection)&&<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==="projects"&&<Projects view={view} setView={setView} sites={sites} loading={loading} go={go} loadSite={loadSite} notify={notify} onDelete={deleteSites} onCreateSub={createSubSite}/>}
       {page==="dashboard"&&<Dashboard/>}
       {page==="report-dashboard"&&<ImagePage src="/t/stationbuilder/ext-pages/report-dashboard.jpg"/>}
       {page==="report-doc"&&<ImagePage src="/t/stationbuilder/ext-pages/report-doc.jpg"/>}
@@ -709,7 +717,7 @@ function SiteEditTabs({section,onChange}:{section:SiteEditSection;onChange:(sect
   return <nav className="site-edit-tabs" aria-label="站点编辑功能">{SITE_EDIT_STEPS.map((item,index)=><button key={item} className={section===item?"active":""} aria-current={section===item?"page":undefined} onClick={()=>onChange(item)}><i>{index+1}</i><span>{meta[item].label}</span></button>)}</nav>;
 }
 
-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>}){
+function Projects({view,setView,sites,loading,go,loadSite,notify,onDelete,onCreateSub}:{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>;onCreateSub:(parentId:number)=>Promise<void>}){
   const counts = useMemo(()=>{
     const all = sites.length;
     const draft = sites.filter(s=>s.status==="draft").length;
@@ -732,6 +740,58 @@ function Projects({view,setView,sites,loading,go,loadSite,notify,onDelete}:{view
   const batchDelete = async ()=>{ await onDelete([...selected]); setSelected(new Set()); };
   const statusText=(status:string)=>status==="generated"?"生成完成":status==="partial"?"部分失败":status==="running"?"生成中":"草稿";
   const openProject=(row:SiteSummary)=>go("siteEdit",row.id);
+  const [collapsed,setCollapsed]=useState<Set<number>>(new Set());
+  const toggleCollapse=(id:number)=>setCollapsed(prev=>{ const n=new Set(prev); if(n.has(id)) n.delete(id); else n.add(id); return n; });
+  const childMap=useMemo(()=>{
+    const m=new Map<number, SiteSummary[]>();
+    for(const s of sites){ const p=Number(s.parent_id)||0; if(!m.has(p)) m.set(p,[]); m.get(p)!.push(s); }
+    for(const arr of m.values()) arr.sort((a,b)=>(b.updated_at||"").localeCompare(a.updated_at||""));
+    return m;
+  },[sites]);
+  const matchesFilter=(s:SiteSummary)=>(!query||(s.name||"").toLowerCase().includes(query.toLowerCase()))&&(!domain||s.domain===domain)&&(!statusFilter||s.status===statusFilter);
+  const visibleIds=useMemo(()=>{
+    const set=new Set<number>();
+    const dfs=(s:SiteSummary):boolean=>{
+      const kids=childMap.get(s.id)||[];
+      let visible=matchesFilter(s);
+      for(const k of kids) if(dfs(k)) visible=true;
+      if(visible) set.add(s.id);
+      return visible;
+    };
+    for(const s of (childMap.get(0)||[])) dfs(s);
+    return set;
+  },[childMap,query,domain,statusFilter]);
+  const buildRows=(pid:number, depth:number):React.ReactNode[]=>{
+    const kids=(childMap.get(pid)||[]).filter(s=>visibleIds.has(s.id));
+    const out:React.ReactNode[]=[];
+    for(const row of kids){
+      const rowKids=(childMap.get(row.id)||[]).filter(s=>visibleIds.has(s.id));
+      const hasKids=rowKids.length>0;
+      const isCollapsed=collapsed.has(row.id);
+      out.push(
+        <tr key={row.id} onClick={()=>setPreview(row)}>
+          <td onClick={e=>e.stopPropagation()}><input type="checkbox" checked={selected.has(row.id)} onChange={()=>toggle(row.id)}/></td>
+          <td>
+            {hasKids&&<AntButton type="text" onClick={e=>{e.stopPropagation();toggleCollapse(row.id);}} aria-label={isCollapsed?"展开":"收起"} icon={isCollapsed?<CaretRightOutlined/>:<CaretDownOutlined/>}/>}
+            <button className="link company-record-link" style={{paddingLeft: depth*22}} onClick={e=>{e.stopPropagation();setPreview(row);}}><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>{statusText(row.status)}</Status></td>
+          <td>{row.updated_at}</td>
+          <td onClick={e=>e.stopPropagation()}><Dropdown trigger={["click"]} menu={{items:[
+            {key:"open",label:"打开项目",icon:<EditOutlined/>},
+            {key:"sub",label:"新建子站点",icon:<ApartmentOutlined/>},
+            {key:"delete",label:"删除",danger:true,icon:<DeleteOutlined/>}
+          ],onClick:({key})=>key==="open"?openProject(row):key==="sub"?onCreateSub(row.id):onDelete([row.id])}}><AntButton type="text" aria-label="更多操作" icon={<EllipsisOutlined/>}/></Dropdown></td>
+        </tr>
+      );
+      if(hasKids && !isCollapsed) out.push(...buildRows(row.id, depth+1));
+    }
+    return out;
+  };
   if(loading) return <Panel><div className="empty-state">加载中…</div></Panel>;
   return <>{confirmEl}
     <div className="company-status-tabs" role="tablist" aria-label="站点状态筛选">
@@ -749,9 +809,9 @@ function Projects({view,setView,sites,loading,go,loadSite,notify,onDelete}:{view
         <select value={domain} onChange={e=>setDomain(e.target.value)}><option value="">全部领域</option>{domains.map(d=><option key={d} value={d}>{d}</option>)}</select>
         <div className="filter-actions"><Button onClick={()=>notify(`已显示 ${filtered.length} 个站点`)}>查询</Button><Button secondary onClick={()=>{setQuery("");setDomain("");setStatusFilter("");}}>重置</Button>{selected.size>0&&<Button secondary onClick={batchDelete}>删除所选 ({selected.size})</Button>}</div>
       </div>
-      <table className="data-table company-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>{filtered.map(row=><tr key={row.id} onClick={()=>setPreview(row)}><td onClick={e=>e.stopPropagation()}><input type="checkbox" checked={selected.has(row.id)} onChange={()=>toggle(row.id)}/></td><td><button className="link company-record-link" onClick={e=>{e.stopPropagation();setPreview(row);}}><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>{statusText(row.status)}</Status></td><td>{row.updated_at}</td><td onClick={e=>e.stopPropagation()}><Dropdown trigger={["click"]} menu={{items:[{key:"open",label:"打开项目",icon:<EditOutlined/>},{key:"delete",label:"删除",danger:true,icon:<DeleteOutlined/>}],onClick:({key})=>key==="open"?openProject(row):onDelete([row.id])}}><AntButton type="text" aria-label="更多操作" icon={<EllipsisOutlined/>}/></Dropdown></td></tr>)}</tbody></table>
-      {filtered.length===0&&<div className="empty-state">没有符合条件的站点</div>}
-      <div className="pagination"><span>{filtered.length} / 总数</span><button disabled>‹</button><button className="active">1</button><button disabled>›</button><select><option>10 / 页</option></select></div>
+      <table className="data-table company-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>{buildRows(0,0)}</tbody></table>
+      {visibleIds.size===0&&<div className="empty-state">没有符合条件的站点</div>}
+      <div className="pagination"><span>{visibleIds.size} / 总数</span><button disabled>‹</button><button className="active">1</button><button disabled>›</button><select><option>10 / 页</option></select></div>
     </Panel>
     <Drawer title={preview?.name||"站点详情"} width={420} open={!!preview} onClose={()=>setPreview(null)} extra={preview&&<Button onClick={()=>openProject(preview)}>打开项目</Button>}>
       {preview&&<div className="company-detail"><div className="detail-status"><Status>{statusText(preview.status)}</Status></div><h3>基础信息</h3><dl><dt>所属领域</dt><dd>{preview.domain||"—"}</dd><dt>设备数量</dt><dd>{preview.device_count}</dd><dt>MU 页面</dt><dd>{preview.mu_count}</dd><dt>Cube 页面</dt><dd>{preview.cube_count}</dd><dt>生成文件</dt><dd>{preview.gen_count}</dd><dt>最近修改</dt><dd>{preview.updated_at}</dd><dt>创建时间</dt><dd>{preview.created_at}</dd></dl></div>}
index a8677d504d944c87d8fa11efb37869afbc92a0db..6ee0bcbae4553c45cad71c92990789b37f0784ac 100644 (file)
Binary files a/data/db/StationBuilder.sqlite and b/data/db/StationBuilder.sqlite differ
index 6005ac0f52b81aad5197862bed4d04fcdd0fedad..1b4cb79e84acf5203a05eb728f92e93615ff43b4 100644 (file)
@@ -363,6 +363,7 @@ function ensureColumn(table, column, def) {
 }
 ensureColumn("cubes", "prompt", "TEXT");
 ensureColumn("sites", "style_id", "INTEGER");
+ensureColumn("sites", "parent_id", "INTEGER");
 ensureColumn("domain_templates", "supplement", "TEXT");
 ensureColumn("domain_templates", "style_id", "INTEGER");
 ensureColumn("domain_pages", "status", "TEXT DEFAULT 'pending'");
@@ -2568,7 +2569,7 @@ async function route(req, res) {
       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 || "");
+        const info = db.prepare("INSERT INTO sites (name, domain, description, supplement, status, parent_id) VALUES (?,?,?,?,'draft',?)").run(body.name || "", body.domain || "", body.description || "", body.supplement || "", body.parent_id != null ? Number(body.parent_id) : null);
         // insert default empty assets
         const siteId = info.lastInsertRowid;
         const insertAsset = db.prepare("INSERT INTO assets (site_id, type, count, rule, sort_order) VALUES (?,?,?,?,?)");
@@ -2594,9 +2595,10 @@ async function route(req, res) {
       }
       if (req.method === "PUT") {
         const body = await parseBody(req);
-        db.prepare("UPDATE sites SET name=?, domain=?, description=?, supplement=?, style_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?").run(
+        db.prepare("UPDATE sites SET name=?, domain=?, description=?, supplement=?, style_id=?, parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?").run(
           body.name ?? "", body.domain ?? "", body.description ?? "", body.supplement ?? "",
-          body.style_id != null ? Number(body.style_id) : null, siteId
+          body.style_id != null ? Number(body.style_id) : null,
+          body.parent_id != null ? Number(body.parent_id) : null, siteId
         );
         return respondJson(res, 200, hydrateSite(siteId));
       }