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,
);
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);
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.*,
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+)$/);
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));
--- /dev/null
+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();
+}