feat(web): 移动端显示修复 + 对外品牌名 ZCBOT_BRAND_NAME(bump 0.45.0)

移动端:
- 浮层菜单(⋯ 任务菜单 / ⚙ 媒体模型弹层)原只右对齐触发按钮,手机上触发钮
  靠左时菜单冲出屏幕左缘盖住正文;改为放不下时切左对齐 + 双向钳视口内
- 手机断点:菜单项加大触控目标、模型下拉限宽 48vw、header 品牌名单行省略
- 「管理」入口挪到 任务/对话/文件 tabs 行右侧小按钮(CSS order 实现,
  embed 与普通模式同款,embed 专属覆盖规则随之删除)

品牌名:
- 新 env ZCBOT_BRAND_NAME(默认「总院科研辅助助手」),zcbot 只是内部代号
- /healthz 返 brand 字段,前端 boot 拉取覆盖页面标题/顶栏/登录卡
- 微信/企微测试推送、OAuth 提示页、绑定页等用户可见文案改品牌名或中性「助手」

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-07 10:44:26 +08:00
parent b0a8802d35
commit 7f77e5aca9
9 changed files with 78 additions and 30 deletions

View File

@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-07 / 0.45.0**:移动端显示修复 + 对外品牌名 env——① 浮层菜单(⋯ 任务菜单 `dom.js`、⚙ 媒体模型弹层 `chat.js`)原只做右对齐,手机上触发钮靠左时菜单冲出屏幕左缘盖住正文,改成"右对齐放不下→左对齐 + 双向钳视口";手机断点补:菜单项加大触控目标、模型下拉限宽 48vw、「管理」入口统一挪到 任务/对话/文件 tabs 行右侧小按钮(embed 与普通模式同款,靠 CSS order 实现不动 DOM)、header 品牌名单行省略。② 新 env `ZCBOT_BRAND_NAME`(默认「总院科研辅助助手」,zcbot 只是内部代号):`/healthz` 返 brand 字段,前端 boot 拉取覆盖页面标题/顶栏/登录卡(静态页默认值也已改为默认品牌);微信/企微测试推送、OAuth 提示页、绑定页等用户可见文案全部改用品牌名或中性"助手",不再露 zcbot。RUN.md env 段同步。
- **07-07 / 0.44.0**:企业微信应用主页免登——企微后台「应用主页」填 `/v1/wecom/entry`,成员点开应用即静默授权(snsapi_base)登进 embed 控制台。后端两端点(entry 302 授权、callback code→userid→反查绑定→签 JWT→fragment 带 token 302 进 `dev.html?embed=1&wecom=1`),UA 分支让外部浏览器退 wwlogin 扫码;前端 wecom 变体(fragment 吃 token 即清 hash,401 回 entry 静默重签,不需要 parent_origin);未绑定成员提示先在控制台绑定(不自动建号,避免重复账号)。EMBED.md §9 + RUN.md 企微段同步。 - **07-07 / 0.44.0**:企业微信应用主页免登——企微后台「应用主页」填 `/v1/wecom/entry`,成员点开应用即静默授权(snsapi_base)登进 embed 控制台。后端两端点(entry 302 授权、callback code→userid→反查绑定→签 JWT→fragment 带 token 302 进 `dev.html?embed=1&wecom=1`),UA 分支让外部浏览器退 wwlogin 扫码;前端 wecom 变体(fragment 吃 token 即清 hash,401 回 entry 静默重签,不需要 parent_origin);未绑定成员提示先在控制台绑定(不自动建号,避免重复账号)。EMBED.md §9 + RUN.md 企微段同步。
- **07-07 / 0.43.1**:跳秒指示补两处空窗——① 刷新/重连接管后 phaseSince 随页面内存丢失,退回静态"思考中"不跳秒(恰是用户因疑似卡死而刷新的场景),重建直播卡时立即按"思考中"起跳,下一个阶段事件校正;② 发消息 POST 返回即起跳,覆盖 build_agent + 首轮 TTFT(原先要等 llm_start 才开始计时)。重连后秒数从重连时刻起算(非真实累计),可接受。 - **07-07 / 0.43.1**:跳秒指示补两处空窗——① 刷新/重连接管后 phaseSince 随页面内存丢失,退回静态"思考中"不跳秒(恰是用户因疑似卡死而刷新的场景),重建直播卡时立即按"思考中"起跳,下一个阶段事件校正;② 发消息 POST 返回即起跳,覆盖 build_agent + 首轮 TTFT(原先要等 llm_start 才开始计时)。重连后秒数从重连时刻起算(非真实累计),可接受。
- **07-07 / 0.43.0**:run 长耗时可视化——修"前端一直静止显示思考中被当成卡死":① 新增 SSE `reasoning{delta}` 事件(loop 提取 `delta.reasoning_content`,thinking 模型分钟级推理原先整段丢弃、前端零反馈),前端渲染灰色折叠"思考过程"卡,历史消息里持久化的 reasoning_content 同款渲染;② 前端活跃状态指示:TTFT/推理期占位段每秒跳"思考中/深度思考中 · Ns"(CSS attr(data-status)),工具卡运行中加 spinner + 跳秒、结果到达定格为执行耗时;每轮 llm_start 重建占位段,工具轮之后不再空窗。 - **07-07 / 0.43.0**:run 长耗时可视化——修"前端一直静止显示思考中被当成卡死":① 新增 SSE `reasoning{delta}` 事件(loop 提取 `delta.reasoning_content`,thinking 模型分钟级推理原先整段丢弃、前端零反馈),前端渲染灰色折叠"思考过程"卡,历史消息里持久化的 reasoning_content 同款渲染;② 前端活跃状态指示:TTFT/推理期占位段每秒跳"思考中/深度思考中 · Ns"(CSS attr(data-status)),工具卡运行中加 spinner + 跳秒、结果到达定格为执行耗时;每轮 llm_start 重建占位段,工具轮之后不再空窗。

