ui: dev.html 手机自适应 (两档 @media + tab 单列切换)

平板 641-1024px 强制 rail (不写 localStorage,回桌面用户偏好仍生效);
手机 ≤640px 单列 + body.mv-{left,mid,right} 切换 + header tab 按钮换行铺底;
selectTask 自动切到对话视图,100dvh 解决 iOS 工具栏挤压,
input/textarea ≥16px 防 focus 缩放,4 modal 改 min(92vw,…) / file-preview 全屏化。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-22 09:39:42 +08:00
parent 7ff58c488e
commit 7d3a93fc1f
2 changed files with 122 additions and 1 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-05-22(豆包 Seedance 2.0 Fast 视频生成接入 + videogen skill) 最后更新:2026-05-22(dev SPA 手机自适应:tab 单列 + tablet rail 强制)
--- ---
@ -23,6 +23,7 @@
### 2026-05-22 ### 2026-05-22
- **dev SPA 手机自适应:两档断点 + tab 单列**:`web/static/dev.html` 加 `@media`,**平板段(641-1024px)**纯 CSS 强制 rail(grid `40px 1fr 260px` + 左 pane 子项 `display:none` + 留 toggle 按钮),不写 localStorage —— 回桌面用户原偏好仍生效。**手机段(≤640px)**单列布局 grid `1fr` + `grid-template-areas:"head" "main"`,三 pane 都 `grid-area: main` 且默认 `display:none`,新加 `body.mv-{left,mid,right}` 控制当前可见 pane;header 加 `.mobile-tabs`(任务/对话/文件)桌面 `display:none`,手机段 `order:99 + flex-basis:100%` 换行铺底;`selectTask` 末加 `if (mqPhone.matches) setMobileView("mv-mid")` 选中任务自动切对话。`applyMobileMode()` 监听 `matchMedia("(max-width: 640px)")`,进手机时清 DOM 上的 `left-collapsed` class(localStorage 不动),回桌面再 `applyLeftCollapsed(读 localStorage)` 恢复。`100vh → 100dvh` 解决 iOS Safari 工具栏挤压;`textarea / input` 强制 ≥16px 防 focus 双指缩放;4 个 modal 卡片宽度从固定 px 改 `min(92vw, …)`,file-preview 改 `100vw × 100dvh` 全屏化。`#pane-toggle-left { display:none !important; }` 手机不允许折叠避免与 tab 切换语义冲突。否决:(a) 抽屉式(要写遮罩+手势 JS,自用工具不划算);(b) 只缩字号不改布局(三列在 360px 屏字段严重截断)。
- **豆包 Seedance 2.0 Fast 视频生成接入(文生视频)+ videogen skill**:`config/media/doubao.yaml` 展开 video 段(`seedance_2_fast`:¥37/Mtok 文生 / ¥22/Mtok 图生,实测档位 480p 5s ¥1.86 / 720p 5s ¥4.00 — 由 token 公式 `(in+out)×W×H×fps/1024` 反推校验通过);`tools/seedance.py` 走 ark POST `/contents/generations/tasks` → 5s 间隔轮询 → succeeded 后 download mp4 + .meta.json 落 `<wd>/videos/<ts>-<rand>.mp4`,失败/cancel 不计费;`core/storage/usage.py::record_video_usage` 多态 units snapshot(resolution/duration/ratio/fps/tokens/单价);`build_agent` 加 `video_variant` + `cancel_check` 形参 — cancel_check 必须在 build 阶段传(SeedanceTool ctor 持有用于轮询期间响应停止按钮,改了原"build 后赋 agent.cancel_check"的延迟绑定,web 入口同步迁移);`web/app.py` 加 `_list_video_variants` / `_resolve_video_model` / `GET /v1/video_models` / `MessageRequest.video_model` / `OptimizePromptRequest.video_model`;`dev.html` 顶栏第三下拉 + `state.videoModels/videoModel` + 发消息一起 POST。前端 chip / inline `<video>` / `extractMediaBanner` / `_categorize` 在前期工作里已为 seedance 留好脚手架,几乎不动。`skills/videogen/SKILL.md` 六维诊断把 imagegen 的"光线"换成"运动+镜头"两维(运动必填,否则应该走 seedream 而非 seedance —— 差 18 倍价钱);BLOCKING 门槛比 imagegen 更严(¥4 vs ¥0.22)且要等 30-90s,贴 prompt+参数+预计花费+预计等待四件套等明确确认。`general_v1.md` 加 seedance 触发指引(平行 seedream)。phase 1 仅 t2v,**不支持 i2v**(skill 明示告诉用户)。fast 上限 720p,1080p+ 留给 pro variant(yaml 当前未配)。否决:(a) progress 事件流化(需要给 tool 加 sink 注入,phase 1 用 `run_status=running` 够了);(b) 远端 cgt-task DELETE(Volcengine 无明确 API,best-effort 不动);(c) i2v phase 1 拉进来(要图片转 URL + UI 选已有图,延后)。 - **豆包 Seedance 2.0 Fast 视频生成接入(文生视频)+ videogen skill**:`config/media/doubao.yaml` 展开 video 段(`seedance_2_fast`:¥37/Mtok 文生 / ¥22/Mtok 图生,实测档位 480p 5s ¥1.86 / 720p 5s ¥4.00 — 由 token 公式 `(in+out)×W×H×fps/1024` 反推校验通过);`tools/seedance.py` 走 ark POST `/contents/generations/tasks` → 5s 间隔轮询 → succeeded 后 download mp4 + .meta.json 落 `<wd>/videos/<ts>-<rand>.mp4`,失败/cancel 不计费;`core/storage/usage.py::record_video_usage` 多态 units snapshot(resolution/duration/ratio/fps/tokens/单价);`build_agent` 加 `video_variant` + `cancel_check` 形参 — cancel_check 必须在 build 阶段传(SeedanceTool ctor 持有用于轮询期间响应停止按钮,改了原"build 后赋 agent.cancel_check"的延迟绑定,web 入口同步迁移);`web/app.py` 加 `_list_video_variants` / `_resolve_video_model` / `GET /v1/video_models` / `MessageRequest.video_model` / `OptimizePromptRequest.video_model`;`dev.html` 顶栏第三下拉 + `state.videoModels/videoModel` + 发消息一起 POST。前端 chip / inline `<video>` / `extractMediaBanner` / `_categorize` 在前期工作里已为 seedance 留好脚手架,几乎不动。`skills/videogen/SKILL.md` 六维诊断把 imagegen 的"光线"换成"运动+镜头"两维(运动必填,否则应该走 seedream 而非 seedance —— 差 18 倍价钱);BLOCKING 门槛比 imagegen 更严(¥4 vs ¥0.22)且要等 30-90s,贴 prompt+参数+预计花费+预计等待四件套等明确确认。`general_v1.md` 加 seedance 触发指引(平行 seedream)。phase 1 仅 t2v,**不支持 i2v**(skill 明示告诉用户)。fast 上限 720p,1080p+ 留给 pro variant(yaml 当前未配)。否决:(a) progress 事件流化(需要给 tool 加 sink 注入,phase 1 用 `run_status=running` 够了);(b) 远端 cgt-task DELETE(Volcengine 无明确 API,best-effort 不动);(c) i2v phase 1 拉进来(要图片转 URL + UI 选已有图,延后)。
### 2026-05-21 ### 2026-05-21

