<main className="workspace"><div className="crumb">iStation StationBuilder / <b>{["domains","domainDetail","styleLibrary","deviceTemplates"].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>{!["projects","domains","domainDetail","styleLibrary","deviceTemplates"].includes(page)&&<Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>}{page==="projects"&&<Button onClick={()=>go("basic","new")}>+ 新建站点</Button>}{page==="domains"&&<Button onClick={()=>setOpenDomainTemplateId("create")}>+ 新增领域方案</Button>}{page==="domains"&&openDomainTemplateId!=null&&<Button secondary onClick={()=>setOpenDomainTemplateId(null)}>← 返回方案列表</Button>}{domainFilter!=null&&page==="domains"&&<Button secondary onClick={()=>{setDomainFilter(null);}}>清除领域筛选:{domainOptions.find(d=>d.id===domainFilter)?.name||""}</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"||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}/>
+ ? <DomainDetail id={openDomainTemplateId==="create"?null:openDomainTemplateId} go={go} notify={notify} onBack={()=>setOpenDomainTemplateId(null)} onCreated={setOpenDomainTemplateId} viewCompletePrompt={viewDtCompletePrompt} viewCompleteAssetsPrompt={viewDtCompleteAssetsPrompt} viewCompleteCubesPrompt={viewDtCompleteCubesPrompt} pageStyles={pageStyles}/>
: <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)} domainFilter={domainFilter} domainOptions={domainOptions} onDomainDeleted={handleDomainDeleted}/>)}
{page==="styleLibrary"&&<PageStyleLibrary pageStyles={pageStyles} loadPageStyles={loadPageStyles} notify={notify}/>}
{page==="deviceTemplates"&&<DeviceTemplates notify={notify}/>}
function OpsFlowsViewer(){ const [flows,setFlows]=useState<any[]>([]); useEffect(()=>{ fetchDb("/ops-flows").then((d:any)=>setFlows(d||[])).catch(()=>{}); },[]); return <Panel title="运维流程(本期为占位,后续实现)"><table className="data-table"><thead><tr><th>流程名称</th><th>内容</th></tr></thead><tbody>{flows.length===0? <tr><td colSpan={2} className="muted">暂无</td></tr> : flows.map((f:any)=><tr key={f.id}><td>{f.name}</td><td style={{maxWidth:480,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{f.content}</td></tr>)}</tbody></table><p className="muted">运维流程功能尚未实现,当前仅保留占位记录。</p></Panel>; }
-function DomainDetail({id,go,notify,onBack,onCreated,viewCompletePrompt,viewCompleteAssetsPrompt,viewCompleteCubesPrompt}:{id:number|null;go:(p:PageKey)=>void;notify:(m:string)=>void;onBack:()=>void;onCreated:(nid:number)=>void;viewCompletePrompt:(id:number,name:string,domain:string,description:string)=>void;viewCompleteAssetsPrompt:(id:number,name:string,domain:string,description:string,supplement:string)=>void;viewCompleteCubesPrompt:(id:number,name:string,domain:string,description:string,supplement:string,assets:any[])=>void}){
+function DomainDetail({id,go,notify,onBack,onCreated,viewCompletePrompt,viewCompleteAssetsPrompt,viewCompleteCubesPrompt,pageStyles}:{id:number|null;go:(p:PageKey)=>void;notify:(m:string)=>void;onBack:()=>void;onCreated:(nid:number)=>void;viewCompletePrompt:(id:number,name:string,domain:string,description:string)=>void;viewCompleteAssetsPrompt:(id:number,name:string,domain:string,description:string,supplement:string)=>void;viewCompleteCubesPrompt:(id:number,name:string,domain:string,description:string,supplement:string,assets:any[])=>void;pageStyles:PageStyle[]}){
const [doms,setDoms]=useState<any[]>([]);
const [loading,setLoading]=useState(false);
const loadDoms=async()=>{ try{ setDoms(await fetchDb("/domains") as any); }catch{} };
useEffect(()=>{ loadDoms(); },[]);
if(id==null) return <CreateTemplate doms={doms} notify={notify} onCreated={onCreated}/>;
- return <DomainWizard id={id} doms={doms} go={go} notify={notify} onBack={onBack} viewCompletePrompt={viewCompletePrompt} viewCompleteAssetsPrompt={viewCompleteAssetsPrompt} viewCompleteCubesPrompt={viewCompleteCubesPrompt}/>;
+ return <DomainWizard id={id} doms={doms} go={go} notify={notify} onBack={onBack} viewCompletePrompt={viewCompletePrompt} viewCompleteAssetsPrompt={viewCompleteAssetsPrompt} viewCompleteCubesPrompt={viewCompleteCubesPrompt} pageStyles={pageStyles}/>;
}
const DOMAIN_STEPS:{key:DomainStepKey;label:string}[]=[
</div></div>;
}
-function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteAssetsPrompt,viewCompleteCubesPrompt}:{id:number;doms:any[];go:(p:PageKey)=>void;notify:(m:string)=>void;onBack:()=>void;viewCompletePrompt:(id:number,name:string,domain:string,description:string)=>void;viewCompleteAssetsPrompt:(id:number,name:string,domain:string,description:string,supplement:string)=>void;viewCompleteCubesPrompt:(id:number,name:string,domain:string,description:string,supplement:string,assets:any[])=>void}){
+function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteAssetsPrompt,viewCompleteCubesPrompt,pageStyles}:{id:number;doms:any[];go:(p:PageKey)=>void;notify:(m:string)=>void;onBack:()=>void;viewCompletePrompt:(id:number,name:string,domain:string,description:string)=>void;viewCompleteAssetsPrompt:(id:number,name:string,domain:string,description:string,supplement:string)=>void;viewCompleteCubesPrompt:(id:number,name:string,domain:string,description:string,supplement:string,assets:any[])=>void;pageStyles:PageStyle[]}){
const [t,setT]=useState<any>(null);
const [m,setM]=useState<any>(null);
const [assets,setAssets]=useState<DomainAsset[]>([]);
const [d, mt]=await Promise.all([ fetchDb(`/domain-templates/${id}`) as any, fetchDb(`/mu-tree`) as Promise<MuNode[]> ]);
setMuTreeLocal(mt);
setT(d);
- setM({domain_id:d.domain_id,name:d.name,description:d.description||"",status:d.status,version:d.version,creator:d.creator||"",supplement:d.supplement||""});
+ setM({domain_id:d.domain_id,name:d.name,description:d.description||"",status:d.status,version:d.version,creator:d.creator||"",supplement:d.supplement||"",style_id:d.style_id!=null?Number(d.style_id):null});
setAssets((d.assets||[]).map((a:any)=>({id:a.id,device_template_id:a.device_template_id,device_name:a.device_name||"",count:Number(a.count)||0,rule:a.rule||"",sort_order:a.sort_order||0})));
setPages((d.pages||[]).map((p:any)=>({id:p.id,name:p.name,description:p.description||"",json_text:p.json_text||"",status:p.status||"pending",gen_filename:p.gen_filename||null,prompt:p.prompt||null,source:p.source||null,sort_order:p.sort_order||0})));
setMu(d.mu && d.mu.length ? d.mu : []);
const saveMu=async(next?:string[])=>{ const sel=next??mu; setMu(sel); await patch({mu:sel}, "MU 选择已保存"); };
const toggleMu=(key:string)=> saveMu(effSelected.includes(key)? effSelected.filter(k=>k!==key) : [...effSelected, key]);
+ // ===== 领域方案页面样式(镜像站点侧 style_id 机制)=====
+ const selectStyle=async(sid:number|null)=>{
+ try{
+ await fetchDb(`/domain-templates/${id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({style_id:sid})});
+ setM((prev:any)=>({...prev,style_id:sid}));
+ notify(sid?"已应用页面样式":"已清除页面样式");
+ }catch(e:any){ notify("应用样式失败:"+e.message); }
+ };
+
// ===== Step1 AI 智能补全(写入补充描述)=====
const runBasicComplete=async()=>{
if(basicAi.completing) return;
genIds={genIds} genLog={genLog}
cubesAi={cubesAi} onRunCubesComplete={runCubesComplete} onViewCompleteCubesPrompt={()=>viewCompleteCubesPrompt(id,m.name,domainName,m.description,m.supplement||"",assets.map(a=>({type:a.device_name,count:a.count,rule:a.rule})))}
updatePageField={(i,patchRow)=>setPages(prev=>prev.map((p,idx)=>idx===i?{...p,...patchRow}:p))}
- tName={m.name} saving={saving} notify={notify} />}
+ tName={m.name} saving={saving} notify={notify}
+ pageStyles={pageStyles} styleId={m.style_id??null} onSelectStyle={selectStyle} />}
{step==="opsflow" && <PlaceholderStep title="运维流程" note="运维流程编辑功能待开发,后续版本将支持在方案中配置标准运维 SOP 与告警处理流程。"/>}
{step==="knowledge" && <PlaceholderStep title="领域知识" note="领域知识(知识文档、标准规范、参考资料)编辑功能待开发,后续版本将支持在方案中沉淀领域知识库。"/>}
return <Panel title={title}><div className="placeholder-block"><span className="badge todo">待开发</span><p>{note}</p></div></Panel>;
}
-function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,onUploadPageJson,onSavePageMeta,onDeletePage,onGeneratePage,onDownloadPage,onPreviewPage,genIds,genLog,cubesAi,onRunCubesComplete,onViewCompleteCubesPrompt,updatePageField,tName,saving,notify}:{muTree:MuNode[];grouped:any[];effSelected:string[];onToggleMu:(k:string)=>void;pages:DomainPage[];onAddPage:()=>Promise<void>;onUploadPageJson:(f:File)=>Promise<void>;onSavePageMeta:(p:DomainPage)=>Promise<void>;onDeletePage:(p:DomainPage)=>Promise<void>;onGeneratePage:(p:DomainPage)=>Promise<void>;onDownloadPage:(p:DomainPage)=>void;onPreviewPage:(p:DomainPage)=>void;genIds:Set<number>;genLog:string;cubesAi:ReturnType<typeof useStreamComplete>;onRunCubesComplete:()=>Promise<void>;onViewCompleteCubesPrompt:()=>void;updatePageField:(i:number,patch:Partial<DomainPage>)=>void;tName:string;saving:boolean;notify:(m:string)=>void}){
+function FunctionsStep({muTree,grouped,effSelected,onToggleMu,pages,onAddPage,onUploadPageJson,onSavePageMeta,onDeletePage,onGeneratePage,onDownloadPage,onPreviewPage,genIds,genLog,cubesAi,onRunCubesComplete,onViewCompleteCubesPrompt,updatePageField,tName,saving,notify,pageStyles,styleId,onSelectStyle}:{muTree:MuNode[];grouped:any[];effSelected:string[];onToggleMu:(k:string)=>void;pages:DomainPage[];onAddPage:()=>Promise<void>;onUploadPageJson:(f:File)=>Promise<void>;onSavePageMeta:(p:DomainPage)=>Promise<void>;onDeletePage:(p:DomainPage)=>Promise<void>;onGeneratePage:(p:DomainPage)=>Promise<void>;onDownloadPage:(p:DomainPage)=>void;onPreviewPage:(p:DomainPage)=>void;genIds:Set<number>;genLog:string;cubesAi:ReturnType<typeof useStreamComplete>;onRunCubesComplete:()=>Promise<void>;onViewCompleteCubesPrompt:()=>void;updatePageField:(i:number,patch:Partial<DomainPage>)=>void;tName:string;saving:boolean;notify:(m:string)=>void;pageStyles:PageStyle[];styleId:number|null;onSelectStyle:(id:number|null)=>void}){
const sourceBadge=(src?:string|null)=>{
if(src==="upload") return <span className="badge src-upload">↑ 上传</span>;
if(src==="ai") return <span className="badge src-ai">✦ AI生成</span>;
{tab==="mu" && <div className="mu-tree">{grouped.map(g=><div key={g.parentMenu} className="mu-group"><b>{g.parentMenu}</b><div className="mu-items">{g.items.map((item:any)=><label key={item.key} className={item.disabled?"disabled":""}><input type="checkbox" disabled={item.disabled} checked={effSelected.includes(item.key)} onChange={()=>onToggleMu(item.key)}/><span>{item.label}</span></label>)}</div></div>)}</div>}
{tab==="cube" && <div className="two-column">
<div>
+ <div className="style-picker">
+ <h4>领域方案页面样式 <small>方案级 · 本方案全部 Cube 页面共用</small></h4>
+ <p className="hint">选择一个样式库方案作为本方案所有 Cube 页面的视觉风格(生成时参考其 JSON 主题参数)。</p>
+ <div className="style-pick-list">
+ <button className={"style-pick"+(!styleId?" active":"")} onClick={()=>onSelectStyle(null)}>
+ <div className="style-pick-thumb none">无</div><span>不使用样式</span>
+ </button>
+ {pageStyles.map(s=><button key={s.id} className={"style-pick"+(styleId===s.id?" active":"")} onClick={()=>onSelectStyle(s.id)}>
+ <div className="style-pick-thumb"><img src={`/api/db/page-styles/${s.id}/image`} alt={s.name} loading="lazy"/></div>
+ <span>{s.name}</span>
+ </button>)}
+ </div>
+ </div>
<div className="cube-editor"><table className="data-table editable cube-editor-table"><thead><tr><th>页面名称 <small>最多6字</small></th><th>页面描述</th><th>来源</th><th>Json文件</th><th>操作</th></tr></thead><tbody>{pages.map((p,i)=><tr key={p.id}>
<td><input value={p.name} maxLength={6} placeholder="例如:运行总览" onChange={e=>updatePageField(i,{name:e.target.value})}/></td>
<td><input value={p.description} placeholder="描述该页面要展示的内容与指标" onChange={e=>updatePageField(i,{description:e.target.value})}/></td>
ensureColumn("cubes", "prompt", "TEXT");
ensureColumn("sites", "style_id", "INTEGER");
ensureColumn("domain_templates", "supplement", "TEXT");
+ensureColumn("domain_templates", "style_id", "INTEGER");
ensureColumn("domain_pages", "status", "TEXT DEFAULT 'pending'");
ensureColumn("domain_pages", "gen_filename", "TEXT");
ensureColumn("domain_pages", "prompt", "TEXT");
const version = body.version != null ? body.version.toString() : row.version;
const creator = body.creator != null ? body.creator.toString() : row.creator;
const supplement = body.supplement != null ? body.supplement : row.supplement;
+ const style_id = body.style_id !== undefined ? (body.style_id != null ? Number(body.style_id) : null) : row.style_id;
if (!name) return respondError(res, 400, "模板名称不能为空");
- db.prepare("UPDATE domain_templates SET domain_id=?,name=?,description=?,status=?,version=?,creator=?,supplement=? WHERE id=?").run(domain_id, name, description, status, version, creator, supplement, id);
+ db.prepare("UPDATE domain_templates SET domain_id=?,name=?,description=?,status=?,version=?,creator=?,supplement=?,style_id=? WHERE id=?").run(domain_id, name, description, status, version, creator, supplement, style_id, id);
if (body.device_template_ids !== undefined) {
db.prepare("DELETE FROM device_template_domain_templates WHERE domain_template_id=?").run(id);
for (const devId of Array.isArray(body.device_template_ids) ? body.device_template_ids : []) {
const dt = db.prepare("SELECT dt.*, d.name AS domain_name FROM domain_templates dt JOIN domains d ON d.id=dt.domain_id WHERE dt.id=?").get(tmplId);
if (!dt) return null;
const assets = db.prepare("SELECT a.device_template_id, dt.name AS type, a.count, a.rule FROM domain_template_assets a JOIN device_templates dt ON dt.id=a.device_template_id WHERE a.domain_template_id=?").all(tmplId);
- return { id: dt.id, name: dt.name, domain: dt.domain_name, description: dt.description || "", supplement: dt.supplement || "", assets, style_id: null, cubes: [] };
+ return { id: dt.id, name: dt.name, domain: dt.domain_name, description: dt.description || "", supplement: dt.supplement || "", assets, style_id: dt.style_id != null ? dt.style_id : null, cubes: [] };
}
async function resolveDeviceTemplate(name, domainId, tmplId) {