--- name: bricks-layout-patterns description: Bricks布局/Menu/DSPY/RBAC模式——PCCS踩坑实录,避免下一个应用重复犯错 version: 1.0.0 tags: [bricks, menu, dspy, rbac, layout, pccs] --- # Bricks 布局与开发模式 从 PCCS 部署实战中提炼的通用模式,适用于所有 Sage/Bricks 应用。 --- ## Menu 控件(不是 Tree) 参考 Sage `global_menu.ui`。 **格式**: ```json {"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`: ```python # 错误 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 空值: ```python 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`): ```python 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 双重校验**: ```sql ALTER TABLE x ADD UNIQUE KEY uk_node_id (node_id); ``` DSPY 里先 `sor.R` 查存在性再插入,报友好错误。 ### CRUD 桩代码检测 ```bash grep -rl "'status': 'ok'" wwwroot/api/ | xargs grep -L "sor\.\(C\|U\|D\)" ``` 桩代码只返回 `{'status':'ok','message':'created'}` 未操作数据库。 --- ## Header 标准布局 参照 Sage `index.ui` header: ``` [Logo 品牌名] ... Filler ... [🌓主题] [语言切换] [👤用户面板] ``` ```json {"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 用: ```javascript "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 方法): ```javascript 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:{}}`: ```python return {'widgettype': 'Text', 'options': {'text': '统计信息...', 'cfontsize': 0.9}} ``` ### ⚠️ Text 控件用 `text` 不是 `otext`(PCCS 实锤踩坑) Bricks `Text.set_attrs()` 渲染的是 `this.text`,不是 `this.otext`: ```javascript // 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 三步走 1. 每模块 `i18n/{zh,en}/msg.txt`(格式:`原文: 译文`) 2. `merge_i18n.py` 合并 → `wwwroot/i18n/{lang}/i18n.json` 3. `wwwroot/i18n_getmsgs.dspy`(bricks.js 默认调用此端点) --- ## PopupWindow 与子控件 binds **关键发现**:`binds` 放在 PopupWindow 的 `subwidgets` 内部(如 Tree/VScrollPanel 上)**不会被注册**。Bricks 的 `widgetBuild` 流程在处理 PopupWindow 时不会递归注册子控件的 bind。 **正确做法**:binds 放在 PopupWindow 顶层,用 `wid` 指定目标 widget id: ```json { "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 引用。 ```javascript // 错误: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 控件参数 ```json { "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}`: ```python 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` 返回子节点: ```python 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()` 包装: ```python # 正确 "dataurl": entire_url("/pipeline-sdlc/api/workspace_tree.dspy") # 错误(第二次重犯) "dataurl": "/pipeline-sdlc/api/workspace_tree.dspy" ``` 没有 `entire_url()` 会导致 URL 缺少域名前缀(即使用 `json.dumps` 前后一致),相对路径在 PopupWindow 中会解析错误。 ## 弹窗关闭残留 浏览器测试时多次打开 PopupWindow 会导致前一个弹窗的 DOM 残留和事件处理器污染新弹窗。测试前必须关闭所有弹窗: ```javascript document.querySelectorAll('.popup').forEach(function(p) { p.remove(); }); ``` ## 工作空间典型模式 HBox + VBox(Tree) + VScrollPanel 的标准工作空间布局(参考 `references/workspace-popup-pattern.md`):