View File

@ -532,6 +532,88 @@
.small { font-size: 12px; } .small { font-size: 12px; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
/* ───── mobile tab nav (header) — 桌面隐藏,手机断点内显示 ───── */
.mobile-tabs { display: none; gap: 4px; }
.mobile-tabs button {
padding: 5px 10px; font-size: 12px; line-height: 1.2;
background: transparent; border: 1px solid var(--border);
border-radius: var(--r-md); color: var(--muted); cursor: pointer;
transition: var(--t);
}
.mobile-tabs button.active {
background: var(--accent-soft); border-color: var(--accent); color: var(--accent);
}
/* ───── responsive: tablet (641-1024px) ─────
断点内强制 rail(纯 CSS,不写 localStorage;回桌面用户偏好仍生效) */
@media (min-width: 641px) and (max-width: 1024px) {
#app.ready { grid-template-columns: 40px 1fr 260px; }
#pane-left > * { display: none; }
#pane-left > .pane-head:first-child {
display: flex; justify-content: center; align-items: center;
padding: 6px 4px; border-bottom: none; background: transparent;
position: static;
}
#pane-left > .pane-head:first-child > * { display: none; }
#pane-left > .pane-head:first-child > #pane-toggle-left { display: inline-block; }
}
/* ───── responsive: phone (≤ 640px) — 单列 + tab 切换 ─────
JS 在进入手机视口时会清掉 body.left-collapsed,这里无需为它写覆盖
iOS 用 100dvh 避免地址栏/工具栏挤压视口高度 */
@media (max-width: 640px) {
html, body { height: 100dvh; }
#app.ready {
height: 100dvh;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
grid-template-areas: "head" "main";
}
/* 三 pane 默认隐藏,body.mv-* 决定显示哪个 */
#pane-left, #pane-mid, #pane-right {
grid-area: main; border-right: none; display: none;
}
body.mv-left #pane-left { display: block; }
body.mv-mid #pane-mid { display: flex; }
body.mv-right #pane-right { display: block; }
/* 折叠按钮在手机不可见 */
#pane-toggle-left { display: none !important; }
/* header 紧凑化 */
header { padding: 6px 10px; gap: 6px; flex-wrap: wrap; }
header .who { display: none; }
header .title { font-size: 14px; }
/* tab 按钮:整行铺底,order:99 让它换行到 header 第二行 */
.mobile-tabs { display: flex; order: 99; flex-basis: 100%; }
.mobile-tabs button { flex: 1; }
#hd-new { padding: 4px 8px; font-size: 12px; }
#hd-logout { padding: 4px 8px; font-size: 12px; }
/* iOS 防 focus 自动缩放:input/textarea 字号 ≥ 16 */
textarea,
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]) {
font-size: 16px;
}
/* chat / 文件 微调 */
.msg { max-width: 96%; }
#chat-meta { padding: 6px 10px; gap: 8px; font-size: 11px; }
#chat-form { padding: 8px; }
.art-media img, .art-media video { max-width: 100%; }
.file-row { padding: 8px 12px; }
/* 4 个 modal 卡片自适应宽度 */
#login .card { width: min(92vw, 380px); padding: 24px 22px 22px; }
#admin-modal .card { width: min(92vw, 360px); }
#new-task-modal .card { width: min(92vw, 420px); }
#src-picker-modal .card { width: min(96vw, 560px); max-height: 88dvh; }
#file-preview-modal .card {
width: 100vw; height: 100dvh;
max-width: 100vw; max-height: 100dvh;
border-radius: 0;
}
}
</style> </style>
</head> </head>
<body> <body>
@ -610,6 +692,12 @@
<div class="spacer"></div> <div class="spacer"></div>
<button id="hd-new" class="primary">+ 新建任务</button> <button id="hd-new" class="primary">+ 新建任务</button>
<button id="hd-logout">退出登录</button> <button id="hd-logout">退出登录</button>
<!-- 手机 tab(桌面 display:none):任务 / 对话 / 文件 -->
<div class="mobile-tabs" role="tablist">
<button id="mv-tab-left" data-mv="mv-left" class="active">任务</button>
<button id="mv-tab-mid" data-mv="mv-mid">对话</button>
<button id="mv-tab-right" data-mv="mv-right">文件</button>
</div>
</header> </header>
<!-- left --> <!-- left -->
@ -1108,6 +1196,36 @@ $("pane-toggle-left").onclick = () => {
}; };
applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1"); applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1");
// ───── 手机视图切换(单列 + tab) ─────
// body.mv-{left,mid,right} 控制当前显示的 pane;桌面下三 pane 都可见,本函数仅维护 class
// 进入手机视口时清掉 left-collapsed(只 DOM,不动 localStorage —— 回桌面用户偏好仍生效)
const mqPhone = window.matchMedia("(max-width: 640px)");
function setMobileView(view) {
// view ∈ "mv-left" | "mv-mid" | "mv-right"
document.body.classList.remove("mv-left", "mv-mid", "mv-right");
document.body.classList.add(view);
for (const b of document.querySelectorAll(".mobile-tabs button")) {
b.classList.toggle("active", b.dataset.mv === view);
}
}
function applyMobileMode() {
if (mqPhone.matches) {
// 手机:清掉桌面 rail 状态,默认显示任务列表(若未设过)
document.body.classList.remove("left-collapsed");
if (!document.body.matches(".mv-left, .mv-mid, .mv-right")) {
setMobileView("mv-left");
}
} else {
// 桌面/平板:恢复 localStorage 的 collapsed 偏好(平板靠 @media 强制 rail,不需依赖 class)
applyLeftCollapsed(localStorage.getItem(LS_LEFT_COLLAPSED) === "1");
}
}
mqPhone.addEventListener("change", applyMobileMode);
applyMobileMode();
for (const b of document.querySelectorAll(".mobile-tabs button")) {
b.onclick = () => setMobileView(b.dataset.mv);
}
// ───── enter app ───── // ───── enter app ─────
function enterApp() { function enterApp() {
$("login").style.display = "none"; $("login").style.display = "none";
@ -1321,6 +1439,8 @@ async function selectTask(tid) {
document.querySelectorAll(".task-row").forEach((el) => { document.querySelectorAll(".task-row").forEach((el) => {
el.classList.toggle("active", el.dataset.tid === tid); el.classList.toggle("active", el.dataset.tid === tid);
}); });
// 手机视图:选中任务自动切到对话面板(桌面 mqPhone 不命中 → no-op)
if (mqPhone.matches) setMobileView("mv-mid");
try { try {
const meta = await api("GET", "/v1/tasks/" + tid); const meta = await api("GET", "/v1/tasks/" + tid);
state.taskMeta = meta; state.taskMeta = meta;