@aiyiran/myclaw 1.1.44 → 1.1.45
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/assets/myclaw-artifacts.js +14 -8
- package/package.json +1 -1
- package/server/sync_workspace.py +72 -41
- package/skills/yiran-course-template-pipeline/scripts/build_template_index.py +56 -19
- package/skills/yiran-course-template-pipeline/scripts/identify_template.py +176 -0
- package/skills/yiran-course-template-pipeline/scripts/migrate_templates.py +104 -0
- package/skills/yiran-course-template-pipeline/scripts/move_template_task.py +59 -59
- package/skills/yiran-course-template-pipeline/scripts/publish_template.py +51 -37
|
@@ -2310,6 +2310,8 @@
|
|
|
2310
2310
|
resetBtn(copyLocalBtn);
|
|
2311
2311
|
if (res.ok) {
|
|
2312
2312
|
setFooterStatus('✓ 已复制到 ' + res.folder_rel_path, '#10b981');
|
|
2313
|
+
} else if (res.code === 'not_ready') {
|
|
2314
|
+
setFooterStatus('⟳ 模板文件下载中,完成后即可使用', '#f59e0b');
|
|
2313
2315
|
} else {
|
|
2314
2316
|
setFooterStatus('✗ ' + (res.error || '复制失败'), '#ff4444');
|
|
2315
2317
|
}
|
|
@@ -2341,6 +2343,8 @@
|
|
|
2341
2343
|
window.location.href = 'http://127.0.0.1:18789/chat?session=' + encodeURIComponent(sessionParam);
|
|
2342
2344
|
}, 4000);
|
|
2343
2345
|
}
|
|
2346
|
+
} else if (res.code === 'not_ready') {
|
|
2347
|
+
setFooterStatus('⟳ 模板文件下载中,完成后即可使用', '#f59e0b');
|
|
2344
2348
|
} else {
|
|
2345
2349
|
setFooterStatus('✗ ' + (res.error || '创建失败'), '#ff4444');
|
|
2346
2350
|
}
|
|
@@ -2367,13 +2371,14 @@
|
|
|
2367
2371
|
leftPane.appendChild(loadingEl);
|
|
2368
2372
|
|
|
2369
2373
|
function flattenTemplates(indexData) {
|
|
2370
|
-
//
|
|
2374
|
+
// 新格式:{index_updated_at, templates: {id: record}},直接平铺
|
|
2371
2375
|
var raw = indexData.templates || indexData;
|
|
2372
|
-
var list =
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2376
|
+
var list = Object.values(raw);
|
|
2377
|
+
// 按系列+编号排序
|
|
2378
|
+
list.sort(function (a, b) {
|
|
2379
|
+
var sa = (a['系列'] || '') + ('000' + (a['编号'] || '0')).slice(-4);
|
|
2380
|
+
var sb = (b['系列'] || '') + ('000' + (b['编号'] || '0')).slice(-4);
|
|
2381
|
+
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
|
2377
2382
|
});
|
|
2378
2383
|
return list;
|
|
2379
2384
|
}
|
|
@@ -2423,7 +2428,7 @@
|
|
|
2423
2428
|
rightHeader.appendChild(infoSpan);
|
|
2424
2429
|
rightHeader.appendChild(openNewBtn);
|
|
2425
2430
|
rightHeader.appendChild(useBtn);
|
|
2426
|
-
previewIframe.src = TEMPLATE_CDN_BASE + '/' + encodeURIComponent(tpl['文件夹名']) + '/__student-view__.html?t=' + Date.now();
|
|
2431
|
+
previewIframe.src = TEMPLATE_CDN_BASE + '/' + encodeURIComponent(tpl['文件夹名']) + '/' + (tpl['version'] || 'v1') + '/__student-view__.html?t=' + Date.now();
|
|
2427
2432
|
}
|
|
2428
2433
|
|
|
2429
2434
|
row.onclick = setActive;
|
|
@@ -2437,7 +2442,7 @@
|
|
|
2437
2442
|
badge.style.cssText = 'font-size:10px;font-weight:bold;background:#4a4a7a;color:#cdd6f4;padding:1px 7px;border-radius:8px;flex-shrink:0;';
|
|
2438
2443
|
var nameEl = document.createElement('span');
|
|
2439
2444
|
nameEl.textContent = tpl['名称'];
|
|
2440
|
-
nameEl.style.cssText = 'font-size:12px;color:#cdd6f4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
|
|
2445
|
+
nameEl.style.cssText = 'font-size:12px;color:#cdd6f4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;';
|
|
2441
2446
|
topRow.appendChild(badge);
|
|
2442
2447
|
topRow.appendChild(nameEl);
|
|
2443
2448
|
|
|
@@ -2461,6 +2466,7 @@
|
|
|
2461
2466
|
});
|
|
2462
2467
|
}
|
|
2463
2468
|
|
|
2469
|
+
// ── 就绪状态追踪 ──────────────────────────────────────────────────────
|
|
2464
2470
|
// ── 并行:加载本地 index + 检查 CDN 更新 ──────────────────────────────
|
|
2465
2471
|
var _pollTimer = null; // 提到外层,doSync 守卫用
|
|
2466
2472
|
|
package/package.json
CHANGED
package/server/sync_workspace.py
CHANGED
|
@@ -505,28 +505,40 @@ TEMPLATE_CDN_INDEX_URL = 'https://cdn.yiranlaoshi.com/myclaw/live/yiran/skills/y
|
|
|
505
505
|
|
|
506
506
|
|
|
507
507
|
def _do_sync_templates(cdn_index, local_index, pending):
|
|
508
|
-
"""后台线程:按 folder 全量下载有变化的模板文件
|
|
509
|
-
pending: list of (folder_name, is_new)
|
|
508
|
+
"""后台线程:按 folder+version 全量下载有变化的模板文件
|
|
509
|
+
pending: list of (folder_name, version_str, is_new)
|
|
510
|
+
|
|
511
|
+
流程:
|
|
512
|
+
1. 删除旧版本目录(保证本地只有一个版本)
|
|
513
|
+
2. 下载到临时目录 .tmp/
|
|
514
|
+
3. 下载完成后 rename .tmp/ → v{N}/(版本目录存在即代表就绪)
|
|
510
515
|
"""
|
|
516
|
+
import shutil
|
|
511
517
|
try:
|
|
512
518
|
added = []
|
|
513
519
|
updated = []
|
|
514
520
|
|
|
515
521
|
_sync_log('开始同步,共 {} 个 folder'.format(len(pending)))
|
|
516
522
|
|
|
517
|
-
for folder_name, is_new in pending:
|
|
518
|
-
_sync_log('{} {} → 下载中...'.format('新增' if is_new else '更新', folder_name))
|
|
519
|
-
cdn_prefix = TEMPLATE_CDN_PREFIX_BASE + '/{}/'.format(folder_name)
|
|
520
|
-
folder_dir = os.path.join(TEMPLATE_LOCAL_DIR, folder_name)
|
|
521
|
-
os.makedirs(folder_dir, exist_ok=True)
|
|
522
|
-
|
|
523
|
-
# 下载开始前先删掉标记,防止更新窗口期内被误判为就绪
|
|
524
|
-
done_marker = os.path.join(folder_dir, '.sync-done')
|
|
525
|
-
try:
|
|
526
|
-
os.remove(done_marker)
|
|
527
|
-
except OSError:
|
|
528
|
-
pass
|
|
523
|
+
for folder_name, version_str, is_new in pending:
|
|
524
|
+
_sync_log('{} {}/{} → 下载中...'.format('新增' if is_new else '更新', folder_name, version_str))
|
|
529
525
|
|
|
526
|
+
cdn_prefix = '{}/{}/{}/'.format(TEMPLATE_CDN_PREFIX_BASE, folder_name, version_str)
|
|
527
|
+
folder_dir = os.path.join(TEMPLATE_LOCAL_DIR, folder_name)
|
|
528
|
+
version_dir = os.path.join(folder_dir, version_str)
|
|
529
|
+
tmp_dir = os.path.join(folder_dir, '.tmp')
|
|
530
|
+
|
|
531
|
+
# 1. 删除旧版本目录和残留临时目录
|
|
532
|
+
if os.path.isdir(folder_dir):
|
|
533
|
+
for entry in os.listdir(folder_dir):
|
|
534
|
+
if re.match(r'^v\d+$', entry) and entry != version_str:
|
|
535
|
+
shutil.rmtree(os.path.join(folder_dir, entry), ignore_errors=True)
|
|
536
|
+
_sync_log(' 删除旧版本: {}'.format(entry))
|
|
537
|
+
if os.path.isdir(tmp_dir):
|
|
538
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
539
|
+
|
|
540
|
+
# 2. 下载到 .tmp/
|
|
541
|
+
os.makedirs(tmp_dir, exist_ok=True)
|
|
530
542
|
cdn_files = _list_files_from_qiniu(cdn_prefix)
|
|
531
543
|
_sync_log(' 列举到 {} 个文件'.format(len(cdn_files)))
|
|
532
544
|
for item in cdn_files:
|
|
@@ -534,22 +546,18 @@ def _do_sync_templates(cdn_index, local_index, pending):
|
|
|
534
546
|
filename = key[len(cdn_prefix):]
|
|
535
547
|
if not filename:
|
|
536
548
|
continue
|
|
537
|
-
ok = _download_from_cdn(key, os.path.join(
|
|
549
|
+
ok = _download_from_cdn(key, os.path.join(tmp_dir, filename))
|
|
538
550
|
_sync_log(' {} {}'.format('✓' if ok else '✗', filename))
|
|
539
551
|
|
|
540
|
-
#
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
f.write(cdn_ua or '')
|
|
544
|
-
except Exception:
|
|
545
|
-
pass
|
|
552
|
+
# 3. rename .tmp/ → v{N}/ (目录存在即代表该版本就绪)
|
|
553
|
+
os.rename(tmp_dir, version_dir)
|
|
554
|
+
_sync_log(' → 已就绪: {}/{}'.format(folder_name, version_str))
|
|
546
555
|
|
|
547
556
|
if is_new:
|
|
548
557
|
added.append(folder_name)
|
|
549
558
|
else:
|
|
550
559
|
updated.append(folder_name)
|
|
551
560
|
|
|
552
|
-
# 每完成一个 folder 就更新进度
|
|
553
561
|
with _sync_lock:
|
|
554
562
|
_sync_state['completed'].append(folder_name)
|
|
555
563
|
|
|
@@ -1057,18 +1065,29 @@ class MyclawAPIHandler(BaseHTTPRequestHandler):
|
|
|
1057
1065
|
)
|
|
1058
1066
|
|
|
1059
1067
|
def _resolve_template_path(self, folder):
|
|
1060
|
-
"""校验 folder
|
|
1068
|
+
"""校验 folder 并返回模板最新版本绝对路径;非法或未就绪时直接响应并返回 None"""
|
|
1061
1069
|
if not folder or '..' in folder or '/' in folder or '\\' in folder:
|
|
1062
1070
|
self._send_json({'error': 'invalid folder'}, 400)
|
|
1063
1071
|
return None
|
|
1064
|
-
|
|
1072
|
+
folder_dir = os.path.join(
|
|
1065
1073
|
get_openclaw_path(), 'skills',
|
|
1066
1074
|
'yiran-playground-template-use', 'templates', folder
|
|
1067
1075
|
)
|
|
1068
|
-
|
|
1076
|
+
# 找最新 v{N} 子目录(目录存在即代表就绪)
|
|
1077
|
+
max_v = 0
|
|
1078
|
+
version_path = None
|
|
1079
|
+
if os.path.isdir(folder_dir):
|
|
1080
|
+
for entry in os.listdir(folder_dir):
|
|
1081
|
+
m = re.match(r'^v(\d+)$', entry)
|
|
1082
|
+
if m:
|
|
1083
|
+
v = int(m.group(1))
|
|
1084
|
+
if v > max_v:
|
|
1085
|
+
max_v = v
|
|
1086
|
+
version_path = os.path.join(folder_dir, entry)
|
|
1087
|
+
if not version_path:
|
|
1069
1088
|
self._send_json({'error': '模板尚未下载完成,请稍后重试', 'code': 'not_ready'}, 409)
|
|
1070
1089
|
return None
|
|
1071
|
-
return
|
|
1090
|
+
return version_path
|
|
1072
1091
|
|
|
1073
1092
|
def _run_skill_script(self, cmd, timeout=60):
|
|
1074
1093
|
"""运行 skill 脚本,解析 stdout JSON 并返回 (data, err)"""
|
|
@@ -1229,21 +1248,21 @@ class MyclawAPIHandler(BaseHTTPRequestHandler):
|
|
|
1229
1248
|
self._send_json({'changed': False})
|
|
1230
1249
|
return
|
|
1231
1250
|
|
|
1232
|
-
# 计算需要下载的 folder
|
|
1251
|
+
# 计算需要下载的 folder 列表(新格式:index.templates 为 {id: record} 平铺字典)
|
|
1252
|
+
# 就绪标准:{folder}/{version}/ 目录存在即代表完整就绪
|
|
1233
1253
|
cdn_templates = cdn_index.get('templates', {})
|
|
1234
1254
|
local_templates = local_index.get('templates', {})
|
|
1235
|
-
pending = [] # list of (folder_name, is_new)
|
|
1236
|
-
for
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
pending.append((folder_name, is_new))
|
|
1255
|
+
pending = [] # list of (folder_name, version_str, is_new)
|
|
1256
|
+
for tid, record in cdn_templates.items():
|
|
1257
|
+
folder_name = record.get('文件夹名', '')
|
|
1258
|
+
version_str = record.get('version', 'v1')
|
|
1259
|
+
if not folder_name:
|
|
1260
|
+
continue
|
|
1261
|
+
version_dir = os.path.join(TEMPLATE_LOCAL_DIR, folder_name, version_str)
|
|
1262
|
+
if os.path.isdir(version_dir):
|
|
1263
|
+
continue # 本地已有该版本,无需下载
|
|
1264
|
+
is_new = tid not in local_templates
|
|
1265
|
+
pending.append((folder_name, version_str, is_new))
|
|
1247
1266
|
|
|
1248
1267
|
if not pending:
|
|
1249
1268
|
# index_updated_at 变了但所有 folder 都没变,直接更新 index 即可
|
|
@@ -1306,10 +1325,22 @@ class MyclawAPIHandler(BaseHTTPRequestHandler):
|
|
|
1306
1325
|
total = _sync_state['total']
|
|
1307
1326
|
last_result = _sync_state['last_result']
|
|
1308
1327
|
|
|
1328
|
+
# 扫描磁盘,返回实际已就绪的 folder 列表(v{N}/ 目录存在即就绪)
|
|
1329
|
+
ready = []
|
|
1330
|
+
if os.path.isdir(TEMPLATE_LOCAL_DIR):
|
|
1331
|
+
for folder in os.listdir(TEMPLATE_LOCAL_DIR):
|
|
1332
|
+
folder_path = os.path.join(TEMPLATE_LOCAL_DIR, folder)
|
|
1333
|
+
if not os.path.isdir(folder_path):
|
|
1334
|
+
continue
|
|
1335
|
+
for version_entry in os.listdir(folder_path):
|
|
1336
|
+
if re.match(r'^v\d+$', version_entry) and os.path.isdir(os.path.join(folder_path, version_entry)):
|
|
1337
|
+
ready.append(folder)
|
|
1338
|
+
break
|
|
1339
|
+
|
|
1309
1340
|
if running:
|
|
1310
|
-
self._send_json({'running': True, 'completed': completed, 'total': total})
|
|
1341
|
+
self._send_json({'running': True, 'completed': completed, 'total': total, 'ready': ready})
|
|
1311
1342
|
else:
|
|
1312
|
-
self._send_json({'running': False, 'result': last_result})
|
|
1343
|
+
self._send_json({'running': False, 'result': last_result, 'ready': ready})
|
|
1313
1344
|
|
|
1314
1345
|
def log_message(self, format, *args):
|
|
1315
1346
|
# 静默日志,避免轮询刷屏
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
build_template_index.py
|
|
4
|
+
|
|
5
|
+
扫描 templates/ 目录,为每个模板读取最新版本子目录(v{N}/)中的
|
|
6
|
+
__teacher__.json 和 __student__.json,生成 template-index.json 和 template-index.md。
|
|
7
|
+
|
|
8
|
+
index 以 _id 为主键(flat 结构),每条记录包含版本号。
|
|
9
|
+
"""
|
|
2
10
|
import json
|
|
3
11
|
import re
|
|
4
12
|
from datetime import datetime, timezone
|
|
@@ -11,7 +19,7 @@ OUTPUT_JSON = ROOT / 'template-index.json'
|
|
|
11
19
|
OUTPUT_MD = ROOT / 'template-index.md'
|
|
12
20
|
|
|
13
21
|
|
|
14
|
-
def read_json(path: Path):
|
|
22
|
+
def read_json(path: Path) -> dict:
|
|
15
23
|
try:
|
|
16
24
|
return json.loads(path.read_text(encoding='utf-8'))
|
|
17
25
|
except Exception:
|
|
@@ -25,15 +33,30 @@ def parse_folder_name(name: str):
|
|
|
25
33
|
return None, None, None
|
|
26
34
|
|
|
27
35
|
|
|
28
|
-
def
|
|
36
|
+
def get_latest_version_dir(folder: Path):
|
|
37
|
+
"""返回最新版本子目录 Path 和版本号 int,没有则返回 (None, 0)"""
|
|
38
|
+
max_v = 0
|
|
39
|
+
result = None
|
|
40
|
+
for d in folder.iterdir():
|
|
41
|
+
if d.is_dir():
|
|
42
|
+
m = re.match(r'^v(\d+)$', d.name)
|
|
43
|
+
if m:
|
|
44
|
+
v = int(m.group(1))
|
|
45
|
+
if v > max_v:
|
|
46
|
+
max_v = v
|
|
47
|
+
result = d
|
|
48
|
+
return result, max_v
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_entry_file(version_dir: Path) -> Optional[Path]:
|
|
29
52
|
for name in ['index.html', '__demo__.html', '__student-view__.html']:
|
|
30
|
-
if (
|
|
31
|
-
return
|
|
53
|
+
if (version_dir / name).exists():
|
|
54
|
+
return version_dir / name
|
|
32
55
|
return None
|
|
33
56
|
|
|
34
57
|
|
|
35
58
|
def main():
|
|
36
|
-
|
|
59
|
+
templates = {} # { _id: record }
|
|
37
60
|
items_for_md = []
|
|
38
61
|
|
|
39
62
|
for folder in sorted(TEMPLATES_DIR.iterdir() if TEMPLATES_DIR.exists() else []):
|
|
@@ -44,8 +67,16 @@ def main():
|
|
|
44
67
|
if not series:
|
|
45
68
|
continue
|
|
46
69
|
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
version_dir, version_num = get_latest_version_dir(folder)
|
|
71
|
+
if not version_dir:
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
teacher = read_json(version_dir / '__teacher__.json')
|
|
75
|
+
student = read_json(version_dir / '__student__.json')
|
|
76
|
+
|
|
77
|
+
tid = teacher.get('_id', '').strip()
|
|
78
|
+
if not tid:
|
|
79
|
+
continue
|
|
49
80
|
|
|
50
81
|
name = teacher.get('任务名称') or student.get('任务标题', '')
|
|
51
82
|
一句话 = student.get('一句话说明', '')
|
|
@@ -62,20 +93,25 @@ def main():
|
|
|
62
93
|
类型标签,
|
|
63
94
|
])))
|
|
64
95
|
|
|
65
|
-
entry = get_entry_file(
|
|
96
|
+
entry = get_entry_file(version_dir)
|
|
66
97
|
|
|
67
|
-
|
|
68
|
-
folder_mtime = max(
|
|
98
|
+
all_files = list(version_dir.rglob('*'))
|
|
99
|
+
folder_mtime = max(
|
|
100
|
+
(f.stat().st_mtime for f in all_files if f.is_file()),
|
|
101
|
+
default=0
|
|
102
|
+
)
|
|
69
103
|
updated_at = datetime.fromtimestamp(folder_mtime, tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
70
104
|
|
|
105
|
+
version_str = f'v{version_num}'
|
|
106
|
+
|
|
71
107
|
record = {
|
|
108
|
+
'id': tid,
|
|
72
109
|
'系列': series,
|
|
73
110
|
'编号': number,
|
|
74
111
|
'名称': name,
|
|
75
112
|
'文件夹名': folder.name,
|
|
76
|
-
'
|
|
77
|
-
'
|
|
78
|
-
'teacher_json': str((folder / '__teacher__.json').relative_to(ROOT)) if (folder / '__teacher__.json').exists() else '',
|
|
113
|
+
'version': version_str,
|
|
114
|
+
'路径': str(version_dir.relative_to(ROOT)),
|
|
79
115
|
'入口文件': str(entry.relative_to(ROOT)) if entry else '',
|
|
80
116
|
'一句话说明': 一句话,
|
|
81
117
|
'主能力标签': 能力标签,
|
|
@@ -84,28 +120,29 @@ def main():
|
|
|
84
120
|
'updated_at': updated_at,
|
|
85
121
|
}
|
|
86
122
|
|
|
87
|
-
|
|
88
|
-
index[series] = {}
|
|
89
|
-
index[series][number] = record
|
|
123
|
+
templates[tid] = record
|
|
90
124
|
items_for_md.append(record)
|
|
91
125
|
|
|
92
126
|
index_updated_at = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
93
|
-
output = {'index_updated_at': index_updated_at, 'templates':
|
|
127
|
+
output = {'index_updated_at': index_updated_at, 'templates': templates}
|
|
94
128
|
OUTPUT_JSON.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
95
129
|
|
|
130
|
+
items_for_md.sort(key=lambda x: (x['系列'], int(x['编号'])))
|
|
96
131
|
lines = ['# 模板索引', '', '每次 templates 内模板有变化后,都应重新运行本脚本。', '']
|
|
97
132
|
for item in items_for_md:
|
|
98
133
|
lines.append(f"## {item['系列']}{item['编号']} {item['名称']}")
|
|
134
|
+
lines.append(f"- ID:{item['id']}")
|
|
135
|
+
lines.append(f"- 版本:{item['version']}")
|
|
99
136
|
lines.append(f"- 路径:{item['路径']}")
|
|
100
137
|
lines.append(f"- 入口文件:{item['入口文件']}")
|
|
101
138
|
lines.append(f"- 主能力标签:{item['主能力标签']}")
|
|
102
139
|
lines.append(f"- 任务类型标签:{item['任务类型标签']}")
|
|
103
140
|
if item['关键词']:
|
|
104
|
-
lines.append(f"- 关键词:{', '.join(item['关键词'])}")
|
|
141
|
+
lines.append(f"- 关键词:{', '.join(str(k) for k in item['关键词'])}")
|
|
105
142
|
lines.append('')
|
|
106
143
|
|
|
107
144
|
OUTPUT_MD.write_text('\n'.join(lines), encoding='utf-8')
|
|
108
|
-
print(f'已生成: {OUTPUT_JSON}')
|
|
145
|
+
print(f'已生成: {OUTPUT_JSON}(共 {len(templates)} 条)')
|
|
109
146
|
print(f'已生成: {OUTPUT_MD}')
|
|
110
147
|
|
|
111
148
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
identify_template.py
|
|
4
|
+
|
|
5
|
+
给模板源目录颁发或校准身份信息,直接修改源目录的 __teacher__.json。
|
|
6
|
+
不做任何文件移动,可单独运行。
|
|
7
|
+
|
|
8
|
+
写入字段:
|
|
9
|
+
_id : 8位十六进制,首次颁发,后续不变
|
|
10
|
+
_version : 首次=1,更新=已有版本+1
|
|
11
|
+
任务类别 : 校准后的系列(A/B/C)
|
|
12
|
+
任务编号 : 校准后的3位编号(100起)
|
|
13
|
+
|
|
14
|
+
用法:
|
|
15
|
+
python3 identify_template.py <模板目录>
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import secrets
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
24
|
+
TEMPLATES_DIR = ROOT / 'templates'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_json(path: Path) -> dict:
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
30
|
+
except Exception:
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save_json(path: Path, data: dict):
|
|
35
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def gen_id() -> str:
|
|
39
|
+
return secrets.token_hex(4) # 8位十六进制
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_category(raw) -> str:
|
|
43
|
+
cat = str(raw).upper().strip()
|
|
44
|
+
if cat not in {'A', 'B', 'C'}:
|
|
45
|
+
raise ValueError(f'任务类别只能是 A、B、C,当前: {cat}')
|
|
46
|
+
return cat
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_latest_version_num(folder: Path) -> int:
|
|
50
|
+
"""返回 folder 下最大的版本号,没有则返回 0"""
|
|
51
|
+
max_v = 0
|
|
52
|
+
for d in folder.iterdir():
|
|
53
|
+
if d.is_dir():
|
|
54
|
+
m = re.match(r'^v(\d+)$', d.name)
|
|
55
|
+
if m:
|
|
56
|
+
max_v = max(max_v, int(m.group(1)))
|
|
57
|
+
return max_v
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def scan_templates(templates_dir: Path):
|
|
61
|
+
"""
|
|
62
|
+
扫描 templates/ 下所有已入库模板。
|
|
63
|
+
返回:
|
|
64
|
+
id_map : { _id -> {'folder': Path, 'version': int} }
|
|
65
|
+
number_map : { category -> { number(int) -> _id } }
|
|
66
|
+
"""
|
|
67
|
+
id_map = {}
|
|
68
|
+
number_map = {}
|
|
69
|
+
pattern = re.compile(r'^([a-zA-Z])(\d{3})_')
|
|
70
|
+
|
|
71
|
+
if not templates_dir.exists():
|
|
72
|
+
return id_map, number_map
|
|
73
|
+
|
|
74
|
+
for folder in templates_dir.iterdir():
|
|
75
|
+
if not folder.is_dir():
|
|
76
|
+
continue
|
|
77
|
+
m = pattern.match(folder.name)
|
|
78
|
+
if not m:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
version_num = get_latest_version_num(folder)
|
|
82
|
+
if version_num == 0:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
version_dir = folder / f'v{version_num}'
|
|
86
|
+
teacher = load_json(version_dir / '__teacher__.json')
|
|
87
|
+
tid = teacher.get('_id')
|
|
88
|
+
if not tid:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
cat = m.group(1).upper()
|
|
92
|
+
num = int(m.group(2))
|
|
93
|
+
|
|
94
|
+
id_map[tid] = {'folder': folder, 'version': version_num}
|
|
95
|
+
number_map.setdefault(cat, {})[num] = tid
|
|
96
|
+
|
|
97
|
+
return id_map, number_map
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def calibrate_number(category: str, desired: int, number_map: dict, own_id: str) -> int:
|
|
101
|
+
"""
|
|
102
|
+
校准编号:
|
|
103
|
+
- 宣称的编号不存在,或已属于自己 → 合法,直接用
|
|
104
|
+
- 已被别人占用 → 找最小可用编号(>=100)
|
|
105
|
+
"""
|
|
106
|
+
used = number_map.get(category, {})
|
|
107
|
+
owner = used.get(desired)
|
|
108
|
+
if owner is None or owner == own_id:
|
|
109
|
+
return desired
|
|
110
|
+
# 冲突,找最小可用
|
|
111
|
+
for num in range(100, 1000):
|
|
112
|
+
if num not in used or used[num] == own_id:
|
|
113
|
+
return num
|
|
114
|
+
raise RuntimeError(f'{category} 类编号已用尽')
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main():
|
|
118
|
+
if len(sys.argv) < 2:
|
|
119
|
+
print('用法: python3 identify_template.py <模板目录>')
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
source_dir = Path(sys.argv[1]).expanduser().resolve()
|
|
123
|
+
teacher_path = source_dir / '__teacher__.json'
|
|
124
|
+
|
|
125
|
+
if not teacher_path.exists():
|
|
126
|
+
print(f'❌ 找不到 __teacher__.json: {teacher_path}')
|
|
127
|
+
sys.exit(1)
|
|
128
|
+
|
|
129
|
+
teacher = load_json(teacher_path)
|
|
130
|
+
id_map, number_map = scan_templates(TEMPLATES_DIR)
|
|
131
|
+
|
|
132
|
+
# ── 1. _id 与版本号 ────────────────────────────────────────────────────
|
|
133
|
+
tid = teacher.get('_id', '').strip()
|
|
134
|
+
in_templates = tid and tid in id_map
|
|
135
|
+
|
|
136
|
+
if not tid:
|
|
137
|
+
tid = gen_id()
|
|
138
|
+
new_version = 1
|
|
139
|
+
print(f'🆔 颁发新 ID: {tid} 版本: v1')
|
|
140
|
+
elif not in_templates:
|
|
141
|
+
new_version = 1
|
|
142
|
+
print(f'🆔 已有 ID {tid},首次入库 版本: v1')
|
|
143
|
+
else:
|
|
144
|
+
old_version = id_map[tid]['version']
|
|
145
|
+
new_version = old_version + 1
|
|
146
|
+
print(f'🔄 更新 ID {tid} 版本: v{old_version} → v{new_version}')
|
|
147
|
+
|
|
148
|
+
# ── 2. 校准 任务类别 + 任务编号 ────────────────────────────────────────
|
|
149
|
+
try:
|
|
150
|
+
category = normalize_category(teacher.get('任务类别', ''))
|
|
151
|
+
except ValueError as e:
|
|
152
|
+
print(f'❌ {e}')
|
|
153
|
+
sys.exit(1)
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
desired = int(str(teacher.get('任务编号', '100')))
|
|
157
|
+
except ValueError:
|
|
158
|
+
desired = 100
|
|
159
|
+
|
|
160
|
+
calibrated = calibrate_number(category, desired, number_map, tid)
|
|
161
|
+
if calibrated != desired:
|
|
162
|
+
print(f'⚠️ 编号冲突:{category}{desired:03d} 已被占用,校准为 {category}{calibrated:03d}')
|
|
163
|
+
|
|
164
|
+
# ── 3. 写回 __teacher__.json ───────────────────────────────────────────
|
|
165
|
+
teacher['_id'] = tid
|
|
166
|
+
teacher['_version'] = new_version
|
|
167
|
+
teacher['任务类别'] = category
|
|
168
|
+
teacher['任务编号'] = f'{calibrated:03d}'
|
|
169
|
+
save_json(teacher_path, teacher)
|
|
170
|
+
|
|
171
|
+
print(f'✅ 已写回: {teacher_path}')
|
|
172
|
+
print(f' _id={tid}, _version=v{new_version}, 分类={category}{calibrated:03d}')
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == '__main__':
|
|
176
|
+
main()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
migrate_templates.py
|
|
4
|
+
|
|
5
|
+
将旧版平铺结构迁移到版本化结构:
|
|
6
|
+
templates/{folder}/*.html → templates/{folder}/v1/*.html
|
|
7
|
+
|
|
8
|
+
同时为没有 _id 的模板颁发 ID,写入 __teacher__.json。
|
|
9
|
+
|
|
10
|
+
用法:
|
|
11
|
+
python3 scripts/migrate_templates.py [--dry-run]
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import shutil
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
21
|
+
TEMPLATES_DIR = ROOT / 'templates'
|
|
22
|
+
INDEX_SCRIPT = ROOT / 'scripts' / 'build_template_index.py'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_json(path: Path) -> dict:
|
|
26
|
+
try:
|
|
27
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
28
|
+
except Exception:
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_json(path: Path, data: dict):
|
|
33
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def gen_id() -> str:
|
|
37
|
+
return secrets.token_hex(4)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def has_version_subdir(folder: Path) -> bool:
|
|
41
|
+
for d in folder.iterdir():
|
|
42
|
+
if d.is_dir() and re.match(r'^v\d+$', d.name):
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
dry_run = '--dry-run' in sys.argv
|
|
49
|
+
if dry_run:
|
|
50
|
+
print('🔍 DRY RUN 模式,不实际修改文件\n')
|
|
51
|
+
|
|
52
|
+
if not TEMPLATES_DIR.exists():
|
|
53
|
+
print('❌ templates/ 目录不存在')
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
folders = sorted(d for d in TEMPLATES_DIR.iterdir() if d.is_dir())
|
|
57
|
+
migrated = 0
|
|
58
|
+
|
|
59
|
+
for folder in folders:
|
|
60
|
+
if has_version_subdir(folder):
|
|
61
|
+
print(f'✓ 跳过(已版本化): {folder.name}')
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
# 列出所有直接子文件(排除目录)
|
|
65
|
+
files = [f for f in folder.iterdir() if f.is_file()]
|
|
66
|
+
if not files:
|
|
67
|
+
print(f'⚠️ 空目录,跳过: {folder.name}')
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
teacher_path = folder / '__teacher__.json'
|
|
71
|
+
teacher = load_json(teacher_path)
|
|
72
|
+
|
|
73
|
+
# 补充 _id
|
|
74
|
+
if not teacher.get('_id'):
|
|
75
|
+
teacher['_id'] = gen_id()
|
|
76
|
+
print(f'🆔 颁发 ID {teacher["_id"]}: {folder.name}')
|
|
77
|
+
|
|
78
|
+
# 补充 _version
|
|
79
|
+
if not teacher.get('_version'):
|
|
80
|
+
teacher['_version'] = 1
|
|
81
|
+
|
|
82
|
+
if not dry_run:
|
|
83
|
+
# 先写回 __teacher__.json
|
|
84
|
+
save_json(teacher_path, teacher)
|
|
85
|
+
|
|
86
|
+
# 创建 v1/ 并移动所有文件
|
|
87
|
+
v1_dir = folder / 'v1'
|
|
88
|
+
v1_dir.mkdir(exist_ok=True)
|
|
89
|
+
for f in files:
|
|
90
|
+
shutil.move(str(f), str(v1_dir / f.name))
|
|
91
|
+
|
|
92
|
+
print(f'📦 迁移: {folder.name} → {folder.name}/v1/')
|
|
93
|
+
migrated += 1
|
|
94
|
+
|
|
95
|
+
print(f'\n{"[DRY RUN] " if dry_run else ""}共迁移 {migrated} 个模板')
|
|
96
|
+
|
|
97
|
+
if not dry_run and migrated > 0:
|
|
98
|
+
import subprocess
|
|
99
|
+
subprocess.run(['python3', str(INDEX_SCRIPT)], check=True)
|
|
100
|
+
print('✅ 索引已重建')
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == '__main__':
|
|
104
|
+
main()
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
move_template_task.py
|
|
4
|
+
|
|
5
|
+
将模板源目录纳入 pipeline(复制,不删除原目录)。
|
|
6
|
+
|
|
7
|
+
流程:
|
|
8
|
+
1. 校验必需文件
|
|
9
|
+
2. 调用 identify_template.py(颁发/校准身份,写回源目录)
|
|
10
|
+
3. 读取更新后的 __teacher__.json,拼出目标路径
|
|
11
|
+
4. 复制到 templates/{folder_name}/v{N}/
|
|
12
|
+
5. 重建 template-index.json
|
|
13
|
+
|
|
14
|
+
用法:
|
|
15
|
+
python3 move_template_task.py <模板目录> [目标根目录]
|
|
16
|
+
"""
|
|
2
17
|
import json
|
|
3
18
|
import re
|
|
4
19
|
import shutil
|
|
@@ -8,86 +23,71 @@ from pathlib import Path
|
|
|
8
23
|
|
|
9
24
|
ROOT = Path(__file__).resolve().parent.parent
|
|
10
25
|
DEFAULT_TARGET = ROOT / 'templates'
|
|
26
|
+
IDENTIFY_SCRIPT = ROOT / 'scripts' / 'identify_template.py'
|
|
11
27
|
INDEX_SCRIPT = ROOT / 'scripts' / 'build_template_index.py'
|
|
12
|
-
REQUIRED_FILES = [
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
with path.open('r', encoding='utf-8') as f:
|
|
17
|
-
return json.load(f)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def save_json(path, data):
|
|
21
|
-
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def normalize_category(teacher):
|
|
25
|
-
category = str(teacher['任务类别']).upper()
|
|
26
|
-
if category not in {'A', 'B', 'C'}:
|
|
27
|
-
raise ValueError('任务类别只能是 A、B、C')
|
|
28
|
-
return category
|
|
29
|
-
|
|
28
|
+
REQUIRED_FILES = [
|
|
29
|
+
'__demo__.html', '__student__.json', '__teacher__.json',
|
|
30
|
+
'__student-view__.html', '__teacher-view__.html',
|
|
31
|
+
]
|
|
30
32
|
|
|
31
|
-
def sanitize_name(name):
|
|
32
|
-
return str(name).replace('/', '_').replace(' ', '_')
|
|
33
33
|
|
|
34
|
+
def load_json(path: Path) -> dict:
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(path.read_text(encoding='utf-8'))
|
|
37
|
+
except Exception:
|
|
38
|
+
return {}
|
|
34
39
|
|
|
35
|
-
def find_next_available_number(category, target_root):
|
|
36
|
-
used = set()
|
|
37
|
-
pattern = re.compile(r'^([a-zA-Z])(\d{3})_')
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
continue
|
|
42
|
-
m = pattern.match(folder.name)
|
|
43
|
-
if not m:
|
|
44
|
-
continue
|
|
45
|
-
if m.group(1).upper() != category:
|
|
46
|
-
continue
|
|
47
|
-
used.add(int(m.group(2)))
|
|
48
|
-
|
|
49
|
-
for num in range(100, 1000):
|
|
50
|
-
if num not in used:
|
|
51
|
-
return f'{num:03d}'
|
|
52
|
-
raise RuntimeError(f'{category} 类编号已用尽')
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def task_folder_name(teacher, target_root):
|
|
56
|
-
category = normalize_category(teacher)
|
|
57
|
-
number = find_next_available_number(category, target_root)
|
|
58
|
-
teacher['任务编号'] = number
|
|
59
|
-
name = sanitize_name(teacher['任务名称'])
|
|
60
|
-
return f'{category.lower()}{number}_{name}'
|
|
41
|
+
def sanitize_name(name: str) -> str:
|
|
42
|
+
return re.sub(r'[/\\ ]', '_', str(name).strip())
|
|
61
43
|
|
|
62
44
|
|
|
63
45
|
def main():
|
|
64
46
|
if len(sys.argv) < 2:
|
|
65
|
-
print('用法: python3 move_template_task.py
|
|
47
|
+
print('用法: python3 move_template_task.py <模板目录> [目标根目录]')
|
|
66
48
|
sys.exit(1)
|
|
67
49
|
|
|
68
50
|
source_dir = Path(sys.argv[1]).expanduser().resolve()
|
|
69
51
|
target_root = Path(sys.argv[2]).expanduser().resolve() if len(sys.argv) > 2 else DEFAULT_TARGET
|
|
70
52
|
|
|
53
|
+
if not source_dir.is_dir():
|
|
54
|
+
print(f'❌ 目录不存在: {source_dir}')
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
# 1. 校验必需文件
|
|
71
58
|
for filename in REQUIRED_FILES:
|
|
72
59
|
if not (source_dir / filename).exists():
|
|
73
|
-
|
|
60
|
+
print(f'❌ 缺少必需文件: {filename}')
|
|
61
|
+
sys.exit(1)
|
|
74
62
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
63
|
+
# 2. 颁发/校准身份(写回源目录的 __teacher__.json)
|
|
64
|
+
print('\n── 身份认证 ────────────────────────────────')
|
|
65
|
+
subprocess.run([sys.executable, str(IDENTIFY_SCRIPT), str(source_dir)], check=True)
|
|
66
|
+
|
|
67
|
+
# 3. 读取更新后的身份信息
|
|
68
|
+
teacher = load_json(source_dir / '__teacher__.json')
|
|
69
|
+
category = teacher.get('任务类别', '').upper()
|
|
70
|
+
number = teacher.get('任务编号', '100')
|
|
71
|
+
name = sanitize_name(teacher.get('任务名称', 'untitled'))
|
|
72
|
+
version = teacher.get('_version', 1)
|
|
73
|
+
|
|
74
|
+
folder_name = f'{category.lower()}{number}_{name}'
|
|
75
|
+
version_str = f'v{version}'
|
|
76
|
+
target_dir = target_root / folder_name / version_str
|
|
80
77
|
|
|
81
|
-
target_dir = target_root / folder_name
|
|
82
78
|
if target_dir.exists():
|
|
83
|
-
|
|
79
|
+
print(f'❌ 目标目录已存在(重复操作?): {target_dir}')
|
|
80
|
+
sys.exit(1)
|
|
84
81
|
|
|
85
|
-
|
|
86
|
-
print(f'
|
|
87
|
-
|
|
82
|
+
# 4. 复制到 templates/{folder}/v{N}/
|
|
83
|
+
print(f'\n── 复制文件 ────────────────────────────────')
|
|
84
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
shutil.copytree(str(source_dir), str(target_dir))
|
|
86
|
+
print(f'✅ 已复制到: {target_dir}')
|
|
88
87
|
|
|
89
|
-
|
|
90
|
-
print('
|
|
88
|
+
# 5. 重建 index
|
|
89
|
+
print(f'\n── 重建索引 ────────────────────────────────')
|
|
90
|
+
subprocess.run([sys.executable, str(INDEX_SCRIPT)], check=True)
|
|
91
91
|
|
|
92
92
|
|
|
93
93
|
if __name__ == '__main__':
|
|
@@ -4,85 +4,99 @@ publish_template.py
|
|
|
4
4
|
|
|
5
5
|
发布模板文件夹到七牛 CDN,完成后重建并上传 template-index.json。
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
2. 重建 template-index.json 并上传到 CDN
|
|
7
|
+
CDN 路径:{CDN_PREFIX}/{folder_name}/{version}/{filename}
|
|
8
|
+
版本号从 templates/{folder}/v{N}/ 子目录读取(以本地最新版本为准)。
|
|
10
9
|
|
|
11
10
|
用法:
|
|
12
11
|
python3 scripts/publish_template.py <模板文件夹路径>
|
|
13
12
|
python3 scripts/publish_template.py --all
|
|
14
|
-
|
|
15
|
-
例:
|
|
16
|
-
python3 scripts/publish_template.py templates/a103_做一个介绍页面
|
|
17
|
-
python3 scripts/publish_template.py --all
|
|
18
13
|
"""
|
|
19
|
-
|
|
20
14
|
import argparse
|
|
15
|
+
import re
|
|
21
16
|
import subprocess
|
|
22
17
|
import sys
|
|
23
18
|
from pathlib import Path
|
|
24
19
|
|
|
25
20
|
from qiniu import Auth, put_file_v2, CdnManager
|
|
26
21
|
|
|
27
|
-
# ── 七牛配置(与 server 保持一致)──────────────────────────────────────────
|
|
28
22
|
QINIU_KEY = "T3tgxM7EMx1j4VESw4m4PIfFXoOvBo-wQEOQewXX"
|
|
29
23
|
QINIU_SECRET = "PVZvlKVOjX2RqlV2ILMg-QwpNMssOlpVbaEzypz0"
|
|
30
24
|
QINIU_BUCKET = "yiran1"
|
|
31
25
|
CDN_DOMAIN = "https://cdn.yiranlaoshi.com"
|
|
32
|
-
|
|
26
|
+
CDN_INDEX_KEY = "myclaw/live/yiran/skills/yiran-playground-template-use/template-index.json"
|
|
27
|
+
CDN_PREFIX = "myclaw/live/yiran/skills/yiran-playground-template-use/templates"
|
|
33
28
|
|
|
34
29
|
q = Auth(QINIU_KEY, QINIU_SECRET)
|
|
35
30
|
cdn_manager = CdnManager(q)
|
|
36
31
|
|
|
37
32
|
|
|
38
|
-
def
|
|
39
|
-
"""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
def get_latest_version_dir(folder: Path):
|
|
34
|
+
"""返回最新 v{N} 子目录和版本号,没有则返回 (None, 0)"""
|
|
35
|
+
max_v = 0
|
|
36
|
+
result = None
|
|
37
|
+
for d in folder.iterdir():
|
|
38
|
+
if d.is_dir():
|
|
39
|
+
m = re.match(r'^v(\d+)$', d.name)
|
|
40
|
+
if m:
|
|
41
|
+
v = int(m.group(1))
|
|
42
|
+
if v > max_v:
|
|
43
|
+
max_v = v
|
|
44
|
+
result = d
|
|
45
|
+
return result, max_v
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def upload_file(local_path: Path, cdn_key: str) -> str:
|
|
49
|
+
token = q.upload_token(QINIU_BUCKET, cdn_key, 3600, policy={'insertOnly': 0})
|
|
50
|
+
ret, info = put_file_v2(token, cdn_key, str(local_path))
|
|
43
51
|
if info.status_code != 200:
|
|
44
|
-
raise RuntimeError(
|
|
45
|
-
cdn_url =
|
|
52
|
+
raise RuntimeError('上传失败 {}: {}'.format(local_path.name, info))
|
|
53
|
+
cdn_url = '{}/{}'.format(CDN_DOMAIN, cdn_key)
|
|
46
54
|
cdn_manager.refresh_urls([cdn_url])
|
|
47
55
|
return cdn_url
|
|
48
56
|
|
|
49
57
|
|
|
50
|
-
def publish(folder_path):
|
|
58
|
+
def publish(folder_path: Path):
|
|
51
59
|
folder_name = folder_path.name
|
|
52
|
-
|
|
60
|
+
version_dir, version_num = get_latest_version_dir(folder_path)
|
|
61
|
+
|
|
62
|
+
if not version_dir:
|
|
63
|
+
print('⚠️ {} 没有版本子目录,跳过'.format(folder_name))
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
version_str = 'v{}'.format(version_num)
|
|
67
|
+
files = [f for f in version_dir.rglob('*') if f.is_file() and f.name != '.sync-done']
|
|
53
68
|
|
|
54
69
|
if not files:
|
|
55
|
-
print(
|
|
70
|
+
print('⚠️ 文件夹为空: {}/{}'.format(folder_name, version_str))
|
|
56
71
|
return
|
|
57
72
|
|
|
58
|
-
print(
|
|
73
|
+
print('📂 发布: {}/{}'.format(folder_name, version_str))
|
|
59
74
|
for f in sorted(files):
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
rel = f.relative_to(version_dir)
|
|
76
|
+
cdn_key = '{}/{}/{}/{}'.format(CDN_PREFIX, folder_name, version_str, rel.as_posix())
|
|
77
|
+
cdn_url = upload_file(f, cdn_key)
|
|
78
|
+
print(' ⬆️ {} → {}'.format(rel, cdn_url))
|
|
64
79
|
|
|
80
|
+
print('✅ 完成: {}/{}\n'.format(folder_name, version_str))
|
|
65
81
|
|
|
66
82
|
|
|
67
83
|
def publish_index(pipeline_root: Path):
|
|
68
|
-
"""重建 template-index.json 并上传到 CDN。"""
|
|
69
84
|
index_script = pipeline_root / 'scripts' / 'build_template_index.py'
|
|
70
|
-
print(
|
|
85
|
+
print('🔄 重建 template-index.json ...')
|
|
71
86
|
subprocess.run(['python3', str(index_script)], check=True)
|
|
72
87
|
|
|
73
88
|
index_file = pipeline_root / 'template-index.json'
|
|
74
89
|
if not index_file.is_file():
|
|
75
|
-
print(
|
|
90
|
+
print('⚠️ template-index.json 未生成,跳过上传')
|
|
76
91
|
return
|
|
77
92
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
ret, info = put_file_v2(token, key, str(index_file))
|
|
93
|
+
token = q.upload_token(QINIU_BUCKET, CDN_INDEX_KEY, 3600, policy={'insertOnly': 0})
|
|
94
|
+
ret, info = put_file_v2(token, CDN_INDEX_KEY, str(index_file))
|
|
81
95
|
if info.status_code != 200:
|
|
82
|
-
raise RuntimeError(
|
|
83
|
-
cdn_url =
|
|
96
|
+
raise RuntimeError('index 上传失败: {}'.format(info))
|
|
97
|
+
cdn_url = '{}/{}'.format(CDN_DOMAIN, CDN_INDEX_KEY)
|
|
84
98
|
cdn_manager.refresh_urls([cdn_url])
|
|
85
|
-
print(
|
|
99
|
+
print(' ⬆️ 已上传索引 → {}'.format(cdn_url))
|
|
86
100
|
|
|
87
101
|
|
|
88
102
|
def main():
|
|
@@ -98,15 +112,15 @@ def main():
|
|
|
98
112
|
templates_dir = pipeline_root / 'templates'
|
|
99
113
|
folders = sorted(d for d in templates_dir.iterdir() if d.is_dir())
|
|
100
114
|
if not folders:
|
|
101
|
-
print(
|
|
115
|
+
print('⚠️ templates/ 下没有找到任何模板文件夹')
|
|
102
116
|
sys.exit(0)
|
|
103
|
-
print(
|
|
117
|
+
print('📦 共 {} 个模板,开始全量发布...\n'.format(len(folders)))
|
|
104
118
|
for folder in folders:
|
|
105
119
|
publish(folder)
|
|
106
120
|
else:
|
|
107
121
|
folder = Path(args.folder).expanduser().resolve()
|
|
108
122
|
if not folder.is_dir():
|
|
109
|
-
print(
|
|
123
|
+
print('❌ 路径不存在或不是文件夹: {}'.format(folder))
|
|
110
124
|
sys.exit(1)
|
|
111
125
|
publish(folder)
|
|
112
126
|
|