]> acesimba.cloud Git - StationBuilder.git/commitdiff
实现手动配置资产后端:db-sidecar 新增 asset_groups 表与 CRUD,使前端手动配置功能(资产树/节点抽屉/属性与数据绑定)可用并落库
authorroot <root@localhost.localdomain>
Wed, 9 Sep 2026 03:50:44 +0000 (11:50 +0800)
committerroot <root@localhost.localdomain>
Wed, 9 Sep 2026 03:50:44 +0000 (11:50 +0800)
db-sidecar.mjs
scripts/verify-manual-asset.mjs [new file with mode: 0644]

index b988b029b96df3bcd7c5e146c3ff9debf7fa7a4c..147b651dcb82cf54c5f9dfe2d23432557bfeabf3 100644 (file)
@@ -191,6 +191,17 @@ function initSchema() {
       FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE,
       UNIQUE(site_id, page_key)
     );
+    CREATE TABLE IF NOT EXISTS asset_groups (
+      id INTEGER PRIMARY KEY AUTOINCREMENT,
+      site_id INTEGER NOT NULL,
+      name TEXT NOT NULL,
+      category TEXT NOT NULL DEFAULT '站点资产',
+      template_name TEXT,
+      structure_json TEXT NOT NULL DEFAULT '[]',
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+      FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE
+    );
     CREATE TABLE IF NOT EXISTS cubes (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       site_id INTEGER NOT NULL,
@@ -327,6 +338,7 @@ function initSchema() {
     );
     CREATE INDEX IF NOT EXISTS idx_sites_updated ON sites(updated_at);
     CREATE INDEX IF NOT EXISTS idx_sites_status ON sites(status);
+    CREATE INDEX IF NOT EXISTS idx_asset_groups_site ON asset_groups(site_id, updated_at);
   `);
   const insertDomain = db.prepare("INSERT OR IGNORE INTO domains (name) VALUES (?)");
   for (const d of DEFAULT_DOMAINS) insertDomain.run(d);
@@ -402,6 +414,17 @@ function hydrateSite(id) {
   site.files = db.prepare("SELECT id,filename,mime,size,created_at FROM reference_files WHERE site_id = ?").all(id);
   return site;
 }
+function normalizeAssetGroup(row) {
+  if (!row) return null;
+  let structure = [];
+  try { structure = JSON.parse(row.structure_json || "[]"); }
+  catch { structure = []; }
+  const { structure_json, ...rest } = row;
+  return { ...rest, structure };
+}
+function listAssetGroups(siteId) {
+  return db.prepare("SELECT * FROM asset_groups WHERE site_id=? ORDER BY updated_at DESC,id DESC").all(siteId).map(normalizeAssetGroup);
+}
 function siteList() {
   return db.prepare(`
     SELECT s.*,
@@ -2412,6 +2435,8 @@ async function route(req, res) {
   const mHealth = path === "/api/health";
   const mMuTree = path === "/api/mu-tree";
   const mSiteAssets = path.match(/^\/api\/sites\/(\d+)\/assets$/);
+  const mSiteAssetGroups = path.match(/^\/api\/sites\/(\d+)\/asset-groups$/);
+  const mSiteAssetGroup = path.match(/^\/api\/sites\/(\d+)\/asset-groups\/(\d+)$/);
   const mSiteMu = path.match(/^\/api\/sites\/(\d+)\/mu$/);
   const mSiteCubes = path.match(/^\/api\/sites\/(\d+)\/cubes$/);
   const mSiteCube = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)$/);
@@ -2546,6 +2571,52 @@ async function route(req, res) {
         return respondJson(res, 200, db.prepare("SELECT * FROM assets WHERE site_id=? ORDER BY sort_order,id").all(siteId));
       }
     }
