"use client";
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
-import { Button as AntButton, Drawer, Dropdown, Tag } from "antd";
-import { CaretDownOutlined, CaretRightOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, EllipsisOutlined, EyeOutlined, RobotOutlined } from "@ant-design/icons";
+import { Button as AntButton, Drawer, Dropdown, QRCode, Tag } from "antd";
+import { AimOutlined, ApartmentOutlined, BarsOutlined, BlockOutlined, CaretDownOutlined, CaretRightOutlined, ControlOutlined, CopyOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, EllipsisOutlined, EyeOutlined, LinkOutlined, PlusOutlined, PlusSquareOutlined, RobotOutlined, SearchOutlined, SnippetsOutlined, UploadOutlined, ZoomInOutlined, ZoomOutOutlined } from "@ant-design/icons";
-type PageKey = "login" | "projects" | "basic" | "assets" | "functions" | "generate" | "result" | "preview" | "domains" | "domainDetail" | "styleLibrary" | "deviceTemplates" | "toolSettings" | "domainSettings";
+type PageKey = "login" | "projects" | "siteEdit" | "basic" | "assets" | "pageConfig" | "functions" | "generate" | "result" | "preview" | "domains" | "domainDetail" | "styleLibrary" | "deviceTemplates" | "toolSettings" | "domainSettings";
type ModalKey = "" | "feedback" | "download" | "history" | "prompt" | "completePrompt";
const meta: Record<PageKey, { label: string; icon: string; subtitle: string }> = {
login:{label:"内部登录",icon:"◇",subtitle:"使用公司账号登录"},
projects:{label:"站点项目",icon:"▦",subtitle:"创建、查找和管理站点生成项目"},
+ siteEdit:{label:"站点编辑",icon:"",subtitle:"维护已建站点的基本属性与参考资料"},
basic:{label:"站点基本属性",icon:"▤",subtitle:"描述站点业务背景,信息越完整,生成结果越符合预期"},
- assets:{label:"Asset 资产设置",icon:"▧",subtitle:"配置设备类型、数量和资产层级,为页面和模拟数据提供基础"},
+ assets:{label:"站点资产设置",icon:"▧",subtitle:"通过快速配置或手动配置建立场站资产结构"},
+ pageConfig:{label:"页面配置",icon:"",subtitle:""},
functions:{label:"功能与菜单",icon:"☷",subtitle:"选择 MU 标准页面,定义 Cube 扩展页面及菜单结构"},
generate:{label:"生成确认",icon:"↻",subtitle:"确认生成范围,调用 Cube Skill 生成页面和模拟数据"},
result:{label:"站点结果",icon:"◫",subtitle:"集中查看、预览、校验并下载站点交付物"},
type DomainAsset = { id?:number; device_template_id:number; device_name:string; count:number; rule:string; sort_order:number };
type DomainPage = { id:number; name:string; description:string; json_text:string; status:string; gen_filename?:string|null; prompt?:string|null; source?:string|null; sort_order:number };
type DomainStepKey = "basic" | "assets" | "functions" | "opsflow" | "knowledge" | "generate" | "expert";
+type SiteEditSection = "basic" | "assets" | "pageConfig" | "functions" | "generate";
+const SITE_EDIT_STEPS:SiteEditSection[] = ["basic","assets","pageConfig","functions","generate"];
+type ManualAssetBinding = {source:string;name?:string;point:string;collect_type?:string;group?:string;device_name?:string;unit?:string;precision?:string;mapping?:string;description?:string};
+type ManualAssetNode = { id:string; name:string; alias?:string; asset_type?:string; image_data?:string; node_type:"group"|"device"; template_name:string; description:string; properties:Array<{name:string;value:string;unit:string}>; bindings:ManualAssetBinding[]; children:ManualAssetNode[] };
+type ManualAssetGroup = { id:number; site_id:number; name:string; category:string; template_name:string; structure:ManualAssetNode[]; created_at:string; updated_at:string };
// ---- helpers ----
function Status({children}:{children:React.ReactNode}){const v=String(children);const tone=/不可用|失败|异常|failed/.test(v)?"bad":/完成|正常|成功|通过|推荐|可用|已配置|已生成|已上传|generated/.test(v)?"ok":/生成中|校验中|告警|running/.test(v)?"warn":"muted";return <Tag className={"status "+tone}>{children}</Tag>}
export default function Home(){
const [page,setPage]=useState<PageKey>("projects");
const [projectNavOpen,setProjectNavOpen]=useState(true);
+ const [extensionNavOpen,setExtensionNavOpen]=useState(true);
+ const [reportNavOpen,setReportNavOpen]=useState(true);
const [resourceNavOpen,setResourceNavOpen]=useState(true);
const [systemNavOpen,setSystemNavOpen]=useState(true);
const [toast,setToast]=useState("");
});
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 routeAppliedRef=useRef(false);
const dragState=useRef<{active:boolean;startX:number;startY:number;origX:number;origY:number;moved:boolean}|null>(null);
const lastPosRef=useRef<{x:number;y:number}|null>(null);
const onFabPointerDown=(e:ReactPointerEvent)=>{
const [newDomainName,setNewDomainName]=useState("");
const [view,setView]=useState<"table"|"card">("table");
+ const [siteEditSection,setSiteEditSection]=useState<SiteEditSection>("basic");
+ const [assetConfigMode,setAssetConfigMode]=useState<"quick"|"manual">("quick");
const [functionTab,setFunctionTab]=useState<"mu"|"cube"|"menu">("mu");
const [previewTab,setPreviewTab]=useState<"info"|"json"|"feedback">("info");
const [feedbackCube,setFeedbackCube]=useState<Cube|null>(null);
const go=async(target:PageKey, sid?:number|"new")=>{
try{
+ if(target==="siteEdit")setSiteEditSection("basic");
// 显式要求新建站点:始终创建空白站点,避免复用/误建
if(sid==="new"){ await createSite(); setPage(target); window.history.replaceState({}, "", "?page="+target); return; }
- const curId = sid ?? siteId;
+ let curId = sid ?? siteId;
+ if(target==="siteEdit" && !curId){
+ const first = sites[0];
+ if(!first){
+ setPage("projects");
+ window.history.replaceState({}, "", "?page=projects");
+ notify("暂无可编辑站点,请先新建站点");
+ return;
+ }
+ curId = first.id;
+ }
// 详情类页面必须依附于已选站点:无站点时不自动建站,回站点列表并提示
- const needsSite = ["basic","assets","functions","generate","result","preview"].includes(target);
+ const needsSite = ["siteEdit","basic","assets","pageConfig","functions","generate","result","preview"].includes(target);
if(needsSite && !curId){
setPage("projects");
window.history.replaceState({}, "", "?page=projects");
}
};
+ useEffect(()=>{
+ if(loading||routeAppliedRef.current)return;
+ routeAppliedRef.current=true;
+ const params=new URLSearchParams(window.location.search);
+ const target=params.get("page") as PageKey|null;
+ if(!target||target==="projects"||!Object.prototype.hasOwnProperty.call(meta,target))return;
+ const rawId=Number(params.get("id"));
+ void go(target,Number.isInteger(rawId)&&rawId>0?rawId:undefined);
+ },[loading]);
+
const saveSiteBasic=async(patch:Partial<SiteDetail>)=>{
if(!site) return;
const next = {...site, ...patch};
if(page==="login")return <Login onLogin={()=>go("projects")}/>;
- const projectArea = (["projects","basic","assets","functions","generate","result","preview"] as PageKey[]).includes(page);
+ const projectArea = (["projects","siteEdit","basic","assets","pageConfig","functions","generate","result","preview"] as PageKey[]).includes(page);
const resourceArea = (["domains","domainDetail","deviceTemplates","styleLibrary"] as PageKey[]).includes(page);
const systemArea = (["toolSettings","domainSettings"] as PageKey[]).includes(page);
+ const siteEditMeta = meta[siteEditSection];
+ const manualAssetActive = assetConfigMode==="manual" && (page==="assets" || (page==="siteEdit"&&siteEditSection==="assets"));
return <div className="app-shell">
<header className="topbar wise-header">
<nav className="side-menu" aria-label="StationBuilder 主菜单">
<section className={projectArea?"menu-section current":"menu-section"}>
<button className="menu-section-title" aria-expanded={projectNavOpen} onClick={()=>setProjectNavOpen(v=>!v)}><span>站点管理</span>{projectNavOpen?<CaretDownOutlined/>:<CaretRightOutlined/>}</button>
- {projectNavOpen&&<div className="menu-section-items"><button className={projectArea?"active":""} onClick={()=>go("projects")}><span>站点项目</span></button></div>}
+ {projectNavOpen&&<div className="menu-section-items">
+ <button className={page==="projects"?"active":""} onClick={()=>go("projects")}><span>站点项目</span></button>
+ <button className={page==="siteEdit"?"active":""} onClick={()=>go("siteEdit",sites[0]?.id)}><span>站点编辑</span></button>
+ </div>}
+ </section>
+ <section className="menu-section extension-menu-section" aria-label="扩展页面菜单">
+ <button className="menu-section-title" aria-expanded={extensionNavOpen} onClick={()=>setExtensionNavOpen(v=>!v)}><span>扩展页面</span>{extensionNavOpen?<CaretDownOutlined/>:<CaretRightOutlined/>}</button>
+ {extensionNavOpen&&<div className="menu-section-items extension-menu-items">
+ <div className="extension-menu-item">仪表盘</div>
+ <div className="extension-submenu">
+ <button className="extension-menu-item extension-submenu-title" aria-expanded={reportNavOpen} onClick={()=>setReportNavOpen(v=>!v)}><span>报表</span>{reportNavOpen?<CaretDownOutlined/>:<CaretRightOutlined/>}</button>
+ {reportNavOpen&&<div className="extension-third-menu">
+ <div>仪表盘报表</div>
+ <div>文档报表</div>
+ </div>}
+ </div>
+ <div className="extension-menu-item">数据源</div>
+ </div>}
</section>
<section className={resourceArea?"menu-section current":"menu-section"}>
<button className="menu-section-title" aria-expanded={resourceNavOpen} onClick={()=>setResourceNavOpen(v=>!v)}><span>方案中心</span>{resourceNavOpen?<CaretDownOutlined/>:<CaretRightOutlined/>}</button>
</section>
</nav>
</aside>
- <aside className="side-nav"><div className="module-switch"><button className={(["projects","basic","assets","functions","generate","result","preview"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("projects")}><b>▦</b><span>StationBuilder<small>站点生成工具</small></span></button><button className={(["domains","domainDetail","deviceTemplates"] as PageKey[]).includes(page)?"active":""} onClick={()=>{setDomainFilter(null); go("domains");}}><b>◆</b><span>领域方案库<small>行业标准方案</small></span></button><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b><span>页面样式库<small>页面视觉样式</small></span></button></div>{page==="styleLibrary"?<><small>样式库导航</small><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b>全部页面样式</button></>:(["domains","domainDetail","deviceTemplates"] as PageKey[]).includes(page)?<><small>方案库导航</small><button className={page==="domains"?"active":""} onClick={()=>{setDomainFilter(null); go("domains");}}><b>▦</b>领域方案</button><button className={page==="deviceTemplates"?"active":""} onClick={()=>go("deviceTemplates")}><b>⬡</b>设备模版</button><small>领域分类</small>
+ <aside className="side-nav"><div className="module-switch"><button className={(["projects","basic","assets","pageConfig","functions","generate","result","preview"] as PageKey[]).includes(page)?"active":""} onClick={()=>go("projects")}><b>▦</b><span>StationBuilder<small>站点生成工具</small></span></button><button className={(["domains","domainDetail","deviceTemplates"] as PageKey[]).includes(page)?"active":""} onClick={()=>{setDomainFilter(null); go("domains");}}><b>◆</b><span>领域方案库<small>行业标准方案</small></span></button><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b><span>页面样式库<small>页面视觉样式</small></span></button></div>{page==="styleLibrary"?<><small>样式库导航</small><button className={page==="styleLibrary"?"active":""} onClick={()=>go("styleLibrary")}><b>❖</b>全部页面样式</button></>:(["domains","domainDetail","deviceTemplates"] as PageKey[]).includes(page)?<><small>方案库导航</small><button className={page==="domains"?"active":""} onClick={()=>{setDomainFilter(null); go("domains");}}><b>▦</b>领域方案</button><button className={page==="deviceTemplates"?"active":""} onClick={()=>go("deviceTemplates")}><b>⬡</b>设备模版</button><small>领域分类</small>
{domainOptions.map((d:any)=>(
<div key={d.id} className={"domain-nav-row"+(page==="domains"&&domainFilter===d.id?" active":"")}>
<button className={(page==="domains"&&domainFilter===d.id)?"active":""} onClick={()=>{setDomainFilter(d.id); go("domains");}}><b>◆</b><span className="domain-nav-name">{d.name}</span></button>
</div>
) : (
<div className="domain-nav-add-cta"><button className="domain-nav-add" onClick={()=>{setAddingDomain(true);setNewDomainName("");}}><b>+</b>新增领域</button></div>
-)}</>:<><small>站点列表</small><button className={page==="projects"?"active":""} onClick={()=>go("projects")}><b>▦</b>站点项目</button><small>站点详情</small><select className="site-switcher" value={siteId ?? ""} onChange={e=>{const v=e.target.value; if(v){ const t=(page==="projects"||["domains","domainDetail","styleLibrary","deviceTemplates"].includes(page))?"basic":page; go(t, Number(v)); }}}><option value="">选择站点…</option>{sites.map(s=><option key={s.id} value={s.id}>{s.name||"未命名站点"}</option>)}</select><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">
+)}</>:<><small>站点列表</small><button className={page==="projects"?"active":""} onClick={()=>go("projects")}><b>▦</b>站点项目</button><small>站点详情</small><select className="site-switcher" value={siteId ?? ""} onChange={e=>{const v=e.target.value; if(v){ const t=(page==="projects"||["domains","domainDetail","styleLibrary","deviceTemplates"].includes(page))?"basic":page; go(t, Number(v)); }}}><option value="">选择站点…</option>{sites.map(s=><option key={s.id} value={s.id}>{s.name||"未命名站点"}</option>)}</select><small>生成向导</small>{SITE_EDIT_STEPS.map((item,index)=><button key={item} className={page===item?"active":""} onClick={()=>go(item)}><em>{index+1}</em>{meta[item].label}</button>)}<small>方案交付</small><button className={page==="result"?"active":""} onClick={()=>go("result")}><b>◫</b>站点结果</button><button className={page==="preview"?"active":""} onClick={()=>go("preview")}><b>▣</b>页面预览</button></>}<div className="side-help">
<b>内部工具 · v0.3</b>
<span className="model-trigger" onClick={() => setModelOpen(o => !o)}>
Wordbuddy AI · {model}<span className="caret">▾</span>
</>
)}
</div></aside>
- <main className="workspace"><div className="crumb">iStation StationBuilder / <b>{["domains","domainDetail","styleLibrary","deviceTemplates","toolSettings","domainSettings"].includes(page)?meta[page].label:page==="projects"?"站点列表":`站点详情 / ${meta[page].label}`}</b></div><div className="page-heading"><div><h1>{meta[page].label}</h1><p>{meta[page].subtitle}</p></div><div>{page==="projects"&&<Button onClick={()=>go("basic","new")}>+ 新建站点</Button>}{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>{(["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>{["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==="domains"||page==="domainDetail")&&(openDomainTemplateId!=null
? <DomainDetail id={openDomainTemplateId==="create"?null:openDomainTemplateId} go={go} notify={notify} onBack={()=>setOpenDomainTemplateId(null)} onCreated={setOpenDomainTemplateId} viewCompletePrompt={viewDtCompletePrompt} viewCompleteAssetsPrompt={viewDtCompleteAssetsPrompt} viewCompleteCubesPrompt={viewDtCompleteCubesPrompt} pageStyles={pageStyles}/>
{page==="deviceTemplates"&&<CompanyDeviceTemplates notify={notify} onViewCpPrompt={viewDeviceCpCompletePrompt}/>}
{page==="toolSettings"&&<InternalToolSettings model={model} onModelChange={setModel} notify={notify}/>}
{page==="domainSettings"&&<DomainSettings domains={domainOptions} notify={notify} onDeleted={handleDomainDeleted}/>}
- {page==="basic"&&<Basic site={site} domains={domains} onChange={saveSiteBasic} onAddDomain={addDomain} onUpload={uploadFile} saving={saving} notify={notify} onViewCompletePrompt={viewCompletePrompt}/>}
- {page==="assets"&&<Assets site={site} onChange={saveAssets} saving={saving} notify={notify} onViewCompletePrompt={viewCompleteAssetsPrompt}/>}
+ {(page==="basic"||(page==="siteEdit"&&siteEditSection==="basic"))&&<Basic site={site} domains={domains} onChange={saveSiteBasic} onAddDomain={addDomain} onUpload={uploadFile} saving={saving} notify={notify} onViewCompletePrompt={viewCompletePrompt}/>}
+ {page==="assets"&&<Assets site={site} onChange={saveAssets} saving={saving} notify={notify} onViewCompletePrompt={viewCompleteAssetsPrompt} mode={assetConfigMode} onModeChange={setAssetConfigMode}/>}
+ {page==="siteEdit"&&siteEditSection==="assets"&&<Assets site={site} onChange={saveAssets} saving={saving} notify={notify} onViewCompletePrompt={viewCompleteAssetsPrompt} mode={assetConfigMode} onModeChange={setAssetConfigMode}/>}
+ {(page==="pageConfig"||(page==="siteEdit"&&siteEditSection==="pageConfig"))&&<div className="site-step-blank" aria-label="页面配置内容区域"/>}
{page==="functions"&&<Functions site={site} muTree={muTree} onMuChange={saveMu} onSiteChange={setSite} pageStyles={pageStyles} notify={notify} onViewCompletePrompt={viewCompleteCubesPrompt}/>}
+ {page==="siteEdit"&&siteEditSection==="functions"&&<Functions site={site} muTree={muTree} onMuChange={saveMu} onSiteChange={setSite} pageStyles={pageStyles} notify={notify} onViewCompletePrompt={viewCompleteCubesPrompt}/>}
{page==="generate"&&<Generate site={site} onSiteChange={setSite} notify={notify} viewPrompt={viewPrompt} viewSitePrompt={viewSitePrompt}/>}
+ {page==="siteEdit"&&siteEditSection==="generate"&&<Generate site={site} onSiteChange={setSite} notify={notify} viewPrompt={viewPrompt} viewSitePrompt={viewSitePrompt}/>}
{page==="result"&&<Result site={site} go={go} setModal={setModal} viewPrompt={viewPrompt} notify={notify} openFeedback={openFeedback} viewSitePrompt={viewSitePrompt}/>}
{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>
+ </div>{SITE_EDIT_STEPS.slice(0,-1).includes(page as SiteEditSection)&&<div className="sticky-actions"><Button secondary onClick={()=>{const i=SITE_EDIT_STEPS.indexOf(page as SiteEditSection);go(i>0?SITE_EDIT_STEPS[i-1]:"projects")}}>上一步</Button><div><Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button><Button onClick={()=>{const i=SITE_EDIT_STEPS.indexOf(page as SiteEditSection);go(SITE_EDIT_STEPS[i+1])}}>下一步</Button></div></div>}{page==="siteEdit"&&<div className="sticky-actions"><Button secondary onClick={()=>{const i=SITE_EDIT_STEPS.indexOf(siteEditSection);if(i>0)setSiteEditSection(SITE_EDIT_STEPS[i-1]);else go("projects")}}>上一步</Button><div><Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>{siteEditSection!=="generate"&&<Button onClick={()=>{const i=SITE_EDIT_STEPS.indexOf(siteEditSection);setSiteEditSection(SITE_EDIT_STEPS[i+1])}}>下一步</Button>}</div></div>}</main>
<footer className="site-footer">陕ICP备2026022239号-2</footer>
{addingDomain&&page==="domainSettings"&&<div className="dlg-backdrop" onMouseDown={()=>{setAddingDomain(false);setNewDomainName("");}}><div className="dlg-box domain-create-dialog" onMouseDown={e=>e.stopPropagation()}><h2>新增领域</h2><p>创建后可在领域方案和站点基本信息中直接选择。</p><input autoFocus value={newDomainName} maxLength={6} placeholder="请输入领域名称(最多 6 个字)" onChange={e=>setNewDomainName(e.target.value)} onKeyDown={async e=>{if(e.key==="Enter")await doAddDomain();if(e.key==="Escape"){setAddingDomain(false);setNewDomainName("");}}}/><div className="dlg-actions"><Button secondary onClick={()=>{setAddingDomain(false);setNewDomainName("");}}>取消</Button><Button disabled={!newDomainName.trim()} onClick={doAddDomain}>创建</Button></div></div></div>}
{toast&&<div className="toast"><b>✓</b>{toast}</div>}{modal&&<Modal type={modal} close={()=>setModal("")} notify={notify} promptData={promptData} feedbackCube={feedbackCube} refineCube={refineCube} refining={refining} refineLog={refineLog}/>}
- {!aiOpen&&<button ref={fabRef} className="ai-fab" title="AI 助手 · 拖动可移动位置,双击复位" aria-label="AI 助手"
+ {!aiOpen&&!manualAssetActive&&<button ref={fabRef} className="ai-fab" title="AI 助手 · 拖动可移动位置,双击复位" aria-label="AI 助手"
style={fabPos?{left:fabPos.x,top:fabPos.y,right:"auto",bottom:"auto"}:undefined}
onPointerDown={onFabPointerDown} onPointerMove={onFabPointerMove} onPointerUp={onFabPointerUp} onDoubleClick={resetFab}>
<img src="/t/stationbuilder/ai-icon.png" alt="AI 助手" className="ai-fab-icon" draggable={false} />
</button>}
- {aiOpen&&<div className="ai-dialog">
+ {aiOpen&&!manualAssetActive&&<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>
<div className="ai-messages">
- {aiMessages.length===0&&<div className="ai-empty">我是由 Wordbuddy <b>{model}</b> 驱动的站点助手,问我和站点生成相关的问题吧。</div>}
+ {aiMessages.length===0&&<div className="ai-welcome">
+ <div className="ai-welcome-intro"><span>✦</span><div><b>我可以帮你建设场站</b><p>进入对应页面使用 AI 功能,或直接在下方提问。</p></div></div>
+ <div className="ai-capability-list">
+ <div><i>1</i><span><b>完善站点需求</b><small>站点编辑 → 站点基本属性</small></span></div>
+ <div><i>2</i><span><b>推荐设备、数量与命名规则</b><small>站点编辑 → 站点资产设置</small></span></div>
+ <div><i>3</i><span><b>规划并生成 Cube 页面</b><small>站点编辑 → 功能与菜单</small></span></div>
+ <div><i>4</i><span><b>检查结果并按意见修改页面</b><small>站点编辑 → 生成确认/页面预览</small></span></div>
+ <div><i>5</i><span><b>补全领域方案与设备采集点</b><small>方案中心 → 领域方案/设备模板</small></span></div>
+ </div>
+ <div className="ai-welcome-tip"><b>你可以问:</b>当前站点还缺少哪些设备或页面?</div>
+ </div>}
{aiMessages.map((m,i)=><div key={i} className={"ai-bubble "+(m.role==="user"?"me":"bot")}><span className="ai-avatar">{m.role==="user"?"W":"✦"}</span><div className="ai-text">{m.content||(m.role==="assistant"&&aiBusy?"…":"")}</div></div>)}
<div ref={aiEndRef}/>
</div>
}
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 Steps({page,go}:{page:PageKey;go:(p:PageKey)=>void}){const current=SITE_EDIT_STEPS.indexOf(page as SiteEditSection);return <div className="steps">{SITE_EDIT_STEPS.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<SITE_EDIT_STEPS.length-1&&<b>›</b>}</button>)}</div>}
+
+function SiteEditTabs({section,onChange}:{section:SiteEditSection;onChange:(section:SiteEditSection)=>void}){
+ 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>}){
const counts = useMemo(()=>{
const toggleAll = ()=> setSelected(allSelected ? new Set() : new Set(filtered.map(s=>s.id)));
const batchDelete = async ()=>{ await onDelete([...selected]); setSelected(new Set()); };
const statusText=(status:string)=>status==="generated"?"生成完成":status==="partial"?"部分失败":status==="running"?"生成中":"草稿";
- const openProject=(row:SiteSummary)=>go(row.status==="generated"?"result":"basic",row.id);
+ const openProject=(row:SiteSummary)=>go("siteEdit",row.id);
if(loading) return <Panel><div className="empty-state">加载中…</div></Panel>;
return <>{confirmEl}
<div className="company-status-tabs" role="tablist" aria-label="站点状态筛选">
</Drawer></>;
}
-function Assets({site,onChange,saving,notify,onViewCompletePrompt}:{site:SiteDetail|null;onChange:(assets:Asset[])=>void;saving:boolean;notify:(m:string)=>void;onViewCompletePrompt:()=>void}){
+function Assets({site,onChange,saving,notify,onViewCompletePrompt,mode,onModeChange}:{site:SiteDetail|null;onChange:(assets:Asset[])=>void;saving:boolean;notify:(m:string)=>void;onViewCompletePrompt:()=>void;mode:"quick"|"manual";onModeChange:(mode:"quick"|"manual")=>void}){
const [completing,setCompleting]=useState(false);
const [completeLog,setCompleteLog]=useState("");
const completeConsoleRef=useRef<HTMLPreElement>(null);
};
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="two-column">
+ return <div className="asset-config-shell">
+ <div className="asset-config-tabs" role="tablist" aria-label="资产配置方式">
+ <button role="tab" aria-selected={mode==="quick"} className={mode==="quick"?"active":""} onClick={()=>onModeChange("quick")}><span>快速资产配置</span><small>按设备类型、数量和命名规则快速生成</small></button>
+ <button role="tab" aria-selected={mode==="manual"} className={mode==="manual"?"active":""} onClick={()=>onModeChange("manual")}><span>手动配置</span><small>按资产组逐层维护节点与数据关系</small></button>
+ </div>
+ {mode==="manual"?<ManualAssetConfiguration site={site} notify={notify}/>:<div className="two-column quick-asset-config">
<div>
<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>}>
</Panel>
{saving&&<Panel><div className="empty-state">正在保存…</div></Panel>}
</div>
+ </div>}
</div>;
}
+function newAssetNodeId(){ return "node-"+Date.now().toString(36)+Math.random().toString(36).slice(2,7); }
+
+function cloneAssetNode(node:ManualAssetNode):ManualAssetNode{
+ return {...node,id:newAssetNodeId(),properties:node.properties.map(item=>({...item})),bindings:node.bindings.map(item=>({...item})),children:node.children.map(cloneAssetNode)};
+}
+
+function findAssetNode(nodes:ManualAssetNode[],id:string):ManualAssetNode|null{
+ for(const node of nodes){ if(node.id===id)return node; const child=findAssetNode(node.children,id); if(child)return child; }
+ return null;
+}
+
+function updateAssetNode(nodes:ManualAssetNode[],id:string,next:ManualAssetNode):ManualAssetNode[]{
+ return nodes.map(node=>node.id===id?next:{...node,children:updateAssetNode(node.children,id,next)});
+}
+
+function appendAssetNode(nodes:ManualAssetNode[],parentId:string|null,node:ManualAssetNode):ManualAssetNode[]{
+ if(!parentId)return [...nodes,node];
+ return nodes.map(item=>item.id===parentId?{...item,children:[...item.children,node]}:{...item,children:appendAssetNode(item.children,parentId,node)});
+}
+
+function removeAssetNode(nodes:ManualAssetNode[],id:string):ManualAssetNode[]{
+ return nodes.filter(node=>node.id!==id).map(node=>({...node,children:removeAssetNode(node.children,id)}));
+}
+
+function findAssetParentId(nodes:ManualAssetNode[],id:string,parentId:string|null=null):string|null{
+ for(const node of nodes){if(node.id===id)return parentId;const found=findAssetParentId(node.children,id,node.id);if(found!==null)return found;}
+ return null;
+}
+
+function ManualAssetConfiguration({site,notify}:{site:SiteDetail;notify:(message:string)=>void}){
+ const [group,setGroup]=useState<ManualAssetGroup|null>(null);
+ const [loading,setLoading]=useState(true);
+ const [loadError,setLoadError]=useState("");
+
+ const seedChildren=()=>site.assets.filter(item=>item.type).map(item=>({
+ id:newAssetNodeId(),name:item.type,node_type:"group" as const,template_name:item.type,description:`${item.type}设备集合`,properties:[],bindings:[],
+ children:Array.from({length:Math.max(0,Number(item.count)||0)},(_,index)=>({id:newAssetNodeId(),name:formatInstances(item.rule,Math.max(0,Number(item.count)||0)).split("、")[index]||`${item.type}-${String(index+1).padStart(2,"0")}`,node_type:"device" as const,template_name:item.type,description:"",properties:[],bindings:[],children:[]}))
+ }));
+ const seedStructure=():ManualAssetNode[]=>[{id:newAssetNodeId(),name:site.name||"未命名站点",node_type:"group",template_name:"站点资产根节点",description:"当前站点的资产结构根节点",properties:[],bindings:[],children:seedChildren()}];
+
+ const loadGroup=async()=>{
+ if(!site.id)return;
+ setLoading(true);
+ setLoadError("");
+ try{
+ let rows=await fetchDb(`/sites/${site.id}/asset-groups`) as ManualAssetGroup[];
+ if(rows.length===0){
+ const created=await fetchDb(`/sites/${site.id}/asset-groups`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:`${site.name||"未命名站点"}资产组`,category:site.domain||"站点资产",template_name:"通用资产模板",structure:seedStructure()})}) as ManualAssetGroup;
+ rows=[created];
+ }
+ // 手动配置只维护当前站点所属的唯一资产组;旧版本若已有多条,取最近更新的一条。
+ const current=rows[0];
+ setGroup({...current,name:current.name?.trim()||`${site.name||"未命名站点"}资产组`,structure:current.structure.length?current.structure:seedStructure()});
+ }catch(e:any){const message="加载资产组失败:"+(e?.message||e);setLoadError(message);notify(message);}
+ finally{setLoading(false);}
+ };
+ useEffect(()=>{setGroup(null);loadGroup();},[site.id]);
+
+ const saveGroup=async(group:ManualAssetGroup)=>{
+ const saved=await fetchDb(`/sites/${site.id}/asset-groups/${group.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(group)}) as ManualAssetGroup;
+ setGroup(saved); notify("资产组已保存"); return saved;
+ };
+
+ if(loading)return <div className="asset-editor-loading"><span>正在打开当前站点资产组…</span></div>;
+ if(loadError||!group)return <div className="asset-editor-loading error"><span>{loadError||"资产组不可用"}</span><Button secondary onClick={loadGroup}>重新加载</Button></div>;
+ return <ManualAssetEditor key={group.id} group={group} siteName={site.name} notify={notify} onSave={saveGroup}/>;
+}
+
+function flattenAssetNodes(nodes:ManualAssetNode[],depth=0):Array<{node:ManualAssetNode;depth:number}>{
+ return nodes.flatMap(node=>[{node,depth},...flattenAssetNodes(node.children,depth+1)]);
+}
+
+const assetPropertyFields=["型号","价值","地址","安装位置","负责人","品牌","供应商","经销商","出厂日期","投产日期","质保截止日期","NFC ID","备注","设备运行状态计算阈值"] as const;
+function assetPropertyValue(node:ManualAssetNode,name:string){return node.properties.find(item=>item.name===name)?.value||"";}
+function setAssetProperty(node:ManualAssetNode,name:string,value:string):ManualAssetNode{
+ const exists=node.properties.some(item=>item.name===name);
+ return {...node,properties:exists?node.properties.map(item=>item.name===name?{...item,value}:item):[...node.properties,{name,value,unit:""}]};
+}
+
+function ManualAssetEditor({group,siteName,onSave,notify}:{group:ManualAssetGroup;siteName:string;onSave:(group:ManualAssetGroup)=>Promise<ManualAssetGroup>;notify:(message:string)=>void}){
+ const [draft,setDraft]=useState(group);
+ const [selectedId,setSelectedId]=useState<string|null>(group.structure[0]?.id||null);
+ const [nodeOpen,setNodeOpen]=useState(false);
+ const [nodeTab,setNodeTab]=useState<"basic"|"properties"|"binding">("basic");
+ const [nodeDraft,setNodeDraft]=useState<ManualAssetNode|null>(null);
+ const [batchOpen,setBatchOpen]=useState(false);
+ const [batchTab,setBatchTab]=useState<"online"|"excel">("online");
+ const [batchRows,setBatchRows]=useState([{template_name:"进水提升泵",count:2,rule:"beng-ts-##"}]);
+ const [clipboard,setClipboard]=useState<ManualAssetNode|null>(null);
+ const [search,setSearch]=useState("");
+ const [viewMode,setViewMode]=useState<"tree"|"list">("tree");
+ const [zoom,setZoom]=useState(1);
+ const [pan,setPan]=useState({x:0,y:0});
+ const [dragging,setDragging]=useState(false);
+ const [saving,setSaving]=useState(false);
+ const [templateCategory,setTemplateCategory]=useState("site");
+ const [creatingNodeId,setCreatingNodeId]=useState<string|null>(null);
+ const [bindingComponent,setBindingComponent]=useState("HealthStatus");
+ const [bindingImportOpen,setBindingImportOpen]=useState(false);
+ const selected=selectedId?findAssetNode(draft.structure,selectedId):null;
+ const openNode=(node:ManualAssetNode)=>{setCreatingNodeId(null);setSelectedId(node.id);setNodeDraft({...node,alias:node.alias||`${node.name}-alias`,asset_type:node.asset_type||(node.node_type==="device"?"Machine":"Group"),properties:node.properties.map(item=>({...item})),bindings:node.bindings.map(item=>({...item})),children:node.children});setBindingComponent(node.bindings[0]?.source||"HealthStatus");setNodeTab("basic");setNodeOpen(true);};
+ const addNode=(parent:ManualAssetNode|null=selected,templateName=draft.template_name)=>{
+ const node:ManualAssetNode={id:newAssetNodeId(),name:"新建资产节点",alias:"new-asset-alias",asset_type:"Machine",node_type:"device",template_name:templateName,description:"",properties:[],bindings:[],children:[]};
+ setDraft(prev=>({...prev,structure:appendAssetNode(prev.structure,parent?.id||null,node)}));setCreatingNodeId(node.id);setSelectedId(node.id);setNodeDraft(node);setBindingComponent("HealthStatus");setNodeTab("basic");setNodeOpen(true);
+ };
+ const addSibling=()=>{const parentId=selectedId?findAssetParentId(draft.structure,selectedId):null;const node:ManualAssetNode={id:newAssetNodeId(),name:"新建同级节点",alias:"new-peer-alias",asset_type:"Machine",node_type:"device",template_name:draft.template_name,description:"",properties:[],bindings:[],children:[]};setDraft(prev=>({...prev,structure:appendAssetNode(prev.structure,parentId,node)}));setCreatingNodeId(node.id);setSelectedId(node.id);setNodeDraft(node);setBindingComponent("HealthStatus");setNodeTab("basic");setNodeOpen(true);};
+ const pasteNode=()=>{if(!clipboard){notify("请先复制一个节点");return;}const node=cloneAssetNode(clipboard);setDraft(prev=>({...prev,structure:appendAssetNode(prev.structure,selectedId,node)}));setSelectedId(node.id);notify("节点已粘贴");};
+ const deleteNode=()=>{if(!selectedId){notify("请先选择节点");return;}setDraft(prev=>({...prev,structure:removeAssetNode(prev.structure,selectedId)}));setSelectedId(null);notify("节点已移除,保存后生效");};
+ const saveNode=()=>{if(!nodeDraft)return;if(!nodeDraft.name.trim()||!nodeDraft.alias?.trim()||!nodeDraft.template_name.trim()||!nodeDraft.asset_type?.trim()){notify("请填写名称、编号/别名、模板和类型");return;}setDraft(prev=>({...prev,structure:updateAssetNode(prev.structure,nodeDraft.id,nodeDraft)}));setCreatingNodeId(null);setNodeOpen(false);notify("节点信息已更新");};
+ const cancelNode=()=>{if(creatingNodeId){setDraft(prev=>({...prev,structure:removeAssetNode(prev.structure,creatingNodeId)}));setSelectedId(null);}setCreatingNodeId(null);setNodeOpen(false);setNodeDraft(null);};
+ const readNodeImage=(file:File)=>{if(file.size>2*1024*1024){notify("图片不能超过 2 MB");return;}const reader=new FileReader();reader.onload=()=>setNodeDraft(prev=>prev?{...prev,image_data:String(reader.result||"")}:prev);reader.readAsDataURL(file);};
+ const addBatchRow=()=>setBatchRows(prev=>[...prev,{template_name:"",count:1,rule:"device-##"}]);
+ const createBatch=()=>{
+ let structure=draft.structure;
+ for(const row of batchRows.filter(item=>item.template_name&&item.count>0)){
+ const parent:ManualAssetNode={id:newAssetNodeId(),name:row.template_name,node_type:"group",template_name:row.template_name,description:"批量创建",properties:[],bindings:[],children:Array.from({length:row.count},(_,index)=>({id:newAssetNodeId(),name:row.rule.includes("#")?row.rule.replace(/#+/,match=>String(index+1).padStart(match.length,"0")):`${row.rule}${String(index+1).padStart(2,"0")}`,node_type:"device",template_name:row.template_name,description:"",properties:[],bindings:[],children:[]}))};
+ structure=appendAssetNode(structure,selectedId,parent);
+ }
+ setDraft(prev=>({...prev,structure}));setBatchOpen(false);notify("批量节点已加入资产树");
+ };
+ const importBatchFile=async(file:File)=>{
+ try{
+ const XLSX=await import("xlsx"); const wb=XLSX.read(await file.arrayBuffer()); const sheet=wb.Sheets[wb.SheetNames[0]]; const data=XLSX.utils.sheet_to_json<Record<string,unknown>>(sheet);
+ const rows=data.map(item=>({template_name:String(item["设备类型"]||item["资产模板"]||item["名称"]||""),count:Number(item["数量"]||1),rule:String(item["命名规则"]||"device-##")})).filter(item=>item.template_name);
+ if(!rows.length)throw new Error("未找到“设备类型/资产模板、数量、命名规则”列"); setBatchRows(rows); setBatchTab("online"); notify(`已读取 ${rows.length} 行配置`);
+ }catch(e:any){notify("读取文件失败:"+(e?.message||e));}
+ };
+ const saveAll=async()=>{setSaving(true);try{const saved=await onSave(draft);setDraft(saved);}catch(e:any){notify("保存失败:"+(e?.message||e));}finally{setSaving(false);}};
+ const copySelected=(deep:boolean)=>{if(!selected){notify("请先选择节点");return;}setClipboard(deep?selected:{...selected,children:[]});notify(deep?"已深度复制节点及其下级":"已复制节点");};
+ const editSelected=(tab:"basic"|"properties"|"binding"="basic")=>{if(!selected){notify("请先选择节点");return;}openNode(selected);setNodeTab(tab);};
+ const bindTemplate=(templateName:string)=>{if(!selected){notify("请先选择节点");return;}const next={...selected,template_name:templateName};setDraft(prev=>({...prev,structure:updateAssetNode(prev.structure,selected.id,next)}));notify(`已应用${templateName}`);};
+ const bindingComponents=Array.from(new Set(["HealthStatus","Motor DE H","Motor NDE H",...(nodeDraft?.bindings.map(item=>item.source).filter(Boolean)||[])]));
+ const bindEdgeComponent=(component:string)=>{if(!nodeDraft)return;const exists=nodeDraft.bindings.some(item=>item.source===component);const bindings=exists?nodeDraft.bindings.map(item=>item.source===component?{...item,collect_type:item.collect_type||"IoT Edge",group:item.group||"Default",device_name:item.device_name||`${nodeDraft.name}-device`}:item):[...nodeDraft.bindings,{source:component,name:component,point:"",collect_type:"IoT Edge",group:"Default",device_name:`${nodeDraft.name}-device`,unit:"",precision:"2",mapping:"",description:""}];setNodeDraft({...nodeDraft,bindings});setBindingComponent(component);notify(`已绑定边缘组件 ${component}`);};
+ const updateBinding=(index:number,patch:Partial<ManualAssetBinding>)=>{if(!nodeDraft)return;const targetIndexes=nodeDraft.bindings.map((item,i)=>item.source===bindingComponent?i:-1).filter(i=>i>=0);const target=targetIndexes[index];if(target===undefined)return;setNodeDraft({...nodeDraft,bindings:nodeDraft.bindings.map((item,i)=>i===target?{...item,...patch}:item)});};
+ const removeBinding=(index:number)=>{if(!nodeDraft)return;const targetIndexes=nodeDraft.bindings.map((item,i)=>item.source===bindingComponent?i:-1).filter(i=>i>=0);const target=targetIndexes[index];setNodeDraft({...nodeDraft,bindings:nodeDraft.bindings.filter((_,i)=>i!==target)});};
+ const importBindings=async(file:File)=>{if(!nodeDraft)return;try{const XLSX=await import("xlsx");const wb=XLSX.read(await file.arrayBuffer());const sheet=wb.Sheets[wb.SheetNames[0]];const data=XLSX.utils.sheet_to_json<Record<string,unknown>>(sheet);const rows:ManualAssetBinding[]=data.map(item=>({source:bindingComponent,name:String(item["名称"]||item["参数名称"]||""),point:String(item["采集点"]||item["点位"]||""),unit:String(item["单位"]||""),precision:String(item["精度"]??"2"),mapping:String(item["映射"]||""),description:String(item["描述"]||"")})).filter(item=>item.name||item.point);if(!rows.length)throw new Error("未找到名称或采集点数据");setNodeDraft({...nodeDraft,bindings:[...nodeDraft.bindings.filter(item=>item.source!==bindingComponent),...rows]});setBindingImportOpen(false);notify(`已导入 ${rows.length} 个点位参数`);}catch(e:any){notify("导入失败:"+(e?.message||e));}};
+ const visibleRows=flattenAssetNodes(draft.structure).filter(({node})=>!search||node.name.toLowerCase().includes(search.toLowerCase())||node.template_name.toLowerCase().includes(search.toLowerCase()));
+ const fitCanvas=()=>{setZoom(1);setPan({x:0,y:0});};
+ return <div className="manual-asset-editor">
+ <div className="asset-editor-title"><div><h2>资产组详情</h2><span>当前站点:{siteName||"未命名站点"}</span></div><Button disabled={saving} onClick={saveAll}>{saving?"保存中…":"保存"}</Button></div>
+ <div className="asset-editor-workbench">
+ <aside className="asset-editor-side"><Field label="站点资产组"><input value={draft.name} onChange={e=>setDraft(prev=>({...prev,name:e.target.value}))}/></Field><button className="asset-batch-entry" onClick={()=>setBatchOpen(true)}>批量创建</button><div className="asset-template-panel"><strong>资产模板</strong><select aria-label="资产模板分类" value={templateCategory} onChange={e=>setTemplateCategory(e.target.value)}><option value="site">场站设备类别</option><option value="unbound">不绑定设备的类别</option></select>{(templateCategory==="site"?["通用资产模板","污水处理设备模板","泵站设备模板"]:["不绑定设备的模板"]).map(template=><button key={template} onClick={()=>addNode(selected,template)}><span>{templateCategory==="site"?"◆":"◇"}</span><b>{template}</b><small>点击添加到当前选中节点</small></button>)}</div></aside>
+ <section className="asset-tree-workspace"><div className="asset-editor-toolbar"><div className="asset-tool-group"><AntButton type="text" className="asset-tool-button" title="复制节点" aria-label="复制节点" disabled={!selected} icon={<CopyOutlined/>} onClick={()=>copySelected(false)}/><AntButton type="text" className="asset-tool-button" title="深度复制节点及其下级" aria-label="深度复制节点" disabled={!selected} icon={<SnippetsOutlined/>} onClick={()=>copySelected(true)}/><AntButton type="text" className="asset-tool-button" title="节点属性设置" aria-label="节点属性设置" disabled={!selected} icon={<ControlOutlined/>} onClick={()=>editSelected("properties")}/><AntButton type="text" className="asset-tool-button" title="删除节点" aria-label="删除节点" danger disabled={!selected} icon={<DeleteOutlined/>} onClick={deleteNode}/><span className="asset-tool-divider"/><AntButton type="text" className="asset-tool-button primary" title="添加同级节点" aria-label="添加同级节点" disabled={!selected} icon={<PlusSquareOutlined/>} onClick={addSibling}/><AntButton type="text" className="asset-tool-button primary" title="添加子节点" aria-label="添加子节点" icon={<PlusOutlined/>} onClick={()=>addNode(selected)}/><Dropdown trigger={["click"]} menu={{items:[{key:"通用资产模板",label:"应用通用资产模板"},{key:"污水处理设备模板",label:"应用污水处理设备模板"},{key:"泵站设备模板",label:"应用泵站设备模板"}],onClick:({key})=>bindTemplate(key)}}><AntButton type="text" className="asset-tool-button primary" title="应用资产模板" aria-label="应用资产模板" disabled={!selected} icon={<BlockOutlined/>}/></Dropdown></div><div className="asset-view-switch" role="group" aria-label="视图切换"><button className={viewMode==="tree"?"active":""} title="资产树视图" onClick={()=>setViewMode("tree")}><ApartmentOutlined/></button><button className={viewMode==="list"?"active":""} title="列表视图" onClick={()=>setViewMode("list")}><BarsOutlined/></button></div><div className="asset-canvas-tools"><AntButton type="text" className="asset-tool-button" title="缩小" aria-label="缩小" icon={<ZoomOutOutlined/>} onClick={()=>setZoom(value=>Math.max(.55,Number((value-.1).toFixed(2))))}/><AntButton type="text" className="asset-tool-button" title="放大" aria-label="放大" icon={<ZoomInOutlined/>} onClick={()=>setZoom(value=>Math.min(1.6,Number((value+.1).toFixed(2))))}/><AntButton type="text" className="asset-tool-button" title="适合窗口" aria-label="适合窗口" icon={<AimOutlined/>} onClick={fitCanvas}/><label className="asset-tree-search"><SearchOutlined/><input value={search} onChange={e=>setSearch(e.target.value)} placeholder="搜索资产节点"/></label></div></div>{viewMode==="tree"?<div className={dragging?"asset-tree-canvas dragging":"asset-tree-canvas"} onPointerDown={(e:ReactPointerEvent<HTMLDivElement>)=>{if((e.target as HTMLElement).closest("button,input,[role='button']"))return;setDragging(true);e.currentTarget.setPointerCapture(e.pointerId);}} onPointerMove={(e:ReactPointerEvent<HTMLDivElement>)=>{if(dragging)setPan(value=>({x:value.x+e.movementX,y:value.y+e.movementY}));}} onPointerUp={(e:ReactPointerEvent<HTMLDivElement>)=>{setDragging(false);e.currentTarget.releasePointerCapture(e.pointerId);}}><div className="asset-tree-stage" style={{transform:`translate(${pan.x}px, ${pan.y}px) scale(${zoom})`}}><div className="asset-node-tree">{draft.structure.map(node=><AssetTreeBranch key={node.id} node={node} selectedId={selectedId} search={search} onSelect={setSelectedId} onOpen={openNode} onAddChild={addNode}/>)}</div></div></div>:<div className="asset-node-list"><div className="asset-node-list-head"><span>资产名称</span><span>节点类型</span><span>资产模板</span><span>下级节点</span></div>{visibleRows.length?visibleRows.map(({node,depth})=><button key={node.id} className={selectedId===node.id?"selected":""} onClick={()=>openNode(node)}><span style={{paddingLeft:depth*22}}>{node.node_type==="group"?"◇":"◆"} {node.name}</span><span>{node.node_type==="group"?"资产分组":"设备资产"}</span><span>{node.template_name||"—"}</span><span>{node.children.length}</span></button>):<div className="asset-tree-empty"><b>没有匹配的资产节点</b><span>请更换搜索关键词。</span></div>}</div>}</section>
+ </div>
+ <Drawer className="asset-node-drawer" title="节点配置" width="58vw" open={nodeOpen} onClose={cancelNode} footer={<div className="node-drawer-footer"><Button secondary onClick={cancelNode}>取消</Button><Button disabled={!nodeDraft?.name.trim()||!nodeDraft?.alias?.trim()||!nodeDraft?.template_name.trim()||!nodeDraft?.asset_type?.trim()} onClick={saveNode}>确定</Button></div>}>
+ <div className="node-drawer-tabs"><button className={nodeTab==="basic"?"active":""} onClick={()=>setNodeTab("basic")}>基础信息设定</button><button className={nodeTab==="properties"?"active":""} onClick={()=>setNodeTab("properties")}>属性设定</button><button className={nodeTab==="binding"?"active":""} onClick={()=>setNodeTab("binding")}>数据绑定</button></div>
+ {nodeDraft&&nodeTab==="basic"&&<div className="node-config-page"><section className="node-config-section"><h3>基础设定</h3><div className="node-id-row"><span>节点ID</span><b>{nodeDraft.id.replace("node-","").slice(0,10)}</b></div><div className="node-basic-grid"><Field label="名称" required><input value={nodeDraft.name} onChange={e=>setNodeDraft({...nodeDraft,name:e.target.value})}/></Field><Field label="编号/别名" required><input value={nodeDraft.alias||""} onChange={e=>setNodeDraft({...nodeDraft,alias:e.target.value})}/></Field><Field label="模板" required><select value={nodeDraft.template_name} onChange={e=>setNodeDraft({...nodeDraft,template_name:e.target.value})}><option>通用资产模板</option><option>污水处理设备模板</option><option>泵站设备模板</option><option>不绑定设备的模板</option></select></Field><Field label="类型" required><input value={nodeDraft.asset_type||""} onChange={e=>setNodeDraft({...nodeDraft,asset_type:e.target.value})}/></Field><Field label="节点描述"><input value={nodeDraft.description} placeholder="请输入" onChange={e=>setNodeDraft({...nodeDraft,description:e.target.value})}/></Field></div></section><section className="node-config-section"><h3>图片</h3><div className="node-image-row"><label className="node-image-preview"><input type="file" accept="image/jpeg,image/png,image/bmp,image/svg+xml" hidden onChange={e=>{const file=e.target.files?.[0];if(file)readNodeImage(file);}}/>{nodeDraft.image_data?<img src={nodeDraft.image_data} alt="节点图片"/>:<BlockOutlined/>}</label><div><label className="node-upload-link"><input type="file" accept="image/jpeg,image/png,image/bmp,image/svg+xml" hidden onChange={e=>{const file=e.target.files?.[0];if(file)readNodeImage(file);}}/><UploadOutlined/> 图片上传</label><small>支持格式 jpeg/jpg/png/bmp/svg,文件不超过 2 MB</small>{nodeDraft.image_data&&<button className="text-button" onClick={()=>setNodeDraft({...nodeDraft,image_data:""})}>移除图片</button>}</div></div></section><section className="node-config-section"><h3>节点二维码</h3><QRCode size={112} value={`stationbuilder://site/${group.site_id}/asset/${nodeDraft.id}`} bordered={false}/></section></div>}
+ {nodeDraft&&nodeTab==="properties"&&<div className="node-config-page"><section className="node-config-section"><h3>基础属性</h3><div className="node-property-grid">{assetPropertyFields.map(name=><Field key={name} label={name}><input type={name.includes("日期")?"date":"text"} value={assetPropertyValue(nodeDraft,name)||(name==="设备运行状态计算阈值"?"5":"")} placeholder={name==="设备运行状态计算阈值"?"5":"请输入"} onChange={e=>setNodeDraft(setAssetProperty(nodeDraft,name,e.target.value))}/></Field>)}</div></section></div>}
+ {nodeDraft&&nodeTab==="binding"&&<div className="node-config-page binding-page"><section className="node-config-section"><h3>边缘端绑定</h3><h4>组件列表</h4><div className="edge-component-table"><header><span>边缘组件</span><span>采集类型</span><span>群组</span><span>设备名称</span><span>操作</span></header>{bindingComponents.map(component=>{const item=nodeDraft.bindings.find(binding=>binding.source===component);return <div key={component} className={bindingComponent===component?"active":""}><b>{component}</b><span>{item?.collect_type||"—"}</span><span>{item?.group||"—"}</span><span>{item?.device_name||"—"}</span><span><button title="编辑点位" onClick={()=>setBindingComponent(component)}><EditOutlined/></button><button title="绑定边缘设备" onClick={()=>bindEdgeComponent(component)}><LinkOutlined/></button></span></div>})}</div></section><section className="node-config-section"><h3>点位绑定</h3><div className="binding-component-tabs">{bindingComponents.map(component=><button key={component} className={bindingComponent===component?"active":""} onClick={()=>setBindingComponent(component)}>{component}</button>)}</div><div className="binding-parameter-head"><h4>参数列表</h4><Button onClick={()=>setBindingImportOpen(true)}>+ 批量导入</Button></div><div className="binding-parameter-table"><header><span>名称</span><span>采集点</span><span>单位</span><span>精度</span><span>映射</span><span>描述</span><span>操作</span></header>{nodeDraft.bindings.filter(item=>item.source===bindingComponent).map((item,index)=><div key={`${bindingComponent}-${index}`}><input value={item.name||""} placeholder="参数名称" onChange={e=>updateBinding(index,{name:e.target.value})}/><input value={item.point} placeholder="采集点" onChange={e=>updateBinding(index,{point:e.target.value})}/><input value={item.unit||""} onChange={e=>updateBinding(index,{unit:e.target.value})}/><input value={item.precision||""} onChange={e=>updateBinding(index,{precision:e.target.value})}/><input value={item.mapping||""} onChange={e=>updateBinding(index,{mapping:e.target.value})}/><input value={item.description||""} onChange={e=>updateBinding(index,{description:e.target.value})}/><button className="trash" title="删除参数" onClick={()=>removeBinding(index)}><DeleteOutlined/></button></div>)}{!nodeDraft.bindings.some(item=>item.source===bindingComponent)&&<div className="binding-empty">暂无数据,请先在组件列表中绑定设备或批量导入点位。</div>}</div></section></div>}
+ </Drawer>
+ {bindingImportOpen&&<div className="dlg-backdrop binding-import-backdrop" onMouseDown={()=>setBindingImportOpen(false)}><div className="dlg-box binding-import-dialog" onMouseDown={e=>e.stopPropagation()}><h2>批量导入点位参数</h2><p className="hint">导入到组件:{bindingComponent}</p><label className="manual-excel-upload"><input type="file" accept=".xlsx,.xls,.csv" hidden onChange={e=>{const file=e.target.files?.[0];if(file)importBindings(file);}}/><b>⇧</b><span>点击选择点位文件</span><small>表头支持:名称、采集点、单位、精度、映射、描述</small></label><div className="dlg-actions"><Button secondary onClick={()=>setBindingImportOpen(false)}>取消</Button></div></div></div>}
+ {batchOpen&&<div className="dlg-backdrop" onMouseDown={()=>setBatchOpen(false)}><div className="dlg-box batch-create-dialog" onMouseDown={e=>e.stopPropagation()}><h2>批量创建资产节点</h2><div className="node-drawer-tabs"><button className={batchTab==="online"?"active":""} onClick={()=>setBatchTab("online")}>在线配置</button><button className={batchTab==="excel"?"active":""} onClick={()=>setBatchTab("excel")}>Excel 导入</button></div>{batchTab==="online"?<div className="batch-table"><header><span>资产模板</span><span>数量</span><span>命名规则</span><span></span></header>{batchRows.map((row,index)=><div key={index}><input value={row.template_name} onChange={e=>setBatchRows(prev=>prev.map((item,i)=>i===index?{...item,template_name:e.target.value}:item))}/><input type="number" min={1} value={row.count} onChange={e=>setBatchRows(prev=>prev.map((item,i)=>i===index?{...item,count:Number(e.target.value)}:item))}/><input value={row.rule} onChange={e=>setBatchRows(prev=>prev.map((item,i)=>i===index?{...item,rule:e.target.value}:item))}/><button className="trash" onClick={()=>setBatchRows(prev=>prev.filter((_,i)=>i!==index))}>×</button></div>)}<Button secondary onClick={addBatchRow}>+ 添加一行</Button></div>:<label className="manual-excel-upload"><input type="file" accept=".xlsx,.xls,.csv" hidden onChange={e=>{const file=e.target.files?.[0];if(file)importBatchFile(file);}}/><b>⇧</b><span>点击选择资产配置文件</span><small>表头支持:设备类型或资产模板、数量、命名规则</small></label>}<div className="dlg-actions"><Button secondary onClick={()=>setBatchOpen(false)}>取消</Button><Button disabled={batchTab!=="online"||!batchRows.some(item=>item.template_name&&item.count>0)} onClick={createBatch}>创建节点</Button></div></div></div>}
+ </div>;
+}
+
+function AssetTreeBranch({node,selectedId,search,onSelect,onOpen,onAddChild}:{node:ManualAssetNode;selectedId:string|null;search:string;onSelect:(id:string)=>void;onOpen:(node:ManualAssetNode)=>void;onAddChild:(node:ManualAssetNode)=>void}){
+ const [expanded,setExpanded]=useState(true);
+ const matched=!!search&&(node.name.toLowerCase().includes(search.toLowerCase())||node.template_name.toLowerCase().includes(search.toLowerCase()));
+ return <div className="asset-tree-branch"><div role="button" tabIndex={0} className={["asset-node-card",selectedId===node.id?"selected":"",matched?"matched":""].filter(Boolean).join(" ")} onClick={()=>onOpen(node)} onKeyDown={e=>{if(e.key==="Enter")onOpen(node);}}><span>{node.node_type==="group"?"◇":"◆"}</span><b>{node.name}</b><small>{node.template_name}</small>{node.children.length?<button title={expanded?"收起下级节点":"展开下级节点"} aria-label={expanded?`收起${node.name}`:`展开${node.name}`} onClick={e=>{e.stopPropagation();onSelect(node.id);setExpanded(value=>!value);}}>{expanded?"−":"+"}</button>:<button title="添加子节点" aria-label={`为${node.name}添加子节点`} onClick={e=>{e.stopPropagation();onSelect(node.id);onAddChild(node);}}><PlusOutlined/></button>}</div>{expanded&&node.children.length>0&&<div className="asset-tree-children">{node.children.map(child=><AssetTreeBranch key={child.id} node={child} selectedId={selectedId} search={search} onSelect={onSelect} onOpen={onOpen} onAddChild={onAddChild}/>)}</div>}</div>;
+}
+
function Functions({site,muTree,onMuChange,onSiteChange,pageStyles,notify,onViewCompletePrompt}:{site:SiteDetail|null;muTree:MuNode[];onMuChange:(s:string[])=>void;onSiteChange:(s:SiteDetail)=>void;pageStyles:PageStyle[];notify:(m:string)=>void;onViewCompletePrompt:()=>void}){
const [tab,setTab]=useState<"mu"|"cube"|"menu">("mu");
const leaves = useMemo(()=>buildMuLeaves(muTree),[muTree]);