feat(web): dev.html 加 iframe embed 模式 (?embed=1&parent_origin=...) + EMBED.md

embed 模式藏左上 brand / 用户名 / 退出登录,桌面整层 header 移除,移动端保留
header 给 mobile-tabs;"+ 新建任务" JS 移到任务面板 pane-head。postMessage 双向
握手:iframe 发 zcbot-ready / zcbot-401,父端回 zcbot-token{token,user_id,
user_name?};双向 event.origin 校验(parent_origin URL 参数白名单)。401 改写
logout 不 reload,而是通知父端 + 显灰底 spinner 等待层。复用已有 /v1/auth/login
SSO 入口,platform 后端 PLATFORM_KEY 代换 JWT,不下放浏览器。EMBED.md 写给
platform 工程的对接手册(URL / 协议 / Node+Python 后端示例 / 父端前端示例 / CORS
+ CSP frame-ancestors 收紧建议 / 调试 + 故障兜底)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-22 14:32:16 +08:00
parent d6cbe8194b
commit 76f4c45350
4 changed files with 342 additions and 2 deletions

227
EMBED.md Normal file
View File

@ -0,0 +1,227 @@
# zcbot 控制台 iframe 嵌入对接
> 给 **platform 工程**:把 zcbot 控制台(`/static/dev.html`)嵌入 platform 主页,需要做的事。
>
> 假设:platform 和 zcbot **不同源**(eg. `https://portal.example.com``https://zcbot.example.com`)。同源直接共享 localStorage 不需要这套握手。
---
## 一句话总结
```html
<iframe src="https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com"></iframe>
```
iframe 加载完会发 `postMessage({type:"zcbot-ready"})` 给 platform。platform 后端拿 `PLATFORM_KEY``POST /v1/auth/login` 换出 JWT,通过 `postMessage({type:"zcbot-token", token, user_id})` 推回 iframe。完事。
embed 模式下:左上 brand 隐藏 · 退出登录隐藏 · 登录页不显示 · "+ 新建任务"挪到任务面板。
---
## 1. URL 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `embed` | 是 | 固定 `1`,触发 embed 模式 |
| `parent_origin` | 是 | platform 主页 origin(scheme + host + 端口),postMessage 白名单。**没传 / 不匹配 → iframe 显错误占位、所有 message 都被丢** |
示例:
```
https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com
```
---
## 2. postMessage 协议
所有消息 `data` 都是 plain object,`type` 字段标识种类。两端都必须校验 `event.origin`
### iframe → platform(子发父)
| `type` | 时机 | 字段 | platform 应做 |
|---|---|---|---|
| `zcbot-ready` | iframe 加载完成 / 已有缓存 token 但仍发一次 | (无) | 检查当前用户 → 调后端换 JWT → 回推 `zcbot-token` |
| `zcbot-401` | 用户操作触发 401(token 过期 / 被吊销) | (无) | 重新换 JWT(可能要求用户重新登录 platform)→ 回推 `zcbot-token`;或决定关闭 iframe |
### platform → iframe(父发子)
| `type` | 字段 | iframe 行为 |
|---|---|---|
| `zcbot-token` | `token` (string, JWT) <br> `user_id` (string, UUID) <br> `user_name` (string, 可选) | 写 localStorage + 进入主界面;若已在主界面(401 重签场景)只重载任务列表 |
---
## 3. platform 后端:换 JWT
zcbot 已经有 SSO 入口,**platform 后端用共享的 `PLATFORM_KEY` 代任意用户换 JWT**:
```
POST https://zcbot.example.com/v1/auth/login
Content-Type: application/json
{
"user_id": "<UUID,platform 决定>",
"platform_key": "< zcbot env PLATFORM_KEY 同串>"
}
```
返回:
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user_id": "...",
"exp": 1740000000
}
```
- `PLATFORM_KEY`:platform 和 zcbot **后端共享的密钥**,**绝不能下放到浏览器**。换 token 必须 platform 后端代理。
- `user_id`:任意 UUID;首次出现 zcbot 会自动建 `users` 行(占位,无 email/密码),后续 task / message 都用这个 user_id 作为分区键。**platform 用户 ↔ zcbot user_id 映射由 platform 维护**(建议 1:1)。
- JWT 默认 7 天 TTL(可改 `ZCBOT_JWT_TTL_SECONDS` env)。token 过期前 iframe 收到 401 → 发 `zcbot-401` 给父端 → 父端再走这个换 token 流程。
### Node.js 后端示例
```js
// POST /api/zcbot-token (platform 自己的 API,由前端登录态保护)
app.post("/api/zcbot-token", async (req, res) => {
const userId = req.session.zcbotUserId; // platform user → zcbot user_id 映射
const r = await fetch("https://zcbot.example.com/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: userId,
platform_key: process.env.ZCBOT_PLATFORM_KEY,
}),
});
if (!r.ok) return res.status(502).json({ error: "zcbot login failed" });
const { token } = await r.json();
res.json({ token, user_id: userId, user_name: req.session.userName });
});
```
### Python (FastAPI) 后端示例
```python
import os, httpx
from fastapi import HTTPException
@app.post("/api/zcbot-token")
async def issue_zcbot_token(user_id: str = Depends(current_user_id)):
async with httpx.AsyncClient(timeout=5) as c:
r = await c.post(
"https://zcbot.example.com/v1/auth/login",
json={"user_id": user_id, "platform_key": os.environ["ZCBOT_PLATFORM_KEY"]},
)
if r.status_code != 200:
raise HTTPException(502, "zcbot login failed")
data = r.json()
return {"token": data["token"], "user_id": user_id}
```
---
## 4. platform 前端:iframe + postMessage
```html
<iframe
id="zcbot-frame"
src="https://zcbot.example.com/static/dev.html?embed=1&parent_origin=https://portal.example.com"
style="width:100%; height:100vh; border:0;"
allow="clipboard-read; clipboard-write"
></iframe>
<script>
const ZCBOT_ORIGIN = "https://zcbot.example.com";
const frame = document.getElementById("zcbot-frame");
async function pushToken() {
// 调 platform 后端拿 zcbot JWT(后端用 PLATFORM_KEY 换)
const r = await fetch("/api/zcbot-token", { method: "POST", credentials: "include" });
if (!r.ok) {
console.error("zcbot token fetch failed");
return;
}
const { token, user_id, user_name } = await r.json();
frame.contentWindow.postMessage(
{ type: "zcbot-token", token, user_id, user_name },
ZCBOT_ORIGIN
);
}
window.addEventListener("message", (e) => {
if (e.origin !== ZCBOT_ORIGIN) return;
const t = e.data && e.data.type;
if (t === "zcbot-ready" || t === "zcbot-401") {
pushToken();
}
});
</script>
```
iframe **高度必须足够**(推荐 `100vh` 或固定大尺寸,zcbot 内部有多个全屏 `position:fixed` modal,iframe 太小会挤瘪)。
---
## 5. 安全要点
1. **`parent_origin` URL 参数必填** —— 缺失 / 非法 iframe 不工作。这是 postMessage 的 origin 白名单。
2. **`PLATFORM_KEY` 只在 platform 后端** —— 等同 JWT 签名权限,泄漏 = 任意伪造任意用户。前端代码 / Git 仓库 / 客户端日志都不能出现。
3. **iframe 收 message 强制校验 `event.origin === parent_origin`** —— 已在 dev.html 实现,父端也应对称校验 `event.origin === ZCBOT_ORIGIN`,防恶意页面伪造消息。
4. **CSP `frame-ancestors`** —— 真发布时 zcbot 这边建议加响应头限制只允许 platform 域嵌入,目前 zcbot 未默认设置(本地宽松,见 §6)。
5. **HTTPS 必须** —— `parent_origin``https://`,生产部署不要走 http(postMessage 在 http 不发警告,但中间人可改 iframe src 注入恶意 token)。
6. **TTL**:JWT 默认 7 天。若 platform 希望更短(强制频繁刷新),改 zcbot env `ZCBOT_JWT_TTL_SECONDS` 即可。
---
## 6. zcbot 部署侧需要做的事
> 这一节是 **zcbot 运维 / 我自己** 要做的,platform 工程不需要操心,列出来供参考。
1. **CORS `allow_origins`** 收紧到 platform 域名(目前 `["*"]`,见 `web/app.py:516`)
```python
allow_origins=["https://portal.example.com"],
allow_credentials=False,
```
2. **CSP `frame-ancestors`** 加响应头中间件,只放行 platform 域:
```python
@app.middleware("http")
async def csp_headers(req, call_next):
resp = await call_next(req)
resp.headers["Content-Security-Policy"] = (
"frame-ancestors https://portal.example.com"
)
return resp
```
(FastAPI 默认不发 `X-Frame-Options`,iframe 加载本身不会被浏览器拦;`frame-ancestors` 是 CSP 替代品,更细粒度。)
3. **env**:`PLATFORM_KEY` / `JWT_SECRET` 必填(已是启动 fail-fast 校验,见 `web/auth.py:67`)。
---
## 7. 调试
**本地试 iframe(同机两端口)**:
- zcbot 跑 `8765` → 浏览器开 `http://127.0.0.1:8765/static/dev.html?embed=1&parent_origin=http://127.0.0.1:3000`
- 单独不带 platform 时 iframe 会显示"等待登录…",DevTools console 看 `postMessage` 收发。
- 手动模拟 platform 推 token(在 iframe DevTools 里):
```js
window.postMessage({type:"zcbot-token", token:"<手工 curl 换的 JWT>", user_id:"<UUID>"}, location.origin)
```
注意 origin 必须 match `parent_origin` 参数,本机调试时把 `parent_origin` 设成 iframe 自己的 origin 即可(自发自收)。
**curl 试后端换 token**:
```bash
curl -X POST http://127.0.0.1:8765/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"user_id":"00000000-0000-0000-0000-000000000001","platform_key":"<env PLATFORM_KEY>"}'
```
---
## 8. 故障兜底
| 现象 | 原因 | 处置 |
|---|---|---|
| iframe 显示"embed 模式缺少 parent_origin 参数" | URL 里没传 `parent_origin` | 加上 `&parent_origin=https://...` |
| iframe 永远显"等待登录…" | platform 没收 / 没回 `zcbot-ready` | DevTools console 看 message;校验 `event.origin` 是否匹配 |
| iframe 收到 `zcbot-token` 但仍不进主界面 | `event.origin !== parent_origin``token` / `user_id` 字段缺失 | iframe 是静默丢弃,看 console 是否有报错;校验字段拼写 |
| 一动作就跳回等待页 | JWT 过期 / 后端校验失败,触发 `zcbot-401` | platform 重新换 token 回推;或检查 zcbot 端 JWT_SECRET 是否变过 |
| 嵌入页面 modal(新建任务 / 文件预览)被挤瘪 | iframe 高度不够 | iframe `height``100vh` 或更高,zcbot modal 是 `position:fixed; inset:0` |