+    if (mSiteAssetGroups) {
+      const siteId = Number(mSiteAssetGroups[1]);
+      if (!getSite(siteId)) return respondError(res, 404, "站点不存在");
+      if (req.method === "GET") return respondJson(res, 200, listAssetGroups(siteId));
+      if (req.method === "POST") {
+        const body = await parseBody(req);
+        const name = String(body.name || "").trim();
+        if (!name) return respondError(res, 400, "资产组名称不能为空");
+        const info = db.prepare("INSERT INTO asset_groups (site_id,name,category,template_name,structure_json) VALUES (?,?,?,?,?)").run(
+          siteId,
+          name,
+          String(body.category || "站点资产").trim() || "站点资产",
+          String(body.template_name || "通用资产模板"),
+          JSON.stringify(Array.isArray(body.structure) ? body.structure : [])
+        );
+        db.prepare("UPDATE sites SET updated_at=CURRENT_TIMESTAMP WHERE id=?").run(siteId);
+        return respondJson(res, 200, normalizeAssetGroup(db.prepare("SELECT * FROM asset_groups WHERE id=?").get(info.lastInsertRowid)));
+      }
+    }
+    if (mSiteAssetGroup) {
+      const siteId = Number(mSiteAssetGroup[1]);
+      const groupId = Number(mSiteAssetGroup[2]);
+      const current = db.prepare("SELECT * FROM asset_groups WHERE id=? AND site_id=?").get(groupId, siteId);
+      if (!current) return respondError(res, 404, "资产组不存在");
+      if (req.method === "GET") return respondJson(res, 200, normalizeAssetGroup(current));
+      if (req.method === "PUT") {
+        const body = await parseBody(req);
+        const name = String(body.name ?? current.name).trim();
+        if (!name) return respondError(res, 400, "资产组名称不能为空");
+        db.prepare("UPDATE asset_groups SET name=?,category=?,template_name=?,structure_json=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND site_id=?").run(
+          name,
+          String(body.category ?? current.category).trim() || "站点资产",
+          String(body.template_name ?? current.template_name ?? ""),
+          JSON.stringify(Array.isArray(body.structure) ? body.structure : JSON.parse(current.structure_json || "[]")),
+          groupId,
+          siteId
+        );
+        db.prepare("UPDATE sites SET updated_at=CURRENT_TIMESTAMP WHERE id=?").run(siteId);
+        return respondJson(res, 200, normalizeAssetGroup(db.prepare("SELECT * FROM asset_groups WHERE id=?").get(groupId)));
+      }
+      if (req.method === "DELETE") {
+        db.prepare("DELETE FROM asset_groups WHERE id=? AND site_id=?").run(groupId, siteId);
+        db.prepare("UPDATE sites SET updated_at=CURRENT_TIMESTAMP WHERE id=?").run(siteId);
+        return respondJson(res, 200, { ok: true });
+      }
+    }
     if (mSiteMu) {
       const siteId = Number(mSiteMu[1]);
       if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT page_key,selected FROM mu_selections WHERE site_id=?").all(siteId));
diff --git a/scripts/verify-manual-asset.mjs b/scripts/verify-manual-asset.mjs
new file mode 100644 (file)
index 0000000..93fea5f
--- /dev/null
@@ -0,0 +1,77 @@
+import pkg from '/home/StationBuilder/node_modules/playwright/index.js';
+const { chromium } = pkg;
+
+const BASE = 'http://127.0.0.1:37821/t/stationbuilder/';
+const results = [];
+const errs = [];
+const check = (n, c, e='') => results.push({ n, ok: !!c, e });
+
+const browser = await chromium.launch();
+const ctx = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
+const page = await ctx.newPage();
+page.on('console', m => { if (m.type()==='error') errs.push(m.text()); });
+page.on('pageerror', e => errs.push('PAGEERROR: '+e.message));
+
+try {
+  await page.goto(BASE, { waitUntil:'networkidle' });
+  await page.waitForSelector('.company-side-nav', { timeout:15000 });
+  await page.locator('.company-side-nav button:has-text("站点编辑")').first().click();
+  await page.waitForSelector('.site-edit-tabs', { timeout:5000 });
+  await page.locator('.site-edit-tabs button:has-text("站点资产设置")').click();
+  await page.waitForSelector('button[role="tab"]', { timeout:5000 });
+  await page.locator('button[role="tab"]:has-text("手动配置")').click();
+  // Expect full editor, NOT error state
+  await page.waitForSelector('.manual-asset-editor', { timeout:10000 });
+  check('手动配置: 完整编辑器渲染(非错误态)', await page.locator('.manual-asset-editor').count()>0 && await page.locator('.asset-editor-loading.error').count()===0);
+  await page.screenshot({ path:'/tmp/sb-ma-1-editor.png' });
+
+  const before = await page.locator('.asset-node-card').count();
+  // Add a child node via toolbar 添加子节点 (this also auto-opens the node drawer)
+  await page.locator('.asset-tool-button[title="添加子节点"]').click();
+  await page.waitForTimeout(400);
+  const after = await page.locator('.asset-node-card').count();
+  check('内部页面: 添加节点后资产树节点数增加', after > before, `before=${before} after=${after}`);
+  await page.screenshot({ path:'/tmp/sb-ma-2-added.png' });
+
+  // Node drawer should already be open (design behavior: addNode opens drawer)
+  await page.waitForSelector('.asset-node-drawer', { timeout:5000 });
+  check('弹出侧边页面: 节点配置抽屉自动打开', await page.locator('.asset-node-drawer').count()>0);
+  const tabs = await page.locator('.node-drawer-tabs button').allInnerTexts();
+  check('抽屉三页签: 基础信息/属性/数据绑定', tabs.join(',').includes('基础信息') && tabs.join(',').includes('属性') && tabs.join(',').includes('数据绑定'), tabs.join(','));
+  await page.screenshot({ path:'/tmp/sb-ma-3-drawer.png' });
+
+  // Switch to 属性 tab
+  await page.locator('.node-drawer-tabs button:has-text("属性")').click();
+  await page.waitForTimeout(300);
+  check('子页面: 属性设定页渲染', await page.locator('.node-property-grid').count()>0);
+  // Switch to 数据绑定 tab
+  await page.locator('.node-drawer-tabs button:has-text("数据绑定")').click();
+  await page.waitForTimeout(300);
+  check('子页面: 数据绑定页渲染', await page.locator('.edge-component-table').count()>0);
+  await page.screenshot({ path:'/tmp/sb-ma-4-binding.png' });
+
+  // Close drawer via antd Drawer close (X), then save
+  await page.locator('.ant-drawer-close').click();
+  await page.waitForTimeout(500);
+  await page.locator('.asset-editor-title button').click();
+  await page.waitForTimeout(800);
+  check('保存: 无报错', await page.locator('.asset-editor-loading.error').count()===0);
+
+  // Verify persistence via API
+  const siteId = await page.evaluate(()=>new URLSearchParams(location.search).get('id'));
+  const api = await page.evaluate(async()=>{
+    const r = await fetch('/api/db/sites/'+new URLSearchParams(location.search).get('id')+'/asset-groups');
+    return await r.json();
+  });
+  check('落库: 资产组已持久化', Array.isArray(api) && api.length>=1 && Array.isArray(api[0].structure) && api[0].structure.length>=1, JSON.stringify(api?.slice?.(0,1)?.map?.(g=>({id:g.id, nodes:g.structure?.length}))));
+  await page.screenshot({ path:'/tmp/sb-ma-5-saved.png' });
+
+} catch(e) {
+  results.push({ n:'SCRIPT ERROR', ok:false, e:e.message });
+} finally {
+  console.log('=== CHECKS ===');
+  for (const r of results) console.log(`${r.ok?'PASS':'FAIL'}  ${r.n}${r.e?'  ('+r.e+')':''}`);
+  console.log('=== CONSOLE ERRORS ('+errs.length+') ===');
+  for (const e of errs.slice(0,20)) console.log(e);
+  await browser.close();
+}