cicy-desktop 2.1.78 → 2.1.79

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 (54) hide show
  1. package/bin/cicy-desktop +7 -7
  2. package/package.json +6 -6
  3. package/src/backends/homepage-preload.js +22 -0
  4. package/src/backends/homepage-react/assets/index-CKpaMBKz.css +1 -0
  5. package/src/backends/homepage-react/assets/index-CSsNZgC5.js +365 -0
  6. package/src/backends/homepage-react/index.html +2 -2
  7. package/src/backends/homepage-window.js +52 -7
  8. package/src/backends/ipc.js +57 -0
  9. package/src/backends/local-teams.js +73 -26
  10. package/src/backends/sidecar-ipc.js +11 -0
  11. package/src/backends/webview-preload.js +5 -3
  12. package/src/backends/window-manager.js +13 -3
  13. package/src/chrome/chrome-launcher.js +5 -4
  14. package/src/chrome/debugger-port-resolver.js +1 -1
  15. package/src/cloud/cloud-client.js +237 -41
  16. package/src/cluster/types.js +0 -5
  17. package/src/extension/inject.js +1 -1
  18. package/src/main.js +282 -88
  19. package/src/master/chrome-config.js +2 -2
  20. package/src/preload-rpc.js +1 -1
  21. package/src/profiles/profile-store.js +321 -0
  22. package/src/profiles/trusted-origins-store.js +95 -0
  23. package/src/server/worker-observability-routes.js +0 -2
  24. package/src/sidecar/cicy-code.js +84 -23
  25. package/src/sidecar/localbin.js +20 -3
  26. package/src/sidecar/native.js +3 -3
  27. package/src/sidecar/version.js +45 -0
  28. package/src/tabbrowser/newtab-protocol.js +54 -0
  29. package/src/tabbrowser/tab-browser.html +151 -0
  30. package/src/tabbrowser/tab-shell-preload.js +28 -0
  31. package/src/tabbrowser/tab-shell.html +227 -0
  32. package/src/tools/account-tools.js +191 -25
  33. package/src/tools/chrome-tools.js +173 -37
  34. package/src/tools/device-tools.js +25 -0
  35. package/src/tools/index.js +2 -0
  36. package/src/tools/tab-browser-tools.js +453 -0
  37. package/src/tools/window-tools.js +64 -7
  38. package/src/utils/brand-host-electron.js +25 -0
  39. package/src/utils/context-menu-options.js +80 -0
  40. package/src/utils/cookie-logins.js +58 -0
  41. package/src/utils/ip-probe.js +50 -0
  42. package/src/utils/rpc-audit.js +53 -0
  43. package/src/utils/rpc-guard.js +189 -0
  44. package/src/utils/window-monitor.js +5 -15
  45. package/src/utils/window-registry.js +210 -0
  46. package/src/utils/window-thumbnails.js +126 -0
  47. package/src/utils/window-utils.js +146 -109
  48. package/workers/render/package-lock.json +6 -6
  49. package/workers/render/src/App.css +36 -2
  50. package/workers/render/src/App.jsx +587 -103
  51. package/src/backends/artifact-ipc.js +0 -142
  52. package/src/backends/homepage-react/assets/index-DE9m6JTn.css +0 -1
  53. package/src/backends/homepage-react/assets/index-DLYMzgf5.js +0 -365
  54. package/src/cluster/artifact-registry.js +0 -61
@@ -255,6 +255,9 @@ export default function App() {
255
255
  const [teams, setTeams] = useState(null);
256
256
  const [profileLoading, setProfileLoading] = useState(false);
257
257
  const [profileError, setProfileError] = useState("");
258
+ // Guards the auto re-login so a dead session (/api/teams 401) triggers the
259
+ // magic-link flow ONCE instead of looping. Reset when a fresh login lands.
260
+ const reauthing = useRef(false);
258
261
  // Local teams discovered from ~/cicy-ai/global.json (main-process probe).
259
262
  const [localTeams, setLocalTeams] = useState(null);
260
263
  const [localTeamsLoading, setLocalTeamsLoading] = useState(false);
@@ -287,6 +290,18 @@ export default function App() {
287
290
  window.cicy.cloud.fetch(`${CLOUD_BASE}/api/user/self`, { headers }),
288
291
  window.cicy.cloud.fetch(`${CLOUD_BASE}/api/teams`, { headers }),
289
292
  ]);
293
+ // Session DEAD (cloud invalidated the sk-sess token) → /api/teams 401. The
294
+ // "永久登录" red line forbids re-prompting on mere restart/expiry, but a
295
+ // GENUINE 401 means the session is gone — the only recovery is a fresh
296
+ // login. Trigger the magic-link ONCE (guarded) instead of retrying forever.
297
+ if (teamsRes?.status === 401) {
298
+ if (!reauthing.current && window.cicy?.auth?.loginStart) {
299
+ reauthing.current = true;
300
+ setProfileError("会话已过期,正在重新登录…");
301
+ try { await window.cicy.auth.loginStart(); } catch {}
302
+ }
303
+ return;
304
+ }
290
305
  // /api/teams drives the team grid — it is the ONLY critical call here.
291
306
  if (!teamsRes?.ok) throw new Error(`/api/teams ${teamsRes?.status || "?"} ${teamsRes?.error || ""}`);
292
307
  // /api/teams is bare: { teams: [...] }
@@ -311,6 +326,20 @@ export default function App() {
311
326
  }
312
327
  }, []);
313
328
 
329
+ // 私有云/云端团队信息持续同步:/api/teams 原本只在登录/挂载拉一次,所以 dash 上改了
330
+ // 私有云的 host_url / 名字 / 状态,桌面看不到。用 ref 持有当前 bearer,挂到对账循环里
331
+ // 按窗口可见性周期重拉(轻量:只 setTeams,不动 loading/self,不在 401 时清空列表)。
332
+ const bearerRef = useRef("");
333
+ useEffect(() => { bearerRef.current = token || accessToken || ""; }, [token, accessToken]);
334
+ const refreshCloudTeams = useCallback(async () => {
335
+ const at = bearerRef.current;
336
+ if (!at || !window.cicy?.cloud?.fetch) return;
337
+ try {
338
+ const r = await window.cicy.cloud.fetch(`${CLOUD_BASE}/api/teams`, { headers: { Authorization: `Bearer ${at}` } });
339
+ if (r?.ok) { const b = JSON.parse(r.body || "{}"); if (Array.isArray(b?.teams)) setTeams(b.teams); }
340
+ } catch {}
341
+ }, []);
342
+
314
343
  // First profile fetch on mount. The cloud console endpoints (/api/user/self,
315
344
  // /api/teams) authenticate the owner-bound LOGIN token (the sk-xxx from the
316
345
  // /cb callback) — NOT the console access_token (the cloud never mints one;
@@ -341,32 +370,53 @@ export default function App() {
341
370
  // Rename a local team: persist via localTeams.update then refresh the list.
342
371
  // Empty name falls back to 未命名 (mirrors local-teams.addTeam default).
343
372
  const renameLocalTeam = useCallback(async (id, name) => {
344
- if (!window.cicy?.localTeams?.update) return;
373
+ if (!window.cicy?.localTeams?.update) return { ok: false, error: "no_bridge" };
374
+ let r;
345
375
  try {
346
- await window.cicy.localTeams.update(id, { name: String(name || "").trim() || tr("localTeams.unnamed", "未命名") });
347
- } catch {}
348
- await fetchLocalTeams();
376
+ r = await window.cicy.localTeams.update(id, { name: String(name || "").trim() || tr("localTeams.unnamed", "未命名") });
377
+ } catch (e) { r = { ok: false, error: e?.message || String(e) }; }
378
+ await fetchLocalTeams(); // 对账:props 追上后清乐观名
379
+ return r || { ok: false, error: "no_result" };
349
380
  }, [fetchLocalTeams]);
