12 KiB
| name | description | version | tags | ||||||
|---|---|---|---|---|---|---|---|---|---|
| bricks-layout-patterns | Bricks布局/Menu/DSPY/RBAC模式——PCCS踩坑实录,避免下一个应用重复犯错 | 1.0.0 |
|
Bricks 布局与开发模式
从 PCCS 部署实战中提炼的通用模式,适用于所有 Sage/Bricks 应用。
Menu 控件(不是 Tree)
参考 Sage global_menu.ui。
格式:
{"widgettype": "Menu", "id": "xxx_menu", "options": {
"menuitem_css": "menuitem",
"items": [
{"name": "id", "label": "显示文字", "url": "...", "target": "app.xxx_content"},
{"name": "parent", "label": "父菜单", "items": [
{"name": "child", "label": "子菜单", "url": "...", "target": "app.xxx_content"}
]}
]
}}
关键规则:
- 子菜单用
"items",不是"children" target必须用"app.xxx"前缀——bricks.getWidgetById基于 DOMclosest/querySelector,从 Menu 出发找不到兄弟节点。app.xxx从 body 级搜索- target id 放在纯内容容器上(VScrollPanel),不要放在包裹 sidebar+content 的 HBox 上,否则菜单点击会覆盖 sidebar
icon字段不要放 emoji——emoji 会被当作 URL 路径请求,产生401 /📊之类的 permission check failed 错误。emoji 只能放label里("label": "📊 概览"),icon留空或放 SVG URL(/imgs/xxx.svg)
DSPY 文件铁律
禁止 f-string
f-string 在 exec() 包裹的 DSPY 中导致 unterminated string literal:
# 错误
return {'otext': f'CPU: {n}核\n 内存: {m}GB'}
# 正确
txt = 'CPU: ' + str(n) + '核 | 内存: ' + str(m) + 'GB'
return {'otext': txt}
禁止 import
除了 from sqlor.filter import DBFilter,其他 import 全部预加载(json, datetime, DBPools, get_sor_context 等)。
add/update DSPY 空值清理(datetime 字段 500)
表单空提交会把 '' 发到 datetime 字段,MySQL 报 Incorrect datetime value: ''。所有 add/update DSPY 除了 _text 清理外,还要清 datetime 空值:
for k in list(ns.keys()):
if k.endswith('_text'):
ns.pop(k, None)
if k.endswith('_at') or k.endswith('_time') or k == 'last_heartbeat':
if ns.get(k) in ('', None, 'None'):
ns[k] = None
跨模块多 DB 查询 + 唯一约束(节点分配模式)
一个 DSPY 里查两个模块的库(各自 get_module_dbname() + 独立 sqlorContext):
async with DBPools().sqlorContext(get_module_dbname('pcpool')) as sor:
nodes = await sor.R('compute_node', {'pool_id': pool_id}) # 源库
async with DBPools().sqlorContext(get_module_dbname('pcc')) as sor:
assigned = await sor.R('cluster_node', {}) # 目标库
available = [n for n in nodes if n.id not in {a.node_id for a in assigned}]
唯一性(节点不可重复分配)用 DB 唯一索引 + DSPY 双重校验:
ALTER TABLE x ADD UNIQUE KEY uk_node_id (node_id);
DSPY 里先 sor.R 查存在性再插入,报友好错误。
CRUD 桩代码检测
grep -rl "'status': 'ok'" wwwroot/api/ | xargs grep -L "sor\.\(C\|U\|D\)"
桩代码只返回 {'status':'ok','message':'created'} 未操作数据库。
Header 标准布局
参照 Sage index.ui header:
[Logo 品牌名] ... Filler ... [🌓主题] [语言切换] [👤用户面板]
{"widgettype": "HBox", "options": {"halign": "space-between"},
"subwidgets": [
{"widgettype": "HBox", "subwidgets": [品牌]},
{"widgettype": "Filler"},
{"widgettype": "Button", "id": "theme_toggle_btn", "options": {"label": "🌓"}},
{"widgettype": "urlwidget", "options": {"url": "{{entire_url('i18n/language.ui')}}"}},
{"widgettype": "urlwidget", "options": {"url": "{{entire_url('/rbac/user/user_panel.ui')}}"}}
]}
需要从 Sage 仓库复制 i18n/language.ui 到项目 wwwroot/i18n/,同时复制 i18n/menu.ui(语言选择弹窗,否则点击语言按钮 401)。
语言切换与主题切换(PCCS 踩坑)
语言切换:本版本 bricks 没有 this.change_language / bricks.app.set_lang / bricks.app.fire。唯一有效方法是 bricks.app.change_language(lang)(async)。i18n/menu.ui 的菜单项 script 用:
"script": "bricks.app.change_language('zh')" // 或 'en'
不要用 Sage 的 this.change_language('zh')——那是 Menu widget 方法,PCCS 的 bricks.js 版本没有。
主题切换:theme_toggle_btn 用 data-theme 属性切换,脚本里更新按钮文字用 b.dom_element.textContent,不是 b.refresh()(Button 没有 refresh 方法):
var h=document.documentElement;var t=h.getAttribute('data-theme')||'light';
var n=t=='light'?'dark':'light';h.setAttribute('data-theme',n);
localStorage.setItem('pccs-theme',n);
var b=bricks.getWidgetById('theme_toggle_btn',bricks.app);
if(b){b.opts.label=n=='light'?'🌙':'☀️';b.dom_element.textContent=b.opts.label}
语言切换端点需 RBAC any
/i18n/menu.ui 和 /i18n/language.ui 都要注册 any 角色(语言切换不应要求登录),否则点击语言按钮 401。/rbac/user/user.ui、/rbac/user/userinfo.ui、/rbac/user/user_panel.ui 也要 any(未登录时 header 用户区不报错)。
静态资源通配权限
/bricks/**、/favicon.ico、/bricks/imgs/**、/bricks/3parties/** 需要 any 权限,否则一堆 401。清理 401 的标准做法:grep 'permission check failed' nohup.log 看具体 path,逐个补权限。
RBAC 权限隔离
| 角色 | 可访问 |
|---|---|
any |
只读:stats、get_*、index.ui、menu.ui、i18n_getmsgs |
logined |
CRUD 写操作 |
严禁 any 覆盖 create/update/delete/deploy/allocate 等写路径。
每次改权限必须:redis-cli FLUSHDB + 重启服务(RBAC 有 Redis 缓存)。
概览页 Stats API
必须返回 Bricks Widget 格式(有 widgettype),不能只返回 {status:'ok', data:{}}:
return {'widgettype': 'Text', 'options': {'text': '统计信息...', 'cfontsize': 0.9}}
⚠️ Text 控件用 text 不是 otext(PCCS 实锤踩坑)
Bricks Text.set_attrs() 渲染的是 this.text,不是 this.otext:
// bricks.js set_attrs()
if (this.i18n && this.otext) {
this.text = bricks.app.i18n._(this.otext); // 只有 i18n=true 才翻译 otext
}
this.dom_element.innerHTML = this.text || ''; // 最终渲染 this.text
text= 已翻译好的最终文本(直接渲染)otext= 原文(只有i18n: true时才被翻译后渲染)
DSPY 返回普通文本卡片时,用 text 字段。用 otext 且不设 i18n: true → 卡片空内容(HTML 里没有值,innerText 为空)。
概览页多卡片最佳实践:概览页本身写成 .dspy(不是 .ui + urlwidget 加载子 dspy),直接在 DSPY 里查库返回完整卡片树,避免 urlwidget 加载 dspy 返回 widget JSON 时子控件不渲染的问题。卡片用 card(title, value, subtitle) 辅助函数生成 VBox 卡片,options 用 text 字段。
i18n 三步走
- 每模块
i18n/{zh,en}/msg.txt(格式:原文: 译文) merge_i18n.py合并 →wwwroot/i18n/{lang}/i18n.jsonwwwroot/i18n_getmsgs.dspy(bricks.js 默认调用此端点)
PopupWindow 与子控件 binds
关键发现:binds 放在 PopupWindow 的 subwidgets 内部(如 Tree/VScrollPanel 上)不会被注册。Bricks 的 widgetBuild 流程在处理 PopupWindow 时不会递归注册子控件的 bind。
正确做法:binds 放在 PopupWindow 顶层,用 wid 指定目标 widget id:
{
"widgettype": "PopupWindow",
"options": {"title": "...", "auto_open": true},
"subwidgets": [
{"widgettype": "Tree", "id": "ws_tree", "options": {...}},
{"widgettype": "VScrollPanel", "id": "ws_files", "options": {...}}
],
"binds": [{
"wid": "ws_tree",
"event": "selected",
"actiontype": "urlwidget",
"target": "ws_files",
"options": {
"url": ".../api/workspace_files.dspy",
"event_params": ["user_data.id"]
}
}]
}
event_params 从事件数据(TreeNode)中提取字段作为 URL 参数。TreeNode 的节点数据在 user_data 中,所以用 ["user_data.id"]。
buildScriptHandler 中 this 不是 widget
Bricks 的 buildScriptHandler 用 new AsyncFunction('params', 'event', script) 包装脚本,this 不是触发事件的 widget。要用 bricks.getWidgetById(id, bricks.app) 或闭包变量获取 widget 引用。
// 错误:this 不指向 Tree widget
var nid = this.selected_node.user_data.id;
// 正确
var tree = bricks.getWidgetById('ws_tree', bricks.app);
var nid = tree.selected_node.user_data.id;
Tree 控件参数
{
"widgettype": "Tree",
"options": {
"dataurl": "...", // 使用 entire_url()
"textField": "label", // 显示字段名(不是 valueField)
"idField": "id", // 节点id字段(默认已是 'id')
"cfontsize": 1.0, // 字体大小
"css": "filler",
"cheight": "100%"
}
}
textField— 节点显示文本的字段名(NOTvalueField)idField— 节点唯一标识的字段名(NOTvalueField,默认'id')- 没有
parentField— 父子关系通过数据中的parentid字段建立 cfontsize— 控制节点文字大小
Tree 数据格式
两种方式:
全量返回(简单,适合目录树)
DSPY 返回所有节点一次性数组,每个节点含 {id, parentid, label, is_leaf}:
return [
{"id": "__root__", "parentid": "", "label": "📁 项目", "is_leaf": False},
{"id": "apps", "parentid": "__root__", "label": "📁 apps", "is_leaf": False},
...
]
Tree 内部根据 parentid 自动建立层级关系。无需懒加载,所有节点一次性展开。
懒加载(适合大数据量)
Tree 初次请求无 id 参数,展开节点时发送 id=<父节点id>。DSPY 根据 id 返回子节点:
node_id = (params_kw or {}).get('id', '').strip()
if not node_id:
# 初始:只返回根节点
return [{"id": "__root__", ...}]
# 展开:返回该目录的直接子节点
return [{"id": "apps", "parentid": node_id, ...}, ...]
注意:展开根节点时 Tree 发送 id=__root__,需要特殊处理映射到实际根目录。
POPUPWINDOW 内容渲染
PopupWindow 的 content_w(Layout, class='flexbox')和 content_box(VBox, class='resizebox')是不同的 DOM 元素。子控件添加到 content_w(flexbox),但浏览器中可见的 resizebox 是另一个独立元素。PopWindow 自动处理这个结构,创建时不要手动操作 resizebox。
DSPY entire_url() 铁律
所有 dataurl、url 属性在 DSPY 中必须使用 entire_url() 包装:
# 正确
"dataurl": entire_url("/pipeline-sdlc/api/workspace_tree.dspy")
# 错误(第二次重犯)
"dataurl": "/pipeline-sdlc/api/workspace_tree.dspy"
没有 entire_url() 会导致 URL 缺少域名前缀(即使用 json.dumps 前后一致),相对路径在 PopupWindow 中会解析错误。
弹窗关闭残留
浏览器测试时多次打开 PopupWindow 会导致前一个弹窗的 DOM 残留和事件处理器污染新弹窗。测试前必须关闭所有弹窗:
document.querySelectorAll('.popup').forEach(function(p) { p.remove(); });
工作空间典型模式
HBox + VBox(Tree) + VScrollPanel 的标准工作空间布局(参考 references/workspace-popup-pattern.md):