View File

@ -23,6 +23,7 @@
### 2026-05-22
- **dev SPA 加 iframe embed 模式(`?embed=1&parent_origin=...`)**:`web/static/dev.html` 加 embed 模式 — 父页面 iframe 嵌入时藏左上 brand / 顶栏 `#hd-who` / 退出登录按钮(桌面段整层 header `display:none`,移动段保留 header 给 `.mobile-tabs` 切换用),JS `embedInit``#hd-new`("+ 新建任务")从 header 节点移到任务面板 pane-head(spacer 之后、`#filter-status` 之前,加 `small` class 跟周边按钮对齐高度)。postMessage 协议:iframe 启动发 `{type:"zcbot-ready"}` 给父端,父端调自家后端用 `PLATFORM_KEY` 走 zcbot 已有的 `POST /v1/auth/login` 拿 JWT,通过 `{type:"zcbot-token", token, user_id, user_name?}` 推回 iframe;iframe 写 localStorage + `enterApp()`。401 时改写 `logout()` 不再 `location.reload()`,而是发 `{type:"zcbot-401"}` 通知父端重换 token,期间显灰底等待层(`#embed-waiting` spinner + 文案);新加 css class `body.embed-mode` / `body.embed-mode.embed-waiting` 控制可见性。**安全要点**:`event.origin` 双向校验(白名单 = URL 参数 `parent_origin`),缺参数直接显错误占位拒收;`PLATFORM_KEY` 留在 platform 后端绝不下发浏览器。`web/EMBED.md` 写给 platform 工程的对接手册(URL / 协议 / Node/Python 后端示例 / 父端前端示例 / CORS / CSP frame-ancestors 收紧建议 / 调试 + 故障兜底表)。否决:(a) URL 参数直接传 token —— Referer / 浏览器历史泄漏面;(b) 同源 + 共享 localStorage —— 用户明确说不同源;(c) 拆 dev.html 进 platform SPA route —— 工作量爆炸。
- **dev SPA chat-input 支持 Ctrl+V 粘贴文件上传 + chat-hint 反馈**:`web/static/dev.html` 给 `#chat-input``paste` 监听 —— `e.clipboardData.files` 非空时 `preventDefault` + 复用现有 `uploadFiles(files)``/v1/files/upload` 落到 `state.filesPath`(与拖拽到右 pane 同通路);纯文本粘贴走默认不拦。`uploadFiles` 改返回 bool(成功 true / 失败 false,原 alert 行为不变);粘贴 handler 通过 `chat-hint` 广播 "上传中:<name>…" → "已粘贴:<name>"(4s 后回前一个 hint,同 `optimizePrompt` 救回范式,不破坏 streaming/optimizing 期间的状态)。失败仍走 alert,hint 立即恢复。placeholder 提示加 `Ctrl+V 可粘贴文件`。常见场景:截图后直接 Ctrl+V 入对话区当作素材上传,免去切窗口走右 pane 拖拽。
- **dev SPA 文件预览弹框让出 chat-form 高度(打开期间输入区仍可点可打字)**:`web/static/dev.html` 给 `#file-preview-modal``bottom: var(--preview-bottom-inset, 0)` —— 默认 0 行为不变,`openFilePreview` 时 JS 量 `#chat-form.offsetHeight`(隐藏走 `offsetParent` 判空 → 0,无活动任务恢复全屏)写到弹框元素 inline style 上;`.card` 加 `max-height: calc(100vh - var(...) - 32px)` 让卡片随容器收缩不溢出,手机段同理用 `100dvh`。`closeFilePreview` `removeProperty` 清掉避免下次冗余。弹框遮罩本身物理上不覆盖底部输入区 → chat-form 自然可点击/打字,Esc 与点遮罩关闭逻辑不动。否决:(a) 整窗 `pointer-events: none` + card 收回 —— 遮罩物理还在覆盖,视觉仍遮挡;(b) 弹框抽进 `#pane-mid` 内 absolute —— 弹框来源含 `#pane-right` 文件列表和聊天 chip,挂 mid 内会限制弹框只能在 mid 列,且 `#pane-mid` 多层 flex 嵌套要重排;(c) 硬编一个常量 `bottom: 140px` —— chat-form 高度依据 textarea 用户拖拽变化(min 60 但可拉高),JS 量一次足够准。
- **对外路径协议刚性化(system 强约束 + SKILL 简化 + UI 一次性兼容)**:`prompts/system/general_v1.md`「路径」段加规则 —— 助手对外 echo 产物路径必须用 user_root 相对全形式 `<wd_name>/<rel>`(`<wd_name>` = task_dir 末段,如 `生图测试/videos/xxx.mp4` / `基金申报/sections/01-绪论.md` / `公司汇报/slides/deck.pptx`),不简写为 `videos/xxx.mp4` 这种 task 内裸形式(Web UI 按 `<wd_name>/` 前缀挂 chip,简写 → chip 失效用户点不开)。媒体 tool(`seedream` / `seedance`)的 `saved:` 行已是规范全形式可直接照抄,其他场景(ppt / proposal / coding 等 run_python/write 写文件)自己拼。**跨所有产物 skill 统一生效**(不止 imagegen/videogen)。`skills/imagegen/SKILL.md` + `skills/videogen/SKILL.md` 把原有"把 `saved: xxx` 告诉用户"重复教学改成"照抄 saved 行,详见 system「路径」段"(消除 skill 内具体写法 → 协议归一到 system,新产物 skill 不用重复教育)。ppt/proposal/coding 等 SKILL **不动** —— 它们只泛说"告诉用户文件路径"没教错,system 协议升级后助手自然按全形式 echo,加 skill 提醒反而是协议漂移源。`web/static/dev.html::extractArtifactRels` 加一次性兼容兜底:产物目录裸路径 `videos/xxx.<ext>` / `figures/xxx.<ext>`(协议刚性前历史简写)prepend `<wdName>/` 拼成 user_root rel —— 白名单显式枚举两项不扩展、长期老消息归档后整段可删。**术语校准**:前缀叫 `<wd_name>`(working_dir 最后一段)而非 `<task_name>` —— 用户允许 `wd_name ≠ task_name`(`build_agent` wd_raw 走 working_dir 字段独立可指定),`_display` 锚 user_root 出来的是 `<wd_name>`,SKILL/system 早期写 `task_name` 在分叉场景下会误导助手拼错前缀。否决:(a) 后端 `_display` 改 task-relative 让 tool 输出本身就裸 —— `Tool` 基类 + fs/skill_tool/seedream/seedance/agent_builder/smoke 改 8 个文件,且 fs 跨 task 时要分层 fallback(working_dir → user_root → 绝对),复杂度超过收益;(b) 后端补 HEAD 探针让前端验文件存在再挂 chip —— 工程量与开发期需求不匹配;(c) 白名单常驻服务所有简写形式 —— 维护负担+清单可能膨胀,改成"一次性兼容历史消息"角色后边界清晰;(d) 每个写产物的 SKILL 各加一句"按 system 协议" —— 协议漂移源,违反"system 谈通用、SKILL 谈领域"边界。