381
+ // 自适应对账:窗口可见时 ~3s 拉一次云端 title(远端/dash 改名秒级可见),隐藏时
382
+ // 退避到 30s 只刷新本地(云端对账交给 main 进程 30s 兜底);切回可见/聚焦立即对账。
350
383
  useEffect(() => {
351
- let fastTimer;
352
- let slowTimer;
353
- let elapsed = 0;
354
- const FAST_MS = 3_000;
355
- const FAST_WINDOW_MS = 30_000;
356
- const SLOW_MS = 30_000;
357
-
358
- const tick = async () => {
359
- await fetchLocalTeams();
360
- elapsed += FAST_MS;
361
- if (elapsed < FAST_WINDOW_MS) {
362
- fastTimer = setTimeout(tick, FAST_MS);
363
- } else {
364
- slowTimer = setInterval(fetchLocalTeams, SLOW_MS);
365
- }
384
+ let timer;
385
+ let stopped = false;
386
+ const VISIBLE_MS = 3_000;
387
+ const HIDDEN_MS = 30_000;
388
+
389
+ // 一发对账:本地 title 拉进 teams.json + 刷新本地列表 + 重拉云端团队(私有云
390
+ // host_url/名字/状态的同步)。三件事并行。
391
+ const reconcile = async () => {
392
+ try { await window.cicy?.localTeams?.syncCloud?.(); } catch {}
393
+ await Promise.all([fetchLocalTeams(), refreshCloudTeams()]);
366
394
  };
367
- tick();
368
- return () => { clearTimeout(fastTimer); clearInterval(slowTimer); };
369
- }, [fetchLocalTeams]);
395
+
396
+ const schedule = () => {
397
+ if (stopped) return;
398
+ const visible = document.visibilityState === "visible";
399
+ timer = setTimeout(async () => {
400
+ if (document.visibilityState === "visible") await reconcile();
401
+ else await fetchLocalTeams(); // 隐藏:只刷新本地,不打云端
402
+ schedule();
403
+ }, visible ? VISIBLE_MS : HIDDEN_MS);
404
+ };
405
+
406
+ reconcile(); // 挂载即来一发
407
+ schedule();
408
+
409
+ // 切回可见/聚焦 → 立即对账(dash 改完名点回桌面秒同步)
410
+ const onWake = () => { if (document.visibilityState === "visible") reconcile(); };
411
+ document.addEventListener("visibilitychange", onWake);
412
+ window.addEventListener("focus", onWake);
413
+
414
+ return () => {
415
+ stopped = true; clearTimeout(timer);
416
+ document.removeEventListener("visibilitychange", onWake);
417
+ window.removeEventListener("focus", onWake);
418
+ };
419
+ }, [fetchLocalTeams, refreshCloudTeams]);
370
420
 
371
421
  // Webview relay — the Team Helper <webview> calls window.cicy.localTeams.add(...)
372
422
  // inside the webview, that hops main → here. We run the actual IPC, refresh
@@ -456,6 +506,10 @@ export default function App() {
456
506
  return;
457
507
  }
