12 KiB
Raw Blame History

name description version tags
bricks-layout-patterns Bricks布局/Menu/DSPY/RBAC模式——PCCS踩坑实录避免下一个应用重复犯错 1.0.0
bricks
menu
dspy
rbac
layout
pccs

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"}
        ]}
    ]
}}

关键规则

  1. 子菜单用 "items",不是 "children"
  2. target 必须用 "app.xxx" 前缀——bricks.getWidgetById 基于 DOM closest/querySelector,从 Menu 出发找不到兄弟节点。app.xxx 从 body 级搜索
  3. target id 放在纯内容容器上VScrollPanel不要放在包裹 sidebar+content 的 HBox 上,否则菜单点击会覆盖 sidebar
  4. 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)asynci18n/menu.ui 的菜单项 script 用:

"script": "bricks.app.change_language('zh')"   // 或 'en'

不要用 Sage 的 this.change_language('zh')——那是 Menu widget 方法PCCS 的 bricks.js 版本没有。

主题切换theme_toggle_btndata-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.uimenu.uii18n_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 不是 otextPCCS 实锤踩坑)

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 卡片,optionstext 字段。


i18n 三步走

  1. 每模块 i18n/{zh,en}/msg.txt(格式:原文: 译文
  2. merge_i18n.py 合并 → wwwroot/i18n/{lang}/i18n.json
  3. wwwroot/i18n_getmsgs.dspybricks.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 的 buildScriptHandlernew 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 — 节点显示文本的字段名NOT valueField
  • idField — 节点唯一标识的字段名NOT valueField,默认 '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_wLayout, class='flexbox')和 content_boxVBox, class='resizebox')是不同的 DOM 元素。子控件添加到 content_wflexbox但浏览器中可见的 resizebox 是另一个独立元素。PopWindow 自动处理这个结构,创建时不要手动操作 resizebox。

DSPY entire_url() 铁律

所有 dataurlurl 属性在 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