draftgo-cli 2.0.5 → 3.0.0

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.
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ DraftGo Delete Script
4
+ 删除云端资源,并同步移除本地 index.json 条目和文件。
5
+
6
+ 用法:
7
+ python draftgo_delete.py pages <id>
8
+ python draftgo_delete.py nav <id>
9
+ python draftgo_delete.py db_meta <id>
10
+ python draftgo_delete.py custom_scripts <id>
11
+ python draftgo_delete.py docs <id>
12
+ python draftgo_delete.py doc_categories <id>
13
+ python draftgo_delete.py external_apis <id>
14
+ python draftgo_delete.py aihub <id>
15
+ """
16
+ import json, sys
17
+ from pathlib import Path
18
+ import urllib.request, urllib.error
19
+
20
+ SCRIPT_DIR = Path(__file__).resolve().parent
21
+
22
+
23
+ def find_project_root(start: Path) -> Path:
24
+ cur = start
25
+ for _ in range(10):
26
+ if (cur / ".draftgo").is_dir():
27
+ return cur
28
+ if cur.parent == cur:
29
+ break
30
+ cur = cur.parent
31
+ cwd = Path.cwd()
32
+ if (cwd / ".draftgo").is_dir():
33
+ return cwd
34
+ return start.parents[3] if len(start.parents) >= 4 else start
35
+
36
+
37
+ DEFAULT_ROOT = find_project_root(SCRIPT_DIR)
38
+
39
+
40
+ def load_config():
41
+ cfg_path = DEFAULT_ROOT / ".draftgo/config.json"
42
+ if not cfg_path.exists():
43
+ print("ERR: .draftgo/config.json not found, run /draftgo init first", file=sys.stderr)
44
+ sys.exit(1)
45
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
46
+ return cfg["server"].rstrip("/"), cfg["token"]
47
+
48
+
49
+ def api_delete(server, token, path):
50
+ headers = {
51
+ "Authorization": f"Bearer {token}",
52
+ "Accept": "application/json",
53
+ }
54
+ req = urllib.request.Request(
55
+ f"{server}{path}",
56
+ method="DELETE",
57
+ headers=headers,
58
+ )
59
+ try:
60
+ with urllib.request.urlopen(req, timeout=15) as r:
61
+ return True, r.status, r.read().decode("utf-8", errors="replace")
62
+ except urllib.error.HTTPError as e:
63
+ return False, e.code, e.read().decode(errors="replace")
64
+ except Exception as e:
65
+ return False, None, str(e)
66
+
67
+
68
+ def _load_index(rel_index):
69
+ new_path = DEFAULT_ROOT / ".draftgo" / rel_index
70
+ legacy_path = DEFAULT_ROOT / rel_index
71
+ path = new_path if new_path.exists() else legacy_path
72
+ if not path.exists():
73
+ print(f"ERR: {rel_index} not found", file=sys.stderr)
74
+ sys.exit(1)
75
+ return path, json.loads(path.read_text(encoding="utf-8"))
76
+
77
+
78
+ def _remove_local_file(rel_path):
79
+ if not rel_path:
80
+ return
81
+ full = DEFAULT_ROOT / rel_path
82
+ if full.exists():
83
+ full.unlink()
84
+ print(f" deleted local file: {rel_path}")
85
+
86
+
87
+ # type → (api_prefix, index_rel, id_field, file_field)
88
+ TYPE_MAP = {
89
+ "pages": ("/api/pages", "pages/index.json", "id", "html_file"),
90
+ "nav": ("/api/navigations", "navigations/index.json", "id", "html_file"),
91
+ "db_meta": ("/api/db-meta", "db_meta/index.json", "id", None),
92
+ "custom_scripts": ("/api/scripts", "custom_scripts/index.json", "id", "code_file"),
93
+ "docs": ("/api/docs/articles", "docs/articles/index.json", "id", "content_file"),
94
+ "doc_categories": ("/api/docs/categories", "doc_categories/index.json", "id", None),
95
+ "external_apis": ("/api/external-apis", "external_apis/index.json", "id", None),
96
+ "aihub": ("/api/aihub", "aihub/index.json", "id", None),
97
+ }
98
+
99
+
100
+ def delete_resource(type_name, resource_id):
101
+ if type_name not in TYPE_MAP:
102
+ print(f"ERR: unknown type '{type_name}'. Supported: {', '.join(TYPE_MAP)}", file=sys.stderr)
103
+ sys.exit(1)
104
+
105
+ api_prefix, index_rel, id_field, file_field = TYPE_MAP[type_name]
106
+ server, token = load_config()
107
+
108
+ # 1. 找到 index 条目
109
+ index_path, items = _load_index(index_rel)
110
+ item = next((it for it in items if str(it.get(id_field)) == str(resource_id)), None)
111
+ label = (
112
+ item.get("title") or item.get("name") or item.get("type") or item.get("slug") or resource_id
113
+ if item else resource_id
114
+ )
115
+
116
+ # 2. 调用 DELETE API
117
+ ok, status, body = api_delete(server, token, f"{api_prefix}/{resource_id}")
118
+ if not ok and status != 404:
119
+ print(f" ERR [{label}] 删除失败: HTTP {status}: {body}")
120
+ sys.exit(1)
121
+
122
+ if status == 404:
123
+ print(f" WARN [{label}] 云端不存在(id={resource_id}),仅清理本地")
124
+ else:
125
+ print(f" OK [{label}] 已从云端删除(id={resource_id})")
126
+
127
+ # 3. 删除本地文件
128
+ if item and file_field:
129
+ _remove_local_file(item.get(file_field))
130
+
131
+ # 4. 从 index 移除条目
132
+ new_items = [it for it in items if str(it.get(id_field)) != str(resource_id)]
133
+ if len(new_items) < len(items):
134
+ index_path.write_text(json.dumps(new_items, ensure_ascii=False, indent=2), encoding="utf-8")
135
+ print(f" OK [{label}] 已从本地 index 移除")
136
+ else:
137
+ print(f" WARN [{label}] 本地 index 中未找到 id={resource_id}")
138
+
139
+
140
+ def main():
141
+ if len(sys.argv) < 3:
142
+ print(f"usage: draftgo_delete.py <{'|'.join(TYPE_MAP)}> <id>", file=sys.stderr)
143
+ sys.exit(1)
144
+
145
+ type_name = sys.argv[1]
146
+ resource_id = sys.argv[2]
147
+ delete_resource(type_name, resource_id)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -48,7 +48,7 @@ ENDPOINTS = {
48
48
  "roles": "/api/roles",
49
49
  "users": "/api/users?page=1&page_size=500",
50
50
  "db_meta": "/api/db-meta",
51
- "aihub": "/api/aihub",
51
+ "aihub": "/api/aihub?page=1&page_size=500",
52
52
  "external_apis": "/api/external-apis?page=1&page_size=500",
53
53
  "system_config": "/api/system/config",
54
54
  "doc_categories": "/api/docs/categories?flat=true",
@@ -43,6 +43,45 @@ def find_project_root(start: Path) -> Path:
43
43
 
44
44
  DEFAULT_ROOT = find_project_root(SCRIPT_DIR)
45
45
 
46
+ # ── 冲突检测 ──
47
+ FORCE = False
48
+
49
+
50
+ def fetch_get(server, token, path):
51
+ """GET 请求,返回解析后的 JSON 或 None。"""
52
+ ok, _status, body = api_call("GET", server, token, path)
53
+ if not ok:
54
+ return None
55
+ try:
56
+ return json.loads(body)
57
+ except Exception:
58
+ return None
59
+
60
+
61
+ def check_conflict(server, token, path, local_updated_at, label):
62
+ """检查云端 updated_at 是否与本地基线一致。返回 True 表示可以继续推送。"""
63
+ if FORCE:
64
+ return True
65
+ if not local_updated_at:
66
+ return True
67
+ raw = fetch_get(server, token, path)
68
+ if raw is None:
69
+ print(f" WARN [{label}] 无法获取云端状态,跳过冲突检测")
70
+ return True
71
+ data = raw.get("data") if isinstance(raw, dict) else None
72
+ if not isinstance(data, dict):
73
+ return True
74
+ remote_updated = data.get("updated_at")
75
+ if not remote_updated:
76
+ return True
77
+ if str(remote_updated) != str(local_updated_at):
78
+ print(f" SKIP [{label}] 云端已被他人修改")
79
+ print(f" 本地基线: {local_updated_at}")
80
+ print(f" 云端时间: {remote_updated}")
81
+ print(f" 请先 pull 同步后再 push,或使用 --force 强制覆盖")
82
+ return False
83
+ return True
84
+
46
85
 
47
86
  def load_config():
48
87
  cfg_path = DEFAULT_ROOT / ".draftgo/config.json"
@@ -182,6 +221,8 @@ def sync_pages(server, token, ids=None):
182
221
  "value": {"html": html},
183
222
  }
184
223
  if pid:
224
+ if not check_conflict(server, token, f"/api/pages/{pid}", page.get("updated_at"), title):
225
+ continue
185
226
  ok, status, body = api_call("PUT", server, token, f"/api/pages/{pid}", payload)
186
227
  # PUT 404:本地 id 与云端不一致(删了重建 / 跨环境),按 route 自动创建
187
228
  if not ok and status == 404:
@@ -225,6 +266,8 @@ def sync_db_meta(server, token, ids=None):
225
266
  "extra": meta.get("extra"),
226
267
  }
227
268
  if mid:
269
+ if not check_conflict(server, token, f"/api/db-meta/{mid}", meta.get("updated_at"), label):
270
+ continue
228
271
  ok, status, body = api_call("PUT", server, token, f"/api/db-meta/{mid}", payload)
229
272
  # 本地 index 的 id 与云端不一致(删了重建 / 跨环境同步)时 PUT 404,按 type 创建
230
273
  if not ok and status == 404:
@@ -261,6 +304,8 @@ def sync_nav(server, token, ids=None):
261
304
  continue
262
305
  html = html_file.read_text(encoding="utf-8")
263
306
  if nid:
307
+ if not check_conflict(server, token, f"/api/navigations/{nid}", nav.get("updated_at"), name):
308
+ continue
264
309
  ok, status, body = api_call("PUT", server, token, f"/api/navigations/{nid}", {"html": html})
265
310
  if not ok and status == 404:
266
311
  print(f" WARN [{name}] nav_id={nid} 不存在,尝试创建")
@@ -294,36 +339,85 @@ def sync_nav(server, token, ids=None):
294
339
 
295
340
 
296
341
  def sync_aihub(server, token, ids=None):
297
- items = _load_index("aihub/index.json")
342
+ all_items = _load_index("aihub/index.json")
343
+ items = all_items
298
344
  if ids:
299
- items = [it for it in items if str(it.get("id")) in ids]
345
+ items = [it for it in all_items if str(it.get("id")) in ids]
346
+ dirty = False
300
347
  for it in items:
301
348
  iid = it.get("id")
302
- name = it.get("name", iid)
349
+ name = it.get("name", iid or "(new)")
303
350
  # 服务端 AIHubUpdate 接受的字段子集
304
351
  payload = {k: it.get(k) for k in (
305
352
  "type", "name", "data", "priority", "version",
306
353
  "tags", "describe", "permission", "status",
307
354
  ) if it.get(k) is not None}
308
- ok, status, body = api_call("PUT", server, token, f"/api/aihub/{iid}", payload)
309
- print(f" {'OK' if ok else 'ERR'} [{name}] aihub_id={iid}{'' if ok else ' -> ' + _info(ok, status, body)}")
355
+ if iid:
356
+ if not check_conflict(server, token, f"/api/aihub/{iid}", it.get("updated_at"), name):
357
+ continue
358
+ ok, status, body = api_call("PUT", server, token, f"/api/aihub/{iid}", payload)
359
+ if not ok and status == 404:
360
+ print(f" WARN [{name}] aihub_id={iid} 不存在,尝试创建")
361
+ iid = None
362
+ else:
363
+ print(f" {'OK' if ok else 'ERR'} [{name}] aihub_id={iid}{'' if ok else ' -> ' + _info(ok, status, body)}")
364
+ if not iid:
365
+ create_payload = {k: payload.get(k) for k in (
366
+ "type", "name", "data", "priority", "version",
367
+ "tags", "describe", "permission",
368
+ ) if payload.get(k) is not None}
369
+ ok, status, body = api_call("POST", server, token, "/api/aihub", create_payload)
370
+ new_id = _extract_created_id(body) if ok else None
371
+ if ok and new_id:
372
+ it["id"] = new_id
373
+ dirty = True
374
+ print(f" OK [{name}] 已创建 aihub_id={new_id}(已回写 index)")
375
+ else:
376
+ print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
377
+ if dirty:
378
+ _writeback_index("aihub/index.json", all_items)
310
379
 
311
380
 
312
381
  def sync_external_apis(server, token, ids=None):
313
- items = _load_index("external_apis/index.json")
382
+ all_items = _load_index("external_apis/index.json")
383
+ items = all_items
314
384
  if ids:
315
- items = [it for it in items if str(it.get("id")) in ids]
385
+ items = [it for it in all_items if str(it.get("id")) in ids]
386
+ dirty = False
316
387
  for it in items:
317
388
  iid = it.get("id")
318
- code = it.get("code", iid)
389
+ code = it.get("code", iid or "(new)")
319
390
  # 服务端 ExternalAPIUpdate 接受字段
320
391
  payload = {k: it.get(k) for k in (
321
392
  "name", "base_url", "method", "path", "headers",
322
393
  "auth_type", "auth_config", "timeout_ms", "permission",
323
394
  "param_schema", "tags", "description", "status",
324
395
  ) if it.get(k) is not None}
325
- ok, status, body = api_call("PUT", server, token, f"/api/external-apis/{iid}", payload)
326
- print(f" {'OK' if ok else 'ERR'} [{code}] api_id={iid}{'' if ok else ' -> ' + _info(ok, status, body)}")
396
+ if iid:
397
+ if not check_conflict(server, token, f"/api/external-apis/{iid}", it.get("updated_at"), code):
398
+ continue
399
+ ok, status, body = api_call("PUT", server, token, f"/api/external-apis/{iid}", payload)
400
+ if not ok and status == 404:
401
+ print(f" WARN [{code}] api_id={iid} 不存在,尝试创建")
402
+ iid = None
403
+ else:
404
+ print(f" {'OK' if ok else 'ERR'} [{code}] api_id={iid}{'' if ok else ' -> ' + _info(ok, status, body)}")
405
+ if not iid:
406
+ create_payload = {k: it.get(k) for k in (
407
+ "code", "name", "base_url", "method", "path", "headers",
408
+ "auth_type", "auth_config", "timeout_ms", "permission",
409
+ "param_schema", "tags", "description",
410
+ ) if it.get(k) is not None}
411
+ ok, status, body = api_call("POST", server, token, "/api/external-apis", create_payload)
412
+ new_id = _extract_created_id(body) if ok else None
413
+ if ok and new_id:
414
+ it["id"] = new_id
415
+ dirty = True
416
+ print(f" OK [{code}] 已创建 api_id={new_id}(已回写 index)")
417
+ else:
418
+ print(f" ERR [{code}] 创建失败 -> {_info(ok, status, body)}")
419
+ if dirty:
420
+ _writeback_index("external_apis/index.json", all_items)
327
421
 
328
422
 
329
423
  def sync_system_config(server, token, keys=None):
@@ -345,8 +439,16 @@ def sync_system_config(server, token, keys=None):
345
439
  "status": it.get("status"),
346
440
  }
347
441
  payload = {k: v for k, v in payload.items() if v is not None}
442
+ if not check_conflict(server, token, f"/api/system/{ck}", it.get("updated_at"), ck):
443
+ continue
348
444
  ok, status, body = api_call("PUT", server, token, f"/api/system/{ck}", payload)
349
- print(f" {'OK' if ok else 'ERR'} [{ck}]{'' if ok else ' -> ' + _info(ok, status, body)}")
445
+ if not ok and status == 404:
446
+ print(f" WARN [{ck}] system_config 不存在,尝试创建")
447
+ create_payload = {"config_key": ck, **payload}
448
+ ok, status, body = api_call("POST", server, token, "/api/system/", create_payload)
449
+ print(f" {'OK' if ok else 'ERR'} [{ck}] 已创建{'' if ok else ' -> ' + _info(ok, status, body)}")
450
+ else:
451
+ print(f" {'OK' if ok else 'ERR'} [{ck}]{'' if ok else ' -> ' + _info(ok, status, body)}")
350
452
 
351
453
 
352
454
  def sync_roles(server, token, ids=None):
@@ -360,6 +462,8 @@ def sync_roles(server, token, ids=None):
360
462
  payload = {k: it.get(k) for k in (
361
463
  "name", "description", "status", "sort_order", "user_visible",
362
464
  ) if it.get(k) is not None}
465
+ if not check_conflict(server, token, f"/api/roles/{rid}", it.get("updated_at"), code):
466
+ continue
363
467
  ok, status, body = api_call("PUT", server, token, f"/api/roles/{rid}", payload)
364
468
  print(f" {'OK' if ok else 'ERR'} [{code}] role_id={rid}{'' if ok else ' -> ' + _info(ok, status, body)}")
365
469
 
@@ -376,6 +480,8 @@ def sync_users(server, token, ids=None):
376
480
  "username", "email", "phone_number", "nickname",
377
481
  "avatar", "status", "notes",
378
482
  ) if it.get(k) is not None}
483
+ if not check_conflict(server, token, f"/api/users/{uid}", it.get("updated_at"), uname):
484
+ continue
379
485
  ok, status, body = api_call("PUT", server, token, f"/api/users/{uid}", payload)
380
486
  print(f" {'OK' if ok else 'ERR'} [{uname}] user_id={uid}{'' if ok else ' -> ' + _info(ok, status, body)}")
381
487
 
@@ -404,6 +510,8 @@ def sync_docs(server, token, ids=None):
404
510
  ) if it.get(k) is not None}
405
511
  payload["content"] = content
406
512
  if aid:
513
+ if not check_conflict(server, token, f"/api/docs/articles/{aid}", it.get("updated_at"), title):
514
+ continue
407
515
  ok, status, body = api_call("PUT", server, token, f"/api/docs/articles/{aid}", payload)
408
516
  if not ok and status == 404:
409
517
  print(f" WARN [{title}] article_id={aid} 不存在,尝试创建")
@@ -428,18 +536,38 @@ def sync_docs(server, token, ids=None):
428
536
 
429
537
 
430
538
  def sync_doc_categories(server, token, ids=None):
431
- items = _load_index("doc_categories/index.json")
539
+ all_items = _load_index("doc_categories/index.json")
540
+ items = all_items
432
541
  if ids:
433
- items = [it for it in items if str(it.get("id")) in ids]
542
+ items = [it for it in all_items if str(it.get("id")) in ids]
543
+ dirty = False
434
544
  for it in items:
435
545
  cid = it.get("id")
436
- name = it.get("name", cid)
546
+ name = it.get("name", cid or "(new)")
437
547
  # CategoryUpdate 字段
438
548
  payload = {k: it.get(k) for k in (
439
549
  "name", "slug", "description", "icon", "parent_id", "sort_order", "status",
440
550
  ) if it.get(k) is not None}
441
- ok, status, body = api_call("PUT", server, token, f"/api/docs/categories/{cid}", payload)
442
- print(f" {'OK' if ok else 'ERR'} [{name}] category_id={cid}{'' if ok else ' -> ' + _info(ok, status, body)}")
551
+ if cid:
552
+ if not check_conflict(server, token, f"/api/docs/categories/{cid}", it.get("updated_at"), name):
553
+ continue
554
+ ok, status, body = api_call("PUT", server, token, f"/api/docs/categories/{cid}", payload)
555
+ if not ok and status == 404:
556
+ print(f" WARN [{name}] category_id={cid} 不存在,尝试创建")
557
+ cid = None
558
+ else:
559
+ print(f" {'OK' if ok else 'ERR'} [{name}] category_id={cid}{'' if ok else ' -> ' + _info(ok, status, body)}")
560
+ if not cid:
561
+ ok, status, body = api_call("POST", server, token, "/api/docs/categories", payload)
562
+ new_id = _extract_created_id(body) if ok else None
563
+ if ok and new_id:
564
+ it["id"] = new_id
565
+ dirty = True
566
+ print(f" OK [{name}] 已创建 category_id={new_id}(已回写 index)")
567
+ else:
568
+ print(f" ERR [{name}] 创建失败 -> {_info(ok, status, body)}")
569
+ if dirty:
570
+ _writeback_index("doc_categories/index.json", all_items)
443
571
 
444
572
 
445
573
  def _normalize_script_triggers(mode, triggers):
@@ -472,6 +600,8 @@ def sync_custom_scripts(server, token, ids=None):
472
600
  code = code_path.read_text(encoding="utf-8")
473
601
  normalized_triggers = _normalize_script_triggers(it.get("mode"), it.get("triggers"))
474
602
  if sid:
603
+ if not check_conflict(server, token, f"/api/scripts/{sid}", it.get("updated_at"), name):
604
+ continue
475
605
  # ScriptUpdate 接受字段(不含 slug/mode,避免误改启停/路由)
476
606
  payload = {k: it.get(k) for k in (
477
607
  "name", "description", "config", "permission",
@@ -597,22 +727,35 @@ def run_batch(server, token, args):
597
727
 
598
728
 
599
729
  def main():
730
+ global FORCE
731
+
600
732
  if len(sys.argv) < 2:
601
733
  print(f"usage: draftgo_push.py {{{'|'.join(HANDLERS)}}} [id ...]\n"
602
- f" draftgo_push.py --batch <mode> <ids> [<mode> <ids> ...]")
734
+ f" draftgo_push.py --batch <mode> <ids> [<mode> <ids> ...]\n"
735
+ f" --force 跳过冲突检测,强制覆盖")
736
+ sys.exit(1)
737
+
738
+ # 解析 --force(可出现在任意位置)
739
+ args = [a for a in sys.argv[1:] if a != "--force"]
740
+ if len(args) < len(sys.argv) - 1:
741
+ FORCE = True
742
+ print("[force] 已跳过冲突检测,将强制覆盖云端数据")
743
+
744
+ if not args:
745
+ print(f"usage: draftgo_push.py {{{'|'.join(HANDLERS)}}} [id ...]")
603
746
  sys.exit(1)
604
747
 
605
- if sys.argv[1] == "--batch":
748
+ if args[0] == "--batch":
606
749
  server, token, cfg = load_config()
607
- run_batch(server, token, sys.argv[2:])
750
+ run_batch(server, token, args[1:])
608
751
  _lessons_reminder(cfg)
609
752
  return
610
753
 
611
- if sys.argv[1] not in HANDLERS:
754
+ if args[0] not in HANDLERS:
612
755
  print(f"usage: draftgo_push.py {{{'|'.join(HANDLERS)}}} [id ...]")
613
756
  sys.exit(1)
614
- mode = sys.argv[1]
615
- ids = sys.argv[2:] or None
757
+ mode = args[0]
758
+ ids = args[1:] or None
616
759
  server, token, cfg = load_config()
617
760
  HANDLERS[mode](server, token, ids)
618
761
  _lessons_reminder(cfg)
@@ -0,0 +1,108 @@
1
+ ---
2
+ read_when: 操作动态 DB 之前 · 设计数据结构时 · 使用 filters 检索时
3
+ ---
4
+
5
+ # 动态 DB & 数据层
6
+
7
+ ## DB Meta 结构
8
+
9
+ ```json
10
+ {
11
+ "type": "order",
12
+ "label": "订单",
13
+ "schema": {
14
+ "type": "object",
15
+ "properties": {
16
+ "name": { "type": "string", "title": "姓名", "required": true, "searchable": "fuzzy" },
17
+ "status": { "type": "string", "title": "状态", "required": false, "searchable": "exact" },
18
+ "amount": { "type": "number", "title": "金额", "required": false, "searchable": "range" },
19
+ "tags": { "type": "array", "title": "标签", "required": false, "searchable": "contains" },
20
+ "note": { "type": "string", "title": "备注", "required": false, "searchable": false }
21
+ }
22
+ },
23
+ "permission": {
24
+ "public": { "read": "none", "create": "none", "update": "none", "delete": "none" },
25
+ "login": { "read": "all", "create": "all", "update": "owner", "delete": "owner" },
26
+ "admin": { "read": "all", "create": "all", "update": "all", "delete": "all" }
27
+ }
28
+ }
29
+ ```
30
+
31
+ **searchable 模式**:`false`(不可检索)/ `"exact"`(精确)/ `"fuzzy"`(模糊)/ `"range"`(数值范围)/ `"contains"`(数组包含)
32
+
33
+ ---
34
+
35
+ ## CRUD 操作范式
36
+
37
+ ```javascript
38
+ // 查询(分页 + 结构化检索)
39
+ const res = await App.get(`db/order`, {
40
+ page: 1, page_size: 20,
41
+ filters: ['name:like:张', 'status:eq:paid', 'amount:gte:100'],
42
+ order_by: 'amount', order: 'desc',
43
+ });
44
+ const { items, total } = res.data;
45
+
46
+ // 创建(业务字段必须放在 data 包裹里)
47
+ await App.post(`db/order`, { data: { name: '张三', amount: 200 }, status: 1 });
48
+
49
+ // 更新
50
+ await App.put(`db/order/${id}`, { data: { amount: 250 } });
51
+
52
+ // 删除
53
+ await App.delete(`db/order/${id}`);
54
+
55
+ // 批量更新(原子事务,任一失败全批回滚)
56
+ await App.patch(`db/order/batch`, [
57
+ { id: 1, data: { status: 'paid' } },
58
+ { id: 2, data: { status: 'paid' } },
59
+ ]);
60
+ ```
61
+
62
+ ---
63
+
64
+ ## filters 操作符
65
+
66
+ | 操作符 | 含义 | 适用 searchable 模式 |
67
+ |---|---|---|
68
+ | `eq` | 精确等于 | exact / fuzzy / range |
69
+ | `like` | 模糊包含 | fuzzy |
70
+ | `gte` / `lte` / `gt` / `lt` | 数值范围 | range |
71
+ | `in` | 枚举命中(值逗号分隔:`status:in:paid,pending`) | exact / fuzzy |
72
+ | `contains` | 数组字段包含某值 | contains |
73
+
74
+ - 省略操作符(`filters: ['name:张三']`)默认 `like`
75
+ - 多个 filters 为 AND
76
+ - 字段未标 searchable 或操作符不匹配 → 后端返回 400
77
+
78
+ ---
79
+
80
+ ## db_meta 本地文件
81
+
82
+ 开发前先读 `.draftgo/db_meta/index.json` 了解可用 type 和字段:
83
+
84
+ ```json
85
+ [
86
+ {
87
+ "id": 1,
88
+ "type": "order",
89
+ "label": "订单",
90
+ "schema": { ... }
91
+ }
92
+ ]
93
+ ```
94
+
95
+ ⚠️ **GET `/api/db-meta/{type}` 用 type(如 `order`),不是 id。PUT/DELETE 才用 id。**
96
+
97
+ ---
98
+
99
+ ## 通用筛选参数(非动态 DB)
100
+
101
+ 用于 users / roles / pages / navigations / feedback 等标准资源:
102
+
103
+ | 参数 | 说明 |
104
+ |---|---|
105
+ | `page` / `page_size` | 分页(不传返回全量,超 10000 条后端拒绝) |
106
+ | `search` | 全文搜索(动态 DB 不用此参数) |
107
+ | `status` | 状态过滤 |
108
+ | `type` / `tag` | 类型/标签过滤 |
@@ -0,0 +1,98 @@
1
+ ---
2
+ read_when: 需要理解运行时机制时 · 处理 token/路由/事件相关问题时
3
+ ---
4
+
5
+ # 运行时机制(Runtime)
6
+
7
+ ## 壳层启动流程
8
+
9
+ 1. `reloadSystemConfig()` — 拉取 `/api/system/config`,填充 `state.config`
10
+ 2. `validateToken()` — 验证 `localStorage.dg_access_token`,成功后设置 `currentUser`
11
+ 3. `loadSetupStatus()` — 检查系统是否已初始化
12
+ 4. `loadPage()` — 根据当前路由拉取页面 HTML,注入 iframe
13
+
14
+ ---
15
+
16
+ ## iframe 注入机制
17
+
18
+ 壳层通过 `decorateFrameHtml(html, routeContext, theme)` 处理数据库页面 HTML,注入:
19
+
20
+ - `window.__DG_ROUTE_CONTEXT__` — 当前路由上下文(含 `query` 参数),冻结对象
21
+ - `window.__DG_QUERY__` — `routeContext.query` 快捷方式
22
+ - `window.__DG_GET_ROUTE_CONTEXT__()` — 函数形式兜底读取
23
+ - 主题 CSS 变量(写入 `<head>` 最顶部,DOM 解析时即生效)
24
+ - 静态资源(Tailwind runtime、FontAwesome、GSAP 等)
25
+
26
+ 页面以 `iframe.srcdoc` 渲染,**不是独立 URL**,因此:
27
+ - `window.location` 指向壳层地址,**不可用于读取路由参数**
28
+ - `window.parent.App` 是壳层暴露的能力对象
29
+ - `window.location.href = '/path'` 只跳 iframe 自身!导航必须用 `window.parent.location.href`
30
+
31
+ ---
32
+
33
+ ## App 对象来源
34
+
35
+ 壳层将 `app` 对象赋值给 `window.App`,页面通过 `window.parent.App` 访问。
36
+
37
+ `app` 组成:`api.js`(请求)+ `feedback.js`(弹窗/Toast)+ `runtime.js`(路由/主题)+ `i18n.js`(国际化)+ state(currentUser/isAdmin/config/theme)
38
+
39
+ 完整 API 见 `{{SKILL_DIR}}/quickref/app-api.md`
40
+
41
+ ---
42
+
43
+ ## Token 存储
44
+
45
+ | key | 说明 |
46
+ |---|---|
47
+ | `localStorage.dg_access_token` | 访问 token |
48
+ | `localStorage.dg_refresh_token` | 刷新 token |
49
+
50
+ 登录后调用 `App.setAuthTokens({ access_token, refresh_token })` 写入并触发验证。
51
+ 401 时壳层自动用 refresh token 刷新,无需页面处理。
52
+
53
+ ---
54
+
55
+ ## URL 参数读取(标准三阶回落)
56
+
57
+ ```javascript
58
+ const routeContext =
59
+ window.__DG_ROUTE_CONTEXT__
60
+ || window.__DG_GET_ROUTE_CONTEXT__?.()
61
+ || window.parent?.App?.getCurrentRouteContext?.()
62
+ || { query: {} };
63
+ const query = routeContext.query || {};
64
+
65
+ // 路由 /orders?orderId=42
66
+ const orderId = query.orderId; // "42"
67
+ ```
68
+
69
+ **禁止**:`new URLSearchParams(window.location.search)` / 直接读 `window.location.search`
70
+
71
+ ---
72
+
73
+ ## 全局事件
74
+
75
+ | 事件名 | 触发时机 |
76
+ |---|---|
77
+ | `dg:auth-ready` | token 验证完成(成功或失败) |
78
+ | `dg:auth-changed` | token 变更(登录/登出) |
79
+
80
+ 监听方式:`window.parent.addEventListener('dg:auth-ready', handler)`
81
+
82
+ ---
83
+
84
+ ## 前端全局层
85
+
86
+ 全局浮窗、客服入口、统计脚本等属于全局层,不属于单个业务页面。
87
+
88
+ 存储:`sys_config.category = frontend_global`
89
+
90
+ | config_key | 用途 |
91
+ |---|---|
92
+ | `frontend_global_head_html` | 注入壳层 head |
93
+ | `frontend_global_body_html` | 注入壳层 body 末尾 |
94
+ | `frontend_global_css` / `_js` | 壳层全局 CSS/JS |
95
+ | `frontend_global_iframe_head_html` | 注入每个业务页面 iframe head |
96
+ | `frontend_global_widget_html/css/js` | 全局挂件层 |
97
+
98
+ 规则:**不能在业务页面内重复实现全局层能力;不允许新增槽位,只能编辑内置槽位。**