];
// ---- 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; };
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;
)}
</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"/>}
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;
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="站点状态筛选">
<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>}
}
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'");
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 (?,?,?,?,?)");
}
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));
}