--- name: sage-bricks-app-patterns description: "Use when building Sage Bricks apps. Copy Sage, don't invent." version: 1.0.0 tags: [sage, bricks, rbac, permissions, login, index.ui] --- # Sage Bricks App Patterns Standard patterns for building Sage-platform web applications. Reference: Sage (元境) at `~/work/repos/sage`. ## Golden Rule Look at Sage first. Before inventing anything, check how Sage does it. Copy the pattern. ## Index Page (index.ui) Bricks `.ui` page, NOT static HTML. Pattern (copied from Sage `~/work/repos/sage/wwwroot/index.ui`): ``` VBox (root, height:100%) ├── HBox (header, 48px) — brand, Filler, theme button, language urlwidget, user_panel urlwidget ├── HBox (main, css:filler) │ ├── VBox (sidebar, dark bg, 220px) — Menu widget │ └── VScrollPanel (id:xxx_main_content, css:filler) — urlwidget for dashboard ``` ### Header 三要素(参照 Sage) ```json // Header HBox subwidgets: [ {"widgettype":"HBox","subwidgets":[brand text + subtitle]}, {"widgettype":"Filler"}, {"widgettype":"Button","id":"theme_toggle_btn","options":{"label":"🌓","width":"36px","height":"36px"}}, {"widgettype":"urlwidget","options":{"url":"{{entire_url('i18n/language.ui')}}"}}, {"widgettype":"urlwidget","options":{"url":"{{entire_url('/rbac/user/user_panel.ui')}}"}} ] ``` Key: `user_panel.ui` 自动处理 登录/注册/用户信息 三种状态。 ### Menu 控件(不要用 Tree) **强制使用 `widgettype: "Menu"`**,参照 Sage `global_menu.ui`: ```json {"widgettype": "Menu", "id": "xxx_menu", "options": { "items": [ {"name": "dashboard", "label": "📊 概览", "icon": "", "url": "{{entire_url('/overview.ui')}}", "target": "app.xxx_main_content"}, {"name": "group", "label": "📦 分组", "icon": "", "items": [ {"name": "sub1", "label": "子项1", "url": "{{entire_url('/list/index.ui')}}", "target": "app.xxx_main_content"} ]} ] }} ``` **Menu target 三定律**: 1. **必须用 `app.xxx_main_content`** — `bricks.getWidgetById` 基于 DOM `closest/querySelector`,找不到兄弟节点,只能从 body 级别搜索 2. **target id 放 VScrollPanel 上**(不能放 HBox 父容器,否则清空时会连带 sidebar 一起删掉) 3. **`icon` 不能用 emoji** — `"icon":"📊"` 会被 bricks 当作 URL 请求 `/📊`,触发 401。设为 `""` ## RBAC Permissions Use two scripts (ref Sage): 1. `set_role_perm.py` — idempotent: `python set_role_perm.py ` 2. `setup_rbac_perms.sh` — calls set_role_perm.py for all paths in tiers ### Permission Tiers (严格边界) | 层级 | 角色 | 范围 | |------|------|------| | 公开 | `any` | stats API, index.ui, menu.ui, 静态资源, login/register | | 登录 | `logined` | 所有 get_*.dspy 数据查询, CRUD 写操作 | **铁律**:`any` 只能放只读操作(stats/get/list/index/menu),绝不可放 create/update/delete/deploy/allocate/release。 ### CRITICAL: Wildcard Paths 略...(保留原内容) ### Redis Cache After DB permission changes: `redis-cli FLUSHDB` then restart service. RBAC 缓存不改不会生效。 Permissions for directories MUST use `/**`: ``` WRONG: /bricks/ → exact match only RIGHT: /bricks/** → matches all sub-paths ``` RBAC only does wildcard matching when path ends with `**` or `%`. Trailing slash alone does NOT work as prefix match. ### Redis Cache After DB permission changes: `redis-cli KEYS "rbac*" | xargs redis-cli DEL` then restart service. `redis-cli FLUSHDB` also works but wipes ALL caches (safe on dev). ## Login WRONG: custom HTML modal, iframe, custom doLogin() RIGHT: urlwidget → user_panel.ui (handles everything automatically) ## 用户菜单(user_menu.ui / usermenu.ui) 用户头像菜单(👤)的菜单项聚合机制(参照 Sage): - **应用级** `wwwroot/user_menu.ui`(带下划线):用户菜单的聚合入口,Sage 平台自动收集各模块的 usermenu.ui 合并展示。 - **模块级** `wwwroot/usermenu.ui`(无下划线):各模块在**自己 wwwroot** 下放这个文件,声明要加入用户菜单的菜单项。 即:应用放 `user_menu.ui`,模块想往用户菜单加项就在模块 wwwroot 放 `usermenu.ui`。实测存在:`sage/wwwroot/user_menu.ui`(应用级)+ `accounting|rbac|unipay/wwwroot/usermenu.ui`(模块级)。**不要直接改 rbac 的 usermenu.ui**——rbac 是通用模块,所有应用共用。 ## Build Script ```bash # deploy index.ui cp -f "${PKGDIR}/pccs/wwwroot/index.ui" "${CDIR}/wwwroot/index.ui" # run permission setup bash "${CDIR}/setup_rbac_perms.sh" ``` ## Pitfalls 1. **bricks.js 401** → permission needs `/**` wildcard + `any` role 2. **Login page redirects** → check `/rbac/user/*` files have `any` 3. **401 loop** → check `/appbase/menu.ui`, `/rbac/user/user_panel.ui` have `any` 4. **Still shows static HTML** → config `indexes: ["index.ui", "index.html"]` 5. **Don't reinvent** → check Sage's index.ui and top.ui first 6. **Menu emoji 401** → `icon` 不能用 emoji(会被当成 URL 请求 `/📊`),设为 `""` 7. **CRUD 工具栏消失** → `"tools": []` 空数组覆盖默认按钮,删除此字段 8. **免登录可 CRUD** → `any` 权限只能给 stats/index.ui/menu.ui,get_*.dspy 和 CRUD 必须 `logined` 9. **DSPY f-string** → 禁止!用字符串拼接:`'text: ' + str(n)` 10. **DSPY 桩代码** → `grep -rl "'status': 'ok'" pkgs/*/wwwroot/api/` 检测假实现 11. **admin orgid=null** → `get_userorgid()` 返回 null → DSPY 拒绝访问 12. **改 RBAC 不生效** → `redis-cli FLUSHDB` + 重启服务(RBAC 有 Redis 缓存) 13. **Menu target 覆盖全页** → target id 必须在 VScrollPanel 上,不能在 HBox 父容器 14. **Text 控件用 `text` 不用 `otext`(非 i18n)** → `set_attrs()` 渲染 `this.text`;`otext` 只在 `i18n:true` 时经翻译转成 text。DSPY 动态返回 widget JSON 时写 `{'text': '值'}`,写 `{'otext': ...}` 渲染空白 15. **语言切换方法** → `bricks.app.change_language('zh')`(本版本在 app 上,NOT Sage 的 `this.change_language` 在 Menu 上)。备用:`bricks.app.i18n.change_lang()`. 语言 menu.ui 的 Menu item `script: "bricks.app.change_language('zh')"` 16. **概览/stats 动态页面** → 用一个 DSPY 直接返回完整 widget 树(HBox/VBox 卡片),不要在 .ui 里用 urlwidget 加载子 DSPY(子 DSPY 返回的 widget JSON 子内容渲染空白) 17. **rbac 是通用模块,不放应用专属能力** → rbac 的 usermenu.ui/userinfo.ui 等是 Sage 平台通用模块(所有应用共用),不要往里塞某个应用专属的菜单项/能力(如"绑定微信")。应用专属入口放应用自己的主菜单(sidebar)或模块自己的 usermenu.ui。 ## 概览 stats DSPY 模式 单个 DSPY 查所有模块数据,返回卡片树: ```python def card(title, value, subtitle, bgcolor, color): return {'widgettype':'VBox','options':{'bgcolor':bgcolor,'padding':'16px','width':'25%','border':'1px solid '+color,'borderRadius':'8px'}, 'subwidgets':[ {'widgettype':'Text','options':{'text':title,'cfontsize':0.7,'color':'#64748b'}}, {'widgettype':'Text','options':{'text':value,'cfontsize':2,'fontWeight':'bold','color':color}}, {'widgettype':'Text','options':{'text':subtitle,'cfontsize':0.7,'color':'#94a3b8'}} ]} # 资源聚合:总数=SUM(所有子节点),已用=SUM(status='allocated' 的子节点) async with DBPools().sqlorContext(get_module_dbname('pcpool')) as sor: pools = await sor.R('compute_pool', {}) for p in pools: nodes = await sor.R('compute_node', {'pool_id': p.id}) for n in nodes: total_cpu += int(getattr(n,'cpu_cores',0) or 0) if getattr(n,'status','')=='allocated': alloc_cpu += ... return {'widgettype':'VBox','options':{...},'subwidgets':[ ...pool_cards... ]} ``` 关键:模型去掉冗余 total_*/allocated_* 字段,全部从子节点动态聚合。 ## CRUD 子表 (subtables) 父表 CRUD JSON 加 `params.subtables`,生成工具栏按钮,点选后弹窗显示子表(自动过滤外键): ```json "params": { "subtables": [{ "field": "pool_id", // 子表外键字段 "title": "算力节点", "url": "{{entire_url('../compute_node_list')}}", "subtable": "compute_node" }] } ``` 子表 CRUD JSON 加 `data_filter` 让列表可被外键过滤。注意:**`entire_url` 内用单引号**(`'../xxx'`),双引号会被 Jinja2 转义成 `\\\"` 导致 `TemplateSyntaxError`。加了子表后,左侧主菜单不再需要子菜单项。