labull-framework 0.1.0__py3-none-any.whl
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.
- labull_framework/__init__.py +8 -0
- labull_framework/admin.py +63 -0
- labull_framework/apps.py +33 -0
- labull_framework/auth/__init__.py +4 -0
- labull_framework/auth/backend.py +144 -0
- labull_framework/auth/bootstrap.py +47 -0
- labull_framework/auth/sso.py +259 -0
- labull_framework/auth/views.py +184 -0
- labull_framework/canvas/__init__.py +19 -0
- labull_framework/canvas/data.py +523 -0
- labull_framework/canvas/gitinfo.py +155 -0
- labull_framework/canvas/static/canvas.css +487 -0
- labull_framework/canvas/static/canvas.js +1036 -0
- labull_framework/canvas/static/page.html +52 -0
- labull_framework/canvas/views.py +121 -0
- labull_framework/env.py +187 -0
- labull_framework/jsonio.py +57 -0
- labull_framework/magic_block/__init__.py +2 -0
- labull_framework/magic_block/devkit.py +428 -0
- labull_framework/magic_block/dispatch.py +314 -0
- labull_framework/magic_block/export.py +773 -0
- labull_framework/magic_block/health.py +220 -0
- labull_framework/magic_block/registry.py +157 -0
- labull_framework/magic_block/sdk.py +245 -0
- labull_framework/magic_block/service.py +405 -0
- labull_framework/magic_block/validate.py +558 -0
- labull_framework/magic_block/views.py +407 -0
- labull_framework/management/__init__.py +0 -0
- labull_framework/management/commands/__init__.py +0 -0
- labull_framework/management/commands/labull_contract.py +52 -0
- labull_framework/migrations/0001_initial.py +71 -0
- labull_framework/migrations/__init__.py +0 -0
- labull_framework/models.py +76 -0
- labull_framework/settings.py +223 -0
- labull_framework/urls.py +93 -0
- labull_framework-0.1.0.dist-info/METADATA +140 -0
- labull_framework-0.1.0.dist-info/RECORD +40 -0
- labull_framework-0.1.0.dist-info/WHEEL +5 -0
- labull_framework-0.1.0.dist-info/licenses/LICENSE +28 -0
- labull_framework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""开发包里**后端那几份生成物**的正文:`backend.lock.pyi` 与 `README.lock.md`。
|
|
2
|
+
|
|
3
|
+
🔴 它们**不参与运行**:块作者拿去查"我能用什么"(能 import 哪几个库、`ctx` 长什么样、
|
|
4
|
+
环境变量有哪几个、项目里有哪些模型)。真正被 import 的是 `sdk`(`sdk.LIB_SDK`),
|
|
5
|
+
模型走每张表下面那行注释里的真实路径。
|
|
6
|
+
🔴 **正文只有这一处**:浏览器那一侧只负责把文件写进用户选的目录(`frontend/magic-block/src/localfs.ts`),
|
|
7
|
+
一条判据、一句文案都不抄 —— 抄一份的下场是"两边迟早漂",而这里漂了没人会立刻发现。
|
|
8
|
+
⚠️ 前端自己那份 `pages.lock.ts`(Page 枚举)反过来**由前端生成**:路由表只有前端有,
|
|
9
|
+
后端一个页面路径都不知道(所以导出请求里不带它,也没法带)。
|
|
10
|
+
🔴 **这里永远不写值**:环境变量只报名字与说明(`env.ENV_ITEMS`),而环境里有数据库连接串与登录密钥。
|
|
11
|
+
|
|
12
|
+
正文全部**从真源算出来**(`sdk` 的 dataclass / `ctx.ENV_ITEMS` / `canvas.data.shape()`),
|
|
13
|
+
不是手抄一遍:`ctx` 多一个键、`sdk` 多一个错误类、多一个环境变量,这里跟着变。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import dataclasses
|
|
19
|
+
import inspect
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from ..env import ENV_ITEMS, EnvItem
|
|
23
|
+
from . import sdk
|
|
24
|
+
from .sdk import BLOCK_BACKEND_FILE, BLOCK_ENTRY, BLOCK_MANIFEST
|
|
25
|
+
|
|
26
|
+
# ──────────────────────────── 生成物的名字(**契约**) ────────────────────────────
|
|
27
|
+
#
|
|
28
|
+
# 🔴 这几个名字是给用户看的,也是"读回改动"时要跳过的名单 —— 名字的真源就在**写这几份东西的这一处**,
|
|
29
|
+
# 导出那一层直接用(`export.PLATFORM_FILES` / `_SKIP_ROOT_FILES`),不再各写一份字面量。
|
|
30
|
+
#: 开发目录的说明。
|
|
31
|
+
README = 'README.lock.md'
|
|
32
|
+
#: 后端那几样(能 import 的库 / `ctx` / 环境变量 / 模型)。
|
|
33
|
+
BACKEND_LOCK = 'backend.lock.pyi'
|
|
34
|
+
#: 精简版开发说明(改一个块要知道的全部事)。
|
|
35
|
+
GUIDE = 'GUIDE.lock.md'
|
|
36
|
+
#: 前端那一份(Page 枚举):**正文由前端生成**(路由表只有前端有),名字后端也认(跳过名单里有它)。
|
|
37
|
+
#: ⚠️ 与 `frontend/magic-block/src/devkit.ts` 的 `PAGES_LOCK` **逐字一致**。
|
|
38
|
+
PAGES_LOCK = 'pages.lock.ts'
|
|
39
|
+
#: 主题 CSS 与控件清单:**正文也由前端生成**(样式层只有前端有 —— 后端一个字都不知道),
|
|
40
|
+
#: 但名字后端要认(读回时得跳过:它们是生成物,不是块包的一部分)。
|
|
41
|
+
#: ⚠️ 这两个名字与 `frontend/magic-block/src/theme.ts` 的 `THEME_FILE` / `CONTROLS_FILE` **逐字一致**。
|
|
42
|
+
THEME = 'labull.css'
|
|
43
|
+
CONTROLS = 'CONTROLS.lock.html'
|
|
44
|
+
#: 后端依赖的那份清单(**项目自己的 `requirements.txt` 原样抄一份**):
|
|
45
|
+
#: 块后端跑在项目的解释器里,作者要 import 什么就得先装什么 —— 给他一份可照抄的参考。
|
|
46
|
+
#: 文件本身是 `requirements.txt` 的逐字副本(开头加了一行说明注释),可以直接 `pip install -r`。
|
|
47
|
+
REQUIREMENTS = 'requirements.lock.txt'
|
|
48
|
+
|
|
49
|
+
#: 清单里"模型字段 → Python 类型"那张表:按 `shape()` 里的 `kind`(Django 字段的种类)翻。
|
|
50
|
+
#:
|
|
51
|
+
#: 🔴 用 `kind` 而不是库里那一列的类型:作者要的是"在 Django 里它是什么" —— `FileField` 在库里
|
|
52
|
+
#: 只是一列 varchar(写 `str` 是对的),而 `DecimalField` 必须写 `Decimal`,写成 float
|
|
53
|
+
#: 会让人在金额上丢掉精度。
|
|
54
|
+
_KIND_TYPES: dict[str, str] = {
|
|
55
|
+
'money': 'Decimal',
|
|
56
|
+
'datetime': 'datetime',
|
|
57
|
+
'date': 'date',
|
|
58
|
+
'time': 'time',
|
|
59
|
+
'duration': 'timedelta',
|
|
60
|
+
'json': 'Any',
|
|
61
|
+
'boolean': 'bool',
|
|
62
|
+
'binary': 'bytes',
|
|
63
|
+
'uuid': 'UUID',
|
|
64
|
+
'integer': 'int',
|
|
65
|
+
'float': 'float',
|
|
66
|
+
'file': 'str',
|
|
67
|
+
'image': 'str',
|
|
68
|
+
'email': 'str',
|
|
69
|
+
'url': 'str',
|
|
70
|
+
'slug': 'str',
|
|
71
|
+
'ip': 'str',
|
|
72
|
+
'varchar': 'str',
|
|
73
|
+
'text': 'str',
|
|
74
|
+
'other': 'Any',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#: 一份 `.pyi` 里用到的 import(用不上的也留着:这是一张**清单**,少一行不如多一行)。
|
|
78
|
+
_PYI_IMPORTS: tuple[str, ...] = (
|
|
79
|
+
'from datetime import date, datetime, time, timedelta',
|
|
80
|
+
'from decimal import Decimal',
|
|
81
|
+
'from typing import Any',
|
|
82
|
+
'from uuid import UUID',
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _flat(text: Any) -> str:
|
|
87
|
+
"""多行文本压成一行(备注写进 `#` 注释里,断行会把后面的内容顶到注释外)。"""
|
|
88
|
+
return ' '.join(str(text or '').split())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _first_line(text: Any) -> str:
|
|
92
|
+
"""多行文本的第一行(文档字符串用它:一句话比一段压成一行好读)。"""
|
|
93
|
+
for line in str(text or '').splitlines():
|
|
94
|
+
stripped = line.strip()
|
|
95
|
+
if stripped:
|
|
96
|
+
return stripped
|
|
97
|
+
return ''
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _brief(text: Any, limit: int = 240) -> str:
|
|
101
|
+
"""整段备注压成一行、超长截断(模型的 docstring 常常是好几行的完整说明)。
|
|
102
|
+
|
|
103
|
+
🔴 截断要**看得出来**(尾部那个 `…`):一句被砍掉的话不标出来,人会以为源文件就写到那里。
|
|
104
|
+
"""
|
|
105
|
+
flat = _flat(text)
|
|
106
|
+
return flat if len(flat) <= limit else flat[:limit].rstrip() + '…'
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _error_classes() -> list[tuple[str, int]]:
|
|
110
|
+
"""`errors` 上的那六个错误:`[(类名, 状态码)]`,**从 `sdk` 的对象上读**(不抄一份名字)。
|
|
111
|
+
|
|
112
|
+
⚠️ 按状态码排:清单的顺序因此可复现(`dir()` 给的是字母序,看着像随机的)。
|
|
113
|
+
"""
|
|
114
|
+
out: list[tuple[str, int]] = []
|
|
115
|
+
for name in dir(sdk.errors):
|
|
116
|
+
if name.startswith('_'):
|
|
117
|
+
continue
|
|
118
|
+
value = getattr(sdk.errors, name)
|
|
119
|
+
status = getattr(value, 'status', None)
|
|
120
|
+
if isinstance(value, type) and issubclass(value, sdk.MagicBackendHTTPError) and isinstance(status, int):
|
|
121
|
+
out.append((name, status))
|
|
122
|
+
return sorted(out, key=lambda item: (item[1], item[0]))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _ctx_classes() -> list[str]:
|
|
126
|
+
"""`ctx` 那几个 dataclass 的正文(字段名 / 类型 / 默认值都从 `sdk` 上读)。"""
|
|
127
|
+
lines: list[str] = []
|
|
128
|
+
for cls in (sdk.BlockUser, sdk.BlockCtx):
|
|
129
|
+
doc = _first_line(getattr(cls, '__doc__', '') or '')
|
|
130
|
+
lines.append(f'class {cls.__name__}:')
|
|
131
|
+
if doc:
|
|
132
|
+
lines.append(f' """{doc}"""')
|
|
133
|
+
for field in dataclasses.fields(cls):
|
|
134
|
+
annotation = _flat(getattr(field.type, '__name__', field.type))
|
|
135
|
+
default = '' if field.default is dataclasses.MISSING else f' = {field.default!r}'
|
|
136
|
+
lines.append(f' {field.name}: {annotation}{default}')
|
|
137
|
+
lines.append('')
|
|
138
|
+
return lines
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _api_lines() -> list[str]:
|
|
142
|
+
"""`MagicBackendAPI` 那一行声明 —— 签名从 `sdk` 的函数上读(改了签名这里跟着变)。"""
|
|
143
|
+
try:
|
|
144
|
+
signature = str(inspect.signature(sdk.MagicBackendAPI)).replace("'", '').replace('"', '')
|
|
145
|
+
name, _, params = signature.partition('(')
|
|
146
|
+
params = params.rsplit(')', 1)[0] if ')' in params else params
|
|
147
|
+
except (TypeError, ValueError): # pragma: no cover —— 拿不到签名就退回"只写名字"
|
|
148
|
+
params = '...'
|
|
149
|
+
return [
|
|
150
|
+
f'def MagicBackendAPI({params}) -> Any:',
|
|
151
|
+
' """挂在 handler 方法上的路由声明:`@MagicBackendAPI(permission="app.codename")`。',
|
|
152
|
+
'',
|
|
153
|
+
' 省略 permission = 任何登录用户都能调(fail-open 是刻意的:该拦的写在 handler 里);',
|
|
154
|
+
' 传一个不是字符串的东西会在**保存时的试加载**里当场 TypeError。',
|
|
155
|
+
' ⚠️ 没挂这个装饰器的方法**不是路由** —— 少写一行装饰器的表现是前端调它回 404。',
|
|
156
|
+
' """',
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _field_lines(field: dict[str, Any]) -> str:
|
|
161
|
+
"""一个模型字段那一行(类型 + 一行备注)。关联字段的类型是目标模型的名字。"""
|
|
162
|
+
kind = str(field.get('kind') or '')
|
|
163
|
+
name = str(field.get('name') or '')
|
|
164
|
+
if not name:
|
|
165
|
+
return ''
|
|
166
|
+
if kind in ('foreignKey', 'oneToOne'):
|
|
167
|
+
annotation = str((field.get('target') or {}).get('name') or 'Any')
|
|
168
|
+
elif kind == 'enum':
|
|
169
|
+
annotation = 'str'
|
|
170
|
+
else:
|
|
171
|
+
annotation = _KIND_TYPES.get(kind, 'Any')
|
|
172
|
+
|
|
173
|
+
notes: list[str] = []
|
|
174
|
+
brief = _first_line(field.get('brief') or field.get('briefLine'))
|
|
175
|
+
if brief:
|
|
176
|
+
notes.append(brief)
|
|
177
|
+
if field.get('primary'):
|
|
178
|
+
notes.append('主键')
|
|
179
|
+
elif field.get('unique'):
|
|
180
|
+
notes.append('唯一')
|
|
181
|
+
if field.get('null'):
|
|
182
|
+
notes.append('可空')
|
|
183
|
+
default = field.get('default')
|
|
184
|
+
if default:
|
|
185
|
+
notes.append(f'默认 {_flat(default)}')
|
|
186
|
+
enum = field.get('enum') or {}
|
|
187
|
+
values = enum.get('values') or []
|
|
188
|
+
if values:
|
|
189
|
+
labels = enum.get('labels') or {}
|
|
190
|
+
notes.append('取值:' + ' / '.join(f'{value}={_flat(labels.get(value)) or value}' for value in values))
|
|
191
|
+
comment = f' # {" · ".join(notes)}' if notes else ''
|
|
192
|
+
return f' {name}: {annotation}{comment}'
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _relation_lines(table: dict[str, Any]) -> list[str]:
|
|
196
|
+
"""关联里**没在字段那一半出现过**的那些(多对多与反向),只写成注释。
|
|
197
|
+
|
|
198
|
+
🔴 外键 / 一对一已经是上面那些字段行了,这里再写一遍就是同一件事两个说法。
|
|
199
|
+
"""
|
|
200
|
+
out: list[str] = []
|
|
201
|
+
for relation in table.get('relations') or []:
|
|
202
|
+
kind = str(relation.get('kind') or '')
|
|
203
|
+
if kind not in ('manyToMany', 'reverse'):
|
|
204
|
+
continue
|
|
205
|
+
field = str(relation.get('field') or '')
|
|
206
|
+
target = str(relation.get('target') or '?')
|
|
207
|
+
if kind == 'manyToMany':
|
|
208
|
+
via = _flat(relation.get('via')) or '(没有中间表名)'
|
|
209
|
+
out.append(f' # 多对多:{field} → {target}(经 {via})')
|
|
210
|
+
else:
|
|
211
|
+
out.append(f' # 反向(只读,库里没有这一列):{field} → {target}')
|
|
212
|
+
return out
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _table_block(table: dict[str, Any]) -> list[str]:
|
|
216
|
+
"""一张表那一块(类名 / 备注 / 真实 import / 字段 / 关联)。"""
|
|
217
|
+
name = str(table.get('name') or '')
|
|
218
|
+
module = str(table.get('module') or '')
|
|
219
|
+
if not name:
|
|
220
|
+
return []
|
|
221
|
+
brief = _brief(table.get('brief') or table.get('briefLine') or table.get('verbose'))
|
|
222
|
+
where = f'表 {_flat(table.get("table"))}' + (f' · 模块 {module}' if module else '')
|
|
223
|
+
out = [f'class {name}:', f' """{brief}({where})"""' if brief else f' """{where}"""']
|
|
224
|
+
if module:
|
|
225
|
+
out.append(f' # 一般这样 import:from {module}.models import {name}')
|
|
226
|
+
for field in table.get('fields') or []:
|
|
227
|
+
line = _field_lines(field if isinstance(field, dict) else {})
|
|
228
|
+
if line:
|
|
229
|
+
out.append(line)
|
|
230
|
+
out.extend(_relation_lines(table))
|
|
231
|
+
out.append('')
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def backend_lock_pyi(tables: list[dict[str, Any]] | None = None, items: tuple[EnvItem, ...] = ENV_ITEMS) -> str:
|
|
236
|
+
"""`backend.lock.pyi` 的正文:后端能 import 的库 + `ctx` + 环境变量 + 项目里的模型。
|
|
237
|
+
|
|
238
|
+
:param tables: `canvas.data.shape()['tables']`(模型形状);不给就只铺前面三段。
|
|
239
|
+
"""
|
|
240
|
+
lines: list[str] = [
|
|
241
|
+
'"""生成物:每次导出都会重写,别手改(改了下次导出就没了)。',
|
|
242
|
+
'',
|
|
243
|
+
'这份清单回答「块后端(backend_open.py)里我能用什么」:',
|
|
244
|
+
' ① 必写的那一条 import(校验要求包里必须有它);',
|
|
245
|
+
' ② 能抛的六个 HTTP 语义错误;',
|
|
246
|
+
' ③ 服务端交给 handler 的 ctx(就那 4 个键);',
|
|
247
|
+
' ④ 平台读哪些环境变量(**只有名字与说明,没有值**);',
|
|
248
|
+
' ⑤ 项目里现在有哪些模型、每个字段是什么、枚举有哪些取值。',
|
|
249
|
+
'',
|
|
250
|
+
f'🔴 它不参与运行:块后端真正 import 的是 {sdk.LIB_SDK},模型走每张表下面那行注释的路径。',
|
|
251
|
+
'🔴 块包必备件:' + ' / '.join((BLOCK_ENTRY, BLOCK_MANIFEST)) + f';块自己的后端是 {BLOCK_BACKEND_FILE}(可选)。',
|
|
252
|
+
'"""',
|
|
253
|
+
'',
|
|
254
|
+
*_PYI_IMPORTS,
|
|
255
|
+
'',
|
|
256
|
+
'# ── ① 块后端必写的那一条(校验只认这一条:没有它就没有任何接口) ──',
|
|
257
|
+
f'from {sdk.LIB_SDK} import MagicBackendAPI as MagicBackendAPI',
|
|
258
|
+
'',
|
|
259
|
+
*_api_lines(),
|
|
260
|
+
'',
|
|
261
|
+
'# ── ② 能抛的错误:`raise errors.NotFound("...")`(同一个对象上的类属性,except 抓得住) ──',
|
|
262
|
+
f'# 真实来源:from {sdk.LIB_SDK} import errors',
|
|
263
|
+
'class errors:',
|
|
264
|
+
' """六个 HTTP 语义错误(状态码就是 HTTP 那个意思,别的异常一律 500)。"""',
|
|
265
|
+
'',
|
|
266
|
+
' class MagicBackendHTTPError(Exception):',
|
|
267
|
+
' status: int',
|
|
268
|
+
' message: str',
|
|
269
|
+
'',
|
|
270
|
+
]
|
|
271
|
+
for name, status in _error_classes():
|
|
272
|
+
lines.append(f' class {name}(MagicBackendHTTPError):')
|
|
273
|
+
lines.append(f' status: int # {status}')
|
|
274
|
+
lines.append('')
|
|
275
|
+
lines.extend(
|
|
276
|
+
[
|
|
277
|
+
'# ── ③ ctx:handler 第二个参数(**4 个键,一个都不许加**) ──',
|
|
278
|
+
'# 认人只认 ctx.user(来自会话);模型不经过 ctx —— 直接 import 你自己的(见下面第 ⑤ 段)。',
|
|
279
|
+
*_ctx_classes(),
|
|
280
|
+
'# ── ④ 环境变量(名字 + 说明;值在服务器的环境里,块只能看前缀白名单那一族) ──',
|
|
281
|
+
]
|
|
282
|
+
)
|
|
283
|
+
group = ''
|
|
284
|
+
for item in items:
|
|
285
|
+
if item.group != group:
|
|
286
|
+
group = item.group
|
|
287
|
+
lines.append(f'# · {group}')
|
|
288
|
+
marks = '必填' if item.required else '可选'
|
|
289
|
+
example = f'|例如 {item.hint}' if item.hint else ''
|
|
290
|
+
lines.append(f'# {item.name}({marks}):{item.brief}{example}')
|
|
291
|
+
lines.extend(
|
|
292
|
+
[
|
|
293
|
+
f'# ⚠️ `{sdk.BLOCK_CONFIG_PREFIX}` 那一族是块**唯一**读得到的配置:前缀剥掉当键进 `ctx.config`',
|
|
294
|
+
'# (环境里 `' + sdk.BLOCK_CONFIG_PREFIX + 'OCR_KEY=a` ⇒ `ctx.config["OCR_KEY"] == "a"`);',
|
|
295
|
+
'# 没配的键**不存在**(先判 `"OCR_KEY" in ctx.config`),别的变量一个都不给块。',
|
|
296
|
+
'',
|
|
297
|
+
'# ── ⑤ 项目里的模型(照 .labull/contract.json 那份模型形状生成) ──',
|
|
298
|
+
]
|
|
299
|
+
)
|
|
300
|
+
if not tables:
|
|
301
|
+
lines.append('# (这次导出没拿到模型形状:没有任何 app 注册模型,或者模型那一层没起来)')
|
|
302
|
+
for table in tables or []:
|
|
303
|
+
lines.extend(_table_block(table))
|
|
304
|
+
return '\n'.join(lines).rstrip() + '\n'
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def dev_readme(blocks: int) -> str:
|
|
308
|
+
"""开发目录里的说明(铺了什么、哪几份是生成物、改完怎么回库)。
|
|
309
|
+
|
|
310
|
+
⚠️ 它与 `BACKEND_LOCK` / `PAGES_LOCK` 一样,每次导出都会**整个覆盖**:它是给人读的,不是输入。
|
|
311
|
+
"""
|
|
312
|
+
return (
|
|
313
|
+
'# 开发目录(magic block)\n'
|
|
314
|
+
'\n'
|
|
315
|
+
f'这个目录是导出算出来的(后端只算内容,**写盘在你自己的浏览器里**):库里 {blocks} 个块。'
|
|
316
|
+
'用**你自己的编辑器**改,改完回管理台读回去。\n'
|
|
317
|
+
'\n'
|
|
318
|
+
'| 路径 | 是什么 |\n'
|
|
319
|
+
'|---|---|\n'
|
|
320
|
+
'| `workspace/<可读名>-<块 uuid>/` | 一个块一个文件夹,包里就是它的全部文件 |\n'
|
|
321
|
+
f'| `{GUIDE}` | **生成物**:怎么改一个块(精简版开发说明,先看这一份) |\n'
|
|
322
|
+
f'| `{PAGES_LOCK}` | **生成物**:Page 枚举(前端能跳哪些页面)+ 块里能调的接口 |\n'
|
|
323
|
+
f'| `{THEME}` | **生成物**:这个应用正在用的 labull 主题 + 控件样式(宿主会注入每个块,这一份给你查类名/token) |\n'
|
|
324
|
+
f'| `{CONTROLS}` | **生成物**:控件清单(照着抄 markup,能直接打开看) |\n'
|
|
325
|
+
f'| `{BACKEND_LOCK}` | **生成物**:后端能 import 的库、`ctx`、环境变量、项目里的模型 |\n'
|
|
326
|
+
f'| `{REQUIREMENTS}` | **生成物**:这个项目的后端依赖清单(`requirements.txt` 的副本,块后端要装什么照它) |\n'
|
|
327
|
+
f'| `{README}` | 这一份说明 |\n'
|
|
328
|
+
'| 块文件夹里的 `api.open.d.ts` / `VERSION.lock` | **生成物**:这个块自己的接口声明 / 导出时用的库 |\n'
|
|
329
|
+
'\n'
|
|
330
|
+
'改完怎么回库:回管理台 → **选同一个文件夹** → 「读回改动」→ 勾上要存的块 → 保存。\n'
|
|
331
|
+
'\n'
|
|
332
|
+
f'一个块 = 一个文件夹 = 一整个文件包;包里必备 `{BLOCK_ENTRY}` 与 `{BLOCK_MANIFEST}`,\n'
|
|
333
|
+
f'块自己的后端是 `{BLOCK_BACKEND_FILE}`(可选,不参与前端渲染)。\n'
|
|
334
|
+
'\n'
|
|
335
|
+
'⚠️ 导出时会先问一句要不要**清空这个目录**再铺:选"清空"⇒ 你改过还没读回库的东西也会一起没;\n'
|
|
336
|
+
' 选"只覆盖"⇒ 只写不删(库里已经删掉的块会留在这个目录里)。\n'
|
|
337
|
+
'⚠️ 导出会**覆盖同名文件**(它读的是库里的那一份):目录里改过还没读回库的东西,导出前先读回去。\n'
|
|
338
|
+
'⚠️ 文件夹名里那段 uuid 就是它的身份;**改文件夹名不影响读回**(认的是清单里的 `id`,其次才是 uuid),\n'
|
|
339
|
+
' 但同一个块在目录里出现两份时只认一处。\n'
|
|
340
|
+
'🔴 上面标了「生成物」的那几份,下次导出会**整个覆盖**:别在它们里面改东西(改了也没用)。\n'
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def dev_guide() -> str:
|
|
345
|
+
"""`GUIDE.lock.md` 的正文:**精简版开发说明**(改一个块要知道的全部事,一页看完)。
|
|
346
|
+
|
|
347
|
+
🔴 里面的名字(入口 / 清单 / 后端文件名、`ctx` 的键、六个错误、配置前缀)**都从 `sdk` 上取** ——
|
|
348
|
+
文档里写死一份的下场是"代码改了、说明还在说旧名字",而那句说明正是块作者照着做的那一份。
|
|
349
|
+
"""
|
|
350
|
+
ctx = ' / '.join(sdk.CTX_KEYS)
|
|
351
|
+
errors = ' / '.join(name for name, _status in _error_classes())
|
|
352
|
+
prefix = sdk.BLOCK_CONFIG_PREFIX
|
|
353
|
+
return (
|
|
354
|
+
'# magic block 开发说明(精简版)\n'
|
|
355
|
+
'\n'
|
|
356
|
+
'一个块 = **一个 uuid + 一整个文件包**,宿主把它塞进沙箱 iframe 渲染:**不重新编译、不重启**。\n'
|
|
357
|
+
'\n'
|
|
358
|
+
'## 一个块长什么样\n'
|
|
359
|
+
'\n'
|
|
360
|
+
'```\n'
|
|
361
|
+
'workspace/<可读名>-<块 uuid>/\n'
|
|
362
|
+
f'├─ {BLOCK_ENTRY} 入口(必备)\n'
|
|
363
|
+
f'├─ {BLOCK_MANIFEST} 清单(必备):id / module / name / brief 四个键,全部必填\n'
|
|
364
|
+
'├─ 任意 js / css / 子目录\n'
|
|
365
|
+
f'└─ {BLOCK_BACKEND_FILE} 块自己的后端(可选)\n'
|
|
366
|
+
'```\n'
|
|
367
|
+
'\n'
|
|
368
|
+
f'🔴 认块只认清单里的 `id`(就是页面里挂载时写死的那个 uuid):`id` 与文件夹名里那段 uuid 对不上时,\n'
|
|
369
|
+
' 以清单为准(文件夹名只是给人看的)。\n'
|
|
370
|
+
'\n'
|
|
371
|
+
'## 前端那一半(`' + BLOCK_ENTRY + '` 里能用的)\n'
|
|
372
|
+
'\n'
|
|
373
|
+
'```js\n'
|
|
374
|
+
'const me = await magicBlock.getMe() // 当前用户(认人只认它)\n'
|
|
375
|
+
"await magicBlock.navigateTab('/orders') // 跳一个页面(可跳哪些见 pages.lock.ts)\n"
|
|
376
|
+
'const rows = await magicBackendApi.Order.list({}) // 调这个块自己的后端\n'
|
|
377
|
+
'```\n'
|
|
378
|
+
'\n'
|
|
379
|
+
'- 这三样是宿主**注入**的,运行期就有;别去 import 任何东西(块没有构建步骤,就是普通 HTML + JS)。\n'
|
|
380
|
+
'- **样式别自己写**:用这个应用正在用的 labull 类名(`p-button` / `p-inputtext` / `p-tag` / `p-checkbox` /\n'
|
|
381
|
+
f' `labull-panel` / `labull-subject-title` / `pi pi-*`,以及 `--sp-*` 那几个 token)。**什么都不用引** ——\n'
|
|
382
|
+
' 宿主会把主题注入每个块的 iframe(`MagicBlockHost` 的 `theme` prop,默认开),直接写类名就生效。\n'
|
|
383
|
+
f' 想查有哪些类名 / 抄 markup:开发目录顶层的 `{CONTROLS}`(控件清单,能直接打开看),\n'
|
|
384
|
+
f' 以及 `{THEME}`(就是被注入的那一份 CSS,可以搜类名与 token)。\n'
|
|
385
|
+
' ⚠️ 那里**只有 CSS,没有 JS 组件**:下拉 / 日期 / 弹窗要装了 PrimeVue 才有行为。\n'
|
|
386
|
+
f' ⚠️ 别把 `{THEME}` 抄进块包:宿主已经注入了一份,重复只会更慢(而且它 500 KB 上下)。\n'
|
|
387
|
+
'- 路由名 = **类名.方法名**;路径由宿主拼(`/bx/<这个块的 uuid>/<类名>.<方法名>`),你别自己拼。\n'
|
|
388
|
+
'- 参数就是请求正文;失败是 rejected Promise,`message` 是后端抛的中文原话。\n'
|
|
389
|
+
'- 类型与补全:`pages.lock.ts`(Page 枚举 + 上面这两样)与这个块文件夹里的 `api.open.d.ts`(它自己的路由)。\n'
|
|
390
|
+
'\n'
|
|
391
|
+
'## 后端那一半(`' + BLOCK_BACKEND_FILE + '`)\n'
|
|
392
|
+
'\n'
|
|
393
|
+
'```python\n'
|
|
394
|
+
f'from {sdk.LIB_SDK} import MagicBackendAPI, errors\n'
|
|
395
|
+
'\n'
|
|
396
|
+
'\n'
|
|
397
|
+
'class Order:\n'
|
|
398
|
+
' @MagicBackendAPI() # 省略 permission = 任何登录用户都能调\n'
|
|
399
|
+
' def list(self, ctx, params):\n'
|
|
400
|
+
' from orders.models import Order # 模型直接 import 你自己的(Django 的写法)\n'
|
|
401
|
+
' return [{"id": o.id} for o in Order.objects.all()[:20]]\n'
|
|
402
|
+
'\n'
|
|
403
|
+
" @MagicBackendAPI(permission='labull_framework.change_magicblock')\n"
|
|
404
|
+
' def close(self, ctx, params):\n'
|
|
405
|
+
' if not params.get("id"):\n'
|
|
406
|
+
' raise errors.BadRequest("要给我一个 id")\n'
|
|
407
|
+
' return {"ok": True}\n'
|
|
408
|
+
'```\n'
|
|
409
|
+
'\n'
|
|
410
|
+
f'- **没挂 `@MagicBackendAPI` 的方法不是路由**(前端调它会回 404,报文里列出真实有的路由名)。\n'
|
|
411
|
+
f'- `ctx` 只有 {len(sdk.CTX_KEYS)} 个键:`{ctx}`(认人用 `ctx.user`,配置用 `ctx.config`)。\n'
|
|
412
|
+
f'- 返回值要能编成 JSON;日期 / 金额 / uuid 一律编成**字符串**(金额走 float 会静默丢精度)。\n'
|
|
413
|
+
f'- 抛 `errors.{errors.replace(" / ", "` / `errors.")}` ⇒ 对应的 HTTP 状态码;别的异常一律 500。\n'
|
|
414
|
+
f'- 配置面:环境变量里**只有带 `{prefix}` 前缀的**才进 `ctx.config`(`{prefix}OCR_KEY=a` ⇒ `ctx.config["OCR_KEY"] == "a"`);\n'
|
|
415
|
+
' 没配的键**不存在**(先判 `"OCR_KEY" in ctx.config`)。\n'
|
|
416
|
+
'- 服务端能 import 什么、项目里有哪些模型与字段、还有哪些环境变量:见 `backend.lock.pyi`;\n'
|
|
417
|
+
f' 后端**装了哪些包**(块后端 import 之前得确认它在):见 `{REQUIREMENTS}`(项目 `requirements.txt` 的副本)。\n'
|
|
418
|
+
'\n'
|
|
419
|
+
'## 保存时会真的跑一次\n'
|
|
420
|
+
'\n'
|
|
421
|
+
f'保存与体检会在**子进程里真的加载一次** `{BLOCK_BACKEND_FILE}`:模块名拼错、语法错、装饰器写错当场报出来;\n'
|
|
422
|
+
'库里的块会被定期体检,过不了的自动停用(页面上显示成红框),改好再体检一次就恢复。\n'
|
|
423
|
+
'\n'
|
|
424
|
+
'## 改完怎么回库\n'
|
|
425
|
+
'\n'
|
|
426
|
+
'管理台 → 选**同一个文件夹** → 「读回改动」(只读,先给你一份"改了哪些文件"的清单)→ 勾上要存的块 → 保存。\n'
|
|
427
|
+
)
|
|
428
|
+
|