h3factory 0.0.0-stage → 0.2.2
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.
- package/README.md +241 -2
- package/bin/h3factory.js +87 -0
- package/cli.py +951 -0
- package/config.example.json +8 -0
- package/docs/platform_gotchas.md +118 -0
- package/docs/requirement_checklist.md +54 -0
- package/docs/schema_doc.md +216 -0
- package/docs/workflow.md +146 -0
- package/h3design/__init__.py +0 -0
- package/h3design/controls.py +414 -0
- package/h3design/dsl.py +553 -0
- package/h3design/newbuilder.py +852 -0
- package/h3platform/__init__.py +0 -0
- package/h3platform/api.py +214 -0
- package/h3platform/h3console.py +99 -0
- package/h3platform/settings.py +74 -0
- package/h3verify/__init__.py +0 -0
- package/h3verify/verify.py +217 -0
- package/package.json +51 -4
- package/projects/demo/dicts.json +10 -0
- package/projects/demo/generated/product.cs +82 -0
- package/projects/demo/groups.json +3 -0
- package/projects/demo/sheets/product.json +24 -0
- package/scripts/postinstall.js +109 -0
- package/skills/h3factory/SKILL.md +137 -0
- package/skills/h3yun-dev-assistant/BACKEND_GUIDELINES.md +120 -0
- package/skills/h3yun-dev-assistant/DEV_DOC_ROUTING.md +39 -0
- package/skills/h3yun-dev-assistant/FORM_ACTIONS_GUIDELINES.md +116 -0
- package/skills/h3yun-dev-assistant/FRONTEND_GUIDELINES.md +47 -0
- package/skills/h3yun-dev-assistant/OPENAPI_GUIDELINES.md +8 -0
- package/skills/h3yun-dev-assistant/PLATFORM_CONSTRAINTS.md +160 -0
- package/skills/h3yun-dev-assistant/POST_GUIDELINES.md +47 -0
- package/skills/h3yun-dev-assistant/PRACTICE_NOTES.md +626 -0
- package/skills/h3yun-dev-assistant/SKILL.md +70 -0
- package/skills/h3yun-dev-assistant/SQL_GUIDELINES.md +9 -0
- package/skills/h3yun-dev-assistant/TOOL_FUNCTIONS.md +96 -0
- package/templates/controller.cs.tpl +78 -0
|
File without changes
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""氚云 HTTP 封装:/v1/functionnode、/v1/formdesign/save、/v1/schema、/OpenApi/Invoke"""
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.request
|
|
6
|
+
import urllib.error
|
|
7
|
+
|
|
8
|
+
from . import settings
|
|
9
|
+
|
|
10
|
+
# 响应键大小写不一(web 接口 camelCase,OpenAPI PascalCase),统一不区分大小写取值
|
|
11
|
+
def _ci(d, *keys):
|
|
12
|
+
if not isinstance(d, dict):
|
|
13
|
+
return None
|
|
14
|
+
for k in keys:
|
|
15
|
+
for kk, vv in d.items():
|
|
16
|
+
if kk.lower() == k.lower():
|
|
17
|
+
return vv
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class H3Error(Exception):
|
|
22
|
+
def __init__(self, message, raw=None):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.raw = raw
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class H3:
|
|
28
|
+
def __init__(self, cfg=None):
|
|
29
|
+
if cfg is None:
|
|
30
|
+
cfg = settings.load_config()
|
|
31
|
+
self.base_url = cfg["baseUrl"].rstrip("/") + "/"
|
|
32
|
+
self.engine_code = cfg["engineCode"]
|
|
33
|
+
self.engine_secret = cfg["engineSecret"]
|
|
34
|
+
self.authorization = cfg["authorization"]
|
|
35
|
+
self.app_code = cfg.get("appCode", "")
|
|
36
|
+
self.app_name = cfg.get("appName", "")
|
|
37
|
+
|
|
38
|
+
def _call(self, url, payload=None, timeout=120, headers_extra=None,
|
|
39
|
+
method=None):
|
|
40
|
+
data = None
|
|
41
|
+
if payload is not None:
|
|
42
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
43
|
+
if method is None:
|
|
44
|
+
method = "POST" if data else "GET"
|
|
45
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
46
|
+
req.add_header("Content-Type", "application/json")
|
|
47
|
+
req.add_header("EngineCode", self.engine_code)
|
|
48
|
+
if headers_extra:
|
|
49
|
+
for k, v in headers_extra.items():
|
|
50
|
+
req.add_header(k, v)
|
|
51
|
+
try:
|
|
52
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
53
|
+
body = resp.read().decode("utf-8", "replace")
|
|
54
|
+
except urllib.error.HTTPError as e:
|
|
55
|
+
raise H3Error(f"HTTP {e.code} on {url.split('?')[0]}", e.read().decode("utf-8", "replace"))
|
|
56
|
+
except urllib.error.URLError as e:
|
|
57
|
+
raise H3Error(f"network error: {e}")
|
|
58
|
+
if not body.strip():
|
|
59
|
+
raise H3Error(f"empty response from {url.split('?')[0]}")
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(body)
|
|
62
|
+
except ValueError:
|
|
63
|
+
raise H3Error(f"non-JSON response from {url.split('?')[0]}: {body[:300]}")
|
|
64
|
+
|
|
65
|
+
# ---- web 接口(Authorization + EngineCode)----
|
|
66
|
+
def _web_headers(self):
|
|
67
|
+
return {"Authorization": self.authorization}
|
|
68
|
+
|
|
69
|
+
def _check_web(self, resp):
|
|
70
|
+
if resp is None:
|
|
71
|
+
raise H3Error("empty response")
|
|
72
|
+
ok = _ci(resp, "successful")
|
|
73
|
+
if ok is False or _ci(resp, "errorCode") is not None:
|
|
74
|
+
msg = _ci(resp, "errorMessage") or _ci(resp, "message") or "unknown error"
|
|
75
|
+
raise H3Error(msg, resp)
|
|
76
|
+
return _ci(resp, "returnData") if "returnData" in resp else resp
|
|
77
|
+
|
|
78
|
+
def save_form(self, schema_request):
|
|
79
|
+
"""保存表单结构(建表)。schema_request 为 FormSchemaRequest 同构 dict。"""
|
|
80
|
+
url = self.base_url + "v1/formdesign/save"
|
|
81
|
+
resp = self._call(url, schema_request, headers_extra=self._web_headers())
|
|
82
|
+
return self._check_web(resp)
|
|
83
|
+
|
|
84
|
+
def get_schema(self, schema_code):
|
|
85
|
+
"""读取已保存的表单结构(用于回读验证)。无此表单返回 None。"""
|
|
86
|
+
url = (self.base_url + "v1/schema?schemaCode=" + schema_code + "×tamp=-1")
|
|
87
|
+
resp = self._call(url, headers_extra=self._web_headers())
|
|
88
|
+
ec = _ci(resp, "errorCode")
|
|
89
|
+
if ec and "isnull" in str(ec):
|
|
90
|
+
return None
|
|
91
|
+
if _ci(resp, "successful") is False:
|
|
92
|
+
raise H3Error(_ci(resp, "errorMessage") or "schema read failed", resp)
|
|
93
|
+
return _ci(resp, "returnData") or resp
|
|
94
|
+
|
|
95
|
+
def create_app(self, app_code, app_name):
|
|
96
|
+
url = self.base_url + "v1/functionnode"
|
|
97
|
+
payload = {"appCode": app_code, "code": app_code, "displayName": app_name,
|
|
98
|
+
"icon": "ywfl;cpyf;yw-cpyf-agility", "iconColor": 0, "nodeType": 190}
|
|
99
|
+
resp = self._call(url, payload, headers_extra=self._web_headers())
|
|
100
|
+
return self._check_web(resp)
|
|
101
|
+
|
|
102
|
+
# ---- 表单分组(应用菜单分组;v1/functionnode,载荷逐字对照 UI,2026-09-09)----
|
|
103
|
+
def create_group(self, app_code, display_name, parent_code=None):
|
|
104
|
+
"""建分组(nodeType=230)。顶层分组 parentCode=appCode(可省 = 顶层)。
|
|
105
|
+
UI 实证建组是两步:create 落一个 32hex 分组码(非表单编码格式),
|
|
106
|
+
随后必须 update 改名 —— 客户端 create 后要跟 update_group。返回 (code, objectId)。"""
|
|
107
|
+
payload = {"appCode": app_code, "parentCode": parent_code or app_code,
|
|
108
|
+
"displayName": display_name, "nodeType": 230, "icon": ""}
|
|
109
|
+
resp = self._call(self.base_url + "v1/functionnode", payload,
|
|
110
|
+
headers_extra=self._web_headers())
|
|
111
|
+
return self._node_keys(self._check_web(resp))
|
|
112
|
+
|
|
113
|
+
def update_group(self, app_code, group_code, object_id, display_name,
|
|
114
|
+
parent_code=None):
|
|
115
|
+
"""分组改名/落定(update 载荷 = create 全套字段 + code/objectId/summary)。"""
|
|
116
|
+
payload = {"appCode": app_code, "parentCode": parent_code or app_code,
|
|
117
|
+
"displayName": display_name, "nodeType": 230, "icon": "",
|
|
118
|
+
"code": group_code, "objectId": object_id, "summary": ""}
|
|
119
|
+
resp = self._call(self.base_url + "v1/functionnode/update", payload,
|
|
120
|
+
headers_extra=self._web_headers())
|
|
121
|
+
return self._check_web(resp)
|
|
122
|
+
|
|
123
|
+
def move_node(self, code, parent_code):
|
|
124
|
+
"""归组/移动:PUT v1/functionnode/sort。code=表单编码或分组码,
|
|
125
|
+
parentCode=目标分组码(表单置回顶层 = appCode)。"""
|
|
126
|
+
resp = self._call(self.base_url + "v1/functionnode/sort",
|
|
127
|
+
{"code": code, "parentCode": parent_code},
|
|
128
|
+
method="PUT", headers_extra=self._web_headers())
|
|
129
|
+
return self._check_web(resp)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _node_keys(resp):
|
|
133
|
+
"""从 functionnode 响应取 (code, node_id):兼容顶层或 ReturnData 嵌套、键大小写。
|
|
134
|
+
实证(2026-09-09):create 响应 = {id: GUID, code: 32hex};update 载荷要用
|
|
135
|
+
objectId 字段传同一个 GUID(UI 载荷同)。"""
|
|
136
|
+
d = resp
|
|
137
|
+
if isinstance(d, dict):
|
|
138
|
+
rd = None
|
|
139
|
+
for k, v in d.items():
|
|
140
|
+
if k.lower() == "returndata":
|
|
141
|
+
rd = v
|
|
142
|
+
if isinstance(rd, dict):
|
|
143
|
+
d = rd
|
|
144
|
+
code, oid = _ci(d, "code"), _ci(d, "id") or _ci(d, "objectid")
|
|
145
|
+
if code and oid:
|
|
146
|
+
return code, oid
|
|
147
|
+
raise H3Error("functionnode 响应缺 code/id: %s" % str(resp)[:300], resp)
|
|
148
|
+
|
|
149
|
+
# ---- OpenAPI(EngineCode + EngineSecret,建数据用)----
|
|
150
|
+
def _open_headers(self):
|
|
151
|
+
return {"EngineCode": self.engine_code, "EngineSecret": self.engine_secret}
|
|
152
|
+
|
|
153
|
+
def openapi_invoke(self, action, schema_code=None, extra=None, timeout=120):
|
|
154
|
+
params = {"ActionName": action}
|
|
155
|
+
if schema_code:
|
|
156
|
+
params["SchemaCode"] = schema_code
|
|
157
|
+
if extra:
|
|
158
|
+
params.update(extra)
|
|
159
|
+
resp = self._call(self.base_url + "OpenApi/Invoke", params,
|
|
160
|
+
timeout=timeout, headers_extra=self._open_headers())
|
|
161
|
+
ok = _ci(resp, "successful")
|
|
162
|
+
if ok is False:
|
|
163
|
+
raise H3Error(_ci(resp, "errorMessage") or "openapi failed", resp)
|
|
164
|
+
return _ci(resp, "returnData")
|
|
165
|
+
|
|
166
|
+
def create_biz_objects(self, schema_code, objs, is_submit=False, batch=50):
|
|
167
|
+
"""批量创建表单数据,objs 为字段 dict 列表。"""
|
|
168
|
+
created, failed = 0, []
|
|
169
|
+
for i in range(0, len(objs), batch):
|
|
170
|
+
chunk = objs[i:i + batch]
|
|
171
|
+
try:
|
|
172
|
+
self.openapi_invoke("CreateBizObjects", schema_code, {
|
|
173
|
+
"BizObjectArray": json.dumps(chunk, ensure_ascii=False),
|
|
174
|
+
"IsSubmit": str(is_submit).lower(),
|
|
175
|
+
}, timeout=300)
|
|
176
|
+
created += len(chunk)
|
|
177
|
+
except H3Error as e:
|
|
178
|
+
failed.extend((i + j, obj) for j, obj in enumerate(chunk))
|
|
179
|
+
print(f" batch {i // batch} failed: {e}")
|
|
180
|
+
return created, failed
|
|
181
|
+
|
|
182
|
+
def create_biz_object(self, schema_code, obj, is_submit=True, timeout=60):
|
|
183
|
+
"""新增单条。BizObject 必须是 JSON 字符串(对照 h3yun-api skill 实测)。"""
|
|
184
|
+
return self.openapi_invoke("CreateBizObject", schema_code, {
|
|
185
|
+
"BizObject": json.dumps(obj, ensure_ascii=False),
|
|
186
|
+
"IsSubmit": is_submit,
|
|
187
|
+
}, timeout=timeout)
|
|
188
|
+
|
|
189
|
+
def load_biz_objects(self, schema_code, max_rows=1000, page_size=500):
|
|
190
|
+
"""读回数据。Filter 树形 Matcher 格式(参照 云星辰 H3CloudClient 实测)。
|
|
191
|
+
ReturnData = {BizObjectArray:[{字段..., ObjectId}]}"""
|
|
192
|
+
all_rows, from_row = [], 0
|
|
193
|
+
while True:
|
|
194
|
+
filter_json = json.dumps({
|
|
195
|
+
"FromRowNum": from_row, "ToRowNum": from_row + page_size,
|
|
196
|
+
"RequireCount": False, "ReturnItems": [],
|
|
197
|
+
"SortByCollection": [],
|
|
198
|
+
"Matcher": {"Type": "And", "Matchers": []}}, ensure_ascii=False)
|
|
199
|
+
rd = self.openapi_invoke("LoadBizObjects", schema_code, {"Filter": filter_json})
|
|
200
|
+
if isinstance(rd, dict):
|
|
201
|
+
rows = _ci(rd, "bizobjectarray") or []
|
|
202
|
+
else:
|
|
203
|
+
rows = rd or []
|
|
204
|
+
all_rows.extend(rows)
|
|
205
|
+
if len(rows) < page_size or len(all_rows) >= max_rows or from_row + page_size >= 1000:
|
|
206
|
+
break
|
|
207
|
+
from_row += page_size
|
|
208
|
+
return all_rows
|
|
209
|
+
|
|
210
|
+
def load_biz_object(self, schema_code, object_id):
|
|
211
|
+
return self.openapi_invoke("LoadBizObject", schema_code, {"BizObjectId": object_id})
|
|
212
|
+
|
|
213
|
+
def remove_biz_object(self, schema_code, object_id):
|
|
214
|
+
return self.openapi_invoke("RemoveBizObject", schema_code, {"BizObjectId": object_id})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""氚云设计器控制台接口(新引擎真实建表端点):
|
|
3
|
+
POST {base}/Console/SheetDesigner/OnAction
|
|
4
|
+
表单编码 x-www-form-urlencoded: PostData=<JSON字符串>"""
|
|
5
|
+
import json
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
import urllib.error
|
|
9
|
+
|
|
10
|
+
from . import settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConsoleError(Exception):
|
|
14
|
+
def __init__(self, message, raw=None):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.raw = raw
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class H3Console:
|
|
20
|
+
def __init__(self, cfg=None):
|
|
21
|
+
if cfg is None:
|
|
22
|
+
cfg = settings.load_config()
|
|
23
|
+
self.base_url = cfg["baseUrl"].rstrip("/") + "/"
|
|
24
|
+
self.authorization = cfg["authorization"]
|
|
25
|
+
self.engine_code = cfg["engineCode"]
|
|
26
|
+
|
|
27
|
+
def _call(self, post_json, headers_extra=None, timeout=120):
|
|
28
|
+
body = urllib.parse.urlencode(
|
|
29
|
+
{"PostData": json.dumps(post_json, ensure_ascii=False)}).encode("utf-8")
|
|
30
|
+
req = urllib.request.Request(self.base_url + "Console/SheetDesigner/OnAction",
|
|
31
|
+
data=body, method="POST")
|
|
32
|
+
req.add_header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
|
33
|
+
req.add_header("Authorization", self.authorization)
|
|
34
|
+
req.add_header("EngineCode", self.engine_code)
|
|
35
|
+
if headers_extra:
|
|
36
|
+
for k, v in headers_extra.items():
|
|
37
|
+
req.add_header(k, v)
|
|
38
|
+
try:
|
|
39
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
40
|
+
raw = resp.read().decode("utf-8", "replace")
|
|
41
|
+
except urllib.error.HTTPError as e:
|
|
42
|
+
raise ConsoleError(f"HTTP {e.code}", e.read().decode("utf-8", "replace"))
|
|
43
|
+
except urllib.error.URLError as e:
|
|
44
|
+
raise ConsoleError(f"network error: {e}")
|
|
45
|
+
if not raw.strip():
|
|
46
|
+
raise ConsoleError("empty response")
|
|
47
|
+
try:
|
|
48
|
+
return json.loads(raw)
|
|
49
|
+
except ValueError:
|
|
50
|
+
raise ConsoleError("non-JSON response: " + raw[:300])
|
|
51
|
+
|
|
52
|
+
def action(self, name, **extra):
|
|
53
|
+
"""任意 OnAction 动作"""
|
|
54
|
+
payload = {"ActionName": name}
|
|
55
|
+
payload.update(extra)
|
|
56
|
+
return self._call(payload)
|
|
57
|
+
|
|
58
|
+
def load_form(self, sheet_code):
|
|
59
|
+
"""设计器加载表单(完整设计态,含 SchemaStr/BizSheetStr 原文)"""
|
|
60
|
+
return self.action("LoadForm", id=sheet_code)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def save_form_console(console, sheet_code, engine_code, app_code, parent_code,
|
|
64
|
+
display_name, schema_dict, biz_dict,
|
|
65
|
+
name_schema="", display_setting=1, pc_layout="1",
|
|
66
|
+
pc_layout_config=('{"LayoutType":1,"TitleFontWeight":400,'
|
|
67
|
+
'"TitleWidth":84,"TitleAlign":"left","BorderShow":false}'),
|
|
68
|
+
mobile_layout="1",
|
|
69
|
+
mobile_layout_config=('{"LayoutType":1,"TitleFontWeight":400,'
|
|
70
|
+
'"TitleWidth":80,"TitleAlign":"left","BorderShow":false}'),
|
|
71
|
+
node_type=200, icon="icon-cgfk", open_type=0,
|
|
72
|
+
enable_data_acl=True, control_settings_str="[]",
|
|
73
|
+
describe_image_list=(), summary="", extra_top=None):
|
|
74
|
+
"""构造并调用设计器 SaveForm。schema_dict/biz_dict 为 dict,内部会序列化为字符串字段。"""
|
|
75
|
+
payload = {
|
|
76
|
+
"ActionName": "SaveForm",
|
|
77
|
+
"SheetCode": sheet_code,
|
|
78
|
+
"EngineCode": engine_code,
|
|
79
|
+
"AppCode": app_code,
|
|
80
|
+
"ParentCode": parent_code,
|
|
81
|
+
"DisplayName": display_name,
|
|
82
|
+
"DisplaySetting": display_setting,
|
|
83
|
+
"PCLayout": pc_layout,
|
|
84
|
+
"PCLayoutConfig": pc_layout_config,
|
|
85
|
+
"MobileLayout": mobile_layout,
|
|
86
|
+
"MobileLayoutConfig": mobile_layout_config,
|
|
87
|
+
"NodeType": node_type,
|
|
88
|
+
"Icon": icon,
|
|
89
|
+
"OpenType": open_type,
|
|
90
|
+
"IsBindBizService": False,
|
|
91
|
+
"SchemaStr": json.dumps(schema_dict, ensure_ascii=False),
|
|
92
|
+
"BizSheetStr": json.dumps(biz_dict, ensure_ascii=False),
|
|
93
|
+
"ControlSettingsStr": control_settings_str,
|
|
94
|
+
"DescribeImageList": list(describe_image_list),
|
|
95
|
+
"Summary": summary,
|
|
96
|
+
}
|
|
97
|
+
if extra_top:
|
|
98
|
+
payload.update(extra_top)
|
|
99
|
+
return console._call(payload)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""工作区与配置路径解析。
|
|
3
|
+
|
|
4
|
+
代码根 CODE_ROOT(npm 安装包目录)只读;工作区 WORKSPACE 是用户当前目录
|
|
5
|
+
(或 H3FACTORY_HOME),存放 projects/ 和 config.json。这样全局安装后不会
|
|
6
|
+
把业务数据写进 node_modules。
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
CODE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(Exception):
|
|
15
|
+
"""配置缺失或无法读取。"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def workspace_root():
|
|
19
|
+
env = os.environ.get("H3FACTORY_HOME")
|
|
20
|
+
if env:
|
|
21
|
+
return os.path.abspath(os.path.expanduser(env))
|
|
22
|
+
return os.getcwd()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def projects_dir():
|
|
26
|
+
return os.path.join(workspace_root(), "projects")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def project_dir(name):
|
|
30
|
+
return os.path.join(projects_dir(), name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def user_config_dir():
|
|
34
|
+
return os.path.join(os.path.expanduser("~"), ".h3factory")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def config_candidates():
|
|
38
|
+
"""按优先级列出配置文件路径:显式环境变量 > 工作区 > 用户目录。"""
|
|
39
|
+
env = os.environ.get("H3FACTORY_CONFIG")
|
|
40
|
+
if env:
|
|
41
|
+
yield os.path.abspath(os.path.expanduser(env))
|
|
42
|
+
yield os.path.join(workspace_root(), "config.json")
|
|
43
|
+
yield os.path.join(user_config_dir(), "config.json")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resolve_config_path():
|
|
47
|
+
for path in config_candidates():
|
|
48
|
+
if os.path.isfile(path):
|
|
49
|
+
return path
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_config_path():
|
|
54
|
+
"""init 的写入位置:H3FACTORY_CONFIG > 工作区 config.json。"""
|
|
55
|
+
env = os.environ.get("H3FACTORY_CONFIG")
|
|
56
|
+
if env:
|
|
57
|
+
return os.path.abspath(os.path.expanduser(env))
|
|
58
|
+
return os.path.join(workspace_root(), "config.json")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_config(required=True):
|
|
62
|
+
path = resolve_config_path()
|
|
63
|
+
if path:
|
|
64
|
+
try:
|
|
65
|
+
with open(path, encoding="utf-8") as f:
|
|
66
|
+
return json.load(f)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
raise ConfigError("读取配置失败 %s: %s" % (path, e))
|
|
69
|
+
if not required:
|
|
70
|
+
return {"appCode": ""}
|
|
71
|
+
raise ConfigError(
|
|
72
|
+
"缺 config.json —— 先运行 h3factory auth status --json 检查;"
|
|
73
|
+
"需要录入时用 h3factory auth set --json(stdin 写入)或 h3factory init,"
|
|
74
|
+
"也可设置 H3FACTORY_CONFIG 指向已有配置")
|
|
File without changes
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""LoadForm 只读回读校验:把"期望"(loader 构建的控件结构)与线上表单比对。
|
|
3
|
+
不 SaveForm,可安全重复运行。核对项:
|
|
4
|
+
- 字段数 / 缺字段 / 额外字段
|
|
5
|
+
- 每字段 DisplayRule 隐藏条件文本(期望 vs 线上)
|
|
6
|
+
- 一行多列(type 102)行节点数(layout 里 list 组的个数)
|
|
7
|
+
- NameSchema
|
|
8
|
+
报告 dict 供 cli.py 输出(stdout + logs/verify_report.txt UTF-8)。"""
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
MAX_SHOW = 10
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ci(d, name):
|
|
16
|
+
"""dict 大小写不敏感取值(LoadForm 用 PascalCase,SaveForm 用 camelCase)"""
|
|
17
|
+
for k in d:
|
|
18
|
+
if k.lower() == name.lower():
|
|
19
|
+
return d[k]
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def walk_items(node):
|
|
24
|
+
"""递归收集控件/行节点 dict(含一行多列行节点内 columns/childControls 子项)"""
|
|
25
|
+
out = []
|
|
26
|
+
if isinstance(node, dict):
|
|
27
|
+
if ci(node, "Options") is not None or ci(node, "type") is not None \
|
|
28
|
+
or ci(node, "Key") is not None:
|
|
29
|
+
out.append(node)
|
|
30
|
+
for v in node.values():
|
|
31
|
+
out.extend(walk_items(v))
|
|
32
|
+
elif isinstance(node, list):
|
|
33
|
+
for v in node:
|
|
34
|
+
out.extend(walk_items(v))
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_dmc(rd):
|
|
39
|
+
dmc = rd.get("DesignModeContent")
|
|
40
|
+
dmc = json.loads(dmc) if isinstance(dmc, str) else dmc
|
|
41
|
+
return dmc
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def collect_live(rd):
|
|
45
|
+
"""DesignModeContent → (by_code{key: Options}, rows[行节点], names)"""
|
|
46
|
+
items = walk_items(_parse_dmc(rd))
|
|
47
|
+
by_code, rows = {}, []
|
|
48
|
+
for d in items:
|
|
49
|
+
o = ci(d, "Options") or ci(d, "options") or {}
|
|
50
|
+
ty = ci(d, "type")
|
|
51
|
+
cols = ci(d, "columns") or ci(d, "childControls")
|
|
52
|
+
# 子表(104)节点虽有 columns,但整行占表单底,不算一行多列(102)行组
|
|
53
|
+
if ty == 102 or (cols is not None and ci(d, "Key") is None and ty != 104):
|
|
54
|
+
rows.append(d)
|
|
55
|
+
continue
|
|
56
|
+
key = ci(o, "DataField") or ci(d, "Key")
|
|
57
|
+
if key:
|
|
58
|
+
by_code.setdefault(key, o)
|
|
59
|
+
return by_code, rows
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def expected_rules(form):
|
|
63
|
+
"""loader 产物 → {code: 期望 DisplayRule 文本}(空串=常显)。
|
|
64
|
+
布局项(分组标题/描述)没有 DataField,回读设计树按 key 收集 —— 同样登记。
|
|
65
|
+
子表:子表主编码(key=child schema code)与其每列 dotted code
|
|
66
|
+
(code.列key)作为普通期望字段 —— LoadForm 设计树中 104 节点以
|
|
67
|
+
options.DataField 收集,与主字段同路径比对。"""
|
|
68
|
+
expect = {}
|
|
69
|
+
for c in list(form["fields"]) + list(form.get("extras") or []):
|
|
70
|
+
r = (c.get("Options") or {}).get("DisplayRule")
|
|
71
|
+
expect[c["Key"]] = r.get("Rule") if isinstance(r, dict) else ""
|
|
72
|
+
for s in form.get("subs") or []:
|
|
73
|
+
code = s.get("code")
|
|
74
|
+
if not code:
|
|
75
|
+
continue # 未建表(registry 无 subs 编码)时只比主字段
|
|
76
|
+
expect.setdefault(code, "")
|
|
77
|
+
for col in s["cols"]:
|
|
78
|
+
expect.setdefault("%s.%s" % (code, col["Key"]), "")
|
|
79
|
+
return expect
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def expected_layout_rows(form):
|
|
83
|
+
"""layout 里一行多列(list)组数;单控件整行不计行组"""
|
|
84
|
+
return sum(1 for g in form.get("layout", []) if isinstance(g, list))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _af_rule(ctl):
|
|
88
|
+
"""控件/裸 Options 的 AssociationFilter 规则文本(联动下拉)。Options 侧是
|
|
89
|
+
{Rule: ..} 字典(FormLayout/回读),宽容处理裸字符串与 RuleText。
|
|
90
|
+
两种入参形态:{Key, Options{...}}(期望侧)或 裸 Options(live_by_code 侧)。"""
|
|
91
|
+
o = ctl if isinstance(ctl, dict) and "Options" not in ctl else (ctl or {}).get("Options")
|
|
92
|
+
if not isinstance(o, dict):
|
|
93
|
+
return ""
|
|
94
|
+
af = o.get("AssociationFilter")
|
|
95
|
+
if isinstance(af, dict):
|
|
96
|
+
for k in ("Rule", "RuleText"):
|
|
97
|
+
if k in af:
|
|
98
|
+
return af[k] or ""
|
|
99
|
+
return ""
|
|
100
|
+
return af or ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _norm_rule(s):
|
|
104
|
+
"""规则文本规整比对:去 NBSP/空白漂移。UI 重存会在规则串头尾、AND 两侧、
|
|
105
|
+
函数逗号后插 NBSP、给 ==/!= 加空格(2026-09-09 实证)——语义比对前规整。"""
|
|
106
|
+
return "".join(str(s or "").replace(" ", " ").split())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _settings_of(controls):
|
|
110
|
+
"""期望侧:位置控件的 ControlSettings(控件 dict 上的独立键)→ {key: {k: 文本}}"""
|
|
111
|
+
out = {}
|
|
112
|
+
for c in controls:
|
|
113
|
+
cs = c.get("ControlSettings")
|
|
114
|
+
if cs:
|
|
115
|
+
out[c["Key"]] = {str(k): str(v) for k, v in cs.items()}
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _live_settings(rd):
|
|
120
|
+
"""线上侧:LoadForm ReturnData.ControlSettings(**首字母大写**:Key/DataField/
|
|
121
|
+
Options;SaveForm 收的是小写 key/dataField/options)→ {key: {k: 文本}}"""
|
|
122
|
+
out = {}
|
|
123
|
+
for e in (rd.get("ControlSettings") or []):
|
|
124
|
+
if not isinstance(e, dict):
|
|
125
|
+
continue
|
|
126
|
+
k = ci(e, "Key") or ci(e, "DataField")
|
|
127
|
+
o = ci(e, "Options") or {}
|
|
128
|
+
if k:
|
|
129
|
+
out[k] = {str(kk): str(vv) for kk, vv in o.items()}
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def compare(form, rd):
|
|
134
|
+
"""form = loader 产物;rd = LoadForm ReturnData → 核对结果 dict"""
|
|
135
|
+
expect = expected_rules(form)
|
|
136
|
+
live_by_code, rows = collect_live(rd)
|
|
137
|
+
miss, extra, bad, badf = [], [], [], []
|
|
138
|
+
for code in expect:
|
|
139
|
+
if code not in live_by_code:
|
|
140
|
+
miss.append(code)
|
|
141
|
+
continue
|
|
142
|
+
o = live_by_code[code]
|
|
143
|
+
r = o.get("DisplayRule") or {}
|
|
144
|
+
got = r.get("Rule") if isinstance(r, dict) else ""
|
|
145
|
+
if (expect[code] or "") != (got or ""):
|
|
146
|
+
bad.append((code, expect[code], got))
|
|
147
|
+
# 联动下拉 AssociationFilter:期望带规则的才比;规则有 NBSP/空白漂移,
|
|
148
|
+
# 规整(去全部空白)后语义比对;线上重存后整条丢失也视为不符
|
|
149
|
+
for c in form["fields"]:
|
|
150
|
+
exp = _norm_rule(_af_rule(c))
|
|
151
|
+
if not exp:
|
|
152
|
+
continue
|
|
153
|
+
got = _norm_rule(_af_rule(live_by_code.get(c["Key"])))
|
|
154
|
+
if exp != got:
|
|
155
|
+
badf.append((c["Key"], exp, got))
|
|
156
|
+
for code in live_by_code:
|
|
157
|
+
# 系统拥有者字段(OwnerId/OwnerDeptId)经 UI 布局加入,属正常存在,不算额外
|
|
158
|
+
if code not in expect and code not in ("_id", "ObjectId",
|
|
159
|
+
"OwnerId", "OwnerDeptId"):
|
|
160
|
+
extra.append(code)
|
|
161
|
+
# 位置控件设置(ControlSettingsStr):期望有才比,逐键比文本
|
|
162
|
+
exp_set = _settings_of(form["fields"])
|
|
163
|
+
live_set = _live_settings(rd)
|
|
164
|
+
badset = []
|
|
165
|
+
for k, want in exp_set.items():
|
|
166
|
+
got_s = live_set.get(k)
|
|
167
|
+
if got_s is None:
|
|
168
|
+
badset.append((k, want, None))
|
|
169
|
+
continue
|
|
170
|
+
for kk, vv in want.items():
|
|
171
|
+
if got_s.get(kk) != vv:
|
|
172
|
+
badset.append(("%s.%s" % (k, kk), vv, got_s.get(kk)))
|
|
173
|
+
row_sizes = [len(ci(d, "columns") or ci(d, "childControls") or []) for d in rows]
|
|
174
|
+
n_expect_rows = expected_layout_rows(form)
|
|
175
|
+
name_live = rd.get("NameSchema")
|
|
176
|
+
# extra(线上比定义多的字段)只提示不判失败:用户在界面加字段是常态,定义可随后同步
|
|
177
|
+
ok = (not miss and not bad and not badf and not badset
|
|
178
|
+
and len(rows) == n_expect_rows)
|
|
179
|
+
return {"ok": ok,
|
|
180
|
+
"fields_expect": len(expect), "fields_live": len(live_by_code),
|
|
181
|
+
"miss": miss, "extra": extra[:MAX_SHOW], "bad": bad[:MAX_SHOW],
|
|
182
|
+
"badf": badf[:MAX_SHOW], "badset": badset[:MAX_SHOW],
|
|
183
|
+
"rows_expect": n_expect_rows, "rows_live": len(rows),
|
|
184
|
+
"row_sizes": row_sizes,
|
|
185
|
+
"name_expect": form.get("name_schema", ""), "name_live": name_live}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def format_report(tag, code, form, rd):
|
|
189
|
+
"""核对结果 → 多行文本(PASS/FAIL + 明细),可写日志或直出"""
|
|
190
|
+
r = compare(form, rd)
|
|
191
|
+
lines = ["== %s %s ==" % (tag, code)]
|
|
192
|
+
subs = form.get("subs") or []
|
|
193
|
+
n_sub = len(subs)
|
|
194
|
+
n_scol = sum(len(s["cols"]) for s in subs)
|
|
195
|
+
lines.append("字段: 期望 %d 实得 %d | 规则: 不符 %d%s%s | 行组(102): 期望 %d 实得 %d (%s)%s"
|
|
196
|
+
% (r["fields_expect"], r["fields_live"], len(r["bad"]),
|
|
197
|
+
(" | 联动过滤: 不符 %d" % len(r["badf"])) if r["badf"] else "",
|
|
198
|
+
(" | 位置设置: 不符 %d" % len(r["badset"])) if r["badset"] else "",
|
|
199
|
+
r["rows_expect"], r["rows_live"], r["row_sizes"],
|
|
200
|
+
(" | 子表: %d(列%d)" % (n_sub, n_scol)) if n_sub else ""))
|
|
201
|
+
if r["miss"]:
|
|
202
|
+
lines.append("缺字段(%d): %s" % (len(r["miss"]), r["miss"][:MAX_SHOW]))
|
|
203
|
+
if r["extra"]:
|
|
204
|
+
lines.append("INFO 线上多出字段(UI 后加,定义未同步): %s" % r["extra"])
|
|
205
|
+
for k, e, g in r["bad"][:MAX_SHOW]:
|
|
206
|
+
lines.append("规则不符 %s\n 期望 %s\n 实得 %s" % (k, e, g))
|
|
207
|
+
for k, e, g in r["badf"][:MAX_SHOW]:
|
|
208
|
+
lines.append("联动过滤不符 %s\n 期望 %s\n 实得 %s" % (k, e, g))
|
|
209
|
+
for k, e, g in r["badset"][:MAX_SHOW]:
|
|
210
|
+
if g is None:
|
|
211
|
+
lines.append("位置设置缺失 %s(期望 %s;线上无此控件的 ControlSettings)" % (k, e))
|
|
212
|
+
else:
|
|
213
|
+
lines.append("位置设置不符 %s\n 期望 %s\n 实得 %s" % (k, e, g))
|
|
214
|
+
if r["name_expect"] and r["name_live"] != r["name_expect"]:
|
|
215
|
+
lines.append("NameSchema 期望 %s 实得 %s" % (r["name_expect"], r["name_live"]))
|
|
216
|
+
lines.append("核对结果: %s" % ("PASS" if r["ok"] else "FAIL"))
|
|
217
|
+
return "\n".join(lines)
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "h3factory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "氚云(H3Yun)表单工厂:需求 → 表单定义 → 建表 → 只读核对 → 代码骨架",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/xiangduo10146814/h3factory.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/xiangduo10146814/h3factory#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/xiangduo10146814/h3factory/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"h3yun",
|
|
16
|
+
"氚云",
|
|
17
|
+
"low-code",
|
|
18
|
+
"ai-agent",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"cli"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"h3factory": "bin/h3factory.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin/",
|
|
31
|
+
"scripts/postinstall.js",
|
|
32
|
+
"cli.py",
|
|
33
|
+
"h3platform/",
|
|
34
|
+
"h3design/",
|
|
35
|
+
"h3verify/",
|
|
36
|
+
"templates/",
|
|
37
|
+
"docs/",
|
|
38
|
+
"skills/",
|
|
39
|
+
"projects/demo/",
|
|
40
|
+
"config.example.json",
|
|
41
|
+
"README.md",
|
|
42
|
+
"!**/__pycache__/",
|
|
43
|
+
"!**/*.pyc"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"postinstall": "node scripts/postinstall.js",
|
|
47
|
+
"prepare": "node scripts/postinstall.js --quiet",
|
|
48
|
+
"pack:check": "npm pack --dry-run"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=16"
|
|
52
|
+
}
|
|
53
|
+
}
|