dsh-mini-remote 0.1.9 → 0.2.0
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/CHANGELOG.md +26 -0
- package/README.en.md +13 -7
- package/README.md +13 -7
- package/lib/index.js +149 -5
- package/lib/mirror.js +1422 -0
- package/lib/page.html +624 -6
- package/lib/server.js +373 -1
- package/lib/store.js +41 -0
- package/package.json +2 -2
package/lib/page.html
CHANGED
|
@@ -656,6 +656,14 @@
|
|
|
656
656
|
display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
|
657
657
|
font-size: 13.5px; line-height: 1.45;
|
|
658
658
|
}
|
|
659
|
+
/* 「插话」那一行的标记(2026-10-06)。不标的话,插话和排队在列表里长得一模一样,
|
|
660
|
+
而两者的等待时间差一个数量级(插话几秒、排队可能几分钟)——用户根本分不出
|
|
661
|
+
自己那条到底插进去了没有。颜色用既有的状态色 --run(和轨迹里的「进行中」同色)。 */
|
|
662
|
+
.queue .q-tag {
|
|
663
|
+
flex: 0 0 auto; font-size: 11px; line-height: 1;
|
|
664
|
+
padding: 2px 5px; border-radius: var(--r);
|
|
665
|
+
color: var(--run); border: 1px solid var(--run);
|
|
666
|
+
}
|
|
659
667
|
/* 长指令折行,但最多两行——队列是「提醒还有几条」,不是给人重读全文的地方。 */
|
|
660
668
|
.queue .q-text {
|
|
661
669
|
flex: 1 1 auto; min-width: 0; color: var(--fg);
|
|
@@ -1036,6 +1044,18 @@
|
|
|
1036
1044
|
/* 四类状态的字样:只上色、零动效(动效守卫只认那五种关键帧),颜色全取现成令牌。 */
|
|
1037
1045
|
.traj-err { color: var(--err); } /* 失败首行:折叠处一眼可见 */
|
|
1038
1046
|
.traj-run { color: var(--run); } /* 进行中:有标识;2026-10-04 起再加一道扫光(用户点名) */
|
|
1047
|
+
/* 条目行里那两个字样**不许被压扁、不许折行**(2026-10-06 用户真机反馈:
|
|
1048
|
+
「进行中」的「中」挂到下一行)。
|
|
1049
|
+
根因:它们是直接拼在 .traj-row 里的裸 <span>,而 .traj-row 是**弹性容器**
|
|
1050
|
+
——于是各自变成一个弹性项,默认 flex: 0 1 auto、min-width: auto。
|
|
1051
|
+
中文的「最小内容宽度」是**一个字**,空间一紧它就缩成一字宽,文字自己折行,
|
|
1052
|
+
变成「进 / 行 / 中」竖着排,还把整行撑高(真机复现过,长摘要那一行最明显)。
|
|
1053
|
+
修法:让它不参与压缩、且不许折行——**正好也是 2026-10-04 定下的口径**
|
|
1054
|
+
(「出错/进行中的字样是独立的一段,不许被省略号吃掉」):它保持完整宽度,
|
|
1055
|
+
让左边那段 .traj-detail 去缩、去打省略号。
|
|
1056
|
+
只挂在 .traj-row 下:组头那份 .traj-head .traj-run 自己有 nowrap + 省略号,
|
|
1057
|
+
不要动它。 */
|
|
1058
|
+
.traj-row .traj-err, .traj-row .traj-run { flex: none; white-space: nowrap; }
|
|
1039
1059
|
/* 跑着的行有一道渐变光扫过去(照 DSH 的 TextShimmer,2026-10-04 用户点名复现,
|
|
1040
1060
|
要适配主题)。机制照 PC:底色还是语义色(--run),一道亮色(--shimmer)扫过;
|
|
1041
1061
|
每秒 48 格、1.5 秒一轮、走前 2/3 停后 1/3,与 PC 同参。颜色全走现成令牌,
|
|
@@ -1122,6 +1142,16 @@
|
|
|
1122
1142
|
gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line);
|
|
1123
1143
|
}
|
|
1124
1144
|
.row:last-of-type { border-bottom: none; }
|
|
1145
|
+
|
|
1146
|
+
/* **这一句不能省。** `hidden` 只是 UA 样式表里的一条 `display:none`,
|
|
1147
|
+
而上面 `.row` 写了 `display:flex`——作者样式表压得住 UA 那一句(同一个道理
|
|
1148
|
+
见 `#mirrorView[hidden]`、`.sheet .version[hidden]`)。
|
|
1149
|
+
少了它,`.row` 上的 `hidden` 等于没写:元素照样撑开一段高度(2026-10-08 实测
|
|
1150
|
+
358×70,`getComputedStyle` 回来还是 `flex`),于是设置里「电脑端界面」那一行
|
|
1151
|
+
在进阶设置关着时照样露着。**改脚本没用**——脚本一直是对的,它确实把 hidden
|
|
1152
|
+
设上了;漏的是这条兜底样式。 */
|
|
1153
|
+
.row[hidden] { display: none; }
|
|
1154
|
+
|
|
1125
1155
|
/* 权限档位那一块外面套了一层(#permBlock,服务拿不到时整块要藏起来)。
|
|
1126
1156
|
它下面**没有分割线**,而别的板块之间都有——2026-10-05 用户截图点名。
|
|
1127
1157
|
原因:别的板块的分割线都是「那一行自己的下边线」(.row 的 border-bottom),
|
|
@@ -1208,6 +1238,50 @@
|
|
|
1208
1238
|
}
|
|
1209
1239
|
.sheet .foot .btn { flex: 1 1 0; text-align: center; }
|
|
1210
1240
|
|
|
1241
|
+
/* ---------- 「电脑端设置界面」那一整屏(内嵌,不跳转) ----------
|
|
1242
|
+
原委写在页面里 `#mirrorView` 上面那段注释里:跳转那条路在用户手机上三次都没成,
|
|
1243
|
+
所以改成嵌在这一页里——点一下只是把这一层显示出来、给里面的框设个地址,
|
|
1244
|
+
**浏览器没有任何东西可以拦**。
|
|
1245
|
+
|
|
1246
|
+
层级 80:要盖住设置抽屉(50)和那些动态小面板(70),但**必须在**授权卡(300+)
|
|
1247
|
+
和门禁页(100)**下面**——那两样是打断性的,任何时候都该在最上面。
|
|
1248
|
+
|
|
1249
|
+
`[hidden]` 那一句不能省:下面写了 display: flex,作者样式表里的 [hidden] 会被压住
|
|
1250
|
+
(和 `.sheet .version[hidden]` 同一个道理,见上面那段注释)。 */
|
|
1251
|
+
#mirrorView {
|
|
1252
|
+
position: fixed; inset: 0; z-index: 80;
|
|
1253
|
+
/* 高度跟「看得见的那块」走,而不是跟「地址栏收起时的那块」走(2026-10-07)。
|
|
1254
|
+
`inset:0` 的高度是按**大视口**算的(地址栏收起时的高度),而手机地址栏拉出来之后,
|
|
1255
|
+
真正看得见的只有下面那一截——于是框比可视区高,里面那份界面在框里**垂直居中**,
|
|
1256
|
+
底边就落到屏幕外面去了(用户报过「面板底边被切掉」)。
|
|
1257
|
+
`100dvh` 跟可视区走。**这条是「从参考插件学来、但落点要换」的典型**:它给官方面板补
|
|
1258
|
+
`max-height:calc(100dvh - 32px)`,可那个 `dvh` 是**框里面**的,框里的可视区单位等于
|
|
1259
|
+
框自己的高,等于白写;要治这个病只能写在**外层**这一页——这里 `dvh` 才是真的。
|
|
1260
|
+
老浏览器不认 `dvh` 会丢掉这一条,退回现在的行为,不会崩。 */
|
|
1261
|
+
height: 100dvh;
|
|
1262
|
+
display: flex; flex-direction: column;
|
|
1263
|
+
background: var(--bg);
|
|
1264
|
+
}
|
|
1265
|
+
#mirrorView[hidden] { display: none; }
|
|
1266
|
+
.mv-bar {
|
|
1267
|
+
flex: 0 0 auto; display: flex; align-items: center; gap: 10px;
|
|
1268
|
+
padding: 10px 12px;
|
|
1269
|
+
padding-top: calc(10px + env(safe-area-inset-top, 0px));
|
|
1270
|
+
border-bottom: 1px solid var(--line);
|
|
1271
|
+
background: var(--bg-top);
|
|
1272
|
+
}
|
|
1273
|
+
.mv-title { flex: 1 1 auto; font-size: 14px; color: var(--fg); }
|
|
1274
|
+
#mirrorFrame {
|
|
1275
|
+
flex: 1 1 auto; width: 100%; border: 0; background: var(--bg);
|
|
1276
|
+
/* 底部让开手势条(2026-10-07 从「被镜像的那份页面」搬回来的)。
|
|
1277
|
+
这条原先是写在 lib/mirror.js 的 ADAPT_CSS 里,而那段样式是**注进框里那份文档**的
|
|
1278
|
+
——`#mirrorFrame` 是本文件(外层)的元素,两个不同的文档,所以那条规则一个像素
|
|
1279
|
+
都没生效:活服务上量过,框的 `padding-bottom` 是 0px,被镜像的那份 HTML 里反倒
|
|
1280
|
+
躺着一份没人用的 `#mirrorFrame` 规则(官方界面里根本没有这个 id)。
|
|
1281
|
+
顶部那条不归这里管:外层横条 .mv-bar 自己写了 padding-top。 */
|
|
1282
|
+
padding-bottom: env(safe-area-inset-bottom, 0px); box-sizing: border-box;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1211
1285
|
/* ---------- 统一操作面板(输入框旁边那一个入口点开的东西) ----------
|
|
1212
1286
|
骨架和设置抽屉**是同一套**:同一个 `#xxx.open` 即时显隐、同一个贴底的 `.sheet`
|
|
1213
1287
|
(它自带 max-height / overflow-y / safe-area 那三样,窄屏顶不出去),不新建样式体系。
|
|
@@ -1317,6 +1391,208 @@
|
|
|
1317
1391
|
width: 18px; height: 18px; fill: none; stroke: currentColor;
|
|
1318
1392
|
stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round;
|
|
1319
1393
|
}
|
|
1394
|
+
|
|
1395
|
+
/* ---------- 「回到底部」浮标(2026-10-08) ----------
|
|
1396
|
+
用户要的是「瞬间回到底部的按钮,圈子里一个简约的往下箭头」,参考电脑端 DSH 那颗。
|
|
1397
|
+
|
|
1398
|
+
形状照电脑端那颗抄:圆底 + 一根下折线(矢量 svg,不是字形、不是 emoji——
|
|
1399
|
+
字形换个机型就可能变成豆腐块)。尺寸不一样:电脑端 34×34,这一颗 38×38
|
|
1400
|
+
(第四轮缩到 27、第五轮回到 31,见下面那两段)。
|
|
1401
|
+
圆角走 999px 那一档,不新造档位。
|
|
1402
|
+
|
|
1403
|
+
---- 第二轮:用户要「克制而高级的主题适配」,外观只动下面这三处 ----
|
|
1404
|
+
|
|
1405
|
+
① 边界从「画上去的一根 1px 线」改成「从面里长出来的一道 0.5px 环」:
|
|
1406
|
+
去掉 border,环改由 box-shadow 的第一层 `0 0 0 .5px` 来画。这不是自创——
|
|
1407
|
+
电脑端那套高度系统本来就长这样(dsh-client-ui-theme 里那道 elevation-stroke
|
|
1408
|
+
就是「0 0 0 .5px 加描边色」,官方那颗浮标挂的 elevation-panel 第一位正是它)。
|
|
1409
|
+
0.5px 在 2 倍屏上正好落成 1 个物理像素:比 1px 细一半,看得见轮廓,
|
|
1410
|
+
又找不到那根线。颜色仍走 --line(全页的细线都用它)。
|
|
1411
|
+
|
|
1412
|
+
② 投影换轻一档,深色单独处理:
|
|
1413
|
+
· 浅色用 --sh-1,不再用 --sh-2。--sh-2(0 2px 8px / 12%)是这两档里更重的
|
|
1414
|
+
一档,压在这颗 38px 的小圆上像贴了块膏药;--sh-1(0 1px 2px / 7%)
|
|
1415
|
+
同一个蓝调,更小更柔。官方那套的第二三层(0 3px 8px @3% 加 0 0 16px @2%)
|
|
1416
|
+
比这更浅也更宽——要贴到那个程度就得现调一组新数,与这一轮「克制」的调子
|
|
1417
|
+
相悖,所以取现成的那一档。
|
|
1418
|
+
· 深色把投影层整个去掉。实测它在深色下等于零:浮标落在页面底色渐变最暗的
|
|
1419
|
+
那一头(近黑的 #070b11),再压一层黑色投影,同一像素开关投影前后最多差
|
|
1420
|
+
5/255(整幅平均 0.07/255)。层次改由**底色本身**立——实测按钮的 --panel #121a25 比它脚下的
|
|
1421
|
+
背景 #070b11 亮 (+11,+15,+20),本身就已经高出一档,和官方那颗(深色下用
|
|
1422
|
+
比背景亮的浮层底色)是同一个道理。底线是静息色不能改成 --panel-2:
|
|
1423
|
+
那是这颗按钮**按下**时的底色,拿它当静息色,按下去就没有反馈了。
|
|
1424
|
+
|
|
1425
|
+
③ 看得见的圆缩到 38,手指的范围反而变大:原来 42×42 是「看得见多大就点得到
|
|
1426
|
+
多大」,而 42 其实低于常说的那条 44 下限;现在圆缩到 38,看不见的热区用
|
|
1427
|
+
::before 撑到 46×46。图标等比缩到 14.5px(16 × 38/42),箭头在圆里占的比例
|
|
1428
|
+
和原来一样,既不空也不挤。
|
|
1429
|
+
|
|
1430
|
+
颜色**一个令牌都不新造**,全用主屏现成的那几个:底 --panel、线 --line、
|
|
1431
|
+
图标 --fg、按下 --panel-2、投影 --sh-1。两套主题各自成对。
|
|
1432
|
+
|
|
1433
|
+
---- 第四轮(2026-10-08):看得见的面积减半,同时往左让开滚动条那一条 ----
|
|
1434
|
+
|
|
1435
|
+
用户两条原话:「这个按钮还可以再缩小一半的面积」「如果旁边滚动条显示的话,
|
|
1436
|
+
点不下去,是否可以触压位往左侧再拖拽一些」。两件事分开算:
|
|
1437
|
+
|
|
1438
|
+
① 面积减半 = 直径乘 √2 的一半(0.707),不是把直径砍一半。
|
|
1439
|
+
38 × 0.707 = 26.87 → **27**。(真把直径砍成 19,面积就只有四分之一,
|
|
1440
|
+
那不是「减半」,是「小得离谱」。)
|
|
1441
|
+
看得见的地方小一半,可点的地方一点没缩:热区仍 **≥44**(46 → 45,见 ::before),
|
|
1442
|
+
这是手指那条下限,不能跟着圆一起缩。
|
|
1443
|
+
图标跟着等比缩:16 × 27/38 = **10.3px**——占圆的比例和 38 那会儿一模一样,
|
|
1444
|
+
所以圆里既不空也不挤。
|
|
1445
|
+
|
|
1446
|
+
② 往左让开滚动条(这条更要紧,用户是点不下去才说的)。两件事都在**活页面**上量过:
|
|
1447
|
+
· 手机仿真(= 手机上的实际形态)下 main 的 `offsetWidth - clientWidth` **= 0**:
|
|
1448
|
+
滚动条是**覆盖式**的,版面上一个像素都不占,内容区照样通到最右边 390。
|
|
1449
|
+
所以按钮的右沿天生停在 390 − 14(main 的 padding-right)= **376**,
|
|
1450
|
+
右边只剩 14px——滚动条一冒出来就贴在那一条上,正好压住按钮右半边。
|
|
1451
|
+
· 换一个**经典式**(占宽度)的形态量,滚动条占的正是**最右边那条 15px**
|
|
1452
|
+
(x 375..390,盒模型与像素两边对得上,见 ui.test.mjs 里那条断言)。
|
|
1453
|
+
也就是:不管哪一种形态,那条带都从 375 起头。
|
|
1454
|
+
于是往左挪的量按**实测那条带**算,不拍脑袋:
|
|
1455
|
+
滚动条带左沿 375 —— 热区右沿要退到 372(留 3px 余量,免得取整蹭上去)
|
|
1456
|
+
—— 热区比圆右侧多出 9px(inset −9)—— 所以圆的右沿要退到 363。
|
|
1457
|
+
现在圆右沿是 376,**往左 13px** 正好落在 363。
|
|
1458
|
+
落法只有一处:`.to-bottom` 自己的 `margin-right: 13px`。槽是 flex-end,
|
|
1459
|
+
给它一个右外边距就等于把它从槽的右沿往里推 13px。
|
|
1460
|
+
sticky 那套机制、main 的布局、滚动条的样式,一行都没碰。
|
|
1461
|
+
(经典式形态下滚动条本来就把内容区挤窄 15px,那时圆右沿是 361,
|
|
1462
|
+
再让 13px 是 348——只会更宽裕,不会挤到别处。)
|
|
1463
|
+
|
|
1464
|
+
为什么不用脚本算滚动条有多宽:覆盖式的宽度脚本根本量不到(上面那个 0 就是证据),
|
|
1465
|
+
而且 sticky 的位置本来就不该由脚本碰。按实测到的那条**最宽**的带留出固定余量,
|
|
1466
|
+
两种形态都盖得住,这才是稳的写法。
|
|
1467
|
+
|
|
1468
|
+
---- 第五轮(2026-10-08):用户说「有点缩得太小了,增大 15%」 ----
|
|
1469
|
+
|
|
1470
|
+
① 「15%」按**看得见的直径**读:27 × 1.15 = 31.05 → **31**。
|
|
1471
|
+
(另一种读法是「面积增大 15%」,那只有 27 × √1.15 = 28.96 → 29。
|
|
1472
|
+
两个读法只差 2px;这颗圆刚被上一轮削到 27,取大的那个才叫「不再偏小」。)
|
|
1473
|
+
图标继续等比:10.3 × 31/27 = **11.8px**——箭头在圆里占的比例,和 38、27 那两轮
|
|
1474
|
+
一模一样(16/38 = 10.3/27 = 11.8/31),所以圆里既不空也不挤。
|
|
1475
|
+
`margin-top` 必须跟着等于 −31:它是负的自身高度,槽高 0,只有相等下沿才落在槽线上。
|
|
1476
|
+
|
|
1477
|
+
② 热区一个数都没动,它自己长大了:31 + 9×2 = **49**(44 那条手指下限照旧守住)。
|
|
1478
|
+
这正是 inset 不必跟着改的原因——圆缩时热区不许跟着缩,圆长时它跟着长是白赚的。
|
|
1479
|
+
|
|
1480
|
+
③ **第四轮那条「让开滚动条」没有被弄坏,因为要守的是右沿、不是宽度**:
|
|
1481
|
+
按钮走 flex-end,直径一变只往**左**边长;右沿的位置只由
|
|
1482
|
+
main 的 padding-right(14)加按钮自己的 margin-right(13)定,
|
|
1483
|
+
所以圆的右沿仍是 376 − 13 = **363**,热区再往右 9px 是 **372**,
|
|
1484
|
+
离滚动条那条带(左沿 375)仍是 3px。margin-right 一个字没动。
|
|
1485
|
+
|
|
1486
|
+
位置的关键在 .to-bottom-slot:它是 main 的最后一个孩子,靠 `position: sticky`
|
|
1487
|
+
贴在**滚动口下沿往上 16px** 的地方。这一手的好处是位置不用脚本算——
|
|
1488
|
+
main 的下沿天生就是输入区那一摞(附件条 / 指令菜单 / 底栏)的上沿,
|
|
1489
|
+
所以输入框长高、附件条冒出来、指令菜单弹开,它都跟着一起让位,永远不会盖住
|
|
1490
|
+
输入框和发送键;底部那条手势条也由 footer 自己的 safe-area 内边距让开了,
|
|
1491
|
+
它比输入区还高一截,离手势条更远。 */
|
|
1492
|
+
.to-bottom-slot {
|
|
1493
|
+
position: sticky; bottom: 16px; z-index: 5;
|
|
1494
|
+
height: 0; display: flex; justify-content: flex-end;
|
|
1495
|
+
/* 这一条不能省:槽是通栏的,不设 none 就会在底边上拦掉底下内容的点击。
|
|
1496
|
+
按钮自己再打开 auto,所以只有那一个圆圈可点。 */
|
|
1497
|
+
pointer-events: none;
|
|
1498
|
+
}
|
|
1499
|
+
.to-bottom {
|
|
1500
|
+
/* position 只是为了给下面那片透明热区一个定位原点;左右位置由 margin-right 让出来。 */
|
|
1501
|
+
position: relative;
|
|
1502
|
+
/* margin-top 是负的自身高度:按钮的下沿才会正好落在槽那条线上(槽高 0)。
|
|
1503
|
+
第五轮起圆是 31(第四轮 27 × 1.15),这个数就得跟着是 -31——
|
|
1504
|
+
留着 -27 会把它整圈**下**移 4px(下沿掉到槽线下面去)。
|
|
1505
|
+
margin-right: 13px 是第四轮那条「让开滚动条」的全部落法,数怎么来的见上面;
|
|
1506
|
+
它让的是**右沿**,而右沿只由 14 + 13 定,跟直径无关——所以这一条不用重算。 */
|
|
1507
|
+
width: 31px; height: 31px; margin-top: -31px; margin-right: 13px; padding: 0;
|
|
1508
|
+
border: 0; border-radius: 999px;
|
|
1509
|
+
background: var(--panel); color: var(--fg);
|
|
1510
|
+
/* ① 第一层就是那道 0.5px 环;深色到此为止(② 的深色那半:投影在那儿等于零)。 */
|
|
1511
|
+
box-shadow: 0 0 0 .5px var(--line);
|
|
1512
|
+
cursor: pointer; pointer-events: auto;
|
|
1513
|
+
display: flex; align-items: center; justify-content: center;
|
|
1514
|
+
/* ---- 第三轮(2026-10-08):出现 / 消失各来一段 130ms 的淡入淡出 ----
|
|
1515
|
+
用户原话「这个按键消失的时候应该有个渐出效果,官方 pc 端是做了的」。
|
|
1516
|
+
真去量了电脑端那份界面(/mini/mirror 上逐帧采 opacity + MutationObserver 看 DOM),
|
|
1517
|
+
结论是**两半都对一半**,两头都得记下来:
|
|
1518
|
+
|
|
1519
|
+
· 电脑端那颗按钮**身上确实挂着一条 `opacity .13s ease`**——但它不是 ChatView 自己的
|
|
1520
|
+
样式,是外形(skin)那层的基础按钮规则给的(选择器是
|
|
1521
|
+
`html[data-dsh-skin="claude"] button, …[role="button"], …`,实测这条很清楚地匹配到
|
|
1522
|
+
那颗浮标)。所以「官方做了渐出」这句话在**声明**上是对的。
|
|
1523
|
+
· 可它**画面上从来没淡过**:ChatView 是 `!followingTail && <div class=toBottomSlot>…`
|
|
1524
|
+
的条件挂载,一滚到底整块节点直接被 React 摘掉,那条 transition 根本没有机会跑。
|
|
1525
|
+
活服务上逐帧采样实测:消失前后只有两个状态(t≈1.5ms 时 opacity=1 且 isConnected=true,
|
|
1526
|
+
t≈18.6ms 时节点已 isConnected=false、computed 全空),中间**一帧过渡值都没有**;
|
|
1527
|
+
MutationObserver 同一时刻记下的是 toBottomSlot 被 childList 整块移除,没有任何
|
|
1528
|
+
「先加类、过一会儿再摘」的动作。也就是说:**设计语言里那一档是 130ms ease,
|
|
1529
|
+
但官方的手法是硬卸载**。
|
|
1530
|
+
|
|
1531
|
+
所以这里按用户的要求把它补上,并且**只补官方自己没接上的那一段**:时长与缓动
|
|
1532
|
+
直接取官方那颗按钮身上现成的那一档 `130ms / ease`,不另起一个数。
|
|
1533
|
+
方向对称:消失淡出、出现淡入,两边同一档,不做缩放、不做弹跳。
|
|
1534
|
+
|
|
1535
|
+
为什么不用 `display:none` 收:`display:none` 一挂上,元素就不在渲染树里了,
|
|
1536
|
+
过渡根本无从谈起——所以「用 display 收尾」和「要一段淡出」这两件事天生互斥。两条正路:
|
|
1537
|
+
(a) 脚本两端配合(先淡出,等过渡结束再置 hidden);
|
|
1538
|
+
(b) `transition-behavior: allow-discrete` + `@starting-style`。
|
|
1539
|
+
这里走的是第三条、也是同一件事的**更保险写法**:藏起来用
|
|
1540
|
+
`visibility: hidden` + `pointer-events: none` 代替 `display:none`。
|
|
1541
|
+
理由有两条,都是实打实的:
|
|
1542
|
+
· 兼容面。`allow-discrete` / `@starting-style` 要 Chrome 117 / Safari 17.4 /
|
|
1543
|
+
Firefox 129 往上;手机浏览器五花八门,老一档的机器上那两条会被整条丢掉,
|
|
1544
|
+
结果就是**一点淡出都没有**——正好回到用户抱怨的那个状态。而 `visibility`
|
|
1545
|
+
的过渡是十年以上的老写法,哪台机器都认。
|
|
1546
|
+
· 「过渡走完才真正藏起来」这件事由浏览器替我办:`visibility` 的插值规则很特别,
|
|
1547
|
+
只要两端有一端是 `visible`,整个过渡期间它就一直算 `visible`,
|
|
1548
|
+
到进度 1 才翻成 `hidden`。所以淡出那 130ms 里它照样画在屏幕上,
|
|
1549
|
+
走完就真的不可见——等于把 (a) 交给浏览器做,脚本一行都不用加,
|
|
1550
|
+
`paintToBottom` 仍然是「只改 hidden」那一个动作(页面测试也是这么钉的)。
|
|
1551
|
+
|
|
1552
|
+
`visibility` 那条也顺手把「出现」这半边解决了:元素在藏着的时候**仍然有计算样式**
|
|
1553
|
+
(因为它不是 display:none),opacity 老老实实是 0,所以一露出来就是 0→1 的淡入,
|
|
1554
|
+
不需要 `@starting-style` 去给一个起点。
|
|
1555
|
+
|
|
1556
|
+
最后一句:这里只有 `transition`,没有关键帧、没有 animation——
|
|
1557
|
+
它是一颗按钮,不是一段表演(`test/ui.test.mjs` 里那条护栏就是钉这个的)。 */
|
|
1558
|
+
transition: opacity .13s ease, visibility .13s;
|
|
1559
|
+
opacity: 1; visibility: visible;
|
|
1560
|
+
}
|
|
1561
|
+
/* ③ 手指要的那 44 由这片**看不见**的热区补上:圆 31,热区 49。
|
|
1562
|
+
绝对定位 + 负 inset 让它溢出去,不参与版式(槽的高度还是 0,位置照旧全交给
|
|
1563
|
+
CSS 的 sticky);它是按钮自己的伪元素,命中的点事件目标仍然是按钮,所以
|
|
1564
|
+
「只有那一个圆圈可点」这条规矩没破,只是那个圈比看得见的那个大 9px。
|
|
1565
|
+
inset 从 −4 放到 −9 是**跟着第四轮那个 27 一起算的**:27 + 9×2 = 45 ≥ 44。
|
|
1566
|
+
第五轮圆长到 31 之后一直是 31 + 9×2 = 49 ≥ 44,所以这个数**不用动**——
|
|
1567
|
+
热区跟着圆一起长是白赚的,而它绝不会跟着圆一起缩(那是手指的下限,不是外观)。 */
|
|
1568
|
+
.to-bottom::before {
|
|
1569
|
+
content: ''; position: absolute; inset: -9px; border-radius: 999px;
|
|
1570
|
+
}
|
|
1571
|
+
/* ② 浅色那边:环比深色**深一档**,再叠第二层更安静的那一档投影(深色两层都不动)。
|
|
1572
|
+
为什么不用现成变量:浅色这套色阶里,--line(相对亮度 0.747)往下一格是空的——
|
|
1573
|
+
再往下就只剩 --dim(0.298)和 --muted(0.207),一用就把「一道细线」变成「一圈描边」
|
|
1574
|
+
(实测它们相对底色要差 85 / 109 个灰阶、对比度 2.4 / 3.1:1,放在这颗小圆上太重)。
|
|
1575
|
+
所以在 --line 与 --fg(正文色,同一个蓝调家族)之间取一成的 --fg:
|
|
1576
|
+
实测环相对相邻底色由 11/255 变成 28/255(对比度 1.096 → 1.292:1),
|
|
1577
|
+
刚好从「几乎看不见」跨成「看得见的一道细线」,离 --dim 那一档还差着三倍。
|
|
1578
|
+
颜色仍全部来自现成令牌,不写死任何色值;深色那边一个数没动,还是 --line。 */
|
|
1579
|
+
[data-theme="light"] .to-bottom {
|
|
1580
|
+
box-shadow: 0 0 0 .5px color-mix(in srgb, var(--line) 90%, var(--fg)), var(--sh-1);
|
|
1581
|
+
}
|
|
1582
|
+
.to-bottom:active { background: var(--panel-2); }
|
|
1583
|
+
/* 藏起来的那个状态。**这一句不能省**:作者样式表里的 display:flex 会顶掉 hidden 自带的
|
|
1584
|
+
display:none,所以「藏」这件事必须由我们自己在这条规则里写实。
|
|
1585
|
+
原来写的是 display:none,现在换成下面这三条(为什么换见上面那段长注释):
|
|
1586
|
+
· opacity: 0 —— 淡出的终点,130ms 里从 1 走到 0;
|
|
1587
|
+
· visibility: hidden —— 过渡期间它一直是 visible(visibility 的插值规则),
|
|
1588
|
+
走完才翻成 hidden,所以真正不可见发生在淡出**结束之后**;
|
|
1589
|
+
· pointer-events: none —— 这一条**不进过渡**,属性一挂上就立刻生效,
|
|
1590
|
+
所以淡出那 130ms 里它已经点不到了;淡完之后它也不是一层透明的可点层。
|
|
1591
|
+
版式一个像素都没动:槽高是 0,按钮靠 margin-top 负的自身高度挂在槽线上方,
|
|
1592
|
+
这两个状态里它的盒子一样大,所以不产生任何位移。 */
|
|
1593
|
+
.to-bottom[hidden] { opacity: 0; visibility: hidden; pointer-events: none; }
|
|
1594
|
+
/* 箭头等比缩:10.3 × 31/27 = 11.8。占圆的比例和 27、38 那两轮一样,不空也不挤。 */
|
|
1595
|
+
.to-bottom svg { width: 11.8px; height: 11.8px; }
|
|
1320
1596
|
</style>
|
|
1321
1597
|
<script>
|
|
1322
1598
|
// 主题必须在**首屏画出来之前**定下来,否则浅色手机打开会先闪一下深色底。
|
|
@@ -1413,6 +1689,22 @@
|
|
|
1413
1689
|
排队和鲸鱼娘是两件事——前者是「还有几条在等」,后者是「她正在忙」,
|
|
1414
1690
|
别因为后者要藏就把前者一起藏了。 -->
|
|
1415
1691
|
<div class="queue" id="queue" hidden></div>
|
|
1692
|
+
|
|
1693
|
+
<!-- 「回到底部」浮标(2026-10-08 用户要求)。样子参考电脑端 DSH 那颗
|
|
1694
|
+
(ChatView.module.css 的 .xz4KEq_toBottom:34×34 圆底 + 一根 14×14 的下折线 svg),
|
|
1695
|
+
位置、尺寸和配色按手机这页的规格重做,见样式表里那一段。
|
|
1696
|
+
|
|
1697
|
+
**它必须留在 #reply 外面**:跟 #work 同一个道理——拼进重绘里的话,每次状态广播
|
|
1698
|
+
都会把它重建一次,用户正按着它就没了。也**必须在 main 里面**:只有作为滚动口的孩子,
|
|
1699
|
+
CSS 的 sticky 才贴得住(位置由 CSS 算,脚本一个像素都不碰)。
|
|
1700
|
+
|
|
1701
|
+
箭头路径原样抄自电脑端那颗图标(dsh-client-ui-primitives 的 IconChevronDownOutline),
|
|
1702
|
+
没有自己重画:viewBox 0 0 16 16、1px 描边、fill:none、颜色走 currentColor。 -->
|
|
1703
|
+
<div class="to-bottom-slot" id="toBottomSlot">
|
|
1704
|
+
<button class="to-bottom" id="toBottom" type="button" aria-label="回到底部" hidden>
|
|
1705
|
+
<svg viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1"><path d="M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6"/></svg>
|
|
1706
|
+
</button>
|
|
1707
|
+
</div>
|
|
1416
1708
|
</main>
|
|
1417
1709
|
|
|
1418
1710
|
<div class="attach-bar" id="attachBar" hidden></div>
|
|
@@ -1598,6 +1890,37 @@
|
|
|
1598
1890
|
<button class="btn" id="btnPlugins">查看</button>
|
|
1599
1891
|
</div>
|
|
1600
1892
|
|
|
1893
|
+
<!--
|
|
1894
|
+
「进阶设置(电脑端界面)」(2026-10-06 用户要求)。
|
|
1895
|
+
|
|
1896
|
+
手机上这张遥控页是**专门为手机做的**,所以电脑上那些插件贡献的设置页它进不去。
|
|
1897
|
+
这一项是退路:**把电脑端的界面整个搬过来看**——同一份界面,不迁移、不逐个适配。
|
|
1898
|
+
|
|
1899
|
+
**默认关,而且开着要付代价**:打开它 = 把这个令牌的权限从「我们那张手机页暴露的
|
|
1900
|
+
那点事」扩到**整个电脑端设置面**(模型配置、凭据页、插件装卸、会话记录……)。
|
|
1901
|
+
所以:默认关、后果写在提示里、开启要点两次(见下面 tapMirror)。
|
|
1902
|
+
|
|
1903
|
+
服务端没这个能力时(没拿到 DSH_WEB_URL)整行不显示——**不摆一个假的开关**,
|
|
1904
|
+
和「繁忙时的发送行为」同一条口径。 -->
|
|
1905
|
+
<div class="row" id="rowMirror" hidden>
|
|
1906
|
+
<div>
|
|
1907
|
+
<div class="label">进阶设置</div>
|
|
1908
|
+
<div class="hint" id="mirrorHint">打开电脑上的 DSH 界面,改那些手机上没有的设置</div>
|
|
1909
|
+
</div>
|
|
1910
|
+
<div class="seg" id="segMirror">
|
|
1911
|
+
<button data-mirror="on">开</button>
|
|
1912
|
+
<button data-mirror="off">关</button>
|
|
1913
|
+
</div>
|
|
1914
|
+
</div>
|
|
1915
|
+
|
|
1916
|
+
<div class="row" id="rowMirrorOpen" hidden>
|
|
1917
|
+
<div>
|
|
1918
|
+
<div class="label">电脑端界面</div>
|
|
1919
|
+
<div class="hint">电脑端dsh完整页面</div>
|
|
1920
|
+
</div>
|
|
1921
|
+
<button class="btn" id="btnMirrorOpen" type="button">打开</button>
|
|
1922
|
+
</div>
|
|
1923
|
+
|
|
1601
1924
|
<div class="foot">
|
|
1602
1925
|
<button class="btn" id="btnForget">清除 token</button>
|
|
1603
1926
|
<button class="btn primary" id="btnClose">完成</button>
|
|
@@ -1608,6 +1931,33 @@
|
|
|
1608
1931
|
</div>
|
|
1609
1932
|
</div>
|
|
1610
1933
|
|
|
1934
|
+
<!--
|
|
1935
|
+
「电脑端设置界面」那一整屏:**嵌在这一页里,不跳转、不开新标签。**
|
|
1936
|
+
|
|
1937
|
+
## 为什么最后做成内嵌(2026-10-06 用户三次实机报「点击打开没反应」)
|
|
1938
|
+
|
|
1939
|
+
前两版都想让它「打开」出去,都栽了:
|
|
1940
|
+
· 第一版 `<a target="_blank">`:内嵌浏览器(微信这类)常把新标签直接拦掉;
|
|
1941
|
+
· 第二版加了 JS 兜底(`window.open` 失败就同标签跳):还是没反应——那些浏览器里
|
|
1942
|
+
`window.open` 会「成功」返回一个对象,代码以为开好了就不再跳,而新标签开在后台;
|
|
1943
|
+
· 第三版干脆用普通 `<a href>` 同标签跳(理论上拦不掉):**用户那边仍然没反应**。
|
|
1944
|
+
|
|
1945
|
+
三次都在赌「跳转」这个动作本身。**这一版不赌了**:点一下只是把这一层显示出来、
|
|
1946
|
+
给里面的框设个地址——没有跳转、没有新标签、没有弹窗,**浏览器没有任何东西可以拦**。
|
|
1947
|
+
已经用真浏览器验过:那个界面**愿意被嵌**(不设「不许被嵌」的响应头,实测里面
|
|
1948
|
+
标题、侧栏、欢迎页全都正常)。
|
|
1949
|
+
|
|
1950
|
+
另外整行都能点(不只右边那个按钮)——按钮只有 54×40,手指容易点偏。
|
|
1951
|
+
-->
|
|
1952
|
+
<div id="mirrorView" hidden>
|
|
1953
|
+
<div class="mv-bar">
|
|
1954
|
+
<span class="mv-title">电脑端界面</span>
|
|
1955
|
+
<button class="btn" id="mirrorClose" type="button">返回</button>
|
|
1956
|
+
</div>
|
|
1957
|
+
<!-- 地址是点开那一刻才设的:没点之前一个字节都不加载。 -->
|
|
1958
|
+
<iframe id="mirrorFrame" title="电脑端界面"></iframe>
|
|
1959
|
+
</div>
|
|
1960
|
+
|
|
1611
1961
|
<!-- 统一操作面板:输入框旁边那一个入口点开的就是它,上面「添加文件」、下面「调用指令」。
|
|
1612
1962
|
原来这两件事各有各的门(回形针直接开文件选择器、打 `/` 才弹指令菜单),手机上没人
|
|
1613
1963
|
知道打一个斜杠还能调指令。
|
|
@@ -2743,13 +3093,30 @@
|
|
|
2743
3093
|
if (list.length > shown) nodes += '<div class="q-node"><i></i><b>…</b></div>';
|
|
2744
3094
|
var rows = '';
|
|
2745
3095
|
for (var i = 0; i < list.length; i += 1) {
|
|
3096
|
+
// 插话和排队**要分得出来**(2026-10-06):服务端每条都带 placement,
|
|
3097
|
+
// `next-step` 是插话(下一个步骤边界就取走,通常几秒),
|
|
3098
|
+
// `next-turn` 是排队(等这一轮跑完,可能几分钟)。原来两条画得一模一样。
|
|
3099
|
+
var isSteer = list[i].placement === 'next-step';
|
|
2746
3100
|
rows += '<div class="q-row">'
|
|
3101
|
+
+ (isSteer ? '<span class="q-tag">插话</span>' : '')
|
|
2747
3102
|
+ '<span class="q-text">' + escapeHtml(list[i].text) + '</span>'
|
|
2748
3103
|
+ '<button class="q-drop" type="button" data-drop="' + escapeHtml(list[i].id) + '"'
|
|
2749
3104
|
+ ' aria-label="撤掉这条"><svg viewBox="0 0 24 24" aria-hidden="true">'
|
|
2750
3105
|
+ '<path d="M6 6l12 12M18 6L6 18"/></svg></button></div>';
|
|
2751
3106
|
}
|
|
2752
|
-
|
|
3107
|
+
// 标题得跟着实际内容说:**只要有一条插话,「跑完这轮就轮到它们」就是假话**。
|
|
3108
|
+
// 三种情况各说各的,不糊一句通用的。
|
|
3109
|
+
var steerCount = 0;
|
|
3110
|
+
for (var k = 0; k < list.length; k += 1) {
|
|
3111
|
+
if (list[k].placement === 'next-step') steerCount += 1;
|
|
3112
|
+
}
|
|
3113
|
+
var waitCount = list.length - steerCount;
|
|
3114
|
+
var head = steerCount && waitCount
|
|
3115
|
+
? '插话 ' + steerCount + ' 条(下一步就看到) · 排队 ' + waitCount + ' 条'
|
|
3116
|
+
: steerCount
|
|
3117
|
+
? '插话 ' + steerCount + ' 条 · 它下一步就看到'
|
|
3118
|
+
: '排队中 ' + list.length + ' 条 · 跑完这轮就轮到它们';
|
|
3119
|
+
el.innerHTML = '<div class="q-head">' + head + '</div>'
|
|
2753
3120
|
+ '<div class="q-scale">' + nodes + '</div>' + rows;
|
|
2754
3121
|
}
|
|
2755
3122
|
|
|
@@ -3734,9 +4101,15 @@
|
|
|
3734
4101
|
var kind = last && last.kind === 'tool' ? trajActivity(last.name || '') : 'thinking';
|
|
3735
4102
|
// 活片段优先(正在想的那一句,随打字刷新,PC 的 label · detail 同款);
|
|
3736
4103
|
// 没有片段才退到条目自己的摘要。
|
|
3737
|
-
var
|
|
3738
|
-
|| (last
|
|
3739
|
-
|
|
4104
|
+
var prose = state.trajectoryLive
|
|
4105
|
+
|| (last && last.kind === 'think' ? trajFirstLine(last.output) : '');
|
|
4106
|
+
var plain = !prose && last ? (last.summary || '') : '';
|
|
4107
|
+
// 右边那半边是模型原话时走行内渲染:**加粗**、`代码` 要成样式,不是把记号原样贴在
|
|
4108
|
+
// 脸上(2026-10-06 用户报的,和 2026-09-27 气泡那回同一个根因)。
|
|
4109
|
+
// 工具摘要**不**走——那是命令/路径原文,一条「ls *.ts *.js」里的两个星号会被斜体
|
|
4110
|
+
// 规则吃掉(在真渲染器上试过),摘要就照旧只转义。
|
|
4111
|
+
var detail = prose ? mdInline(prose) : (plain ? escapeHtml(plain) : '');
|
|
4112
|
+
return { kind: kind, live: true, text: TRAJ_LIVE[kind], detailHtml: detail };
|
|
3740
4113
|
}
|
|
3741
4114
|
return trajTitleOf(g);
|
|
3742
4115
|
}
|
|
@@ -3791,8 +4164,11 @@
|
|
|
3791
4164
|
function trajGroupHtml(g) {
|
|
3792
4165
|
var open = trajGroupIsOpen(g);
|
|
3793
4166
|
var t = trajGroupTitle(g);
|
|
4167
|
+
// 右半边(活片段/思考原话)在来源处已经渲染好、也转义过,见 trajGroupTitle;
|
|
4168
|
+
// 状态词与收官标题是我们自己拼的词,这里照旧转义。
|
|
3794
4169
|
var title = t.live
|
|
3795
|
-
? '<span class="traj-run traj-shimmer"' + trajShimStyle() + '>' + escapeHtml(t.text)
|
|
4170
|
+
? '<span class="traj-run traj-shimmer"' + trajShimStyle() + '>' + escapeHtml(t.text)
|
|
4171
|
+
+ (t.detailHtml ? ' · ' + t.detailHtml : '') + '</span>'
|
|
3796
4172
|
: '<span class="traj-title">' + escapeHtml(t.text) + '</span>';
|
|
3797
4173
|
// 组头只显示状态词/标题,失败信息在下方条目行里显示,不重复。
|
|
3798
4174
|
var head = '<button type="button" class="traj-head" data-traj-group="'
|
|
@@ -3898,6 +4274,33 @@
|
|
|
3898
4274
|
if (head) toggleTrajGroup(head.getAttribute('data-traj-group'));
|
|
3899
4275
|
}
|
|
3900
4276
|
|
|
4277
|
+
/**
|
|
4278
|
+
* 「回到底部」那颗浮标的露 / 收。
|
|
4279
|
+
*
|
|
4280
|
+
* 判据直接用 atBottom(),不另设一个数。这样有个好处:**浮标出现的那一刻,正好
|
|
4281
|
+
* 就是页面停止自动跟随的那一刻**——两处要是各用各的容差(比如浮标 25px、跟随 48px),
|
|
4282
|
+
* 中间那段就会出现「浮标说没到底、页面却自己在往下滚」这种自相矛盾的样子。
|
|
4283
|
+
* 电脑端那颗用的是它自己的 25px 容差(useChatReading 里的 useScrollFollow(_, 25)),
|
|
4284
|
+
* 这里跟的是本页既有的 48px,因为本页的黏底判据就是它。
|
|
4285
|
+
*
|
|
4286
|
+
* **单帧模式下一律不出现**(用户 2026-10-08 要求)。那一模式整屏只留最新一条回答,
|
|
4287
|
+
* 回答落定的那一刻页面**故意**把它的开头对齐到顶部(见 renderMinimal 里
|
|
4288
|
+
* scrolledReplyAt 那一段),好让人从第一行读起。所以那儿「不在底部」是设计如此,
|
|
4289
|
+
* **不是「用户翻上去了」**——这时候挂一颗「回到底部」,等于劝用户把刚对齐好的
|
|
4290
|
+
* 开头再滚掉,正好把这个模式唯一要做的事抵消掉。
|
|
4291
|
+
* 聊天 / 完整是多帧(一串历史一条条往上排),那里不在底部就是用户自己翻上去的,
|
|
4292
|
+
* 照旧出现。所以这条判的是**模式**(state.mode),不是新设一个「离底多少像素」。
|
|
4293
|
+
*
|
|
4294
|
+
* 只改 hidden,**不碰任何位置和尺寸**:位置全在 CSS 的 sticky 里算好了(见样式表)。
|
|
4295
|
+
* 每次现取元素、取不到就静静退出——页面测试会把 render 那一段单独抠出来跑,
|
|
4296
|
+
* 那里给的 $ 是替身,不能假设它一定认得这个 id。
|
|
4297
|
+
*/
|
|
4298
|
+
function paintToBottom() {
|
|
4299
|
+
var el = $('toBottom');
|
|
4300
|
+
if (!el) return;
|
|
4301
|
+
el.hidden = atBottom() || state.mode === 'minimal';
|
|
4302
|
+
}
|
|
4303
|
+
|
|
3901
4304
|
function render() {
|
|
3902
4305
|
$('hintMode').textContent = modeHint();
|
|
3903
4306
|
if (state.mode === 'full') renderFull(); else if (state.mode === 'chat') renderChat(); else renderMinimal();
|
|
@@ -3905,6 +4308,9 @@
|
|
|
3905
4308
|
// 进度条由 CSS 藏掉,只留「已 N 秒 / 停止」。#work 不跟着 #reply 重画,
|
|
3906
4309
|
// 所以这一下挂在总分发这里,切模式时各归各位。
|
|
3907
4310
|
$('work').className = 'work' + (state.mode === 'full' ? ' bare' : '');
|
|
4311
|
+
// 重画之后滚动位置可能被浏览器夹回来(内容变短)——那种情况不一定发 scroll 事件,
|
|
4312
|
+
// 所以浮标的状态也在这里跟一次。放在最后:上面几句可能刚动过 scrollTop。
|
|
4313
|
+
paintToBottom();
|
|
3908
4314
|
}
|
|
3909
4315
|
|
|
3910
4316
|
function applySnapshot(snap) {
|
|
@@ -5264,15 +5670,24 @@
|
|
|
5264
5670
|
|
|
5265
5671
|
api('/mini/api/send', { method: 'POST', body: JSON.stringify({ text: text, uploadIds: uploadIds }) })
|
|
5266
5672
|
.then(function (res) {
|
|
5267
|
-
//
|
|
5673
|
+
// 服务端说这条排队了 / 插话了(发的时候正在跑),给一句话。
|
|
5268
5674
|
// 队列那块也会跟着推过来,但那要等一个来回;先给个即时反馈,
|
|
5269
5675
|
// 免得用户以为又石沉大海了。
|
|
5676
|
+
//
|
|
5677
|
+
// **两条要说不同的话**(2026-10-06):插话是「下一个步骤边界就取走」,
|
|
5678
|
+
// 通常几秒;排队是「等这一轮跑完」,可能好几分钟。都回一句「已排队」
|
|
5679
|
+
// 就等于把插话也说成了排队——用户就是这么发现「插话好像没实现」的。
|
|
5680
|
+
if (res && res.steered) {
|
|
5681
|
+
toast('已插话,它下一步就看到');
|
|
5682
|
+
return;
|
|
5683
|
+
}
|
|
5270
5684
|
if (res && res.queued) {
|
|
5271
5685
|
toast('已排队,跑完这轮就轮到它');
|
|
5272
5686
|
// **排队不算发出去,所以这里不回显气泡。** 用户 2026-09-22 实机提的:
|
|
5273
5687
|
// 排队的指令立刻长成一条指令气泡,看着就像已经发出去了,其实还在队列里。
|
|
5274
5688
|
// 它已经在下面的排队列表里露过面了,那就是它此刻该待的地方;
|
|
5275
5689
|
// 轮到它真跑起来时,服务端会把它记进 history,气泡那时才出现。
|
|
5690
|
+
// 插话走的是同一条账(等它真被取走时才长气泡),所以这里一起 return。
|
|
5276
5691
|
return;
|
|
5277
5692
|
}
|
|
5278
5693
|
// 真发出去了才回显。放在这里而不是发之前,是因为**发之前还不知道会不会排队**;
|
|
@@ -5492,6 +5907,9 @@
|
|
|
5492
5907
|
watchBuild();
|
|
5493
5908
|
// 繁忙时的发送行为也现读一次:**电脑上随时能改**,开机读那一次会过期。
|
|
5494
5909
|
loadBusyEnter();
|
|
5910
|
+
// 进阶设置(电脑端界面)同样现读。它还会被电脑端从小标签关掉——
|
|
5911
|
+
// 手机上那份状态不能一直停在「开着」。
|
|
5912
|
+
loadMirror();
|
|
5495
5913
|
});
|
|
5496
5914
|
$('btnClose').addEventListener('click', function () { sheet.classList.remove('open'); });
|
|
5497
5915
|
// 顶部吸顶那个关闭口(2026-10-05 设置页改整屏时加的)。
|
|
@@ -6270,6 +6688,182 @@
|
|
|
6270
6688
|
});
|
|
6271
6689
|
});
|
|
6272
6690
|
|
|
6691
|
+
/**
|
|
6692
|
+
* 「进阶设置(电脑端界面)」(2026-10-06 用户要求)。
|
|
6693
|
+
*
|
|
6694
|
+
* 手机上这张遥控页是**专门为手机做的**,所以电脑上那些插件贡献的设置页它进不去。
|
|
6695
|
+
* 这一项是退路:**把电脑端的界面整个搬过来看**——同一份界面,不迁移、不逐个适配。
|
|
6696
|
+
* 服务端怎么把这个界面转出来的,写在 lib/mirror.js 开头。
|
|
6697
|
+
*
|
|
6698
|
+
* **它是个权限开关,不是普通偏好。** 打开它 = 这个令牌的权限从「我们这张手机页
|
|
6699
|
+
* 暴露的那点事」扩到整个电脑端设置面(模型配置、凭据页、插件装卸、会话记录……)。
|
|
6700
|
+
* 所以三件事一个都不能省:
|
|
6701
|
+
* ① 默认关;
|
|
6702
|
+
* ② 后果写在提示里,而且要等**第二次点击**才真开(`armed`)——照「完全权限」
|
|
6703
|
+
* 那一档的规矩,理由也一样(页内两步,不用 window.confirm:那种弹窗在微信
|
|
6704
|
+
* 这类内嵌浏览器里会被直接屏蔽,屏蔽时万一返回真值就等于确认被跳过);
|
|
6705
|
+
* ③ 关着的时候**入口那一行也不显示**——不是露一个点了没用的按钮。
|
|
6706
|
+
*
|
|
6707
|
+
* 读不到就整块不画:这台电脑没这个能力时(没拿到 DSH_WEB_URL),
|
|
6708
|
+
* 藏起来就好,不摆一个假开关。
|
|
6709
|
+
*/
|
|
6710
|
+
var mirror = { available: false, enabled: false, armed: false, ready: false };
|
|
6711
|
+
|
|
6712
|
+
function paintMirror() {
|
|
6713
|
+
$('rowMirror').hidden = !mirror.available;
|
|
6714
|
+
if (!mirror.available) {
|
|
6715
|
+
$('rowMirrorOpen').hidden = true;
|
|
6716
|
+
return;
|
|
6717
|
+
}
|
|
6718
|
+
Array.prototype.forEach.call($('segMirror').children, function (b) {
|
|
6719
|
+
b.classList.toggle('active', (b.dataset.mirror === 'on') === mirror.enabled);
|
|
6720
|
+
});
|
|
6721
|
+
$('mirrorHint').textContent = mirror.enabled
|
|
6722
|
+
? '已打开。手机现在能进整个电脑设置面'
|
|
6723
|
+
: mirror.armed
|
|
6724
|
+
? '打开的后果:手机将获得完整的电脑设置权限。再点一次「开」确认'
|
|
6725
|
+
: '打开电脑上的 DSH 界面,改那些手机上没有的设置';
|
|
6726
|
+
$('rowMirrorOpen').hidden = !mirror.enabled;
|
|
6727
|
+
}
|
|
6728
|
+
|
|
6729
|
+
/**
|
|
6730
|
+
* 镜像入口那个地址。
|
|
6731
|
+
*
|
|
6732
|
+
* **尾斜杠不能省**:那个界面外壳写着 `<base href="./">`,在 `/mini/mirror` 上它
|
|
6733
|
+
* 解析成 `/mini/`,在 `/mini/mirror/` 上才是 `/mini/mirror/`——差这一个字符,
|
|
6734
|
+
* 里面引用的脚本会全 404、界面白屏(真浏览器验出来的)。
|
|
6735
|
+
*
|
|
6736
|
+
* 地址里带 token:框里那些请求不一定都带得上 cookie(这个框是新开的文档),
|
|
6737
|
+
* 入口那一下顺手把 cookie 写上,之后就靠 cookie 认人了。
|
|
6738
|
+
*/
|
|
6739
|
+
function mirrorUrl() {
|
|
6740
|
+
return '/mini/mirror/?token=' + encodeURIComponent(state.token || '');
|
|
6741
|
+
}
|
|
6742
|
+
|
|
6743
|
+
/**
|
|
6744
|
+
* 点「打开」:把那一整屏显示出来,并**在那一刻**才给框设地址(没点之前不加载)。
|
|
6745
|
+
*
|
|
6746
|
+
* 这一版为什么能成:**没有跳转、没有新标签、没有弹窗**——浏览器没有任何东西可以拦。
|
|
6747
|
+
* 前两版都在赌「跳转」这个动作(详见页面里 `#mirrorView` 上面那段注释)。
|
|
6748
|
+
*/
|
|
6749
|
+
function openMirror() {
|
|
6750
|
+
if (!mirror.enabled) return;
|
|
6751
|
+
// **先给一句提示,再显示那一屏。**
|
|
6752
|
+
//
|
|
6753
|
+
// 这一句不是装饰,是**诊断**(2026-10-06 用户三次报「点了没反应」之后加的):
|
|
6754
|
+
// 三种情况从此分得开——看到提示且界面出来=成了;只看到提示=显示那一步的问题;
|
|
6755
|
+
// 连提示都没有=点击压根没到按钮上。前三次就是分不开这三种,才一路猜错。
|
|
6756
|
+
toast('正在打开电脑端界面…');
|
|
6757
|
+
// **顺序要紧:先把这一层显示出来,再给框设地址。**
|
|
6758
|
+
//
|
|
6759
|
+
// 反过来的话,那个界面是**在 `display:none` 里开始加载的**——在不可见的上下文里
|
|
6760
|
+
// 渲染出来的东西,要等用户点一下触发重绘才画全(真机上就是「图标旁边的文字
|
|
6761
|
+
// 点一下才出来」)。2026-10-06 查出:**直接打开镜像没有这个毛病**,
|
|
6762
|
+
// 差别就在这两行的先后。
|
|
6763
|
+
$('mirrorView').hidden = false;
|
|
6764
|
+
$('mirrorFrame').src = mirrorUrl();
|
|
6765
|
+
}
|
|
6766
|
+
|
|
6767
|
+
function closeMirror() {
|
|
6768
|
+
$('mirrorView').hidden = true;
|
|
6769
|
+
// 把地址清掉:那一屏里是个完整应用(还在跑、还连着),关掉就该真停,
|
|
6770
|
+
// 而不是留在后台继续占内存和连接。
|
|
6771
|
+
$('mirrorFrame').src = 'about:blank';
|
|
6772
|
+
}
|
|
6773
|
+
|
|
6774
|
+
// 按钮和**整行**都绑:那个按钮只有 54×40,手指容易点偏;整行点下去都算数。
|
|
6775
|
+
$('btnMirrorOpen').addEventListener('click', openMirror);
|
|
6776
|
+
$('rowMirrorOpen').addEventListener('click', function (e) {
|
|
6777
|
+
// 点在按钮上时上面那个监听已经处理了,别处理两遍。
|
|
6778
|
+
if (e.target === $('btnMirrorOpen')) return;
|
|
6779
|
+
openMirror();
|
|
6780
|
+
});
|
|
6781
|
+
$('mirrorClose').addEventListener('click', closeMirror);
|
|
6782
|
+
|
|
6783
|
+
/**
|
|
6784
|
+
* 框里那一页自己发的「返回手机页」。
|
|
6785
|
+
*
|
|
6786
|
+
* **为什么需要**:镜像那一屏里,框中的文档正常情况下是电脑端界面(它有自己的退出方式),
|
|
6787
|
+
* 但**打不开的时候**它是我们自己发的一页提示(见 lib/server.js 的 sendMirrorUnavailable)
|
|
6788
|
+
* ——那一页里除了浏览器自己的返回,没有别的出口,用户会卡在一个提示页上。
|
|
6789
|
+
* 所以那一页的「返回」按钮往这里发一条消息,我们把这整屏收起来。
|
|
6790
|
+
*
|
|
6791
|
+
* 只认这一条字符串,且只认同源:这个监听**只做「收屏」这一件事**,别的消息一概不理。
|
|
6792
|
+
*/
|
|
6793
|
+
window.addEventListener('message', function (e) {
|
|
6794
|
+
if (e.origin === location.origin && e.data === 'mini-mirror-close') closeMirror();
|
|
6795
|
+
});
|
|
6796
|
+
|
|
6797
|
+
function loadMirror() {
|
|
6798
|
+
api('/mini/api/mirror').then(function (r) {
|
|
6799
|
+
if (!r || !r.ok || !r.available) {
|
|
6800
|
+
mirror.available = false;
|
|
6801
|
+
paintMirror();
|
|
6802
|
+
return;
|
|
6803
|
+
}
|
|
6804
|
+
mirror.available = true;
|
|
6805
|
+
mirror.enabled = r.enabled === true;
|
|
6806
|
+
mirror.ready = true;
|
|
6807
|
+
paintMirror();
|
|
6808
|
+
}).catch(function () {
|
|
6809
|
+
mirror.available = false;
|
|
6810
|
+
mirror.armed = false;
|
|
6811
|
+
paintMirror();
|
|
6812
|
+
});
|
|
6813
|
+
}
|
|
6814
|
+
|
|
6815
|
+
function tapMirror(next) {
|
|
6816
|
+
if (!mirror.ready) return;
|
|
6817
|
+
if (next === 'off') {
|
|
6818
|
+
// 关掉不需要确认:往「更安全」的方向走,不用拦。
|
|
6819
|
+
mirror.armed = false;
|
|
6820
|
+
if (!mirror.enabled) { paintMirror(); return; }
|
|
6821
|
+
mirror.enabled = false;
|
|
6822
|
+
paintMirror();
|
|
6823
|
+
api('/mini/api/mirror', { method: 'POST', body: JSON.stringify({ enabled: false }) })
|
|
6824
|
+
.then(function (r) {
|
|
6825
|
+
if (r && r.ok) { toast('已关掉电脑端界面'); return; }
|
|
6826
|
+
mirror.enabled = true;
|
|
6827
|
+
paintMirror();
|
|
6828
|
+
toast('没能关掉,再试一次');
|
|
6829
|
+
})
|
|
6830
|
+
.catch(function () {
|
|
6831
|
+
mirror.enabled = true;
|
|
6832
|
+
paintMirror();
|
|
6833
|
+
toast('没能关掉,检查一下连接');
|
|
6834
|
+
});
|
|
6835
|
+
return;
|
|
6836
|
+
}
|
|
6837
|
+
// 开:第一次点只是「举起来」,把后果摆出来;再点一次才真开。
|
|
6838
|
+
if (!mirror.enabled && !mirror.armed) {
|
|
6839
|
+
mirror.armed = true;
|
|
6840
|
+
paintMirror();
|
|
6841
|
+
return;
|
|
6842
|
+
}
|
|
6843
|
+
if (mirror.enabled) return;
|
|
6844
|
+
mirror.armed = false;
|
|
6845
|
+
mirror.enabled = true;
|
|
6846
|
+
paintMirror();
|
|
6847
|
+
api('/mini/api/mirror', { method: 'POST', body: JSON.stringify({ enabled: true }) })
|
|
6848
|
+
.then(function (r) {
|
|
6849
|
+
if (r && r.ok) { toast('已打开,点下面的「打开」进去'); return; }
|
|
6850
|
+
mirror.enabled = false;
|
|
6851
|
+
paintMirror();
|
|
6852
|
+
toast(r && r.reason === 'unavailable' ? '这台电脑没有这个能力' : '没能打开,再试一次');
|
|
6853
|
+
})
|
|
6854
|
+
.catch(function () {
|
|
6855
|
+
mirror.enabled = false;
|
|
6856
|
+
paintMirror();
|
|
6857
|
+
toast('没能打开,检查一下连接');
|
|
6858
|
+
});
|
|
6859
|
+
}
|
|
6860
|
+
|
|
6861
|
+
$('segMirror').addEventListener('click', function (e) {
|
|
6862
|
+
var btn = e.target.closest ? e.target.closest('button[data-mirror]') : null;
|
|
6863
|
+
if (!btn) return;
|
|
6864
|
+
tapMirror(btn.dataset.mirror);
|
|
6865
|
+
});
|
|
6866
|
+
|
|
6273
6867
|
if (NOTIFY_OK) {
|
|
6274
6868
|
// 先注册 service worker。**这一步不申请权限**——权限要用户主动点一下才给,
|
|
6275
6869
|
// 页面一加载就弹窗会被浏览器直接拒掉,而且很讨厌。
|
|
@@ -6478,6 +7072,30 @@
|
|
|
6478
7072
|
// 整块抠出来求值,测试里的假 mainEl 没有 addEventListener,写在那边好几个测试当场报错。
|
|
6479
7073
|
mainEl.addEventListener('scroll', onMainScroll);
|
|
6480
7074
|
|
|
7075
|
+
// 「回到底部」浮标(2026-10-08)也挂在这一根滚动线上,但**另起一条监听**:
|
|
7076
|
+
// onMainScroll 被页面测试整块抠出来跑(chatPager 那个替身只给了 state / replyEl /
|
|
7077
|
+
// mainEl / timeLabel,没有 $),把浮标并进去会让那几个测试当场报 ReferenceError。
|
|
7078
|
+
mainEl.addEventListener('scroll', paintToBottom);
|
|
7079
|
+
// 首屏先定一次:刚进来是空页,按判据算「在底部」,浮标该是收着的。
|
|
7080
|
+
paintToBottom();
|
|
7081
|
+
|
|
7082
|
+
// 点它:**瞬时**到底,不做平滑滚动。
|
|
7083
|
+
//
|
|
7084
|
+
// 两条理由,都是「跟现成的东西保持一致」而不是我挑的:
|
|
7085
|
+
// · 用户原话就是「瞬间」;
|
|
7086
|
+
// · 电脑端那颗也是瞬时的(ChatView 里 reading.followTail → viewport.scrollToBottom
|
|
7087
|
+
// → follow.toBottom(..., 'instant'));
|
|
7088
|
+
// · 本页「按发送回底部」本来就是直接赋值(见 send 里那句
|
|
7089
|
+
// mainEl.scrollTop = mainEl.scrollHeight),同一件事用同一套动作,
|
|
7090
|
+
// 用户不会看到「有时滑一下、有时直接跳」两种脾气。
|
|
7091
|
+
//
|
|
7092
|
+
// 赋值本身会发一次 scroll 事件,浮标自己就收了;这里再补一次是防「本来就在最底、
|
|
7093
|
+
// 位置没变所以不发事件」那一种——补一下,状态就是确定的。
|
|
7094
|
+
$('toBottom').addEventListener('click', function () {
|
|
7095
|
+
mainEl.scrollTop = mainEl.scrollHeight;
|
|
7096
|
+
paintToBottom();
|
|
7097
|
+
});
|
|
7098
|
+
|
|
6481
7099
|
// ---------------- 启动 ----------------
|
|
6482
7100
|
syncSeg();
|
|
6483
7101
|
autoGrow();
|