draftgo-cli 1.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,462 @@
1
+ ---
2
+ name: draftgo-frontend-rules
3
+ description: DraftGo frontend page development rules.
4
+ version: 1.0.0
5
+ ---
6
+
7
+ # DraftGo 前端开发规范
8
+
9
+ ## 架构认知
10
+
11
+ DraftGo 前端**不是传统 SPA**:
12
+ - 业务页面 HTML 存在数据库,运行在 `iframe.srcdoc`
13
+ - 导航栏 HTML 存在数据库,由壳层按需加载
14
+ - 页面通过 `window.parent.App` 调用平台能力
15
+
16
+ ---
17
+
18
+ ## 运行时机制(Runtime)
19
+
20
+ ### 壳层启动流程
21
+
22
+ 壳层入口(`frontend/src/core/runtime.js`)调用 `createAppRuntime()` 完成以下初始化:
23
+
24
+ 1. `reloadSystemConfig()` — 拉取 `/api/system/config`,填充 `state.config`
25
+ 2. `validateToken()` — 验证 `localStorage.dg_access_token`,成功后设置 `currentUser`
26
+ 3. `loadSetupStatus()` — 检查系统是否已初始化(`/api/system/setup/status`)
27
+ 4. `loadPage()` — 根据当前路由拉取页面 HTML,注入 iframe
28
+
29
+ ### iframe 注入机制
30
+
31
+ 壳层通过 `decorateFrameHtml(html, routeContext, theme)` 处理页面 HTML,注入:
32
+ - `window.__DG_ROUTE_CONTEXT__` — 当前路由上下文(含 `query` 参数)
33
+ - 主题 CSS 变量
34
+ - 静态资源(Tailwind、FontAwesome 等)
35
+
36
+ 页面 HTML 以 `iframe.srcdoc` 方式渲染,**不是独立 URL**,因此:
37
+ - `window.location` 指向壳层地址,不可用于读取路由参数
38
+ - `window.parent.App` 是壳层暴露的能力对象
39
+
40
+ ### App 对象来源
41
+
42
+ 壳层将 `app` 对象赋值给 `window.App`(通过 `Object.assign(app, ...)`),页面内通过 `window.parent.App` 访问。
43
+
44
+ `app` 对象包含以下模块(均已合并到顶层):
45
+
46
+ | 属性/方法 | 来源模块 | 说明 |
47
+ |---|---|---|
48
+ | `get/post/put/patch/delete` | `api.js` | HTTP 请求,自动携带 token |
49
+ | `uploadFile(file, onProgress)` | `api.js` | 文件上传 |
50
+ | `toast(msg, type?, duration?)` | `feedback.js` | Toast 通知(type: success/error/warning/info,默认 info) |
51
+ | `showSuccess(msg)` | `feedback.js` | 成功 Toast 3000ms ✓ |
52
+ | `showError(msg)` | `feedback.js` | 错误 Toast 4000ms ✕ |
53
+ | `showWarning(msg)` | `feedback.js` | 警告 Toast 3000ms ⚠ |
54
+ | `showInfo(msg)` | `feedback.js` | 信息 Toast 3000ms ℹ |
55
+ | `confirm(msg, title?)` | `feedback.js` | 确认弹窗,返回 Promise\<boolean\> |
56
+ | `showModal(msg, title?)` | `feedback.js` | 信息模态框,带确定按钮 |
57
+ | `showLoading() / hideLoading()` | `feedback.js` | 全局 loading 蒙层 |
58
+ | `navigate(route)` | `runtime.js` | 路由跳转(pushState) |
59
+ | `getCurrentRoute()` | `runtime.js` | 当前路径字符串 |
60
+ | `getCurrentRouteContext()` | `runtime.js` | 完整路由上下文(含 query) |
61
+ | `logout()` | `runtime.js` | 登出并跳转登录页 |
62
+ | `setAuthTokens({access_token, refresh_token})` | `runtime.js` | 登录后写入 token |
63
+ | `applyTheme(theme)` | `runtime.js` | 切换主题(light/dark),同步写 localStorage + iframe |
64
+ | `t(key, fallback?)` | `i18n.js` | 国际化文本 |
65
+ | `currentUser` | state | 当前用户对象(未登录为 null) |
66
+ | `isAdmin` | state | 是否管理员 |
67
+ | `isAuthenticated` | state | 是否已认证 |
68
+ | `hasToken` | state | 是否有 token(含未验证) |
69
+ | `config` | state | 系统配置键值对 |
70
+ | `theme` | state | 当前主题名(`'light'` \| `'dark'`) |
71
+
72
+ ### Token 存储
73
+
74
+ | key | 说明 |
75
+ |---|---|
76
+ | `localStorage.dg_access_token` | 访问 token |
77
+ | `localStorage.dg_refresh_token` | 刷新 token |
78
+
79
+ 登录成功后调用 `App.setAuthTokens({ access_token, refresh_token })` 写入并触发验证。
80
+
81
+ ### 全局事件
82
+
83
+ | 事件名 | 触发时机 |
84
+ |---|---|
85
+ | `dg:auth-ready` | token 验证完成(成功或失败) |
86
+ | `dg:auth-changed` | token 变更(登录/登出) |
87
+
88
+ 页面内监听:`window.parent.addEventListener('dg:auth-ready', handler)`
89
+
90
+ ### API 请求路径解析规则
91
+
92
+ `App.get/post/...` 的路径参数解析(来自 `api.js`):
93
+
94
+ | 传入值 | 实际请求 |
95
+ |---|---|
96
+ | `'pages'` | `{apiBase}/api/pages` |
97
+ | `'pages/123'` | `{apiBase}/api/pages/123` |
98
+ | `'/api/pages'` | `{apiBase}/api/pages` |
99
+ | `'https://...'` | 直接请求(绝对 URL) |
100
+
101
+ ### GET 请求筛选范式
102
+
103
+ **分页**(绝大多数列表接口):
104
+
105
+ ```javascript
106
+ App.get('users', { page: 1, page_size: 20 })
107
+ App.get('pages/', { page: 1, page_size: 20 })
108
+ ```
109
+
110
+ > 分页参数统一使用 `page` 与 `page_size`。GET 列表请求若不携带这两个参数,则返回全量数据;当数据量超过 10000 条时后端会拒绝并返回 400,此时必须传入 `page` 与 `page_size`。
111
+
112
+ **通用筛选参数**:
113
+
114
+ | 参数 | 类型 | 说明 |
115
+ |---|---|---|
116
+ | `page` | int | 页码,从 1 开始 |
117
+ | `page_size` | int | 每页条数 |
118
+ | `search` | string | 全文搜索(users/roles/pages/navigations/feedback/db) |
119
+ | `status` | string | 状态过滤(`active`/`inactive`/`published` 等,按资源而定) |
120
+ | `type` | string | 类型过滤(notices/feedback/aihub) |
121
+ | `tag` | string | 标签过滤(pages/aihub) |
122
+
123
+ **典型示例**:
124
+
125
+ ```javascript
126
+ // 用户列表,带搜索+状态+角色过滤
127
+ App.get('users', { page: 1, page_size: 20, search: 'alice', status: 'active', role_id: 3 })
128
+
129
+ // 页面列表,带标签+状态过滤
130
+ App.get('pages/', { page: 1, page_size: 20, search: 'home', tag: 'blog', status: 'published' })
131
+
132
+ // 动态 DB 数据
133
+ App.get(`db/${type}`, { page: 1, page_size: 20, search: 'keyword' })
134
+ ```
135
+
136
+ **DB Meta 说明**:涉及动态数据库操作前,先读取 `db_meta/index.json` 了解当前项目有哪些 DB 类型(`type` 字段),再调用 `/api/db/{type}` 进行 CRUD。不要硬编码 type 值。
137
+
138
+ ---
139
+
140
+ ---
141
+
142
+ ## 页面资产开发
143
+
144
+ ### 必须
145
+ - 完整 HTML 文档结构(`<html><head><body>`)
146
+ - 静态资源优先使用本地路径:
147
+ ```html
148
+ <script src="/assets/tailwindcss.js"></script>
149
+ <link href="/assets/fontawesome/css/all.min.css" rel="stylesheet">
150
+ ```
151
+ - 颜色全部用语义 token(见下方"颜色 Token")
152
+ - 弹窗用 `App.confirm()` / `App.toast()`
153
+ - 页面初始渲染必须展示默认状态,不能因网络请求延迟导致空白
154
+
155
+ ### 禁止
156
+ - 禁止引用境外 CDN(`fonts.googleapis.com`、`cdn.jsdelivr.net`、`cdnjs.cloudflare.com`、`unpkg.com` 等)
157
+ - 如需外部 CDN,只允许国内镜像(`npmmirror.com`、`staticfile.net`),且优先用本地资源
158
+ - 禁止 `window.alert()` / `window.confirm()` / `window.prompt()`
159
+ - 禁止在页面内渲染系统 Header、Logo、用户头像下拉等全局导航元素
160
+ - 禁止 `App()` 写法(`App` 是对象不是函数)
161
+ - 禁止 `const App = () => window.parent?.App`(正确:`const App = window.parent?.App`)
162
+
163
+ ### 本地静态资源清单
164
+
165
+ | 路径 | 说明 |
166
+ |---|---|
167
+ | `/assets/tailwindcss.js` | Tailwind CSS 运行时 |
168
+ | `/assets/fontawesome/css/all.min.css` | FontAwesome 6 图标库 |
169
+ | `/assets/fonts/inter.css` | Inter 字体 |
170
+ | `/assets/fonts/lexend.css` | Lexend 字体 |
171
+ | `/assets/fonts/plus-jakarta-sans.css` | Plus Jakarta Sans 字体 |
172
+ | `/assets/fonts/plus-jakarta-sans-jetbrains-mono.css` | Plus Jakarta Sans + JetBrains Mono 等宽 |
173
+ | `/assets/vendor/marked/marked.min.js` | Markdown 解析 |
174
+ | `/assets/vendor/prism/prism.min.js` | 代码语法高亮 |
175
+ | `/assets/vendor/prism/themes/prism-tomorrow.min.css` | Prism 主题 |
176
+ | `/assets/vendor/prism/components/prism-python.min.js` | Prism Python 语言支持 |
177
+ | `/assets/vendor/prism/components/prism-json.min.js` | Prism JSON 语言支持 |
178
+ | `/assets/vendor/prism/components/prism-yaml.min.js` | Prism YAML 语言支持 |
179
+
180
+ ---
181
+
182
+ ## URL 参数读取
183
+
184
+ 页面运行在 `iframe.srcdoc` 中,`window.location.search` 不可靠——它指向壳层地址,不是页面路由。
185
+
186
+ ### 注入机制
187
+
188
+ 壳层通过 `decorateFrameHtml()` 向每个页面注入 `<script data-dg-route-bridge>`,该脚本将路由上下文写入 iframe 的 `window`:
189
+
190
+ - `window.__DG_ROUTE_CONTEXT__` — 完整路由上下文(冻结对象),含 `query`、`route`、`params` 等
191
+ - `window.__DG_QUERY__` — `routeContext.query` 的快捷方式
192
+ - `window.__DG_GET_ROUTE_CONTEXT__()` — 函数形式的兜底读取
193
+
194
+ ### 标准读取方式(三阶回落)
195
+
196
+ ```javascript
197
+ const routeContext =
198
+ window.__DG_ROUTE_CONTEXT__ // 首选:bridge 注入的主变量
199
+ || window.__DG_GET_ROUTE_CONTEXT__?.() // 备选:函数兜底
200
+ || window.parent?.App?.getCurrentRouteContext?.() // 三选:壳层 API
201
+ || { query: {} }; // 兜底:空对象防崩溃
202
+ const query = routeContext.query || {};
203
+
204
+ // 使用示例
205
+ // 路由 /doctor/prescription-estimate?patientId=42&visitId=7
206
+ const patientId = query.patientId; // "42"
207
+ const visitId = query.visitId; // "7"
208
+ ```
209
+
210
+ ### 禁止
211
+
212
+ - `new URLSearchParams(window.location.search)` — iframe 中取不到壳层 URL
213
+ - `window.location.search` 直接解析 — 同上
214
+
215
+ ### 常见陷阱
216
+
217
+ 页面 JS 中引用 `window.__DG_ROUTE_CONTEXT__` 是**读取**操作。框架注入 bridge 的检测基于 `<script data-dg-route-bridge>` 标记,不会因页面代码读取该变量而误判跳过注入。
218
+
219
+ ---
220
+
221
+ ## 平台能力调用(App API)
222
+
223
+ ```javascript
224
+ const App = window.parent?.App;
225
+
226
+ // GET(第二参数为 query params)
227
+ const data = await App.get('pages', { page: 1, page_size: 20 });
228
+
229
+ // POST / PUT / PATCH / DELETE
230
+ await App.post('feedback', { type: 'bug', title: '标题' });
231
+ await App.put('pages/123', { title: '新标题' });
232
+ await App.delete('pages/123');
233
+
234
+ // Toast(推荐用便捷方法,默认时长更合理)
235
+ App.toast('操作成功', 'success'); // 通用方法,type 默认 'info'
236
+ App.showSuccess('操作成功'); // 绿色 ✓ 3000ms
237
+ App.showError('操作失败'); // 红色 ✕ 4000ms
238
+ App.showWarning('请注意'); // 黄色 ⚠ 3000ms
239
+ App.showInfo('提示信息'); // 蓝色 ℹ 3000ms
240
+ // 特性:玻璃质感、顶部居中弹入、进度条倒计时、hover 暂停、× 按钮关闭、明暗适配
241
+
242
+ // 确认弹窗(玻璃蒙层 + 弹跳动画入、动画出,⚠ 图标,两个按钮)
243
+ const ok = await App.confirm('确认删除?', '删除确认');
244
+ if (!ok) return;
245
+ // App.confirm(msg, title?) — 返回 Promise<boolean>,取消/点击蒙层返回 false
246
+
247
+ // 信息模态框(玻璃蒙层 + 弹跳动画,i 图标,单按钮)
248
+ App.showModal('Token 详情内容', 'Token 明细');
249
+ // App.showModal(msg, title?) — 替代 window.alert()
250
+
251
+ // Loading 蒙层
252
+ App.showLoading(); await someAsyncOp(); App.hideLoading();
253
+
254
+ // 文件上传
255
+ const result = await App.uploadFile(file, progress => console.log(progress));
256
+ ```
257
+
258
+ 路径解析:`https://...` 直接请求 · `/api/...` 拼接 origin · 其他相对路径拼接 apiBase
259
+
260
+ ---
261
+
262
+ ## 颜色 Token(强制)
263
+
264
+ 禁止硬编码任何 hex / rgb / rgba / hsl 值。
265
+
266
+ | Token | 用途 |
267
+ |---|---|
268
+ | `--dg-bg-base` | 页面底色 |
269
+ | `--dg-bg-page` | 内容区背景 |
270
+ | `--dg-bg-surface` | 卡片/面板背景 |
271
+ | `--dg-text-primary` | 主文字 |
272
+ | `--dg-text-secondary` | 次要文字 |
273
+ | `--dg-text-muted` | 弱化文字 |
274
+ | `--dg-accent` | 主题色 |
275
+ | `--dg-accent-hover` | 主题色 hover |
276
+ | `--dg-border` | 边框 |
277
+ | `--dg-success/error/warning/info` | 状态色 |
278
+
279
+ 例外:SVG 的 `fill`/`stroke` 可用 `currentColor` 或 `var(--dg-accent)`。
280
+
281
+ ---
282
+
283
+ ## 主题机制(App.theme / App.colorScheme)
284
+
285
+ ### 概念分层
286
+
287
+ | 概念 | 键 | 取值 | 说明 |
288
+ |---|---|---|---|
289
+ | 显示模式 | `App.theme` / `dg_theme` | `'light'` \| `'dark'` | 亮色/暗色 |
290
+ | 配色方案 | `App.colorScheme` / `dg_color_scheme` | `'dark-gray-white'` \| `'deep-blue-white'` \| `'orange-white'` \| `'custom'` | light 模式下的色彩搭配 |
291
+
292
+ ### 内置配色方案
293
+
294
+ | 方案 | 键 | 主题色 | 特点 |
295
+ |---|---|---|---|
296
+ | 深灰白(默认) | `dark-gray-white` | #27272a 深灰 | 冷调深灰,干净利落 |
297
+ | 深蓝白 | `deep-blue-white` | #1e3a5f 深蓝 | 专业稳重,企业风格 |
298
+ | 橙白 | `orange-white` | #ea580c 活力橙 | 温暖醒目,创意风格 |
299
+ | 自定义 | `custom` | 用户自选 | 基于预设微调配色 |
300
+
301
+ ### 入场主题
302
+
303
+ 页面以 `iframe.srcdoc` 渲染,壳层在注入 HTML 时已将当前主题的 CSS 变量写入 `<head>` 最顶部。
304
+
305
+ **页面无需任何初始化代码**,CSS 变量在 DOM 解析时已生效,直接用 `var(--dg-*)` 即可。
306
+
307
+ ### 读取当前主题
308
+
309
+ ```javascript
310
+ const App = window.parent?.App;
311
+ const theme = App?.theme; // 'light' | 'dark'
312
+ const scheme = App?.colorScheme; // 'dark-gray-white' | 'deep-blue-white' | 'orange-white' | 'custom'
313
+ ```
314
+
315
+ 仅在需要**按主题做 JS 逻辑分支**时才读取,纯样式差异用 CSS 变量解决。
316
+
317
+ ### 切换主题
318
+
319
+ ```javascript
320
+ App.applyTheme('dark'); // 切换显示模式
321
+ App.setColorScheme('deep-blue-white'); // 切换为预设方案
322
+ App.setColorScheme('custom', customVarsObject); // 应用自定义配色
323
+ ```
324
+
325
+ 调用后:壳层更新 CSS 变量 → 写入 localStorage → 同步注入当前 iframe。
326
+
327
+ ### 获取方案详情
328
+
329
+ ```javascript
330
+ const info = App.getColorScheme();
331
+ // { name: 'dark-gray-white', vars: { ... }, schemes: { ... } }
332
+ ```
333
+
334
+ ### 监听主题变化
335
+
336
+ 壳层不广播主题变更事件。如果页面需要响应用户切换主题,用 `MutationObserver` 监听根元素 CSS 变量变化,或在切换按钮的回调里手动处理。
337
+
338
+ ### 禁止
339
+
340
+ - 禁止在页面内读取 `localStorage.dg_theme`(通过 `App.theme` 获取)
341
+ - 禁止在页面内读取 `localStorage.dg_color_scheme`(通过 `App.colorScheme` 获取)
342
+ - 禁止在页面内调用 `document.documentElement.style.setProperty` 设置主题变量
343
+ - 禁止硬编码任何颜色值作为主题分支的输出
344
+
345
+ ---
346
+
347
+ ## 样式规范
348
+
349
+ - 圆角统一 `rounded-md`(6px),禁止 `rounded-lg` 或更大
350
+ - 自定义组件类名以 `-dg` 结尾(如 `input-dg`、`btn-dg-primary`)
351
+
352
+ | 组件 | 类名 |
353
+ |---|---|
354
+ | 输入框 | `.input-dg` — `rounded-md border-slate-200 text-[13px] h-8 px-3` |
355
+ | 主按钮 | `.btn-dg-primary` — Slate-900 风格 |
356
+ | 次按钮 | `.btn-dg-secondary` — White/Slate-200 风格 |
357
+ | 危险按钮 | `.btn-dg-danger` — White/Red-50 hover 风格 |
358
+
359
+ ---
360
+
361
+ ## 导航栏开发
362
+
363
+ 导航栏是**数据库资产**,HTML 存储在 `navigation.html` 字段,由前端 runtime 动态挂载。
364
+
365
+ ### 导航类型
366
+
367
+ | 类型 | 根元素 | 必填属性 | 布局 |
368
+ |---|---|---|---|
369
+ | 顶栏 | `<header>` | `data-nav-position="top"` | 顶部,高度 64px |
370
+ | 侧边 | `<aside>` | `data-nav-position="side"` | 左侧,宽度 260px |
371
+
372
+ ### 必须
373
+ - 根元素加 `data-nav-position="top|side"`
374
+ - 所有导航链接必须用 `data-page-route`:
375
+ ```html
376
+ <a href="/dashboard" data-page-route="/dashboard">仪表盘</a>
377
+ ```
378
+
379
+ ### 导航专用颜色 Token
380
+
381
+ | 变量 | 用途 |
382
+ |---|---|
383
+ | `var(--dg-bg-topnav, #fff)` | 导航背景色 |
384
+ | `var(--dg-bg-topnav-hover, #f3f4f6)` | 悬停背景色 |
385
+ | `var(--dg-text-topnav, #111827)` | 主文字 |
386
+ | `var(--dg-text-topnav-muted, #6b7280)` | 次要文字 |
387
+ | `var(--dg-border-topnav, #e5e7eb)` | 边框 |
388
+ | `var(--dg-bg-nav, #fff)` | 侧边栏背景(side 专用) |
389
+
390
+ ### 顶栏模板
391
+
392
+ ```html
393
+ <header data-nav-position="top" style="
394
+ width:100%; height:64px; box-sizing:border-box;
395
+ display:flex; align-items:center; justify-content:space-between;
396
+ padding:0 24px;
397
+ background:var(--dg-bg-topnav,#fff);
398
+ border-bottom:1px solid var(--dg-border-topnav,#e5e7eb);
399
+ position:sticky; top:0; z-index:50;
400
+ ">
401
+ <div style="display:flex;align-items:center;gap:16px;">
402
+ <span style="font-size:18px;font-weight:700;color:var(--dg-text-topnav,#111827);">应用名称</span>
403
+ <nav style="display:flex;align-items:center;gap:4px;">
404
+ <a href="/dashboard" data-page-route="/dashboard"
405
+ style="padding:8px 14px;border-radius:6px;color:var(--dg-text-topnav-muted,#6b7280);text-decoration:none;font-size:14px;font-weight:500;">
406
+ 仪表盘
407
+ </a>
408
+ </nav>
409
+ </div>
410
+ <div style="display:flex;align-items:center;gap:8px;"><!-- 右侧操作区 --></div>
411
+ </header>
412
+ ```
413
+
414
+ ### 侧边栏模板
415
+
416
+ ```html
417
+ <aside data-nav-position="side" style="
418
+ width:100%; height:100%; box-sizing:border-box;
419
+ background:var(--dg-bg-nav,#fff);
420
+ display:flex; flex-direction:column;
421
+ border-right:1px solid var(--dg-border-topnav,#e2e8f0);
422
+ ">
423
+ <div style="padding:20px;border-bottom:1px solid var(--dg-border-topnav,#e2e8f0);">
424
+ <span style="font-size:16px;font-weight:700;color:var(--dg-text-topnav,#111827);">应用名称</span>
425
+ </div>
426
+ <nav style="flex:1;padding:12px;display:flex;flex-direction:column;gap:4px;overflow-y:auto;">
427
+ <a href="/dashboard" data-page-route="/dashboard" style="
428
+ display:flex;align-items:center;gap:10px;
429
+ padding:10px 12px;border-radius:6px;
430
+ color:var(--dg-text-topnav-muted,#6b7280);
431
+ text-decoration:none;font-size:14px;font-weight:500;">
432
+ 仪表盘
433
+ </a>
434
+ </nav>
435
+ </aside>
436
+ ```
437
+
438
+ ### 常见错误
439
+
440
+ | 错误 | 原因 | 修复 |
441
+ |---|---|---|
442
+ | 导航不显示 | `status` 为 `inactive` | 改为 `active` |
443
+ | 布局错乱 | 缺少 `data-nav-position` | 在根元素加上该属性 |
444
+ | 链接点击无效 | 缺少 `data-page-route` | 在 `<a>` 上加该属性 |
445
+ | 主题切换后颜色异常 | 使用了硬编码颜色 | 改用 `var(--dg-*)` 变量 |
446
+
447
+ ---
448
+
449
+ ## 退出登录
450
+
451
+ ```javascript
452
+ window.location.href = '/login'; // 正确:完整刷新,导航栏重新加载
453
+ // 错误:navigate('/login') — 导航栏不会更新
454
+ ```
455
+
456
+ ---
457
+
458
+ ## 加载体验
459
+
460
+ - 推荐骨架屏占位(`bg-slate-100`)
461
+ - 禁止因网络请求失败导致整个页面空白
462
+ - 页面初始化必须先渲染默认状态,再异步填充数据
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ DraftGo Init Script
4
+ 拉取云端数据,生成本地开发上下文文件。
5
+
6
+ 用法:
7
+ python draftgo_init.py --server https://xxx --token yyy [project_dir]
8
+
9
+ 默认项目根:从脚本所在目录向上查找第一个包含 `.draftgo/` 的目录,
10
+ 兼容新旧两种安装位置(.draftgo/skill/scripts/ 与 .claude/skills/draftgo/scripts/)。
11
+ """
12
+ import argparse, json, sys
13
+ from pathlib import Path
14
+ import urllib.request, urllib.error
15
+
16
+ SCRIPT_DIR = Path(__file__).resolve().parent
17
+
18
+
19
+ def find_project_root(start: Path) -> Path:
20
+ """向上查找包含 `.draftgo/` 的目录作为项目根。找不到则回退到原有层级(parents[3])。"""
21
+ cur = start
22
+ for _ in range(10):
23
+ if (cur / ".draftgo").is_dir():
24
+ return cur
25
+ if cur.parent == cur:
26
+ break
27
+ cur = cur.parent
28
+ # Fallback:旧安装位置 .claude/skills/draftgo/scripts/ → parents[3]
29
+ return start.parents[3] if len(start.parents) >= 4 else start
30
+
31
+
32
+ DEFAULT_ROOT = find_project_root(SCRIPT_DIR)
33
+
34
+
35
+ def fetch(server, token, path):
36
+ url = f"{server}{path}"
37
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
38
+ try:
39
+ with urllib.request.urlopen(req, timeout=15) as r:
40
+ return json.loads(r.read())
41
+ except urllib.error.HTTPError as e:
42
+ print(f" ✗ {path} → HTTP {e.code}", file=sys.stderr)
43
+ return None
44
+ except Exception as e:
45
+ print(f" ✗ {path} → {e}", file=sys.stderr)
46
+ return None
47
+
48
+
49
+ def extract_items(raw):
50
+ if isinstance(raw, list):
51
+ return raw
52
+ if isinstance(raw, dict):
53
+ for key in ("items", "results"):
54
+ v = raw.get(key)
55
+ if isinstance(v, list):
56
+ return v
57
+ d = raw.get("data")
58
+ if isinstance(d, list):
59
+ return d
60
+ if isinstance(d, dict):
61
+ return d.get("items", [])
62
+ return []
63
+
64
+
65
+ def safe_slug(s):
66
+ return str(s).strip("/").replace("/", "_") or "root"
67
+
68
+
69
+ def write_index(folder, items):
70
+ folder.mkdir(parents=True, exist_ok=True)
71
+ (folder / "index.json").write_text(
72
+ json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8"
73
+ )
74
+
75
+
76
+ def explode_pages(out_dir, raw):
77
+ items = extract_items(raw)
78
+ index = []
79
+ for p in items:
80
+ pid = p.get("id", "x")
81
+ fname = f"page_{pid}_{safe_slug(p.get('route', pid))}.html"
82
+ html = (p.get("value") or {}).get("html", "")
83
+ (out_dir / fname).write_text(html, encoding="utf-8")
84
+ meta = {k: v for k, v in p.items() if k != "value"}
85
+ # 相对项目根的路径,供 sync 脚本读取
86
+ meta["html_file"] = f".draftgo/pages/{fname}"
87
+ index.append(meta)
88
+ write_index(out_dir, index)
89
+ return len(index)
90
+
91
+
92
+ def explode_html_field(out_dir, raw, html_field, fname_prefix, rel_prefix):
93
+ items = extract_items(raw)
94
+ index = []
95
+ for item in items:
96
+ iid = item.get("id", "x")
97
+ name_slug = safe_slug(item.get("code") or item.get("route") or iid)
98
+ fname = f"{fname_prefix}_{iid}_{name_slug}.html"
99
+ (out_dir / fname).write_text(item.get(html_field, ""), encoding="utf-8")
100
+ meta = {k: v for k, v in item.items() if k != html_field}
101
+ meta["html_file"] = f"{rel_prefix}/{fname}"
102
+ index.append(meta)
103
+ write_index(out_dir, index)
104
+ return len(index)
105
+
106
+
107
+ def build_claude_md(server, script_dir):
108
+ """复制 skill 的 CLAUDE.md 模板,仅替换服务器地址。"""
109
+ template_path = script_dir.parent / "CLAUDE.md"
110
+ if not template_path.exists():
111
+ return f"# DraftGo 项目上下文\n\n> 由 draftgo-init 自动生成。\n\n## 连接信息\n- 服务器:{server}\n"
112
+ content = template_path.read_text(encoding="utf-8")
113
+ content = content.replace("https://dev.draftgo.cn", server)
114
+ return content
115
+
116
+
117
+ def main():
118
+ parser = argparse.ArgumentParser()
119
+ parser.add_argument("--server", required=True)
120
+ parser.add_argument("--token", required=True)
121
+ parser.add_argument(
122
+ "project_dir",
123
+ nargs="?",
124
+ default=None,
125
+ help="项目根目录(可选),默认自动推导",
126
+ )
127
+ args = parser.parse_args()
128
+
129
+ server = args.server.rstrip("/")
130
+ token = args.token
131
+ root = Path(args.project_dir).resolve() if args.project_dir else DEFAULT_ROOT
132
+ dg_dir = root / ".draftgo"
133
+ dg_dir.mkdir(parents=True, exist_ok=True)
134
+
135
+ (dg_dir / "config.json").write_text(
136
+ json.dumps({"server": server, "token": token}, indent=2),
137
+ encoding="utf-8",
138
+ )
139
+
140
+ gi = root / ".gitignore"
141
+ content = gi.read_text(encoding="utf-8") if gi.exists() else ""
142
+ if ".draftgo/config.json" not in content:
143
+ with gi.open("a", encoding="utf-8") as f:
144
+ f.write("\n.draftgo/config.json\n")
145
+
146
+ endpoints = {
147
+ "pages": "/api/pages/?page=1&page_size=500",
148
+ "navigations": "/api/navigations",
149
+ "roles": "/api/roles",
150
+ "users": "/api/users?page=1&page_size=500",
151
+ "db_meta": "/api/db-meta",
152
+ "aihub": "/api/aihub",
153
+ "system_config": "/api/system/config",
154
+ }
155
+ raw = {}
156
+ for name, path in endpoints.items():
157
+ print(f" fetching {name}...")
158
+ result = fetch(server, token, path)
159
+ raw[name] = result if result is not None else []
160
+
161
+ counts = {}
162
+
163
+ out_pages = dg_dir / "pages"
164
+ out_pages.mkdir(parents=True, exist_ok=True)
165
+ counts["pages"] = explode_pages(out_pages, raw["pages"])
166
+ print(f" OK .draftgo/pages/ ({counts['pages']} pages)")
167
+
168
+ out_navs = dg_dir / "navigations"
169
+ out_navs.mkdir(parents=True, exist_ok=True)
170
+ counts["navigations"] = explode_html_field(
171
+ out_navs, raw["navigations"], "html", "nav", ".draftgo/navigations"
172
+ )
173
+ print(f" OK .draftgo/navigations/ ({counts['navigations']} navs)")
174
+
175
+ for name in ("roles", "users", "db_meta", "aihub", "system_config"):
176
+ items = extract_items(raw[name])
177
+ write_index(dg_dir / name, items)
178
+ counts[name] = len(items)
179
+ print(f" OK .draftgo/{name}/ ({counts[name]} items)")
180
+
181
+ (dg_dir / "bugs").mkdir(parents=True, exist_ok=True)
182
+ (dg_dir / "iteration").mkdir(parents=True, exist_ok=True)
183
+ print(" OK .draftgo/bugs/ .draftgo/iteration/")
184
+
185
+ # rules 已经由 CLI 安装到 .draftgo/skill/rules/,这里不再重复写入。
186
+
187
+ (root / "CLAUDE.md").write_text(build_claude_md(server, SCRIPT_DIR), encoding="utf-8")
188
+ print(f"\nDone: {counts['pages']} pages, {counts['navigations']} navs, server: {server}")
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()