4
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-05-22(seedance 视频生成接入 — 同 ARK_API_KEY,新增 videogen skill)
最后更新:2026-05-22(dev SPA 加 iframe embed 模式 — `?embed=1&parent_origin=...`,对接见 `EMBED.md`)
---
@ -117,6 +117,8 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
**dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),登录页两 tab(默认"邮箱密码",备用"UUID + PLATFORM_KEY",last-used 持久化 LS)进入 3 栏(task / chat / files)。给同事试用:`main.py user add` 发用户,**不用重启** web(每次 login 都查 DB),把 URL + 邮箱密码分别发给同事。
**iframe 嵌入**(platform 主页内嵌):URL 加 `?embed=1&parent_origin=<父页面 origin>`,触发 embed 模式 —— 藏左上 brand / 退出按钮,登录页不显示,新建任务挪到任务面板;父页面通过 `postMessage` 协议推 JWT(`zcbot-ready` / `zcbot-token` / `zcbot-401`)。完整对接手册见 `EMBED.md`(URL 参数 / 协议 / 后端 SSO 示例 / 父端前端示例 / 安全 / 故障兜底)。
### 路由表
全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `/docs`

View File

@ -620,6 +620,33 @@
border-radius: 0;
}
}
/* ───── embed mode (?embed=1&parent_origin=...) —— 父页面 iframe 嵌入 ─────
藏左上 brand / 用户名 / 退出登录;桌面整层 header 去掉(没 mobile-tabs 切换需求);
"+ 新建任务" 由 JS 移到任务面板 pane-head。 */
body.embed-mode #login { display: none !important; }
body.embed-mode header .brand,
body.embed-mode header #hd-who,
body.embed-mode header #hd-logout { display: none; }
@media (min-width: 641px) {
body.embed-mode header { display: none; }
}
#embed-waiting {
position: fixed; inset: 0; z-index: 90;
display: none; align-items: center; justify-content: center;
background: var(--bg); color: var(--muted); font-size: 13px;
flex-direction: column; gap: 12px; padding: 24px;
}
body.embed-mode.embed-waiting #embed-waiting { display: flex; }
body.embed-mode.embed-waiting #app { visibility: hidden; }
#embed-waiting .text { text-align: center; max-width: 80%; }
#embed-waiting .err { color: var(--accent); font-size: 12px; max-width: 80%; text-align: center; min-height: 1em; }
@keyframes embed-spin { to { transform: rotate(360deg); } }
#embed-waiting .spinner {
width: 24px; height: 24px; border-radius: 50%;
border: 2px solid var(--border); border-top-color: var(--accent);
animation: embed-spin .8s linear infinite;
}
</style>
</head>
<body>
@ -687,6 +714,13 @@
</div>
</div>
<!-- ───── embed-mode waiting overlay (token 握手中) ───── -->
<div id="embed-waiting">
<div class="spinner"></div>
<div class="text">等待登录…</div>
<div class="err"></div>
</div>
<!-- ───── main 3-pane ───── -->
<div id="app">
<header>
@ -854,6 +888,11 @@ const LS_UID = "zcbot.user_id";
const LS_NAME = "zcbot.name";
const LS_LEFT_COLLAPSED = "zcbot.left-collapsed";
// ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token
const _embedQS = new URLSearchParams(location.search);
const EMBED = _embedQS.get("embed") === "1";
const EMBED_PARENT_ORIGIN = (_embedQS.get("parent_origin") || "").trim();
const state = {
token: localStorage.getItem(LS_TOKEN) || "",
userId: localStorage.getItem(LS_UID) || "",
@ -1121,6 +1160,12 @@ function logout() {
localStorage.removeItem(LS_UID);
localStorage.removeItem(LS_NAME);
if (state.evtSrc) state.evtSrc.close();
if (EMBED) {
embedPostToParent({ type: "zcbot-401" });
embedShowWaiting("登录已失效,等待父页面重新签发…", false);
document.body.classList.add("embed-waiting");
return;
}
location.reload();
}
$("hd-logout").onclick = logout;
@ -3145,8 +3190,73 @@ $("nt-wd-new").addEventListener("input", () => {
updateWdHint();
});
// ───── embed mode ─────
function embedPostToParent(msg) {
if (!EMBED_PARENT_ORIGIN || window.parent === window) return;
try { window.parent.postMessage(msg, EMBED_PARENT_ORIGIN); } catch (e) {}
}
function embedShowWaiting(text, isErr) {
const w = $("embed-waiting");
if (!w) return;
if (isErr) {
w.querySelector(".text").textContent = "";
w.querySelector(".err").textContent = text || "";
w.querySelector(".spinner").style.display = "none";
} else {
w.querySelector(".text").textContent = text || "等待登录…";
w.querySelector(".err").textContent = "";
w.querySelector(".spinner").style.display = "";
}
}
function embedHandleMessage(e) {
if (e.origin !== EMBED_PARENT_ORIGIN) return;
const d = e.data || {};
if (d.type === "zcbot-token" && d.token && d.user_id) {
state.token = d.token;
state.userId = d.user_id;
state.userName = d.user_name || "";
localStorage.setItem(LS_TOKEN, state.token);
localStorage.setItem(LS_UID, state.userId);
if (state.userName) localStorage.setItem(LS_NAME, state.userName);
else localStorage.removeItem(LS_NAME);
document.body.classList.remove("embed-waiting");
if ($("app").classList.contains("ready")) {
// 401 后重签:重载列表,不重复 enterApp
loadTaskList();
} else {
enterApp();
}
}
}
function embedInit() {
if (!EMBED_PARENT_ORIGIN) {
document.body.classList.add("embed-mode", "embed-waiting");
embedShowWaiting("embed 模式缺少 parent_origin 参数 (URL 必须形如 ?embed=1&parent_origin=https://your-portal.com)", true);
return;
}
document.body.classList.add("embed-mode");
// 把 #hd-new 从 header 移到任务面板 pane-head(spacer 之后、filter-status 之前)
const newBtn = $("hd-new");
const head = document.querySelector("#pane-left .pane-head");
const ref = $("filter-status");
if (newBtn && head && ref) {
newBtn.classList.add("small");
head.insertBefore(newBtn, ref);
}
window.addEventListener("message", embedHandleMessage);
if (state.token) {
enterApp();
} else {
document.body.classList.add("embed-waiting");
embedShowWaiting("等待登录…", false);
}
embedPostToParent({ type: "zcbot-ready" });
}
// ───── boot ─────
if (state.token) {
if (EMBED) {
embedInit();
} else if (state.token) {
// 已有 token:试探一下,失败回登录页
enterApp();
} else {