ui: 手机端对话面板顶栏 + chat-meta 紧凑化 (5 按钮换行不竖排 + 模型 label 转 emoji)

- #pane-mid .pane-head 加 flex-wrap + 按钮 white-space:nowrap,
  消除 "完\n成" / "废\n弃" 这种内部断行;藏 "对话" label / spacer
- #chat-meta 手机端 gap 8→6px,藏 .tid,.desc 限宽 60vw ellipsis
- 三个模型下拉 label 加 .mdl-text / .mdl-icon 双 span,
  桌面文字 / 手机 emoji (💬/🖼/🎬) CSS 切换,桌面零变化

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-22 16:00:52 +08:00
parent 72ae0ded0a
commit ae6b8630e2
2 changed files with 20 additions and 5 deletions

View File

@ -23,6 +23,7 @@
### 2026-05-22
- **dev SPA 手机端对话面板顶栏 + chat-meta 紧凑化**:`web/static/dev.html` 手机段(≤640px)对 `#pane-mid > .pane-head``flex-wrap: wrap` + 按钮 `white-space: nowrap`,消除 5 个按钮(导出对话记录/清空对话/完成/废弃/删除)在 320-360px 视口被挤压后"完\n成"这种逐字竖排;同时藏掉 `.label`("对话",mobile-tabs 已亮态指示)和 `.spacer`(flex-wrap 下 spacer 会强制后续按钮换行影响视觉一致)。`#chat-meta` 同段把 `gap` 8px → 6px、藏 `.tid`(8 位 UUID 前缀手机用户用不上)、`.desc` 加 `max-width:60vw` ellipsis(避免长 description 独占一行);三个 model 下拉 label "模型/生图/生视频" 用 `.mdl-text / .mdl-icon` 双 span 渲染,桌面显文字 + 手机显 emoji(💬🖼🎬)—— `renderModelDropdown / renderImageModelDropdown / renderVideoModelDropdown` 三处统一。改动只在手机视口生效,桌面零变化。否决:(a) 折叠成 ⋯ 浮层菜单(用户拒,多一次点击);(b) 改图标按钮(5 个动作含义不直观需 tooltip);(c) 把 emoji 应用到桌面(无解决问题且改动用户已习惯的桌面态)。
- **embed 模式接受 `task_id` URL 参数定位 task**:`web/static/dev.html` 解析 `?task_id=<uuid>` 并在首次签发 token 后调 `selectTask(task_id)` 自动加载该 task 的消息列表;两条进入路径都覆盖 —— ① `embedHandleMessage` 收到 `zcbot-token` 首次 `enterApp()` 之后(无缓存 token 走父端握手);② `embedInit` 启动时 localStorage 已有 token 直接 `enterApp()` 之后。`_embedInitialTaskHandled` once 标记保证只生效一次 —— 401 重签时不重置选择(尊重用户中间 UI 切过别的 task),后续切 task 走 UI 用户主导。task_id 错或不属于当前 user → `selectTask` 走原有 401/404 错误分支(chat 区显"加载失败:…")。`EMBED.md` URL 参数表新增 `task_id` 行 + 故障兜底表加一行"带 task_id 没自动定位"。否决:(a) postMessage 协议加 `zcbot-task` 让父端任意时刻切 task —— 当前需求只到"打开 iframe 时定位",加协议增维护面;父端要切换可重载 iframe 走 URL 参数同一路径;(b) 把 task_id 塞进 `zcbot-token` payload —— token 是身份,task 是导航状态,语义混耦;(c) 同时支持 `?msg_id=` 滚动到特定消息 —— 用户没要求,加 anchor 还要改 `loadMessages` 渲染后滚动逻辑,YAGNI。
- **媒体生成每账号每日配额(yaml 可配,默 20 图 / 5 视频)**:`config/agent.yaml` 加 `quotas` 段(`images_per_day: 20` / `videos_per_day: 5`),`core/storage/usage.py::check_daily_quota(user_id, kind, limit)` SELECT COUNT FROM usage_events WHERE user_id=? AND kind IN(image/video) AND created_at >= 本地今日 00:00,`limit<=0` 短路不查 DB。`SeedreamTool` / `SeedanceTool` ctor 新增 `daily_limit` 形参由 `agent_builder` 从 yaml 透传,`execute()` 起手 if 超额返 `[Error] 已达每日 X 生成上限(N/M),次日 00:00 重置` 不调远端不烧钱。tool 返串会进 LLM 上下文 → 模型据此对用户解释,所以**只暴露已用/上限 + 重置时间**,不写 `config/agent.yaml::quotas.X` 这种 yaml 路径(否则 LLM 倾向原文复述,SaaS 场景泄漏内部 schema 给外部用户;管理员要调改自己读代码/yaml 找,30 秒事)。**跨 task 跨 variant 全口径合计** —— 配额是账号级与具体 variant 无关(seedream + 未来的 seedream_pro 共享同一 20 张池)。失败任务不计 —— record_*_usage 只在成功+下载完才落库,失败 retry 不烧配额符合直觉。并发 race(同 user 跨 task 两次 check 同时过)可接受 —— 软上限非计费 hard cap,日级偶尔多 1 张不破坏保护意图,不加事务锁。否决:(a) env 变量(`ZCBOT_IMAGES_PER_DAY` 等)—— 配额是业务策略不是部署秘密 / 环境差异,跟现有 yaml 类参数(默认 size / 价格 / 超时)分工一致,且 yaml 带注释 + 多值组合扩展自然(未来加 audio_per_day);(b) AgentLoop 集中 pre-flight —— 给 loop 加配额映射反而散,tool 层自检每次只多一行 SQL 亚毫秒,符合"工具按原子操作切分";(c) 滑动 24h 窗口 —— 用户直觉是"今天用完了明天再来"的日历日,服务器本地 00:00 重置语义更顺;(d) tool 返串里贴 yaml 路径给管理员看 —— LLM 会向用户复述,泄漏内部 schema。
- **"+ 新建任务"按钮从 header 挪到任务面板 + 改通栏单独一行**:`web/static/dev.html` `#hd-new` 节点直接在 HTML 里挪到 `#pane-left`,放在第一行 `.pane-head`(任务标题 + 计数 + filter + 刷新 + 折叠)之下、搜索行之上的独立 `.pane-head` 行,`flex:1` 撑满整行(primary 红底通栏 CTA)。原本塞在第一行 spacer 之后,但 pane 320px 宽度下"+ 新建任务"中文五字会被挤断行,通栏解决根因。语义更贴(新建任务 = 任务面板的动作);顶栏减负只剩身份区(brand / 用户名 / 退出登录);两种模式 DOM 一致,顺手删了 `embedInit` 里动态 `insertBefore` 那段 + `@media phone``#hd-new` 紧凑覆盖(通栏环境不需要缩字号)。桌面 / 平板折叠态被 `#pane-left > * { display: none }` 自动藏掉,无需额外覆盖。

