@feiyang666/dsh-mobile-remote 1.9.1 → 2.0.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 +37 -0
- package/README.md +3 -2
- package/README.zh.md +6 -8
- package/RELEASE_NOTES.md +23 -33
- package/cordis.patch.yml +4 -1
- package/lib/client.js +42 -5
- package/lib/external.js +97 -8
- package/lib/index.js +143 -956
- package/lib/lanproxy.js +40 -10
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// dsh-mobile-remote — DSH 移动端远程控制插件(host 侧)。
|
|
2
2
|
//
|
|
3
3
|
// 两大职责:
|
|
4
|
-
// 1.
|
|
5
|
-
//
|
|
4
|
+
// 1. **远端可达性**:局域网旁挂听众(宿主只绑回环时)或直接复用宿主自身的
|
|
5
|
+
// 网卡监听(`--host <网卡IP>` 启动时),外加经中转服务器的 frp 外网隧道。
|
|
6
|
+
// 官方 dsh 0.2.2 起自带移动端适配,本插件**不再注入任何移动端 CSS/UI 补丁**,
|
|
7
|
+
// 只注入一个设备心跳脚本(设置页"已连接设备"统计需要)。
|
|
6
8
|
// 2. **远程控制设置 API**(`/__dsh_remote/*`):
|
|
7
9
|
// - `GET /__dsh_remote/status` 返回当前监听 host / port / LAN IP 列表 /
|
|
8
10
|
// 远程控制开关状态 / 当前活跃设备数;
|
|
@@ -222,9 +224,6 @@ function httpFollow(startUrl, maxRedirects = 4) {
|
|
|
222
224
|
})
|
|
223
225
|
}
|
|
224
226
|
|
|
225
|
-
/** 移动端断点:与 DSH 原生 SIDEBAR_AUTO_COLLAPSE = 1024 对齐。 */
|
|
226
|
-
const MOBILE_MAX_WIDTH = 1024
|
|
227
|
-
|
|
228
227
|
/** 心跳过期时间(毫秒):超过该时长未上报的设备视为离线。 */
|
|
229
228
|
const HEARTBEAT_TTL = 90_000
|
|
230
229
|
|
|
@@ -259,7 +258,8 @@ export function apply(ctx) {
|
|
|
259
258
|
try { ctx.logger?.warn?.(`[dsh-mobile-remote] ${message}${error ? `: ${String(error?.message || error)}` : ''}`) } catch { /* ignore */ }
|
|
260
259
|
},
|
|
261
260
|
})
|
|
262
|
-
|
|
261
|
+
// 局域网可达 = 宿主自己绑在网卡上(--host)或旁挂听众在听
|
|
262
|
+
const remoteEnabledNow = () => bindInfo(ctx).direct || lan.status().running
|
|
263
263
|
|
|
264
264
|
// 活跃设备表:deviceId -> 详细设备信息(见 handleHeartbeat)。
|
|
265
265
|
const devices = new Map()
|
|
@@ -440,23 +440,33 @@ export function apply(ctx) {
|
|
|
440
440
|
warn(`[dsh-mobile-remote] 旧配置迁移失败:${String(error?.message || error)}`)
|
|
441
441
|
}
|
|
442
442
|
}
|
|
443
|
-
|
|
443
|
+
const bind = bindInfo(ctx)
|
|
444
|
+
if (bind.loopback) {
|
|
444
445
|
// 旧绑定没生效(比如当前 profile 本来就没写过):直接清理,不影响运行中的监听
|
|
445
446
|
migrate()
|
|
446
447
|
} else {
|
|
447
|
-
//
|
|
448
|
-
//
|
|
448
|
+
// 两种可能:
|
|
449
|
+
// a) 旧版本把 webserver 绑到了局域网 IP —— 清理后会触发一次重载把监听拉回回环;
|
|
450
|
+
// 必须等 HMR watcher 就绪后再写,否则这一次写入不会触发重载。
|
|
451
|
+
// b) 宿主本身就是用 `--host <网卡IP>` 启动的(官方桌面端/第三方壳)——
|
|
452
|
+
// 这不是本插件写的配置,也不该去改;直连反而是最省事的形态,按 direct 处理。
|
|
449
453
|
const timer = setTimeout(() => {
|
|
450
454
|
migrate()
|
|
451
|
-
warn('[dsh-mobile-remote] 监听已切回本机回环;局域网访问由插件的旁挂听众提供')
|
|
452
455
|
}, 4000)
|
|
453
456
|
migrationTimers.push(timer)
|
|
457
|
+
try {
|
|
458
|
+
ctx.logger?.info?.(
|
|
459
|
+
`[dsh-mobile-remote] dsh 直接监听 ${bind.host}:${bind.port}(--host),`
|
|
460
|
+
+ '局域网/外网转发将以该地址为上游,不再需要同地址的旁挂听众',
|
|
461
|
+
)
|
|
462
|
+
} catch { /* ignore */ }
|
|
454
463
|
}
|
|
455
464
|
// 旧版本以「受管 patch 存在」表示开启;首次升级时迁移成持久化配置项
|
|
456
465
|
if (getSetting('remoteEnabled', null) === null) {
|
|
457
466
|
try { setSetting('remoteEnabled', isManagedPatchEnabled(ctx)) } catch { /* ignore */ }
|
|
458
467
|
}
|
|
459
|
-
|
|
468
|
+
// direct 形态下即使配置项是 false,局域网也已经是可达的;仍按配置决定是否补其它网卡
|
|
469
|
+
if (getSetting('remoteEnabled', false) !== true && !bind.direct) return
|
|
460
470
|
try {
|
|
461
471
|
const result = await enableRemote(ctx, lan)
|
|
462
472
|
if (!result.running) {
|
|
@@ -472,7 +482,7 @@ export function apply(ctx) {
|
|
|
472
482
|
// 免去每次手动输入。设置 DSH_DISABLE_AUTO_RESTORE=1 可关闭该行为。
|
|
473
483
|
if (process.env.DSH_DISABLE_AUTO_RESTORE !== '1') {
|
|
474
484
|
const patchFile = patchFilePath(ctx)
|
|
475
|
-
void autoRestoreExternal(external, patchFile, ctx.webServer.port || 3080)
|
|
485
|
+
void autoRestoreExternal(external, patchFile, ctx.webServer.port || 3080, bindInfo(ctx).upstream)
|
|
476
486
|
}
|
|
477
487
|
|
|
478
488
|
// ---- 端口自适应 ----
|
|
@@ -488,7 +498,7 @@ export function apply(ctx) {
|
|
|
488
498
|
lastKnownPort = now
|
|
489
499
|
// 局域网听众也要跟着端口走(否则手机地址指向一个没人监听的端口)
|
|
490
500
|
if (lan.status().running) {
|
|
491
|
-
void
|
|
501
|
+
void enableRemote(ctx, lan).then((result) => {
|
|
492
502
|
if (result.running) {
|
|
493
503
|
try { ctx.logger?.info?.(`[dsh-mobile-remote] 监听端口 ${from} → ${now},局域网听众已自适应重绑`) } catch { /* ignore */ }
|
|
494
504
|
}
|
|
@@ -500,7 +510,7 @@ export function apply(ctx) {
|
|
|
500
510
|
void startExternal(external, {
|
|
501
511
|
bindCode: persisted.bindCode,
|
|
502
512
|
serverBase: persisted.serverBase || undefined,
|
|
503
|
-
}, patchFilePath(ctx), now).catch(() => { /* 状态由 startExternal 内部记录 */ })
|
|
513
|
+
}, patchFilePath(ctx), now, bindInfo(ctx).upstream).catch(() => { /* 状态由 startExternal 内部记录 */ })
|
|
504
514
|
try {
|
|
505
515
|
ctx.logger?.info?.(`[dsh-mobile-remote] 监听端口 ${from} → ${now},外网隧道已自适应重绑`)
|
|
506
516
|
} catch { /* ignore */ }
|
|
@@ -571,6 +581,32 @@ function getLanAddresses() {
|
|
|
571
581
|
return out.sort((a, b) => score(b) - score(a))
|
|
572
582
|
}
|
|
573
583
|
|
|
584
|
+
/**
|
|
585
|
+
* 宿主真实监听信息(本插件所有「转发到哪里」的判断都以它为准)。
|
|
586
|
+
*
|
|
587
|
+
* dsh 0.2.x 的 `webserver.host` 只接受一块具体网卡的字面量地址,因此运行时可能有两种形态:
|
|
588
|
+
* - `loopback`:宿主只绑 127.0.0.1 → 手机必须靠旁挂 LAN 听众转发(本机窗口不受影响);
|
|
589
|
+
* - `direct` :宿主自己就绑在网卡上(官方桌面端/第三方壳用 `--host 192.168.x.x` 启动)。
|
|
590
|
+
* 此时该地址上已经有监听,旁挂听众再绑同一地址必然 EADDRINUSE,
|
|
591
|
+
* 而 127.0.0.1 上根本没有监听 —— 转发目标必须改成这块网卡本身。
|
|
592
|
+
* 这正是「局域网其实能访问、插件却显示未开启且没有二维码」与「外网域名 502」的共同根因。
|
|
593
|
+
* @param {import('@deepseek-ai/cordis').Context} [ctx]
|
|
594
|
+
* @returns {{host: string, port: number, loopback: boolean, direct: boolean, upstream: string}}
|
|
595
|
+
*/
|
|
596
|
+
function bindInfo(ctx) {
|
|
597
|
+
const host = String(ctx?.webServer?.host || '127.0.0.1')
|
|
598
|
+
const port = Number(ctx?.webServer?.port) || 0
|
|
599
|
+
const loopback = isLoopbackHost(host)
|
|
600
|
+
return { host, port, loopback, direct: !loopback, upstream: loopback ? '127.0.0.1' : host }
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** 把 host:port 拼成 URL authority(IPv6 自动加方括号)。 */
|
|
604
|
+
function authorityOf(host, port) {
|
|
605
|
+
const h = String(host || '')
|
|
606
|
+
const wrapped = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h
|
|
607
|
+
return port ? `${wrapped}:${port}` : wrapped
|
|
608
|
+
}
|
|
609
|
+
|
|
574
610
|
/** 所有 IPv4 网卡详情(含网卡名 / 掩码 / MAC / CIDR / 是否回环),供设置页展示。 */
|
|
575
611
|
function getLanInterfaces() {
|
|
576
612
|
const out = []
|
|
@@ -1061,28 +1097,58 @@ async function queryServerStatus(base, bindCode) {
|
|
|
1061
1097
|
* @param {ReturnType<typeof createLanProxy>} proxy
|
|
1062
1098
|
*/
|
|
1063
1099
|
async function enableRemote(ctx, proxy) {
|
|
1064
|
-
const
|
|
1100
|
+
const bind = bindInfo(ctx)
|
|
1101
|
+
const port = bind.port
|
|
1065
1102
|
if (!port) throw new Error('dsh 尚未监听端口,请稍后重试')
|
|
1066
|
-
const
|
|
1103
|
+
const lan = getLanAddresses()
|
|
1104
|
+
if (bind.direct) {
|
|
1105
|
+
// 宿主自己已经绑在这块网卡上:同地址不能再绑(EADDRINUSE),只补其余网卡。
|
|
1106
|
+
// 转发目标也必须是宿主真实监听的那块网卡 —— 回环上没有任何监听。
|
|
1107
|
+
const extras = lan.filter((ip) => ip !== bind.host)
|
|
1108
|
+
const result = extras.length
|
|
1109
|
+
? await proxy.start(extras, port, { upstreamHost: bind.host, skipHosts: [bind.host] })
|
|
1110
|
+
: { running: false, hosts: [], port: 0, upstream: `${authorityOf(bind.host, port)}`, failures: [] }
|
|
1111
|
+
setSetting('remoteEnabled', true)
|
|
1112
|
+
return {
|
|
1113
|
+
// 直连由宿主提供,语义上就是「已开启」
|
|
1114
|
+
running: true,
|
|
1115
|
+
direct: true,
|
|
1116
|
+
hosts: [bind.host, ...result.hosts],
|
|
1117
|
+
port,
|
|
1118
|
+
upstream: result.upstream,
|
|
1119
|
+
sidecar: result.running === true,
|
|
1120
|
+
failures: result.failures || [],
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const result = await proxy.start(lan, port, { upstreamHost: bind.upstream })
|
|
1067
1124
|
if (result.running) setSetting('remoteEnabled', true)
|
|
1068
1125
|
return result
|
|
1069
1126
|
}
|
|
1070
1127
|
|
|
1071
1128
|
/**
|
|
1072
1129
|
* 关闭局域网访问:释放旁挂听众,本机监听自始至终未受影响。
|
|
1130
|
+
* 注意:若宿主是用 `--host <网卡IP>` 启动的,那份监听属于 dsh 本身,插件无权关闭,
|
|
1131
|
+
* 只有改启动参数才能真正收回 —— 这里如实返回 direct 标记,由前端说明。
|
|
1073
1132
|
* @param {ReturnType<typeof createLanProxy>} proxy
|
|
1133
|
+
* @param {import('@deepseek-ai/cordis').Context} [ctx]
|
|
1074
1134
|
*/
|
|
1075
|
-
async function disableRemote(proxy) {
|
|
1135
|
+
async function disableRemote(proxy, ctx) {
|
|
1076
1136
|
await proxy.stop()
|
|
1077
1137
|
setSetting('remoteEnabled', false)
|
|
1138
|
+
return { direct: !!(ctx && bindInfo(ctx).direct) }
|
|
1078
1139
|
}
|
|
1079
1140
|
|
|
1080
1141
|
/** GET /__dsh_remote/status */
|
|
1081
1142
|
function handleStatus(ctx, devices, external, tracker, dshCache, proxy) {
|
|
1082
1143
|
return async (req, res) => {
|
|
1083
|
-
const
|
|
1144
|
+
const bind = bindInfo(ctx)
|
|
1145
|
+
// 宿主自己绑在网卡上时,这块地址排在最前(手机优先用直连地址)
|
|
1146
|
+
const lan = bind.direct
|
|
1147
|
+
? [bind.host, ...getLanAddresses().filter((ip) => ip !== bind.host)]
|
|
1148
|
+
: getLanAddresses()
|
|
1084
1149
|
const proxyState = proxy.status()
|
|
1085
|
-
|
|
1150
|
+
// 「局域网可达」= 宿主直连(--host 网卡IP)或插件的旁挂听众至少有一个在听
|
|
1151
|
+
const remoteEnabled = bind.direct || proxyState.running
|
|
1086
1152
|
// 外网 frpc 是否仍在运行
|
|
1087
1153
|
const extRunning = !!external.proc && external.proc.exitCode === null
|
|
1088
1154
|
// 手机访问地址统一附加新版 dsh 的浏览器会话 token(无认证时返回原 URL)
|
|
@@ -1135,27 +1201,36 @@ function handleStatus(ctx, devices, external, tracker, dshCache, proxy) {
|
|
|
1135
1201
|
// WS 长连接是否已建立(供前端判断当前处于"实时推送"还是"退让/重连")
|
|
1136
1202
|
const wsConnected = !!(external.wsClient && external.wsClient.isOpen())
|
|
1137
1203
|
json(res, 200, {
|
|
1138
|
-
host:
|
|
1139
|
-
port:
|
|
1204
|
+
host: bind.host,
|
|
1205
|
+
port: bind.port,
|
|
1140
1206
|
lanAddresses: lan,
|
|
1141
1207
|
lanInterfaces: getLanInterfaces(),
|
|
1142
1208
|
remoteEnabled,
|
|
1143
|
-
//
|
|
1144
|
-
bindHost:
|
|
1145
|
-
//
|
|
1209
|
+
// 本机监听地址(可能回环,也可能是 --host 指定的网卡 IP)
|
|
1210
|
+
bindHost: bind.host,
|
|
1211
|
+
// 宿主是否自己就绑在网卡上(此时局域网直连由 dsh 提供,旁挂听众不再是必需)
|
|
1212
|
+
direct: {
|
|
1213
|
+
enabled: bind.direct,
|
|
1214
|
+
host: bind.host,
|
|
1215
|
+
port: bind.port,
|
|
1216
|
+
upstream: bind.upstream,
|
|
1217
|
+
},
|
|
1218
|
+
// 旁挂听众的实时状态(开启后有 running/hosts/port/upstream)
|
|
1146
1219
|
lan: {
|
|
1147
1220
|
running: proxyState.running,
|
|
1148
1221
|
hosts: proxyState.hosts,
|
|
1149
1222
|
port: proxyState.port,
|
|
1223
|
+
upstream: proxyState.upstream,
|
|
1224
|
+
direct: bind.direct,
|
|
1150
1225
|
},
|
|
1151
1226
|
patchEnabled: isManagedPatchEnabled(ctx),
|
|
1152
1227
|
deviceCount: devices.size,
|
|
1153
1228
|
devices: deviceList,
|
|
1154
1229
|
patchFile: patchFilePath(ctx),
|
|
1155
1230
|
profile: profileName(ctx),
|
|
1156
|
-
url: lan.length ? authUrl(`http://${lan[0]
|
|
1231
|
+
url: lan.length ? authUrl(`http://${authorityOf(lan[0], bind.port)}`) : null,
|
|
1157
1232
|
// 全部局域网 IP 的「手机可访问」地址(已带新版 dsh 认证 token;无认证时即裸地址)
|
|
1158
|
-
lanUrls: lan.map((ip) => authUrl(`http://${ip
|
|
1233
|
+
lanUrls: lan.map((ip) => authUrl(`http://${authorityOf(ip, bind.port)}`)),
|
|
1159
1234
|
// 本机运行时信息(诊断用,设置页展示更详细的数据)
|
|
1160
1235
|
runtime: runtimeInfo(ctx),
|
|
1161
1236
|
uptime: Math.floor(process.uptime()),
|
|
@@ -1180,6 +1255,10 @@ function handleStatus(ctx, devices, external, tracker, dshCache, proxy) {
|
|
|
1180
1255
|
? String(persisted.bindCode).slice(0, 4) + '****'
|
|
1181
1256
|
: '',
|
|
1182
1257
|
error: external.error || null,
|
|
1258
|
+
// frpc 转发的本地地址(宿主真实监听地址;非回环时是网卡 IP)
|
|
1259
|
+
localHost: external.localHost || null,
|
|
1260
|
+
// 外网入口可达性探测({ ok, status, at, error }):502 等会在这里直接暴露
|
|
1261
|
+
probe: external.probe || null,
|
|
1183
1262
|
defaultServerBase: DEFAULT_SERVER_BASE,
|
|
1184
1263
|
persisted: persisted
|
|
1185
1264
|
? { bindCode: persisted.bindCode || '', serverBase: persisted.serverBase || '' }
|
|
@@ -1233,18 +1312,24 @@ function handleToggle(ctx, proxy) {
|
|
|
1233
1312
|
json(res, 200, {
|
|
1234
1313
|
ok: true,
|
|
1235
1314
|
enabled: true,
|
|
1315
|
+
direct: result.direct === true,
|
|
1236
1316
|
lanHosts: result.hosts,
|
|
1237
|
-
urls: result.hosts.map((ip) => `http://${ip
|
|
1238
|
-
note:
|
|
1317
|
+
urls: result.hosts.map((ip) => `http://${authorityOf(ip, port)}`),
|
|
1318
|
+
note: result.direct
|
|
1319
|
+
? 'dsh is already bound to a LAN address (--host); the side-car listener is not needed'
|
|
1320
|
+
: 'lan listener started; dsh keeps listening on loopback (no restart, no patch)',
|
|
1239
1321
|
})
|
|
1240
1322
|
return
|
|
1241
1323
|
}
|
|
1242
|
-
await disableRemote(proxy)
|
|
1324
|
+
const stopped = await disableRemote(proxy, ctx)
|
|
1243
1325
|
notifyChanged()
|
|
1244
1326
|
json(res, 200, {
|
|
1245
1327
|
ok: true,
|
|
1246
1328
|
enabled: false,
|
|
1247
|
-
|
|
1329
|
+
direct: stopped.direct === true,
|
|
1330
|
+
note: stopped.direct
|
|
1331
|
+
? 'side-car listener stopped; dsh still listens on its --host address, remove that flag to fully close LAN access'
|
|
1332
|
+
: 'lan listener stopped; local access was never affected',
|
|
1248
1333
|
})
|
|
1249
1334
|
} catch (error) {
|
|
1250
1335
|
json(res, 500, { ok: false, error: String(error?.message || error) })
|
|
@@ -1252,7 +1337,7 @@ function handleToggle(ctx, proxy) {
|
|
|
1252
1337
|
}
|
|
1253
1338
|
}
|
|
1254
1339
|
|
|
1255
|
-
/** POST /__dsh_remote/heartbeat
|
|
1340
|
+
/** POST /__dsh_remote/heartbeat(浏览器上报的详细设备元数据,见下方 CLIENT_JS)。 */
|
|
1256
1341
|
function handleHeartbeat(devices, tracker) {
|
|
1257
1342
|
return async (req, res) => {
|
|
1258
1343
|
const body = await readBody(req)
|
|
@@ -1335,7 +1420,11 @@ function handleExternalStart(ctx, external) {
|
|
|
1335
1420
|
try { body = await readBody(req) } catch { /* ignore */ }
|
|
1336
1421
|
try {
|
|
1337
1422
|
const patchFile = patchFilePath(ctx)
|
|
1338
|
-
|
|
1423
|
+
// 第 5 个参数是 frpc 的本地转发地址:宿主未绑回环时必须是网卡 IP,
|
|
1424
|
+
// 否则 frpc 会转发到没人监听的 127.0.0.1:port —— 外网域名直接 502。
|
|
1425
|
+
const result = await startExternal(
|
|
1426
|
+
external, body, patchFile, ctx.webServer.port || 3080, bindInfo(ctx).upstream,
|
|
1427
|
+
)
|
|
1339
1428
|
notifyChanged() // 外网隧道启动:通知 SSE 订阅者
|
|
1340
1429
|
json(res, 200, Object.assign({ ok: true }, result))
|
|
1341
1430
|
} catch (error) {
|
|
@@ -1369,14 +1458,14 @@ function stopFrpc(external) {
|
|
|
1369
1458
|
function injectIntoIndex(html, remoteEnabled) {
|
|
1370
1459
|
const bootConfig =
|
|
1371
1460
|
'<script id="dsh-mobile-remote-config">window.__DSH_MOBILE_REMOTE__ = ' +
|
|
1372
|
-
JSON.stringify({ lanEnabled: remoteEnabled
|
|
1461
|
+
JSON.stringify({ lanEnabled: remoteEnabled }) +
|
|
1373
1462
|
';</script>'
|
|
1374
1463
|
const gateStyle = `<style id="dsh-mobile-remote-gate-css">${GATE_CSS}</style>`
|
|
1375
1464
|
const gateScript = `<script id="dsh-mobile-remote-gate-js">${GATE_JS}</script>`
|
|
1376
|
-
|
|
1377
|
-
const script = `<script id="dsh-mobile-remote-js">${
|
|
1465
|
+
// 不再注入任何移动端布局/交互 CSS:官方 dsh 0.2.2 起自带窄屏抽屉等适配。
|
|
1466
|
+
const script = `<script id="dsh-mobile-remote-js">${CLIENT_JS}</script>`
|
|
1378
1467
|
// 门禁脚本注入在 head 最前,确保在 DSH 界面渲染前就绪
|
|
1379
|
-
const injection = bootConfig + gateStyle + gateScript +
|
|
1468
|
+
const injection = bootConfig + gateStyle + gateScript + script
|
|
1380
1469
|
|
|
1381
1470
|
if (html.includes('</head>')) {
|
|
1382
1471
|
return html.replace('</head>', `${injection}\n</head>`)
|
|
@@ -1632,454 +1721,24 @@ const GATE_JS = `
|
|
|
1632
1721
|
})();
|
|
1633
1722
|
`
|
|
1634
1723
|
|
|
1635
|
-
const MOBILE_CSS = `
|
|
1636
|
-
/* === dsh-mobile-remote 移动端输入栏补丁 ===
|
|
1637
|
-
* 作用范围:仅在移动设备(<1024px)下生效。DSH 原生已经在 <1024px 时把侧边栏
|
|
1638
|
-
* 折叠为 rail 并提供原生汉堡按钮,本补丁不碰那一套,只修 composer。
|
|
1639
|
-
*
|
|
1640
|
-
* 选择器结构(packages/client/ui-conversation/src/client/skeleton/InputBar.tsx):
|
|
1641
|
-
* [data-composer-card] ← composer 卡片根
|
|
1642
|
-
* ├ [data-input-scroll] ← 文本输入滚动区
|
|
1643
|
-
* └ div:last-child ← 工具行 .row
|
|
1644
|
-
* ├ div:first-child ← .tools(+ 按钮、访问模式、安全模式 select)
|
|
1645
|
-
* └ div:last-child ← .trailing(模型 select、上下文计量、发送按钮)
|
|
1646
|
-
*/
|
|
1647
|
-
|
|
1648
|
-
html[data-dsh-mobile] {
|
|
1649
|
-
-webkit-text-size-adjust: 100%;
|
|
1650
|
-
text-size-adjust: 100%;
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
html[data-dsh-mobile] body {
|
|
1654
|
-
-webkit-tap-highlight-color: transparent;
|
|
1655
|
-
touch-action: manipulation;
|
|
1656
|
-
overscroll-behavior-y: contain;
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
/* ----- 输入栏工具行:窄屏下允许换行,避免左右两侧的选择器互相挤压粘连 ----- */
|
|
1660
|
-
html[data-dsh-mobile] [data-composer-card] > div:last-child {
|
|
1661
|
-
flex-wrap: wrap;
|
|
1662
|
-
row-gap: 6px;
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
/* 左半区(+ 按钮 + 访问模式 + 安全模式)可收缩 */
|
|
1666
|
-
html[data-dsh-mobile] [data-composer-card] > div:last-child > div:first-child {
|
|
1667
|
-
flex: 1 1 auto;
|
|
1668
|
-
min-width: 0;
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
/* 右半区(模型选择 + 上下文 + 发送)换到第二行右对齐,与左半区彻底分开 */
|
|
1672
|
-
html[data-dsh-mobile] [data-composer-card] > div:last-child > div:last-child {
|
|
1673
|
-
flex: none !important;
|
|
1674
|
-
width: 100% !important;
|
|
1675
|
-
justify-content: flex-end !important;
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
/* 工具行内的所有 <select>(选择模型 / 选择安全模式等)限宽防溢出 + 加大触控目标 */
|
|
1679
|
-
html[data-dsh-mobile] [data-composer-card] select {
|
|
1680
|
-
max-width: min(46vw, 200px) !important;
|
|
1681
|
-
min-height: 36px;
|
|
1682
|
-
font-size: 14px;
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
/* 工具行内的按钮加大触控目标(44px 苹果 HIG 推荐) */
|
|
1686
|
-
html[data-dsh-mobile] [data-composer-card] > div:last-child button {
|
|
1687
|
-
min-width: 40px;
|
|
1688
|
-
min-height: 40px;
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
/* ----- iOS 触屏优化:输入框字号 >= 16px 防止聚焦自动缩放 ----- */
|
|
1692
|
-
html[data-dsh-mobile] textarea,
|
|
1693
|
-
html[data-dsh-mobile] input[type='text'],
|
|
1694
|
-
html[data-dsh-mobile] input[type='search'] {
|
|
1695
|
-
font-size: 16px !important;
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
/* ----- 移动端 Tooltip 抑制 -----
|
|
1699
|
-
* DSH 原生 Tooltip 气泡由 hover / focus 触发(<span role="tooltip">)。
|
|
1700
|
-
* 触屏设备上没有 hover,点击按钮后焦点停留在按钮上,气泡会一直显示在屏幕上
|
|
1701
|
-
* (点击空白 / 滚动也不会消失)——即“停止 / 开始 / 关闭菜单栏等按钮的提示文字
|
|
1702
|
-
* 常驻屏幕”问题。移动端直接隐藏气泡,按钮仍保留 aria-label 无障碍名。
|
|
1703
|
-
*/
|
|
1704
|
-
html[data-dsh-mobile] [role='tooltip'] {
|
|
1705
|
-
display: none !important;
|
|
1706
|
-
visibility: hidden !important;
|
|
1707
|
-
opacity: 0 !important;
|
|
1708
|
-
pointer-events: none !important;
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
/* ----- 移动端设置页适配 -----
|
|
1712
|
-
* 原生设置弹窗是 800px 双栏布局(188px 导航栏 + 内容列),手机上内容被挤得
|
|
1713
|
-
* 几乎没有空间。由 MOBILE_JS 的 MutationObserver 把设置弹窗打上
|
|
1714
|
-
* data-dsh-settings 标记(结构:role=dialog 且首个子元素为 <nav>),这里改为
|
|
1715
|
-
* 全屏单栏:导航栏变顶部横向滚动标签,内容区独占剩余空间。
|
|
1716
|
-
* 不依赖 harness 内部哈希类名,harness 升级后仍能直接适配。
|
|
1717
|
-
*/
|
|
1718
|
-
html[data-dsh-mobile] [data-dsh-settings] {
|
|
1719
|
-
display: flex !important;
|
|
1720
|
-
flex-direction: column !important;
|
|
1721
|
-
width: 100vw !important;
|
|
1722
|
-
max-width: 100vw !important;
|
|
1723
|
-
height: 100vh !important;
|
|
1724
|
-
height: 100dvh !important;
|
|
1725
|
-
max-height: 100vh !important;
|
|
1726
|
-
max-height: 100dvh !important;
|
|
1727
|
-
border-radius: 0 !important;
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
|
-
/* 导航栏:横向标签条,可横向滚动 */
|
|
1731
|
-
html[data-dsh-mobile] [data-dsh-settings] > nav {
|
|
1732
|
-
display: flex !important;
|
|
1733
|
-
flex-direction: row !important;
|
|
1734
|
-
align-items: center !important;
|
|
1735
|
-
width: 100% !important;
|
|
1736
|
-
flex: none !important;
|
|
1737
|
-
padding: 8px 10px !important;
|
|
1738
|
-
gap: 6px !important;
|
|
1739
|
-
overflow-x: auto !important;
|
|
1740
|
-
-webkit-overflow-scrolling: touch;
|
|
1741
|
-
border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.18)) !important;
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
/* 导航标题(设置面板的标题文案)在移动端隐藏,省出空间给标签 */
|
|
1745
|
-
html[data-dsh-mobile] [data-dsh-settings] > nav > div:first-child {
|
|
1746
|
-
display: none !important;
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
/* 导航列表:横向排列、宽度自适应内容 */
|
|
1750
|
-
html[data-dsh-mobile] [data-dsh-settings] > nav > div + div {
|
|
1751
|
-
display: flex !important;
|
|
1752
|
-
flex-direction: row !important;
|
|
1753
|
-
width: max-content !important;
|
|
1754
|
-
gap: 6px !important;
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
/* 导航项:胶囊标签,加大触控目标 */
|
|
1758
|
-
html[data-dsh-mobile] [data-dsh-settings] nav button {
|
|
1759
|
-
flex: none !important;
|
|
1760
|
-
height: 36px !important;
|
|
1761
|
-
padding: 0 14px !important;
|
|
1762
|
-
gap: 6px !important;
|
|
1763
|
-
border-radius: 999px !important;
|
|
1764
|
-
white-space: nowrap !important;
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
/* 内容列:占满剩余空间。
|
|
1768
|
-
* min-height:0 是关键——flex 子项默认 min-height:auto,内容列无法收缩到
|
|
1769
|
-
* 比自身内容更矮,导致内容撑高后被面板 overflow:hidden 裁掉、内部 .options
|
|
1770
|
-
* 区永远拿不到受限高度而不能滚动。设为 0 后内容列可收缩,把高度让给
|
|
1771
|
-
* .options(其自身 overflow-y:auto)去内部滚动。 */
|
|
1772
|
-
html[data-dsh-mobile] [data-dsh-settings] > div {
|
|
1773
|
-
width: 100% !important;
|
|
1774
|
-
flex: 1 1 auto !important;
|
|
1775
|
-
min-width: 0 !important;
|
|
1776
|
-
min-height: 0 !important;
|
|
1777
|
-
overflow: hidden !important;
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
/* 顶部 header 的按钮(关闭等)加大触控目标 */
|
|
1781
|
-
html[data-dsh-mobile] [data-dsh-settings] > div > div:first-child button {
|
|
1782
|
-
min-width: 36px !important;
|
|
1783
|
-
min-height: 36px !important;
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
/* 内容区边距收紧,让 section 内容有更多可用宽度 */
|
|
1787
|
-
html[data-dsh-mobile] [data-dsh-settings] > div > div:last-child {
|
|
1788
|
-
padding: 0 12px 16px !important;
|
|
1789
|
-
}
|
|
1790
|
-
|
|
1791
|
-
/* ----- 移动端上传图片按钮(composer 工具行,+ 加号旁)-----
|
|
1792
|
-
* 由 MOBILE_JS 注入到 [data-composer-card] 工具行。视觉上与 DSH 原生
|
|
1793
|
-
* 加号按钮同款(28px 圆、selector 填充),保证窄屏下排在一起不突兀。
|
|
1794
|
-
*/
|
|
1795
|
-
html[data-dsh-mobile] [data-composer-card] .dsh-mr-attach {
|
|
1796
|
-
display: grid;
|
|
1797
|
-
place-items: center;
|
|
1798
|
-
flex: none;
|
|
1799
|
-
width: 28px;
|
|
1800
|
-
height: 28px;
|
|
1801
|
-
margin: 0;
|
|
1802
|
-
padding: 0;
|
|
1803
|
-
border: none;
|
|
1804
|
-
border-radius: 999px;
|
|
1805
|
-
background: var(--dsw-specific-selector, #ececee);
|
|
1806
|
-
color: var(--dsw-alias-label-primary, #1f1f1f);
|
|
1807
|
-
cursor: pointer;
|
|
1808
|
-
-webkit-tap-highlight-color: transparent;
|
|
1809
|
-
transition: background-color 100ms ease;
|
|
1810
|
-
}
|
|
1811
|
-
html[data-dsh-mobile] [data-composer-card] .dsh-mr-attach:active {
|
|
1812
|
-
background: var(--dsw-alias-interactive-bg-hover-solid, #e0e0e3);
|
|
1813
|
-
}
|
|
1814
|
-
html[data-dsh-mobile] [data-composer-card] .dsh-mr-attach:disabled {
|
|
1815
|
-
opacity: 0.45;
|
|
1816
|
-
cursor: default;
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
|
-
/* 发送状态指示:composer 上方的「上传中 / 发送中」小浮层(含旋转 spinner)。
|
|
1820
|
-
* 位置:absolute 相对 [data-composer-card](其 position:relative 且无
|
|
1821
|
-
* overflow 裁剪),贴在卡片上沿外侧。 */
|
|
1822
|
-
html[data-dsh-mobile] [data-composer-card] .dsh-mr-sending {
|
|
1823
|
-
position: absolute;
|
|
1824
|
-
left: 12px;
|
|
1825
|
-
right: 12px;
|
|
1826
|
-
bottom: calc(100% + 8px);
|
|
1827
|
-
z-index: 30;
|
|
1828
|
-
display: flex;
|
|
1829
|
-
align-items: center;
|
|
1830
|
-
gap: 8px;
|
|
1831
|
-
padding: 8px 12px;
|
|
1832
|
-
border-radius: 10px;
|
|
1833
|
-
background: var(--dsw-alias-surface-solid, #ffffff);
|
|
1834
|
-
border: 1px solid var(--dsw-alias-divider, rgba(0, 0, 0, 0.08));
|
|
1835
|
-
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
|
|
1836
|
-
font-size: 13px;
|
|
1837
|
-
line-height: 1.4;
|
|
1838
|
-
color: var(--dsw-alias-text-tertiary, #666);
|
|
1839
|
-
}
|
|
1840
|
-
html[data-dsh-mobile] [data-composer-card] .dsh-mr-sending .dsh-mr-spinner {
|
|
1841
|
-
width: 14px;
|
|
1842
|
-
height: 14px;
|
|
1843
|
-
flex: none;
|
|
1844
|
-
border: 2px solid var(--dsw-alias-divider, rgba(0, 0, 0, 0.12));
|
|
1845
|
-
border-top-color: currentColor;
|
|
1846
|
-
border-radius: 50%;
|
|
1847
|
-
animation: dsh-mr-spin 0.8s linear infinite;
|
|
1848
|
-
}
|
|
1849
|
-
@keyframes dsh-mr-spin {
|
|
1850
|
-
to { transform: rotate(360deg); }
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
/* ----- 移动端上传轻提示气泡(自备极简 Toast)----- */
|
|
1854
|
-
#dsh-mr-toast {
|
|
1855
|
-
position: fixed;
|
|
1856
|
-
left: 50%;
|
|
1857
|
-
bottom: 96px;
|
|
1858
|
-
transform: translateX(-50%);
|
|
1859
|
-
z-index: 2147483000;
|
|
1860
|
-
max-width: min(86vw, 340px);
|
|
1861
|
-
padding: 9px 16px;
|
|
1862
|
-
border-radius: 10px;
|
|
1863
|
-
font-size: 13px;
|
|
1864
|
-
line-height: 1.5;
|
|
1865
|
-
text-align: center;
|
|
1866
|
-
color: var(--dsw-alias-label-primary-inverted, #fff);
|
|
1867
|
-
background: var(--dsw-alias-button-contrast-fill, rgba(0, 0, 0, 0.82));
|
|
1868
|
-
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
|
|
1869
|
-
pointer-events: none;
|
|
1870
|
-
opacity: 0;
|
|
1871
|
-
animation: dsh-mr-toast-in 2.4s ease forwards;
|
|
1872
|
-
}
|
|
1873
|
-
@keyframes dsh-mr-toast-in {
|
|
1874
|
-
0% { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
|
1875
|
-
8% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
1876
|
-
86% { opacity: 1; }
|
|
1877
|
-
100% { opacity: 0; transform: translateX(-50%) translateY(-4px); }
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
/* ----- 移动端图片缩略图 rail:删除按钮触控目标加大 -----
|
|
1881
|
-
* DSH 原生 AttachmentRail 的删除按钮只有 18px,触屏上太小。
|
|
1882
|
-
* 结构:rail([role=group]) > item(div) > button(缩略图) + button(删除)。
|
|
1883
|
-
*/
|
|
1884
|
-
html[data-dsh-mobile] [data-composer-card] [role='group'] > div > button:last-child {
|
|
1885
|
-
width: 24px !important;
|
|
1886
|
-
height: 24px !important;
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
/* ----- 移动端弹窗(权限选择 / 模型选择菜单)适配 -----
|
|
1890
|
-
* DSH 的 Menu(PermissionSelect 权限选择)与 ModelSelect(模型选择)都是
|
|
1891
|
-
* 绝对定位浮层且不含 viewport 水平钳制:
|
|
1892
|
-
* - 权限菜单 .list:left:0 + min-width:218px / max-width:360px——锚点在
|
|
1893
|
-
* 左区时菜单向右展开,容易超出屏幕右侧;
|
|
1894
|
-
* - 模型菜单 .menu:right:0 + width:min(240px, calc(100vw-32px))——锚点
|
|
1895
|
-
* 在右区时向左展开,容易向左偏/遮挡左侧工具区。
|
|
1896
|
-
* 这里:
|
|
1897
|
-
* - 权限菜单(无 aria-label)统一限制宽度不超屏;水平位置由 MOBILE_JS
|
|
1898
|
-
* 在菜单出现时用 translateX 钳制到视口内(无 transform 动画,可安全平移);
|
|
1899
|
-
* - 模型菜单(带 aria-label 的 [role=menu])在移动端改为「底部抽屉」
|
|
1900
|
-
* (bottom sheet):贴底、宽度自适应屏幕、高度受限内部滚动,不再从
|
|
1901
|
-
* 锚点向左侧/上方展开遮挡 composer 左侧的 + / 上传图片等按钮。
|
|
1902
|
-
*/
|
|
1903
|
-
html[data-dsh-mobile] [data-composer-card] [role='menu'] {
|
|
1904
|
-
max-width: calc(100vw - 16px) !important;
|
|
1905
|
-
}
|
|
1906
|
-
|
|
1907
|
-
/* 模型选择菜单(ModelSelect 的 .menu 带 aria-label,可据此与权限菜单区分;
|
|
1908
|
-
* 不依赖 harness 内部哈希类名)——移动端底部抽屉样式 */
|
|
1909
|
-
html[data-dsh-mobile] [data-composer-card] [role='menu'][aria-label] {
|
|
1910
|
-
position: fixed !important;
|
|
1911
|
-
top: auto !important;
|
|
1912
|
-
left: 8px !important;
|
|
1913
|
-
right: 8px !important;
|
|
1914
|
-
bottom: 24px; /* fallback:不支持 max()/env() 的旧浏览器也保持上移 */
|
|
1915
|
-
bottom: max(24px, env(safe-area-inset-bottom, 24px)) !important;
|
|
1916
|
-
width: auto !important;
|
|
1917
|
-
min-width: 0 !important;
|
|
1918
|
-
max-width: none !important;
|
|
1919
|
-
max-height: 62vh !important;
|
|
1920
|
-
z-index: 1000 !important;
|
|
1921
|
-
border-radius: 16px !important;
|
|
1922
|
-
overflow: hidden !important;
|
|
1923
|
-
overscroll-behavior: contain !important;
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
/* ----- 移动端插件宽表兜底:防止被压成逐字竖排 -----
|
|
1927
|
-
* dsh-usage-plugin 的「消耗表 / 消耗明细 / 每日统计」等 9–11 列宽表依赖其
|
|
1928
|
-
* 自身在运行时注入的全局样式(.dsh-usage-table{min-width:720px} + ≤540px
|
|
1929
|
-
* 折叠中间列)来避免列被压到极限宽度。若该样式因 CSP 或加载时序未生效,
|
|
1930
|
-
* 手机窄屏下每列会被压到约 1 字符宽、内容逐字竖排、数据无法阅读。
|
|
1931
|
-
* 本插件(dsh-mobile-remote)的样式是随 index.html 一起由服务端下发的,
|
|
1932
|
-
* 必定生效,因此作为可靠兜底:
|
|
1933
|
-
* - 宽表强制 min-width:720px,配合其外层 overflow-x 容器横向滑动,始终可读;
|
|
1934
|
-
* - ≤540px 时对带 collapse-mobile 的宽表隐藏第 4 列至倒数第 2 列,只保留
|
|
1935
|
-
* 前 3 个主标识列 + 最后合计列,手机上一屏即可看全关键数据;
|
|
1936
|
-
* - 价格表等窄表(无 collapse-mobile)不折叠,保留横向滚动。
|
|
1937
|
-
*/
|
|
1938
|
-
html[data-dsh-mobile] .dsh-usage-table {
|
|
1939
|
-
min-width: 720px !important;
|
|
1940
|
-
}
|
|
1941
|
-
@media (max-width: 540px) {
|
|
1942
|
-
html[data-dsh-mobile] .dsh-usage-table.collapse-mobile tr > *:not(:nth-child(-n+3)):not(:last-child) {
|
|
1943
|
-
display: none !important;
|
|
1944
|
-
}
|
|
1945
|
-
html[data-dsh-mobile] .dsh-usage-table.collapse-mobile {
|
|
1946
|
-
min-width: 320px !important;
|
|
1947
|
-
}
|
|
1948
|
-
}
|
|
1949
|
-
|
|
1950
|
-
/* ================================================================== */
|
|
1951
|
-
/* 移动端侧栏:默认彻底收起,悬浮按钮唤出「抽屉」 */
|
|
1952
1724
|
/* ------------------------------------------------------------------ */
|
|
1953
|
-
/*
|
|
1954
|
-
/*
|
|
1955
|
-
/*
|
|
1956
|
-
/*
|
|
1957
|
-
/*
|
|
1958
|
-
/*
|
|
1959
|
-
/*
|
|
1960
|
-
/*
|
|
1961
|
-
/*
|
|
1962
|
-
/* harness 的哈希类名。 */
|
|
1963
|
-
/* ================================================================== */
|
|
1964
|
-
|
|
1965
|
-
/* 常态:侧栏轨道 0 宽,内容区独占整屏 */
|
|
1966
|
-
html[data-dsh-mobile] [data-dsh-mobile-frame] {
|
|
1967
|
-
grid-template-columns: 0px minmax(0px, 1fr) minmax(0px, 0px) !important;
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
/* 抽屉打开:侧栏变成从左侧滑入的浮层 */
|
|
1971
|
-
html[data-dsh-mobile][data-dsh-nav='open'] [data-dsh-mobile-frame] > div:first-child {
|
|
1972
|
-
position: fixed !important;
|
|
1973
|
-
left: 0 !important;
|
|
1974
|
-
top: 0 !important;
|
|
1975
|
-
bottom: 0 !important;
|
|
1976
|
-
width: min(86vw, 330px) !important;
|
|
1977
|
-
max-width: 86vw !important;
|
|
1978
|
-
z-index: 9010 !important;
|
|
1979
|
-
overflow: hidden !important;
|
|
1980
|
-
border-right: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .22)) !important;
|
|
1981
|
-
border-radius: 0 16px 16px 0 !important;
|
|
1982
|
-
box-shadow: 0 18px 60px rgba(0, 0, 0, .38) !important;
|
|
1983
|
-
animation: dshMobileNavIn .2s cubic-bezier(.2, .8, .3, 1);
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
/* 侧栏内部根节点:宽度铺满抽屉(原生在窄屏下用内联宽度 = rail 宽度) */
|
|
1987
|
-
html[data-dsh-mobile][data-dsh-nav='open'] [data-dsh-mobile-frame] > div:first-child > * {
|
|
1988
|
-
width: 100% !important;
|
|
1989
|
-
max-width: none !important;
|
|
1990
|
-
height: 100% !important;
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
@keyframes dshMobileNavIn {
|
|
1994
|
-
from { transform: translateX(-14px); opacity: .6; }
|
|
1995
|
-
to { transform: translateX(0); opacity: 1; }
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
/* 抽屉遮罩:点击任意空白处收起 */
|
|
1999
|
-
#dsh-mobile-nav-scrim {
|
|
2000
|
-
position: fixed;
|
|
2001
|
-
inset: 0;
|
|
2002
|
-
z-index: 9005;
|
|
2003
|
-
background: rgba(0, 0, 0, .46);
|
|
2004
|
-
opacity: 0;
|
|
2005
|
-
pointer-events: none;
|
|
2006
|
-
transition: opacity .18s ease;
|
|
2007
|
-
-webkit-tap-highlight-color: transparent;
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
html[data-dsh-mobile][data-dsh-nav='open'] #dsh-mobile-nav-scrim {
|
|
2011
|
-
opacity: 1;
|
|
2012
|
-
pointer-events: auto;
|
|
2013
|
-
}
|
|
2014
|
-
|
|
2015
|
-
/* 抽屉打开时锁住背景滚动(只锁 body,不动框架自身布局) */
|
|
2016
|
-
html[data-dsh-mobile][data-dsh-nav='open'] body {
|
|
2017
|
-
overflow: hidden !important;
|
|
2018
|
-
overscroll-behavior: none;
|
|
2019
|
-
}
|
|
2020
|
-
|
|
2021
|
-
/* 悬浮唤出按钮(收起态唯一入口;抽屉打开时淡出) */
|
|
2022
|
-
#dsh-mobile-nav-btn {
|
|
2023
|
-
position: fixed;
|
|
2024
|
-
top: calc(env(safe-area-inset-top, 0px) + 8px);
|
|
2025
|
-
left: 8px;
|
|
2026
|
-
width: 38px;
|
|
2027
|
-
height: 38px;
|
|
2028
|
-
display: flex;
|
|
2029
|
-
align-items: center;
|
|
2030
|
-
justify-content: center;
|
|
2031
|
-
padding: 0;
|
|
2032
|
-
border: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .26));
|
|
2033
|
-
border-radius: 12px;
|
|
2034
|
-
background: var(--dsw-alias-bg-l1, rgba(255, 255, 255, .86));
|
|
2035
|
-
color: var(--dsw-alias-text-l1, inherit);
|
|
2036
|
-
box-shadow: 0 2px 12px rgba(0, 0, 0, .18);
|
|
2037
|
-
-webkit-backdrop-filter: blur(10px);
|
|
2038
|
-
backdrop-filter: blur(10px);
|
|
2039
|
-
z-index: 9020;
|
|
2040
|
-
cursor: pointer;
|
|
2041
|
-
transition: transform .15s ease, opacity .15s ease;
|
|
2042
|
-
}
|
|
2043
|
-
|
|
2044
|
-
#dsh-mobile-nav-btn:active {
|
|
2045
|
-
transform: scale(.94);
|
|
2046
|
-
}
|
|
2047
|
-
|
|
2048
|
-
html[data-dsh-mobile][data-dsh-nav='open'] #dsh-mobile-nav-btn {
|
|
2049
|
-
opacity: 0;
|
|
2050
|
-
pointer-events: none;
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
/* 抽屉里没有可折叠的宿主时(极端情况)也不留白 */
|
|
2054
|
-
html[data-dsh-mobile]:not([data-dsh-nav='open']) [data-dsh-mobile-frame] > div:first-child {
|
|
2055
|
-
overflow: hidden !important;
|
|
2056
|
-
}
|
|
2057
|
-
`
|
|
2058
|
-
|
|
2059
|
-
/* ------------------------------------------------------------------ */
|
|
2060
|
-
/* 移动端设备检测 + 心跳上报:仅设置 html[data-dsh-mobile] 标记,并定时向 */
|
|
2061
|
-
/* host 上报设备在线状态(供设置页“当前连接的设备数量”统计)。 */
|
|
2062
|
-
/* 不注入任何 UI、不修改任何 React 状态、不动任何 DOM 节点。 */
|
|
1725
|
+
/* 设备心跳(本插件唯一注入的前端脚本) */
|
|
1726
|
+
/* */
|
|
1727
|
+
/* 官方 dsh 0.2.2 起已自带移动端适配:<768px 时侧栏改为浮层抽屉(带遮罩、 */
|
|
1728
|
+
/* inert 与 Esc 收起)、1024px 自动塌缩、移动 UA 特判(不抢焦点、提交后 */
|
|
1729
|
+
/* 收键盘)、iOS 软键盘光标修正与 PWA 清单。因此本插件不再注入任何布局 */
|
|
1730
|
+
/* CSS / 交互补丁(旧版的移动端 CSS、上传图片按钮、侧栏抽屉、Toast、 */
|
|
1731
|
+
/* 发送状态浮层、菜单钳制全部删除),只保留设备心跳上报——设置页的 */
|
|
1732
|
+
/* "当前连接的设备"列表依赖它。 */
|
|
1733
|
+
/* 本脚本不注入 UI、不改结构、不接管原生交互。 */
|
|
2063
1734
|
/* ------------------------------------------------------------------ */
|
|
2064
1735
|
|
|
2065
|
-
const
|
|
1736
|
+
const CLIENT_JS = `
|
|
2066
1737
|
(function () {
|
|
2067
1738
|
'use strict';
|
|
2068
1739
|
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
/* 设备检测:移动 UA,或触屏 + 窄视口。与 DSH 原生 SIDEBAR_AUTO_COLLAPSE 同界。 */
|
|
2073
|
-
function isMobile() {
|
|
2074
|
-
var ua = navigator.userAgent || '';
|
|
2075
|
-
var mobileUA = /Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
|
|
2076
|
-
var touch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
|
2077
|
-
var narrow = Math.min(window.innerWidth, (window.screen && window.screen.width) || Infinity) <= MAX_WIDTH;
|
|
2078
|
-
return mobileUA || (touch && narrow);
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
// 设备心跳:稳定标识 + 30s 间隔上报,host 统计并展示"已连接设备"。
|
|
2082
|
-
// 所有访问本 DSH 的浏览器(PC + 手机)都上报,仅移动端才打 data-dsh-mobile 标记。
|
|
1740
|
+
/* 设备心跳:稳定标识 + 30s 间隔上报,host 统计并展示"已连接设备"。
|
|
1741
|
+
* PC 与手机都会上报(设备列表按 UA 展示类型),不区分是否移动端。 */
|
|
2083
1742
|
try {
|
|
2084
1743
|
var KEY = '__dsh_mobile_remote_device';
|
|
2085
1744
|
var id = localStorage.getItem(KEY);
|
|
@@ -2087,8 +1746,7 @@ const MOBILE_JS = `
|
|
|
2087
1746
|
id = 'dev-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
|
|
2088
1747
|
localStorage.setItem(KEY, id);
|
|
2089
1748
|
}
|
|
2090
|
-
//
|
|
2091
|
-
// 电池信息为异步获取,首次拿到后缓存在变量中,后续心跳一并上报。
|
|
1749
|
+
// 电池信息异步获取,首次拿到后缓存在变量中,后续心跳一并上报。
|
|
2092
1750
|
var batteryLevel = null;
|
|
2093
1751
|
var batteryCharging = null;
|
|
2094
1752
|
try {
|
|
@@ -2099,6 +1757,10 @@ const MOBILE_JS = `
|
|
|
2099
1757
|
}).catch(function () {});
|
|
2100
1758
|
}
|
|
2101
1759
|
} catch (e) {}
|
|
1760
|
+
function isMobile() {
|
|
1761
|
+
var ua = navigator.userAgent || '';
|
|
1762
|
+
return /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
|
|
1763
|
+
}
|
|
2102
1764
|
function deviceMeta() {
|
|
2103
1765
|
var scr = window.screen || {};
|
|
2104
1766
|
var scrStr = (scr.width && scr.height)
|
|
@@ -2144,483 +1806,8 @@ const MOBILE_JS = `
|
|
|
2144
1806
|
}
|
|
2145
1807
|
beat();
|
|
2146
1808
|
setInterval(beat, 30000);
|
|
1809
|
+
window.__DSH_REMOTE_READY__ = { mobile: isMobile(), at: Date.now() };
|
|
2147
1810
|
} catch (e) {}
|
|
2148
|
-
|
|
2149
|
-
if (!isMobile()) return;
|
|
2150
|
-
|
|
2151
|
-
document.documentElement.setAttribute('data-dsh-mobile', '');
|
|
2152
|
-
|
|
2153
|
-
// 标记设置弹窗:原生设置页 = role="dialog" 且首个子元素为 <nav>(双栏弹窗)。
|
|
2154
|
-
// 打上 data-dsh-settings 标记后,上面的移动端 CSS 可精确适配,无需依赖
|
|
2155
|
-
// harness 内部的哈希类名——harness 升级后只要弹窗结构仍是
|
|
2156
|
-
// “dialog 直下 nav + 内容列”,本插件无需修改即可直接适配。
|
|
2157
|
-
function tagSettingsPanel() {
|
|
2158
|
-
var dialogs = document.querySelectorAll('[role="dialog"][aria-modal="true"]');
|
|
2159
|
-
for (var i = 0; i < dialogs.length; i++) {
|
|
2160
|
-
var dlg = dialogs[i];
|
|
2161
|
-
if (dlg.getAttribute('data-dsh-settings') === '1') continue;
|
|
2162
|
-
var kids = dlg.children;
|
|
2163
|
-
var hasNav = false;
|
|
2164
|
-
for (var j = 0; j < kids.length; j++) {
|
|
2165
|
-
if (kids[j].tagName === 'NAV') { hasNav = true; break; }
|
|
2166
|
-
}
|
|
2167
|
-
if (hasNav) dlg.setAttribute('data-dsh-settings', '1');
|
|
2168
|
-
}
|
|
2169
|
-
}
|
|
2170
|
-
tagSettingsPanel();
|
|
2171
|
-
if (typeof MutationObserver !== 'undefined') {
|
|
2172
|
-
// 设置弹窗是动态挂载/卸载的,用 MutationObserver 监听其出现并打标。
|
|
2173
|
-
var obs = new MutationObserver(function () { tagSettingsPanel(); });
|
|
2174
|
-
try { obs.observe(document.documentElement, { childList: true, subtree: true }); } catch (e) {}
|
|
2175
|
-
window.__DSH_MOBILE_REMOTE_SETTINGS_OBSERVER__ = obs;
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
// 调试钩子:在控制台暴露状态,便于移动端远程排查。
|
|
2179
|
-
window.__DSH_MOBILE_REMOTE_READY__ = {
|
|
2180
|
-
mobile: true,
|
|
2181
|
-
viewport: { w: window.innerWidth, h: window.innerHeight },
|
|
2182
|
-
dpr: window.devicePixelRatio || 1,
|
|
2183
|
-
lanEnabled: cfg.lanEnabled === true,
|
|
2184
|
-
};
|
|
2185
|
-
|
|
2186
|
-
/* ==================================================================
|
|
2187
|
-
* 移动端上传图片(composer 加号旁的「上传图片」入口)
|
|
2188
|
-
* ----------------------------------------------------------------
|
|
2189
|
-
* DSH 桌面端原生支持图片消息(粘贴 / 拖放均可,格式/数量/大小预检查、
|
|
2190
|
-
* 缩略图 rail、随消息上传全部由官方链路完成),但移动端没有「选择图片」
|
|
2191
|
-
* 的入口:工具行的 + 按钮只打开命令菜单。
|
|
2192
|
-
*
|
|
2193
|
-
* 此处注入一个图片按钮 + 隐藏的 <input type=file>。用户在手机上通过
|
|
2194
|
-
* 系统相册 / 文件选择器选图后,用普通对象模拟 clipboardData / dataTransfer
|
|
2195
|
-
* (不依赖 new DataTransfer(),iOS Safari 兼容性差)派发 paste 事件——
|
|
2196
|
-
* DSH 的 onPaste 原生 intakeImages 自动接管;若 paste 未生效,再回退向
|
|
2197
|
-
* document 派发 drop 事件(DSH 的文档级 onDrop 同样走 intakeImages)。
|
|
2198
|
-
* 全程不依赖 DSH 内部 API,harness 升级后只要 paste / drop 链路还在即可
|
|
2199
|
-
* 正常工作。
|
|
2200
|
-
* ================================================================== */
|
|
2201
|
-
|
|
2202
|
-
// 轻提示:DSH 的 Toast 是内部组件,这里自备一个极简气泡。
|
|
2203
|
-
var mrToastTimer = null;
|
|
2204
|
-
function mrToast(text) {
|
|
2205
|
-
var old = document.getElementById('dsh-mr-toast');
|
|
2206
|
-
if (old) { try { old.parentNode.removeChild(old); } catch (e) {} }
|
|
2207
|
-
var t = document.createElement('div');
|
|
2208
|
-
t.id = 'dsh-mr-toast';
|
|
2209
|
-
t.textContent = text;
|
|
2210
|
-
(document.body || document.documentElement).appendChild(t);
|
|
2211
|
-
if (mrToastTimer) clearTimeout(mrToastTimer);
|
|
2212
|
-
mrToastTimer = setTimeout(function () {
|
|
2213
|
-
try { t.parentNode && t.parentNode.removeChild(t); } catch (e) {}
|
|
2214
|
-
}, 2400);
|
|
2215
|
-
}
|
|
2216
|
-
|
|
2217
|
-
// 隐藏的文件选择器(全局唯一,选完即重置,可重复选同一张图)。
|
|
2218
|
-
// 注意:本脚本注入在 <head>,此时 document.body 可能尚未创建;若直接
|
|
2219
|
-
// 挂到 documentElement,部分 iOS WebView 会拒绝 .click() 打开选择器,
|
|
2220
|
-
// 因此等 body 就绪后再挂载(change 监听与挂载时机无关)。
|
|
2221
|
-
var mrFileInput = document.createElement('input');
|
|
2222
|
-
mrFileInput.type = 'file';
|
|
2223
|
-
mrFileInput.accept = 'image/*';
|
|
2224
|
-
mrFileInput.multiple = true;
|
|
2225
|
-
mrFileInput.tabIndex = -1;
|
|
2226
|
-
mrFileInput.setAttribute('aria-hidden', 'true');
|
|
2227
|
-
mrFileInput.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none;';
|
|
2228
|
-
mrFileInput.addEventListener('change', function () {
|
|
2229
|
-
var files = [];
|
|
2230
|
-
try { files = Array.prototype.slice.call(mrFileInput.files || []); } catch (e) {}
|
|
2231
|
-
if (files.length > 0) mrIntakeFiles(files);
|
|
2232
|
-
try { mrFileInput.value = ''; } catch (e) {}
|
|
2233
|
-
});
|
|
2234
|
-
function mrMountFileInput() {
|
|
2235
|
-
var host = document.body || document.documentElement;
|
|
2236
|
-
if (host && !mrFileInput.parentNode) host.appendChild(mrFileInput);
|
|
2237
|
-
}
|
|
2238
|
-
if (document.body) mrMountFileInput();
|
|
2239
|
-
else document.addEventListener('DOMContentLoaded', mrMountFileInput, { once: true });
|
|
2240
|
-
|
|
2241
|
-
// ===== 图片 intake:兼容各种浏览器的事件派发 =====
|
|
2242
|
-
// DSH 原生 intake 读两个事件:
|
|
2243
|
-
// - onPaste(React 合成事件):e.clipboardData.items —— 只要求 items
|
|
2244
|
-
// 可迭代、每项 kind==='file' 且 getAsFile() 返回真实 File;
|
|
2245
|
-
// - onDrop(document 原生监听器):e.dataTransfer.types / .files。
|
|
2246
|
-
// 我们不构造 DataTransfer / ClipboardEvent(iOS Safari 上
|
|
2247
|
-
// new DataTransfer() + items.add() 之后 files 返回空 FileList、
|
|
2248
|
-
// item.getAsFile() 可能返回 null,兼容性差),而是用普通 JS 对象
|
|
2249
|
-
// 模拟这两个接口——任何浏览器都可靠。结果用「缩略图是否真的出现」
|
|
2250
|
-
// 判断(不依赖 defaultPrevented——locked/machineBusy 时 DSH 也会
|
|
2251
|
-
// preventDefault)。
|
|
2252
|
-
|
|
2253
|
-
// 当前 composer 附件缩略图数量(rail 渲染后计数)。
|
|
2254
|
-
function mrCountRailImages() {
|
|
2255
|
-
try { return document.querySelectorAll('[data-composer-card] [role="group"] img').length; } catch (e) { return 0; }
|
|
2256
|
-
}
|
|
2257
|
-
|
|
2258
|
-
// 构造模拟 clipboardData(供 onPaste 使用):items 是普通数组,每项
|
|
2259
|
-
// 的 kind='file'、getAsFile() 返回真实 File;getData/setData/clearData
|
|
2260
|
-
// 补齐 DSH onPaste 会调用的接口(缺 getData 会抛 TypeError 导致 React
|
|
2261
|
-
// 事件处理器崩溃)。绕开 DataTransfer 构造器在 iOS Safari 上的 files
|
|
2262
|
-
// 为空 / getAsFile 返回 null 等兼容问题。
|
|
2263
|
-
function mrBuildClipboardData(images) {
|
|
2264
|
-
var items = [];
|
|
2265
|
-
for (var j = 0; j < images.length; j++) {
|
|
2266
|
-
(function (file) {
|
|
2267
|
-
items.push({
|
|
2268
|
-
kind: 'file',
|
|
2269
|
-
type: file.type || '',
|
|
2270
|
-
getAsFile: function () { return file; },
|
|
2271
|
-
});
|
|
2272
|
-
})(images[j]);
|
|
2273
|
-
}
|
|
2274
|
-
return {
|
|
2275
|
-
items: items,
|
|
2276
|
-
files: images,
|
|
2277
|
-
types: ['Files'],
|
|
2278
|
-
getData: function () { return ''; },
|
|
2279
|
-
setData: function () {},
|
|
2280
|
-
clearData: function () {},
|
|
2281
|
-
};
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2284
|
-
// 构造模拟 dataTransfer(供 onDrop 使用):只读 .types / .files。
|
|
2285
|
-
function mrBuildDataTransfer(images) {
|
|
2286
|
-
return { files: images, types: ['Files'] };
|
|
2287
|
-
}
|
|
2288
|
-
|
|
2289
|
-
// 向 document 派发 drop(DSH 的原生 onDrop 监听器会 intakeImages)。
|
|
2290
|
-
function mrDispatchDrop(dt) {
|
|
2291
|
-
try {
|
|
2292
|
-
var evt = new Event('drop', { bubbles: true, cancelable: true });
|
|
2293
|
-
try { evt.dataTransfer = dt; } catch (e) {}
|
|
2294
|
-
document.dispatchEvent(evt);
|
|
2295
|
-
return true;
|
|
2296
|
-
} catch (e) { return false; }
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
// 向 textarea 派发 paste(React 合成 onPaste 会 intakeImages)。
|
|
2300
|
-
function mrDispatchPaste(cd, textarea) {
|
|
2301
|
-
try {
|
|
2302
|
-
var evt = new Event('paste', { bubbles: true, cancelable: true });
|
|
2303
|
-
try { evt.clipboardData = cd; } catch (e) {}
|
|
2304
|
-
textarea.dispatchEvent(evt);
|
|
2305
|
-
return true;
|
|
2306
|
-
} catch (e) { return false; }
|
|
2307
|
-
}
|
|
2308
|
-
|
|
2309
|
-
// 把选中的图片喂给 DSH 原生 intake。
|
|
2310
|
-
function mrIntakeFiles(files) {
|
|
2311
|
-
var textarea = document.querySelector('[data-composer-card] [data-input-scroll] textarea');
|
|
2312
|
-
if (!textarea) { mrToast('未找到输入框,请重试'); return; }
|
|
2313
|
-
if (textarea.disabled || textarea.readOnly) { mrToast('当前输入框不可用'); return; }
|
|
2314
|
-
var images = [];
|
|
2315
|
-
for (var i = 0; i < files.length; i++) {
|
|
2316
|
-
// 注意:本函数经模板字符串注入 <script>,正则里的反斜杠转义(\/)
|
|
2317
|
-
// 会被当作非转义序列丢弃(/^image\//i 变成 /^image//i,解析成除法,
|
|
2318
|
-
// 运行时抛 i.test is not a function)。因此这里用 indexOf 判断类型。
|
|
2319
|
-
if ((files[i].type || '').indexOf('image/') === 0) images.push(files[i]);
|
|
2320
|
-
}
|
|
2321
|
-
if (images.length === 0) { mrToast('请选择图片文件'); return; }
|
|
2322
|
-
var before = mrCountRailImages();
|
|
2323
|
-
// 方式一:paste(React onPaste 读 clipboardData.items,模拟对象对
|
|
2324
|
-
// iOS Safari / 安卓 / 桌面全兼容)。
|
|
2325
|
-
mrDispatchPaste(mrBuildClipboardData(images), textarea);
|
|
2326
|
-
setTimeout(function () {
|
|
2327
|
-
if (mrCountRailImages() > before) return;
|
|
2328
|
-
// 方式二:drop 到 document(原生监听器兜底)。
|
|
2329
|
-
mrDispatchDrop(mrBuildDataTransfer(images));
|
|
2330
|
-
setTimeout(function () {
|
|
2331
|
-
if (mrCountRailImages() > before) return;
|
|
2332
|
-
mrToast('图片未添加上,请长按输入框粘贴图片');
|
|
2333
|
-
}, 300);
|
|
2334
|
-
}, 300);
|
|
2335
|
-
}
|
|
2336
|
-
|
|
2337
|
-
// 图片按钮:与 DSH 原生 + 按钮同风格的小圆钮(SVG 图片图标)。
|
|
2338
|
-
function mrMakeAttachBtn() {
|
|
2339
|
-
var btn = document.createElement('button');
|
|
2340
|
-
btn.type = 'button';
|
|
2341
|
-
btn.className = 'dsh-mr-attach';
|
|
2342
|
-
btn.setAttribute('aria-label', '上传图片');
|
|
2343
|
-
btn.title = '上传图片';
|
|
2344
|
-
btn.innerHTML =
|
|
2345
|
-
'<svg viewBox="0 0 16 16" width="15" height="15" fill="none" aria-hidden="true">' +
|
|
2346
|
-
'<rect x="1.6" y="2.7" width="12.8" height="10.6" rx="2.3" stroke="currentColor" stroke-width="1.35"/>' +
|
|
2347
|
-
'<circle cx="5.4" cy="6.1" r="1.3" fill="currentColor"/>' +
|
|
2348
|
-
'<path d="M2.4 12.4L6.3 8.3a1.05 1.05 0 0 1 1.5 0l1.15 1.2 2.2-2.35a1.05 1.05 0 0 1 1.55 0l1.9 2.1" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/>' +
|
|
2349
|
-
'</svg>';
|
|
2350
|
-
btn.addEventListener('click', function (e) {
|
|
2351
|
-
e.preventDefault();
|
|
2352
|
-
if (btn.disabled) return;
|
|
2353
|
-
try { mrFileInput.value = ''; } catch (err) {}
|
|
2354
|
-
mrFileInput.click();
|
|
2355
|
-
});
|
|
2356
|
-
return btn;
|
|
2357
|
-
}
|
|
2358
|
-
|
|
2359
|
-
// 同步按钮禁用态:跟随 composer 的 textarea(session 移除 / 锁定 / 提交中)。
|
|
2360
|
-
function mrSyncAttachState(btn) {
|
|
2361
|
-
if (!btn) return;
|
|
2362
|
-
var textarea = document.querySelector('[data-composer-card] [data-input-scroll] textarea');
|
|
2363
|
-
btn.disabled = !textarea || textarea.disabled || textarea.readOnly;
|
|
2364
|
-
}
|
|
2365
|
-
|
|
2366
|
-
// 把上传按钮插到工具行(+ 加号按钮之后)。
|
|
2367
|
-
function mrInjectAttach() {
|
|
2368
|
-
var card = document.querySelector('[data-composer-card]');
|
|
2369
|
-
if (!card || card.querySelector('.dsh-mr-attach')) return;
|
|
2370
|
-
var tools = card.querySelector(':scope > div:last-child > div:first-child');
|
|
2371
|
-
if (!tools) return;
|
|
2372
|
-
var btn = mrMakeAttachBtn();
|
|
2373
|
-
tools.insertBefore(btn, tools.children[1] || null);
|
|
2374
|
-
mrSyncAttachState(btn);
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
// ===== 移动端弹窗水平钳制 =====
|
|
2378
|
-
// 权限选择菜单是绝对定位浮层,不含 viewport 钳制,窄屏下容易向右溢出
|
|
2379
|
-
// 或向左偏(模型选择菜单在移动端已改为底部抽屉,由 CSS 定位并自带
|
|
2380
|
-
// 钳制,见 mrClampMenus 内的 fixed 跳过)。菜单出现/布局变化时读取
|
|
2381
|
-
// rect,用 translateX 把它平移回视口内(菜单无 transform 动画,平移
|
|
2382
|
-
// 安全;每次覆盖式设置,不会累积偏移)。隐藏中的菜单 rect 为 0,跳过。
|
|
2383
|
-
function mrClampMenus() {
|
|
2384
|
-
var menus = document.querySelectorAll('[data-composer-card] [role="menu"]');
|
|
2385
|
-
for (var i = 0; i < menus.length; i++) {
|
|
2386
|
-
var menu = menus[i];
|
|
2387
|
-
try {
|
|
2388
|
-
// 模型选择菜单在移动端已改为固定底部抽屉(bottom sheet),
|
|
2389
|
-
// 位置由 CSS 决定且自带视口钳制,跳过平移。
|
|
2390
|
-
if (window.getComputedStyle(menu).position === 'fixed') continue;
|
|
2391
|
-
var r = menu.getBoundingClientRect();
|
|
2392
|
-
if (r.width === 0 && r.height === 0) continue;
|
|
2393
|
-
var vw = window.innerWidth;
|
|
2394
|
-
var dx = 0;
|
|
2395
|
-
if (r.left < 8) dx = 8 - r.left;
|
|
2396
|
-
else if (r.right > vw - 8) dx = (vw - 8) - r.right;
|
|
2397
|
-
if (dx !== 0) menu.style.transform = 'translateX(' + dx + 'px)';
|
|
2398
|
-
} catch (e) { /* ignore */ }
|
|
2399
|
-
}
|
|
2400
|
-
}
|
|
2401
|
-
|
|
2402
|
-
// ===== 发送状态指示 =====
|
|
2403
|
-
// DSH 点击发送后,machine 进入 'submitting'(消息)/ 'adjudicating'(命令
|
|
2404
|
-
// 裁决)phase:发送前要先把附件图片 serializeImages 成 base64 再 prompt,
|
|
2405
|
-
// 移动端大图这一步可能持续数秒,而会话列表要等发送成功才出现新消息,
|
|
2406
|
-
// 用户容易误以为「没发出去」而重发或离开。这里在 composer 上方显示
|
|
2407
|
-
// 「上传中 / 发送中」提示,phase 恢复后自动隐藏。只读 textarea 的
|
|
2408
|
-
// data-phase,不依赖 DSH 内部 API。
|
|
2409
|
-
var mrSendingEl = null;
|
|
2410
|
-
|
|
2411
|
-
function mrMakeSendingEl() {
|
|
2412
|
-
var el = document.createElement('div');
|
|
2413
|
-
el.className = 'dsh-mr-sending';
|
|
2414
|
-
el.setAttribute('role', 'status');
|
|
2415
|
-
el.setAttribute('aria-live', 'polite');
|
|
2416
|
-
return el;
|
|
2417
|
-
}
|
|
2418
|
-
|
|
2419
|
-
function mrShowSending() {
|
|
2420
|
-
var card = document.querySelector('[data-composer-card]');
|
|
2421
|
-
if (!card) return;
|
|
2422
|
-
// 会话切换时 composer 卡片会被重建,旧浮层节点会脱离 DOM,需重建挂载。
|
|
2423
|
-
if (!mrSendingEl || !mrSendingEl.isConnected) {
|
|
2424
|
-
mrSendingEl = mrMakeSendingEl();
|
|
2425
|
-
card.appendChild(mrSendingEl);
|
|
2426
|
-
}
|
|
2427
|
-
var hasImages = mrCountRailImages() > 0;
|
|
2428
|
-
mrSendingEl.innerHTML = '<span class="dsh-mr-spinner"></span>' +
|
|
2429
|
-
(hasImages ? '正在上传图片并发送…' : '正在发送…');
|
|
2430
|
-
mrSendingEl.style.display = '';
|
|
2431
|
-
}
|
|
2432
|
-
|
|
2433
|
-
function mrHideSending() {
|
|
2434
|
-
if (mrSendingEl) mrSendingEl.style.display = 'none';
|
|
2435
|
-
}
|
|
2436
|
-
|
|
2437
|
-
function mrSyncSending() {
|
|
2438
|
-
var textarea = document.querySelector('[data-composer-card] [data-input-scroll] textarea');
|
|
2439
|
-
if (!textarea) { mrHideSending(); return; }
|
|
2440
|
-
var phase = textarea.getAttribute('data-phase') || '';
|
|
2441
|
-
if (phase === 'submitting' || phase === 'adjudicating') mrShowSending();
|
|
2442
|
-
else mrHideSending();
|
|
2443
|
-
}
|
|
2444
|
-
|
|
2445
|
-
function mrScan() {
|
|
2446
|
-
mrInjectAttach();
|
|
2447
|
-
var btn = document.querySelector('[data-composer-card] .dsh-mr-attach');
|
|
2448
|
-
mrSyncAttachState(btn);
|
|
2449
|
-
mrClampMenus();
|
|
2450
|
-
mrSyncSending();
|
|
2451
|
-
}
|
|
2452
|
-
|
|
2453
|
-
// composer 是动态挂载/重建的(会话切换),用 MutationObserver 跟踪挂载
|
|
2454
|
-
// 与 textarea 锁定态变化;requestAnimationFrame 合并高频回调。
|
|
2455
|
-
mrScan();
|
|
2456
|
-
var mrScanPending = false;
|
|
2457
|
-
if (typeof MutationObserver !== 'undefined') {
|
|
2458
|
-
var mrObs = new MutationObserver(function () {
|
|
2459
|
-
if (mrScanPending) return;
|
|
2460
|
-
mrScanPending = true;
|
|
2461
|
-
requestAnimationFrame(function () {
|
|
2462
|
-
mrScanPending = false;
|
|
2463
|
-
mrScan();
|
|
2464
|
-
});
|
|
2465
|
-
});
|
|
2466
|
-
try {
|
|
2467
|
-
mrObs.observe(document.documentElement, {
|
|
2468
|
-
childList: true,
|
|
2469
|
-
subtree: true,
|
|
2470
|
-
attributes: true,
|
|
2471
|
-
attributeFilter: ['disabled', 'readonly', 'data-phase'],
|
|
2472
|
-
});
|
|
2473
|
-
} catch (e) {}
|
|
2474
|
-
}
|
|
2475
|
-
|
|
2476
|
-
/* ------------------------------------------------------------------ */
|
|
2477
|
-
/* 移动端侧栏:默认收起,悬浮按钮唤出覆盖式抽屉 */
|
|
2478
|
-
/* ------------------------------------------------------------------ */
|
|
2479
|
-
/* 原生在窄屏把侧栏折成 56px 的 icon rail,展开又是「挤压中栏」的网格列—— */
|
|
2480
|
-
/* 手机上一展开内容区只剩百来像素。这里保留官方 store 与原生 toggle 按钮, */
|
|
2481
|
-
/* 只用 CSS 把「挤压」改写成「浮层抽屉」: */
|
|
2482
|
-
/* · 常态:侧栏轨道 0px,内容吃满整屏(由 MOBILE_CSS 负责); */
|
|
2483
|
-
/* · 点悬浮按钮 → 触发原生 toggle(store 真正展开)→ 打上 data-dsh-nav */
|
|
2484
|
-
/* · 点遮罩 / 按 Esc / 选中会话 → 触发原生 toggle 收起并去掉标记 */
|
|
2485
|
-
function mrNavInit() {
|
|
2486
|
-
if (!isMobile()) return;
|
|
2487
|
-
if (document.getElementById('dsh-mobile-nav-btn')) return;
|
|
2488
|
-
|
|
2489
|
-
var html = document.documentElement;
|
|
2490
|
-
var SIDEBAR_LABELS = ['打开侧边栏', 'Open sidebar', '展开侧边栏'];
|
|
2491
|
-
var COLLAPSE_LABELS = ['收起侧边栏', 'Collapse sidebar'];
|
|
2492
|
-
|
|
2493
|
-
function findFrame() {
|
|
2494
|
-
var overlay = document.querySelector('[data-shell-overlay]');
|
|
2495
|
-
if (overlay && overlay.parentElement) return overlay.parentElement;
|
|
2496
|
-
var root = document.getElementById('root');
|
|
2497
|
-
return root ? root.firstElementChild : null;
|
|
2498
|
-
}
|
|
2499
|
-
|
|
2500
|
-
function markFrame() {
|
|
2501
|
-
var frame = findFrame();
|
|
2502
|
-
if (!frame) return null;
|
|
2503
|
-
if (!frame.hasAttribute('data-dsh-mobile-frame')) frame.setAttribute('data-dsh-mobile-frame', '');
|
|
2504
|
-
return frame;
|
|
2505
|
-
}
|
|
2506
|
-
|
|
2507
|
-
function sidebarCollapsed() {
|
|
2508
|
-
var frame = findFrame();
|
|
2509
|
-
return !!(frame && frame.hasAttribute('data-sidebar-collapsed'));
|
|
2510
|
-
}
|
|
2511
|
-
|
|
2512
|
-
function findToggle(wanted) {
|
|
2513
|
-
var buttons = document.querySelectorAll('button[aria-label]');
|
|
2514
|
-
for (var i = 0; i < buttons.length; i++) {
|
|
2515
|
-
var label = buttons[i].getAttribute('aria-label') || '';
|
|
2516
|
-
for (var j = 0; j < wanted.length; j++) {
|
|
2517
|
-
if (label === wanted[j]) return buttons[i];
|
|
2518
|
-
}
|
|
2519
|
-
}
|
|
2520
|
-
return null;
|
|
2521
|
-
}
|
|
2522
|
-
|
|
2523
|
-
/* 点原生 toggle:不自造状态,保证官方 store / aria / 快捷键始终一致 */
|
|
2524
|
-
function clickNativeToggle(expand) {
|
|
2525
|
-
var btn = findToggle(expand ? SIDEBAR_LABELS : COLLAPSE_LABELS);
|
|
2526
|
-
if (!btn) {
|
|
2527
|
-
// 兜底:带侧栏快捷键标注的按钮(命令 id: sidebar.left.toggle)
|
|
2528
|
-
var alt = document.querySelector('button[aria-keyshortcuts]');
|
|
2529
|
-
var all = document.querySelectorAll('button[aria-keyshortcuts]');
|
|
2530
|
-
for (var k = 0; k < all.length; k++) {
|
|
2531
|
-
var ks = (all[k].getAttribute('aria-keyshortcuts') || '').toLowerCase();
|
|
2532
|
-
if (ks.indexOf('b') !== -1) { btn = all[k]; break; }
|
|
2533
|
-
}
|
|
2534
|
-
if (!btn) btn = alt;
|
|
2535
|
-
}
|
|
2536
|
-
if (!btn) return false;
|
|
2537
|
-
try { btn.click(); return true } catch (e) { return false }
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
function openNav() {
|
|
2541
|
-
markFrame();
|
|
2542
|
-
if (sidebarCollapsed()) clickNativeToggle(true);
|
|
2543
|
-
html.setAttribute('data-dsh-nav', 'open');
|
|
2544
|
-
// 原生展开后会重新排版,下一帧再确认一次框架标记仍然有效
|
|
2545
|
-
requestAnimationFrame(markFrame);
|
|
2546
|
-
}
|
|
2547
|
-
|
|
2548
|
-
function closeNav() {
|
|
2549
|
-
// 先撤标记:避免原生收起动画期间出现「挤压态」的中间画面
|
|
2550
|
-
html.removeAttribute('data-dsh-nav');
|
|
2551
|
-
if (!sidebarCollapsed()) clickNativeToggle(false);
|
|
2552
|
-
}
|
|
2553
|
-
|
|
2554
|
-
var btn = document.createElement('button');
|
|
2555
|
-
btn.id = 'dsh-mobile-nav-btn';
|
|
2556
|
-
btn.type = 'button';
|
|
2557
|
-
btn.setAttribute('aria-label', '打开侧边栏');
|
|
2558
|
-
btn.innerHTML =
|
|
2559
|
-
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
|
2560
|
-
'stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
|
|
2561
|
-
'<path d="M4 7h16M4 12h16M4 17h16"/></svg>';
|
|
2562
|
-
btn.addEventListener('click', function (event) {
|
|
2563
|
-
event.preventDefault();
|
|
2564
|
-
event.stopPropagation();
|
|
2565
|
-
if (html.getAttribute('data-dsh-nav') === 'open') closeNav();
|
|
2566
|
-
else openNav();
|
|
2567
|
-
});
|
|
2568
|
-
|
|
2569
|
-
var scrim = document.createElement('div');
|
|
2570
|
-
scrim.id = 'dsh-mobile-nav-scrim';
|
|
2571
|
-
scrim.setAttribute('aria-hidden', 'true');
|
|
2572
|
-
scrim.addEventListener('click', function (event) {
|
|
2573
|
-
event.preventDefault();
|
|
2574
|
-
closeNav();
|
|
2575
|
-
});
|
|
2576
|
-
|
|
2577
|
-
document.body.appendChild(scrim);
|
|
2578
|
-
document.body.appendChild(btn);
|
|
2579
|
-
|
|
2580
|
-
// Esc 关闭(外接键盘 / 平板)
|
|
2581
|
-
document.addEventListener('keydown', function (event) {
|
|
2582
|
-
if (event.key === 'Escape' && html.getAttribute('data-dsh-nav') === 'open') closeNav();
|
|
2583
|
-
});
|
|
2584
|
-
|
|
2585
|
-
// 抽屉里点了会话 / 工作区等可导航项就自动收起
|
|
2586
|
-
document.addEventListener('click', function (event) {
|
|
2587
|
-
if (html.getAttribute('data-dsh-nav') !== 'open') return;
|
|
2588
|
-
var frame = findFrame();
|
|
2589
|
-
var col = frame && frame.firstElementChild;
|
|
2590
|
-
if (!col || !col.contains(event.target)) return;
|
|
2591
|
-
if (event.target.closest && event.target.closest('button[aria-label]')) {
|
|
2592
|
-
var label = event.target.closest('button[aria-label]').getAttribute('aria-label');
|
|
2593
|
-
if (COLLAPSE_LABELS.indexOf(label) !== -1) { closeNav(); return }
|
|
2594
|
-
}
|
|
2595
|
-
var item = event.target.closest && event.target.closest('a,[role="option"],[role="treeitem"],[role="menuitem"]');
|
|
2596
|
-
if (item) setTimeout(closeNav, 60);
|
|
2597
|
-
}, true);
|
|
2598
|
-
|
|
2599
|
-
// 原生 toggle(含快捷键 Alt+B)改变了折叠态时,保持我们的标记同步
|
|
2600
|
-
var frameWatch = null;
|
|
2601
|
-
function watchFrame() {
|
|
2602
|
-
var frame = markFrame();
|
|
2603
|
-
if (!frame) return false;
|
|
2604
|
-
if (frameWatch || typeof MutationObserver === 'undefined') return true;
|
|
2605
|
-
frameWatch = new MutationObserver(function () {
|
|
2606
|
-
var open = html.getAttribute('data-dsh-nav') === 'open';
|
|
2607
|
-
var collapsed = frame.hasAttribute('data-sidebar-collapsed');
|
|
2608
|
-
if (open && collapsed) html.removeAttribute('data-dsh-nav');
|
|
2609
|
-
});
|
|
2610
|
-
try { frameWatch.observe(frame, { attributes: true, attributeFilter: ['data-sidebar-collapsed'] }) } catch (e) {}
|
|
2611
|
-
return true;
|
|
2612
|
-
}
|
|
2613
|
-
|
|
2614
|
-
// 框架节点可能晚于本脚本出现:短暂重试 + 兜底轮询
|
|
2615
|
-
var tries = 0;
|
|
2616
|
-
(function tick() {
|
|
2617
|
-
if (watchFrame()) return;
|
|
2618
|
-
if (tries++ > 40) return;
|
|
2619
|
-
setTimeout(tick, 250);
|
|
2620
|
-
})();
|
|
2621
|
-
}
|
|
2622
|
-
|
|
2623
|
-
try { mrNavInit(); } catch (e) {}
|
|
2624
1811
|
})();
|
|
2625
1812
|
`
|
|
2626
1813
|
|