From: root Date: Wed, 9 Sep 2026 03:50:44 +0000 (+0800) Subject: 实现手动配置资产后端:db-sidecar 新增 asset_groups 表与 CRUD,使前端手动配置功能(资产树/节点抽屉/属性与数据绑定)可用并落库 X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=2384da9e03ea3a2241e1b66e861700efce688073;p=StationBuilder.git 实现手动配置资产后端:db-sidecar 新增 asset_groups 表与 CRUD,使前端手动配置功能(资产树/节点抽屉/属性与数据绑定)可用并落库 --- diff --git a/db-sidecar.mjs b/db-sidecar.mjs index b988b02..147b651 100644 --- a/db-sidecar.mjs +++ b/db-sidecar.mjs @@ -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 index 0000000..93fea5f --- /dev/null +++ b/scripts/verify-manual-asset.mjs @@ -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(); +}