3
RUN.md
View File

@ -85,6 +85,9 @@
# 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。 # 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。
# WECOM_CALLBACK_TOKEN=... # 接收消息 Token(企微后台生成) # WECOM_CALLBACK_TOKEN=... # 接收消息 Token(企微后台生成)
# WECOM_CALLBACK_AESKEY=... # EncodingAESKey(43 字符,企微后台生成) # WECOM_CALLBACK_AESKEY=... # EncodingAESKey(43 字符,企微后台生成)
# 对外品牌名:zcbot 是内部代号,所有用户可见文案(页面标题/顶栏/登录卡/微信·企微推送与
# 提示页)统一用品牌名。/healthz 返回 brand 字段,前端 boot 拉取覆盖静态页默认值。
# ZCBOT_BRAND_NAME=总院科研辅助助手 # 可选,默认即此值
``` ```
> litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...` > litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`、`segno`、`cryptography`)。 - **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`、`segno`、`cryptography`)。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.44.0" __version__ = "0.45.0"

View File

@ -69,6 +69,10 @@ from .static_files import NoCacheStaticFiles
# 读法)③ /healthz 返回,蓝绿切换时验证 nginx 已指到新实例。 # 读法)③ /healthz 返回,蓝绿切换时验证 nginx 已指到新实例。
INSTANCE = os.getenv("ZCBOT_INSTANCE", "").strip() INSTANCE = os.getenv("ZCBOT_INSTANCE", "").strip()
# 对外品牌名:zcbot 只是内部项目代号,所有用户可见文案(页面标题 / 推送消息 / OAuth
# 提示页)统一用这个。/healthz 也返回,前端 boot 时拉取覆盖静态页里的默认值。
BRAND = os.getenv("ZCBOT_BRAND_NAME", "").strip() or "总院科研辅助助手"
STATUS_FILTERS = ("active", "completed", "abandoned") STATUS_FILTERS = ("active", "completed", "abandoned")
STATUS_WRITABLE = ("completed", "abandoned") # web 不让从 web 端切回 active(走 CLI) STATUS_WRITABLE = ("completed", "abandoned") # web 不让从 web 端切回 active(走 CLI)
# 渠道镜像 task 的 channel 取值(每用户每渠道一条常驻只读对话):从普通任务列表排除, # 渠道镜像 task 的 channel 取值(每用户每渠道一条常驻只读对话):从普通任务列表排除,
@ -1300,7 +1304,9 @@ def create_app() -> FastAPI:
@app.get("/healthz", tags=["misc"]) @app.get("/healthz", tags=["misc"])
def healthz(): def healthz():
# instance:蓝绿部署时验证打到的是哪个实例(blue/green);单实例为 null # instance:蓝绿部署时验证打到的是哪个实例(blue/green);单实例为 null
return {"status": "ok", "version": __version__, "instance": INSTANCE or None} # brand:对外品牌名(ZCBOT_BRAND_NAME),前端 boot 拉取覆盖页面标题 / 顶栏 / 登录卡
return {"status": "ok", "version": __version__, "instance": INSTANCE or None,
"brand": BRAND}
@app.get("/WW_verify_{token}.txt", include_in_schema=False) @app.get("/WW_verify_{token}.txt", include_in_schema=False)
def wecom_domain_verify(token: str): def wecom_domain_verify(token: str):
@ -1391,7 +1397,7 @@ def create_app() -> FastAPI:
"""自检:给已绑用户推一条测试消息(需用户近 24h 在微信开口过)。""" """自检:给已绑用户推一条测试消息(需用户近 24h 在微信开口过)。"""
from core.wechat import service as _wx from core.wechat import service as _wx
res = await asyncio.to_thread( res = await asyncio.to_thread(
_wx.push_clawbot, user_id, "zcbot 测试消息:绑定成功,这条来自你的 zcbot" _wx.push_clawbot, user_id, f"{BRAND} 测试消息:微信绑定成功,推送通道正常"
) )
return {"ok": res.ok, "reason": res.reason} return {"ok": res.ok, "reason": res.reason}
@ -1404,7 +1410,7 @@ def create_app() -> FastAPI:
return HTMLResponse( return HTMLResponse(
f"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>" f"<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>"
f"<div style='font:16px system-ui;max-width:420px;margin:80px auto;text-align:center;color:{color}'>" f"<div style='font:16px system-ui;max-width:420px;margin:80px auto;text-align:center;color:{color}'>"
f"{msg}</div><div style='text-align:center;color:#888;font:13px system-ui'>可关闭本页返回 zcbot</div>" f"{msg}</div><div style='text-align:center;color:#888;font:13px system-ui'>可关闭本页返回{BRAND}</div>"
) )
@app.get("/v1/wecom/oauth/url", tags=["wecom"]) @app.get("/v1/wecom/oauth/url", tags=["wecom"])
@ -1428,7 +1434,7 @@ def create_app() -> FastAPI:
uid = wecom.verify_state(state) uid = wecom.verify_state(state)
if not uid or uid == _WECOM_LOGIN_STATE: if not uid or uid == _WECOM_LOGIN_STATE:
return _wecom_page("绑定失败:授权已过期或无效,请回 zcbot 重试。", False) return _wecom_page(f"绑定失败:授权已过期或无效,请回{BRAND}重试。", False)
if not code: if not code:
return _wecom_page("绑定失败:未拿到授权码。", False) return _wecom_page("绑定失败:未拿到授权码。", False)
try: try:
@ -1487,7 +1493,7 @@ def create_app() -> FastAPI:
uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wecom_userid) uid = await asyncio.to_thread(_wx.get_user_by_wecom_userid, wecom_userid)
if uid is None: if uid is None:
return _wecom_page( return _wecom_page(
"该企业微信还未绑定 zcbot 账号:请先在电脑端 zcbot 控制台登录," f"该企业微信还未绑定{BRAND}账号:请先在电脑端{BRAND}控制台登录,"
"点左栏「企业微信」渠道卡片完成绑定,再回来打开本应用。", False) "点左栏「企业微信」渠道卡片完成绑定,再回来打开本应用。", False)
token, _exp = mint_token(auth_cfg, uid) token, _exp = mint_token(auth_cfg, uid)
# JWT 是 base64url + '.',UUID 是 hex + '-',都无需再转义,fragment 安全 # JWT 是 base64url + '.',UUID 是 hex + '-',都无需再转义,fragment 安全
@ -1529,7 +1535,7 @@ def create_app() -> FastAPI:
"""自检:给已绑用户推一条企业微信测试消息(无 24h 窗口约束)。""" """自检:给已绑用户推一条企业微信测试消息(无 24h 窗口约束)。"""
from core.wechat import service as _wx from core.wechat import service as _wx
res = await asyncio.to_thread( res = await asyncio.to_thread(
_wx.push_wecom, user_id, "zcbot 测试消息:企业微信绑定成功,这条来自你的 zcbot" _wx.push_wecom, user_id, f"{BRAND} 测试消息:企业微信绑定成功,推送通道正常"
) )
return {"ok": res.ok, "reason": res.reason} return {"ok": res.ok, "reason": res.reason}

View File

@ -2,7 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>zcbot 控制台</title> <title>总院科研辅助助手</title>
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23c0392b'/%3E%3Cstop offset='1' stop-color='%238e2a20'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='100' height='100' rx='22' fill='url(%23g)'/%3E%3Ctext x='50' y='54' font-family='Arial,Helvetica,sans-serif' font-size='62' font-weight='700' fill='%23fff' text-anchor='middle' dominant-baseline='central'%3EZ%3C/text%3E%3C/svg%3E" /> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23c0392b'/%3E%3Cstop offset='1' stop-color='%238e2a20'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='100' height='100' rx='22' fill='url(%23g)'/%3E%3Ctext x='50' y='54' font-family='Arial,Helvetica,sans-serif' font-size='62' font-weight='700' fill='%23fff' text-anchor='middle' dominant-baseline='central'%3EZ%3C/text%3E%3C/svg%3E" />
@ -1154,14 +1154,25 @@
/* 折叠按钮在手机不可见 */ /* 折叠按钮在手机不可见 */
#pane-toggle-left, #pane-toggle-right, .splitter { display: none !important; } #pane-toggle-left, #pane-toggle-right, .splitter { display: none !important; }
/* header 紧凑化 */ /* header 紧凑化;品牌名较长 → 标题单行省略,不挤掉右侧按钮 */
header { padding: 6px 10px; gap: 6px; flex-wrap: wrap; } header { padding: 6px 10px; gap: 6px; flex-wrap: wrap; }
header .who { display: none; } header .who { display: none; }
header .title { font-size: 14px; } header .brand { min-width: 0; flex-shrink: 1; }
/* tab 按钮:整行铺底,order:99 让它换行到 header 第二行 */ header .title { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mobile-tabs { display: flex; order: 99; flex-basis: 100%; } /* tab 行:order 让 tabs 换行到 header 末行,「管理」(order 更大)跟到 tabs 右侧
成小按钮 —— 首行只留 brand + 改密码/退出。flex-basis 70% 是换行开关:
首行剩余空间塞不下 70% → tabs 必换行;换行后 70% + 管理(~15%)同行放得下 */
.mobile-tabs { display: flex; order: 99; flex: 1 1 70%; }
.mobile-tabs button { flex: 1; } .mobile-tabs button { flex: 1; }
#hd-admin { order: 100; flex-shrink: 0; padding: 5px 10px; font-size: 12px; }
#hd-chpw, #hd-logout { padding: 4px 8px; font-size: 12px; } #hd-chpw, #hd-logout { padding: 4px 8px; font-size: 12px; }
/* embed(企业微信免登)时 brand/改密码/退出已藏 → tabs +「管理」自然收成单行 */
body.embed-mode header .spacer { display: none; }
/* 浮层菜单:加大触控目标(桌面 6px 高度在手机上难点准) */
#floating-menu { min-width: 160px; max-width: calc(100vw - 8px); }
#media-model-pop { max-width: calc(100vw - 8px); }
.dd-item { padding: 10px 16px; font-size: 14px; }
/* iOS 防 focus 自动缩放:input/textarea 字号 ≥ 16 */ /* iOS 防 focus 自动缩放:input/textarea 字号 ≥ 16 */
textarea, textarea,
@ -1178,9 +1189,10 @@
#chat-meta .desc { #chat-meta .desc {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60vw;
} }
/* 模型下拉:手机端 label 文字 → emoji */ /* 模型下拉:手机端 label 文字 → emoji;select 限宽防长模型名撑破 meta 行 */
.mdl-text { display: none; } .mdl-text { display: none; }
.mdl-icon { display: inline; } .mdl-icon { display: inline; }
#chat-meta select { max-width: 48vw; }
/* 对话面板顶栏:藏 "对话" label / spacer,5 个按钮自然换行,文字 nowrap 防内部断行 */ /* 对话面板顶栏:藏 "对话" label / spacer,5 个按钮自然换行,文字 nowrap 防内部断行 */
#pane-mid > .pane-head { flex-wrap: wrap; gap: 6px; padding: 6px 8px; } #pane-mid > .pane-head { flex-wrap: wrap; gap: 6px; padding: 6px 8px; }
#pane-mid > .pane-head > .label, #pane-mid > .pane-head > .label,
@ -1268,7 +1280,7 @@
<div class="card"> <div class="card">
<div class="brand"> <div class="brand">
<div class="logo">Z</div> <div class="logo">Z</div>
<div class="name">zcbot</div> <div class="name">总院科研辅助助手</div>
</div> </div>
<h2>登录到控制台</h2> <h2>登录到控制台</h2>
<div class="tabs"> <div class="tabs">
@ -1394,7 +1406,7 @@
<button id="wx-close" class="sk-x" title="关闭"></button> <button id="wx-close" class="sk-x" title="关闭"></button>
</h3> </h3>
<div id="wx-body"> <div id="wx-body">
<div class="muted" style="font-size:12px;margin-bottom:12px;">绑定后可在个人微信「微信 ClawBot」里直接和 zcbot 对话;近 24h 有互动时,定时简报 / 结果也能主动推给你。</div> <div class="muted" style="font-size:12px;margin-bottom:12px;">绑定后可在个人微信「微信 ClawBot」里直接和助手对话;近 24h 有互动时,定时简报 / 结果也能主动推给你。</div>
<div id="wx-state" class="wx-status wait">加载中…</div> <div id="wx-state" class="wx-status wait">加载中…</div>
<div class="wx-acts"> <div class="wx-acts">
<button id="wx-bind" class="small">绑定 / 重新绑定</button> <button id="wx-bind" class="small">绑定 / 重新绑定</button>
@ -1411,7 +1423,7 @@
</ul> </ul>
<hr style="border:none;border-top:1px solid var(--line,#d0d7de);margin:18px 0;"> <hr style="border:none;border-top:1px solid var(--line,#d0d7de);margin:18px 0;">
<div style="font-weight:600;font-size:14px;margin-bottom:6px;">企业微信(推送 + 对话)</div> <div style="font-weight:600;font-size:14px;margin-bottom:6px;">企业微信(推送 + 对话)</div>
<div class="muted" style="font-size:12px;margin-bottom:10px;">无条件主动推(不挑活跃度、无 24h 窗口),适合定时简报必达。扫码授权一次拿成员身份。管理员在应用配「接收消息」回调后,还可在企业微信里直接和 zcbot 对话。</div> <div class="muted" style="font-size:12px;margin-bottom:10px;">无条件主动推(不挑活跃度、无 24h 窗口),适合定时简报必达。扫码授权一次拿成员身份。管理员在应用配「接收消息」回调后,还可在企业微信里直接和助手对话。</div>
<div id="wc-state" class="wx-status wait">加载中…</div> <div id="wc-state" class="wx-status wait">加载中…</div>
<div class="wx-acts"> <div class="wx-acts">
<button id="wc-bind" class="small">扫码绑定</button> <button id="wc-bind" class="small">扫码绑定</button>
@ -1457,7 +1469,7 @@
<header> <header>
<div class="brand"> <div class="brand">
<div class="logo">Z</div> <div class="logo">Z</div>
<div class="title">zcbot</div> <div class="title">总院科研辅助助手</div>
</div> </div>
<div class="who" id="hd-who"></div> <div class="who" id="hd-who"></div>
<div class="spacer"></div> <div class="spacer"></div>
@ -1574,7 +1586,7 @@
<div id="file-crumbs" class="crumbs muted">加载中…</div> <div id="file-crumbs" class="crumbs muted">加载中…</div>
<div id="file-list"></div> <div id="file-list"></div>
<div id="storage-foot" class="storage-foot" title=""> <div id="storage-foot" class="storage-foot" title="">
<span id="app-version" title="zcbot 版本"></span> <span id="app-version" title="版本"></span>
<span class="lbl">存储</span> <span class="lbl">存储</span>
<span class="bar"><i id="storage-foot-bar"></i></span> <span class="bar"><i id="storage-foot-bar"></i></span>
<span class="txt" id="storage-foot-txt"></span> <span class="txt" id="storage-foot-txt"></span>

View File

@ -556,8 +556,15 @@ function openMediaModelPop(btn) {
pop.style.visibility = "hidden"; pop.style.visibility = "hidden";
pop.classList.add("show"); pop.classList.add("show");
const ph = pop.offsetHeight || 100; const ph = pop.offsetHeight || 100;
pop.style.right = Math.max(4, window.innerWidth - rect.right) + "px"; const pw = pop.offsetWidth || 220;
pop.style.left = "auto"; // 同 showMenu:触发按钮靠左时右对齐会冲出左边缘 → 改左对齐并钳在视口内
if (rect.right - pw < 4) {
pop.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - pw - 4)) + "px";
pop.style.right = "auto";
} else {
pop.style.right = Math.max(4, window.innerWidth - rect.right) + "px";
pop.style.left = "auto";
}
pop.style.top = (rect.bottom + ph + 8 > window.innerHeight) pop.style.top = (rect.bottom + ph + 8 > window.innerHeight)
? Math.max(4, rect.top - ph - 4) + "px" ? Math.max(4, rect.top - ph - 4) + "px"
: (rect.bottom + 4) + "px"; : (rect.bottom + 4) + "px";

View File

@ -28,8 +28,16 @@ export function showMenu(triggerEl, items) {
menu.style.visibility = "hidden"; menu.style.visibility = "hidden";
menu.classList.add("show"); menu.classList.add("show");
const mh = menu.offsetHeight || 120; const mh = menu.offsetHeight || 120;
menu.style.right = Math.max(4, window.innerWidth - rect.right) + "px"; const mw = menu.offsetWidth || 132;
menu.style.left = "auto"; // 水平:默认右缘对齐触发按钮;触发按钮靠左(手机上 ⋯ 在顶栏左侧)会把菜单推出
// 左边缘 → 改成左缘对齐,并双向钳在视口内
if (rect.right - mw < 4) {
menu.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - mw - 4)) + "px";
menu.style.right = "auto";
} else {
menu.style.right = Math.max(4, window.innerWidth - rect.right) + "px";
menu.style.left = "auto";
}
if (rect.bottom + mh + 8 > window.innerHeight) { if (rect.bottom + mh + 8 > window.innerHeight) {
menu.style.top = Math.max(4, rect.top - mh - 4) + "px"; menu.style.top = Math.max(4, rect.top - mh - 4) + "px";
} else { } else {

View File

@ -79,15 +79,22 @@ async function loadStorage() {
} }
// 版本号:/healthz 是 auth 豁免接口,embed / 未登录都拿得到 → boot 时无条件拉一次填进左栏底部。 // 版本号 + 品牌名:/healthz 是 auth 豁免接口,embed / 未登录都拿得到 → boot 时无条件拉一次。
// 失败静默(版本号是装饰性信息,不该因网络抖动报错)。 // 版本号填左栏底部;品牌名(ZCBOT_BRAND_NAME,默认「总院科研辅助助手」)覆盖页面标题 /
// 顶栏 / 登录卡 —— 静态 HTML 里写的是默认品牌,env 改名时这里统一刷。失败静默。
async function loadVersion() { async function loadVersion() {
const el = $("app-version");
if (!el) return;
try { try {
const r = await fetch("/healthz"); const r = await fetch("/healthz");
const d = await r.json(); const d = await r.json();
if (d && d.version) el.textContent = "v" + d.version; const el = $("app-version");
if (el && d && d.version) el.textContent = "v" + d.version;
if (d && d.brand) {
document.title = d.brand;
const hd = document.querySelector("header .brand .title");
if (hd) hd.textContent = d.brand;
const li = document.querySelector("#login .brand .name");
if (li) li.textContent = d.brand;
}
} catch (e) {} } catch (e) {}
} }

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>绑定微信 · zcbot</title> <title>绑定微信 · 总院科研辅助助手</title>
<style> <style>
:root { --fg:#1f2328; --muted:#656d76; --line:#d0d7de; --accent:#a01e1e; --bg:#f6f8fa; } :root { --fg:#1f2328; --muted:#656d76; --line:#d0d7de; --accent:#a01e1e; --bg:#f6f8fa; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@ -33,7 +33,7 @@
<body> <body>
<div class="wrap"><div class="card"> <div class="wrap"><div class="card">
<h1>绑定微信(ClawBot)</h1> <h1>绑定微信(ClawBot)</h1>
<p class="sub">绑定后可在个人微信「微信 ClawBot」里直接和 zcbot 对话;近 24h 内有互动时,定时简报 / 结果也能主动推给你。</p> <p class="sub">绑定后可在个人微信「微信 ClawBot」里直接和助手对话;近 24h 内有互动时,定时简报 / 结果也能主动推给你。</p>
<div id="state" class="status wait">加载中…</div> <div id="state" class="status wait">加载中…</div>
@ -76,7 +76,7 @@ function setState(cls, msg) {
} }
async function refresh() { async function refresh() {
if (!token()) { setState("err", "未登录:请先在 zcbot 主页登录,再打开本页。"); return; } if (!token()) { setState("err", "未登录:请先在控制台登录,再打开本页。"); return; }
try { try {
const b = await api("/v1/wechat/bind"); const b = await api("/v1/wechat/bind");
if (b.bound) { if (b.bound) {
@ -147,6 +147,10 @@ $("btn-test").onclick = async () => {
}; };
refresh(); refresh();
// 品牌名(ZCBOT_BRAND_NAME)刷页面标题;/healthz auth 豁免,失败静默保留默认
fetch("/healthz").then(r => r.json())
.then(d => { if (d && d.brand) document.title = "绑定微信 · " + d.brand; })
.catch(() => {});
</script> </script>
</body> </body>
</html> </html>