458
508
  if (payload?.token) {
509
+ // Fresh session landed — clear the dead-session re-auth guard + message
510
+ // so a future 401 can re-trigger recovery.
511
+ reauthing.current = false;
512
+ setProfileError("");
459
513
  try { localStorage.setItem(TOKEN_KEY, payload.token); } catch {}
460
514
  setToken(payload.token);
461
515
  if (payload.accessToken) {
@@ -595,23 +649,36 @@ export default function App() {
595
649
  <Header me={me} welcome={welcome} onLogout={handleLogout}
596
650
  mitmTeam={localList.length > 0 ? localList[0] : null} />
597
651
  <main className="main">
598
- <div className="app__tabs">
599
- {[
600
- { k: "all", label: "全部", n: localCount + customCount + cloudCount },
601
- { k: "local", label: "本地", n: localCount },
602
- { k: "cloud", label: "云端", n: cloudCount },
603
- { k: "custom", label: "自定义", n: customCount },
604
- ].map(({ k, label, n }) => (
605
- <button
606
- key={k}
607
- type="button"
608
- className={`app__tab ${tab === k ? "is-active" : ""}`}
609
- onClick={() => setTab(k)}
610
- >
611
- {label}
612
- <span className="app__tab-count">{n}</span>
613
- </button>
614
- ))}
652
+ {/* 整行:左边 tab 药丸,右边「新加团队」顶到行尾 */}
653
+ <div className="app__tabsrow">
654
+ <div className="app__tabs">
655
+ {[
656
+ { k: "all", label: "全部", n: localCount + customCount + cloudCount },
657
+ { k: "local", label: "本地", n: localCount },
658
+ { k: "cloud", label: "私有云", n: cloudCount },
659
+ { k: "custom", label: "自定义", n: customCount },
660
+ ].map(({ k, label, n }) => (
661
+ <button
662
+ key={k}
663
+ type="button"
664
+ className={`app__tab ${tab === k ? "is-active" : ""}`}
665
+ onClick={() => setTab(k)}
666
+ >
667
+ {label}
668
+ <span className="app__tab-count">{n}</span>
669
+ </button>
670
+ ))}
671
+ </div>
672
+ {/* 行尾:新加团队 → 跳浏览器到云端 dash 私有云页 */}
673
+ <button
674
+ type="button"
675
+ data-id="AddTeamButton"
676
+ className="app__add-team"
677
+ title={tr("teams.addHint", "在云端新建私有云团队")}
678
+ onClick={() => openCloudPage("?tab=private")}
679
+ >
680
+ + {tr("teams.add", "新加团队")}
681
+ </button>
615
682
  </div>
616
683
 
617
684
  {/* Docker 安装卡已下线 (主人令): Windows 走原生 cicy-code.exe --helper,不再用 Docker。 */}
@@ -630,6 +697,30 @@ export default function App() {
630
697
  {showLocal && localList.map((t) => (
631
698
  <LocalTeamCard key={"local:" + t.id} team={t} onOpen={() => openLocalTeam(t.id)} onRename={renameLocalTeam} onRefresh={fetchLocalTeams} />
632
699
  ))}
700
+ {/* 占位卡 (主人: "本地团队没有占位"): a fresh install starts the sidecar
701
+ and main auto-registers 本地团队 once :8008 answers — until that
702
+ lands, hold its spot so the 本地 tab is never blank. The slow
703
+ localTeams poll swaps this for the real card automatically. */}
704
+ {showLocal && localList.length === 0 && (
705
+ <div data-id="LocalTeamPlaceholder" className="bcard bcard--local">
706
+ <div className="bcard__accent" />
707
+ <div className="bcard__top">
708
+ <div className="bcard__pill">
709
+ <span className="bcard__dot" data-tone="warn" />
710
+ <LaptopIcon />
711
+ </div>
712
+ </div>
713
+ <div className="bcard__body">
714
+ <h3 className="bcard__name">本地团队</h3>
715
+ <div className="bcard__host">http://127.0.0.1:8008</div>
716
+ <div className="bcard__meta" />
717
+ </div>
718
+ <button type="button" className="bcard__cta" disabled>
719
+ <Spinner />
720
+ <span>{localTeamsFetched ? "正在启动,就绪后自动加入…" : "检测中…"}</span>
721
+ </button>
722
+ </div>
723
+ )}
633
724
  {showCustom && customList.map((t) => (
634
725
  <LocalTeamCard key={"custom:" + t.id} team={t} onOpen={() => openLocalTeam(t.id)} onRename={renameLocalTeam} onRefresh={fetchLocalTeams} />
635
726
  ))}
@@ -638,8 +729,11 @@ export default function App() {
638
729
  key={"cloud:" + t.id}
639
730
  team={t}
640
731
  onOpen={() => {
641
- const url = t.workspace_url || t.workspace_direct_url;
642
- if (url) window.cicy?.shell?.openExternal?.(url);
732
+ // private:开 host_url(自托管地址);历史 cloud:开 workspace_url
733
+ // Open as a TAB in the current profile (like the local card), NOT
734
+ // the system browser.
735
+ const url = t.kind === "private" ? t.host_url : (t.workspace_url || t.workspace_direct_url);
736
+ if (url) window.cicy?.tabs?.open?.(url, t.name || t.title || "");
643
737
  }}
644
738
  />
645
739
  ))}
@@ -666,10 +760,262 @@ export default function App() {
666
760
  );
667
761
  }
668
762
 
763
+ // Chrome-style "site settings" for the trusted-origins allowlist: which sites may
764
+ // receive the electronRPC bridge in profile 0 (= run commands on this machine).
765
+ // Backed by window.cicy.trustedOrigins.{list,add,remove}; built-ins (localhost)
766
+ // are greyed + non-removable; the default list is just the built-ins. Inline
767
+ // styles keep it self-contained (no dependency on App.css classes).
768
+ function TrustedSitesModal({ onClose }) {
769
+ const [rows, setRows] = useState(null); // [{host, builtin}] | null(loading)
770
+ const [input, setInput] = useState("");
771
+ const [busy, setBusy] = useState(false);
772
+ const [err, setErr] = useState("");
773
+ const api = (typeof window !== "undefined" && window.cicy && window.cicy.trustedOrigins) || null;
774
+
775
+ const load = useCallback(async () => {
776
+ try { setRows((api && (await api.list())) || []); } catch { setRows([]); }
777
+ }, [api]);
778
+ useEffect(() => { load(); }, [load]);
779
+
780
+ const doAdd = async () => {
781
+ const v = input.trim();
782
+ if (!v || busy || !api) return;
783
+ setBusy(true); setErr("");
784
+ try {
785
+ const r = await api.add(v);
786
+ if (r && r.ok === false) setErr(r.error || tr("trustedSites.addFailed", "添加失败"));
787
+ else { setInput(""); setRows((r && r.origins) || (await api.list())); }
788
+ } catch (e) { setErr(String((e && e.message) || e)); }
789
+ finally { setBusy(false); }
790
+ };
791
+ const doRemove = async (host) => {
792
+ if (busy || !api) return;
793
+ setBusy(true); setErr("");
794
+ try {
795
+ const r = await api.remove(host);
796
+ if (r && r.ok === false) setErr(r.error || tr("trustedSites.removeFailed", "删除失败"));
797
+ else setRows((r && r.origins) || (await api.list()));
798
+ } catch (e) { setErr(String((e && e.message) || e)); }
799
+ finally { setBusy(false); }
800
+ };
801
+
802
+ const S = {
803
+ overlay: { position: "fixed", inset: 0, zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.62)", backdropFilter: "blur(3px)" },
804
+ card: { width: 560, maxWidth: "94vw", maxHeight: "82vh", display: "flex", flexDirection: "column", background: "#101012", border: "1px solid rgba(255,255,255,.09)", borderRadius: 16, boxShadow: "0 24px 64px rgba(0,0,0,.55)", overflow: "hidden", color: "#e4e4e7" },
805
+ head: { display: "flex", alignItems: "center", gap: 8, padding: "14px 16px", borderBottom: "1px solid rgba(255,255,255,.06)" },
806
+ title: { margin: 0, fontSize: 15, fontWeight: 600, flex: 1 },
807
+ x: { background: "transparent", border: "none", color: "#a1a1aa", fontSize: 16, cursor: "pointer", lineHeight: 1, padding: 4 },
808
+ warn: { margin: "14px 16px 0", padding: "10px 12px", fontSize: 12.5, lineHeight: 1.55, color: "#fca5a5", background: "rgba(239,68,68,.08)", border: "1px solid rgba(239,68,68,.25)", borderRadius: 10 },
809
+ addRow: { display: "flex", gap: 8, padding: "12px 16px 4px" },
810
+ input: { flex: 1, minWidth: 0, background: "#161618", border: "1px solid rgba(255,255,255,.1)", borderRadius: 9, padding: "9px 11px", color: "#e4e4e7", fontSize: 13, outline: "none" },
811
+ addBtn: { background: "rgba(255,255,255,.1)", border: "none", borderRadius: 9, padding: "0 16px", color: "#fff", fontSize: 13, fontWeight: 500, cursor: "pointer" },
812
+ err: { margin: "6px 16px 0", fontSize: 12, color: "#fca5a5" },
813
+ listWrap: { margin: "10px 16px 16px", border: "1px solid rgba(255,255,255,.07)", borderRadius: 10, overflow: "auto", flex: 1, minHeight: 80 },
814
+ row: { display: "flex", alignItems: "center", gap: 10, padding: "9px 12px", borderTop: "1px solid rgba(255,255,255,.05)" },
815
+ host: (b) => ({ flex: 1, fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", fontSize: 13, color: b ? "#71717a" : "#e4e4e7", wordBreak: "break-all" }),
816
+ tag: { fontSize: 11, color: "#71717a", background: "rgba(255,255,255,.05)", borderRadius: 6, padding: "2px 7px" },
817
+ rm: { background: "transparent", border: "none", color: "#a1a1aa", fontSize: 12, cursor: "pointer", padding: "3px 6px", borderRadius: 6 },
818
+ muted: { padding: "16px", textAlign: "center", color: "#71717a", fontSize: 12.5 },
819
+ };
820
+
821
+ return createPortal(
822
+ <div style={S.overlay} onClick={onClose} data-id="TrustedSitesModal">
823
+ <div style={S.card} onClick={(e) => e.stopPropagation()}>
824
+ <div style={S.head}>
825
+ <h2 style={S.title}>{tr("trustedSites.title", "受信任站点")}</h2>
826
+ <button type="button" style={S.x} onClick={onClose} aria-label="close">✕</button>
827
+ </div>
828
+ <div style={S.warn}>
829
+ {tr("trustedSites.warn", "⚠ 列表中的站点可以在你的电脑上执行命令(exec)。只添加你完全信任的地址。")}
830
+ </div>
831
+ <div style={S.addRow}>
832
+ <input
833
+ data-id="trusted-sites-input"
834
+ style={S.input}
835
+ value={input}
836
+ onChange={(e) => setInput(e.target.value)}
837
+ onKeyDown={(e) => { if (e.key === "Enter") doAdd(); }}
838
+ placeholder={tr("trustedSites.placeholder", "添加站点,如 app.cicy-ai.com 或 my-cloud.example.org")}
839
+ />
840
+ <button type="button" data-id="trusted-sites-add" style={{ ...S.addBtn, opacity: busy || !input.trim() ? 0.5 : 1 }} onClick={doAdd} disabled={busy || !input.trim()}>
841
+ {tr("trustedSites.add", "添加")}
842
+ </button>
843
+ </div>
844
+ {err && <div style={S.err}>{err}</div>}
845
+ <div style={S.listWrap}>
846
+ {rows === null ? (
847
+ <div style={S.muted}>{tr("trustedSites.loading", "加载中…")}</div>
848
+ ) : rows.length === 0 ? (
849
+ <div style={S.muted}>{tr("trustedSites.empty", "暂无")}</div>
850
+ ) : (
851
+ rows.map((r) => (
852
+ <div key={r.host} style={S.row} data-id="trusted-sites-row">
853
+ <span style={S.host(r.builtin)}>{r.host}</span>
854
+ {r.builtin ? (
855
+ <span style={S.tag}>{tr("trustedSites.builtin", "系统")}</span>
856
+ ) : (
857
+ <button type="button" style={S.rm} onClick={() => doRemove(r.host)} disabled={busy}>
858
+ {tr("trustedSites.remove", "删除")}
859
+ </button>
860
+ )}
861
+ </div>
862
+ ))
863
+ )}
864
+ </div>
865
+ </div>
866
+ </div>,
867
+ document.body,
868
+ );
869
+ }
870
+
871
+ // Read-only viewer for the RPC audit log (~/cicy-ai/db/rpc-audit.log): every
872
+ // electronRPC call + every authorization decision (incl. temporary 本次允许 / 允许
873
+ // 一次) + allowlist edit. Backed by window.cicy.rpcAudit.tail(); newest-first,
874
+ // refreshable. Review-only — there is no mutation path.
875
+ function AuditLogModal({ onClose }) {
876
+ const [entries, setEntries] = useState(null); // [] | null(loading)
877
+ const [err, setErr] = useState("");
878
+ const [logPath, setLogPath] = useState("");
879
+ const [busy, setBusy] = useState(false);
880
+ const api = (typeof window !== "undefined" && window.cicy && window.cicy.rpcAudit) || null;
881
+
882
+ const load = useCallback(async () => {
883
+ setBusy(true); setErr("");
884
+ try {
885
+ const r = api && (await api.tail(400));
886
+ if (!r || r.ok === false) { setErr((r && r.error) || tr("audit.loadFailed", "读取失败")); setEntries([]); }
887
+ else { setEntries(r.entries || []); setLogPath(r.path || ""); }
888
+ } catch (e) { setErr(String((e && e.message) || e)); setEntries([]); }
889
+ finally { setBusy(false); }
890
+ }, [api]);
891
+ useEffect(() => { load(); }, [load]);
892
+
893
+ const [filter, setFilter] = useState("all"); // all | rpc | auth
894
+ const [query, setQuery] = useState("");
895
+
896
+ const fmtTime = (ts) => { try { return new Date(ts).toLocaleString(); } catch { return ts || ""; } };
897
+ const badge = (e) => {
898
+ if (e.kind === "auth") {
899
+ const deny = /deny/.test(e.decision || "");
900
+ return { text: e.decision || "auth", color: deny ? "#fca5a5" : "#86efac", bg: deny ? "rgba(239,68,68,.14)" : "rgba(34,197,94,.14)" };
901
+ }
902
+ if (e.kind === "rpc") {
903
+ const ok = e.ok !== false && !e.error;
904
+ return { text: ok ? "ok" : "err", color: ok ? "#86efac" : "#fca5a5", bg: ok ? "rgba(34,197,94,.14)" : "rgba(239,68,68,.14)" };
905
+ }
906
+ return { text: e.kind || "log", color: "#a1a1aa", bg: "rgba(255,255,255,.06)" };
907
+ };
908
+ const opText = (e) => {
909
+ if (e.kind === "auth") return `${e.gate || ""}${e.decision ? " · " + e.decision : ""}`;
910
+ if (e.kind === "rpc") return `${e.tool || ""}${e.dangerous ? " ⚠" : ""}`;
911
+ return e.kind || "";
912
+ };
913
+ const detailText = (e) => e.error || e.args || (e.kind === "rpc" ? e.channel : "") || "";
914
+
915
+ const all = entries || [];
916
+ const q = query.trim().toLowerCase();
917
+ const view = all.filter((e) => {
918
+ if (filter !== "all" && e.kind !== filter) return false;
919
+ if (!q) return true;
920
+ return [e.origin, e.host, e.tool, e.gate, e.decision, e.channel, e.args, e.error, e.kind]
921
+ .filter(Boolean).join(" ").toLowerCase().includes(q);
922
+ });
923
+
924
+ const COLS = "186px 104px minmax(160px,1.1fr) minmax(150px,1.1fr) minmax(220px,1.8fr)";
925
+ const S = {
926
+ overlay: { position: "fixed", inset: 0, zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.66)", backdropFilter: "blur(4px)" },
927
+ card: { width: "96vw", height: "92vh", maxWidth: 1480, display: "flex", flexDirection: "column", background: "#0d0d0f", border: "1px solid rgba(255,255,255,.09)", borderRadius: 18, boxShadow: "0 32px 80px rgba(0,0,0,.6)", overflow: "hidden", color: "#e4e4e7" },
928
+ head: { display: "flex", alignItems: "center", gap: 14, padding: "20px 24px", borderBottom: "1px solid rgba(255,255,255,.07)" },
929
+ titleWrap: { flex: 1, minWidth: 0 },
930
+ title: { margin: 0, fontSize: 21, fontWeight: 650, letterSpacing: .2 },
931
+ subtitle: { margin: "3px 0 0", fontSize: 12.5, color: "#71717a", fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", wordBreak: "break-all" },
932
+ count: { fontSize: 12.5, color: "#a1a1aa", whiteSpace: "nowrap" },
933
+ refresh: { background: "rgba(255,255,255,.1)", border: "none", borderRadius: 9, padding: "9px 16px", color: "#fff", fontSize: 13, fontWeight: 500, cursor: "pointer" },
934
+ x: { background: "transparent", border: "none", color: "#a1a1aa", fontSize: 20, cursor: "pointer", lineHeight: 1, padding: 6 },
935
+ toolbar: { display: "flex", alignItems: "center", gap: 10, padding: "14px 24px", borderBottom: "1px solid rgba(255,255,255,.05)" },
936
+ chips: { display: "flex", gap: 6 },
937
+ chip: (on) => ({ background: on ? "rgba(255,255,255,.14)" : "transparent", border: "1px solid rgba(255,255,255,.12)", borderRadius: 999, padding: "6px 16px", color: on ? "#fff" : "#a1a1aa", fontSize: 13, cursor: "pointer" }),
938
+ search: { flex: 1, minWidth: 0, background: "#161618", border: "1px solid rgba(255,255,255,.1)", borderRadius: 10, padding: "10px 14px", color: "#e4e4e7", fontSize: 13.5, outline: "none" },
939
+ err: { margin: "10px 24px 0", fontSize: 12.5, color: "#fca5a5" },
940
+ tableWrap: { flex: 1, overflow: "auto", margin: "0" },
941
+ theadRow: { position: "sticky", top: 0, zIndex: 1, display: "grid", gridTemplateColumns: COLS, gap: 16, padding: "12px 24px", background: "#141417", borderBottom: "1px solid rgba(255,255,255,.08)", fontSize: 11.5, letterSpacing: .6, textTransform: "uppercase", color: "#71717a", fontWeight: 600 },
942
+ row: { display: "grid", gridTemplateColumns: COLS, gap: 16, padding: "13px 24px", borderBottom: "1px solid rgba(255,255,255,.045)", alignItems: "center" },
943
+ time: { fontSize: 12.5, color: "#a1a1aa", fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", whiteSpace: "nowrap" },
944
+ badge: (b) => ({ justifySelf: "start", fontSize: 11.5, color: b.color, background: b.bg, borderRadius: 7, padding: "3px 10px", whiteSpace: "nowrap", fontWeight: 500 }),
945
+ cell: { fontSize: 13, color: "#d4d4d8", fontFamily: "ui-monospace,SFMono-Regular,Menlo,monospace", wordBreak: "break-all" },
946
+ detail: { fontSize: 12.5, color: "#8b8b93", wordBreak: "break-all" },
947
+ muted: { padding: "60px 24px", textAlign: "center", color: "#71717a", fontSize: 14 },
948
+ };
949
+ const Th = (t) => <div>{t}</div>;
950
+
951
+ return createPortal(
952
+ <div style={S.overlay} onClick={onClose} data-id="AuditLogModal">
953
+ <div style={S.card} onClick={(e) => e.stopPropagation()}>
954
+ <div style={S.head}>
955
+ <div style={S.titleWrap}>
956
+ <h2 style={S.title}>{tr("audit.title", "审计日志")}</h2>
957
+ {logPath && <p style={S.subtitle}>{logPath}</p>}
958
+ </div>
959
+ <span style={S.count}>{tr("audit.count", "共")} {view.length}{filter !== "all" || q ? ` / ${all.length}` : ""}</span>
960
+ <button type="button" data-id="audit-refresh" style={{ ...S.refresh, opacity: busy ? 0.5 : 1 }} onClick={load} disabled={busy}>
961
+ {tr("audit.refresh", "刷新")}
962
+ </button>
963
+ <button type="button" style={S.x} onClick={onClose} aria-label="close">✕</button>
964
+ </div>
965
+ <div style={S.toolbar}>
966
+ <div style={S.chips}>
967
+ <button type="button" style={S.chip(filter === "all")} onClick={() => setFilter("all")}>{tr("audit.all", "全部")}</button>
968
+ <button type="button" style={S.chip(filter === "rpc")} onClick={() => setFilter("rpc")}>{tr("audit.rpc", "RPC 调用")}</button>
969
+ <button type="button" style={S.chip(filter === "auth")} onClick={() => setFilter("auth")}>{tr("audit.auth", "授权决定")}</button>
970
+ </div>
971
+ <input
972
+ data-id="audit-search"
973
+ style={S.search}
974
+ value={query}
975
+ onChange={(e) => setQuery(e.target.value)}
976
+ placeholder={tr("audit.search", "搜索来源 / 工具 / 命令…")}
977
+ />
978
+ </div>
979
+ {err && <div style={S.err}>{err}</div>}
980
+ <div style={S.tableWrap}>
981
+ <div style={S.theadRow}>
982
+ {Th(tr("audit.colTime", "时间"))}
983
+ {Th(tr("audit.colType", "类型"))}
984
+ {Th(tr("audit.colSource", "来源"))}
985
+ {Th(tr("audit.colOp", "操作"))}
986
+ {Th(tr("audit.colDetail", "详情"))}
987
+ </div>
988
+ {entries === null ? (
989
+ <div style={S.muted}>{tr("audit.loading", "加载中…")}</div>
990
+ ) : view.length === 0 ? (
991
+ <div style={S.muted}>{all.length === 0 ? tr("audit.empty", "暂无审计记录") : tr("audit.noMatch", "无匹配记录")}</div>
992
+ ) : (
993
+ view.map((e, i) => {
994
+ const b = badge(e);
995
+ return (
996
+ <div key={i} style={S.row} data-id="audit-row">
997
+ <span style={S.time}>{fmtTime(e.ts)}</span>
998
+ <span style={S.badge(b)}>{b.text}</span>
999
+ <span style={S.cell}>{e.origin || e.host || "—"}</span>
1000
+ <span style={S.cell}>{opText(e) || "—"}</span>
1001
+ <span style={S.detail}>{detailText(e) || "—"}</span>
1002
+ </div>
1003
+ );
1004
+ })
1005
+ )}
1006
+ </div>
1007
+ </div>
1008
+ </div>,
1009
+ document.body,
1010
+ );
1011
+ }
1012
+
669
1013
  function Header({ me, welcome, onLogout, mitmTeam }) {
670
1014
  const name = me?.display_name || me?.username || "…";
671
1015
  const initials = (name || "?").slice(0, 1).toUpperCase();
672
1016
  const [open, setOpen] = useState(false);
1017
+ const [trustOpen, setTrustOpen] = useState(false);
1018
+ const [auditOpen, setAuditOpen] = useState(false);
673
1019
  const wrap = useRef(null);
674
1020
  // Click-outside closes the dropdown (mirrors LocalTeamCard's ⋯ menu).
675
1021
  useEffect(() => {
@@ -707,6 +1053,12 @@ function Header({ me, welcome, onLogout, mitmTeam }) {
707
1053
  <button type="button" data-id="UserChip-billing" className="user-chip__menu-item" onClick={() => goDash("?view=usage")}>
708
1054
  我的账单
709
1055
  </button>
1056
+ <button type="button" data-id="UserChip-trusted-sites" className="user-chip__menu-item" onClick={() => { setOpen(false); setTrustOpen(true); }}>
1057
+ {tr("trustedSites.menu", "受信任站点")}
1058
+ </button>
1059
+ <button type="button" data-id="UserChip-audit-log" className="user-chip__menu-item" onClick={() => { setOpen(false); setAuditOpen(true); }}>
1060
+ {tr("audit.menu", "审计日志")}
1061
+ </button>
710
1062
  {mitmTeam && (
711
1063
  <div className="user-chip__menu-mitm" data-id="UserChip-mitm" onClick={(e) => e.stopPropagation()}>
712
1064
  <MitmConsentCard team={mitmTeam} variant="menu" />
@@ -719,6 +1071,8 @@ function Header({ me, welcome, onLogout, mitmTeam }) {
719
1071
  </div>
720
1072
  )}
721
1073
  </div>
1074
+ {trustOpen && <TrustedSitesModal onClose={() => setTrustOpen(false)} />}
1075
+ {auditOpen && <AuditLogModal onClose={() => setAuditOpen(false)} />}
722
1076
  </header>
723
1077
  );
724
1078
  }
@@ -1048,15 +1402,34 @@ function DockerSetup({ onReady }) {
1048
1402
  function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
1049
1403
  const statusInfo = LOCAL_STATUS[team.status] || LOCAL_STATUS.error;
1050
1404
  const tone = statusInfo.tone;
1051
- // Inline rename: double-click the name or click ✎ → edit → Enter/blur saves.
1052
- // All local teams are renamable via window.cicy.localTeams.update(id,{name}).
1405
+ // Inline rename 产品级:点名字即编辑、乐观更新、行内"保存中/已保存"、失败回滚。
1406
+ // 显示名 = pendingName(乐观,保存中暂显)?? team.name(props,后台对账后更新)
1053
1407
  const [editing, setEditing] = useState(false);
1054
1408
  const [draft, setDraft] = useState(team.name || "");
1055
- const startEdit = (e) => { e.stopPropagation(); setDraft(team.name || ""); setEditing(true); };
1409
+ const [pendingName, setPendingName] = useState(null); // 乐观名;props 追上后清
1410
+ const [saveState, setSaveState] = useState(""); // "" | saving | saved | error
1411
+ const displayName = pendingName != null ? pendingName : team.name;
1412
+ // props 追上乐观名 → 清 pending(两者相等,无闪烁)
1413
+ useEffect(() => { if (pendingName != null && team.name === pendingName) setPendingName(null); }, [team.name, pendingName]);
1414
+ const startEdit = (e) => { e?.stopPropagation?.(); setDraft(displayName || ""); setEditing(true); };
1056
1415
  const commit = async () => {
1057
1416
  setEditing(false);
1058
1417
  const next = String(draft || "").trim();
1059
- if (onRename && next && next !== team.name) await onRename(team.id, next);
1418
+ if (!onRename || !next || next === displayName) return;
1419
+ setPendingName(next); // 乐观:立即显示新名
1420
+ setSaveState("saving");
1421
+ let r;
1422
+ try { r = await onRename(team.id, next); } catch (e) { r = { ok: false, error: e?.message }; }
1423
+ // 落定:让权威值(team.name,onRename 已刷新)接管显示。服务端权威下,本端改名若与
1424
+ // 云端并发冲突会被判负,后续 ~3s 对账会把名字换成云端版本——清掉乐观名才不会卡住。
1425
+ setPendingName(null);
1426
+ if (r && r.ok) {
1427
+ setSaveState("saved");
1428
+ setTimeout(() => setSaveState((s) => (s === "saved" ? "" : s)), 1500);
1429
+ } else {
1430
+ setSaveState("");
1431
+ toast.show({ message: tr("localTeams.renameFailed", "改名没保存,已恢复"), status: "error", ttl: 4000 });
1432
+ }
1060
1433
  };
1061
1434
 
1062
1435
  // Lifecycle (启动 / 重启 / 更新 / 停止) acts on the daemon the desktop OWNS —
@@ -1069,46 +1442,63 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
1069
1442
  const running = team.status === "running";
1070
1443
  const [busy, setBusy] = useState(""); // "" | start | restart | update | stop
1071
1444
  const [menuOpen, setMenuOpen] = useState(false);
1072
- const [latest, setLatest] = useState(null); // newest cicy-code on the registry
1445
+ // cicy-code 版本统一从 sidecar.versions() 一处拿(主人令:"拿版本就一个方法")。
1446
+ // running===undefined = 还没查到(用于区分"加载中" vs "停了/拿不到");区别于
1447
+ // running===null(查过了但 daemon 没报版本)。latest/installed 同源。
1448
+ const [versions, setVersions] = useState({ running: undefined, latest: null, installed: null });
1449
+ const latest = versions.latest;
1450
+ const runningVer = versions.running;
1073
1451
  const [checking, setChecking] = useState(false);
1074
- const [upToDateMsg, setUpToDateMsg] = useState(""); // transient "已是最新 vX"
1075
1452
  const menuWrap = useRef(null);
1453
+ const kebabRef = useRef(null); // ⋯ button — anchor for the portaled menu
1454
+ const menuRef = useRef(null); // portaled menu (lives on document.body)
1455
+ const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
1456
+ const MENU_W = 184;
1457
+ // The ⋯ menu is rendered in a PORTAL on document.body (not inside .bcard, whose
1458
+ // overflow:hidden — needed for the rounded card + glow — would otherwise CLIP
1459
+ // the dropdown). Anchor it under the kebab, right-aligned, clamped on-screen.
1460
+ const toggleMenu = () => {
1461
+ if (!menuOpen && kebabRef.current) {
1462
+ const r = kebabRef.current.getBoundingClientRect();
1463
+ const left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
1464
+ setMenuPos({ top: Math.round(r.bottom + 4), left: Math.round(left) });
1465
+ }
1466
+ setMenuOpen((v) => !v);
1467
+ };
1076
1468
 
1077
- // Fetch the newest cicy-code from the registry and compare. Auto-runs once on
1078
- // mount (passive only surfaces 更新 when behind, no nagging when current).
1079
- // The menu's 检查更新 calls it with manual=true to echo "已是最新" when current.
1080
- // Renderer-side via cloud.fetch main proxies it, dodging CORS; no extra IPC.
1469
+ // 版本统一从 sidecar.versions() 一处拿(running=活着的 /api/health 版本,
1470
+ // latest=npm 最新,同源)。Auto-runs once on mount + 每次 daemon 起来(status→
1471
+ // running)时重查,这样 cicy-code 刚启动那一拍拿不到版本、之后能自动补上。
1472
+ // 菜单"检查更新"用 manual=true toast。
1081
1473
  const checkUpdate = useCallback(async (manual = false) => {
1082
- if (!local || !window.cicy?.cloud?.fetch) return;
1083
- if (manual) { setChecking(true); setUpToDateMsg(""); }
1474
+ if (!local || !window.cicy?.sidecar?.versions) return;
1475
+ if (manual) setChecking(true);
1084
1476
  try {
1085
- const r = await window.cicy.cloud.fetch("https://registry.npmmirror.com/cicy-code/latest");
1086
- if (r?.ok) {
1087
- const v = JSON.parse(r.body)?.version || null;
1088
- setLatest(v);
1089
- if (manual && v) {
1090
- if (!team.version) {
1091
- // Current version UNKNOWN (daemon stopped, or health returned no
1092
- // version). Do NOT claim "已是最新" and NEVER show the latest as if
1093
- // it were the current version (the old `team.version || v` bug made
1094
- // it say "已是最新 v<latest>" while the running daemon was older).
1095
- setUpToDateMsg(`${tr("sidecar.latestVersionIs", "最新版本")} v${v}·${tr("sidecar.startToCompare", "启动后对比当前版本")}`);
1096
- setTimeout(() => setUpToDateMsg(""), 3500);
1097
- } else if (cmpVer(v, team.version) > 0) {
1098
- // Behind — the 更新 badge/button drives the upgrade; no toast here.
1099
- } else {
1100
- setUpToDateMsg(`${tr("sidecar.upToDate", "已是最新")} v${team.version}`);
1101
- setTimeout(() => setUpToDateMsg(""), 2500);
1102
- }
1477
+ const v = await window.cicy.sidecar.versions(); // { running, latest, installed }
1478
+ setVersions({ running: v?.running ?? null, latest: v?.latest ?? null, installed: v?.installed ?? null });
1479
+ if (manual) {
1480
+ if (v?.running && v?.latest && cmpVer(v.latest, v.running) > 0) {
1481
+ toast.show({ message: `${tr("sidecar.found", "发现新版本")} v${v.latest}`, status: "done", ttl: 2500 });
1482
+ } else if (v?.running) {
1483
+ toast.show({ message: `${tr("sidecar.upToDate", "已是最新")} v${v.running}`, status: "done", ttl: 2500 });
1484
+ } else {
1485
+ // daemon 没在跑 / 没报版本 别撒谎说最新,也别误报有更新
1486
+ toast.show({ message: tr("sidecar.notRunning", "cicy-code 未运行"), status: "error", ttl: 4000 });
1103
1487
  }
1104
1488
  }
1105
- } catch { /* offline / registry hiccup leave latest as-is */ }
1489
+ } catch { if (manual) toast.show({ message: tr("sidecar.checkFailed", "检查更新失败"), status: "error", ttl: 5000 }); }
1106
1490
  finally { if (manual) setChecking(false); }
1107
- }, [local, team.version]);
1491
+ }, [local]);
1108
1492
 
1109
1493
  useEffect(() => { checkUpdate(false); }, [checkUpdate]);
1110
-
1111
- const updateAvailable = !!(local && latest && team.version && cmpVer(latest, team.version) > 0);
1494
+ // daemon 起来后(或重启/更新后 status 翻 running)自动重查一次版本,补上启动早期的空值。
1495
+ useEffect(() => { if (running) checkUpdate(false); }, [running, checkUpdate]);
1496
+
1497
+ // 只有在【确知运行版本 runningVer 且确实落后 latest】时才提示更新。runningVer 未知
1498
+ // (undefined/null:停了或刚启动还没读到)一律不提示——修掉"版本暂时读不到就误报更新 +
1499
+ // 卡片一直转/显示更新"的 bug。NOTE: 这跟"版本落后却不让更新"不冲突:落后是 runningVer
1500
+ // 已知且 < latest,照样提示。
1501
+ const updateAvailable = !!(local && latest && runningVer && cmpVer(latest, runningVer) > 0);
1112
1502
  // Custom (deeplink-added, non-local) nodes can be removed from the desktop —
1113
1503
  // it just drops them from cicyDesktopNodes; re-addable via deeplink. The
1114
1504
  // local sidecar isn't deletable here. So the ⋯ menu shows for a local card
@@ -1123,7 +1513,13 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
1123
1513
 
1124
1514
  useEffect(() => {
1125
1515
  if (!menuOpen) return;
1126
- const onDoc = (e) => { if (menuWrap.current && !menuWrap.current.contains(e.target)) setMenuOpen(false); };
1516
+ // The menu is portaled to document.body, so menuWrap no longer contains it
1517
+ // check BOTH the kebab anchor and the portaled menu before closing.
1518
+ const onDoc = (e) => {
1519
+ if (kebabRef.current?.contains(e.target)) return;
1520
+ if (menuRef.current?.contains(e.target)) return;
1521
+ setMenuOpen(false);
1522
+ };
1127
1523
  document.addEventListener("mousedown", onDoc);
1128
1524
  return () => document.removeEventListener("mousedown", onDoc);
1129
1525
  }, [menuOpen]);
@@ -1152,7 +1548,7 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
1152
1548
  const isUpdate = kind === "update";
1153
1549
  let unsub = null;
1154
1550
  if (isUpdate) {
1155
- updateDrawer.open({ teamId: team.id, fromVer: team.version, toVer: latest, onRetry: () => runOp("update", fn, doneText) });
1551
+ updateDrawer.open({ teamId: team.id, fromVer: runningVer, toVer: latest, onRetry: () => runOp("update", fn, doneText) });
1156
1552
  if (window.cicy?.sidecar?.onOpProgress) {
1157
1553
  unsub = window.cicy.sidecar.onOpProgress((ev) => { if (ev?.op === "update") updateDrawer.push(ev); });
1158
1554
  }
@@ -1177,6 +1573,9 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
1177
1573
  try { unsub && unsub(); } catch {}
1178
1574
  setBusy("");
1179
1575
  onRefresh?.(); // re-probe so the status dot/chip catches up
1576
+ // 重启/更新/启动后,daemon 版本可能变了(且 status 可能仍是 running、不会触发
1577
+ // 上面那个 effect),所以这里强制重查一次版本,卡片立刻反映真实版本。
1578
+ if (kind === "update" || kind === "restart" || kind === "start") checkUpdate(false);
1180
1579
  }
1181
1580
  };
1182
1581
  const BUSY_LABEL = { start: "启动中…", restart: "重启中…", update: "更新中…", stop: "停止中…" };
@@ -1220,16 +1619,20 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
1220
1619
  <div className="bcard__menuwrap" ref={menuWrap} onClick={(e) => e.stopPropagation()}>
1221
1620
  <button
1222
1621
  type="button"
1622
+ ref={kebabRef}
1223
1623
  data-id="LocalTeamCard-menu-btn"
1224
1624
  className={`bcard__kebab${updateAvailable ? " has-dot" : ""}`}
1225
1625
  title={local ? tr("localTeams.manage", "管理本地 cicy-code") : tr("localTeams.more", "更多")}
1226
1626
  disabled={!!busy}
1227
- onClick={() => setMenuOpen((v) => !v)}
1627
+ onClick={toggleMenu}
1228
1628
  >
1229
1629
  {busy ? <Spinner /> : <KebabIcon />}
1230
1630
  </button>
1231
- {menuOpen && (
1232
- <div className="bcard__menu" data-id="LocalTeamCard-menu" role="menu">
1631
+ {menuOpen && createPortal(
1632
+ <div className="bcard__menu bcard__menu--portal" data-id="LocalTeamCard-menu" role="menu"
1633
+ ref={menuRef}
1634
+ style={{ position: "fixed", top: menuPos.top, left: menuPos.left, width: MENU_W }}
1635
+ onClick={(e) => e.stopPropagation()}>
1233
1636
  {updateAvailable && (
1234
1637
  <button
1235
1638
  type="button"
@@ -1280,9 +1683,7 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
1280
1683
  disabled={checking}
1281
1684
  onClick={(e) => { e.stopPropagation(); checkUpdate(true); }}
1282
1685
  >
1283
- {checking
1284
- ? tr("sidecar.checking2", "检查中…")
1285
- : (upToDateMsg || tr("sidecar.checkUpdate", "检查更新"))}
1686
+ {checking ? tr("sidecar.checking2", "检查中…") : tr("sidecar.checkUpdate", "检查更新")}
1286
1687
  </button>
1287
1688
  )}
1288
1689
  {team.cloud_team_id && (
@@ -1312,7 +1713,8 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
1312
1713
  {confirmDel ? tr("localTeams.removeConfirm", "确认删除?") : tr("localTeams.remove", "删除")}
1313
1714
  </button>
1314
1715
  )}
1315
- </div>
1716
+ </div>,
1717
+ document.body
1316
1718
  )}
1317
1719
  </div>
1318
1720
  )}
@@ -1324,29 +1726,43 @@ function LocalTeamCard({ team, onOpen, onRename, onRefresh }) {
1324
1726
  autoFocus
1325
1727
  value={draft}
1326
1728
  onChange={(e) => setDraft(e.target.value)}
1729
+ onFocus={(e) => e.target.select()}
1327
1730
  onBlur={commit}
1328
1731
  onClick={(e) => e.stopPropagation()}
1329
1732
  onKeyDown={(e) => { if (e.key === "Enter") commit(); else if (e.key === "Escape") setEditing(false); }}
1330
1733
  style={{ width: "100%", font: "inherit", fontWeight: 600, padding: "2px 6px", border: "1px solid #3b82f6", borderRadius: 6, background: "#0d1117", color: "#e6edf3", boxSizing: "border-box" }}
1331
1734
  />
1332
1735
  ) : (
1333
- <h3 className="bcard__name" title={tr("localTeams.renameHint", "双击或点 ✎ 改名")} style={{ display: "flex", alignItems: "center", gap: 6 }} onDoubleClick={startEdit}>
1334
- <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{team.name}</span>
1335
- <button
1336
- type="button"
1337
- data-id="LocalTeamCard-rename-btn"
1338
- title={tr("localTeams.rename", "重命名")}
1736
+ <h3 className="bcard__name" title={tr("localTeams.renameHint", "点名字或 ✎ 改名")} style={{ display: "flex", alignItems: "center", gap: 6 }} onDoubleClick={startEdit}>
1737
+ <span
1738
+ data-id="LocalTeamCard-name-text"
1339
1739
  onClick={startEdit}
1340
- style={{ flex: "none", cursor: "pointer", border: "none", background: "transparent", color: "#8b949e", fontSize: 13, padding: 0, lineHeight: 1 }}
1341
- >✎</button>
1740
+ style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", cursor: "text" }}
1741
+ >{displayName}</span>
1742
+ {/* 行内保存状态:保存中 spinner / 已保存 ✓ */}
1743
+ {saveState === "saving" && (
1744
+ <span data-id="LocalTeamCard-save-state" title={tr("localTeams.saving", "保存中…")} style={{ flex: "none", display: "inline-flex" }}><Spinner /></span>
1745
+ )}
1746
+ {saveState === "saved" && (
1747
+ <span data-id="LocalTeamCard-save-state" title={tr("localTeams.saved", "已保存")} style={{ flex: "none", color: "#3fb950", fontSize: 13, lineHeight: 1 }}>✓</span>
1748
+ )}
1749
+ {!saveState && (
1750
+ <button
1751
+ type="button"
1752
+ data-id="LocalTeamCard-rename-btn"
1753
+ title={tr("localTeams.rename", "重命名")}
1754
+ onClick={startEdit}
1755
+ style={{ flex: "none", cursor: "pointer", border: "none", background: "transparent", color: "#8b949e", fontSize: 13, padding: 0, lineHeight: 1 }}
1756
+ >✎</button>
1757
+ )}
1342
1758
  </h3>
1343
1759
  )}
1344
1760
  <div className="bcard__host">
1345
1761
  {team.base_url || "—"}
1346
1762
  </div>
1347
1763
  <div className="bcard__meta">
1348
- {team.version && (
1349
- <span className="bcard__ver" data-id="LocalTeamCard-version">v{team.version}</span>
1764
+ {(runningVer || team.version) && (
1765
+ <span className="bcard__ver" data-id="LocalTeamCard-version">v{runningVer || team.version}</span>
1350
1766
  )}
1351
1767
  </div>
1352
1768
  </div>
@@ -1392,13 +1808,55 @@ const LOCAL_STATUS = {
1392
1808
  error: { tone: "err", label: "error", cta: "异常" },
1393
1809
  };
1394
1810
 
1811
+ // 私有云 / (历史)云端团队卡片。产品方向变更(w-10032):公有云不做了,主打 private
1812
+ // (用户自托管,数据不出企业)。private 字段:{name,kind:"private",status,apiKey,
1813
+ // gatewayUrl,host_url,titleVersion,deviceId:""}。卡片展示名字+host_url,点开可看/复制 apiKey。
1395
1814
  function TeamCard({ team, onOpen }) {
1396
- const kindLabel = team.team_kind === "personal" ? "个人" : "共享";
1815
+ const isPrivate = team.kind === "private";
1397
1816
  const statusOk = team.status === "active";
1398
- const hasUrl = !!(team.workspace_url || team.workspace_direct_url);
1399
- const billTeamId = team.teamId || team.id; // /dash?team=<teamId> (no key in URL)
1817
+ const name = team.name || team.title || "—";
1818
+ const hostUrl = team.host_url || "";
1819
+ const billTeamId = team.teamId || team.id; // /dash?team=<teamId>(URL 不带 key)
1820
+ const kindLabel = isPrivate ? "私有云" : (team.team_kind === "personal" ? "个人" : "共享");
1821
+ const openUrl = isPrivate ? hostUrl : (team.workspace_url || team.workspace_direct_url);
1822
+ const hasUrl = !!openUrl;
1823
+
1824
+ // ⋯ menu (reload) — mirrors LocalTeamCard so EVERY card has a 刷新窗口. Reload
1825
+ // re-loads the cloud team's tab in profile 0 (opens it if not yet open).
1826
+ const [menuOpen, setMenuOpen] = useState(false);
1827
+ const [busy, setBusy] = useState(false);
1828
+ const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
1829
+ const menuWrap = useRef(null);
1830
+ const kebabRef = useRef(null);
1831
+ const menuRef = useRef(null);
1832
+ const MENU_W = 184;
1833
+ const toggleMenu = () => {
1834
+ if (!menuOpen && kebabRef.current) {
1835
+ const r = kebabRef.current.getBoundingClientRect();
1836
+ const left = Math.max(8, Math.min(r.right - MENU_W, window.innerWidth - MENU_W - 8));
1837
+ setMenuPos({ top: Math.round(r.bottom + 4), left: Math.round(left) });
1838
+ }
1839
+ setMenuOpen((v) => !v);
1840
+ };
1841
+ useEffect(() => {
1842
+ if (!menuOpen) return;
1843
+ const onDoc = (e) => {
1844
+ if (kebabRef.current?.contains(e.target)) return;
1845
+ if (menuRef.current?.contains(e.target)) return;
1846
+ setMenuOpen(false);
1847
+ };
1848
+ document.addEventListener("mousedown", onDoc);
1849
+ return () => document.removeEventListener("mousedown", onDoc);
1850
+ }, [menuOpen]);
1851
+ const doReload = async () => {
1852
+ if (!hasUrl || busy) return;
1853
+ setBusy(true); setMenuOpen(false);
1854
+ try { await window.cicy?.tabs?.reload?.(openUrl, name); } catch {}
1855
+ finally { setBusy(false); }
1856
+ };
1857
+ // 主人令:私有云卡片不展示 api key(安全)。key 只在云端 dash / 注入 global.json 用。
1400
1858
  return (
1401
- <div className={`bcard bcard--cloud${statusOk ? " bcard--online" : ""}`}>
1859
+ <div data-id="TeamCard" className={`bcard bcard--cloud${statusOk ? " bcard--online" : ""}`}>
1402
1860
  <div className="bcard__accent" />
1403
1861
  <div className="bcard__top">
1404
1862
  <div className="bcard__pill">
@@ -1418,16 +1876,42 @@ function TeamCard({ team, onOpen }) {
1418
1876
  {tr("localTeams.billing", "账单")}
1419
1877
  </button>
1420
1878
  )}
1879
+ {hasUrl && (
1880
+ <div className="bcard__menuwrap" ref={menuWrap} onClick={(e) => e.stopPropagation()}>
1881
+ <button
1882
+ type="button"
1883
+ ref={kebabRef}
1884
+ data-id="TeamCard-menu-btn"
1885
+ className="bcard__kebab"
1886
+ title={tr("localTeams.more", "更多")}
1887
+ disabled={busy}
1888
+ onClick={toggleMenu}
1889
+ >
1890
+ {busy ? <Spinner /> : <KebabIcon />}
1891
+ </button>
1892
+ {menuOpen && createPortal(
1893
+ <div className="bcard__menu bcard__menu--portal" data-id="TeamCard-menu" role="menu"
1894
+ ref={menuRef}
1895
+ style={{ position: "fixed", top: menuPos.top, left: menuPos.left, width: MENU_W }}
1896
+ onClick={(e) => e.stopPropagation()}>
1897
+ <button type="button" data-id="TeamCard-reload" className="bcard__menu-item" onClick={doReload}>
1898
+ {tr("localTeams.reloadWindow", "刷新窗口")}
1899
+ </button>
1900
+ </div>,
1901
+ document.body,
1902
+ )}
1903
+ </div>
1904
+ )}
1421
1905
  </div>
1422
1906
  </div>
1423
1907
  <div className="bcard__body">
1424
- <h3 className="bcard__name" title={team.title}>{team.title}</h3>
1425
- <div className="bcard__host">
1426
- {team.runtime_region || team.region || "—"}
1908
+ <h3 className="bcard__name" title={name}>{name}</h3>
1909
+ <div className="bcard__host" title={isPrivate ? (hostUrl || "") : ""}>
1910
+ {isPrivate ? (hostUrl || tr("teamCard.noHost", "未填访问地址")) : (team.runtime_region || team.region || "—")}
1427
1911
  </div>
1428
1912
  <div className="bcard__meta">
1429
1913
  <span className="bcard__chip">{kindLabel}</span>
1430
- {team.membership_status && team.membership_status !== "active" && (
1914
+ {!isPrivate && team.membership_status && team.membership_status !== "active" && (
1431
1915
  <span className="bcard__chip">{team.membership_status}</span>
1432
1916
  )}
1433
1917
  </div>
@@ -1439,7 +1923,7 @@ function TeamCard({ team, onOpen }) {
1439
1923
  disabled={!hasUrl}
1440
1924
  >
1441
1925
  <ArrowIcon />
1442
- <span>{hasUrl ? "打开" : "无 URL"}</span>
1926
+ <span>{hasUrl ? tr("localTeams.open", "打开") : (isPrivate ? tr("teamCard.noHost", "未填访问地址") : tr("teamCard.noUrl", "无 URL"))}</span>
1443
1927
  </button>
1444
1928
  </div>
1445
1929
  );