</div></Panel>;
}
-function DeviceTemplateCard({dev,doms,notify,onChange}:{dev:any;doms:any[];notify:(m:string)=>void;onChange:()=>void}){
+const CP_RANGE_PRESETS = ["0~100","0~1000","-20~80","-40~85","0~50","0~5","0~1","0~100%"];
+
+function DeviceImageField({imageUrl,onPick}:{imageUrl:string|null;onPick:(f:File)=>void}){
+ const ref=useRef<HTMLInputElement>(null);
+ return <div className="device-img-field" onClick={()=>ref.current?.click()}>
+ {imageUrl? <img src={imageUrl} alt="设备图片"/> : <span className="device-img-empty">+ 点击上传设备图片</span>}
+ <input ref={ref} type="file" accept="image/*" hidden onChange={e=>{ const f=e.target.files?.[0]; if(f) onPick(f); e.target.value=""; }}/>
+ </div>;
+}
+
+function DeviceTemplateCard({dev,notify,onChange}:{dev:any;notify:(m:string)=>void;onChange:()=>void}){
const [name,setName]=useState(dev.name); const [description,setDesc]=useState(dev.description||"");
- const [domainIds,setDomainIds]=useState<number[]>(dev.domain_ids||[]);
- const [cps,setCps]=useState<any[]>(dev.collection_points||[]);
- const imgChanged=async(e:any)=>{ const f=e.target.files?.[0]; if(!f) return; const b=await fileToBase64(f); await fetchDb(`/device-templates/${dev.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,domain_ids:domainIds,image:{filename:b.filename,mime:b.mime,data:b.data}})}); notify("已更新图片"); onChange(); };
- const save=async()=>{ await fetchDb(`/device-templates/${dev.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,domain_ids:domainIds})}); notify("设备已保存"); onChange(); };
+ const [imgPreview,setImgPreview]=useState<string|null>(dev.image_path?`/api/db/device-templates/${dev.id}/image`:null);
+ const [imgB64,setImgB64]=useState<{filename:string;mime:string;data:string}|null>(null);
+ const [cps,setCps]=useState<any[]>((dev.collection_points||[]).map((c:any)=>({...c,key:c.id,checked:false})));
+ const onPick=async(f:File)=>{ setImgPreview(URL.createObjectURL(f)); const b=await fileToBase64(f); setImgB64({filename:b.filename,mime:b.mime,data:b.data}); };
+ const save=async()=>{ const body:any={name,description}; if(imgB64) body.image=imgB64; await fetchDb(`/device-templates/${dev.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}); notify("设备已保存"); onChange(); };
const delDev=async()=>{ if(!confirm("删除该设备模板?")) return; await fetchDb(`/device-templates/${dev.id}`,{method:"DELETE"}); onChange(); };
- const addCp=async()=>{ const nm=prompt("采集点名称"); if(!nm) return; await fetchDb(`/device-templates/${dev.id}/collection-points`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:nm})}); onChange(); };
- const delCp=async(id:number)=>{ await fetchDb(`/collection-points/${id}`,{method:"DELETE"}); onChange(); };
+ const addCpRow=()=>setCps([...cps,{key:-(Date.now()),name:"",collect_note:"",unit:"",value_range:"",description:"",checked:false}]);
+ const updCp=(k:number,field:string,val:any)=>setCps(cps.map(c=>c.key===k?{...c,[field]:val}:c));
+ const toggleCp=(k:number)=>setCps(cps.map(c=>c.key===k?{...c,checked:!c.checked}:c));
+ const allChecked=cps.length>0&&cps.every(c=>c.checked);
+ const toggleAll=()=>setCps(cps.map(c=>({...c,checked:!allChecked})));
+ const delCpRows=async()=>{ const sel=cps.filter(c=>c.checked); if(sel.length===0){ notify("请先勾选要删除的采集点"); return; } if(!confirm(`确定删除选中的 ${sel.length} 个采集点?`)) return; for(const c of sel){ if(c.id) await fetchDb(`/collection-points/${c.id}`,{method:"DELETE"}); } setCps(cps.filter(c=>!c.checked)); onChange(); };
+ const saveCpRows=async()=>{ try{ for(const c of cps){ const payload={name:c.name,collect_note:c.collect_note,unit:c.unit,value_range:c.value_range,description:c.description}; if(c.id){ await fetchDb(`/collection-points/${c.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)}); } else { await fetchDb(`/device-templates/${dev.id}/collection-points`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)}); } } notify("采集点已保存"); onChange(); }catch(e:any){ notify("保存失败:"+e.message); } };
return <div className="device-card">
<div className="device-card-head"><b>{name||"(未命名)"}</b><button className="text-button" onClick={delDev}>删除设备</button></div>
<div className="form-grid">
<label>设备名称<input value={name} onChange={e=>setName(e.target.value)}/></label>
- <label>设备图片<input type="file" accept="image/*" onChange={imgChanged}/></label>
+ <label style={{gridColumn:"1 / -1"}}>设备图片<DeviceImageField imageUrl={imgPreview} onPick={onPick}/></label>
<label style={{gridColumn:"1 / -1"}}>说明<textarea value={description} onChange={e=>setDesc(e.target.value)} rows={2}/></label>
- <label style={{gridColumn:"1 / -1"}}>所属领域(多选)
- <div className="chk">{doms.map(d=><label key={d.id} className="chk-item"><input type="checkbox" checked={domainIds.includes(d.id)} onChange={e=>setDomainIds(e.target.checked?[...domainIds,d.id]:domainIds.filter(x=>x!==d.id))}/>{d.name}</label>)}</div>
- </label>
</div>
- <div className="cp-list"><b>采集点</b> <button className="text-button" onClick={addCp}>+ 采集点</button>
- <table className="data-table"><thead><tr><th>名称</th><th>说明</th><th>单位</th><th>数值范围</th><th>描述</th><th></th></tr></thead>
- <tbody>{cps.map((c:any)=>(<tr key={c.id}><td>{c.name}</td><td>{c.collect_note||""}</td><td>{c.unit||""}</td><td>{c.value_range||""}</td><td>{c.description||""}</td><td><button className="text-button" onClick={()=>delCp(c.id)}>删</button></td></tr>))}</tbody></table>
+ <div className="cp-list">
+ <div className="cp-list-head"><b>采集点</b><span><button className="text-button" onClick={addCpRow}>+ 新增一行</button><button className="text-button" onClick={delCpRows}>删除选中</button><button className="text-button" onClick={saveCpRows}>保存采集点</button></span></div>
+ <table className="data-table cp-editor"><thead><tr><th style={{width:34}}><input type="checkbox" checked={allChecked} onChange={toggleAll}/></th><th>名称</th><th>说明</th><th>单位</th><th>数值范围</th><th>描述</th></tr></thead>
+ <tbody>
+ {cps.length===0 && <tr><td colSpan={6} className="muted">暂无采集点,点「+ 新增一行」添加</td></tr>}
+ {cps.map((c:any)=>(
+ <tr key={c.key}>
+ <td><input type="checkbox" checked={!!c.checked} onChange={()=>toggleCp(c.key)}/></td>
+ <td><input value={c.name} onChange={e=>updCp(c.key,"name",e.target.value)} placeholder="名称"/></td>
+ <td><input value={c.collect_note||""} onChange={e=>updCp(c.key,"collect_note",e.target.value)} placeholder="说明"/></td>
+ <td><input value={c.unit||""} onChange={e=>updCp(c.key,"unit",e.target.value)} placeholder="单位"/></td>
+ <td>
+ <select value={CP_RANGE_PRESETS.includes(c.value_range)?c.value_range:"__custom__"} onChange={e=>updCp(c.key,"value_range", e.target.value==="__custom__"?"":e.target.value)}>
+ {CP_RANGE_PRESETS.map(p=><option key={p} value={p}>{p}</option>)}
+ <option value="__custom__">自定义…</option>
+ </select>
+ {!CP_RANGE_PRESETS.includes(c.value_range) && <input className="cp-range-custom" value={c.value_range} onChange={e=>updCp(c.key,"value_range",e.target.value)} placeholder="如 0~100"/>}
+ </td>
+ <td><input value={c.description||""} onChange={e=>updCp(c.key,"description",e.target.value)} placeholder="描述"/></td>
+ </tr>
+ ))}
+ </tbody></table>
</div>
<div style={{marginTop:8}}><Button onClick={save}>保存设备</Button></div>
</div>;
}
-function DeviceTemplateCreate({doms,notify,onDone,onCancel}:{doms:any[];notify:(m:string)=>void;onDone:(id:number)=>void;onCancel:()=>void}){
+function DeviceTemplateCreate({notify,onDone,onCancel}:{notify:(m:string)=>void;onDone:(id:number)=>void;onCancel:()=>void}){
const [name,setName]=useState(""); const [description,setDesc]=useState("");
- const [domainIds,setDomainIds]=useState<number[]>(doms[0]?[doms[0].id]:[]);
- const add=async()=>{ if(!name.trim()){ notify("请填写设备名称"); return; } try{ const r:any=await fetchDb(`/device-templates`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name,description,domain_ids:domainIds})}); notify("已创建设备模版"); onDone(r.id); }catch(e:any){ notify("创建失败:"+e.message); } };
+ const [imgPreview,setImgPreview]=useState<string|null>(null);
+ const [imgB64,setImgB64]=useState<{filename:string;mime:string;data:string}|null>(null);
+ const onPick=async(f:File)=>{ setImgPreview(URL.createObjectURL(f)); const b=await fileToBase64(f); setImgB64({filename:b.filename,mime:b.mime,data:b.data}); };
+ const add=async()=>{ if(!name.trim()){ notify("请填写设备名称"); return; } try{ const body:any={name,description}; if(imgB64) body.image=imgB64; const r:any=await fetchDb(`/device-templates`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}); notify("已创建设备模版"); onDone(r.id); }catch(e:any){ notify("创建失败:"+e.message); } };
return <Panel title="新增设备模版" tools={<button className="text-button" onClick={onCancel}>取消</button>}><div className="form-grid">
<label>设备名称<input value={name} onChange={e=>setName(e.target.value)} placeholder="如:PLC 控制器"/></label>
+ <label style={{gridColumn:"1 / -1"}}>设备图片<DeviceImageField imageUrl={imgPreview} onPick={onPick}/></label>
<label style={{gridColumn:"1 / -1"}}>说明<textarea value={description} onChange={e=>setDesc(e.target.value)} rows={2}/></label>
- <label style={{gridColumn:"1 / -1"}}>所属领域(多选)
- <div className="chk">{doms.map(d=><label key={d.id} className="chk-item"><input type="checkbox" checked={domainIds.includes(d.id)} onChange={e=>setDomainIds(e.target.checked?[...domainIds,d.id]:domainIds.filter(x=>x!==d.id))}/>{d.name}</label>)}</div>
- </label>
<div style={{gridColumn:"1 / -1"}}><Button onClick={add}>创建</Button></div>
</div></Panel>;
}
return <>
<div className="domain-hero"><div><span className="eyebrow">DEVICE TEMPLATE LIBRARY</span><h2>设备模版</h2><p>独立维护设备模版及其采集点,编辑后可在领域方案中按需选用。</p></div></div>
<Panel title="设备模版" tools={creating?null:<Button onClick={()=>setCreating(true)}>+ 新增设备模版</Button>}>
- {creating && <DeviceTemplateCreate doms={domainOptions} notify={notify} onDone={()=>{setCreating(false);load();}} onCancel={()=>setCreating(false)}/>}
+ {creating && <DeviceTemplateCreate notify={notify} onDone={()=>{setCreating(false);load();}} onCancel={()=>setCreating(false)}/>}
{loading? <p>加载中…</p> :
list.length===0 && !creating? <p className="muted">暂无设备模版,点击「+ 新增设备模版」创建。</p> :
- <div className="device-list">{list.map((d:any)=><DeviceTemplateCard key={d.id} dev={d} doms={domainOptions} notify={notify} onChange={load}/>)}</div>}
+ <div className="device-list">{list.map((d:any)=><DeviceTemplateCard key={d.id} dev={d} notify={notify} onChange={load}/>)}</div>}
</Panel>
</>;
}
res.end(buf);
}
+async function handleDeviceTemplateImage(res, id) {
+ const row = db.prepare("SELECT image_path FROM device_templates WHERE id=?").get(id);
+ if (!row || !row.image_path || !existsSync(row.image_path)) return respondError(res, 404, "图片不存在");
+ const buf = await readFile(row.image_path);
+ res.writeHead(200, { "Content-Type": imgContentType(row.image_path), "Content-Length": buf.length, "Cache-Control": "public, max-age=3600" });
+ res.end(buf);
+}
+
async function handlePackageDownload(res, siteId) {
const zipPath = join(GEN_DIR, String(siteId), "station-package.zip");
if (!existsSync(zipPath)) {
const rows = db.prepare(`
SELECT dt.*, (SELECT json_group_array(d.name) FROM device_template_domains dtd JOIN domains d ON d.id=dtd.domain_id WHERE dtd.device_template_id=dt.id) AS domain_names
FROM device_templates dt ORDER BY dt.id DESC`).all();
+ for (const row of rows) row.collection_points = db.prepare("SELECT * FROM device_collection_points WHERE device_template_id=? ORDER BY id").all(row.id);
respondJson(res, 200, rows);
}
const mDevList = path === "/api/device-templates";
const mDevCp = path.match(/^\/api\/device-templates\/(\d+)\/collection-points$/);
const mDev = path.match(/^\/api\/device-templates\/(\d+)$/);
+ const mDevImage = path.match(/^\/api\/device-templates\/(\d+)\/image$/);
const mCp = path.match(/^\/api\/collection-points\/(\d+)$/);
const mDp = path.match(/^\/api\/domain-pages\/(\d+)$/);
const mMenu = path.match(/^\/api\/template-menu-items\/(\d+)$/);
}
// 页面样式库
if (mPageStylesImage) return handlePageStyleImage(res, Number(mPageStylesImage[1]));
+ if (mDevImage) return handleDeviceTemplateImage(res, Number(mDevImage[1]));
if (mPageStyles) {
if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT id,name,created_at FROM page_styles ORDER BY id").all());
if (req.method === "POST") return handleCreatePageStyle(req, res);
<nav class="doc-nav"><div class="doc-nav-inner" id="toc"></div></nav>
<main id="doc">
- <p class="muted">文档版本:v0.6 最后更新:2026-08-18 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
+ <p class="muted">文档版本:v0.7 最后更新:2026-08-18 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
<h1>StationBuilder 产品与交互设计方案</h1>
<h2>1. 文档说明</h2>
<table>
<tr><th>面板 / 元素</th><th>功能</th><th>数据来源 / 接口</th><th>交互效果</th></tr>
<tr><td>模板信息</td><td>编辑所属领域 / 名称 / 版本 / 状态 / 创建人 / 描述</td><td>GET /api/db/domain-templates/:id 详情;PUT /api/db/domain-templates/:id 保存</td><td>「保存模板信息」写回并重新加载</td></tr>
- <tr><td>选用设备模版(DeviceTemplatePicker)</td><td>从「设备模版」页中勾选本方案要用的设备模版</td><td>详情 devices[](已选用的设备模版 id 列表);GET /api/db/device-templates(全部可选);PUT /api/db/domain-templates/:id(传 <code>device_template_ids</code>,服务端重写 device_template_domain_templates 关联)</td><td>勾选即保存:PUT 携带当前全部选中 id,服务端先 DELETE 旧关联再按 id 重建;未找到合适模版可点「+ 新增设备模版」跳到 deviceTemplates 页新建。设备模版的编辑(名称/说明/图片/所属领域/采集点)已移至独立的「设备模版」页,不再在本面板内联编辑</td></tr>
+ <tr><td>选用设备模版(DeviceTemplatePicker)</td><td>从「设备模版」页中勾选本方案要用的设备模版</td><td>详情 devices[](已选用的设备模版 id 列表);GET /api/db/device-templates(全部可选);PUT /api/db/domain-templates/:id(传 <code>device_template_ids</code>,服务端重写 device_template_domain_templates 关联)</td><td>勾选即保存:PUT 携带当前全部选中 id,服务端先 DELETE 旧关联再按 id 重建;未找到合适模版可点「+ 新增设备模版」跳到 deviceTemplates 页新建。设备模版的编辑(名称 / 说明 / 图片 / 采集点)已移至独立的「设备模版」页,不再在本面板内联编辑;设备不再维护「所属领域」多选(改为由领域 / 方案侧选用设备)</td></tr>
<tr><td>采集点</td><td>设备下的采集点(名称 / 说明 / 单位 / 数值范围 / 描述)</td><td>POST /api/db/device-templates/:id/collection-points(名称);DELETE /api/db/collection-points/:id</td><td>「+ 采集点」填名称新增;行内「删」移除</td></tr>
<tr><td>领域页面(PageRow)</td><td>编辑领域页面(=StationBuilder 的 Cube 扩展页面)</td><td>详情 pages[];POST /api/db/domain-templates/:id/pages;PUT / DELETE /api/db/domain-pages/:id</td><td>编辑态展示名称 / 说明 / Cube JSON(textarea);保存前校验 JSON 合法性;「+ 新增页面」建默认空页</td></tr>
<tr><td>模板菜单</td><td>编辑模板菜单(=StationBuilder 的 MU + Cube 菜单保存结构)</td><td>详情 menu[];POST /api/db/domain-templates/:id/menu;DELETE /api/db/template-menu-items/:id</td><td>表格展示菜单项 / 类型 / 父级 / 关联;「+ 新增菜单项」建占位项</td></tr>
<tr><td>样式大图预览(StylePreview)</td><td>查看样式原图</td><td>GET /api/db/page-styles/:id/image</td><td>模态展示</td></tr>
</table>
+ <h3>5.12 设备模版(deviceTemplates)</h3>
+ <p>独立的「设备模版」页,维护可复用的设备模版与采集点,供领域方案在详情中「选用设备模版」引用。列表为整宽卡片(<code>.device-list</code> 单列),每个卡片即一个设备模版的编辑区。</p>
+ <table>
+ <tr><th>元素</th><th>功能</th><th>数据来源 / 接口</th><th>交互效果</th></tr>
+ <tr><td>设备图片</td><td>以长方形预览区展示设备图片,点击预览区即上传 / 更换</td><td>GET /api/db/device-templates/:id/image(服务端按 image_path 落盘 <code>data/db/uploads/devices/</code>);PUT /api/db/device-templates/:id(image base64)</td><td>预览区固定高度、<code>object-fit:cover</code> 不再溢出;上传后即时本地预览,保存时写回;删除图片 <span class="todo">尚未实现</span></td></tr>
+ <tr><td>设备名称 / 说明</td><td>编辑设备基本信息</td><td>GET /api/db/device-templates/:id;PUT /api/db/device-templates/:id</td><td>「保存设备」写回名称和说明(不再维护设备侧「所属领域」,改为由领域 / 方案侧选用)</td></tr>
+ <tr><td>采集点(表格编辑)</td><td>表格方式维护采集点:名称 / 说明 / 单位 / 数值范围 / 描述;可新增一行、勾选删除一行或多行、保存</td><td>GET /api/db/device-templates/:id 的 collection_points;POST /api/db/device-templates/:id/collection-points(新建,带全字段);PUT / DELETE /api/db/collection-points/:id</td><td>「+ 新增一行」追加空白可编辑行;表头复选框全选,行内复选框多选,「删除选中」批量 DELETE;「保存采集点」对新建行 POST、对已有行 PUT;<strong>数值范围</strong>为下拉选择器(预设 0~100 / 0~1000 / -20~80 / -40~85 / 0~50 / 0~5 / 0~1 / 0~100%,或「自定义…」输入)</td></tr>
+ <tr><td>新增设备模版</td><td>新建设备模版(名称 / 说明 / 图片)</td><td>POST /api/db/device-templates</td><td>列表上方「+ 新增设备模版」展开表单,创建后自动出现在列表中</td></tr>
+ </table>
+
<h2>6. 生成流程(端到端)</h2>
<div class="flow">
<ol>
<h3>11.1 版本变更记录(Changelog)</h3>
<table class="changelog">
<tr><th>版本</th><th>日期</th><th>修改人</th><th>说明</th></tr>
+ <tr><td>v0.7</td><td>2026-08-18</td><td>wang.yu</td><td>设备模版页交互优化:设备图片改为长方形预览区(点击上传,不再溢出);移除设备侧「所属领域」多选(改为由领域/方案侧选用设备);采集点改为整宽表格编辑,支持新增一行、勾选批量删除、保存,数值范围改为下拉选择器(预设 + 自定义);修复打开卡片时 collection_points 未加载导致的 runtime error,列表接口补充返回 collection_points。后端新增 GET /api/db/device-templates/:id/image 图片服务。新增 5.12 章节。</td></tr>
<tr><td>v0.6</td><td>2026-08-18</td><td>wang.yu</td><td>领域方案库重构为多菜单:列表页支持「列表 / 卡片」视图切换与按领域分类筛选;新增独立的「设备模版」页(前端 DeviceTemplates,独立维护设备模版与采集点);方案详情的设备面板改为「选用设备模版」(DeviceTemplatePicker,勾选即重写 device_template_domain_templates 关联,未找到可跳转新增);领域分类改为由 GET /api/domains 动态渲染的二级菜单。后端 domain-templates 更新支持 device_template_ids 重关联。同步更新 4.2 / 5.9 / 5.10 章节。</td></tr>
<tr><td>v0.5</td><td>2026-08-18</td><td>wang.yu</td><td>设计方案支持页面截图:新增 figure / 图注 样式(自动编号),撰写方式参考 MachineCube 手册(小节标题 + 说明 +「下图」引用 + 配图 + 图注);图片置于 public/design-doc-assets/。新增「版本变更记录」,每次修改升级小版本号并登记修改人 / 日期。</td></tr>
<tr><td>v0.4</td><td>2026-08-18</td><td>wang.yu</td><td>领域方案库升级为真实数据库驱动 CRUD:新增 schema(领域模板 / 设备模板 / 采集点 / 领域页面 / 模板菜单 / 知识文档 / 运维流程占位);全套接口(/api/domain-templates、/api/device-templates、/api/collection-points、/api/domain-pages、/api/template-menu-items、/api/knowledge-docs、/api/ops-flows);前端列表 + 内嵌编辑器,openDomainTemplateId 控制 列表·新增·详情 切换。</td></tr>