dsh-mobile-ui 0.1.1 → 0.1.2
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.
- package/README.md +2 -1
- package/README.zh.md +30 -0
- package/assets/screenshot-1-home.png +0 -0
- package/assets/screenshot-2-composer.png +0 -0
- package/assets/screenshot-3-chat.png +0 -0
- package/assets/screenshot-4-sessions.png +0 -0
- package/client.js +55 -16
- package/install.sh +46 -8
- package/package.json +1 -1
- package/tests/mobile-layout-probe.py +312 -0
package/README.md
CHANGED
|
@@ -42,7 +42,8 @@ Fast local iteration: copy to `~/.dsh/profiles/node_modules/dsh-mobile-ui/` and
|
|
|
42
42
|
|
|
43
43
|
- **CSS layer**: injects `style[data-plugin-css=dsh-mobile-ui]`, all rules wrapped in `@media (max-width:768px)` — desktop loads none of it
|
|
44
44
|
- **JS layer**: `matchMedia`-driven; on narrow screens the main grid goes single-column, the sidebar is hidden and becomes a fixed overlay drawer with scrim
|
|
45
|
-
- **QA card** (`ask_user_question`, Mbwy4a component): JS detects the card and
|
|
45
|
+
- **QA card** (`ask_user_question`, Mbwy4a component): JS detects the card and turns its composer seat into a full-height floating panel — no `:has()` dependency (works in WeChat/X5-style engines). The card's own `max-height` is lifted and its body stays the single scroll container, so long option lists scroll with a finger on every device; the footer button row wraps when narrow, so the submit button is always visible
|
|
46
|
+
- **Regression probe**: `python3 tests/mobile-layout-probe.py` drives a real headless Chromium (upstream CSS + real DOM nesting + the actual plugin bundle) across viewports and option counts, asserting "option list scrolls / last option reachable after a real touch swipe / submit always visible / the fixed top-right menu button does not overlap the card". Exit code 1 on regression. Two rules it guards: never override the card body's `overflow` (that is exactly what broke scrolling before), and hide the floating menu button whenever the question card is open (it sits at `top:48px; right:12px`, right on top of the card title).
|
|
46
47
|
|
|
47
48
|
## Maintenance notes (important)
|
|
48
49
|
|
package/README.zh.md
CHANGED
|
@@ -28,8 +28,24 @@ bash install.sh --uninstall # 卸载
|
|
|
28
28
|
```bash
|
|
29
29
|
bash scripts/build.sh # 语法 + 契约预检(含 classic-script 校验)
|
|
30
30
|
bash scripts/package.sh # 打包 dist/dsh-mobile-ui-install.tar.gz
|
|
31
|
+
python3 tests/mobile-layout-probe.py # 移动端布局回归探针(提问卡片可滚动/选项可达)
|
|
31
32
|
```
|
|
32
33
|
|
|
34
|
+
**提问卡片回归探针**(`tests/mobile-layout-probe.py`):用真实上游 CSS + 真实 DOM 嵌套 +
|
|
35
|
+
真实插件代码,在 headless Chromium 里按视口 × 选项数跑矩阵,断言「正文可滚 / 真实手指
|
|
36
|
+
滑动后最后一个选项可见 / 提交按钮始终可见 / 右上角菜单按钮不遮挡卡片」,回归时退出码 1。
|
|
37
|
+
它守的是两条硬纪律——
|
|
38
|
+
|
|
39
|
+
1. **绝不要覆盖提问卡片正文(`Mbwy4a_body` / `data-question-scroll`)的 overflow**:
|
|
40
|
+
上游卡片是 `max-height:min(60vh,520px)+overflow:hidden`,正文才是它唯一的滚动容器,
|
|
41
|
+
把它改成 `visible` 会让放不下的选项被卡片直接裁掉、且再也滚不出来(2026-09-11 修复的真实故障)。
|
|
42
|
+
2. **浮层打开时必须隐藏右上角菜单按钮**:它固定在 `top:48px; right:12px`,
|
|
43
|
+
正好压在提问卡片标题右侧(截图实证),所以菜单按钮的显隐要按
|
|
44
|
+
「抽屉 / 设置面板 / 提问卡片」三态判定,而不是只看抽屉。
|
|
45
|
+
|
|
46
|
+
探针已做**双向对照**验证:最老版本(滚动缺陷)与仅修滚动的版本都返回 1,
|
|
47
|
+
完整修复版返回 0 —— 即它抓得住这两类缺陷,不是"永远绿灯"的假探针。
|
|
48
|
+
|
|
33
49
|
本地快速迭代:复制到 `~/.dsh/profiles/node_modules/dsh-mobile-ui/` 后刷新页面
|
|
34
50
|
(客户端插件有 HMR 通道,改 client.js 后页面自动更新)。
|
|
35
51
|
|
|
@@ -41,6 +57,10 @@ bash scripts/package.sh # 打包 dist/dsh-mobile-ui-install.
|
|
|
41
57
|
注入右上角菜单按钮 + 遮罩;菜单把侧边栏临时变为 fixed 抽屉(overlay)。
|
|
42
58
|
- **hero 标题置顶**:纯 CSS 实现——composerHero 栈在 hero 态撑满视口
|
|
43
59
|
(flex:1),输入卡 margin-top:auto 沉底,标题/工作区行自然留在顶部。
|
|
60
|
+
- **提问卡片**:JS 检测到卡片后把它的 composerSeat 变成铺满可视高度的浮层,
|
|
61
|
+
卡片自身的 max-height 放开、正文保持为唯一滚动容器(卡片内 `data-question-scroll`),
|
|
62
|
+
footer 钉在浮层底部。选择器优先用上游稳定属性(`data-question-key` /
|
|
63
|
+
`data-question-scroll` / `data-composer-seat`),hash 类名只作兜底。
|
|
44
64
|
|
|
45
65
|
## 维护须知(重要)
|
|
46
66
|
|
|
@@ -54,6 +74,16 @@ bash scripts/package.sh # 打包 dist/dsh-mobile-ui-install.
|
|
|
54
74
|
pXSMma_root 等):React fiber 树仍记录旧父节点,重渲染时 removeChild 抛
|
|
55
75
|
NotFoundError,整个会话视图被卸载 → 页面空白。布局需求一律用 CSS
|
|
56
76
|
(flex/order/:has)表达;hero 标题置顶即纯 CSS 实现。
|
|
77
|
+
- **严禁覆盖提问卡片正文的 overflow**(2026-09-11 故障根因,已由探针看住):
|
|
78
|
+
上游 `.card{max-height:min(60vh,520px);overflow:hidden}` 且 `.body{overflow-y:auto}`
|
|
79
|
+
——正文是卡片内唯一滚动容器。改成 `overflow:visible` 会造成
|
|
80
|
+
「选项看不全 + 手指滑动毫无反应」:卡片把放不下的选项裁掉,正文又不再滚动,
|
|
81
|
+
浮层里也没有可滚内容(实测 6 选项时 body 内容 1047px / 可见 319px,滑动后几何零变化)。
|
|
82
|
+
正确做法是**给正文更多高度**(浮层撑满视口 + 卡片 max-height:100%),而不是动它的 overflow。
|
|
83
|
+
- **右上角菜单按钮的显隐必须是三态判定**(抽屉 / 设置面板 / 提问卡片):
|
|
84
|
+
该按钮固定在 `top:calc(48px + safe-area); right:12px`,提问卡片打开时正好压在
|
|
85
|
+
卡片标题右侧(2026-09-11 由真实页面截图发现)。旧写法只看 `drawerOpen`,
|
|
86
|
+
既会在提问卡片上造成遮挡,也会把设置面板打开时隐藏的菜单在下次 sync 又显示回来。
|
|
57
87
|
|
|
58
88
|
## 回滚
|
|
59
89
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/client.js
CHANGED
|
@@ -71,13 +71,26 @@ window.__ModuleLoader__.load({
|
|
|
71
71
|
" body.dsh-mobile-ui [class*=composerHero] > [class*=heroWorkspaceRow]{flex:0 0 auto !important;height:auto !important}",
|
|
72
72
|
/* 工作区行内按钮豁免 44px 触摸高(保持 28px 原高) */
|
|
73
73
|
" body.dsh-mobile-ui [class*=heroWorkspaceRow] button{min-height:28px !important;min-width:0 !important;height:auto !important}",
|
|
74
|
-
/* QA 问答卡片(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
/* ── QA 问答卡片(ask_user_question)移动端适配 ─────────────────────
|
|
75
|
+
⚠️ 2026-09-11 复现定案的坑(现象:选项看不全、手指滑动也翻不出来):
|
|
76
|
+
上游卡片自带 max-height:min(60vh,520px) + overflow:hidden,真正的滚动容器
|
|
77
|
+
是它内部的 body(上游 overflow-y:auto)。旧规则把 body 的 overflow 覆盖成
|
|
78
|
+
visible → ①超出卡片的选项被卡片直接裁掉;②body 不再是滚动容器;
|
|
79
|
+
③覆盖层因卡片被 60vh 卡死而没有可滚内容 → 滑动毫无反应、选项永久不可见。
|
|
80
|
+
(实测 6 选项:body scrollHeight 1047 / clientHeight 319,滑动后几何零变化)
|
|
81
|
+
修法三原则:①绝不覆盖 body 的 overflow(保留上游唯一滚动容器);
|
|
82
|
+
②覆盖层撑满可视高度、卡片 max-height:100%(不再被 60vh 卡死);
|
|
83
|
+
③覆盖层自身 overflow:hidden,杜绝双层滚动互抢手势。
|
|
84
|
+
选择器优先用上游稳定属性 data-question-key(frame)/ data-question-scroll(body)
|
|
85
|
+
/ data-composer-seat(seat),hash 类名只作兜底,降低 dsh 升级后的漂移风险。 */
|
|
86
|
+
" body.dsh-mobile-ui [class*=composerSeat].dsh-mobile-qa,body.dsh-mobile-ui [data-composer-seat].dsh-mobile-qa{position:fixed !important;top:calc(12px + env(safe-area-inset-top,0px)) !important;left:8px !important;right:8px !important;bottom:calc(12px + env(safe-area-inset-bottom,0px)) !important;margin:0 !important;z-index:2147482999 !important;padding:0 !important;max-height:none !important;overflow:hidden !important;background:transparent !important;display:flex !important;flex-direction:column !important}",
|
|
87
|
+
" body.dsh-mobile-ui [data-question-key] > section,body.dsh-mobile-ui [class*=Mbwy4a_card]{width:100% !important;max-width:none !important;border:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.14)) !important;box-shadow:0 10px 34px rgba(0,0,0,.45) !important}",
|
|
88
|
+
" body.dsh-mobile-ui [class*=composerSeat].dsh-mobile-qa [data-question-key],body.dsh-mobile-ui [data-composer-seat].dsh-mobile-qa [data-question-key],body.dsh-mobile-ui [class*=composerSeat].dsh-mobile-qa [class*=Mbwy4a_frame]{flex:1 1 auto !important;min-height:0 !important;padding:0 !important;align-items:flex-start !important}",
|
|
89
|
+
" body.dsh-mobile-ui [class*=composerSeat].dsh-mobile-qa [data-question-key] > section,body.dsh-mobile-ui [data-composer-seat].dsh-mobile-qa [data-question-key] > section,body.dsh-mobile-ui [class*=composerSeat].dsh-mobile-qa [class*=Mbwy4a_card]{max-height:100% !important;min-height:0 !important}",
|
|
90
|
+
/* 正文:唯一滚动容器(覆盖层态与普通流态都生效——JS 标记失败也不会再被裁掉);
|
|
91
|
+
overscroll-behavior 防滑动穿透到会话消息流 */
|
|
92
|
+
" body.dsh-mobile-ui [data-question-scroll],body.dsh-mobile-ui [class*=Mbwy4a_body]{flex:1 1 auto !important;min-height:0 !important;overflow-y:auto !important;-webkit-overflow-scrolling:touch !important;overscroll-behavior:contain !important;touch-action:pan-y !important}",
|
|
93
|
+
" body.dsh-mobile-ui [class*=Mbwy4a_footer]{position:sticky !important;bottom:0 !important;background:var(--dsw-alias-bg-layer-1,rgb(44,44,46)) !important}",
|
|
81
94
|
/* QA footer 防溢出:内容超宽时换行(跳过/提交换行后仍可见,提交永不被裁) */
|
|
82
95
|
" body.dsh-mobile-ui [class*=Mbwy4a_footer]{flex-wrap:wrap !important;row-gap:4px !important}",
|
|
83
96
|
" body.dsh-mobile-ui [class*=Mbwy4a_footer] [class*=footerActions]{flex-wrap:wrap !important;min-width:0 !important}",
|
|
@@ -146,9 +159,16 @@ window.__ModuleLoader__.load({
|
|
|
146
159
|
"body.dsh-mobile-ui [class*=uV2eYG_trailing]{gap:2px !important}",
|
|
147
160
|
/* 会话页(有上下文按钮):上下文 margin-left 右移 12px(只扩模型↔上下文间距) */
|
|
148
161
|
"body.dsh-mobile-ui [class*=uV2eYG_trailing]:has(button[aria-label*=上下文]) button[aria-label*=上下文]{margin-left:12px !important}",
|
|
149
|
-
"body.dsh-mobile-ui [class*=VOzbGW_nav]{width:
|
|
162
|
+
"body.dsh-mobile-ui [class*=VOzbGW_nav]{width:68px !important;flex:0 0 68px !important;min-width:68px !important;overflow:visible !important}",
|
|
150
163
|
"body.dsh-mobile-ui [class*=VOzbGW_nav] [class*=navList]{flex:1 1 auto !important;height:auto !important;max-height:none !important;overflow-y:auto !important}",
|
|
151
|
-
"body.dsh-mobile-ui [class*=
|
|
164
|
+
"body.dsh-mobile-ui [class*=VOzbGW_nav] button,body.dsh-mobile-ui [class*=VOzbGW_nav] [role=button]{white-space:nowrap !important;overflow:hidden !important;text-overflow:ellipsis !important}",
|
|
165
|
+
/* 选项卡改竖排(icon 上/文字下居中):dsh 默认横排 icon+88px 标签总宽 112px,
|
|
166
|
+
88px 窄栏放不下会裁掉文字;竖排后 88px 宽容纳 4 字标签 */
|
|
167
|
+
"body.dsh-mobile-ui [class*=VOzbGW_nav] [class*=navCell]{flex-direction:column !important;justify-content:center !important;align-items:center !important;gap:4px !important;padding:4px 0 !important}",
|
|
168
|
+
"body.dsh-mobile-ui [class*=VOzbGW_nav] [class*=navCell] svg{width:20px !important;height:20px !important;flex:0 0 auto !important}",
|
|
169
|
+
"body.dsh-mobile-ui [class*=VOzbGW_nav] [class*=navLabel]{flex:0 0 auto !important;width:auto !important;max-width:100% !important;min-width:0 !important;text-align:center !important;font-size:12px !important;line-height:1.2 !important}",
|
|
170
|
+
"body.dsh-mobile-ui [class*=VOzbGW_nav] [class*=navTitle]{width:100% !important;box-sizing:border-box !important;padding:0 8px !important;white-space:nowrap !important;overflow:hidden !important;text-overflow:ellipsis !important}",
|
|
171
|
+
"body.dsh-mobile-ui [class*=VOzbGW_content]{flex:1 1 auto !important;min-width:0 !important;max-width:calc(100% - 68px) !important;padding:0 12px !important;overflow:hidden !important}",
|
|
152
172
|
/* 头部防重叠加固:关闭按钮固定右侧 + 打开配置文件允许收缩 */
|
|
153
173
|
"body.dsh-mobile-ui [class*=VOzbGW_header]{display:flex !important;align-items:center !important;justify-content:flex-end !important;gap:8px !important;padding:0 12px !important}",
|
|
154
174
|
"body.dsh-mobile-ui [class*=VOzbGW_close]{flex:0 0 28px !important;margin-left:0 !important}",
|
|
@@ -161,6 +181,14 @@ window.__ModuleLoader__.load({
|
|
|
161
181
|
"body.dsh-mobile-ui [class*=VOzbGW_content] [class*=title],body.dsh-mobile-ui [class*=VOzbGW_content] [class*=rowText]{width:100% !important;max-width:100% !important;white-space:normal !important;overflow-wrap:anywhere !important}",
|
|
162
182
|
"body.dsh-mobile-ui [class*=VOzbGW_content] [class*=title]{display:block !important;flex:1 1 auto !important;align-self:stretch !important}",
|
|
163
183
|
"body.dsh-mobile-ui [class*=VOzbGW_content] [class*=rowText]{flex:1 1 auto !important;min-width:0 !important}",
|
|
184
|
+
/* 会话行操作菜单(dsh portal 弹层,原 z-index:1100)提升层级:
|
|
185
|
+
抽屉(2147482995)/遮罩(2147482990)层级过高会把菜单盖住(归档等操作不可见)。
|
|
186
|
+
仅移动端提升,菜单 fixed 定位不受抽屉 overflow 裁剪;QA 卡片(2147482999)仍居最上 */
|
|
187
|
+
"body.dsh-mobile-ui [class*=_portal_]{z-index:2147482996 !important}",
|
|
188
|
+
/* dsh 模态弹层容器提升:目录选择(添加工作区)/Modal 的 portal root 是 fixed z-index:1000,
|
|
189
|
+
会被抽屉(2147482995)/遮罩(2147482990)盖住 → 弹框不可见。
|
|
190
|
+
[role=dialog] 定位容器统一提升到 2996 与菜单同级(< QA 2999) */
|
|
191
|
+
"body.dsh-mobile-ui div:has(> [role=dialog]){z-index:2147482996 !important}",
|
|
164
192
|
].join("\n");
|
|
165
193
|
|
|
166
194
|
function injectStyle() {
|
|
@@ -189,6 +217,7 @@ window.__ModuleLoader__.load({
|
|
|
189
217
|
var scrim = null;
|
|
190
218
|
var drawerOpen = false;
|
|
191
219
|
var settingsOpen = false; /* 设置弹层打开标志(弹层关闭时恢复导航栏) */
|
|
220
|
+
var qaOpen = false; /* 提问卡片打开标志(卡片浮层打开时隐藏右上角菜单,防遮挡标题) */
|
|
192
221
|
var savedGrid = null; /* 窄屏前的 frame 原始 grid(宽屏恢复用) */
|
|
193
222
|
|
|
194
223
|
/* 主布局 frame:display:grid 的顶层容器(CSS 生效前 3 列 / 生效后 1 列均可) */
|
|
@@ -300,17 +329,23 @@ window.__ModuleLoader__.load({
|
|
|
300
329
|
}
|
|
301
330
|
}
|
|
302
331
|
document.body.classList.add("dsh-mobile-ui");
|
|
303
|
-
/* QA 弹层标记:JS
|
|
304
|
-
加 class 到 composerSeat,CSS
|
|
332
|
+
/* QA 弹层标记:JS 检测提问卡片(不依赖 :has,兼容微信 X5 等旧内核)。
|
|
333
|
+
加 class 到 composerSeat,CSS 据此覆盖层化;卡片消失即移除。
|
|
334
|
+
定位优先用上游稳定属性 data-question-key / data-composer-seat
|
|
335
|
+
(hash 类名在 dsh 升级后会漂移,只作兜底) */
|
|
305
336
|
try {
|
|
306
|
-
var qaFrame = document.querySelector("[class*=Mbwy4a_frame]");
|
|
337
|
+
var qaFrame = document.querySelector("[data-question-key],[class*=Mbwy4a_frame]");
|
|
307
338
|
var qaSeat = null;
|
|
308
339
|
if (qaFrame) {
|
|
309
|
-
|
|
310
|
-
|
|
340
|
+
if (typeof qaFrame.closest === "function") qaSeat = qaFrame.closest("[data-composer-seat],[class*=composerSeat]");
|
|
341
|
+
if (!qaSeat) {
|
|
342
|
+
var qn = qaFrame.parentElement;
|
|
343
|
+
while (qn) { if ((typeof qn.className === "string" ? qn.className : "").indexOf("composerSeat") >= 0) { qaSeat = qn; break; } qn = qn.parentElement; }
|
|
344
|
+
}
|
|
311
345
|
}
|
|
346
|
+
qaOpen = !!qaSeat;
|
|
312
347
|
if (qaSeat) qaSeat.classList.add("dsh-mobile-qa");
|
|
313
|
-
else { var qOld = document.querySelector("
|
|
348
|
+
else { var qOld = document.querySelector(".dsh-mobile-qa"); if (qOld) qOld.classList.remove("dsh-mobile-qa"); }
|
|
314
349
|
} catch (e) { /* QA 标记失败静默 */ }
|
|
315
350
|
/* hero 标题置顶改由纯 CSS 实现(composerHero 撑满视口 + 输入卡沉底),
|
|
316
351
|
不再用 insertBefore 搬动 React 节点——移动节点会导致 React 重渲染
|
|
@@ -324,9 +359,13 @@ window.__ModuleLoader__.load({
|
|
|
324
359
|
/* 抽屉态下 sidebar 保持可见,否则隐藏 */
|
|
325
360
|
side.style.display = drawerOpen ? "" : "none";
|
|
326
361
|
ensureChrome();
|
|
327
|
-
|
|
362
|
+
/* 菜单按钮三态判定:抽屉 / 设置面板 / 提问卡片浮层打开时都隐藏。
|
|
363
|
+
(旧写法只看 drawerOpen,会把设置面板打开时隐藏的菜单在下次 sync 又显示回来;
|
|
364
|
+
提问卡片时则会浮在卡片右上角遮住问题标题) */
|
|
365
|
+
if (tabbar) tabbar.style.display = (drawerOpen || settingsOpen || qaOpen) ? "none" : "";
|
|
328
366
|
} else {
|
|
329
367
|
document.body.classList.remove("dsh-mobile-ui");
|
|
368
|
+
qaOpen = false;
|
|
330
369
|
var frame2 = findFrame();
|
|
331
370
|
if (frame2) {
|
|
332
371
|
if (savedGrid) frame2.style.gridTemplateColumns = savedGrid;
|
package/install.sh
CHANGED
|
@@ -46,6 +46,10 @@ done
|
|
|
46
46
|
[ -n "$PLUGIN" ] || { echo "错误: 缺少 --plugin <name>"; exit 1; }
|
|
47
47
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
48
48
|
[ -n "$SRC" ] || SRC="$HERE"
|
|
49
|
+
# tarball 分发布局:install.sh 在根、插件源码在同名子目录(如 <name>-install/<name>/)
|
|
50
|
+
if [ ! -f "$SRC/package.json" ] && [ -f "$SRC/$PLUGIN/package.json" ]; then
|
|
51
|
+
SRC="$SRC/$PLUGIN"
|
|
52
|
+
fi
|
|
49
53
|
# 契约预检脚本位置:优先所在仓库(packages/<name>/install.sh → ../../scripts),
|
|
50
54
|
# 其次环境变量 DSH_PLUGINS_REPO,最后 $HOME/dsh-plugins
|
|
51
55
|
for CAND in "$HERE/../../scripts" "${DSH_PLUGINS_REPO:-$HOME/dsh-plugins}/scripts"; do
|
|
@@ -221,14 +225,48 @@ fi
|
|
|
221
225
|
|
|
222
226
|
if [ "$MODE" = "restart" ]; then
|
|
223
227
|
echo "== 4/4 重启 dsh =="
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
228
|
+
# 统一重启逻辑:systemd 托管优先,回退 pkill(MR-026)
|
|
229
|
+
if [ -f "$HERE/_dsh-common/dsh-restart.sh" ]; then
|
|
230
|
+
# tarball 内自带共享重启脚本(package.sh 打入 _dsh-common/)
|
|
231
|
+
. "$HERE/_dsh-common/dsh-restart.sh"
|
|
232
|
+
restart_dsh "$ROOT"
|
|
233
|
+
elif [ -n "$COMMON" ] && [ -f "$COMMON/dsh-restart.sh" ]; then
|
|
234
|
+
# 开发环境:dsh-plugins 仓库 scripts/
|
|
235
|
+
. "$COMMON/dsh-restart.sh"
|
|
236
|
+
restart_dsh "$ROOT"
|
|
237
|
+
else
|
|
238
|
+
# 极端场景(无共享脚本):内联完整重启逻辑(systemd → pkill 回退)
|
|
239
|
+
SVC=""
|
|
240
|
+
if command -v systemctl >/dev/null 2>&1; then
|
|
241
|
+
for SVC in dsh.service dsh-web.service; do
|
|
242
|
+
systemctl --user is-active --quiet "$SVC" 2>/dev/null && break
|
|
243
|
+
done
|
|
244
|
+
fi
|
|
245
|
+
if [ -n "$SVC" ]; then
|
|
246
|
+
echo " [systemd] $SVC 托管中 → systemctl --user restart $SVC"
|
|
247
|
+
systemctl --user restart "$SVC"
|
|
248
|
+
sleep 6
|
|
249
|
+
curl -s --noproxy '*' -o /dev/null -w " 127.0.0.1:3080 页面 -> %{http_code}\n" http://127.0.0.1:3080/ || echo " [警告] 页面未就绪"
|
|
250
|
+
else
|
|
251
|
+
echo " [回退] 无 systemd 托管,pkill + 手动拉起"
|
|
252
|
+
pkill -TERM -f "dsh web" 2>/dev/null
|
|
253
|
+
pkill -TERM -f 'node_modules/.bin/dsh web' 2>/dev/null
|
|
254
|
+
pkill -TERM -f 'npm exec @deepseek-ai/dsh web' 2>/dev/null
|
|
255
|
+
pkill -TERM -f 'sh -c dsh web' 2>/dev/null
|
|
256
|
+
pkill -TERM -f 'dsh/lib/bin.js web' 2>/dev/null
|
|
257
|
+
sleep 3
|
|
258
|
+
if [ -n "$ROOT" ] && [ -x "$ROOT/node_modules/.bin/dsh" ]; then
|
|
259
|
+
cd "$ROOT" || exit 1
|
|
260
|
+
setsid nohup ./node_modules/.bin/dsh web >> /tmp/dsh-web.log 2>&1 < /dev/null &
|
|
261
|
+
echo " 新进程 PID=$!"
|
|
262
|
+
sleep 8
|
|
263
|
+
curl -s --noproxy '*' -o /dev/null -w " 127.0.0.1:3080 页面 -> %{http_code}\n" http://127.0.0.1:3080/ || echo " [警告] 页面未就绪"
|
|
264
|
+
else
|
|
265
|
+
echo " [警告] 无法定位 dsh 可执行文件,请手动重启 dsh web"
|
|
266
|
+
fi
|
|
267
|
+
fi
|
|
268
|
+
fi
|
|
269
|
+
if [ -x "$ROOT/node_modules/.bin/dsh" ]; then
|
|
232
270
|
curl -s -o /dev/null -w " 127.0.0.1:3080 页面 -> %{http_code}\n" http://127.0.0.1:3080/ || echo " [警告] 页面未就绪,请稍后手动刷新"
|
|
233
271
|
curl -s -o /dev/null -w " $PLUGIN bundle -> %{http_code}\n" "http://127.0.0.1:3080/plugins/$PLUGIN/client.js"
|
|
234
272
|
else
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Mobile UI for the DeepSeek Harness Web GUI: responsive layout, overlay session drawer, 44px touch targets, safe-area support, reading enhancements (≤768px, zero desktop impact)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""dsh-mobile-ui 移动端布局探针:提问卡片(ask_user_question)可读性回归测试。
|
|
3
|
+
|
|
4
|
+
## 为什么需要这个探针
|
|
5
|
+
|
|
6
|
+
提问卡片曾在手机上出现「选项看不全 + 手指滑动也没反应」的故障(2026-09-11 定位修复)。
|
|
7
|
+
根因不是样式不生效,而是**覆盖了上游唯一的滚动容器**:
|
|
8
|
+
|
|
9
|
+
上游 .card { max-height: min(60vh,520px); overflow: hidden }
|
|
10
|
+
上游 .body { overflow-y: auto } ← 卡片内唯一的滚动容器
|
|
11
|
+
旧插件规则 .body { overflow: visible } ← 把滚动容器废掉
|
|
12
|
+
⇒ ① 超出卡片的选项被 card 的 overflow:hidden 直接裁掉
|
|
13
|
+
② body 不再是滚动容器,③ 覆盖层里也没有可滚内容
|
|
14
|
+
⇒ 手指滑动零反应,后面的选项永久不可见
|
|
15
|
+
|
|
16
|
+
这类故障**静默且只在内容溢出时出现**(1~3 个选项时一切正常),靠肉眼审查 CSS 极难发现,
|
|
17
|
+
所以固化成几何测量:在真实上游 CSS + 真实 DOM 嵌套 + 真实插件代码下,
|
|
18
|
+
用 Playwright 断言「正文可滚 / 滚到底末项可见 / 真实触摸滑动后末项可见 / 提交按钮始终可见」。
|
|
19
|
+
|
|
20
|
+
## 用法
|
|
21
|
+
|
|
22
|
+
python3 tests/mobile-layout-probe.py # 默认测当前安装副本
|
|
23
|
+
python3 tests/mobile-layout-probe.py --plugin ./client.js
|
|
24
|
+
python3 tests/mobile-layout-probe.py --viewport 320x568 --options 12
|
|
25
|
+
|
|
26
|
+
依赖:playwright(python)+ 已安装的 dsh(用于抽取上游 CSS)。退出码非 0 表示回归。
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import pathlib
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
import tempfile
|
|
37
|
+
|
|
38
|
+
DSH_NODE_MODULES = pathlib.Path(
|
|
39
|
+
os.environ.get(
|
|
40
|
+
"DSH_NODE_MODULES",
|
|
41
|
+
"/home/wbaifan/.npm-global/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
DEFAULT_PLUGIN = pathlib.Path.home() / ".dsh/plugins/dsh-mobile-ui/client.js"
|
|
45
|
+
|
|
46
|
+
CONV_CSS_RE = r'const css\$4 = ("(?:[^"\\]|\\.)*");'
|
|
47
|
+
QQ_CSS_RE = r'const css = ("(?:[^"\\]|\\.)*");'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_css(path: pathlib.Path, pattern: str, what: str) -> str:
|
|
51
|
+
"""从已安装的 dsh 客户端包里抽出上游 CSS(保证复现页与线上像素级同源)。"""
|
|
52
|
+
if not path.exists():
|
|
53
|
+
raise SystemExit(f"找不到 {path}(用 DSH_NODE_MODULES 指定 dsh 安装位置)")
|
|
54
|
+
m = re.search(pattern, path.read_text(encoding="utf-8"))
|
|
55
|
+
if not m:
|
|
56
|
+
raise SystemExit(f"未能从 {path} 抽取 {what} 的 CSS(上游结构可能已变化)")
|
|
57
|
+
return eval(m.group(1))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_page(plugin: pathlib.Path, options: int, detail_paras: int) -> str:
|
|
61
|
+
conv_css = extract_css(DSH_NODE_MODULES / "dsh-client-ui-conversation/lib/client.js", CONV_CSS_RE, "ConversationRoot")
|
|
62
|
+
qq_css = extract_css(DSH_NODE_MODULES / "dsh-client-ui-user-questions/lib/client.js", QQ_CSS_RE, "QuestionComposer")
|
|
63
|
+
|
|
64
|
+
detail = "".join(
|
|
65
|
+
f"<p>背景说明段落 {i + 1}:用于模拟真实场景中 detail 字段的长文本,"
|
|
66
|
+
f"它会明显占据垂直空间。</p>"
|
|
67
|
+
for i in range(detail_paras)
|
|
68
|
+
)
|
|
69
|
+
opts = "".join(
|
|
70
|
+
f"""
|
|
71
|
+
<button type="button" class="Mbwy4a_option" role="radio" aria-checked="false" aria-label="选项 {i + 1}">
|
|
72
|
+
<span class="Mbwy4a_number">{i + 1}</span>
|
|
73
|
+
<span class="Mbwy4a_optionCopy"><span class="Mbwy4a_optionLine">
|
|
74
|
+
<span class="Mbwy4a_optionLabel">选项 {i + 1}:第 {i + 1} 个候选方案的简短标题</span>
|
|
75
|
+
<span class="Mbwy4a_description">— 该方案的补充说明,用于占位真实文本长度。</span>
|
|
76
|
+
</span></span>
|
|
77
|
+
</button>"""
|
|
78
|
+
for i in range(options)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return f"""<!doctype html>
|
|
82
|
+
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
83
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
84
|
+
<title>mobile-layout-probe</title>
|
|
85
|
+
<style>
|
|
86
|
+
:root{{
|
|
87
|
+
--dsw-alias-bg-base:#16171b; --dsw-alias-bg-layer-1:#1e1f24; --dsw-specific-input-major:#26272d;
|
|
88
|
+
--dsw-alias-label-primary:rgba(255,255,255,.9); --dsw-alias-border-l2:rgba(255,255,255,.14);
|
|
89
|
+
--dsw-alias-scrollbar-bg-l2:rgba(255,255,255,.2); --dsw-alias-scrollbar-hover-l2:rgba(255,255,255,.3);
|
|
90
|
+
--dsh-composer-side-clearance:12px; --dsh-composer-card-max-width:720px;
|
|
91
|
+
--dsh-chat-user-width:clamp(680px, 60vw, 860px); --dsh-scrollbar-width:0px;
|
|
92
|
+
--dsh-conversation-column-width:100%;
|
|
93
|
+
}}
|
|
94
|
+
html,body{{margin:0;padding:0;height:100%;background:var(--dsw-alias-bg-base);color:#eee;
|
|
95
|
+
font-family:-apple-system,"PingFang SC",system-ui,sans-serif}}
|
|
96
|
+
{conv_css}
|
|
97
|
+
{qq_css}
|
|
98
|
+
/* 顶层容器必须带 frame 词根:插件靠 [class*=frame] 找主布局(真实 dsh 同构) */
|
|
99
|
+
#app{{height:100%;display:grid;grid-template-columns:260px minmax(0,1fr) 0px}}
|
|
100
|
+
.sidebarCol{{background:#1a1b1f;border-right:1px solid rgba(255,255,255,.08)}}
|
|
101
|
+
.detailsCol{{background:#1a1b1f}}
|
|
102
|
+
.msg{{padding:10px 14px;margin:8px 12px;border-radius:12px;background:#22232a;line-height:1.5}}
|
|
103
|
+
</style></head>
|
|
104
|
+
<body>
|
|
105
|
+
<div id="app" class="frame">
|
|
106
|
+
<div class="sidebarCol" data-testid="sidebar">sidebar</div>
|
|
107
|
+
<div class="wSkVaW_root" data-phase="active">
|
|
108
|
+
<div class="wSkVaW_header" style="padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.08)">会话标题</div>
|
|
109
|
+
<div class="wSkVaW_body">
|
|
110
|
+
<div class="wSkVaW_scrollBody" data-conversation-scroll>
|
|
111
|
+
<div data-slot="conversation.session"><div class="wSkVaW_viewArea">
|
|
112
|
+
<div class="msg">用户:帮我看下这个方案。</div>
|
|
113
|
+
<div class="msg">助手:好的,我需要你确认几个问题。</div>
|
|
114
|
+
</div></div>
|
|
115
|
+
<div class="wSkVaW_composerSeat" data-composer-seat>
|
|
116
|
+
<div class="Mbwy4a_frame" data-question-key="probe">
|
|
117
|
+
<section class="Mbwy4a_card" aria-labelledby="q1">
|
|
118
|
+
<header class="Mbwy4a_header">
|
|
119
|
+
<div class="Mbwy4a_headingBlock">
|
|
120
|
+
<div class="Mbwy4a_eyebrow">需要你的确认</div>
|
|
121
|
+
<h2 class="Mbwy4a_title" id="q1">这个任务应该按哪种方式继续?请在下面选择一项。</h2>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="Mbwy4a_headerActions">
|
|
124
|
+
<button type="button" class="Mbwy4a_iconButton" aria-label="最小化">⌄</button>
|
|
125
|
+
<button type="button" class="Mbwy4a_iconButton" aria-label="取消">✕</button>
|
|
126
|
+
</div>
|
|
127
|
+
</header>
|
|
128
|
+
<div class="Mbwy4a_body" data-question-scroll>
|
|
129
|
+
<div class="Mbwy4a_detail">{detail}</div>
|
|
130
|
+
<div class="Mbwy4a_options" role="radiogroup">{opts}
|
|
131
|
+
<div class="Mbwy4a_customRow">
|
|
132
|
+
<span class="Mbwy4a_number" aria-hidden="true">+</span>
|
|
133
|
+
<input class="Mbwy4a_fieldInput" placeholder="其他(自定义回答)">
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
<div class="Mbwy4a_footer">
|
|
138
|
+
<div class="Mbwy4a_footerActions"><button class="Mbwy4a_iconButton">跳过</button></div>
|
|
139
|
+
<div class="Mbwy4a_footerActions">
|
|
140
|
+
<span class="Mbwy4a_progress">1/1</span>
|
|
141
|
+
<button class="Mbwy4a_primary">提交</button>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</section>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
<div class="detailsCol" data-testid="details">details</div>
|
|
151
|
+
</div>
|
|
152
|
+
<script>window.__ModuleLoader__={{mode:"queue",load:function(reg){{window.__reg=reg}}}};</script>
|
|
153
|
+
<script src="file://{plugin}"></script>
|
|
154
|
+
<script>window.__reg.factory(function(){{throw new Error("no require")}});window.__reg.factory;window.__plug=window.__reg;window.__plug.factory(function(){{}});</script>
|
|
155
|
+
<script>try{{window.__plug.factory(function(){{}});}}catch(e){{}}</script>
|
|
156
|
+
</body></html>"""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
MEASURE_JS = r"""
|
|
160
|
+
() => {
|
|
161
|
+
const q = (s) => document.querySelector(s);
|
|
162
|
+
const seat = q('[data-composer-seat]');
|
|
163
|
+
const body = q('[data-question-scroll]');
|
|
164
|
+
const opts = [...document.querySelectorAll('button[class*=Mbwy4a_option]')];
|
|
165
|
+
const vis = (el) => {
|
|
166
|
+
if (!el) return {ok:false, why:'missing'};
|
|
167
|
+
const r = el.getBoundingClientRect();
|
|
168
|
+
if (!r.width || !r.height) return {ok:false, why:'zero-size'};
|
|
169
|
+
const cx = r.left + r.width/2, cy = r.top + r.height/2;
|
|
170
|
+
if (cy < 0 || cy > innerHeight) return {ok:false, why:'offscreen'};
|
|
171
|
+
const hit = document.elementFromPoint(cx, cy);
|
|
172
|
+
return {ok: !!hit && (hit === el || el.contains(hit) || hit.contains(el)), why: hit ? String(hit.className||hit.tagName) : 'null'};
|
|
173
|
+
};
|
|
174
|
+
return {
|
|
175
|
+
vp: {w: innerWidth, h: innerHeight},
|
|
176
|
+
seatQa: !!(seat && /dsh-mobile-qa/.test(seat.className)),
|
|
177
|
+
bodyOverflow: body ? getComputedStyle(body).overflowY : null,
|
|
178
|
+
bodyScroll: body ? body.scrollHeight - body.clientHeight : 0,
|
|
179
|
+
last: vis(opts[opts.length-1]),
|
|
180
|
+
submit: vis(q('[class*=Mbwy4a_primary]')),
|
|
181
|
+
// 右上角菜单按钮在提问卡片打开时必须隐藏,否则会浮在卡片右上角遮住问题标题
|
|
182
|
+
menuBtnDisplay: (() => { const m = document.getElementById('dsh-mobile-menu-btn'); return m ? getComputedStyle(m).display : null; })(),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def apply_plugin(page, plugin: pathlib.Path) -> None:
|
|
189
|
+
"""在页面里按真实加载路径执行插件:注册 → factory → apply(ctx)。"""
|
|
190
|
+
page.add_script_tag(path=str(plugin))
|
|
191
|
+
page.evaluate(
|
|
192
|
+
"""() => {
|
|
193
|
+
const reg = window.__reg;
|
|
194
|
+
window.__plug = reg.factory(function () { throw new Error('no require'); });
|
|
195
|
+
window.__plug.apply({ logger: { info() {} } });
|
|
196
|
+
}"""
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def probe(plugin, viewport, options, detail_paras, workdir):
|
|
201
|
+
from playwright.sync_api import sync_playwright
|
|
202
|
+
|
|
203
|
+
w, h = viewport
|
|
204
|
+
html = build_page(plugin, options, detail_paras)
|
|
205
|
+
page_path = workdir / f"probe-{options}-{w}x{h}.html"
|
|
206
|
+
page_path.write_text(html, encoding="utf-8")
|
|
207
|
+
|
|
208
|
+
with sync_playwright() as p:
|
|
209
|
+
browser = p.chromium.launch()
|
|
210
|
+
ctx = browser.new_context(
|
|
211
|
+
viewport={"width": w, "height": h}, device_scale_factor=2, is_mobile=True, has_touch=True
|
|
212
|
+
)
|
|
213
|
+
page = ctx.new_page()
|
|
214
|
+
page.goto(page_path.as_uri())
|
|
215
|
+
apply_plugin(page, plugin)
|
|
216
|
+
page.wait_for_timeout(500)
|
|
217
|
+
|
|
218
|
+
before = page.evaluate(MEASURE_JS)
|
|
219
|
+
|
|
220
|
+
# 真实手指滑动(touch 事件;滚轮不等价:移动端页面常只响应 touch)。
|
|
221
|
+
# 反复滑动直到末项可见(模拟用户持续上滑),最多 8 次;
|
|
222
|
+
# 滚不动的情况下滑多少次都不会可见 —— 因此不会掩盖真问题。
|
|
223
|
+
body = page.query_selector("[data-question-scroll]")
|
|
224
|
+
box = body.bounding_box()
|
|
225
|
+
swipes = 0
|
|
226
|
+
after_swipe = before
|
|
227
|
+
if box:
|
|
228
|
+
cx, y0, y1 = box["x"] + box["width"] / 2, box["y"] + box["height"] - 24, box["y"] + 24
|
|
229
|
+
cdp = ctx.new_cdp_session(page)
|
|
230
|
+
while swipes < 8:
|
|
231
|
+
cdp.send("Input.dispatchTouchEvent", {"type": "touchStart", "touchPoints": [{"x": cx, "y": y0}]})
|
|
232
|
+
for i in range(1, 11):
|
|
233
|
+
cdp.send("Input.dispatchTouchEvent", {
|
|
234
|
+
"type": "touchMove", "touchPoints": [{"x": cx, "y": y0 + (y1 - y0) * i / 10}]})
|
|
235
|
+
page.wait_for_timeout(16)
|
|
236
|
+
cdp.send("Input.dispatchTouchEvent", {"type": "touchEnd", "touchPoints": []})
|
|
237
|
+
page.wait_for_timeout(200)
|
|
238
|
+
swipes += 1
|
|
239
|
+
after_swipe = page.evaluate(MEASURE_JS)
|
|
240
|
+
if after_swipe["last"]["ok"]:
|
|
241
|
+
break
|
|
242
|
+
browser.close()
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
"viewport": f"{w}x{h}",
|
|
246
|
+
"options": options,
|
|
247
|
+
"detail_paras": detail_paras,
|
|
248
|
+
"scrollable": (before["bodyScroll"] or 0) > 1 and before["bodyOverflow"] in ("auto", "scroll"),
|
|
249
|
+
"needs_scroll": (before["bodyScroll"] or 0) > 1,
|
|
250
|
+
"last_visible_after_swipe": after_swipe["last"]["ok"],
|
|
251
|
+
"submit_visible": before["submit"]["ok"],
|
|
252
|
+
"menu_btn_hidden_while_qa": before["menuBtnDisplay"] == "none",
|
|
253
|
+
"swipes_to_reach_last": swipes,
|
|
254
|
+
"seat_qa": before["seatQa"],
|
|
255
|
+
"detail": {"before": before, "after_swipe": after_swipe},
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main() -> int:
|
|
260
|
+
ap = argparse.ArgumentParser(description="dsh-mobile-ui 提问卡片移动端布局探针")
|
|
261
|
+
ap.add_argument("--plugin", default=str(DEFAULT_PLUGIN), help="被测 client.js 路径")
|
|
262
|
+
ap.add_argument("--viewport", action="append", default=None, help="WxH,可重复(默认覆盖常见机型)")
|
|
263
|
+
ap.add_argument("--options", type=int, action="append", default=None, help="选项数量,可重复")
|
|
264
|
+
ap.add_argument("--detail-paras", type=int, default=2, help="detail 长文本段落数")
|
|
265
|
+
ap.add_argument("--json", action="store_true", help="输出 JSON")
|
|
266
|
+
args = ap.parse_args()
|
|
267
|
+
|
|
268
|
+
plugin = pathlib.Path(args.plugin).expanduser()
|
|
269
|
+
if not plugin.exists():
|
|
270
|
+
raise SystemExit(f"被测插件不存在:{plugin}")
|
|
271
|
+
|
|
272
|
+
viewports = [tuple(int(x) for x in v.lower().split("x")) for v in (args.viewport or ["320x568", "360x640", "390x844", "414x896"])]
|
|
273
|
+
options_list = args.options or [1, 3, 6, 12]
|
|
274
|
+
|
|
275
|
+
results = []
|
|
276
|
+
with tempfile.TemporaryDirectory(prefix="dsh-mobile-probe-") as tmp:
|
|
277
|
+
for vp in viewports:
|
|
278
|
+
for n in options_list:
|
|
279
|
+
results.append(probe(plugin, vp, n, args.detail_paras, pathlib.Path(tmp)))
|
|
280
|
+
|
|
281
|
+
failures = [
|
|
282
|
+
r for r in results
|
|
283
|
+
if not r["last_visible_after_swipe"] or not r["submit_visible"]
|
|
284
|
+
or not r["menu_btn_hidden_while_qa"] or (r["needs_scroll"] and not r["scrollable"])
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
if args.json:
|
|
288
|
+
# 纯 JSON 输出(供 CI/脚本消费);结论用退出码表达
|
|
289
|
+
print(json.dumps({"ok": not failures, "results": results}, ensure_ascii=False, indent=2))
|
|
290
|
+
return 1 if failures else 0
|
|
291
|
+
else:
|
|
292
|
+
print(f"被测插件: {plugin}")
|
|
293
|
+
print(f"{'视口':<10} {'选项数':<7} {'内容溢出':<9} {'正文可滚':<9} {'滑动后末项可见':<15} {'提交可见':<9} {'菜单不遮挡':<10}")
|
|
294
|
+
print("-" * 78)
|
|
295
|
+
for r in results:
|
|
296
|
+
print(f"{r['viewport']:<10} {r['options']:<8} {'是' if r['needs_scroll'] else '否':<10} "
|
|
297
|
+
f"{'✓' if r['scrollable'] else ('—' if not r['needs_scroll'] else '✗'):<10} "
|
|
298
|
+
f"{'✓' if r['last_visible_after_swipe'] else '✗':<16} {'✓' if r['submit_visible'] else '✗':<10} "
|
|
299
|
+
f"{'✓' if r['menu_btn_hidden_while_qa'] else '✗':<10}")
|
|
300
|
+
|
|
301
|
+
print()
|
|
302
|
+
if failures:
|
|
303
|
+
print(f"❌ 回归:{len(failures)} 个用例不达标(选项被裁 / 滚动失效 / 提交按钮不可见)")
|
|
304
|
+
for r in failures:
|
|
305
|
+
print(f" - {r['viewport']} options={r['options']}: {r['detail']['before']}")
|
|
306
|
+
return 1
|
|
307
|
+
print(f"✅ 全部 {len(results)} 个用例通过:选项可滚可见、提交按钮始终可见、菜单按钮不遮挡卡片")
|
|
308
|
+
return 0
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
if __name__ == "__main__":
|
|
312
|
+
sys.exit(main())
|