labull-framework 3.0.8__tar.gz → 3.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {labull_framework-3.0.8/labull_framework.egg-info → labull_framework-3.1.0}/PKG-INFO +7 -5
  2. {labull_framework-3.0.8 → labull_framework-3.1.0}/README-pypi.md +6 -4
  3. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/devkit.py +5 -4
  4. {labull_framework-3.0.8 → labull_framework-3.1.0/labull_framework.egg-info}/PKG-INFO +7 -5
  5. {labull_framework-3.0.8 → labull_framework-3.1.0}/pyproject.toml +1 -1
  6. {labull_framework-3.0.8 → labull_framework-3.1.0}/LICENSE +0 -0
  7. {labull_framework-3.0.8 → labull_framework-3.1.0}/MANIFEST.in +0 -0
  8. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/__init__.py +0 -0
  9. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/access.py +0 -0
  10. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/admin.py +0 -0
  11. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/apps.py +0 -0
  12. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/auth/__init__.py +0 -0
  13. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/auth/backend.py +0 -0
  14. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/auth/bootstrap.py +0 -0
  15. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/auth/sso.py +0 -0
  16. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/auth/views.py +0 -0
  17. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/canvas/__init__.py +0 -0
  18. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/canvas/data.py +0 -0
  19. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/canvas/gitinfo.py +0 -0
  20. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/canvas/shape_ast.py +0 -0
  21. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/canvas/static/canvas.css +0 -0
  22. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/canvas/static/canvas.js +0 -0
  23. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/canvas/static/page.html +0 -0
  24. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/canvas/views.py +0 -0
  25. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/checks.py +0 -0
  26. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/env.py +0 -0
  27. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/flow.py +0 -0
  28. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/jsonio.py +0 -0
  29. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/__init__.py +0 -0
  30. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/blockcheck.py +0 -0
  31. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/blockpackage.py +0 -0
  32. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/blocktypes.py +0 -0
  33. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/deepcheck.py +0 -0
  34. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/dispatch.py +0 -0
  35. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/export.py +0 -0
  36. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/health.py +0 -0
  37. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/manifest.py +0 -0
  38. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/parallel.py +0 -0
  39. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/registry.py +0 -0
  40. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/requires.py +0 -0
  41. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/sdk.py +0 -0
  42. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/service.py +0 -0
  43. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/subproc.py +0 -0
  44. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/types/__init__.py +0 -0
  45. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/types/plain.py +0 -0
  46. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/types/workflow.py +0 -0
  47. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/validate.py +0 -0
  48. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/magic_block/views.py +0 -0
  49. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/management/__init__.py +0 -0
  50. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/management/commands/__init__.py +0 -0
  51. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/management/commands/labull_access.py +0 -0
  52. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/management/commands/labull_blocktype.py +0 -0
  53. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/management/commands/labull_contract.py +0 -0
  54. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/management/commands/labull_requires.py +0 -0
  55. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/migrations/0001_initial.py +0 -0
  56. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/migrations/0002_remove_magicblock_prev_files_and_more.py +0 -0
  57. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/migrations/0003_magicblock_tags.py +0 -0
  58. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/migrations/0004_magicblock_detail.py +0 -0
  59. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/migrations/0005_labullrole.py +0 -0
  60. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/migrations/0006_default_groups_permissions.py +0 -0
  61. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/migrations/0007_no_builtin_permissions.py +0 -0
  62. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/migrations/__init__.py +0 -0
  63. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/models.py +0 -0
  64. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/permissions.py +0 -0
  65. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/settings.py +0 -0
  66. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/urls.py +0 -0
  67. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework/virtual.py +0 -0
  68. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework.egg-info/SOURCES.txt +0 -0
  69. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework.egg-info/dependency_links.txt +0 -0
  70. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework.egg-info/requires.txt +0 -0
  71. {labull_framework-3.0.8 → labull_framework-3.1.0}/labull_framework.egg-info/top_level.txt +0 -0
  72. {labull_framework-3.0.8 → labull_framework-3.1.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 3.0.8
3
+ Version: 3.1.0
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -142,10 +142,12 @@ class Order:
142
142
 
143
143
  配置:环境变量里**带 `BLOCK_` 前缀**的才会进 `ctx.config`(`BLOCK_OCR_KEY=a` ⇒ `ctx.config['OCR_KEY']`)。
144
144
 
145
- 🔴 **弹窗走平台**:块里 `await magicBlock.dialog({ title, body, fields, buttons })` —— 由宿主页面渲染
146
- (前端要挂一次 `<MagicBlockDialogs />`)。块自己画的浮层**摆不正**:块可以是好几屏高,块里
147
- `position: fixed` / `100vh` 的基准是"整块高度"而不是用户那一屏。要自己写整篇就传 `{ html }`,
148
- 宿主给一个确定大小的面板 + 另一格沙箱 iframe 装它,里面用 `magicBlock.done({ button, values })` 交回选择。
145
+ 🔴 **弹窗走平台**:块里 `await magicBlock.dialog({ file: 'dialog/close-order.html' })` —— 那一篇是块包里的
146
+ 一个普通 `.html`(结构与脚本都在里面),由宿主页面在真视口里渲染(前端要挂一次 `<MagicBlockDialogs />`)。
147
+ 块自己画的浮层**摆不正**:块可以是好几屏高,块里 `position: fixed` / `100vh` 的基准是"整块高度"而不是那一屏。
148
+ 要给这一次弹窗传参就 `{ data: {…} }`(那一篇里 `await magicBlock.data()`),结果由那一篇
149
+ `magicBlock.done({ button, values })` 交回;临时 / 极短的一篇也可以 `{ html: '…' }` 直接给一串。
150
+ 尺寸、滚动、滚轮全归那一篇写(不给 `size.height` 就按内容定高)。
149
151
 
150
152
  ## 虚拟类(`@virtual_class`)
151
153
 
@@ -114,10 +114,12 @@ class Order:
114
114
 
115
115
  配置:环境变量里**带 `BLOCK_` 前缀**的才会进 `ctx.config`(`BLOCK_OCR_KEY=a` ⇒ `ctx.config['OCR_KEY']`)。
116
116
 
117
- 🔴 **弹窗走平台**:块里 `await magicBlock.dialog({ title, body, fields, buttons })` —— 由宿主页面渲染
118
- (前端要挂一次 `<MagicBlockDialogs />`)。块自己画的浮层**摆不正**:块可以是好几屏高,块里
119
- `position: fixed` / `100vh` 的基准是"整块高度"而不是用户那一屏。要自己写整篇就传 `{ html }`,
120
- 宿主给一个确定大小的面板 + 另一格沙箱 iframe 装它,里面用 `magicBlock.done({ button, values })` 交回选择。
117
+ 🔴 **弹窗走平台**:块里 `await magicBlock.dialog({ file: 'dialog/close-order.html' })` —— 那一篇是块包里的
118
+ 一个普通 `.html`(结构与脚本都在里面),由宿主页面在真视口里渲染(前端要挂一次 `<MagicBlockDialogs />`)。
119
+ 块自己画的浮层**摆不正**:块可以是好几屏高,块里 `position: fixed` / `100vh` 的基准是"整块高度"而不是那一屏。
120
+ 要给这一次弹窗传参就 `{ data: {…} }`(那一篇里 `await magicBlock.data()`),结果由那一篇
121
+ `magicBlock.done({ button, values })` 交回;临时 / 极短的一篇也可以 `{ html: '…' }` 直接给一串。
122
+ 尺寸、滚动、滚轮全归那一篇写(不给 `size.height` 就按内容定高)。
121
123
 
122
124
  ## 虚拟类(`@virtual_class`)
123
125
 
@@ -745,8 +745,9 @@ EXAMPLE_FLOW_FILES: tuple[tuple[str, str], ...] = (
745
745
  ('README.md', '# 流程块(`type: workflow`)—— 这一类块怎么写\n\n这一份是**类型级**的说明,同一份正文铺在两个地方:\n`workspace/workflow/README.md`(你放流程块的那一层)与 `example/workflow/README.md`(样例那一层)。\n\n`example/workflow/workspace/报销流程-<uuid>/` 里那一份是**完整样例**:整个文件夹搬进\n`workspace/workflow/` 就是一个块(搬之前把文件夹名末尾的 uuid 与清单里的 `id` 换成你自己的)。\n\n它比普通块多两个文件:\n\n| 文件 | 是什么 |\n|---|---|\n| `flow.py` | **这条流程的唯一权威**:状态表 / 动作 / 谁能办 / 每个节点的内容 key / 业务钩子 |\n| `flow.js` | 前端**内容渲染器登记表**(`MagicSteps.register(\'<ui>\', fn)`),与 `flow.py` 双向对齐 |\n\n`flow.open.yaml` 是导出时铺的**说明性草稿**(从 `flow.py` 生成,给人对照用)—— 它不进清单、不影响保存。\n\n## 三处"读一眼就懂"的地方\n\n1. **状态表**:`flow.py` 里那一个 `Flow(...)`,加节点就是加一个 `Step(...)`;\n2. **推进**:块后端一行 `advance(flow_of(\'reimbursement\'), row, params[\'action\'], params, ctx)` ——\n 状态校验 / 权限闸门 / 必填意见 / 日志 / 刷处理人 / 报文全在平台;\n3. **内容**:`index.open.html` 的壳不变,只有 `#slot` 随报文里的 `step.ui` 换。\n\n## 这一版多给几种表达力(样例 `flow.py` 里能看到前两条)\n\n| 写法 | 解决什么 |\n|---|---|\n| `permission:canvasdemo.review_reimbursement` / `confirm_permissions=(\'canvasdemo.pay_reimbursement\',)` | 按**权限**认人(权限声明在模型的 `Meta.permissions` 里,`migrate` 之后就有;组只是权限包,`flow.py` 不认组名) |\n| `Step(..., moves_by={\'accounting\': (\'approve\', \'reject\')})` | 同一个状态、**不同单据类型给不同动作集**(列由 `view_field` 给) |\n| `candidates=\'biz:owner,approver\'` | 一步的候选人是**单据上指名的几个人**(多字段取并集) |\n| `Step(..., assignees=\'biz:applicant\')` | **谁能点**(`candidates`)与**待办挂给谁**(`assignees`)分开;不写 = 跟 `candidates` 一样 |\n| `Step(..., legacy=True)` | 登记只存在于**历史数据**里的终态(不参与 D7;存量行打开不 500) |\n| `Move(..., after=...)` | 推进**之后**的副作用(写生效时间 / 解绑关联单据),同一次事务里 |\n| `Flow(handler_kind=\'m2m\')` | 处理人是一组人时(多对多),推进后写入**全部**候选人 |\n| `Flow(log_entry=...)` | 项目已有自己的日志形状时,替换平台默认的那一条 |\n| `Flow(resolve=...)` | 候选人求值的书面出口(⚠️ 跨块比对不比它:两份副本各写一个不会被报出来) |\n\n⚠️ 这是示例:业务表是 `canvasdemo.Reimbursement`(示例项目里那张),换项目就换 `flow.py` / `backend_open.py` 里那两处 import。\n'),
746
746
  ('README.open.md', "# 报销流程\n\n一张报销单从**草稿**到**付款**:申请人填、提交,财务 / 出纳(或申请人自己)审批,通过则付款,退回要写意见。\n\n- **谁能用**:填单人(申请人)与财务 / 出纳(权限 `canvasdemo.review_reimbursement` / `canvasdemo.pay_reimbursement`,\n 由示例项目在自己的 `Reimbursement.Meta.permissions` 里声明、`migrate` 之后就有)。\n- **怎么用**:左边「新建一张草稿单」→ 改事由与金额 →「保存」→「提交」→「通过 / 退回 / 撤回重填」。\n- **边界**:这是**示例**,业务表是 `canvasdemo.Reimbursement`(换项目就换 `flow.py` 与 `backend_open.py` 里那两处 import);\n 钱的真实动作(付款流水、发票)不在这里。\n\n## 这个例子示范了什么(流程块的要点)\n\n| 要点 | 在哪一行 |\n|---|---|\n| 流程表只有一处 | `flow.py` 的 `FLOW = register(Flow(...))` |\n| 一个 `ui` 被多个节点复用 | `draft` 与 `rejected` 都用 `ui='bill'` |\n| 一个 `ui_by` 分叉 | `pending` 的 `ui_by={'accounting': 'approve-zero'}`(列是 `view_field='bill_type'`) |\n| 一个 `moves_by` 动作集分叉 | `pending` 的 `moves_by={'accounting': ('approve', 'reject')}` —— 记账报销没有「撤回重填」 |\n| 「谁能点」与「待办挂给谁」分开 | `pending` 的 `candidates='applicant_or_permissions:canvasdemo.review_reimbursement,canvasdemo.pay_reimbursement'` + `assignees='biz:applicant'` |\n| 按**权限**认人(不认组名) | 权限码在示例项目的 `Reimbursement.Meta.permissions` 里;组只是权限包(`财务` / `出纳` 两个包由 `canvasdemo/0009` 建好) |\n| 谁的点击才算数 | `pending` 的「通过」写了 `confirm_permissions=('canvasdemo.pay_reimbursement',)` —— 没有那个权限的人点了只留痕、不推进 |\n| 必填意见的退回 + 两个终态 | `Move('reject', …, requires_comment=True)`、`paid` / `rejected` |\n| 「保存不推进」 | `draft` 的 `form=keep_title` + `editable=('title','amount')` |\n| 前端按 key 渲染 | `flow.js` 里四行 `MagicSteps.register(...)`,`app.js` 里那四个渲染器 |\n\n🔴 前端登记的 `ui` 与 `flow.py` 用到的 `ui` 是**双向**判据(D16/D17):少一个会在保存时被拦(白屏之前先被拦),\n多一个也会被拦(死代码)。\n"),
747
747
  ('app.css', '/* 报销流程示例块的样式(宿主会把这份内联进 iframe)。\n *\n * 设计口径(与仓库里那些真实业务块一致):\n * · 只用主题给的类与 token:`.labull-card` / `.labull-subject-title` / `.labull-steps` / `--sp-*`;\n * · 底色用 `--sp-color-surface`(那一层浅灰蓝),内容是白卡片 —— 与宿主那一页同一个观感;\n * · 步骤条 `.labull-steps` 一行样式都不写:桌面横向、窄屏自动纵向、≤480px 节点自动缩到 28px(主题里)。\n *\n * 🔴 手机端这一份是**真的**媒体查询,不是"缩一缩":块 iframe 的宽度就是手机宽度,\n * `@media (max-width: 768px)` 在这里照常生效(与 `app.css` 那一档同一个断点)。\n */\nbody {\n margin: 0;\n padding: 16px 20px 24px;\n font: var(--sp-font-md, 14px) / var(--sp-line-height-body, 1.5) var(--sp-font-family, system-ui, sans-serif);\n color: var(--sp-color-text, #2e3440);\n background: var(--sp-color-surface, #eceff4);\n}\n\n.wf { display: flex; flex-direction: column; gap: var(--sp-space-3, 12px); }\n\n/* ── 抬头 ─────────────────────────────────────────────────────────────── */\n.wf-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; min-height: 36px; }\n.wf-head .labull-subject-title { display: inline-flex; align-items: center; gap: 6px; margin: 0; font-size: var(--sp-font-lg, 18px); }\n.wf-no { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: var(--sp-font-md, 14px); color: var(--sp-color-text, #2e3440); }\n.wf-muted { color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n\n/* 状态药丸:颜色按状态走(不是按文字猜),与表单里那几处一致 */\n.wf-pill {\n display: inline-flex;\n align-items: center;\n height: 22px;\n padding: 0 8px;\n border-radius: var(--sp-radius-pill, 999px);\n font-size: var(--sp-font-xs, 12px);\n font-weight: var(--sp-font-weight-strong, 600);\n background: var(--sp-color-active, #d8e1eb);\n color: var(--sp-color-primary, #5e81ac);\n}\n.wf-pill[data-status=\'pending\'] { background: #fdf1dc; color: var(--sp-color-warn, #d98b00); }\n.wf-pill[data-status=\'paid\'] { background: #dff3e6; color: var(--sp-color-success, #2e9e5b); }\n.wf-pill[data-status=\'rejected\'] { background: #fbe5e6; color: var(--sp-color-danger, #bf616a); }\n\n/* ── 步骤条那一张卡片 ─────────────────────────────────────────────────── */\n.wf-steps { padding: 6px 12px; }\n.wf-steps #steps { width: 100%; }\n\n/* ── 内容槽 + 卡片里的排版 ───────────────────────────────────────────── */\n.wf-slot { display: flex; flex-direction: column; gap: var(--sp-space-3, 12px); }\n.wf-card-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }\n.wf-card-head .pi { color: var(--sp-color-primary, #5e81ac); }\n.wf-card-title { margin: 0; font-size: var(--sp-font-md, 14px); font-weight: var(--sp-font-weight-strong, 600); }\n\n/* 一行「标签 + 值/控件」:只读的标签贴着内容(`max-content`),输入框那几行标签列定宽、控件那列最多 220px\n (与示例抬头那种"一眼扫完"的密度一致;长文本仍然在值那一列折行,不把整行撑开)。 */\n.wf-kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: center; gap: 4px 12px; }\n.wf-kv.wf-field { grid-template-columns: 64px minmax(0, 220px); gap: 8px 12px; }\n.wf-kv-label { color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); white-space: nowrap; }\n.wf-kv-value { min-width: 0; overflow-wrap: anywhere; }\n.wf-kv-value .p-inputtext { width: 100%; box-sizing: border-box; }\n.wf-kv-value .wf-num { font-variant-numeric: tabular-nums; }\n\n.wf-hint { margin: 0; color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n.wf-note {\n margin: 0;\n padding: 8px 10px;\n border-left: 3px solid var(--sp-color-primary, #5e81ac);\n border-radius: var(--sp-radius-sm, 4px);\n background: var(--sp-color-active, #d8e1eb);\n font-size: var(--sp-font-sm, 13px);\n}\n.wf-empty { color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n\n/* ── 动作条 + 提示 ───────────────────────────────────────────────────── */\n.wf-actions { display: flex; flex-direction: column; gap: 6px; }\n.wf-actions #actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; min-height: 36px; }\n.wf-actions #actions:empty::before { content: \'这一步没有可点的动作\'; color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n.wf-tip { margin: 0; min-height: 18px; color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n.wf-tip.is-bad { color: var(--sp-color-danger, #bf616a); }\n\n/* ── 切换单子 ────────────────────────────────────────────────────────── */\n.wf-bills { color: var(--sp-color-text-muted, #4c566a); font-size: var(--sp-font-sm, 13px); }\n.wf-bills > summary { cursor: pointer; width: fit-content; padding: 2px 0; }\n.wf-billlist { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 0 0; }\n.wf-billlist button {\n padding: 4px 10px;\n border: var(--sp-border-width, 1px) solid var(--sp-color-border, #e5e9f0);\n border-radius: var(--sp-radius-sm, 4px);\n background: var(--sp-color-surface-raised, #fff);\n color: var(--sp-color-text, #2e3440);\n font-size: var(--sp-font-sm, 13px);\n cursor: pointer;\n}\n.wf-billlist button:hover { border-color: var(--sp-color-primary, #5e81ac); color: var(--sp-color-primary, #5e81ac); }\n\n/* ── 手机:抬头收成两行、卡片贴边、动作条铺满(手指点得到) ───────────── */\n@media (max-width: 768px) {\n body { padding: 10px 12px 16px; }\n .wf { gap: 10px; }\n .wf-head { gap: 6px 8px; }\n .wf-head .labull-subject-title { font-size: var(--sp-font-md, 14px); }\n .wf-head .wf-muted { width: 100%; order: 4; }\n .wf-head #create { margin-left: auto; }\n .wf-steps { padding: 4px 8px; }\n /* 只有"输入框那几行"要定宽标签;只读的键值仍然贴着内容走 */\n .wf-kv.wf-field { grid-template-columns: 56px minmax(0, 1fr); }\n .wf-actions #actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }\n .wf-actions #actions .p-button { width: 100%; min-height: 40px; justify-content: center; }\n .wf-billlist { flex-direction: column; }\n .wf-billlist button { width: 100%; min-height: 40px; text-align: left; }\n}\n\n/* 更窄(≤480):节点那一圈交给主题缩,这里只把内边距再收一点 */\n@media (max-width: 480px) {\n body { padding: 8px 10px 14px; }\n .wf-kv.wf-field { grid-template-columns: 48px minmax(0, 1fr); gap: 6px 10px; }\n}\n'),
748
- ('app.js', '/* 报销流程的驱动 + 四个内容渲染器(**壳不变,只有中间那个内容槽随节点换**)。\n *\n * 🔴 壳(抬头 / 步骤条 / 动作条)永远是同一个:变的只有 `#slot` 里的内容,\n * 而且"换哪一块"由报文里的 `step.ui` 决定 —— 前端**不许**自己映射 status→UI。\n * 🔴 渲染器签名是 `(slot, payload)`:`payload` 就是后端那份报文\n * (`step` / `steps` / `capabilities` / `actions` / `timeline`)。\n * 🔴 这里**不画弹窗**:要人填意见时用 `magicBlock.dialog(...)`,由宿主在真视口里画\n * (块 iframe 的 `position: fixed` 基准是"这一块有多高",自己画的浮层会被摆到看不见的地方)。\n */\n;(function () {\n var api = magicBackendApi.ReimbursementBlock\n var current = null\n\n function esc(value) {\n return String(value == null ? \'\' : value).replace(/[&<>"]/g, function (ch) {\n return { \'&\': \'&amp;\', \'<\': \'&lt;\', \'>\': \'&gt;\', \'"\': \'&quot;\' }[ch]\n })\n }\n\n /** 一张卡片:`head` 是抬头(可带 `?` 右边的东西),`body` 是正文 HTML。 */\n function card(title, icon, body, extra) {\n return (\n \'<section class="labull-card wf-card">\' +\n \'<div class="labull-card__head wf-card-head">\' +\n \'<i class="pi \' + icon + \'"></i><h3 class="labull-card__title wf-card-title">\' + esc(title) + \'</h3>\' +\n (extra || \'\') +\n \'</div>\' +\n \'<div class="labull-card__body">\' + body + \'</div>\' +\n \'</section>\'\n )\n }\n\n /** 一行「标签 + 值」(只读)。 */\n function kv(label, value, extraClass) {\n return (\n \'<div class="wf-kv">\' +\n \'<span class="wf-kv-label">\' + esc(label) + \'</span>\' +\n \'<span class="wf-kv-value \' + (extraClass || \'\') + \'">\' + (value == null || value === \'\' ? \'—\' : esc(value)) + \'</span>\' +\n \'</div>\'\n )\n }\n\n /** 一行「标签 + 输入框」(可编辑的那几列,key 要进 `step.editable` 白名单)。 */\n function field(label, key, value) {\n return (\n \'<div class="wf-kv wf-field">\' +\n \'<span class="wf-kv-label"><label for="wf-\' + esc(key) + \'">\' + esc(label) + \'</label></span>\' +\n \'<span class="wf-kv-value">\' +\n \'<input class="p-inputtext" id="wf-\' + esc(key) + \'" data-field="\' + esc(key) + \'" value="\' + esc(value) + \'">\' +\n \'</span></div>\'\n )\n }\n\n /** 内容 key `bill`:草稿(可编辑 + 保存)与已退回(只读)**共用**这一块。 */\n function bill(slot, payload) {\n var editable = payload.step.editable.length > 0\n var body = editable\n ? field(\'事由\', \'title\', payload.bill.title) + field(\'金额\', \'amount\', payload.bill.amount)\n : kv(\'事由\', payload.bill.title) + kv(\'金额\', payload.bill.amount, \'wf-num\')\n body += \'<p class="wf-hint">\' + (editable\n ? \'改完点下面的「保存」:只保存、不推进(状态还是草稿)。\'\n : \'这张单已经结束,不能再改。\') + \'</p>\'\n slot.innerHTML = card(\'基本信息\', \'pi-info-circle\', body)\n }\n\n /** 内容 key `approve`:待审批(采购报销走这一块)。 */\n function approve(slot, payload) {\n var body =\n kv(\'申请人\', payload.bill.applicant) +\n kv(\'单据类型\', payload.bill.billType === \'accounting\' ? \'记账报销\' : \'采购报销\') +\n kv(\'金额\', payload.bill.amount, \'wf-num\') +\n \'<p class="wf-hint">核对发票与金额后点「通过」;退回要写一句意见(那一步声明了必填意见)。</p>\'\n slot.innerHTML = card(\'审批\', \'pi-check-circle\', body)\n }\n\n /** 内容 key `approve-zero`:同一个节点的另一个内容(`ui_by` 按单据类型分叉,记账报销零输入)。 */\n function approveZero(slot, payload) {\n var body =\n kv(\'单据类型\', \'记账报销\') +\n kv(\'金额\', payload.bill.amount, \'wf-num\') +\n \'<p class="wf-note">记账报销:金额由财务复核,这里<strong>不需要填任何东西</strong>。</p>\' +\n \'<p class="wf-hint">同一个「待审批」节点、两种单据类型是两块不同的内容 —— 这就是 ui + ui_by 的用处。</p>\'\n slot.innerHTML = card(\'审批(记账)\', \'pi-calculator\', body)\n }\n\n /** 内容 key `done`:已付款(终态)。 */\n function done(slot, payload) {\n slot.innerHTML = card(\'结果\', \'pi-check\', kv(\'状态\', payload.step.label) + kv(\'金额\', payload.bill.amount, \'wf-num\') +\n \'<p class="wf-hint">这张单已经处理完,不会再出现在任何人的待办里。</p>\')\n }\n\n window.Renderers = { bill: bill, approve: approve, approveZero: approveZero, done: done }\n\n // ──────────────────────────── 壳(数据 / 渲染 / 动作) ────────────────────────────\n\n function tip(text, bad) {\n var node = document.getElementById(\'tip\')\n node.textContent = text || \'\'\n node.className = \'wf-tip\' + (bad ? \' is-bad\' : \'\')\n }\n\n function paint(data) {\n current = data\n document.getElementById(\'no\').textContent = \'#\' + data.bill.no\n var status = document.getElementById(\'status\')\n status.hidden = false\n status.dataset.status = data.step.code\n status.textContent = data.step.label\n document.getElementById(\'handler\').textContent = data.bill.handler ? \'处理人:\' + data.bill.handler : \'\'\n MagicSteps.renderBar(document.getElementById(\'steps\'), data)\n var slot = document.getElementById(\'slot\')\n slot.innerHTML = \'\'\n MagicSteps.mount(slot, data)\n paintActions(data)\n }\n\n function paintActions(data) {\n var box = document.getElementById(\'actions\')\n box.innerHTML = \'\'\n data.actions.forEach(function (item) {\n if (!item.allowed) return\n var button = document.createElement(\'button\')\n button.type = \'button\'\n button.className = \'p-button p-button-sm\' + (item.kind === \'reject\' ? \' p-button-danger\' : \'\')\n button.textContent = item.label\n button.onclick = function () {\n act(item).catch(function (error) { tip(error.message, true) })\n }\n box.appendChild(button)\n })\n if (data.capabilities.hasForm) {\n var saveButton = document.createElement(\'button\')\n saveButton.type = \'button\'\n saveButton.className = \'p-button p-button-sm p-button-text\'\n saveButton.textContent = \'保存\'\n saveButton.onclick = function () {\n save().catch(function (error) { tip(error.message, true) })\n }\n box.appendChild(saveButton)\n }\n }\n\n /** 推进:要意见的先弹宿主那个窗(`magicBlock.dialog` 由**宿主页面**渲染,位置一定对)。 */\n async function act(item) {\n var params = { id: current.bill.id, action: item.action }\n if (item.needsComment) {\n var answer = await magicBlock.dialog({\n title: \'退回这张单\',\n body: \'退回必须写一句意见(流程图里那一步声明了 requires_comment)。\',\n fields: [{ name: \'comment\', label: \'意见\', type: \'textarea\', required: true }],\n buttons: [{ id: \'ok\', label: \'退回\', primary: true, danger: true }, { id: \'cancel\', label: \'再想想\' }]\n })\n if (answer.button !== \'ok\') return\n params.comment = answer.values.comment\n }\n var data = await api.act(params)\n if (data.applied === false) tip(\'这一下不算数(没有那个权限):只留了一条痕,状态没有推进。\')\n else tip(\'\')\n await open(current.bill.id)\n }\n\n /** 「保存不推进」:只把 `step.editable` 那几列递上去(白名单在平台那一侧再判一次)。 */\n async function save() {\n var params = { id: current.bill.id }\n current.step.editable.forEach(function (name) {\n var input = document.querySelector(\'#slot [data-field="\' + name + \'"]\')\n if (input) params[name] = input.value\n })\n await api.save(params)\n tip(\'已保存(状态没变)\')\n await open(current.bill.id)\n }\n\n async function open(id) {\n paint(await api.detail({ id: id }))\n tip(\'\')\n }\n\n async function list() {\n var data = await api.list({})\n var box = document.getElementById(\'bills\')\n box.innerHTML = \'\'\n data.bills.forEach(function (row) {\n var button = document.createElement(\'button\')\n button.type = \'button\'\n button.textContent = row.no + \' · \' + row.title + \' · \' + row.status\n button.onclick = function () { open(row.id).catch(function (error) { tip(error.message, true) }) }\n box.appendChild(button)\n })\n if (data.bills.length) await open(data.bills[0].id)\n else {\n document.getElementById(\'steps\').innerHTML = \'\'\n document.getElementById(\'slot\').innerHTML =\n \'<section class="labull-card wf-card"><p class="wf-empty">还没有单子:点右上角「新建一张草稿单」,\' +\n \'就能看到「草稿 → 待审批 → 已付款 / 已退回」这条流程。</p></section>\'\n document.getElementById(\'actions\').innerHTML = \'\'\n document.getElementById(\'status\').hidden = true\n tip(\'\')\n }\n }\n\n document.getElementById(\'create\').onclick = function () {\n tip(\'\')\n api.create({}).then(function () { return list() }).catch(function (error) { tip(error.message, true) })\n }\n\n list().catch(function (error) { tip(\'读不出来:\' + error.message, true) })\n})()\n'),
748
+ ('app.js', '/* 报销流程的驱动 + 四个内容渲染器(**壳不变,只有中间那个内容槽随节点换**)。\n *\n * 🔴 壳(抬头 / 步骤条 / 动作条)永远是同一个:变的只有 `#slot` 里的内容,\n * 而且"换哪一块"由报文里的 `step.ui` 决定 —— 前端**不许**自己映射 status→UI。\n * 🔴 渲染器签名是 `(slot, payload)`:`payload` 就是后端那份报文\n * (`step` / `steps` / `capabilities` / `actions` / `timeline`)。\n * 🔴 这里**不画弹窗**:要人填意见时用 `magicBlock.dialog(...)`,由宿主在真视口里画\n * (块 iframe 的 `position: fixed` 基准是"这一块有多高",自己画的浮层会被摆到看不见的地方)。\n */\n;(function () {\n var api = magicBackendApi.ReimbursementBlock\n var current = null\n\n function esc(value) {\n return String(value == null ? \'\' : value).replace(/[&<>"]/g, function (ch) {\n return { \'&\': \'&amp;\', \'<\': \'&lt;\', \'>\': \'&gt;\', \'"\': \'&quot;\' }[ch]\n })\n }\n\n /** 一张卡片:`head` 是抬头(可带 `?` 右边的东西),`body` 是正文 HTML。 */\n function card(title, icon, body, extra) {\n return (\n \'<section class="labull-card wf-card">\' +\n \'<div class="labull-card__head wf-card-head">\' +\n \'<i class="pi \' + icon + \'"></i><h3 class="labull-card__title wf-card-title">\' + esc(title) + \'</h3>\' +\n (extra || \'\') +\n \'</div>\' +\n \'<div class="labull-card__body">\' + body + \'</div>\' +\n \'</section>\'\n )\n }\n\n /** 一行「标签 + 值」(只读)。 */\n function kv(label, value, extraClass) {\n return (\n \'<div class="wf-kv">\' +\n \'<span class="wf-kv-label">\' + esc(label) + \'</span>\' +\n \'<span class="wf-kv-value \' + (extraClass || \'\') + \'">\' + (value == null || value === \'\' ? \'—\' : esc(value)) + \'</span>\' +\n \'</div>\'\n )\n }\n\n /** 一行「标签 + 输入框」(可编辑的那几列,key 要进 `step.editable` 白名单)。 */\n function field(label, key, value) {\n return (\n \'<div class="wf-kv wf-field">\' +\n \'<span class="wf-kv-label"><label for="wf-\' + esc(key) + \'">\' + esc(label) + \'</label></span>\' +\n \'<span class="wf-kv-value">\' +\n \'<input class="p-inputtext" id="wf-\' + esc(key) + \'" data-field="\' + esc(key) + \'" value="\' + esc(value) + \'">\' +\n \'</span></div>\'\n )\n }\n\n /** 内容 key `bill`:草稿(可编辑 + 保存)与已退回(只读)**共用**这一块。 */\n function bill(slot, payload) {\n var editable = payload.step.editable.length > 0\n var body = editable\n ? field(\'事由\', \'title\', payload.bill.title) + field(\'金额\', \'amount\', payload.bill.amount)\n : kv(\'事由\', payload.bill.title) + kv(\'金额\', payload.bill.amount, \'wf-num\')\n body += \'<p class="wf-hint">\' + (editable\n ? \'改完点下面的「保存」:只保存、不推进(状态还是草稿)。\'\n : \'这张单已经结束,不能再改。\') + \'</p>\'\n slot.innerHTML = card(\'基本信息\', \'pi-info-circle\', body)\n }\n\n /** 内容 key `approve`:待审批(采购报销走这一块)。 */\n function approve(slot, payload) {\n var body =\n kv(\'申请人\', payload.bill.applicant) +\n kv(\'单据类型\', payload.bill.billType === \'accounting\' ? \'记账报销\' : \'采购报销\') +\n kv(\'金额\', payload.bill.amount, \'wf-num\') +\n \'<p class="wf-hint">核对发票与金额后点「通过」;退回要写一句意见(那一步声明了必填意见)。</p>\'\n slot.innerHTML = card(\'审批\', \'pi-check-circle\', body)\n }\n\n /** 内容 key `approve-zero`:同一个节点的另一个内容(`ui_by` 按单据类型分叉,记账报销零输入)。 */\n function approveZero(slot, payload) {\n var body =\n kv(\'单据类型\', \'记账报销\') +\n kv(\'金额\', payload.bill.amount, \'wf-num\') +\n \'<p class="wf-note">记账报销:金额由财务复核,这里<strong>不需要填任何东西</strong>。</p>\' +\n \'<p class="wf-hint">同一个「待审批」节点、两种单据类型是两块不同的内容 —— 这就是 ui + ui_by 的用处。</p>\'\n slot.innerHTML = card(\'审批(记账)\', \'pi-calculator\', body)\n }\n\n /** 内容 key `done`:已付款(终态)。 */\n function done(slot, payload) {\n slot.innerHTML = card(\'结果\', \'pi-check\', kv(\'状态\', payload.step.label) + kv(\'金额\', payload.bill.amount, \'wf-num\') +\n \'<p class="wf-hint">这张单已经处理完,不会再出现在任何人的待办里。</p>\')\n }\n\n window.Renderers = { bill: bill, approve: approve, approveZero: approveZero, done: done }\n\n // ──────────────────────────── 壳(数据 / 渲染 / 动作) ────────────────────────────\n\n function tip(text, bad) {\n var node = document.getElementById(\'tip\')\n node.textContent = text || \'\'\n node.className = \'wf-tip\' + (bad ? \' is-bad\' : \'\')\n }\n\n function paint(data) {\n current = data\n document.getElementById(\'no\').textContent = \'#\' + data.bill.no\n var status = document.getElementById(\'status\')\n status.hidden = false\n status.dataset.status = data.step.code\n status.textContent = data.step.label\n document.getElementById(\'handler\').textContent = data.bill.handler ? \'处理人:\' + data.bill.handler : \'\'\n MagicSteps.renderBar(document.getElementById(\'steps\'), data)\n var slot = document.getElementById(\'slot\')\n slot.innerHTML = \'\'\n MagicSteps.mount(slot, data)\n paintActions(data)\n }\n\n function paintActions(data) {\n var box = document.getElementById(\'actions\')\n box.innerHTML = \'\'\n data.actions.forEach(function (item) {\n if (!item.allowed) return\n var button = document.createElement(\'button\')\n button.type = \'button\'\n button.className = \'p-button p-button-sm\' + (item.kind === \'reject\' ? \' p-button-danger\' : \'\')\n button.textContent = item.label\n button.onclick = function () {\n act(item).catch(function (error) { tip(error.message, true) })\n }\n box.appendChild(button)\n })\n if (data.capabilities.hasForm) {\n var saveButton = document.createElement(\'button\')\n saveButton.type = \'button\'\n saveButton.className = \'p-button p-button-sm p-button-text\'\n saveButton.textContent = \'保存\'\n saveButton.onclick = function () {\n save().catch(function (error) { tip(error.message, true) })\n }\n box.appendChild(saveButton)\n }\n }\n\n /** 推进:要意见的先弹宿主那个窗(`magicBlock.dialog` 由**宿主页面**渲染,位置一定对)。\n * 🔴 弹窗是块包里的一个文件(`dialog/reject.html`),这一次的单子/提示走 `data` 交过去;\n * 那一篇里 `magicBlock.done({ values: { comment } })` 交回来,结果就是下面这个 `answer`。 */\n async function act(item) {\n var params = { id: current.bill.id, action: item.action }\n if (item.needsComment) {\n var answer = await magicBlock.dialog({\n file: \'dialog/reject.html\',\n data: {\n title: \'退回这张单\',\n hint: \'退回必须写一句意见(流程图里那一步声明了 requires_comment)。\'\n }\n })\n if (answer.button !== \'ok\') return\n params.comment = answer.values.comment\n }\n var data = await api.act(params)\n if (data.applied === false) tip(\'这一下不算数(没有那个权限):只留了一条痕,状态没有推进。\')\n else tip(\'\')\n await open(current.bill.id)\n }\n\n /** 「保存不推进」:只把 `step.editable` 那几列递上去(白名单在平台那一侧再判一次)。 */\n async function save() {\n var params = { id: current.bill.id }\n current.step.editable.forEach(function (name) {\n var input = document.querySelector(\'#slot [data-field="\' + name + \'"]\')\n if (input) params[name] = input.value\n })\n await api.save(params)\n tip(\'已保存(状态没变)\')\n await open(current.bill.id)\n }\n\n async function open(id) {\n paint(await api.detail({ id: id }))\n tip(\'\')\n }\n\n async function list() {\n var data = await api.list({})\n var box = document.getElementById(\'bills\')\n box.innerHTML = \'\'\n data.bills.forEach(function (row) {\n var button = document.createElement(\'button\')\n button.type = \'button\'\n button.textContent = row.no + \' · \' + row.title + \' · \' + row.status\n button.onclick = function () { open(row.id).catch(function (error) { tip(error.message, true) }) }\n box.appendChild(button)\n })\n if (data.bills.length) await open(data.bills[0].id)\n else {\n document.getElementById(\'steps\').innerHTML = \'\'\n document.getElementById(\'slot\').innerHTML =\n \'<section class="labull-card wf-card"><p class="wf-empty">还没有单子:点右上角「新建一张草稿单」,\' +\n \'就能看到「草稿 → 待审批 → 已付款 / 已退回」这条流程。</p></section>\'\n document.getElementById(\'actions\').innerHTML = \'\'\n document.getElementById(\'status\').hidden = true\n tip(\'\')\n }\n }\n\n document.getElementById(\'create\').onclick = function () {\n tip(\'\')\n api.create({}).then(function () { return list() }).catch(function (error) { tip(error.message, true) })\n }\n\n list().catch(function (error) { tip(\'读不出来:\' + error.message, true) })\n})()\n'),
749
749
  ('backend_open.py', '"""报销流程 —— 块后端(**薄薄一层**:每个路由一行转发,杂活全在平台)。\n\n🔴 它只是一份普通 Python(能 import 什么就 import 什么):`from canvasdemo.models import Reimbursement`\n 就是 Django 的写法。业务模型**不经过 `ctx`**。\n🔴 模块级只许 import / class / docstring(这一份是**入口**):所以流程表不在这里 ——\n 它住在兄弟文件 `flow.py` 里(那份不受入口这几条约束)。\n🔴 **块内文件之间不许互相引用**:所以这里不能 `from flow import FLOW`。要那条流程就\n `flow_of(\'reimbursement\')` —— 平台注册表按 `flow_type` 给,声明仍然只有 `flow.py` 那一份。\n🔴 **类里的每个方法都必须是路由**(带 `@MagicBackendAPI`、签名恰好 `(self, ctx, params)`):\n 所以"取那一行"这种小工具**不写成方法**,就地铺开(四行重复,比多一条判据便宜)。\n🔴 每个 handler 第一句就校验参数:保存与体检会拿空参数真跑一遍。\n"""\n\nfrom labull_framework.magic_block.sdk import MagicBackendAPI, errors\n\n\nclass ReimbursementBlock:\n """报销单的五个接口:列表 / 新建 / 详情 / 推进 / 保存。"""\n\n @MagicBackendAPI()\n def list(self, ctx, params):\n """最近 20 张单(谁申请的、现在到谁手上、什么状态)。"""\n from canvasdemo.models import Reimbursement\n\n rows = Reimbursement.objects.order_by(\'-id\')[:20]\n return {\n \'me\': ctx.user.name or ctx.user.id,\n \'bills\': [\n {\n \'id\': row.id,\n \'no\': row.no,\n \'title\': row.title,\n \'amount\': str(row.amount),\n \'status\': row.status,\n \'billType\': row.bill_type,\n \'handler\': str(row.current_handler or \'\'),\n }\n for row in rows\n ],\n }\n\n @MagicBackendAPI()\n def create(self, ctx, params):\n """新建一张草稿单(申请人 = 当前用户;**示例**才有的入口,真实业务多半在别的页面建单)。"""\n from canvasdemo.models import Reimbursement\n\n latest = Reimbursement.objects.order_by(\'-id\').first()\n number = (latest.id if latest else 0) + 1\n # 🔴 认人只认 `ctx.user`(它来自会话,不是请求参数);外键直接写 `_id` 就不必去取一次 User。\n row = Reimbursement.objects.create(\n no=f\'BX-{number:04d}\',\n title=str(params.get(\'title\') or \'差旅报销\'),\n amount=params.get(\'amount\') or 100,\n bill_type=str(params.get(\'billType\') or \'purchase\'),\n applicant_id=int(ctx.user.id),\n current_handler_id=int(ctx.user.id),\n )\n return {\'id\': row.id}\n\n @MagicBackendAPI()\n def detail(self, ctx, params):\n """详情 = 平台那份只读报文(`step` / 能力位 / 动作 / 时间轴 / 步骤条)+ 这一行的业务字段。"""\n from canvasdemo.models import Reimbursement\n from labull_framework.flow import flow_of, snapshot\n\n bill_id = params.get(\'id\')\n if not bill_id:\n raise errors.BadRequest(\'要给我一个 id\')\n row = Reimbursement.objects.filter(id=bill_id).first()\n if row is None:\n raise errors.NotFound(f\'没有这张报销单:{bill_id}\')\n return dict(\n snapshot(flow_of(\'reimbursement\'), row, ctx),\n bill={\n \'id\': row.id,\n \'no\': row.no,\n \'title\': row.title,\n \'amount\': str(row.amount),\n \'billType\': row.bill_type,\n \'applicant\': str(row.applicant or \'\'),\n \'handler\': str(row.current_handler or \'\'),\n },\n )\n\n @MagicBackendAPI()\n def act(self, ctx, params):\n """**推进状态的唯一口子**:一行转发(状态校验 / 权限 / 必填意见 / 日志 / 刷处理人全在平台)。"""\n from canvasdemo.models import Reimbursement\n from labull_framework.flow import advance, flow_of\n\n bill_id = params.get(\'id\')\n if not bill_id:\n raise errors.BadRequest(\'要给我一个 id\')\n action = str(params.get(\'action\') or \'\').strip()\n if not action:\n raise errors.BadRequest(\'要告诉我要走哪个动作(报文里的 actions[].action)\')\n row = Reimbursement.objects.filter(id=bill_id).first()\n if row is None:\n raise errors.NotFound(f\'没有这张报销单:{bill_id}\')\n return advance(flow_of(\'reimbursement\'), row, action, params, ctx)\n\n @MagicBackendAPI()\n def save(self, ctx, params):\n """「保存不推进」:只写 `step.editable` 那几列(白名单由平台判),状态一个字都不动。"""\n from canvasdemo.models import Reimbursement\n from labull_framework.flow import flow_of, save\n\n bill_id = params.get(\'id\')\n if not bill_id:\n raise errors.BadRequest(\'要给我一个 id\')\n row = Reimbursement.objects.filter(id=bill_id).first()\n if row is None:\n raise errors.NotFound(f\'没有这张报销单:{bill_id}\')\n return save(flow_of(\'reimbursement\'), row, params, ctx)\n'),
750
+ ('dialog/reject.html', '<!doctype html>\n<!-- 「退回」要写意见 —— 流程块里的一个弹窗文件(`magicBlock.dialog({ file: \'dialog/reject.html\', data })`)。\n 🔴 它是一篇**普通网页**:结构与脚本都在这里,宿主只负责在真视口里给它一层位置。\n 🔴 参数走 `data`(`title` / `hint`):文件是死的,这一次要点哪张单、提示什么,由块在 `dialog({ data })` 里给。\n 🔴 结果走 `magicBlock.done({ button, values })`:上面那个 `await` 拿到的就是它。\n 🔴 Esc / 点遮罩由宿主结算(`dismissible: false` 时宿主不认);想自己接就 `preventDefault()`。 -->\n<html lang="zh">\n<head>\n <meta charset="utf-8">\n <meta name="viewport" content="width=device-width, initial-scale=1">\n <title>退回</title>\n <style>\n body { margin: 0; font: var(--sp-font-md, 14px) / 1.6 system-ui, sans-serif; color: var(--sp-color-text, #2e3440); }\n .dlg { display: flex; flex-direction: column; gap: 10px; padding: 16px 18px; }\n .dlg h3 { margin: 0; font-size: var(--sp-font-md, 14px); }\n .dlg p { margin: 0; color: var(--sp-color-text-muted, #4c566a); }\n .dlg textarea { width: 100%; box-sizing: border-box; }\n .dlg footer { display: flex; justify-content: flex-end; gap: 8px; }\n .bad { color: var(--sp-color-danger, #b91c1c); font-size: var(--sp-font-xs, 12px); }\n </style>\n</head>\n<body>\n <form class="dlg" id="form">\n <h3 id="title">退回这张单</h3>\n <p id="hint">退回必须写一句意见。</p>\n <label for="comment">意见</label>\n <textarea id="comment" class="p-inputtext" rows="4" placeholder="例如:金额与发票不一致"></textarea>\n <p class="bad" id="error" hidden>意见不能为空</p>\n <footer>\n <button class="p-button p-button-sm p-button-text" type="button" id="cancel">再想想</button>\n <button class="p-button p-button-sm p-button-danger" type="button" id="ok">退回</button>\n </footer>\n </form>\n\n <script>\n ;(async function () {\n var data = (await magicBlock.data()) || {}\n if (data.title) document.getElementById(\'title\').textContent = data.title\n if (data.hint) document.getElementById(\'hint\').textContent = data.hint\n\n document.getElementById(\'cancel\').onclick = function () {\n magicBlock.done({ button: \'cancel\' })\n }\n document.getElementById(\'ok\').onclick = function () {\n var comment = document.getElementById(\'comment\').value.trim()\n if (!comment) {\n document.getElementById(\'error\').hidden = false\n return\n }\n magicBlock.done({ button: \'ok\', values: { comment: comment } })\n }\n document.getElementById(\'comment\').focus()\n })()\n </script>\n</body>\n</html>\n'),
750
751
  ('flow.js', '/* 节点内容登记表(**薄薄一层**:只放映射与挂载,渲染器实现在 app.js 里)。\n *\n * 🔴 `MagicSteps` 是**平台注进这个 iframe** 的(与 `magicBlock` / `magicBackendApi` 同一座桥):\n * 块里没有模块系统(不能 import / export),所以能力都挂在全局上。\n * 🔴 判据是**双向**的(保存时就会被拦):\n * · `flow.py` 里声明的每个 `ui`,这里不登记 ⇒ 那个节点打开就是**白屏**(D16);\n * · 这里登记的每个 `ui`,`flow.py` 里没有节点用到 ⇒ **死代码**(D17)。\n * 两边靠寄存器里那个 **ui 字符串字面量**对起来(所以 key 不要拼出来)。\n * 🔴 `ui` 是"内容型"不是"节点型":`bill` 同时给「草稿」与「已退回」两个节点用(它们长一样)。\n */\nMagicSteps.register(\'bill\', function (slot, payload) {\n Renderers.bill(slot, payload)\n})\nMagicSteps.register(\'approve\', function (slot, payload) {\n Renderers.approve(slot, payload)\n})\n/* `ui_by` 分叉出来的那一块内容:同一个「待审批」节点,记账报销走这一个。 */\nMagicSteps.register(\'approve-zero\', function (slot, payload) {\n Renderers.approveZero(slot, payload)\n})\nMagicSteps.register(\'done\', function (slot, payload) {\n Renderers.done(slot, payload)\n})\n'),
751
752
  ('flow.py', '"""报销流程 —— **这条流程的唯一权威**:流程表 + 业务钩子 + 推进路由都在这里。\n\n🔴 `flow.py` 是**兄弟文件,不是入口**:\n · 模块级赋值(那张流程表)与模块级函数(业务钩子)都是它的正常写法 ——\n `backend_open.py` 那套"模块级只许 import / class"的约束**不适用于它**;\n · 它可以挂 `@MagicBackendAPI` 路由(路由本来就是整包合扫);\n · 平台**只认 `flow.py` 一个名字、每块最多一个**,而且每块**恰好一条**流程。\n🔴 这个文件的写法:`FLOW = register(Flow(...))`。`Flow(...)` 构造时就判\n D1–D13 / D18 / D19 / D21 / D22 / D24 / D25,不合当场抛(中文原话),保存那一刻就拦下来 ——\n 不用等到某次点按钮才 500。\n🔴 本块的后端**不 import 这个文件**(块内文件之间不许互相引用):它用\n `flow_of(\'reimbursement\')` 从平台注册表按 `flow_type` 取 —— 声明仍然只有这一份。\n"""\n\nfrom labull_framework.flow import Flow, Move, Reject, Step, register\n\n\n# ──────────────────────────── 业务钩子(`f(row, params, ctx)`) ────────────────────────────\n#\n# 🔴 要拒绝就 `raise Reject(\'中文原话\')`:平台把它转成 **400 + 这句原话**(不是 500、不是"操作失败")。\n# 钩子是**业务规则**(金额超限、库存不够),人话就是它唯一的结论。\n# 🔴 钩子里该 import 什么就 import 什么(业务模型的 import 全部留在块里,平台不认识任何业务模型)。\n\n\ndef check_amount(row, params, ctx):\n """提交前校验金额(演示"钩子拒绝 ⇒ 400 中文原话")。"""\n if row.amount is None or row.amount <= 0:\n raise Reject(\'金额必须大于 0 才能提交\')\n\n\ndef keep_title(row, params, ctx):\n """草稿的「保存不推进」钩子:只做业务校验,**状态一个字都不动**。"""\n if not str(row.title or \'\').strip():\n raise Reject(\'事由不能为空\')\n\n\n# ──────────────────────────── 流程表(**唯一一行事实**) ────────────────────────────\nFLOW = register(\n Flow(\n # `type` 是**全局键**:同一套流程在别的块里复制一份时,两份都用它 —— 跨块比对就是按它配对的。\n # 注意它**不是**清单里那个 `type: workflow`(那一格回答"哪一类块")。\n type=\'reimbursement\',\n entity=\'reimbursement\',\n # 状态列:业务行上**真的那一列**(这里叫 status;换成 state / audit_status 也行,\n # 只要那一列真的存在 —— 拼错会在运行期得到一句中文报错,不是 AttributeError)。\n status_field=\'status\',\n initial=\'draft\',\n terminal=(\'paid\', \'rejected\'),\n # 内容分叉的列:`Step.ui_by` 的键就是这一列的**真实取值**。\n view_field=\'bill_type\',\n # 当前处理人那一列(待办靠它一条 JOIN;终态由平台清空)。\n handler_field=\'current_handler\',\n doc=\'报销单:草稿 → 待审批 → 已付款 / 已退回\',\n steps=(\n # ① 草稿:申请人自己办;有「保存不推进」(form + editable 必须一起写)。\n Step(\n \'draft\',\n \'草稿\',\n candidates=\'applicant\',\n ui=\'bill\',\n form=keep_title,\n editable=(\'title\', \'amount\'),\n moves=(\n Move(\'submit\', \'提交\', to=\'pending\', kind=\'submit\', hook=check_amount),\n ),\n ),\n # ② 待审批:申请人 / 财务 / 出纳都能办;内容与**动作集**都按单据类型分叉。\n # 业务权限(`review_reimbursement` / `pay_reimbursement`)是**示例项目自己**在\n # `canvasdemo.Reimbursement.Meta.permissions` 里声明的(`migrate` 之后就有);\n # `flow.py` 里只引用 `app_label.codename`,**不写权限表、更不认组名**\n # (组只是权限包 —— 「财务」「出纳」两个包由示例项目的 0009 迁移建好)。\n Step(\n \'pending\',\n \'待审批\',\n candidates=\'applicant_or_permissions:canvasdemo.review_reimbursement,canvasdemo.pay_reimbursement\',\n # 「谁能点」与「待办挂给谁」是两件事:财务 / 出纳 / 申请人**都能点**,\n # 但「当前处理人」只挂给单据上的申请人(`biz:applicant` 是单据那一列)。\n assignees=\'biz:applicant\',\n ui=\'approve\',\n ui_by={\'accounting\': \'approve-zero\'},\n # 记账报销只有「通过 / 退回」;标准报销多一个「撤回重填」。\n # 列就是 view_field(bill_type),**没命中 ⇒ 全部动作**(与 ui_by 的兜底一致)。\n moves_by={\'accounting\': (\'approve\', \'reject\')},\n moves=(\n # `confirm_permissions`:**谁的点击才算数**(不匹配 = 只留痕、不推进)。\n # 「通过」要真的把钱付出去 ⇒ 认 `pay_reimbursement` 这个权限(出纳那个包)。\n Move(\'approve\', \'通过\', to=\'paid\', kind=\'approve\',\n confirm_permissions=(\'canvasdemo.pay_reimbursement\',)),\n Move(\'reject\', \'退回\', to=\'rejected\', kind=\'reject\', requires_comment=True),\n Move(\'withdraw\', \'撤回重填\', to=\'draft\', kind=\'revoke\'),\n ),\n ),\n # ③ 终态:不挂动作、candidates 必须是空串(D8 / D11)。\n Step(\'paid\', \'已付款\', terminal=True, ui=\'done\'),\n # ④ 又一个终态,**复用 `bill` 那个内容 key**:多个节点共用一个 ui 是正解\n # ("内容不同"才是分叉的理由,不是"节点不同")。\n Step(\'rejected\', \'已退回\', terminal=True, ui=\'bill\'),\n ),\n )\n)\n'),
752
753
  ('index.open.html', '<!doctype html>\n<!-- 报销流程(块类型 workflow 的样例)。\n 🔴 壳**永远不变**:抬头(单号 / 状态 / 处理人 / 新建)+ 流程步骤条 + 内容槽 + 动作条;\n 变的只有中间那个内容槽 `#slot`,换哪一块由报文里的 `step.ui` 决定(前端不做 status→UI 映射)。\n 🔴 块跑在**不透明源**的沙箱 iframe 里:当前用户 / 弹窗 / 调自己的后端一律走宿主注入的桥\n (`magicBlock` / `magicBackendApi` / `MagicSteps`)。\n 🔴 样式只用主题给的东西:`.labull-card` / `.labull-subject-title` / `.labull-steps` 与 `--sp-*` token;\n 块自己那份 `app.css` 只写"这一块的排版",不发明色值。 -->\n<html lang="zh">\n<head>\n <meta charset="utf-8">\n <meta name="viewport" content="width=device-width, initial-scale=1">\n <title>报销流程</title>\n <link rel="stylesheet" href="app.css">\n</head>\n<body>\n <div class="wf">\n <!-- 抬头:单号 / 状态 / 处理人 / 新建 -->\n <header class="wf-head">\n <h2 class="labull-subject-title"><i class="pi pi-file-edit"></i> 报销单 <span class="wf-no" id="no">—</span></h2>\n <span class="wf-pill" id="status" hidden></span>\n <span class="wf-muted" id="handler"></span>\n <button class="p-button p-button-sm p-button-outlined" id="create" type="button">\n <i class="pi pi-plus"></i> 新建一张草稿单\n </button>\n </header>\n\n <!-- 步骤条:由 MagicSteps.renderBar 按报文的 steps 画(桌面横向 / 手机自动纵向) -->\n <section class="labull-card labull-card--tight wf-steps">\n <div id="steps"></div>\n </section>\n\n <!-- 内容槽:渲染器按 payload.step.ui 往这里画一张卡片 -->\n <div id="slot" class="wf-slot"></div>\n\n <!-- 动作条:按钮由报文的能力位算(allowed / needsComment) -->\n <footer class="wf-actions">\n <div id="actions"></div>\n <p class="wf-tip" id="tip"></p>\n </footer>\n\n <details class="wf-bills">\n <summary>切换单子</summary>\n <div id="bills" class="wf-billlist"></div>\n </details>\n </div>\n\n <script src="app.js"></script>\n <script src="flow.js"></script>\n</body>\n</html>\n'),
@@ -874,7 +875,7 @@ def excluded_readme() -> str:
874
875
  # lines[i]='SMOKE_SCRIPT: str = '+repr(body);
875
876
  # dev.write_text('\n'.join(lines), encoding='utf-8', newline='\n')"
876
877
  #: 🔴 抄完**必须**确认相等:对拍判据在 `tools/sdk-check-py.mjs` 的 ⑬d(两份逐字一致),漂了就在那里红。
877
- SMOKE_SCRIPT: str = '/* 块包自检(**本地就能跑**):把"发布前一定会后悔的那几条"提前抓出来。\n *\n * 🔴 这份文件的正文**有两份真源**:仓库里的 `tools/smoke.mjs`,以及导出时逐字抄进开发目录的\n * `smoke.lock.mjs`(`devkit.SMOKE_SCRIPT`,有对拍判据盯着两边逐字一致)。所以下面的用法\n * 写的是**开发目录里那个名字** —— 那才是读这份提示的人手里那份文件。\n * 在本仓库里跑它,路径是 `node tools/smoke.mjs`(名字不同,参数一字不差)。\n *\n * 用法(在开发目录里,或者指向它):\n * node smoke.lock.mjs # 只查"有改动的那几个块"(跟 git 比),没有就查全部\n * node smoke.lock.mjs --all # 查全部块\n * node smoke.lock.mjs <块文件夹|块 uuid> # 只查一个块(可以给多个)\n * node smoke.lock.mjs --workspace <开发目录>\n * LABULL_SMOKE_PYTHON=<python> node smoke.lock.mjs # 指一个装了 Django 的解释器 ⇒ 多跑一层\n *\n * 它查两类东西:\n * ① **纯文本层(不需要任何依赖)**:清单(四个必填键 + 可选的 type / requires / 搬家的 detail)、文件名形状、前端引用链\n * (`<script src>` / `<link href>` 指向的文件在不在包里)、"包里的文件没人引用"、\n * 以及**后端文件之间不许互相引用**这条规矩。\n * ② **平台那一层(有 Python + Django 时才跑)**:真的按平台自己的判据加载一次\n * (`labull_framework.magic_block.validate`)—— 那份判据是**同一份代码**,\n * 不是在这里抄一遍(抄一遍的下场是"本地说没事、管理台说不合格")。\n *\n * 🔴 退了就报清楚:没装 Python / 没装 Django 时,第 ② 层会明说"这一层没跑",\n * **不假装自己全绿**(自检的意义就是提前发现,不是给个安心)。\n * 🔴 输出不用 emoji:中文 Windows 控制台是 cp936,emoji 会变成乱码让整行不可读。\n */\n\nimport { readdirSync, readFileSync, statSync } from \'node:fs\'\nimport { basename, extname, join, relative, resolve, sep } from \'node:path\'\nimport { existsSync } from \'node:fs\'\nimport { spawnSync } from \'node:child_process\'\n\nconst ENTRY = \'index.open.html\'\nconst MANIFEST = \'magic-block.open.yaml\'\nconst BACKEND = \'backend_open.py\'\n/**\n * 平台随**块包**铺下来的那两份生成物:它们**不进块包**,引用不到也不算错。\n *\n * 🔴 名单里只留"可能出现在块文件夹里"的那两份(`api.open.d.ts` / `VERSION.lock`)——\n * 开发目录顶层那几份(`AGENTS.lock.md` / `backend.lock.pyi` / `pages.lock.ts` / `labull.css` /\n * `CONTROLS.lock.html` / `requirements.lock.txt`)**不在块包里**,摆在这儿是死条目。\n */\nconst PLATFORM_FILES = new Set([\n \'api.open.d.ts\', \'VERSION.lock\',\n])\n/** 爬目录时跳过的目录(与后端 `export.SKIP_DIRS` 同一份口径)。\n *\n * 🔴 末尾那两个是**你自己的地盘**(`.exclude`,老名字 `excluded`):里面的东西不是块包的一部分\n * (后端 `sdk.is_scratch()`)—— 导出不铺、读回不扫、保存不存。所以自检**根本看不到它们**,\n * 也就不必再按名字前缀筛第二遍。 */\nconst SKIP_DIRS = new Set([\'.git\', \'.venv\', \'venv\', \'__pycache__\', \'node_modules\', \'dist\', \'build\', \'.idea\', \'.vscode\', \'example\', \'.exclude\', \'excluded\'])\n/** 认哪些扩展名算"能进包的文本"。 */\nconst TEXT_EXT = new Set([\'.html\', \'.htm\', \'.js\', \'.mjs\', \'.cjs\', \'.ts\', \'.css\', \'.json\', \'.yaml\', \'.yml\', \'.md\', \'.txt\', \'.py\', \'.svg\'])\nconst MAX_DEPTH = 3\nconst MAX_FILE_BYTES = 512 * 1024\n\nconst argv = process.argv.slice(2)\nconst flags = { all: false, workspace: \'\', python: process.env.LABULL_SMOKE_PYTHON || \'\', targets: [] }\nfor (let i = 0; i < argv.length; i += 1) {\n if (argv[i] === \'--all\') flags.all = true\n else if (argv[i] === \'--workspace\') flags.workspace = argv[++i] || \'\'\n else if (argv[i] === \'--python\') flags.python = argv[++i] || \'\'\n else flags.targets.push(argv[i])\n}\n\nconst pass = []\nconst fail = []\nconst skip = []\nconst ok = (message) => { pass.push(message); console.log(` [OK] ${message}`) }\nconst ng = (message) => { fail.push(message); console.log(` [NG] ${message}`) }\nconst note = (message) => { skip.push(message); console.log(` [--] ${message}`) }\nconst info = (message) => console.log(message)\n\nfunction readText(path) {\n const raw = readFileSync(path)\n if (raw.length > MAX_FILE_BYTES) return { text: \'\', tooBig: true }\n return { text: raw.toString(\'utf8\').replace(/^\\uFEFF/, \'\'), tooBig: false }\n}\n\n// ──────────────────────────── 找开发目录与块 ────────────────────────────\n\nfunction findWorkspace(start) {\n let dir = resolve(start)\n for (let depth = 0; depth < 6; depth += 1) {\n const workspace = join(dir, \'workspace\')\n if (existsSync(workspace) && statSync(workspace).isDirectory()) return workspace\n if (basename(dir) === \'workspace\') return dir\n const parent = resolve(dir, \'..\')\n if (parent === dir) break\n dir = parent\n }\n return \'\'\n}\n\nfunction listBlocks(workspace) {\n const out = []\n // 🔴 块现在住在 **`workspace/<块类型>/<可读名>-<块 uuid>/`**(按类型分目录):\n // 所以这一层要看两格 —— 第一格是类型目录(它自己没有清单),第二格才是块。\n // ⚠️ 只往下走**一层**:`deep-depth` 的递归会把块内部也当成候选(那些目录没有清单,白跑)。\n const scan = (dir, depth) => {\n for (const name of readdirSync(dir).sort()) {\n const full = join(dir, name)\n if (!statSync(full).isDirectory()) continue\n // 🔴 只认**块文件夹**(里面有清单或入口):开发目录里还有 `example/` 这种平台铺下来的东西,\n // 把它也当成一个块去查,只会平白报一堆"缺这个缺那个"。\n if (existsSync(join(full, MANIFEST)) || existsSync(join(full, ENTRY))) {\n out.push(full)\n continue\n }\n if (depth === 0) scan(full, depth + 1)\n }\n }\n scan(workspace, 0)\n return out\n}\n\n/** 一个块文件夹 → `{相对路径: 正文}`(与平台 `export` 的扫描口径一致)。 */\nfunction readBlock(dir) {\n const files = {}\n const walk = (current, segments, depth) => {\n for (const name of readdirSync(current).sort()) {\n const full = join(current, name)\n const stat = statSync(full)\n const at = [...segments, name]\n if (stat.isDirectory()) {\n if (SKIP_DIRS.has(name) || depth >= MAX_DEPTH) continue\n walk(full, at, depth + 1)\n continue\n }\n if (!TEXT_EXT.has(extname(name).toLowerCase())) continue\n if (stat.size > MAX_FILE_BYTES) { files[at.join(\'/\')] = \'\'; continue }\n files[at.join(\'/\')] = readText(full).text\n }\n }\n walk(dir, [], 0)\n return files\n}\n\n// ──────────────────────────── ① 纯文本层 ────────────────────────────\n\nfunction parseManifest(text) {\n const found = {}\n const issues = []\n /** 那些"只提示、不算错"的话(老块清单里还留着的 detail 那一类)。 */\n const notes = []\n const stripComment = (line) => {\n let quote = \'\'\n for (let i = 0; i < line.length; i += 1) {\n const char = line[i]\n if (quote) { if (char === quote) quote = \'\' }\n else if (char === \'"\' || char === "\'") quote = char\n else if (char === \'#\') return line.slice(0, i)\n }\n return line\n }\n const lines = text.split(/\\r?\\n/)\n for (let index = 0; index < lines.length; index += 1) {\n const raw = lines[index]\n const indent = raw.length - raw.trimStart().length\n const line = stripComment(raw).trim()\n if (!line) continue\n const at = line.indexOf(\':\')\n if (at < 0) { issues.push(`第 ${index + 1} 行不是「键: 值」:${line}`); continue }\n const key = line.slice(0, at).trim()\n const value = line.slice(at + 1).trim().replace(/^([\'"])(.*)\\1$/, \'$2\')\n // 🔴 认得的键里**包含两个可选键**:`type`(块类型,缺省 plain)与 `requires`(依赖声明,\n // 清单里唯一的结构,整块要跳过)—— 漏一个,作者写了就会被自检报成"不认识的键"。\n if (![\'id\', \'module\', \'name\', \'brief\', \'type\', \'requires\', \'detail\'].includes(key)) { issues.push(`第 ${index + 1} 行有不认识的键:${key}`); continue }\n if (found[key] !== undefined) { issues.push(`第 ${index + 1} 行重复写了 ${key}`); continue }\n found[key] = value\n /* 🔴 `requires:` 那一块**整块**是结构(七个键 + `surface` 再深一层):它下面那些 `- 项` /\n * `类路径: [方法名]` 都不是清单的行 —— 不整块跳过就会在这里报一串假错。\n * 形状判据在平台那一侧(`manifest._parse_requires`),这里只管"别把它当写错的清单"。 */\n if (key === \'requires\') {\n while (index + 1 < lines.length && (!lines[index + 1].trim() || /^\\s/.test(lines[index + 1]))) index += 1\n continue\n }\n /* 🔴 **块标量**(`detail: |` + 缩进几行):老块清单里那个搬家的 `detail` 是"业务功能说明,\n * 用 Markdown 写",多行是常态。块里那几行是**正文**(可能以 `-` 或 `#` 开头,那是 md\n * 的列表与标题),不是清单的行 —— 不跳过它们就会在这里报一串"不是「键: 值」"的假错。 */\n if (/^[|>][-+]?$/.test(value)) {\n while (index + 1 < lines.length && (!lines[index + 1].trim() || /^\\s/.test(lines[index + 1]))) index += 1\n }\n if (key === \'detail\' && indent === 0) {\n // 🔴 **提示、不是错**(和平台那一侧同一个口径):老块的清单里可能还留着它 ——\n // 报成 NG 会让那些块直接过不了自检,而它一个字都没写坏,只是"住错地方了"。\n notes.push(`第 ${index + 1} 行还写着 detail:它已经搬到块包内的 README.open.md(这里会被忽略,内容不丢)`)\n }\n }\n // 🔴 只要求**必填**的四个:`detail` 是可选的,没写不是错(与平台同一套判据)。\n for (const key of [\'id\', \'module\', \'name\', \'brief\']) {\n if (!found[key]) issues.push(`清单缺 ${key}(或者是空的)`)\n }\n if (found.id && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(found.id)) {\n issues.push(`清单里的 id 不是 uuid:${found.id}`)\n }\n if (found.name && found.name.length > 40) issues.push(`name 超过 40 个字:${found.name.length}`)\n // `type`(可选):这里只判**写法**(与平台那一侧同一口径);"有没有这种类型"只有平台知道。\n if (found.type !== undefined && !/^[a-z][A-Za-z0-9]*$/.test(found.type)) {\n issues.push(`清单里的 type 写法不合法:${found.type}(小写字母开头、后面字母与数字;缺省不写就是 plain)`)\n }\n return { found, issues, notes }\n}\n\n/** 前端引用链:入口里 `src=` / `href=` 指向的**包内**文件在不在。回 `{missing, referenced}`。 */\nfunction frontendRefs(files) {\n const referenced = new Map() // 引用者 → 被引用的相对路径\n const missing = []\n const pick = (ref) => {\n if (/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i.test(ref)) return undefined // 外链 / data: → 不是包内文件\n const clean = ref.split(/[?#]/)[0].replace(/^\\.\\//, \'\').replace(/^\\/+/, \'\')\n if (!clean) return undefined\n if (files[clean] !== undefined) return clean\n const base = clean.split(\'/\').pop()\n return base ? Object.keys(files).find((name) => name.split(\'/\').pop() === base) : undefined\n }\n const scan = (owner, body) => {\n for (const match of body.matchAll(/<(?:script|link)\\b[^>]*?(?:src|href)\\s*=\\s*(["\'])(.*?)\\1[^>]*>/gi)) {\n const ref = match[2]\n const hit = pick(ref)\n if (hit) referenced.set(owner, [...(referenced.get(owner) || []), hit])\n else if (!/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i.test(ref) && basename(ref) !== \'labull.css\') {\n missing.push(`${owner} 引用的 ${ref} 不在包里`)\n }\n }\n }\n for (const [name, body] of Object.entries(files)) {\n if (name.endsWith(\'.html\')) scan(name, body)\n }\n return { missing, referenced }\n}\n\n/** 每个 `.py` 里 import 的**包内**文件("文件之间不许互相引用"这条规矩的判据)。\n * ⚠️ `.exclude/` 里的草稿不在 `files` 里(`readBlock` 按 `SKIP_DIRS` 就跳过了)。 */\nfunction siblingImports(files) {\n const known = new Map()\n for (const name of Object.keys(files)) {\n if (!name.endsWith(\'.py\')) continue\n const stem = name.slice(0, -3)\n known.set(stem, name)\n known.set(stem.split(\'/\').pop(), name)\n }\n const out = []\n for (const [name, body] of Object.entries(files)) {\n if (!name.endsWith(\'.py\')) continue\n for (const line of body.split(/\\r?\\n/)) {\n const trimmed = line.trim()\n if (trimmed.startsWith(\'#\')) continue\n const from = /^from\\s+([A-Za-z_][\\w.]*)\\s+import\\b/.exec(trimmed)\n const plain = /^import\\s+(.+)$/.exec(trimmed)\n const names = []\n if (from) names.push(from[1])\n else if (plain) names.push(...plain[1].split(\',\').map((item) => item.trim().split(/\\s+as\\s+/)[0]))\n for (const imported of names) {\n const hit = known.get(imported) || known.get(imported.split(\'.\')[0])\n if (hit && hit !== name) out.push(`${name} import 了包内的 ${imported}(就是 ${hit})`)\n }\n }\n }\n return out\n}\n\n/**\n * 从开发目录顶层那份 `backend.lock.pyi` 里读"**你现在配了哪些键**"。\n *\n * 🔴 为什么要从清单里读,而不是自己去扫环境:块作者本地通常**没有** Django(那份清单是平台\n * 导出时算的)。清单里那一块就是"导出这一刻真的存在的 `BLOCK_` 键名",是这里唯一的真源。\n * 格式约定:说明那行是「你现在配了哪些键」,紧跟其后的一串 `# KEY` 就是键名。\n */\nfunction readConfigKeys(workspace) {\n const path = join(workspace, \'..\', \'backend.lock.pyi\')\n if (!existsSync(path)) return null\n const lines = readText(path).text.split(/\\r?\\n/)\n const out = []\n let inside = false\n for (const line of lines) {\n if (line.includes(\'你现在配了哪些键\')) { inside = true; continue }\n if (!inside) continue\n const hit = /^#\\s{3}([A-Za-z_][A-Za-z0-9_]*)\\s*$/.exec(line)\n if (hit) { out.push(hit[1]); continue }\n if (line.trim() && !line.startsWith(\'#\')) break\n if (line.startsWith(\'# (\')) break // "(一个都没有:…)"\n }\n return out\n}\n\n/** 在开发目录附近找 `.env`(`.env` / `.env.local` / `.env.dev`),返回找到的第一个的键名集合。 */\nfunction readEnvKeys(workspace) {\n const names = new Set()\n let dir = resolve(workspace)\n for (let depth = 0; depth < 4; depth += 1) {\n for (const file of [\'.env\', \'.env.local\', \'.env.dev\']) {\n const full = join(dir, file)\n if (!existsSync(full)) continue\n for (const raw of readText(full).text.split(/\\r?\\n/)) {\n const line = raw.trim()\n if (!line || line.startsWith(\'#\')) continue\n const at = line.indexOf(\'=\')\n if (at <= 0) continue\n names.add(line.slice(0, at).trim())\n }\n return { file: full, names }\n }\n const parent = resolve(dir, \'..\')\n if (parent === dir) break\n dir = parent\n }\n return null\n}\n\n/** 一份 `.py` 里用到的配置键:`ctx.config[\'KEY\']` / `ctx.config.get(\'KEY\')` / `"KEY" in ctx.config`。 */\nfunction configUsage(files) {\n const out = []\n const patterns = [\n /config\\[\\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]\\s*\\]/g,\n /config\\.get\\(\\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]/g,\n /[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]\\s+in\\s+config/g,\n ]\n for (const [name, body] of Object.entries(files)) {\n if (!name.endsWith(\'.py\')) continue\n for (const pattern of patterns) {\n for (const match of body.matchAll(pattern)) {\n if (!out.some((item) => item.file === name && item.key === match[1])) {\n out.push({ file: name, key: match[1] })\n }\n }\n }\n }\n return out\n}\n\n/**\n * 查"块用到的配置键对不对"——\n * ① 代码里用到的键必须在清单里(清单空着就跳过,不然没配过的人会被满屏报错吓到);\n * ② 找到了 `.env` 就再对一遍:写了却没配的键是**必错项**(运行期取不到),配了却没人用的只提示。\n */\nfunction checkConfig(label, files, workspace) {\n const known = readConfigKeys(workspace)\n const env = readEnvKeys(workspace)\n const usage = configUsage(files)\n let bad = 0\n for (const { file, key } of usage) {\n if (Array.isArray(known) && known.length > 0 && !known.includes(key)) {\n bad += 1\n ng(`${label}:${file} 里用了 ctx.config[\'${key}\'],但它不在清单里 —— `\n + `导出那一刻环境里配的是:${known.join(\' / \')}(写错的键运行期只会取到 None,不报错)`)\n }\n if (env && !env.names.has(`BLOCK_${key}`)) {\n bad += 1\n ng(`${label}:${file} 里用了 ctx.config[\'${key}\'],但 ${basename(env.file)} 里没有 BLOCK_${key}(运行期取不到)`)\n }\n }\n if (!env) {\n note(`${label}:附近的 .env 没找到 ⇒ "这个键配了没有"那一半没查(代码用到的键仍与清单对过)`)\n } else {\n const used = new Set(usage.map((item) => item.key))\n const unused = [...env.names]\n .filter((name) => name.startsWith(\'BLOCK_\') && !used.has(name.slice(\'BLOCK_\'.length)))\n .map((name) => name.slice(\'BLOCK_\'.length))\n if (unused.length) note(`${label}:${basename(env.file)} 里这几个 BLOCK_ 键没有被用到:${unused.join(\' / \')}`)\n }\n if (usage.length && bad === 0) {\n ok(`${label}:代码里用到的配置键都对得上(${usage.map((item) => item.key).join(\' / \')})`)\n }\n}\n\n/**\n * 块里**越界 import**的那些(判据来自 `AGENTS.lock.md`,不是这里另发明一套):\n * · Django 自带的模型(`auth_permission` / `Group` / `ContentType` …)—— 清单里根本没有它们,\n * 而它们**不是业务权限目录**(这是真出过的一类错);\n * · 平台自己的库直接 import(该走 `ctx`)。\n * 只认"从这几处 import **模型**"的写法;`django.contrib.auth.models.AbstractUser` 这类\n * 造模型用的基类也会被点到 —— 那种要写在**项目**里,不该写在块里。\n */\nconst FORBIDDEN_IMPORTS = [\n { pattern: /from\\s+django\\.contrib\\.(auth|admin|contenttypes|sessions)[\\w.]*\\s+import\\s+(.+)/, what: \'Django 自带的模型\' },\n { pattern: /import\\s+django\\.contrib\\.(auth|admin|contenttypes|sessions)\\b/, what: \'Django 自带的模型\' },\n { pattern: /from\\s+labull_framework\\.models\\s+import\\s+/, what: \'平台自己的模型(要块/用户走 ctx)\' },\n]\n\nfunction forbiddenImports(files) {\n const out = []\n for (const [name, body] of Object.entries(files)) {\n if (!name.endsWith(\'.py\')) continue\n body.split(/\\r?\\n/).forEach((raw, index) => {\n const line = raw.trim()\n if (!line || line.startsWith(\'#\')) return\n for (const { pattern, what } of FORBIDDEN_IMPORTS) {\n if (pattern.test(line)) out.push({ file: name, line: index + 1, text: line, what })\n }\n })\n }\n return out\n}\n\nfunction checkBlock(dir) {\n const label = basename(dir)\n info(`\\n── ${label} ──`)\n const files = readBlock(dir)\n const names = Object.keys(files)\n\n if (!files[ENTRY]) ng(`${label}:缺入口 ${ENTRY}`)\n else ok(`${label}:有入口 ${ENTRY}`)\n\n if (files[MANIFEST] === undefined) {\n ng(`${label}:缺清单 ${MANIFEST}`)\n } else {\n const { found, issues, notes } = parseManifest(files[MANIFEST])\n if (issues.length) ng(`${label}:清单有问题 —— ${issues.join(\';\')}`)\n else ok(`${label}:清单必填的四个键齐(${found.module} / ${found.name})${found.requires ? \',另有依赖声明\' : \'\'}`)\n for (const line of notes) note(`${label}:${line}`)\n }\n\n // 🔴 业务说明与依赖声明各有各的家(两份 `.open.` 文件)——它们**不是**"没人引用"那一类。\n if (files[\'README.open.md\'] !== undefined) ok(`${label}:有 README.open.md(业务说明住在这里)`)\n if (files[\'requires.open.yaml\'] !== undefined) ok(`${label}:有 requires.open.yaml(依赖声明的草稿)`)\n // 🔴 "清单里写着 detail"那种老写法由 `parseManifest` 出**提示**(`notes`,不算错)——\n // 判据与平台那一侧同一个口径:报成错会让老块直接过不了自检,而它一个字都没写坏。\n\n for (const name of names) {\n // 🔴 判据与平台那两份**逐字同源**(`manifest.FILE_NAME_PATTERN` / `manifest.ts` 那份):\n // 不许反斜杠 / 冒号 / Windows 保留字符 / 控制字符、不许 `..` 与绝对路径,**中文可以**\n // (块里那几层目录常常是中文名:`cards/申请单/`)。\n if (!/^(?![A-Za-z]:)(?!\\/)[^\\\\:*?"<>|\\u0000-\\u001f\\u007f]+$/.test(name)\n || name.split(\'/\').includes(\'..\')) {\n ng(`${label}:文件名不合法 ${name}`)\n }\n }\n\n const { missing, referenced } = frontendRefs(files)\n for (const item of missing) ng(`${label}:${item}`)\n if (!missing.length && files[ENTRY]) ok(`${label}:前端的引用都指得到(入口里的 script/link 都在包里)`)\n\n // "包里的文件没人引用":只提示(入口文件与清单、后端本来就不该被引用)。\n const used = new Set([ENTRY, MANIFEST, BACKEND, ...[...referenced.values()].flat()])\n const orphans = names.filter((name) => !used.has(name) && !PLATFORM_FILES.has(name)\n && !name.endsWith(\'.py\') && name !== \'README.md\')\n if (orphans.length) note(`${label}:这几个文件没有任何引用(写进包里也没人用):${orphans.join(\' / \')}`)\n\n const cross = siblingImports(files)\n const codePy = names.filter((name) => name.endsWith(\'.py\'))\n if (cross.length) {\n for (const item of cross) {\n ng(`${label}:${item} —— **块里文件之间不能互相引用**(平台把每个 .py 各自独立加载):`\n + \'要么把那段逻辑复制过去,要么放进项目里的公共模块再 import\')\n }\n } else if (codePy.length) {\n ok(`${label}:后端那些 .py 之间没有互相引用(${codePy.join(\' / \')})`)\n }\n\n checkConfig(label, files, workspace)\n\n const forbidden = forbiddenImports(files)\n for (const item of forbidden) {\n ng(`${label}:${item.file} 第 ${item.line} 行 import 了${item.what} —— `\n + `${item.text.trim()}(见同目录 AGENTS.lock.md 第 1 条:那不是业务口径)`)\n }\n if (!forbidden.length && codePy.length) {\n ok(`${label}:没有 import Django 自带的模型 / 平台内部模型(只用清单里的东西)`)\n }\n return files\n}\n\n// ──────────────────────────── ② 平台那一层 ────────────────────────────\n\nfunction findPython() {\n if (flags.python) return flags.python\n const candidates = [process.env.LABULL_PYTHON, \'python\', \'python3\', \'py\'].filter(Boolean)\n const probe = \'import django, labull_framework; print("ok")\'\n for (const candidate of candidates) {\n const done = spawnSync(candidate, [\'-c\', probe], {\n encoding: \'utf-8\',\n env: { ...process.env, PYTHONIOENCODING: \'utf-8\' },\n })\n if (done.status === 0 && String(done.stdout).includes(\'ok\')) return candidate\n }\n return \'\'\n}\n\nfunction platformCheck(workspace, python, payload) {\n // 🔴 子进程正文:整包走**环境变量**递进去(这是这个脚本唯一这么递的地方),所以读完**必须**清掉——\n // 平台校验会再起"孙子进程"(流程声明 / 引用深检),它继承环境。Windows 的环境块有上限(约 32K),\n // 继承着几百 KB 的 payload,孙子进程直接起不来(`started=False`),而平台把"起不来"当软失败 ⇒\n // 流程块假报"flow.py 里一条流程都没注册"(D14)。这一行就是这个 bug 的修复。\n const script = [\n \'import json, os, sys\',\n \'from labull_framework.magic_block import validate\',\n \'payload = json.loads(os.environ["LABULL_SMOKE_PAYLOAD"])\',\n \'os.environ.pop("LABULL_SMOKE_PAYLOAD", None) # 别让它继承给孙子进程(Windows 环境块有上限)\',\n \'out = []\',\n \'for item in payload:\',\n \' issues = validate.check_block(item["files"])\',\n \' out.append({"name": item["name"], "issues": [list(pair) for pair in issues]})\',\n \'print("LABULL_SMOKE" + json.dumps(out, ensure_ascii=False))\',\n ].join(\'\\n\')\n const done = spawnSync(python, [\'-c\', script], {\n encoding: \'utf-8\',\n env: { ...process.env, PYTHONIOENCODING: \'utf-8\', LABULL_SMOKE_PAYLOAD: JSON.stringify(payload) },\n })\n const text = `${done.stdout || \'\'}${done.stderr || \'\'}`\n const at = text.indexOf(\'LABULL_SMOKE\')\n if (done.status !== 0 || at < 0) {\n note(`平台那一层没跑起来(退出码 ${done.status}):${text.trim().split(\'\\n\').slice(-1)[0] || \'没有输出\'}`)\n return\n }\n const results = JSON.parse(text.slice(at + \'LABULL_SMOKE\'.length))\n for (const item of results) {\n if (!item.issues.length) { ok(`${item.name}:过平台那一层判据(清单/结构/试加载/引用深检)`); continue }\n for (const [stage, message] of item.issues) ng(`${item.name}:[${stage}] ${message}`)\n }\n}\n\n// ──────────────────────────── 主流程 ────────────────────────────\n\nconst workspace = flags.workspace\n ? (findWorkspace(flags.workspace) || resolve(flags.workspace))\n : findWorkspace(process.cwd())\nif (!workspace) {\n console.log(\'找不到开发目录(里面应该有 workspace/ 那一层)。用法:node smoke.lock.mjs [--workspace <开发目录>] [块…]\')\n process.exit(2)\n}\nlet blocks = listBlocks(workspace)\nif (flags.targets.length) {\n blocks = blocks.filter((dir) => flags.targets.some((target) => basename(dir).includes(target)))\n if (!blocks.length) { console.log(`按 ${flags.targets.join(\' / \')} 没找到块(在 ${workspace} 里)`); process.exit(2) }\n}\ninfo(`开发目录:${workspace}`)\ninfo(`要查的块:${blocks.length} 个${flags.targets.length ? `(按 ${flags.targets.join(\' / \')} 筛的)` : flags.all ? \'(全部)\' : \'\'}`)\n\nconst payload = []\nfor (const dir of blocks) {\n const files = checkBlock(dir)\n payload.push({ name: basename(dir), files })\n}\n\nconst python = findPython()\nif (!python) {\n note(\'没找到装了 Django 的 Python ⇒ **平台那一层没跑**(试加载 / 引用深检 / 路由那几条没验)。\'\n + \'设 LABULL_SMOKE_PYTHON=<项目解释器> 或 --python <python> 再跑一次。\')\n} else {\n info(`\\n── 平台判据(用 ${python})──`)\n platformCheck(workspace, python, payload)\n}\n\ninfo(`\\n合计:过 ${pass.length} 条、不过 ${fail.length} 条${skip.length ? `、跳过 ${skip.length} 条` : \'\'}`)\nif (fail.length) {\n info(\'不过的:\')\n for (const item of fail) info(` - ${item}`)\n process.exit(1)\n}\ninfo(\'都过了。\')\n'
878
+ SMOKE_SCRIPT: str = '/* 块包自检(**本地就能跑**):把"发布前一定会后悔的那几条"提前抓出来。\n *\n * 🔴 这份文件的正文**有两份真源**:仓库里的 `tools/smoke.mjs`,以及导出时逐字抄进开发目录的\n * `smoke.lock.mjs`(`devkit.SMOKE_SCRIPT`,有对拍判据盯着两边逐字一致)。所以下面的用法\n * 写的是**开发目录里那个名字** —— 那才是读这份提示的人手里那份文件。\n * 在本仓库里跑它,路径是 `node tools/smoke.mjs`(名字不同,参数一字不差)。\n *\n * 用法(在开发目录里,或者指向它):\n * node smoke.lock.mjs # 只查"有改动的那几个块"(跟 git 比),没有就查全部\n * node smoke.lock.mjs --all # 查全部块\n * node smoke.lock.mjs <块文件夹|块 uuid> # 只查一个块(可以给多个)\n * node smoke.lock.mjs --workspace <开发目录>\n * LABULL_SMOKE_PYTHON=<python> node smoke.lock.mjs # 指一个装了 Django 的解释器 ⇒ 多跑一层\n *\n * 它查两类东西:\n * ① **纯文本层(不需要任何依赖)**:清单(四个必填键 + 可选的 type / requires / 搬家的 detail)、文件名形状、前端引用链\n * (`<script src>` / `<link href>` / `magicBlock.dialog({ file })` 指向的文件在不在包里)、"包里的文件没人引用"、\n * 以及**后端文件之间不许互相引用**这条规矩。\n * ② **平台那一层(有 Python + Django 时才跑)**:真的按平台自己的判据加载一次\n * (`labull_framework.magic_block.validate`)—— 那份判据是**同一份代码**,\n * 不是在这里抄一遍(抄一遍的下场是"本地说没事、管理台说不合格")。\n *\n * 🔴 退了就报清楚:没装 Python / 没装 Django 时,第 ② 层会明说"这一层没跑",\n * **不假装自己全绿**(自检的意义就是提前发现,不是给个安心)。\n * 🔴 输出不用 emoji:中文 Windows 控制台是 cp936,emoji 会变成乱码让整行不可读。\n */\n\nimport { readdirSync, readFileSync, statSync } from \'node:fs\'\nimport { basename, extname, join, relative, resolve, sep } from \'node:path\'\nimport { existsSync } from \'node:fs\'\nimport { spawnSync } from \'node:child_process\'\n\nconst ENTRY = \'index.open.html\'\nconst MANIFEST = \'magic-block.open.yaml\'\nconst BACKEND = \'backend_open.py\'\n/**\n * 平台随**块包**铺下来的那两份生成物:它们**不进块包**,引用不到也不算错。\n *\n * 🔴 名单里只留"可能出现在块文件夹里"的那两份(`api.open.d.ts` / `VERSION.lock`)——\n * 开发目录顶层那几份(`AGENTS.lock.md` / `backend.lock.pyi` / `pages.lock.ts` / `labull.css` /\n * `CONTROLS.lock.html` / `requirements.lock.txt`)**不在块包里**,摆在这儿是死条目。\n */\nconst PLATFORM_FILES = new Set([\n \'api.open.d.ts\', \'VERSION.lock\',\n])\n/** 爬目录时跳过的目录(与后端 `export.SKIP_DIRS` 同一份口径)。\n *\n * 🔴 末尾那两个是**你自己的地盘**(`.exclude`,老名字 `excluded`):里面的东西不是块包的一部分\n * (后端 `sdk.is_scratch()`)—— 导出不铺、读回不扫、保存不存。所以自检**根本看不到它们**,\n * 也就不必再按名字前缀筛第二遍。 */\nconst SKIP_DIRS = new Set([\'.git\', \'.venv\', \'venv\', \'__pycache__\', \'node_modules\', \'dist\', \'build\', \'.idea\', \'.vscode\', \'example\', \'.exclude\', \'excluded\'])\n/** 认哪些扩展名算"能进包的文本"。 */\nconst TEXT_EXT = new Set([\'.html\', \'.htm\', \'.js\', \'.mjs\', \'.cjs\', \'.ts\', \'.css\', \'.json\', \'.yaml\', \'.yml\', \'.md\', \'.txt\', \'.py\', \'.svg\'])\nconst MAX_DEPTH = 3\nconst MAX_FILE_BYTES = 512 * 1024\n\nconst argv = process.argv.slice(2)\nconst flags = { all: false, workspace: \'\', python: process.env.LABULL_SMOKE_PYTHON || \'\', targets: [] }\nfor (let i = 0; i < argv.length; i += 1) {\n if (argv[i] === \'--all\') flags.all = true\n else if (argv[i] === \'--workspace\') flags.workspace = argv[++i] || \'\'\n else if (argv[i] === \'--python\') flags.python = argv[++i] || \'\'\n else flags.targets.push(argv[i])\n}\n\nconst pass = []\nconst fail = []\nconst skip = []\nconst ok = (message) => { pass.push(message); console.log(` [OK] ${message}`) }\nconst ng = (message) => { fail.push(message); console.log(` [NG] ${message}`) }\nconst note = (message) => { skip.push(message); console.log(` [--] ${message}`) }\nconst info = (message) => console.log(message)\n\nfunction readText(path) {\n const raw = readFileSync(path)\n if (raw.length > MAX_FILE_BYTES) return { text: \'\', tooBig: true }\n return { text: raw.toString(\'utf8\').replace(/^\\uFEFF/, \'\'), tooBig: false }\n}\n\n// ──────────────────────────── 找开发目录与块 ────────────────────────────\n\nfunction findWorkspace(start) {\n let dir = resolve(start)\n for (let depth = 0; depth < 6; depth += 1) {\n const workspace = join(dir, \'workspace\')\n if (existsSync(workspace) && statSync(workspace).isDirectory()) return workspace\n if (basename(dir) === \'workspace\') return dir\n const parent = resolve(dir, \'..\')\n if (parent === dir) break\n dir = parent\n }\n return \'\'\n}\n\nfunction listBlocks(workspace) {\n const out = []\n // 🔴 块现在住在 **`workspace/<块类型>/<可读名>-<块 uuid>/`**(按类型分目录):\n // 所以这一层要看两格 —— 第一格是类型目录(它自己没有清单),第二格才是块。\n // ⚠️ 只往下走**一层**:`deep-depth` 的递归会把块内部也当成候选(那些目录没有清单,白跑)。\n const scan = (dir, depth) => {\n for (const name of readdirSync(dir).sort()) {\n const full = join(dir, name)\n if (!statSync(full).isDirectory()) continue\n // 🔴 只认**块文件夹**(里面有清单或入口):开发目录里还有 `example/` 这种平台铺下来的东西,\n // 把它也当成一个块去查,只会平白报一堆"缺这个缺那个"。\n if (existsSync(join(full, MANIFEST)) || existsSync(join(full, ENTRY))) {\n out.push(full)\n continue\n }\n if (depth === 0) scan(full, depth + 1)\n }\n }\n scan(workspace, 0)\n return out\n}\n\n/** 一个块文件夹 → `{相对路径: 正文}`(与平台 `export` 的扫描口径一致)。 */\nfunction readBlock(dir) {\n const files = {}\n const walk = (current, segments, depth) => {\n for (const name of readdirSync(current).sort()) {\n const full = join(current, name)\n const stat = statSync(full)\n const at = [...segments, name]\n if (stat.isDirectory()) {\n if (SKIP_DIRS.has(name) || depth >= MAX_DEPTH) continue\n walk(full, at, depth + 1)\n continue\n }\n if (!TEXT_EXT.has(extname(name).toLowerCase())) continue\n if (stat.size > MAX_FILE_BYTES) { files[at.join(\'/\')] = \'\'; continue }\n files[at.join(\'/\')] = readText(full).text\n }\n }\n walk(dir, [], 0)\n return files\n}\n\n// ──────────────────────────── ① 纯文本层 ────────────────────────────\n\nfunction parseManifest(text) {\n const found = {}\n const issues = []\n /** 那些"只提示、不算错"的话(老块清单里还留着的 detail 那一类)。 */\n const notes = []\n const stripComment = (line) => {\n let quote = \'\'\n for (let i = 0; i < line.length; i += 1) {\n const char = line[i]\n if (quote) { if (char === quote) quote = \'\' }\n else if (char === \'"\' || char === "\'") quote = char\n else if (char === \'#\') return line.slice(0, i)\n }\n return line\n }\n const lines = text.split(/\\r?\\n/)\n for (let index = 0; index < lines.length; index += 1) {\n const raw = lines[index]\n const indent = raw.length - raw.trimStart().length\n const line = stripComment(raw).trim()\n if (!line) continue\n const at = line.indexOf(\':\')\n if (at < 0) { issues.push(`第 ${index + 1} 行不是「键: 值」:${line}`); continue }\n const key = line.slice(0, at).trim()\n const value = line.slice(at + 1).trim().replace(/^([\'"])(.*)\\1$/, \'$2\')\n // 🔴 认得的键里**包含两个可选键**:`type`(块类型,缺省 plain)与 `requires`(依赖声明,\n // 清单里唯一的结构,整块要跳过)—— 漏一个,作者写了就会被自检报成"不认识的键"。\n if (![\'id\', \'module\', \'name\', \'brief\', \'type\', \'requires\', \'detail\'].includes(key)) { issues.push(`第 ${index + 1} 行有不认识的键:${key}`); continue }\n if (found[key] !== undefined) { issues.push(`第 ${index + 1} 行重复写了 ${key}`); continue }\n found[key] = value\n /* 🔴 `requires:` 那一块**整块**是结构(七个键 + `surface` 再深一层):它下面那些 `- 项` /\n * `类路径: [方法名]` 都不是清单的行 —— 不整块跳过就会在这里报一串假错。\n * 形状判据在平台那一侧(`manifest._parse_requires`),这里只管"别把它当写错的清单"。 */\n if (key === \'requires\') {\n while (index + 1 < lines.length && (!lines[index + 1].trim() || /^\\s/.test(lines[index + 1]))) index += 1\n continue\n }\n /* 🔴 **块标量**(`detail: |` + 缩进几行):老块清单里那个搬家的 `detail` 是"业务功能说明,\n * 用 Markdown 写",多行是常态。块里那几行是**正文**(可能以 `-` 或 `#` 开头,那是 md\n * 的列表与标题),不是清单的行 —— 不跳过它们就会在这里报一串"不是「键: 值」"的假错。 */\n if (/^[|>][-+]?$/.test(value)) {\n while (index + 1 < lines.length && (!lines[index + 1].trim() || /^\\s/.test(lines[index + 1]))) index += 1\n }\n if (key === \'detail\' && indent === 0) {\n // 🔴 **提示、不是错**(和平台那一侧同一个口径):老块的清单里可能还留着它 ——\n // 报成 NG 会让那些块直接过不了自检,而它一个字都没写坏,只是"住错地方了"。\n notes.push(`第 ${index + 1} 行还写着 detail:它已经搬到块包内的 README.open.md(这里会被忽略,内容不丢)`)\n }\n }\n // 🔴 只要求**必填**的四个:`detail` 是可选的,没写不是错(与平台同一套判据)。\n for (const key of [\'id\', \'module\', \'name\', \'brief\']) {\n if (!found[key]) issues.push(`清单缺 ${key}(或者是空的)`)\n }\n if (found.id && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(found.id)) {\n issues.push(`清单里的 id 不是 uuid:${found.id}`)\n }\n if (found.name && found.name.length > 40) issues.push(`name 超过 40 个字:${found.name.length}`)\n // `type`(可选):这里只判**写法**(与平台那一侧同一口径);"有没有这种类型"只有平台知道。\n if (found.type !== undefined && !/^[a-z][A-Za-z0-9]*$/.test(found.type)) {\n issues.push(`清单里的 type 写法不合法:${found.type}(小写字母开头、后面字母与数字;缺省不写就是 plain)`)\n }\n return { found, issues, notes }\n}\n\n/** 弹窗文件引用:`magicBlock.dialog({ file: \'dialog/x.html\' })`。\n * ⚠️ 判据是"`file` 前面没有嵌套的 `}`"(`assets: {…}` 写在 `file` 前面就扫不到)—— 只当**引用链**用,\n * 扫不到不会把块判错,只是那个文件会落进"没人引用"的提示里。 */\nconst DIALOG_FILE = /magicBlock\\s*\\.\\s*dialog\\s*\\(\\s*\\{[^}]*?\\bfile\\s*:\\s*([\'"])([^\'"]+)\\1/gs\n\n/** 前端引用链:入口里 `src=` / `href=` 与 `dialog({ file })` 指向的**包内**文件在不在。回 `{missing, referenced}`。 */\nfunction frontendRefs(files) {\n const referenced = new Map() // 引用者 → 被引用的相对路径\n const missing = []\n const pick = (ref) => {\n if (/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i.test(ref)) return undefined // 外链 / data: → 不是包内文件\n const clean = ref.split(/[?#]/)[0].replace(/^\\.\\//, \'\').replace(/^\\/+/, \'\')\n if (!clean) return undefined\n if (files[clean] !== undefined) return clean\n const base = clean.split(\'/\').pop()\n return base ? Object.keys(files).find((name) => name.split(\'/\').pop() === base) : undefined\n }\n const scan = (owner, body) => {\n for (const match of body.matchAll(/<(?:script|link)\\b[^>]*?(?:src|href)\\s*=\\s*(["\'])(.*?)\\1[^>]*>/gi)) {\n const ref = match[2]\n const hit = pick(ref)\n if (hit) referenced.set(owner, [...(referenced.get(owner) || []), hit])\n else if (!/^(?:[a-z][a-z0-9+.-]*:|\\/\\/)/i.test(ref) && basename(ref) !== \'labull.css\') {\n missing.push(`${owner} 引用的 ${ref} 不在包里`)\n }\n }\n // 弹窗那一篇也是包内文件:`magicBlock.dialog({ file: \'dialog/x.html\' })`。\n for (const hit of body.matchAll(DIALOG_FILE)) {\n const ref = hit[2]\n const found = pick(ref)\n if (found) referenced.set(owner, [...(referenced.get(owner) || []), found])\n else missing.push(`${owner} 里的 magicBlock.dialog({ file: \'${ref}\' }) 找不到那个文件`)\n }\n }\n for (const [name, body] of Object.entries(files)) {\n if (/\\.(?:html|js)$/i.test(name)) scan(name, body)\n }\n return { missing, referenced }\n}\n\n/** 每个 `.py` 里 import 的**包内**文件("文件之间不许互相引用"这条规矩的判据)。\n * ⚠️ `.exclude/` 里的草稿不在 `files` 里(`readBlock` 按 `SKIP_DIRS` 就跳过了)。 */\nfunction siblingImports(files) {\n const known = new Map()\n for (const name of Object.keys(files)) {\n if (!name.endsWith(\'.py\')) continue\n const stem = name.slice(0, -3)\n known.set(stem, name)\n known.set(stem.split(\'/\').pop(), name)\n }\n const out = []\n for (const [name, body] of Object.entries(files)) {\n if (!name.endsWith(\'.py\')) continue\n for (const line of body.split(/\\r?\\n/)) {\n const trimmed = line.trim()\n if (trimmed.startsWith(\'#\')) continue\n const from = /^from\\s+([A-Za-z_][\\w.]*)\\s+import\\b/.exec(trimmed)\n const plain = /^import\\s+(.+)$/.exec(trimmed)\n const names = []\n if (from) names.push(from[1])\n else if (plain) names.push(...plain[1].split(\',\').map((item) => item.trim().split(/\\s+as\\s+/)[0]))\n for (const imported of names) {\n const hit = known.get(imported) || known.get(imported.split(\'.\')[0])\n if (hit && hit !== name) out.push(`${name} import 了包内的 ${imported}(就是 ${hit})`)\n }\n }\n }\n return out\n}\n\n/**\n * 从开发目录顶层那份 `backend.lock.pyi` 里读"**你现在配了哪些键**"。\n *\n * 🔴 为什么要从清单里读,而不是自己去扫环境:块作者本地通常**没有** Django(那份清单是平台\n * 导出时算的)。清单里那一块就是"导出这一刻真的存在的 `BLOCK_` 键名",是这里唯一的真源。\n * 格式约定:说明那行是「你现在配了哪些键」,紧跟其后的一串 `# KEY` 就是键名。\n */\nfunction readConfigKeys(workspace) {\n const path = join(workspace, \'..\', \'backend.lock.pyi\')\n if (!existsSync(path)) return null\n const lines = readText(path).text.split(/\\r?\\n/)\n const out = []\n let inside = false\n for (const line of lines) {\n if (line.includes(\'你现在配了哪些键\')) { inside = true; continue }\n if (!inside) continue\n const hit = /^#\\s{3}([A-Za-z_][A-Za-z0-9_]*)\\s*$/.exec(line)\n if (hit) { out.push(hit[1]); continue }\n if (line.trim() && !line.startsWith(\'#\')) break\n if (line.startsWith(\'# (\')) break // "(一个都没有:…)"\n }\n return out\n}\n\n/** 在开发目录附近找 `.env`(`.env` / `.env.local` / `.env.dev`),返回找到的第一个的键名集合。 */\nfunction readEnvKeys(workspace) {\n const names = new Set()\n let dir = resolve(workspace)\n for (let depth = 0; depth < 4; depth += 1) {\n for (const file of [\'.env\', \'.env.local\', \'.env.dev\']) {\n const full = join(dir, file)\n if (!existsSync(full)) continue\n for (const raw of readText(full).text.split(/\\r?\\n/)) {\n const line = raw.trim()\n if (!line || line.startsWith(\'#\')) continue\n const at = line.indexOf(\'=\')\n if (at <= 0) continue\n names.add(line.slice(0, at).trim())\n }\n return { file: full, names }\n }\n const parent = resolve(dir, \'..\')\n if (parent === dir) break\n dir = parent\n }\n return null\n}\n\n/** 一份 `.py` 里用到的配置键:`ctx.config[\'KEY\']` / `ctx.config.get(\'KEY\')` / `"KEY" in ctx.config`。 */\nfunction configUsage(files) {\n const out = []\n const patterns = [\n /config\\[\\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]\\s*\\]/g,\n /config\\.get\\(\\s*[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]/g,\n /[\'"]([A-Za-z_][A-Za-z0-9_]*)[\'"]\\s+in\\s+config/g,\n ]\n for (const [name, body] of Object.entries(files)) {\n if (!name.endsWith(\'.py\')) continue\n for (const pattern of patterns) {\n for (const match of body.matchAll(pattern)) {\n if (!out.some((item) => item.file === name && item.key === match[1])) {\n out.push({ file: name, key: match[1] })\n }\n }\n }\n }\n return out\n}\n\n/**\n * 查"块用到的配置键对不对"——\n * ① 代码里用到的键必须在清单里(清单空着就跳过,不然没配过的人会被满屏报错吓到);\n * ② 找到了 `.env` 就再对一遍:写了却没配的键是**必错项**(运行期取不到),配了却没人用的只提示。\n */\nfunction checkConfig(label, files, workspace) {\n const known = readConfigKeys(workspace)\n const env = readEnvKeys(workspace)\n const usage = configUsage(files)\n let bad = 0\n for (const { file, key } of usage) {\n if (Array.isArray(known) && known.length > 0 && !known.includes(key)) {\n bad += 1\n ng(`${label}:${file} 里用了 ctx.config[\'${key}\'],但它不在清单里 —— `\n + `导出那一刻环境里配的是:${known.join(\' / \')}(写错的键运行期只会取到 None,不报错)`)\n }\n if (env && !env.names.has(`BLOCK_${key}`)) {\n bad += 1\n ng(`${label}:${file} 里用了 ctx.config[\'${key}\'],但 ${basename(env.file)} 里没有 BLOCK_${key}(运行期取不到)`)\n }\n }\n if (!env) {\n note(`${label}:附近的 .env 没找到 ⇒ "这个键配了没有"那一半没查(代码用到的键仍与清单对过)`)\n } else {\n const used = new Set(usage.map((item) => item.key))\n const unused = [...env.names]\n .filter((name) => name.startsWith(\'BLOCK_\') && !used.has(name.slice(\'BLOCK_\'.length)))\n .map((name) => name.slice(\'BLOCK_\'.length))\n if (unused.length) note(`${label}:${basename(env.file)} 里这几个 BLOCK_ 键没有被用到:${unused.join(\' / \')}`)\n }\n if (usage.length && bad === 0) {\n ok(`${label}:代码里用到的配置键都对得上(${usage.map((item) => item.key).join(\' / \')})`)\n }\n}\n\n/**\n * 块里**越界 import**的那些(判据来自 `AGENTS.lock.md`,不是这里另发明一套):\n * · Django 自带的模型(`auth_permission` / `Group` / `ContentType` …)—— 清单里根本没有它们,\n * 而它们**不是业务权限目录**(这是真出过的一类错);\n * · 平台自己的库直接 import(该走 `ctx`)。\n * 只认"从这几处 import **模型**"的写法;`django.contrib.auth.models.AbstractUser` 这类\n * 造模型用的基类也会被点到 —— 那种要写在**项目**里,不该写在块里。\n */\nconst FORBIDDEN_IMPORTS = [\n { pattern: /from\\s+django\\.contrib\\.(auth|admin|contenttypes|sessions)[\\w.]*\\s+import\\s+(.+)/, what: \'Django 自带的模型\' },\n { pattern: /import\\s+django\\.contrib\\.(auth|admin|contenttypes|sessions)\\b/, what: \'Django 自带的模型\' },\n { pattern: /from\\s+labull_framework\\.models\\s+import\\s+/, what: \'平台自己的模型(要块/用户走 ctx)\' },\n]\n\nfunction forbiddenImports(files) {\n const out = []\n for (const [name, body] of Object.entries(files)) {\n if (!name.endsWith(\'.py\')) continue\n body.split(/\\r?\\n/).forEach((raw, index) => {\n const line = raw.trim()\n if (!line || line.startsWith(\'#\')) return\n for (const { pattern, what } of FORBIDDEN_IMPORTS) {\n if (pattern.test(line)) out.push({ file: name, line: index + 1, text: line, what })\n }\n })\n }\n return out\n}\n\nfunction checkBlock(dir) {\n const label = basename(dir)\n info(`\\n── ${label} ──`)\n const files = readBlock(dir)\n const names = Object.keys(files)\n\n if (!files[ENTRY]) ng(`${label}:缺入口 ${ENTRY}`)\n else ok(`${label}:有入口 ${ENTRY}`)\n\n if (files[MANIFEST] === undefined) {\n ng(`${label}:缺清单 ${MANIFEST}`)\n } else {\n const { found, issues, notes } = parseManifest(files[MANIFEST])\n if (issues.length) ng(`${label}:清单有问题 —— ${issues.join(\';\')}`)\n else ok(`${label}:清单必填的四个键齐(${found.module} / ${found.name})${found.requires ? \',另有依赖声明\' : \'\'}`)\n for (const line of notes) note(`${label}:${line}`)\n }\n\n // 🔴 业务说明与依赖声明各有各的家(两份 `.open.` 文件)——它们**不是**"没人引用"那一类。\n if (files[\'README.open.md\'] !== undefined) ok(`${label}:有 README.open.md(业务说明住在这里)`)\n if (files[\'requires.open.yaml\'] !== undefined) ok(`${label}:有 requires.open.yaml(依赖声明的草稿)`)\n // 🔴 "清单里写着 detail"那种老写法由 `parseManifest` 出**提示**(`notes`,不算错)——\n // 判据与平台那一侧同一个口径:报成错会让老块直接过不了自检,而它一个字都没写坏。\n\n for (const name of names) {\n // 🔴 判据与平台那两份**逐字同源**(`manifest.FILE_NAME_PATTERN` / `manifest.ts` 那份):\n // 不许反斜杠 / 冒号 / Windows 保留字符 / 控制字符、不许 `..` 与绝对路径,**中文可以**\n // (块里那几层目录常常是中文名:`cards/申请单/`)。\n if (!/^(?![A-Za-z]:)(?!\\/)[^\\\\:*?"<>|\\u0000-\\u001f\\u007f]+$/.test(name)\n || name.split(\'/\').includes(\'..\')) {\n ng(`${label}:文件名不合法 ${name}`)\n }\n }\n\n const { missing, referenced } = frontendRefs(files)\n for (const item of missing) ng(`${label}:${item}`)\n if (!missing.length && files[ENTRY]) ok(`${label}:前端的引用都指得到(script/link 与 dialog({ file }) 都在包里)`)\n\n // "包里的文件没人引用":只提示(入口文件与清单、后端本来就不该被引用)。\n const used = new Set([ENTRY, MANIFEST, BACKEND, ...[...referenced.values()].flat()])\n const orphans = names.filter((name) => !used.has(name) && !PLATFORM_FILES.has(name)\n && !name.endsWith(\'.py\') && name !== \'README.md\')\n if (orphans.length) note(`${label}:这几个文件没有任何引用(写进包里也没人用):${orphans.join(\' / \')}`)\n\n const cross = siblingImports(files)\n const codePy = names.filter((name) => name.endsWith(\'.py\'))\n if (cross.length) {\n for (const item of cross) {\n ng(`${label}:${item} —— **块里文件之间不能互相引用**(平台把每个 .py 各自独立加载):`\n + \'要么把那段逻辑复制过去,要么放进项目里的公共模块再 import\')\n }\n } else if (codePy.length) {\n ok(`${label}:后端那些 .py 之间没有互相引用(${codePy.join(\' / \')})`)\n }\n\n checkConfig(label, files, workspace)\n\n const forbidden = forbiddenImports(files)\n for (const item of forbidden) {\n ng(`${label}:${item.file} 第 ${item.line} 行 import 了${item.what} —— `\n + `${item.text.trim()}(见同目录 AGENTS.lock.md 第 1 条:那不是业务口径)`)\n }\n if (!forbidden.length && codePy.length) {\n ok(`${label}:没有 import Django 自带的模型 / 平台内部模型(只用清单里的东西)`)\n }\n return files\n}\n\n// ──────────────────────────── ② 平台那一层 ────────────────────────────\n\nfunction findPython() {\n if (flags.python) return flags.python\n const candidates = [process.env.LABULL_PYTHON, \'python\', \'python3\', \'py\'].filter(Boolean)\n const probe = \'import django, labull_framework; print("ok")\'\n for (const candidate of candidates) {\n const done = spawnSync(candidate, [\'-c\', probe], {\n encoding: \'utf-8\',\n env: { ...process.env, PYTHONIOENCODING: \'utf-8\' },\n })\n if (done.status === 0 && String(done.stdout).includes(\'ok\')) return candidate\n }\n return \'\'\n}\n\nfunction platformCheck(workspace, python, payload) {\n // 🔴 子进程正文:整包走**环境变量**递进去(这是这个脚本唯一这么递的地方),所以读完**必须**清掉——\n // 平台校验会再起"孙子进程"(流程声明 / 引用深检),它继承环境。Windows 的环境块有上限(约 32K),\n // 继承着几百 KB 的 payload,孙子进程直接起不来(`started=False`),而平台把"起不来"当软失败 ⇒\n // 流程块假报"flow.py 里一条流程都没注册"(D14)。这一行就是这个 bug 的修复。\n const script = [\n \'import json, os, sys\',\n \'from labull_framework.magic_block import validate\',\n \'payload = json.loads(os.environ["LABULL_SMOKE_PAYLOAD"])\',\n \'os.environ.pop("LABULL_SMOKE_PAYLOAD", None) # 别让它继承给孙子进程(Windows 环境块有上限)\',\n \'out = []\',\n \'for item in payload:\',\n \' issues = validate.check_block(item["files"])\',\n \' out.append({"name": item["name"], "issues": [list(pair) for pair in issues]})\',\n \'print("LABULL_SMOKE" + json.dumps(out, ensure_ascii=False))\',\n ].join(\'\\n\')\n const done = spawnSync(python, [\'-c\', script], {\n encoding: \'utf-8\',\n env: { ...process.env, PYTHONIOENCODING: \'utf-8\', LABULL_SMOKE_PAYLOAD: JSON.stringify(payload) },\n })\n const text = `${done.stdout || \'\'}${done.stderr || \'\'}`\n const at = text.indexOf(\'LABULL_SMOKE\')\n if (done.status !== 0 || at < 0) {\n note(`平台那一层没跑起来(退出码 ${done.status}):${text.trim().split(\'\\n\').slice(-1)[0] || \'没有输出\'}`)\n return\n }\n const results = JSON.parse(text.slice(at + \'LABULL_SMOKE\'.length))\n for (const item of results) {\n if (!item.issues.length) { ok(`${item.name}:过平台那一层判据(清单/结构/试加载/引用深检)`); continue }\n for (const [stage, message] of item.issues) ng(`${item.name}:[${stage}] ${message}`)\n }\n}\n\n// ──────────────────────────── 主流程 ────────────────────────────\n\nconst workspace = flags.workspace\n ? (findWorkspace(flags.workspace) || resolve(flags.workspace))\n : findWorkspace(process.cwd())\nif (!workspace) {\n console.log(\'找不到开发目录(里面应该有 workspace/ 那一层)。用法:node smoke.lock.mjs [--workspace <开发目录>] [块…]\')\n process.exit(2)\n}\nlet blocks = listBlocks(workspace)\nif (flags.targets.length) {\n blocks = blocks.filter((dir) => flags.targets.some((target) => basename(dir).includes(target)))\n if (!blocks.length) { console.log(`按 ${flags.targets.join(\' / \')} 没找到块(在 ${workspace} 里)`); process.exit(2) }\n}\ninfo(`开发目录:${workspace}`)\ninfo(`要查的块:${blocks.length} 个${flags.targets.length ? `(按 ${flags.targets.join(\' / \')} 筛的)` : flags.all ? \'(全部)\' : \'\'}`)\n\nconst payload = []\nfor (const dir of blocks) {\n const files = checkBlock(dir)\n payload.push({ name: basename(dir), files })\n}\n\nconst python = findPython()\nif (!python) {\n note(\'没找到装了 Django 的 Python ⇒ **平台那一层没跑**(试加载 / 引用深检 / 路由那几条没验)。\'\n + \'设 LABULL_SMOKE_PYTHON=<项目解释器> 或 --python <python> 再跑一次。\')\n} else {\n info(`\\n── 平台判据(用 ${python})──`)\n platformCheck(workspace, python, payload)\n}\n\ninfo(`\\n合计:过 ${pass.length} 条、不过 ${fail.length} 条${skip.length ? `、跳过 ${skip.length} 条` : \'\'}`)\nif (fail.length) {\n info(\'不过的:\')\n for (const item of fail) info(` - ${item}`)\n process.exit(1)\n}\ninfo(\'都过了。\')\n'
878
879
 
879
880
 
880
881
  def example_files(parts: dict[str, str] | None = None) -> dict[str, str]:
@@ -1583,7 +1584,7 @@ def dev_doc(blocks: int) -> str:
1583
1584
  9. **业务功能说明写在块包内的 `README.open.md` 里、不在清单里**(`brief` 只是清单里那句短话):写业务用途、谁在什么场景用、输入输出的含义与边界;`detail` 是遗留键,老块里还留着的话读回时会搬进这个文件。**它每一趟都要跟着改**:这一版块变成什么样,它就写成什么样 —— 老功能的说明不做保留地删掉(与第 5 节"改老功能就整段删掉旧的"是同一条规矩,只是落在文档上)。
1584
1585
  10. **依赖声明写在清单的 `requires` 里**(可选):项目模型 / pip 包 / settings 键 / `BLOCK_*` 变量 / 跨块地址这些用到就声明 —— 它**不授予任何能力**,只是让平台把"外部依赖"核对一遍、换项目或改配置后替你**报出来**;草稿不用手写,导出的 `requires.open.yaml` 会把建议写在注释里,读回时**只合并**那一处。
1585
1586
  11. **块完全独立、不许跨块**:块与块之间**不共享文件、不共享代码、不互相引用** —— 你要用的东西就在这个块自己的文件夹里。规矩适用于所有方向:不去读 / 引另一个块的任何文件,不把"公共文件"放在 `workspace/` 顶层或两个块之间指望两个块共用,也不假设自己的代码会被别的块用到。**一份代码要服务两个块就只有两个合法去处**:各块里**各写一份**(复制),或者放进**项目**里的公共模块(那是普通 Python,块后端能 import —— 见第 1 节 `requires` 怎么报备;⚠️ 加这个公共模块要动**项目**的代码,按规矩 13 先问用户)。前端更简单:包内相对路径引用的文件**必须在这个块文件夹里**,`../` 爬到别的块去的一律不算(宿主也不会替你内联它)。
1586
- 12. **所有弹窗都走 `MagicBlockDialogs`**:块里**不许自己画弹窗 / 浮层 / 遮罩**(`position: fixed` / `100vh` / 手写 `.modal` 那一类)。要弹就 `await magicBlock.dialog({{…}})`,由**应用根上挂的那一个 `<MagicBlockDialogs />`** 在真视口里渲染(块可以有好几屏高,自己画的浮层会被摆到看不见的地方);要自己写整篇就传 `{{ html }}`,里面用 `magicBlock.done({{…}})` 交回选择。**弹窗多大不用你算**:`{{ html }}` 那一型的面板**按内容定高**(短的就小的、长了到 `90dvh` 封顶后整篇自滚),`size.height` 只是**上限**不是"必须占这么高";而且**露面之后尺寸固定**(内容再变多只在里面滚,不会"先小后大")⇒ 异步取的数据要在 `dialog()` 之前 await 好。**要看原图 / PDF 别塞进 `html`**(那条 512 KB 上限会把图缩掉):走 `dialog({{ html, assets: {{ 名字: dataURL }} }})` + 弹窗里 `await magicBlock.assets()`。应用那边没挂 `<MagicBlockDialogs />` 时调用会**当场报一句人话**(不是静默失败)。
1587
+ 12. **所有弹窗都走 `MagicBlockDialogs`**:块里**不许自己画弹窗 / 浮层 / 遮罩**(`position: fixed` / `100vh` / 手写 `.modal` 那一类)。要弹就 `await magicBlock.dialog({{…}})`,由**应用根上挂的那一个 `<MagicBlockDialogs />`** 在真视口里渲染(块可以有好几屏高,自己画的浮层会被摆到看不见的地方)。**弹窗只有一种写法:块包里的那一篇 `.html`** —— `await magicBlock.dialog({{ file: 'dialog/close-order.html' }})`;要传参就 `{{ data: {{…}} }}`(那一篇里 `await magicBlock.data()`),结果由那一篇 `magicBlock.done({{…}})` 交回。临时 / 极短的一篇也可以 `{{ html: '…' }}` 直接给一串(与 `file` 二选一)。**那一篇就是一份普通网页**:`size` 写了就是**精确尺寸**(宿主不量、不冻结、不封顶,`100vw/100vh` 就是真满屏),不写 `height` 才按内容定高;**滚动 / 滚轮 / `overflow` 全归你写**(平台只给一条 `html, body {{ margin: 0; overflow: auto; box-sizing: border-box }}` 的**默认值**,你自己的样式盖得住它);Esc 想自己接就 `preventDefault()`(平台那一下看到它就不替你关)。**要看原图 / PDF 别塞进 HTML**:走 `dialog({{ file, assets: {{ 名字: dataURL }} }})` + 弹窗里 `await magicBlock.assets()`(单条 ≤ 24 MB,与 `/bx/` 的正文上限同一个数)。应用那边没挂 `<MagicBlockDialogs />` / 文件路径写错时,调用会**当场报一句人话**(不是静默失败)。
1587
1588
  13. **一趟只动一个块,越界先问**:每次开发**只在当前 workspace 的一个块里**做。要动**第二个块**、或者要改**平台的前后端代码**(`backend/` / `frontend/`),**必须先向用户提问、得到同意才继续**;块里**不许把平台代码当自己的改**(它不进块包,还会影响别的块)。展开见 2.1。
1588
1589
 
1589
1590
  > 拿不准就把"我打算这么做"写下来先说一遍 —— 这里面的多数是"想清楚就不会犯"的。
@@ -1727,7 +1728,7 @@ MagicSteps.renderBar(document.getElementById('steps'), payload) // 步骤条
1727
1728
  | `400` | 参数不对(`errors.BadRequest` 的中文原话就是原因) | 照原话改前端传参;后端**每个 handler 第一句**都要校验 |
1728
1729
  | `500` | 服务端异常(原话在服务器日志里) | 最常见:模型字段/枚举名写错、忘了 `try`、返回了不能编 JSON 的值 |
1729
1730
  | 页面在但**按钮没反应** | 控制台里的 `magicBlock is not defined` 之类 | 脚本没被内联进来(同样查引用),或写了 `import` / `export`(块里**没有模块系统**) |
1730
- | **弹窗没反应 / 弹到看不见的地方** | 那个弹窗是块自己画的吗 | 🔴 **块里不许自己画弹窗**(`position: fixed` / `100vh` / 手写 `.modal` 都不行)—— 块可以有好几屏高,基准是**整块高度**、不是用户那一屏。一律用 `await magicBlock.dialog({{…}})`(**宿主页面**渲染,位置一定对),由应用那边挂的 `<MagicBlockDialogs />` 实现;要自己写整篇就传 `{{ html }}`,里面用 `magicBlock.done({{…}})` 交回选择(没挂那个宿主的话,调用会当场报一句人话) |
1731
+ | **弹窗没反应 / 弹到看不见的地方** | 那个弹窗是块自己画的吗 | 🔴 **块里不许自己画弹窗**(`position: fixed` / `100vh` / 手写 `.modal` 都不行)—— 块可以有好几屏高,基准是**整块高度**、不是用户那一屏。一律用 `await magicBlock.dialog({{ file: 'dialog/xxx.html' }})`(**宿主页面**渲染,位置一定对),由应用那边挂的 `<MagicBlockDialogs />` 实现;那一篇里用 `magicBlock.done({{…}})` 交回选择;要传参就 `{{ data: {{…}} }}`(那一篇里 `await magicBlock.data()`)。没挂那个宿主 / 文件路径写错时,调用会**当场报一句人话** |
1731
1732
  | 拿不到当前用户 / 读不到 localStorage | 这是**设计如此** | 沙箱 iframe 是不透明源的:只能走 `magicBlock.*` 那座桥 |
1732
1733
 
1733
1734
  ## 三、提交前
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labull-framework
3
- Version: 3.0.8
3
+ Version: 3.1.0
4
4
  Summary: Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks
5
5
  Author: Labull Framework
6
6
  License-Expression: MIT
@@ -142,10 +142,12 @@ class Order:
142
142
 
143
143
  配置:环境变量里**带 `BLOCK_` 前缀**的才会进 `ctx.config`(`BLOCK_OCR_KEY=a` ⇒ `ctx.config['OCR_KEY']`)。
144
144
 
145
- 🔴 **弹窗走平台**:块里 `await magicBlock.dialog({ title, body, fields, buttons })` —— 由宿主页面渲染
146
- (前端要挂一次 `<MagicBlockDialogs />`)。块自己画的浮层**摆不正**:块可以是好几屏高,块里
147
- `position: fixed` / `100vh` 的基准是"整块高度"而不是用户那一屏。要自己写整篇就传 `{ html }`,
148
- 宿主给一个确定大小的面板 + 另一格沙箱 iframe 装它,里面用 `magicBlock.done({ button, values })` 交回选择。
145
+ 🔴 **弹窗走平台**:块里 `await magicBlock.dialog({ file: 'dialog/close-order.html' })` —— 那一篇是块包里的
146
+ 一个普通 `.html`(结构与脚本都在里面),由宿主页面在真视口里渲染(前端要挂一次 `<MagicBlockDialogs />`)。
147
+ 块自己画的浮层**摆不正**:块可以是好几屏高,块里 `position: fixed` / `100vh` 的基准是"整块高度"而不是那一屏。
148
+ 要给这一次弹窗传参就 `{ data: {…} }`(那一篇里 `await magicBlock.data()`),结果由那一篇
149
+ `magicBlock.done({ button, values })` 交回;临时 / 极短的一篇也可以 `{ html: '…' }` 直接给一串。
150
+ 尺寸、滚动、滚轮全归那一篇写(不给 `size.height` 就按内容定高)。
149
151
 
150
152
  ## 虚拟类(`@virtual_class`)
151
153
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "labull-framework"
7
- version = "3.0.8"
7
+ version = "3.1.0"
8
8
  description = "Labull Framework: a pluggable Django app with Authing OIDC login and magic blocks"
9
9
  readme = "README-pypi.md"
10
10
  requires-python = ">=3.10"