]> acesimba.cloud Git - StationBuilder.git/commitdiff
设备模板新增「属性列表」区块:关键字/属性名称/属性值/单位,支持行内编辑、勾选删除与保存(复用采集点表格编辑样式)
authorroot <root@localhost.localdomain>
Thu, 10 Sep 2026 06:24:22 +0000 (14:24 +0800)
committerroot <root@localhost.localdomain>
Thu, 10 Sep 2026 06:24:22 +0000 (14:24 +0800)
app/page.tsx
data/db/StationBuilder.sqlite
db-sidecar.mjs

index bebcd4f18be1652f88ac4b9329a3af1d585d728d..c91a773552640da63e826dbb932e5fac0a7719de 100644 (file)
@@ -1604,6 +1604,14 @@ function DeviceTemplateCard({dev,notify,onChange,onBack,onViewCpPrompt}:{dev:any
   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(!(await 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); } };
+  const [attrs,setAttrs]=useState<any[]>((dev.attributes||[]).map((a:any)=>({...a,key:a.id,checked:false})));
+  const addAttrRow=()=>setAttrs([...attrs,{key:-(Date.now()),keyword:"",name:"",value:"",unit:"",checked:false}]);
+  const updAttr=(k:number,field:string,val:any)=>setAttrs(attrs.map(a=>a.key===k?{...a,[field]:val}:a));
+  const toggleAttr=(k:number)=>setAttrs(attrs.map(a=>a.key===k?{...a,checked:!a.checked}:a));
+  const allAttrChecked=attrs.length>0&&attrs.every(a=>a.checked);
+  const toggleAllAttr=()=>setAttrs(attrs.map(a=>({...a,checked:!allAttrChecked})));
+  const delAttrRows=async()=>{ const sel=attrs.filter(a=>a.checked); if(sel.length===0){ notify("请先勾选要删除的属性"); return; } if(!(await confirm(`确定删除选中的 ${sel.length} 个属性?`))) return; for(const a of sel){ if(a.id) await fetchDb(`/attributes/${a.id}`,{method:"DELETE"}); } setAttrs(attrs.filter(a=>!a.checked)); onChange(); };
+  const saveAttrRows=async()=>{ try{ for(const a of attrs){ const payload={keyword:a.keyword,name:a.name,value:a.value,unit:a.unit}; if(a.id){ await fetchDb(`/attributes/${a.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)}); } else { await fetchDb(`/device-templates/${dev.id}/attributes`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)}); } } notify("属性已保存"); onChange(); }catch(e:any){ notify("保存失败:"+e.message); } };
   const cpAi=useStreamComplete();
   const runCpComplete=async()=>{
     if(cpAi.completing) return;
@@ -1661,6 +1669,22 @@ function DeviceTemplateCard({dev,notify,onChange,onBack,onViewCpPrompt}:{dev:any
         ))}
       </tbody></table>
     </div>
+    <div className="cp-list">
+      <div className="cp-list-head"><b>属性列表</b><span><button className="text-button" onClick={addAttrRow}>+ 新增一行</button><button className="text-button" onClick={delAttrRows}>删除选中</button><button className="text-button" onClick={saveAttrRows}>保存属性</button></span></div>
+      <table className="data-table cp-editor"><thead><tr><th style={{width:34}}><input type="checkbox" checked={allAttrChecked} onChange={toggleAllAttr}/></th><th>关键字</th><th>属性名称</th><th>属性值</th><th>单位</th></tr></thead>
+      <tbody>
+        {attrs.length===0 && <tr><td colSpan={5} className="muted">暂无属性,点「+ 新增一行」添加</td></tr>}
+        {attrs.map((a:any)=>(
+          <tr key={a.key}>
+            <td><input type="checkbox" checked={!!a.checked} onChange={()=>toggleAttr(a.key)}/></td>
+            <td><input value={a.keyword||""} onChange={e=>updAttr(a.key,"keyword",e.target.value)} placeholder="关键字"/></td>
+            <td><input value={a.name||""} onChange={e=>updAttr(a.key,"name",e.target.value)} placeholder="属性名称"/></td>
+            <td><input value={a.value||""} onChange={e=>updAttr(a.key,"value",e.target.value)} placeholder="属性值"/></td>
+            <td><input value={a.unit||""} onChange={e=>updAttr(a.key,"unit",e.target.value)} placeholder="单位"/></td>
+          </tr>
+        ))}
+      </tbody></table>
+    </div>
     <div className="field-assist" style={{marginTop:16}}>
       <Button secondary onClick={()=>setAiOpen(true)}><RobotOutlined/> AI 智能补全</Button>
       <span className="muted">基于设备名称 / 领域 / 说明,AI 推荐合适的采集点并追加</span>
index 70c7efabc6b5c220932d2f33da6b7d7950e8827b..a8677d504d944c87d8fa11efb37869afbc92a0db 100644 (file)
Binary files a/data/db/StationBuilder.sqlite and b/data/db/StationBuilder.sqlite differ
index 76e27a3ca921c73f3f56dbcebd6d9ce46af2e300..6005ac0f52b81aad5197862bed4d04fcdd0fedad 100644 (file)
@@ -268,6 +268,15 @@ function initSchema() {
       description TEXT,
       FOREIGN KEY(device_template_id) REFERENCES device_templates(id) ON DELETE CASCADE
     );
+    CREATE TABLE IF NOT EXISTS device_template_attributes (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      device_template_id INTEGER NOT NULL,
+      keyword TEXT,
+      name TEXT,
+      value TEXT,
+      unit TEXT,
+      FOREIGN KEY(device_template_id) REFERENCES device_templates(id) ON DELETE CASCADE
+    );
     CREATE TABLE IF NOT EXISTS device_template_domains (
       device_template_id INTEGER NOT NULL,
       domain_id INTEGER NOT NULL,
@@ -1881,7 +1890,7 @@ async function handleDomainTemplateDetail(res, id) {
     FROM device_templates dt2
     WHERE dt2.id IN (SELECT device_template_id FROM device_template_domain_templates WHERE domain_template_id=?)
   `).all(id);
-  for (const dev of devices) { dev.collection_points = db.prepare("SELECT * FROM device_collection_points WHERE device_template_id=? ORDER BY id").all(dev.id); try { dev.domain_ids = JSON.parse(dev.domain_ids || "[]"); } catch { dev.domain_ids = []; } try { dev.domain_names = JSON.parse(dev.domain_names || "[]"); } catch { dev.domain_names = []; } }
+  for (const dev of devices) { dev.collection_points = db.prepare("SELECT * FROM device_collection_points WHERE device_template_id=? ORDER BY id").all(dev.id); dev.attributes = db.prepare("SELECT * FROM device_template_attributes WHERE device_template_id=? ORDER BY id").all(dev.id); try { dev.domain_ids = JSON.parse(dev.domain_ids || "[]"); } catch { dev.domain_ids = []; } try { dev.domain_names = JSON.parse(dev.domain_names || "[]"); } catch { dev.domain_names = []; } }
   const pages = db.prepare("SELECT * FROM domain_pages WHERE domain_template_id=? ORDER BY id").all(id);
   const menu = db.prepare("SELECT * FROM template_menu_items WHERE domain_template_id=? ORDER BY sort_order,id").all(id);
   const docs = db.prepare("SELECT id,name,doc_type,url,mime,size,created_at FROM knowledge_docs WHERE domain_template_id=? ORDER BY id").all(id);
@@ -2181,7 +2190,7 @@ async function handleDeviceTemplatesList(res) {
   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);
+  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); row.attributes = db.prepare("SELECT * FROM device_template_attributes WHERE device_template_id=? ORDER BY id").all(row.id); }
   respondJson(res, 200, rows);
 }
 
@@ -2200,6 +2209,7 @@ async function handleDeviceTemplateDetail(res, id) {
   const dev = db.prepare("SELECT * FROM device_templates WHERE id=?").get(id);
   if (!dev) return respondError(res, 404, "设备模板不存在");
   dev.collection_points = db.prepare("SELECT * FROM device_collection_points WHERE device_template_id=? ORDER BY id").all(id);
+  dev.attributes = db.prepare("SELECT * FROM device_template_attributes WHERE device_template_id=? ORDER BY id").all(id);
   dev.domain_ids = db.prepare("SELECT domain_id FROM device_template_domains WHERE device_template_id=?").all(id).map(r => r.domain_id);
   dev.domain_template_ids = db.prepare("SELECT domain_template_id FROM device_template_domain_templates WHERE device_template_id=?").all(id).map(r => r.domain_template_id);
   respondJson(res, 200, dev);
@@ -2255,6 +2265,32 @@ async function handleCollectionPointDelete(res, id) {
   respondJson(res, 200, { ok: true });
 }
 
+async function handleAttributeCreate(req, res, devId) {
+  const body = await parseBody(req);
+  const name = (body.name || "").toString().trim();
+  if (!name) return respondError(res, 400, "属性名称不能为空");
+  const info = db.prepare("INSERT INTO device_template_attributes (device_template_id,keyword,name,value,unit) VALUES (?,?,?,?,?)").run(devId, body.keyword || "", name, body.value || "", body.unit || "");
+  respondJson(res, 200, { ok: true, id: info.lastInsertRowid });
+}
+
+async function handleAttributeUpdate(req, res, id) {
+  const row = db.prepare("SELECT * FROM device_template_attributes WHERE id=?").get(id);
+  if (!row) return respondError(res, 404, "属性不存在");
+  const body = await parseBody(req);
+  const keyword = body.keyword != null ? body.keyword : row.keyword;
+  const name = body.name != null ? body.name.toString().trim() : row.name;
+  const value = body.value != null ? body.value : row.value;
+  const unit = body.unit != null ? body.unit : row.unit;
+  if (!name) return respondError(res, 400, "属性名称不能为空");
+  db.prepare("UPDATE device_template_attributes SET keyword=?,name=?,value=?,unit=? WHERE id=?").run(keyword, name, value, unit, id);
+  respondJson(res, 200, { ok: true, id });
+}
+
+async function handleAttributeDelete(res, id) {
+  db.prepare("DELETE FROM device_template_attributes WHERE id=?").run(id);
+  respondJson(res, 200, { ok: true });
+}
+
 async function handleDeviceCompleteCollectionPoints(req, res, devId) {
   res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
   const emit = (o) => streamEvent(res, o);
@@ -2485,12 +2521,14 @@ async function route(req, res) {
   const mDtPageRefine = path.match(/^\/api\/domain-templates\/(\d+)\/pages\/(\d+)\/refine$/);
   const mDevList = path === "/api/device-templates";
   const mDevCp = path.match(/^\/api\/device-templates\/(\d+)\/collection-points$/);
+  const mDevAttr = path.match(/^\/api\/device-templates\/(\d+)\/attributes$/);
   const mDev = path.match(/^\/api\/device-templates\/(\d+)$/);
   const mDevImage = path.match(/^\/api\/device-templates\/(\d+)\/image$/);
   const mDevCpComplete = path.match(/^\/api\/device-templates\/(\d+)\/complete-collection-points$/);
   const mDevCpCompleteStop = path.match(/^\/api\/device-templates\/(\d+)\/complete-collection-points\/stop$/);
   const mDevCpCompletePrompt = path.match(/^\/api\/device-templates\/(\d+)\/complete-collection-points-prompt$/);
   const mCp = path.match(/^\/api\/collection-points\/(\d+)$/);
+  const mAttr = path.match(/^\/api\/attributes\/(\d+)$/);
   const mDp = path.match(/^\/api\/domain-pages\/(\d+)$/);
   const mMenu = path.match(/^\/api\/template-menu-items\/(\d+)$/);
   const mDoc = path.match(/^\/api\/knowledge-docs\/(\d+)$/);
@@ -2770,6 +2808,7 @@ async function route(req, res) {
       if (req.method === "POST") return handleDeviceTemplateCreate(req, res);
     }
     if (mDevCp) { if (req.method === "POST") return handleCollectionPointCreate(req, res, Number(mDevCp[1])); }
+    if (mDevAttr) { if (req.method === "POST") return handleAttributeCreate(req, res, Number(mDevAttr[1])); }
     if (mDev) {
       if (req.method === "GET") return handleDeviceTemplateDetail(res, Number(mDev[1]));
       if (req.method === "PUT") return handleDeviceTemplateUpdate(req, res, Number(mDev[1]));
@@ -2779,6 +2818,10 @@ async function route(req, res) {
       if (req.method === "PUT") return handleCollectionPointUpdate(req, res, Number(mCp[1]));
       if (req.method === "DELETE") return handleCollectionPointDelete(res, Number(mCp[1]));
     }
+    if (mAttr) {
+      if (req.method === "PUT") return handleAttributeUpdate(req, res, Number(mAttr[1]));
+      if (req.method === "DELETE") return handleAttributeDelete(res, Number(mAttr[1]));
+    }
     if (mDevCpComplete) { if (req.method === "POST") return handleDeviceCompleteCollectionPoints(req, res, Number(mDevCpComplete[1])); }
     if (mDevCpCompleteStop) { if (req.method === "POST") return handleDeviceCompleteCollectionPointsStop(res, Number(mDevCpCompleteStop[1])); }
     if (mDevCpCompletePrompt) { if (req.method === "GET") return handleDeviceCompleteCollectionPointsPrompt(req, res, Number(mDevCpCompletePrompt[1])); }