View File

@ -295,6 +295,8 @@
font-size: 12px; color: var(--muted); display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
#chat-meta .tid { font-family: var(--mono); color: var(--text); }
#chat-meta .spacer { flex: 1; }
/* 模型下拉标签:桌面文字 / 手机 emoji 二选一(swap 在 @media 640px 内) */
.mdl-icon { display: none; }
/* 同 wd 并发软警告 banner — 非阻塞,只提示中间产物互覆风险 */
#wd-concurrent-warn { padding: 6px 12px; border-bottom: 1px solid #f0c36d;
background: #fff8e1; color: #6a4500; font-size: 12px; }
@ -602,7 +604,19 @@
/* chat / 文件 微调 */
.msg { max-width: 96%; }
#chat-meta { padding: 6px 10px; gap: 8px; font-size: 11px; }
#chat-meta { padding: 6px 10px; gap: 6px; font-size: 11px; }
#chat-meta .tid { display: none; }
#chat-meta .desc {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 60vw;
}
/* 模型下拉:手机端 label 文字 → emoji */
.mdl-text { display: none; }
.mdl-icon { display: inline; }
/* 对话面板顶栏:藏 "对话" label / spacer,5 个按钮自然换行,文字 nowrap 防内部断行 */
#pane-mid > .pane-head { flex-wrap: wrap; gap: 6px; padding: 6px 8px; }
#pane-mid > .pane-head > .label,
#pane-mid > .pane-head > .spacer { display: none; }
#pane-mid > .pane-head > button { white-space: nowrap; padding: 3px 8px; }
#chat-form { padding: 8px; }
.art-media img, .art-media video { max-width: 100%; }
.file-row { padding: 8px 12px; }
@ -1565,7 +1579,7 @@ function renderChatMeta() {
${wdBadge}
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span class="tid">${t.task_id.slice(0, 8)}</span>
${t.description ? `<span class="muted">${escapeHtml(t.description)}</span>` : ""}
${t.description ? `<span class="muted desc">${escapeHtml(t.description)}</span>` : ""}
<span class="spacer"></span>
${renderModelDropdown(t)}
${renderImageModelDropdown()}
@ -1596,7 +1610,7 @@ function renderModelDropdown(t) {
const opts = state.models.map(m =>
`<option value="${escapeHtml(m.profile)}" ${m.profile === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
return `<span class="muted small" style="display:inline-flex;align-items:center;gap:4px;">模型 <select id="chat-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="切换 task 模型(下条消息生效)">${opts}</select></span>`;
return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">模型</span><span class="mdl-icon" aria-hidden="true">💬</span><select id="chat-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="切换 task 模型(下条消息生效)">${opts}</select></span>`;
}
function renderImageModelDropdown() {
@ -1608,7 +1622,7 @@ function renderImageModelDropdown() {
const opts = state.imageModels.map(m =>
`<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
return `<span class="muted small" style="display:inline-flex;align-items:center;gap:4px;">生图 <select id="chat-image-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生图时使用的模型(本地选择,不入库)">${opts}</select></span>`;
return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">生图</span><span class="mdl-icon" aria-hidden="true">🖼</span><select id="chat-image-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生图时使用的模型(本地选择,不入库)">${opts}</select></span>`;
}
function onChangeImageModel(ev) {
@ -1625,7 +1639,7 @@ function renderVideoModelDropdown() {
const opts = state.videoModels.map(m =>
`<option value="${escapeHtml(m.variant)}" ${m.variant === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
return `<span class="muted small" style="display:inline-flex;align-items:center;gap:4px;">生视频 <select id="chat-video-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生视频时使用的模型(本地选择,不入库)">${opts}</select></span>`;
return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">生视频</span><span class="mdl-icon" aria-hidden="true">🎬</span><select id="chat-video-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="下一条消息触发生视频时使用的模型(本地选择,不入库)">${opts}</select></span>`;
}
function onChangeVideoModel(ev) {