dsh-remote-ui 0.6.0 → 0.6.1

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.
Files changed (3) hide show
  1. package/lib/client.js +220 -17
  2. package/lib/index.js +285 -30
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -134,6 +134,9 @@ window.__ModuleLoader__.load({
134
134
  ".dru-popup-foot button{border:none;background:none;color:#57606a;cursor:pointer;font-size:12px;font-family:inherit;padding:4px 6px}",
135
135
  ".dru-popup-foot button:hover{color:#0969da}",
136
136
  ".dru-popup .dru-msg{text-align:left}",
137
+ ".dru-community-qr{display:block;width:220px;max-width:62vw;margin:0 auto;background:#ffffff;padding:10px;border-radius:10px;border:1px solid #d0d7de;box-sizing:content-box}",
138
+ ".dru-fb-community{margin-top:16px;padding-top:14px;border-top:1px dashed #d0d7de;text-align:center}",
139
+ ".dru-fb-community .dru-community-qr{width:180px;max-width:56vw}",
137
140
  // ── 自管理:版本与更新(插件面板内提供在线更新/彻底卸载,市场无更新按钮) ──
138
141
  ".dru-ver-badge{display:inline-block;font-size:11px;border-radius:999px;padding:1px 8px;margin-left:6px;vertical-align:1px}",
139
142
  ".dru-ver-badge-new{color:#9a6700;background:#fff8c5;border:1px solid #eed888}",
@@ -347,6 +350,60 @@ window.__ModuleLoader__.load({
347
350
  return api(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data || {}) });
348
351
  };
349
352
 
353
+ // ── 交流群(运营二维码;管理后台统一上传,node 半经公开配置下发) ─────────
354
+ // 设置面板「加入交流群」按钮与用户反馈页都展示同一张码;未配置(qrcode 为空)→ 不展示任何入口。
355
+ var COMMUNITY_TTL_MS = 5 * 60 * 1000; // 面板/反馈卡共用缓存:5 分钟内不重复请求
356
+ var communityCache = { at: 0, data: null };
357
+ /** 取交流群信息(模块级缓存;失败视为未配置,不打扰用户)。 */
358
+ function loadCommunity(force) {
359
+ if (!force && communityCache.data && Date.now() - communityCache.at < COMMUNITY_TTL_MS) {
360
+ return Promise.resolve(communityCache.data);
361
+ }
362
+ return api("/dsh-remote/community").then(function (b) {
363
+ communityCache = { at: Date.now(), data: { qrcode: (b && b.qrcode) || "", wechat: (b && b.wechat) || "" } };
364
+ return communityCache.data;
365
+ }).catch(function () {
366
+ communityCache = { at: Date.now(), data: { qrcode: "", wechat: "" } };
367
+ return communityCache.data;
368
+ });
369
+ }
370
+
371
+ // ── 登录后首次加载的自愈(0.6.1) ───────────────────────────────
372
+ // 现象(首次安装后立即登录):面板在 loggedIn 翻真的瞬间就请求 /dsh-remote/access-key 与
373
+ // /dsh-remote/mobile-sessions;此时中继握手可能尚未就绪(bridge 刚被重启、device-login 共享密钥
374
+ // 刚补齐或正在轮换、中继冷启动/网络抖动),旧版只报一次红字且不重试——必须手动刷新页面才行。
375
+ // 现在:可重试类失败按退避自动重试(成功即自动清除红字),并给红字配「重试」按钮。
376
+ var LOGIN_LOAD_RETRY_MS = [1200, 3000, 6000];
377
+
378
+ /** node 半标记 retryable(中继未就绪)或网络/5xx → 值得自动重试;未登录/密码失效不重试。 */
379
+ function retryableFail(e) {
380
+ var body = e && e.body;
381
+ if (body && body.retryable === true) return true;
382
+ if (body && typeof body.hint === "string" && /^relay_/.test(body.hint)) return true;
383
+ var st = e && e.status;
384
+ if (typeof st !== "number") return true; // fetch 本身失败(网络中断/被中止)
385
+ return st === 0 || st === 408 || st === 429 || st >= 500;
386
+ }
387
+
388
+ /** 定时器节流:tests/SSR 环境没有 setTimeout 时静默降级(不抛错、不阻塞面板)。 */
389
+ function later(fn, ms) {
390
+ try { return setTimeout(fn, ms); } catch (e) { return null; }
391
+ }
392
+ function clearLater(t) {
393
+ if (t == null) return;
394
+ try { clearTimeout(t); } catch (e) { /* 忽略 */ }
395
+ }
396
+
397
+ /** 一次性访问密钥 / 已授权设备列表的自动重试状态(放在组件外:不随渲染重建)。 */
398
+ var akeyRetry = { timer: null, attempt: 0 };
399
+ var devRetry = { timer: null, attempt: 0 };
400
+ // 请求在途标记同样放组件外:组件内 state 闭包会在定时器/轮询回调里过期,
401
+ // 导致 busy 护栏失效(重复请求)或误拦(该重试却跳过)。
402
+ var akeyInFlight = { v: false };
403
+ var devInFlight = { v: false };
404
+ function cancelAkeyRetry() { clearLater(akeyRetry.timer); akeyRetry.timer = null; }
405
+ function cancelDevRetry() { clearLater(devRetry.timer); devRetry.timer = null; }
406
+
350
407
  /** 一次性访问 url → 升级/续费页 url(带同一 auth,进入后即登录态到 /app/promo)。 */
351
408
  function promoUrlOf(u) {
352
409
  var s = String(u || "");
@@ -442,6 +499,8 @@ window.__ModuleLoader__.load({
442
499
  var mineListArr = useState(null); var mineList = mineListArr[0]; var setMineList = mineListArr[1];
443
500
  var mineErrArr = useState(false); var mineErr = mineErrArr[0]; var setMineErr = mineErrArr[1];
444
501
  var mineBusyArr = useState(false); var mineBusy = mineBusyArr[0]; var setMineBusy = mineBusyArr[1];
502
+ // 💬 交流群:反馈页底部展示「加入交流群」二维码(与设置面板共用同一份缓存)
503
+ var commArr = useState(communityCache.data); var comm = commArr[0]; var setComm = commArr[1];
445
504
 
446
505
  function setMsg(kind, text) { setFbMsg({ kind: kind, text: text }); }
447
506
 
@@ -449,6 +508,12 @@ window.__ModuleLoader__.load({
449
508
  // 自建/未登录(cfg.phone 为空)→ 仍走本地 thread_token,不发 /mine。
450
509
  var fbAccount = !!(cfg && cfg.phone && fbAuth === "account");
451
510
 
511
+ useEffect(function () {
512
+ var alive = true;
513
+ loadCommunity().then(function (d) { if (alive) setComm(d); });
514
+ return function () { alive = false; };
515
+ }, []);
516
+
452
517
  var loadCfg = useCallback(function () {
453
518
  api("/dsh-remote/feedback-config").then(function (b) {
454
519
  setCfg(b);
@@ -688,7 +753,15 @@ window.__ModuleLoader__.load({
688
753
  h("button", { type: "button", className: "dru-btn dru-btn-primary", style: { width: "100%" }, disabled: busy !== "", onClick: doSubmit }, busy === "submit" ? "提交中…" : "提交反馈"),
689
754
  fbMsg && h("div", { className: "dru-msg dru-msg-" + fbMsg.kind }, fbMsg.text)
690
755
  )
691
- : renderMine()
756
+ : renderMine(),
757
+ // 反馈页底部:企微交流群二维码(后台上传后出现;未配置不展示)
758
+ comm && comm.qrcode
759
+ ? h("div", { className: "dru-fb-community" },
760
+ h("div", { className: "dru-fb-community-title" }, "💬 加入企微交流群"),
761
+ h("div", { className: "dru-hint", style: { marginBottom: 8 } }, "扫码入群:安装答疑 / 使用技巧 / 版本更新 / 问题反馈"),
762
+ h("img", { className: "dru-community-qr", src: comm.qrcode, alt: "企微交流群二维码" }),
763
+ comm.wechat ? h("div", { className: "dru-hint", style: { marginTop: 8 } }, "二维码失效或群满,可加客服微信:" + comm.wechat) : null)
764
+ : null
692
765
  );
693
766
  }
694
767
 
@@ -1099,6 +1172,9 @@ window.__ModuleLoader__.load({
1099
1172
  var pwdSmsArr = useState(""); var pwdSms = pwdSmsArr[0]; var setPwdSms = pwdSmsArr[1];
1100
1173
  var pwdSmsBtnArr = useState("获取验证码"); var pwdSmsBtn = pwdSmsBtnArr[0]; var setPwdSmsBtn = pwdSmsBtnArr[1];
1101
1174
  var pwdNewArr = useState(""); var pwdNew = pwdNewArr[0]; var setPwdNew = pwdNewArr[1]; // 新密码(≥8)
1175
+ // ── 💬 交流群(「加入交流群」按钮 + 弹窗):同样放在全部既有字段之后 ──
1176
+ var communityArr = useState(null); var community = communityArr[0]; var setCommunity = communityArr[1]; // {qrcode,wechat};null=未加载
1177
+ var commOpenArr = useState(false); var commOpen = commOpenArr[0]; var setCommOpen = commOpenArr[1];
1102
1178
 
1103
1179
  var refresh = useCallback(function () {
1104
1180
  setBusy("status");
@@ -1126,6 +1202,8 @@ window.__ModuleLoader__.load({
1126
1202
 
1127
1203
  var loggedIn = !!(st && st.config && (st.config.phone || st.config.hasLocalKey));
1128
1204
  var serviceRunning = !!(st && st.service && st.service.running);
1205
+ // 账号标识(掩码手机号):登录成功/换账号时变化 → 触发上方的重取 effect(比布尔 loggedIn 更敏感)
1206
+ var phoneKey = (st && st.config && st.config.phone) || "";
1129
1207
  var launchdPid = st && st.service && st.service.launchd && st.service.launchd.pid;
1130
1208
 
1131
1209
  // 已登录(SaaS)→ 拉我的信息/配额/公共配置
@@ -1137,6 +1215,13 @@ window.__ModuleLoader__.load({
1137
1215
  }, [st && st.config && st.config.phone]);
1138
1216
 
1139
1217
  useEffect(function () { refresh(); }, [refresh]);
1218
+
1219
+ // 交流群二维码(后台上传即可展示;未配置 → community.qrcode 为空,入口不出现)
1220
+ useEffect(function () {
1221
+ var alive = true;
1222
+ loadCommunity().then(function (d) { if (alive) setCommunity(d); });
1223
+ return function () { alive = false; };
1224
+ }, []);
1140
1225
  useEffect(function () {
1141
1226
  // 展开「忘记密码」重置表单时不用预载登录表单验证码(收起的 reset 表单用独立 pwdCap)
1142
1227
  if (st !== null && !loggedIn && mode === "saas" && authTab === "login" && !pwdOpen && !lcap) loadCaptcha("login");
@@ -1184,20 +1269,26 @@ window.__ModuleLoader__.load({
1184
1269
  return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " " + pad2(d.getHours()) + ":" + pad2(d.getMinutes());
1185
1270
  }
1186
1271
 
1187
- /** 创建(或刷新)一次性访问密钥:GET /dsh-remote/access-key(node 半转发企业端 /api/auth-key)。 */
1272
+ /**
1273
+ * 创建(或刷新)一次性访问密钥:GET /dsh-remote/access-key(node 半转发企业端 /api/auth-key)。
1274
+ * 失败自愈:中继未就绪类失败(node 半 retryable / 网络 / 5xx)按退避自动重试,
1275
+ * 见「登录后首次加载自愈」——首次安装立即登录时不再需要手动刷新页面。
1276
+ */
1188
1277
  function loadAccessKey() {
1189
- if (akeyBusy) return;
1278
+ if (akeyInFlight.v) return;
1190
1279
  // 登录前不请求企业端(避免 401/404 噪音);由账号登录成功后触发
1191
1280
  if (!(st && st.config && st.config.phone)) {
1192
1281
  setAkeyMsg(null);
1282
+ cancelAkeyRetry();
1193
1283
  return;
1194
1284
  }
1285
+ akeyInFlight.v = true;
1195
1286
  setAkeyBusy(true);
1196
1287
  api("/dsh-remote/access-key").then(function (b) {
1197
1288
  if (!b || !b.ok) {
1198
1289
  var why = (b && (b.error || (b.body && b.body.error))) || "未知错误";
1199
- setAkeyMsg({ kind: "err", text: "获取一次性访问地址失败:" + why });
1200
- return;
1290
+ var e = new Error(why); e.body = b || null;
1291
+ throw e;
1201
1292
  }
1202
1293
  // 契约容错:url 必须有;qr_data_url 取不到时仍展示链接/复制/直接打开
1203
1294
  if (!b.url) {
@@ -1212,9 +1303,16 @@ window.__ModuleLoader__.load({
1212
1303
  qr_data_url: b.qr_data_url != null ? b.qr_data_url : null
1213
1304
  });
1214
1305
  setAkeyMsg(null);
1306
+ akeyRetry.attempt = 0;
1215
1307
  }).catch(function (e) {
1216
- setAkeyMsg({ kind: "err", text: "获取一次性访问地址失败:" + e.message });
1217
- }).finally(function () { setAkeyBusy(false); });
1308
+ var text = "获取一次性访问地址失败:" + e.message;
1309
+ if (retryableFail(e) && akeyRetry.attempt < LOGIN_LOAD_RETRY_MS.length) {
1310
+ scheduleAkeyRetry(text);
1311
+ return;
1312
+ }
1313
+ // 自动重试额度用尽(或不该重试)→ 红字 + 「立即重试」入口,不让用户只能刷新页面
1314
+ setAkeyMsg({ kind: "err", text: text, retry: retryAkeyManually });
1315
+ }).finally(function () { akeyInFlight.v = false; setAkeyBusy(false); });
1218
1316
  }
1219
1317
 
1220
1318
  var copyKeyUrl = function () {
@@ -1232,17 +1330,67 @@ window.__ModuleLoader__.load({
1232
1330
  try { window.open(akey.url, "_blank", "noopener"); } catch (e) {}
1233
1331
  };
1234
1332
 
1235
- /** 加载已授权设备列表:GET /dsh-remote/mobile-sessions */
1333
+ /** 加载已授权设备列表:GET /dsh-remote/mobile-sessions(失败同上:可重试类自动重试)。 */
1236
1334
  function loadDevices() {
1237
- if (devBusy !== "") return;
1335
+ if (devInFlight.v || devBusy !== "") return;
1336
+ devInFlight.v = true;
1238
1337
  setDevBusy("list");
1239
1338
  api("/dsh-remote/mobile-sessions").then(function (b) {
1240
1339
  if (!b || !b.ok) throw new Error((b && b.error) || "加载已授权设备失败");
1241
1340
  setDevSessions(Array.isArray(b.sessions) ? b.sessions : []);
1242
1341
  setDevMsg(null);
1342
+ devRetry.attempt = 0;
1243
1343
  }).catch(function (e) {
1244
- setDevMsg({ kind: "err", text: "加载已授权设备失败:" + e.message });
1245
- }).finally(function () { setDevBusy(""); });
1344
+ var text = "加载已授权设备失败:" + e.message;
1345
+ if (retryableFail(e) && devRetry.attempt < LOGIN_LOAD_RETRY_MS.length) {
1346
+ scheduleDevRetry(text);
1347
+ return;
1348
+ }
1349
+ setDevMsg({ kind: "err", text: text, retry: retryDevicesManually });
1350
+ }).finally(function () { devInFlight.v = false; setDevBusy(""); });
1351
+ }
1352
+ /** 手动重试(红字旁的「重试」按钮):重置退避计数后立即重新拉取。 */
1353
+ var retryAkeyManually = function () {
1354
+ cancelAkeyRetry();
1355
+ akeyRetry.attempt = 0;
1356
+ setAkeyMsg(null);
1357
+ loadAccessKey();
1358
+ };
1359
+ var retryDevicesManually = function () {
1360
+ cancelDevRetry();
1361
+ devRetry.attempt = 0;
1362
+ setDevMsg(null);
1363
+ loadDevices();
1364
+ };
1365
+ /** 定时重试一次性访问密钥(黄字提示 + 退避;成功时由 loadAccessKey 清掉提示)。 */
1366
+ function scheduleAkeyRetry(why) {
1367
+ cancelAkeyRetry();
1368
+ var wait = LOGIN_LOAD_RETRY_MS[akeyRetry.attempt] || LOGIN_LOAD_RETRY_MS[LOGIN_LOAD_RETRY_MS.length - 1];
1369
+ akeyRetry.attempt += 1;
1370
+ setAkeyMsg({
1371
+ kind: "warn",
1372
+ text: "中继连接尚未就绪," + Math.round(wait / 1000) + " 秒后自动重试(第 " + akeyRetry.attempt + "/" + LOGIN_LOAD_RETRY_MS.length + " 次):" + why,
1373
+ retry: retryAkeyManually
1374
+ });
1375
+ akeyRetry.timer = later(function () {
1376
+ akeyRetry.timer = null;
1377
+ loadAccessKey(); // 在途标记由上一个请求的 finally 释放,退避窗口内用户也可手动刷新
1378
+ }, wait);
1379
+ }
1380
+ /** 定时重试已授权设备列表(同上)。 */
1381
+ function scheduleDevRetry(why) {
1382
+ cancelDevRetry();
1383
+ var wait = LOGIN_LOAD_RETRY_MS[devRetry.attempt] || LOGIN_LOAD_RETRY_MS[LOGIN_LOAD_RETRY_MS.length - 1];
1384
+ devRetry.attempt += 1;
1385
+ setDevMsg({
1386
+ kind: "warn",
1387
+ text: "中继连接尚未就绪," + Math.round(wait / 1000) + " 秒后自动重试(第 " + devRetry.attempt + "/" + LOGIN_LOAD_RETRY_MS.length + " 次):" + why,
1388
+ retry: retryDevicesManually
1389
+ });
1390
+ devRetry.timer = later(function () {
1391
+ devRetry.timer = null;
1392
+ loadDevices();
1393
+ }, wait);
1246
1394
  }
1247
1395
  /** 操作(取消配对/删除记录/清理已解绑)成功后静默重拉列表,覆盖行内状态。 */
1248
1396
  function refreshDeviceList() {
@@ -1346,13 +1494,22 @@ window.__ModuleLoader__.load({
1346
1494
 
1347
1495
  // 已登录云端主视图停留期间:打开即取一把新 key + 已授权设备;之后每 ~25s 自动轮换
1348
1496
  // (未登录不轮询,避免 401 空转;离开栏目/切视图/退出登录即清理定时器)
1497
+ //
1498
+ // 依赖里带上「账号」与「bridge 运行态」:登录成功(含换账号)与 bridge 拉起/重启后立即重取,
1499
+ // 首次安装后立即登录不再需要手动刷新页面;失败重试由 loadAccessKey/loadDevices 内部退避处理。
1349
1500
  useEffect(function () {
1350
1501
  if (view !== "home" || mode !== "saas" || !loggedIn) return undefined;
1502
+ cancelAkeyRetry(); cancelDevRetry();
1503
+ akeyRetry.attempt = 0; devRetry.attempt = 0;
1351
1504
  loadAccessKey();
1352
1505
  loadDevices();
1353
1506
  var rotateIv = setInterval(function () { loadAccessKey(); }, KEY_AUTO_REFRESH_MS);
1354
- return function () { clearInterval(rotateIv); };
1355
- }, [view, mode, loggedIn]);
1507
+ return function () {
1508
+ clearInterval(rotateIv);
1509
+ cancelAkeyRetry();
1510
+ cancelDevRetry();
1511
+ };
1512
+ }, [view, mode, loggedIn, phoneKey, serviceRunning]);
1356
1513
 
1357
1514
  function setMsg(kind, text) { setMessage({ kind: kind, text: text }); }
1358
1515
 
@@ -1760,7 +1917,13 @@ window.__ModuleLoader__.load({
1760
1917
  h("span", null, statusTxt)
1761
1918
  ),
1762
1919
  renderE2eeBadge(),
1763
- akeyMsg ? h("div", { className: "dru-msg dru-msg-" + akeyMsg.kind, style: { marginTop: 8 } }, akeyMsg.text) : null,
1920
+ akeyMsg
1921
+ ? h("div", { className: "dru-msg dru-msg-" + akeyMsg.kind, style: { marginTop: 8 } },
1922
+ akeyMsg.text,
1923
+ akeyMsg.retry
1924
+ ? h("button", { type: "button", className: "dru-linkbtn", style: { marginLeft: 8 }, disabled: akeyBusy, onClick: akeyMsg.retry }, akeyBusy ? "重试中…" : "立即重试")
1925
+ : null)
1926
+ : null,
1764
1927
  hasKey ? h("div", null, [
1765
1928
  h("div", { className: "dru-url big", style: { marginTop: 8 } },
1766
1929
  h("span", null, akey.url),
@@ -1883,7 +2046,13 @@ window.__ModuleLoader__.load({
1883
2046
  }, devBusy === "list" ? "加载中…" : btnLabel)
1884
2047
  ),
1885
2048
  devOpen ? renderDeviceList() : null,
1886
- devMsg ? h("div", { className: "dru-msg dru-msg-" + devMsg.kind, style: { marginTop: 6 } }, devMsg.text) : null
2049
+ devMsg
2050
+ ? h("div", { className: "dru-msg dru-msg-" + devMsg.kind, style: { marginTop: 6 } },
2051
+ devMsg.text,
2052
+ devMsg.retry
2053
+ ? h("button", { type: "button", className: "dru-linkbtn", style: { marginLeft: 8 }, disabled: devBusy !== "", onClick: devMsg.retry }, devBusy === "list" ? "重试中…" : "立即重试")
2054
+ : null)
2055
+ : null
1887
2056
  ]);
1888
2057
  }
1889
2058
 
@@ -1995,7 +2164,17 @@ window.__ModuleLoader__.load({
1995
2164
  h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "📱 远程访问:用手机或另一台电脑的浏览器,随时随地使用同一份 dsh web——人在哪都能用(免公网 IP、免内网穿透);官方托管中继,4G/5G 即用,也可自建服务。"),
1996
2165
  h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🛠 电脑端一键安装:bridge 与「远程访问」面板一次到位——云端/自建切换、账号登录、bridge 启停、一次性扫码访问、已授权设备管理、意见反馈都在这里。"),
1997
2166
  h("div", { className: "dru-hint", style: { marginBottom: 6 } }, "🔒 安全与通道:HTTP / WebSocket 全量透传,一次性访问密钥认证,面板实时显示设备与已授权设备列表;服务端可配置流量配额。"),
1998
- h("div", { className: "dru-hint" }, "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。")
2167
+ h("div", { className: "dru-hint" }, "🛡 端到端加密:手机↔电脑之间的消息内容用「你的账号密码派生密钥」端到端加密——密钥与密码不落服务端(仅存校验值),中继只可见路径/大小/时间(详见 README「安全与隐私」)。"),
2168
+ // 加入交流群(后台上传二维码后出现;点击弹出二维码大图便于扫码)
2169
+ community && community.qrcode
2170
+ ? h("div", { className: "dru-actions", style: { marginTop: 10 } },
2171
+ h("button", {
2172
+ type: "button",
2173
+ className: "dru-btn dru-btn-ghost",
2174
+ style: { width: "100%" },
2175
+ onClick: function () { setCommOpen(true); }
2176
+ }, "💬 加入交流群"))
2177
+ : null
1999
2178
  ]),
2000
2179
  // 版本与更新(自管理:检测新版 / 一键在线更新 / 彻底卸载)
2001
2180
  h(SelfManageCard, null),
@@ -2003,6 +2182,29 @@ window.__ModuleLoader__.load({
2003
2182
  );
2004
2183
  }
2005
2184
 
2185
+ /** 交流群弹窗(复用满意度弹窗的 dru-popup 视觉;点遮罩/关闭即收起)。 */
2186
+ function renderCommunityModal() {
2187
+ if (!commOpen || !(community && community.qrcode)) return null;
2188
+ var close = function () { setCommOpen(false); };
2189
+ return h("div", { className: "dru-popup", onMouseDown: function (e) { if (e.target === e.currentTarget) close(); } },
2190
+ h("div", { className: "dru-popup-card", role: "dialog", "aria-label": "加入企微交流群" },
2191
+ h("div", { className: "dru-popup-body" },
2192
+ h("div", { className: "dru-popup-icon" }, "💬"),
2193
+ h("div", { className: "dru-popup-title" }, "加入企微交流群"),
2194
+ h("div", { className: "dru-popup-sub" }, "扫码入群:安装答疑 / 使用技巧 / 版本更新 / 问题反馈"),
2195
+ h("img", { className: "dru-community-qr", src: community.qrcode, alt: "企微交流群二维码" }),
2196
+ community.wechat
2197
+ ? h("div", { className: "dru-hint", style: { marginTop: 10 } }, "二维码失效或群满,可加客服微信:" + community.wechat)
2198
+ : h("div", { className: "dru-hint", style: { marginTop: 10 } }, "二维码失效或群满,可到 GitHub 提 Issue 联系作者")
2199
+ ),
2200
+ h("div", { className: "dru-popup-foot" },
2201
+ h("span", null, "手机微信/企业微信扫码即可入群"),
2202
+ h("button", { type: "button", onClick: close }, "关闭")
2203
+ )
2204
+ )
2205
+ );
2206
+ }
2207
+
2006
2208
  return h("div", { className: "dru-settings-section", role: "region", "aria-label": "远程访问" },
2007
2209
  h("div", { className: "dru-settings-head" },
2008
2210
  h("span", { className: "dru-settings-icon" }, "📱"),
@@ -2013,7 +2215,8 @@ window.__ModuleLoader__.load({
2013
2215
  ),
2014
2216
  h("div", { className: "dru-settings-body" },
2015
2217
  view === "feedback" ? renderFeedback() : view === "invite" ? renderInvite() : renderHome()
2016
- )
2218
+ ),
2219
+ renderCommunityModal()
2017
2220
  );
2018
2221
  }
2019
2222
 
package/lib/index.js CHANGED
@@ -612,15 +612,135 @@ async function relayFetch(relayDir, pathname, init) {
612
612
  }
613
613
  }
614
614
 
615
- // ---------- v2 账号/配额/邀请代理(我的信息 免费额度提示) ----------
615
+ // ---------- bridge_secret 自愈(device-login 共享密钥) ----------
616
616
 
617
- /** 获取短期 relay token:SaaS → device-login;本地模式 → /_login(从隧道地址推导同源)。 */
618
- async function relayToken(relayDir) {
619
- const cfg = loadConfig(relayDir);
617
+ /**
618
+ * 企业端 POST /api/device-login 强制校验 `x-dsh-bridge-secret`(缺失/失效 → 401 "需要有效设备密钥"),
619
+ * 密钥由服务端公开配置 /api/public-config 下发、与 dsh-setup.mjs 的 `bridge_secret` 同源。
620
+ *
621
+ * 一键安装器(npx @mrrisega/dsh-remote → dsh-setup.mjs)会在安装时取一次并写入 .dsh-config.json;
622
+ * 但**只装插件**的路径(dsh plugin add / 插件市场安装)没有这一步,于是:
623
+ * 首次安装 → 打开设置面板立即登录 → 面板请求 /dsh-remote/access-key & mobile-sessions
624
+ * → relayToken 拿不到 token(401 需要设备密钥)→ 面板显示「尚未登录」(其实是密钥缺失)
625
+ * 直到后台自愈(scheduleRuntime → ensureRuntime 跑一次 npx 安装器)把 bridge_secret 写回配置,
626
+ * 或用户手动刷新页面才恢复——这正是「首次安装后立即登录,二维码/设备列表报红字」的根因。
627
+ *
628
+ * 这里在插件侧补上同一份自愈:缺密钥就取一次、落盘并缓存;失败短退避后可再试,不阻塞面板。
629
+ */
630
+ let bridgeSecretCache = { dir: "", secret: "", failedAt: 0 };
631
+ const BRIDGE_SECRET_RETRY_MS = 15_000;
632
+
633
+ /** 读取配置里已保存的 bridge_secret(无则空串)。 */
634
+ function readBridgeSecret(relayDir) {
635
+ try {
636
+ return String(loadConfig(relayDir).bridge_secret || "").trim();
637
+ } catch {
638
+ return "";
639
+ }
640
+ }
641
+
642
+ /**
643
+ * 取 device-login 共享密钥:配置已有 → 直接用;否则向企业端公开配置取一次并写回配置(内存缓存兜底)。
644
+ * @param {string} relayDir 配置目录
645
+ * @param {object} [cfgOverride] 尚未落盘的配置(登录请求刚写入 phone/password 时用),仅用于判断模式
646
+ * @param {{force?: boolean, timeoutMs?: number}} [opts] force=true 跳过本地文件/缓存强制重取;
647
+ * timeoutMs 限制单次公开配置请求耗时(登录路径用更短的上限,避免拖慢登录响应)
648
+ * @returns {Promise<string>} 密钥(取不到为空串,调用方按“中继未就绪”降级,不抛错)
649
+ */
650
+ async function bridgeSecretOf(relayDir, cfgOverride, opts) {
651
+ const force = !!(opts && opts.force);
652
+ if (!force) {
653
+ const local = readBridgeSecret(relayDir);
654
+ if (local) {
655
+ bridgeSecretCache = { dir: relayDir, secret: local, failedAt: 0 };
656
+ return local;
657
+ }
658
+ if (bridgeSecretCache.dir === relayDir && bridgeSecretCache.secret) return bridgeSecretCache.secret;
659
+ if (bridgeSecretCache.dir === relayDir && Date.now() - bridgeSecretCache.failedAt < BRIDGE_SECRET_RETRY_MS) return ""; // 负缓存:别把公开配置打爆
660
+ }
661
+ const cfg = cfgOverride && typeof cfgOverride === "object" ? { ...loadConfig(relayDir), ...cfgOverride } : loadConfig(relayDir);
662
+ if (cfg.local_key) return ""; // 自建模式走 /_login,不用设备密钥
620
663
  const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
621
- if (cfg.local_key) {
622
- // 本地认证:POST {tunnel 同源}/_login
623
- const u = new URL(cfg.tunnel_url || api.replace(/^https?/, "wss"));
664
+ const timeoutMs = Number((opts && opts.timeoutMs) || 0) > 0 ? Number(opts.timeoutMs) : 6000;
665
+ let secret = "";
666
+ try {
667
+ const r = await fetch(`${api}/api/public-config`, { signal: AbortSignal.timeout(timeoutMs) });
668
+ if (r.ok) {
669
+ const d = await r.json();
670
+ secret = String((d && d.bridge_secret) || "").trim();
671
+ }
672
+ } catch { /* 网络抖动:负缓存短退避后再试 */ }
673
+ if (!secret) {
674
+ bridgeSecretCache = { dir: relayDir, secret: "", failedAt: Date.now() };
675
+ return "";
676
+ }
677
+ bridgeSecretCache = { dir: relayDir, secret, failedAt: 0 };
678
+ // 落盘(与 dsh-setup.mjs 同一份配置):即使本进程退出,下次启动也不再缺密钥/不再是旧密钥
679
+ let healed = false;
680
+ try {
681
+ const cur = loadConfig(relayDir);
682
+ if (!cur.bridge_secret || (force && cur.bridge_secret !== secret)) {
683
+ cur.bridge_secret = secret;
684
+ saveConfig(relayDir, cur);
685
+ healed = true;
686
+ }
687
+ } catch { /* 写盘失败不致命:内存缓存本次进程内仍生效 */ }
688
+ // 密钥是「从无到有」补上的:已经在跑的 bridge 是用空密钥起的,隧道认证同样会失败——
689
+ // 后台重启一次让它带上密钥(一次性事件,失败只记日志不打断面板请求)。
690
+ if (healed && !UNINSTALLED_DIRS.has(relayDir)) {
691
+ setTimeout(() => {
692
+ try {
693
+ const st = launchdStatus();
694
+ if (!st.running) return;
695
+ const r = startBridge(relayDir);
696
+ console.log(`[dsh-remote-web] 已补记 bridge_secret,后台重启 bridge 使其生效: ${r.status}`);
697
+ } catch (e) {
698
+ console.warn(`[dsh-remote-web] 补记 bridge_secret 后重启 bridge 失败: ${e.message}`);
699
+ }
700
+ }, 50).unref?.();
701
+ }
702
+ return secret;
703
+ }
704
+
705
+ // ---------- v2 账号/配额/邀请代理(我的信息 与 免费额度提示) ----------
706
+
707
+ /** token 获取失败的原因(供 UI 区分“未登录”与“中继未就绪”,不再一律谎报“尚未登录”)。 */
708
+ const AUTH_NO_CREDENTIALS = "no_credentials"; // 本机没存账号/自建密钥 → 该去登录
709
+ const AUTH_BAD_CREDENTIALS = "bad_credentials"; // 账号密码被企业端拒绝 → 该重新登录
710
+ const AUTH_RELAY_UNREACHABLE = "relay_unreachable"; // 网络/5xx/超时 → 稍后重试
711
+ const AUTH_RELAY_NOT_READY = "relay_not_ready"; // 凭证齐全但企业端未接受(缺失/轮换中的设备密钥等)→ 稍后重试
712
+
713
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
714
+
715
+ /** 单次 device-login:返回 { ok, status, token, code, detail }(不抛错,便于按原因降级/重试)。 */
716
+ async function deviceLoginOnce(api, cfg, secret) {
717
+ try {
718
+ const r = await fetch(`${api}/api/device-login`, {
719
+ method: "POST",
720
+ headers: { "content-type": "application/json", ...(secret ? { "x-dsh-bridge-secret": secret } : {}) },
721
+ body: JSON.stringify({ phone: cfg.phone, email: cfg.phone, password: cfg.password }),
722
+ signal: AbortSignal.timeout(6000)
723
+ });
724
+ const text = await r.text();
725
+ let d = null;
726
+ try { d = JSON.parse(text); } catch { d = null; }
727
+ return {
728
+ ok: r.ok,
729
+ status: r.status,
730
+ token: (d && d.token) || "",
731
+ code: (d && d.error && d.error.code) || "",
732
+ detail: (d && d.error && (d.error.message || d.error.code)) || "",
733
+ };
734
+ } catch (e) {
735
+ return { ok: false, status: 0, token: "", code: "", detail: e.message };
736
+ }
737
+ }
738
+
739
+ /** 单次本地认证(自建模式):POST {隧道同源}/_login。 */
740
+ async function localLoginOnce(cfg, api) {
741
+ const fallbackApi = (api || cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
742
+ try {
743
+ const u = new URL(cfg.tunnel_url || fallbackApi.replace(/^https?/, "wss"));
624
744
  u.protocol = u.protocol === "wss:" ? "https:" : "http:";
625
745
  u.pathname = "/_login";
626
746
  const r = await fetch(u.toString(), {
@@ -629,20 +749,59 @@ async function relayToken(relayDir) {
629
749
  body: JSON.stringify({ key: cfg.local_key }),
630
750
  signal: AbortSignal.timeout(6000)
631
751
  });
632
- if (!r.ok) return "";
752
+ if (!r.ok) return { ok: false, status: r.status, token: "", code: "", detail: "" };
633
753
  const d = await r.json();
634
- return d.token || "";
754
+ return { ok: true, status: 200, token: (d && d.token) || "", code: "", detail: "" };
755
+ } catch (e) {
756
+ return { ok: false, status: 0, token: "", code: "", detail: e.message };
635
757
  }
636
- if (!cfg.phone || !cfg.password) return "";
637
- const r = await fetch(`${api}/api/device-login`, {
638
- method: "POST",
639
- headers: { "content-type": "application/json", ...(cfg.bridge_secret ? { "x-dsh-bridge-secret": cfg.bridge_secret } : {}) },
640
- body: JSON.stringify({ phone: cfg.phone, email: cfg.phone, password: cfg.password }),
641
- signal: AbortSignal.timeout(6000)
642
- });
643
- if (!r.ok) return "";
644
- const d = await r.json();
645
- return d.token || "";
758
+ }
759
+
760
+ /**
761
+ * 获取短期 relay token:SaaS device-login;本地模式 /_login(从隧道地址推导同源)
762
+ * 与旧版差异:① device-login 共享密钥时自愈补齐;② 服务端轮换密钥(401/403)自动重取重试;
763
+ * ③ 网络抖动/5xx 退避重试一次;④ 失败时给出**可判定原因**而非空串(消除“尚未登录”误报)。
764
+ * @returns {Promise<{token:string, reason:string, status:number, detail:string}>}
765
+ */
766
+ async function relayTokenWithReason(relayDir, cfgOverride) {
767
+ const cfg = cfgOverride && typeof cfgOverride === "object" ? { ...loadConfig(relayDir), ...cfgOverride } : loadConfig(relayDir);
768
+ const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
769
+ if (cfg.local_key) {
770
+ let r = await localLoginOnce(cfg, api);
771
+ if (!r.token && (r.status === 0 || r.status >= 500)) { await sleep(350); r = await localLoginOnce(cfg, api); }
772
+ if (r.token) return { token: r.token, reason: "", status: r.status, detail: "" };
773
+ return {
774
+ token: "",
775
+ reason: r.status === 401 || r.status === 403 ? AUTH_BAD_CREDENTIALS : AUTH_RELAY_UNREACHABLE,
776
+ status: r.status,
777
+ detail: r.detail,
778
+ };
779
+ }
780
+ if (!cfg.phone || !cfg.password) return { token: "", reason: AUTH_NO_CREDENTIALS, status: 0, detail: "" };
781
+
782
+ let secret = await bridgeSecretOf(relayDir, cfgOverride);
783
+ let r = await deviceLoginOnce(api, cfg, secret);
784
+ // 带了密钥仍被拒 → 可能是服务端轮换了共享密钥:丢弃缓存强制重取一次再试
785
+ if (!r.token && (r.status === 401 || r.status === 403) && secret) {
786
+ const fresh = await bridgeSecretOf(relayDir, cfgOverride, { force: true });
787
+ if (fresh && fresh !== secret) r = await deviceLoginOnce(api, cfg, fresh);
788
+ }
789
+ // 网络抖动/超时/5xx:短退避重试一次(首次安装后中继握手偶发失败很常见)
790
+ if (!r.token && (r.status === 0 || r.status >= 500)) {
791
+ await sleep(350);
792
+ r = await deviceLoginOnce(api, cfg, await bridgeSecretOf(relayDir, cfgOverride));
793
+ }
794
+ if (r.token) return { token: r.token, reason: "", status: r.status, detail: "" };
795
+ if (r.code === "bad_credentials") return { token: "", reason: AUTH_BAD_CREDENTIALS, status: r.status, detail: r.detail };
796
+ // 凭证齐全但企业端没给 token(设备密钥缺失/轮换中、限流、其它 4xx)→ 可重试,不谎报“未登录”
797
+ if (r.status === 0 || r.status >= 500) return { token: "", reason: AUTH_RELAY_UNREACHABLE, status: r.status, detail: r.detail };
798
+ return { token: "", reason: AUTH_RELAY_NOT_READY, status: r.status, detail: r.detail };
799
+ }
800
+
801
+ /** 兼容旧调用方(反馈/账号/配额等只需 token 字符串)。 */
802
+ async function relayToken(relayDir) {
803
+ const r = await relayTokenWithReason(relayDir).catch(() => ({ token: "" }));
804
+ return r.token || "";
646
805
  }
647
806
 
648
807
  /** 我的信息:SaaS 账号的生效套餐/到期日/邀请码(经 /api/me)。 */
@@ -700,6 +859,35 @@ async function relayInviteRecords(relayDir) {
700
859
  return { records: r.body.records || [], rewards: r.body.rewards || [] };
701
860
  }
702
861
 
862
+ // ---------- 交流群二维码(运营配置,公开读取) ----------
863
+
864
+ /**
865
+ * 交流群二维码/客服微信:企业端配置经公开配置 /api/public-config 的 community 字段下发
866
+ * (community.qrcode 形如 "/qr/group-qr.png")。面板与反馈页展示「加入交流群」用。
867
+ *
868
+ * 面板跑在电脑本机 dsh web(http://127.0.0.1:3080),相对路径无法直接当 <img src>,
869
+ * 故这里按企业端公开约定拼成绝对地址:<api_url><qrcode>(与手机端 PWA 的 API_BASE + qr 一致)。
870
+ * 30s 内存缓存:面板与反馈卡都会取,避免重复打公开配置。
871
+ */
872
+ const COMMUNITY_TTL_MS = 30_000;
873
+ let communityCache = { dir: "", at: 0, data: null };
874
+
875
+ async function communityInfo(relayDir) {
876
+ if (communityCache.data && communityCache.dir === relayDir && Date.now() - communityCache.at < COMMUNITY_TTL_MS) {
877
+ return communityCache.data;
878
+ }
879
+ const cfg = loadConfig(relayDir);
880
+ const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
881
+ const r = await relayFetch(relayDir, "/api/public-config");
882
+ const b = r.ok && r.body && typeof r.body === "object" ? r.body : {};
883
+ const c = b.community && typeof b.community === "object" ? b.community : {};
884
+ const raw = String(c.qrcode || "").trim();
885
+ const qrcode = raw && !/^https?:/i.test(raw) ? api + (raw.startsWith("/") ? raw : `/${raw}`) : raw;
886
+ const data = { qrcode, wechat: String(c.wechat || "") };
887
+ communityCache = { dir: relayDir, at: Date.now(), data };
888
+ return data;
889
+ }
890
+
703
891
  // ---------- 一次性访问密钥 / 已授权设备代理(E1 企业端新增 auth-key / mobile-sessions) ----------
704
892
 
705
893
  /** 从 relay 响应里尽量提取人类可读错误信息(兼容 {error:{message}} / {error:".."} / {message} / 纯文本)。 */
@@ -722,6 +910,57 @@ function notLoggedInJson() {
722
910
  return { ok: false, error: "尚未登录:请先在「账号」卡片登录手机号账号(或切换到自建服务)后重试", hint: "login_required" };
723
911
  }
724
912
 
913
+ /**
914
+ * 凭证齐全但拿不到 relay token 时的统一返回:区分「密码失效需重新登录」与「中继暂未就绪可重试」。
915
+ * 关键:不再把中继未就绪/设备密钥缺失谎报成“尚未登录”(旧版 UI 因此显示误导性红字)。
916
+ */
917
+ function relayAuthFailureJson(reason, detail) {
918
+ if (reason === AUTH_NO_CREDENTIALS) return { status: 401, body: notLoggedInJson() };
919
+ if (reason === AUTH_BAD_CREDENTIALS) {
920
+ return {
921
+ status: 401,
922
+ body: {
923
+ ok: false,
924
+ error: "本机保存的账号密码已被中继拒绝(可能已在别处修改过密码):请用新密码重新登录「账号」卡片后重试",
925
+ hint: "relogin_required",
926
+ retryable: false,
927
+ detail: String(detail || ""),
928
+ },
929
+ };
930
+ }
931
+ const unreachable = reason === AUTH_RELAY_UNREACHABLE;
932
+ return {
933
+ status: 503,
934
+ body: {
935
+ ok: false,
936
+ error: unreachable
937
+ ? "中继服务暂时不可达(网络波动或中继正在重启),请稍后重试"
938
+ : "账号已登录,但中继连接尚未就绪(正在建立安全通道),请稍后重试",
939
+ hint: unreachable ? "relay_unreachable" : "relay_not_ready",
940
+ retryable: true,
941
+ detail: String(detail || ""),
942
+ },
943
+ };
944
+ }
945
+
946
+ /**
947
+ * 取 relay token 或直接回错误响应(面板代理统一入口)。
948
+ * @returns {Promise<string>} token;为空串表示已写出错误响应(调用方直接 return)
949
+ */
950
+ async function relayTokenOrReject(relayDir, res, cfgOverride) {
951
+ const a = await relayTokenWithReason(relayDir, cfgOverride).catch((e) => ({
952
+ token: "",
953
+ reason: AUTH_RELAY_UNREACHABLE,
954
+ status: 0,
955
+ detail: e && e.message,
956
+ }));
957
+ if (a.token) return a.token;
958
+ const { status, body } = relayAuthFailureJson(a.reason, a.detail);
959
+ console.warn(`[dsh-remote-web] 取 relay token 失败(${a.reason}/${a.status}): ${a.detail || "无详情"}`);
960
+ sendJson(res, status, body);
961
+ return "";
962
+ }
963
+
725
964
  /**
726
965
  * 透传企业端响应体:契约字段可能在顶层或 data 子对象里(容错)。
727
966
  * 返回扁平对象;数组字段只取首层数组。
@@ -737,8 +976,8 @@ function flattenRelayBody(r) {
737
976
  * 契约容错:url 必须可用;qr_data_url 取不到时返回 null(UI 只展示链接并说明“二维码暂不可用”,不报错)。
738
977
  */
739
978
  async function proxyCreateAccessKey(relayDir, res) {
740
- const token = await relayToken(relayDir).catch(() => "");
741
- if (!token) return sendJson(res, 401, notLoggedInJson());
979
+ const token = await relayTokenOrReject(relayDir, res);
980
+ if (!token) return;
742
981
  const r = await relayFetch(relayDir, "/api/auth-key", {
743
982
  method: "POST",
744
983
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
@@ -766,8 +1005,8 @@ async function proxyCreateAccessKey(relayDir, res) {
766
1005
  * GET /dsh-remote/mobile-sessions → 已授权设备列表(企业端 GET /api/mobile-sessions,Bearer)。
767
1006
  */
768
1007
  async function proxyMobileSessions(relayDir, res) {
769
- const token = await relayToken(relayDir).catch(() => "");
770
- if (!token) return sendJson(res, 401, notLoggedInJson());
1008
+ const token = await relayTokenOrReject(relayDir, res);
1009
+ if (!token) return;
771
1010
  const r = await relayFetch(relayDir, "/api/mobile-sessions", {
772
1011
  method: "GET",
773
1012
  headers: { authorization: `Bearer ${token}` },
@@ -788,8 +1027,8 @@ async function proxyRevokeMobileSession(relayDir, req, res) {
788
1027
  if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
789
1028
  const id = String(body.id ?? "").trim();
790
1029
  if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
791
- const token = await relayToken(relayDir).catch(() => "");
792
- if (!token) return sendJson(res, 401, notLoggedInJson());
1030
+ const token = await relayTokenOrReject(relayDir, res);
1031
+ if (!token) return;
793
1032
  const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}/revoke`, {
794
1033
  method: "POST",
795
1034
  headers: { authorization: `Bearer ${token}` },
@@ -812,8 +1051,8 @@ async function proxyDeleteMobileSession(relayDir, req, res) {
812
1051
  if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
813
1052
  const id = String(body.id ?? "").trim();
814
1053
  if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
815
- const token = await relayToken(relayDir).catch(() => "");
816
- if (!token) return sendJson(res, 401, notLoggedInJson());
1054
+ const token = await relayTokenOrReject(relayDir, res);
1055
+ if (!token) return;
817
1056
  const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}`, {
818
1057
  method: "DELETE",
819
1058
  headers: { authorization: `Bearer ${token}` },
@@ -831,8 +1070,8 @@ async function proxyDeleteMobileSession(relayDir, req, res) {
831
1070
  * (企业端 POST /api/mobile-sessions/purge,Bearer)。
832
1071
  */
833
1072
  async function proxyPurgeMobileSessions(relayDir, res) {
834
- const token = await relayToken(relayDir).catch(() => "");
835
- if (!token) return sendJson(res, 401, notLoggedInJson());
1073
+ const token = await relayTokenOrReject(relayDir, res);
1074
+ if (!token) return;
836
1075
  const r = await relayFetch(relayDir, "/api/mobile-sessions/purge", {
837
1076
  method: "POST",
838
1077
  headers: { authorization: `Bearer ${token}` },
@@ -1030,7 +1269,7 @@ const PLUGIN_ID = "dsh-remote-ui";
1030
1269
  const PLUGIN_LEGACY_IDS = ["dsh-remote-web"];
1031
1270
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1032
1271
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1033
- const PLUGIN_VERSION = "0.6.0";
1272
+ const PLUGIN_VERSION = "0.6.1";
1034
1273
  const UPDATE_LOG = ".dsh-update.log";
1035
1274
  const UPDATE_MARKER = ".dsh-update-running";
1036
1275
 
@@ -1282,6 +1521,14 @@ function registerRoutes(ctx, relayDir) {
1282
1521
  });
1283
1522
  },
1284
1523
  },
1524
+ // 交流群二维码(设置面板「加入交流群」按钮 + 用户反馈页展示;未配置返回空串,UI 不展示入口)
1525
+ {
1526
+ method: "GET",
1527
+ path: "/dsh-remote/community",
1528
+ handler: async (_req, res) => {
1529
+ sendJson(res, 200, { ok: true, ...(await communityInfo(relayDir)) });
1530
+ },
1531
+ },
1285
1532
  // 一次性访问密钥(📱 远程访问卡):GET 即创建新 key,企业端 POST /api/auth-key(Bearer)
1286
1533
  {
1287
1534
  method: "GET",
@@ -1352,6 +1599,14 @@ function registerRoutes(ctx, relayDir) {
1352
1599
  }
1353
1600
  // SaaS 权威归一化:清除自建残留(local_key/假 tunnel_url),api/tunnel 一律按云端重算
1354
1601
  applySaaSMode(cfg);
1602
+ // device-login 共享密钥自愈:只装插件的路径没有安装器那一步,缺密钥会导致
1603
+ // 「首次安装后立即登录」时面板拿不到二维码/设备列表(旧版要等后台自愈或手动刷新)。
1604
+ // 这里在启动 bridge 之前补齐,bridge 首次启动即可带上密钥。
1605
+ if (!cfg.bridge_secret) {
1606
+ // 3s 上限:这次取密钥在登录响应路径上,宁可先放行(后续请求还会自愈)也不拖慢登录
1607
+ const secret = await bridgeSecretOf(relayDir, cfg, { timeoutMs: 3000 });
1608
+ if (secret) cfg.bridge_secret = secret;
1609
+ }
1355
1610
  }
1356
1611
  saveConfig(relayDir, cfg);
1357
1612
  const bridgeRestart = startBridge(relayDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-ui",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "【兼容别名包】dsh-remote 旧名 dsh-remote-ui 的别名,功能与 dsh-remote-web 完全一致(同一份代码同步生成)。 Legacy alias of dsh-remote-web — identical build, kept installable while the renamed entry is pending review. 公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",