cicy-desktop 2.1.209 → 2.1.211

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.
@@ -6,7 +6,7 @@
6
6
  <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
7
7
  <link rel="icon" type="image/png" sizes="256x256" href="./favicon-256.png" />
8
8
  <title>CiCy Desktop</title>
9
- <script type="module" crossorigin src="./assets/index-DXv0ScMw.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-BVmu-4t_.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="./assets/index-C6Zrdgc0.css">
11
11
  </head>
12
12
  <body>
@@ -244,7 +244,7 @@ function register(opts = {}) {
244
244
  ipcMain.handle("tabs:open", async (_e, input) => {
245
245
  try {
246
246
  const tb = require("../tools/tab-browser-tools");
247
- const r = await tb.openTab(0, String((input && input.url) || ""), { systemOpen: true, trusted: false, title: (input && input.title) || "" });
247
+ const r = await tb.openTab(0, String((input && input.url) || ""), { systemOpen: true, trusted: false, title: (input && input.title) || "", avatar: (input && input.avatar) || "", team: !!(input && input.team), colorKey: (input && input.colorKey) || "" });
248
248
  return { ok: true, winId: r.winId, tabId: r.tabId };
249
249
  } catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
250
250
  });
@@ -256,7 +256,7 @@ function register(opts = {}) {
256
256
  const tb = require("../tools/tab-browser-tools");
257
257
  const idx = Number((input && input.accountIdx) || 0) || 0;
258
258
  // navigate:true → 已有同 /dash tab 时导航到新 query(钱包/帐单/团队帐单切视图)。
259
- const r = await tb.openTab(idx, String((input && input.url) || ""), { systemOpen: true, trusted: false, title: (input && input.title) || "", navigate: true });
259
+ const r = await tb.openTab(idx, String((input && input.url) || ""), { systemOpen: true, trusted: false, title: (input && input.title) || "", navigate: true, avatar: (input && input.avatar) || "" });
260
260
  return { ok: true, winId: r.winId, tabId: r.tabId };
261
261
  } catch (e) { return { ok: false, error: String((e && e.message) || e) }; }
262
262
  });
@@ -19,12 +19,25 @@ const https = require("https");
19
19
  const net = require("net");
20
20
  const { execFile } = require("child_process");
21
21
  const { spawn } = require("child_process");
22
- const { BrowserWindow } = require("electron");
22
+ const { BrowserWindow, nativeImage } = require("electron");
23
23
  // i18n for the default team name ("Unnamed"/"未命名"/…). Resolved at create
24
24
  // time from the app locale; falls back to "Unnamed" if i18n isn't ready.
25
- let __t;
26
- try { __t = require("../i18n").t; } catch { __t = null; }
25
+ let __t, __i18nMod;
26
+ try { __i18nMod = require("../i18n"); __t = __i18nMod.t; } catch { __t = null; }
27
27
  const unnamedName = () => { try { return (__t && __t("localTeams.unnamed")) || "Unnamed"; } catch { return "Unnamed"; } };
28
+
29
+ // 默认团队名(「本地团队」/「Local Team」/…)是注册时按当时语言写进 teams.json 的快照。
30
+ // 切换语言后它不会变 → 显示时若发现存的就是任一语言的默认名,改用**当前语言**的默认名;
31
+ // 用户改过的真实名字不匹配任何默认名 → 原样保留。
32
+ function localizedTeamName(stored) {
33
+ try {
34
+ if (!__i18nMod || !__i18nMod.i18next) return stored;
35
+ const cur = __t("localTeams.defaultName");
36
+ if (!stored) return cur;
37
+ const isDefault = (__i18nMod.SUPPORTED || []).some((l) => __i18nMod.i18next.t("localTeams.defaultName", { lng: l }) === stored);
38
+ return isDefault ? cur : stored;
39
+ } catch { return stored; }
40
+ }
28
41
  const log = require("electron-log");
29
42
 
30
43
  // global.json is now only read for the local sidecar's api_token (auto-fill).
@@ -33,6 +46,18 @@ const log = require("electron-log");
33
46
  // leak "Unnamed" ghosts into the team list). Shape: a flat { "<id>": {node} } map.
34
47
  const GLOBAL_JSON = path.join(os.homedir(), "cicy-ai", "global.json");
35
48
  const TEAMS_JSON = path.join(os.homedir(), "cicy-ai", "db", "teams.json");
49
+ // 通用头像映射 { <id>: dataUrl } —— 按 id 存,本地/Docker/云端团队通用(云端团队不在
50
+ // teams.json,只能用独立映射)。id 即各卡片用的 team id(本地 slug / 云端 cloud id)。
51
+ const AVATARS_JSON = path.join(os.homedir(), "cicy-ai", "db", "team-avatars.json");
52
+ function readAvatars() {
53
+ try { const o = JSON.parse(fs.readFileSync(AVATARS_JSON, "utf8")); return (o && typeof o === "object") ? o : {}; } catch { return {}; }
54
+ }
55
+ async function writeAvatars(map) {
56
+ const tmp = `${AVATARS_JSON}.tmp.${process.pid}.${Date.now()}`;
57
+ await fs.promises.mkdir(path.dirname(AVATARS_JSON), { recursive: true });
58
+ await fs.promises.writeFile(tmp, JSON.stringify(map, null, 2), { mode: 0o600 });
59
+ await fs.promises.rename(tmp, AVATARS_JSON);
60
+ }
36
61
  const HEALTH_TIMEOUT_MS = 1500;
37
62
  const PORT_TIMEOUT_MS = 700; // raw TCP-connect liveness for the LOCAL sidecar
38
63
  const CACHE_MS = 4000; // small dedupe so rapid renderer polls don't fan-out
@@ -180,6 +205,7 @@ async function probeLiveness(baseUrl, token) {
180
205
  async function list({ refresh = false } = {}) {
181
206
  if (!refresh && _cache && Date.now() < _cacheUntil) return _cache;
182
207
  const nodes = readNodes();
208
+ const avatars = readAvatars();
183
209
  const slugs = Object.keys(nodes);
184
210
  const teams = await Promise.all(slugs.map(async (slug) => {
185
211
  const node = nodes[slug] || {};
@@ -189,9 +215,10 @@ async function list({ refresh = false } = {}) {
189
215
  const live = await probeLiveness(baseUrl, node.api_token);
190
216
  return {
191
217
  id: slug,
192
- name: node.name || slug,
218
+ name: localizedTeamName(node.name) || slug,
193
219
  base_url: baseUrl,
194
220
  api_token: node.api_token || "",
221
+ avatar: avatars[slug] || node.avatar || "", // 自定义团队头像(data URL,≤64px);空=首字母兜底
195
222
  // Cloud-issued teamId (from name-sync register). The renderer maps it to
196
223
  // the team's sk-cicy- gateway apiKey (via /api/teams) for the 账单 link —
197
224
  // the local api_token is an MCP token the cloud can't bill on.
@@ -289,7 +316,8 @@ async function openTeam(id, opts = {}) {
289
316
  try {
290
317
  const tabBrowser = require("../tools/tab-browser-tools");
291
318
  // tab name = the team's title (not the cicy-code SPA's document.title)
292
- const r = await tabBrowser.openTab(0, url, { trusted: true, systemOpen: true, title: node.name || id });
319
+ // team=true + avatar:这是个团队 tab icon 用团队头像、禁用页面 favicon(见 tab-shell)
320
+ const r = await tabBrowser.openTab(0, url, { trusted: true, systemOpen: true, title: localizedTeamName(node.name) || id, team: true, avatar: readAvatars()[id] || "", colorKey: id });
293
321
  log.info(`[local-teams] open ${id} → tab in win.id=${r.winId} (reused=${r.reused})`);
294
322
  return { ok: true, windowId: r.winId, reused: !!r.reused, tabbed: true };
295
323
  } catch (e) {
@@ -951,4 +979,47 @@ async function upgradeTeam(id) {
951
979
  return result;
952
980
  }
953
981
 
954
- module.exports = { list, openTeam, reloadTeam, closeLocalWindows, addTeam, removeTeam, updateTeam, upgradeTeam, syncAllLocalTeams };
982
+ // Set (or clear) a team's avatar. The uploaded image is resized to ≤64px and
983
+ // stored as a data URL on the node — small enough for teams.json, and usable
984
+ // directly as <img src> in the team card AND the tab strip icon (no file://).
985
+ async function setAvatar(id, dataUrl) {
986
+ if (!id) return { ok: false, error: "no id" };
987
+ let stored = "";
988
+ if (dataUrl && /^data:image\//.test(dataUrl)) {
989
+ try {
990
+ let img = nativeImage.createFromDataURL(dataUrl);
991
+ if (!img.isEmpty()) {
992
+ const { width } = img.getSize();
993
+ if (width > 64) img = img.resize({ width: 64, height: 64, quality: "good" });
994
+ stored = img.toDataURL();
995
+ }
996
+ } catch (e) { log.warn(`[local-teams] setAvatar resize failed: ${e.message}`); }
997
+ }
998
+ const map = readAvatars();
999
+ if (stored) map[id] = stored; else delete map[id];
1000
+ await writeAvatars(map);
1001
+ _cacheUntil = 0;
1002
+ return { ok: true, avatar: stored };
1003
+ }
1004
+
1005
+ // Whole avatar map { id: dataUrl } — renderer fetches once and passes the right
1006
+ // avatar to EVERY card (local / Docker / cloud), all keyed by their team id.
1007
+ function getAvatars() { return readAvatars(); }
1008
+
1009
+ // Look up a team's avatar by URL (origin+pathname match against teams.json) — the
1010
+ // tab strip uses this to icon a LOCAL/Docker team tab. Cloud team tabs pass the
1011
+ // avatar through tabs.open() instead (they're not in teams.json). "" if none.
1012
+ function avatarForUrl(url) {
1013
+ try {
1014
+ const key = stripVolatile(url);
1015
+ const nodes = readNodes();
1016
+ const avatars = readAvatars();
1017
+ for (const id of Object.keys(nodes)) {
1018
+ const b = nodes[id].base_url || "";
1019
+ if (b && (stripVolatile(b) === key || key.startsWith(stripVolatile(b)))) return avatars[id] || "";
1020
+ }
1021
+ } catch {}
1022
+ return "";
1023
+ }
1024
+
1025
+ module.exports = { list, openTeam, reloadTeam, closeLocalWindows, addTeam, removeTeam, updateTeam, upgradeTeam, syncAllLocalTeams, setAvatar, getAvatars, avatarForUrl };
@@ -409,7 +409,8 @@
409
409
  "deleteMsg": "This will remove the private cloud team from the cloud, affecting all devices. Delete «{{name}}»?",
410
410
  "deleted": "Team deleted",
411
411
  "deleteFailed": "Delete failed",
412
- "editAddr": "Edit address"
412
+ "editAddr": "Edit address",
413
+ "changeAvatar": "Click to change avatar"
413
414
  },
414
415
  "updateDrawer": {
415
416
  "done": "Update complete",
@@ -538,5 +539,9 @@
538
539
  "eta": "{{s}}s left",
539
540
  "updateNow": "Update now",
540
541
  "modalSub": "We recommend updating to the latest version"
542
+ },
543
+ "tabShell": {
544
+ "myTeam": "My Team",
545
+ "newTabBtn": "New tab"
541
546
  }
542
547
  }
@@ -409,7 +409,8 @@
409
409
  "deleteMsg": "Cela supprimera l'équipe cloud privée du cloud, affectant tous les appareils. Supprimer «{{name}}»?",
410
410
  "deleted": "Équipe supprimée",
411
411
  "deleteFailed": "Échec de suppression",
412
- "editAddr": "Modifier l'adresse"
412
+ "editAddr": "Modifier l'adresse",
413
+ "changeAvatar": "Cliquer pour changer l'avatar"
413
414
  },
414
415
  "updateDrawer": {
415
416
  "done": "Update complete",
@@ -538,5 +539,9 @@
538
539
  "eta": "{{s}}s restant",
539
540
  "updateNow": "Mettre à jour",
540
541
  "modalSub": "Nous recommandons de mettre à jour vers la dernière version"
542
+ },
543
+ "tabShell": {
544
+ "myTeam": "Mon équipe",
545
+ "newTabBtn": "Nouvel onglet"
541
546
  }
542
547
  }
@@ -409,7 +409,8 @@
409
409
  "deleteMsg": "このプライベートクラウドチームをクラウドから削除し,全デバイスに影響します。«{{name}}» を削除しますか?",
410
410
  "deleted": "チームを削除しました",
411
411
  "deleteFailed": "削除失敗",
412
- "editAddr": "アドレスを変更"
412
+ "editAddr": "アドレスを変更",
413
+ "changeAvatar": "クリックでアバター変更"
413
414
  },
414
415
  "updateDrawer": {
415
416
  "done": "Update complete",
@@ -538,5 +539,9 @@
538
539
  "eta": "残り {{s}}秒",
539
540
  "updateNow": "今すぐ更新",
540
541
  "modalSub": "最新バージョンへの更新を推奨します"
542
+ },
543
+ "tabShell": {
544
+ "myTeam": "マイチーム",
545
+ "newTabBtn": "新しいタブ"
541
546
  }
542
547
  }
@@ -409,7 +409,8 @@
409
409
  "deleteMsg": "删除后该私有云团队将从云端移除,影响所有设备。确定删除「{{name}}」?",
410
410
  "deleted": "团队已删除",
411
411
  "deleteFailed": "删除失败",
412
- "editAddr": "更改地址"
412
+ "editAddr": "更改地址",
413
+ "changeAvatar": "点击更换头像"
413
414
  },
414
415
  "updateDrawer": {
415
416
  "done": "更新完成",
@@ -538,5 +539,9 @@
538
539
  "eta": "剩 {{s}}s",
539
540
  "updateNow": "立即更新",
540
541
  "modalSub": "建议尽快更新到最新版本"
542
+ },
543
+ "tabShell": {
544
+ "myTeam": "我的团队",
545
+ "newTabBtn": "新建标签"
541
546
  }
542
547
  }
package/src/main.js CHANGED
@@ -1127,6 +1127,12 @@ electronApp.whenReady().then(async () => {
1127
1127
  __ipcLT.handle("localTeams:add", (_e, spec) => lt.addTeam(spec || {}));
1128
1128
  __ipcLT.handle("localTeams:remove", (_e, id) => lt.removeTeam(id));
1129
1129
  __ipcLT.handle("localTeams:update", (_e, payload) => lt.updateTeam(payload?.id, payload?.patch || {}));
1130
+ __ipcLT.handle("localTeams:setAvatar", async (_e, payload) => {
1131
+ const r = await lt.setAvatar(payload?.id, payload?.dataUrl || "");
1132
+ try { require("./tools/tab-browser-tools").refreshTabAvatars(); } catch (e) {} // 即时刷新已开 tab 的 icon
1133
+ return r;
1134
+ });
1135
+ __ipcLT.handle("localTeams:avatars", () => lt.getAvatars());
1130
1136
  __ipcLT.handle("localTeams:upgrade", (_e, id) => lt.upgradeTeam(id));
1131
1137
  // Pull cloud title NOW (homepage calls this on window focus so a dash rename
1132
1138
  // reflects immediately instead of waiting for the 15s background tick).
@@ -4,7 +4,22 @@
4
4
  // main) — this preload is ONLY for the thin chrome UI, never the tab content.
5
5
  const { contextBridge, ipcRenderer } = require("electron");
6
6
 
7
+ // i18n: this preload runs in the renderer process, so require("../i18n") is a
8
+ // SEPARATE instance from main — init it with the main process's chosen locale
9
+ // (i18n:locale ipc), so window.tabAPI.t matches the rest of the app.
10
+ let __i18n = null;
11
+ try {
12
+ __i18n = require("../i18n");
13
+ let mainLng;
14
+ try { mainLng = ipcRenderer.sendSync("i18n:locale"); } catch (_) {}
15
+ __i18n.init(mainLng || undefined);
16
+ if (mainLng && __i18n.i18next.language !== __i18n.pickLocale(mainLng)) {
17
+ __i18n.i18next.changeLanguage(__i18n.pickLocale(mainLng));
18
+ }
19
+ } catch (e) { __i18n = null; }
20
+
7
21
  contextBridge.exposeInMainWorld("tabAPI", {
22
+ t: (key, fallback) => { try { return __i18n ? __i18n.t(key, { defaultValue: fallback }) : fallback; } catch (e) { return fallback; } },
8
23
  newTab: (url) => ipcRenderer.send("tabwin:new", { url: url || "" }),
9
24
  activate: (id) => ipcRenderer.send("tabwin:activate", { id }),
10
25
  close: (id) => ipcRenderer.send("tabwin:close", { id }),
@@ -38,12 +38,12 @@
38
38
  body.is-win.is-fullscreen #tabs{padding-right:8px}
39
39
  .tab,#newtab{-webkit-app-region:no-drag}
40
40
  #tabs::-webkit-scrollbar{height:0}
41
- .tab{position:relative;display:flex;align-items:center;gap:9px;height:32px;min-width:54px;max-width:240px;padding:0 12px;border-radius:10px 10px 0 0;font-size:12.5px;color:var(--muted);background:transparent;cursor:default;flex:1 1 0;transition:background .12s,color .12s}
41
+ .tab{position:relative;display:flex;align-items:center;gap:9px;height:32px;min-width:60px;max-width:240px;padding:0 16px;border-radius:10px 10px 0 0;font-size:12.5px;color:var(--muted);background:transparent;cursor:default;flex:1 1 0;transition:background .12s,color .12s}
42
42
  .tab:hover{background:rgba(255,255,255,.06);color:var(--fg)}
43
43
  /* home = the resident homepage tab: pinned first, fixed width, user icon, no
44
44
  title text, no close — like Chrome's profile/avatar button. */
45
45
  /* start-page tab: pinned, sizes to its "我的团队" label (not a flex-grow tab) */
46
- .tab.home{flex:0 0 auto;min-width:0;max-width:none;padding:0 12px 0 10px}
46
+ .tab.home{flex:0 0 auto;min-width:0;max-width:none;padding:0 24px 0 20px}
47
47
  .tab.home .ttl{flex:0 0 auto}
48
48
  .tab.home .fav{overflow:visible}
49
49
  /* CiCy brand mark — gradient rounded square + ✦, matches the start page logo */
@@ -151,9 +151,28 @@
151
151
  const w = document.createElement('span');
152
152
  w.className = 'fav';
153
153
  if (t.loading) { w.innerHTML = '<span class="spin"></span>'; return w; }
154
- if (t.favicon) {
154
+ // 团队 tab:icon 只用团队头像;没设头像就用「名字首字母 + 稳定色块」(和卡片一致),
155
+ // **禁用页面 favicon**(避免 cicy-code 的 favicon 盖掉团队身份)。
156
+ if (t.team) {
157
+ if (t.avatar) {
158
+ const img = document.createElement('img'); img.src = t.avatar; w.appendChild(img);
159
+ } else {
160
+ const name = (t.title || '').trim();
161
+ const ini = (name[0] || '?').toUpperCase();
162
+ // 底色种子 = teamId(colorKey)→ 和卡片同色、且每个 team 唯一(默认名都一样不会撞色)
163
+ const seed = t.colorKey || t.url || name;
164
+ let h = 0; for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) % 360;
165
+ const s = document.createElement('span');
166
+ s.style.cssText = 'display:flex;width:16px;height:16px;border-radius:4px;align-items:center;justify-content:center;background:hsl(' + h + ' 52% 46%);color:#fff;font-size:9px;font-weight:700;line-height:1';
167
+ s.textContent = ini; w.appendChild(s);
168
+ }
169
+ return w;
170
+ }
171
+ // 非团队 tab:页面 favicon → 否则地球。
172
+ const src = t.avatar || t.favicon;
173
+ if (src) {
155
174
  const img = document.createElement('img');
156
- img.src = t.favicon;
175
+ img.src = src;
157
176
  img.onerror = () => { w.innerHTML = ICON_GLOBE; };
158
177
  w.appendChild(img);
159
178
  } else { w.innerHTML = ICON_GLOBE; }
@@ -188,7 +207,7 @@
188
207
  try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(id)); } catch (_) {}
189
208
  d.classList.add('dragging');
190
209
  });
191
- d.addEventListener('dragend', () => { dragId = null; clearDropMarker(); render(); });
210
+ d.addEventListener('dragend', () => { dragId = null; clearDropMarker(); lastTabsSig = null; render(); });
192
211
  d.addEventListener('dragover', (e) => {
193
212
  if (dragId == null || dragId === id) return;
194
213
  e.preventDefault();
@@ -205,14 +224,22 @@
205
224
  });
206
225
  }
207
226
 
227
+ const T = (k, f) => { try { return window.tabAPI.t(k, f); } catch (e) { return f; } };
228
+ let lastTabsSig = null;
208
229
  function render() {
230
+ // 只在 tab 条「显示内容」(id/title/active/loading/favicon/home)变化时才重建。
231
+ // 跳过 pushState 突发(SPA in-page 导航 / 重复标题推送)带来的冗余重建——那正是
232
+ // tab 一直闪 + 点击丢失(mousedown 到 click 之间 DOM 被清空,要点很多次)的根因。
233
+ const sig = JSON.stringify(state.tabs.map((t) => [t.id, t.home ? 1 : 0, t.active ? 1 : 0, t.loading ? 1 : 0, t.team ? 1 : 0, t.favicon || '', t.avatar || '', t.home ? '' : (t.title || '')]));
234
+ if (sig !== lastTabsSig) {
235
+ lastTabsSig = sig;
209
236
  tabsEl.innerHTML = '';
210
237
  const hasHome = state.tabs.some((x) => x.home);
211
238
  const nonHome = state.tabs.filter((x) => !x.home).length;
212
239
  state.tabs.forEach((t) => {
213
240
  const d = document.createElement('div');
214
241
  d.className = 'tab' + (t.home ? ' home' : '') + (t.active ? ' active' : '');
215
- d.title = t.home ? '我的团队' : (t.url || '');
242
+ d.title = t.home ? T('tabShell.myTeam', '我的团队') : (t.url || '');
216
243
  d.onclick = () => window.tabAPI.activate(t.id);
217
244
  // resident start-page tab: pinned first, user icon only, no title, no close
218
245
  if (t.home) {
@@ -220,7 +247,7 @@
220
247
  ic.className = 'fav'; ic.innerHTML = CICY_LOGO;
221
248
  d.appendChild(ic);
222
249
  const ttl = document.createElement('span');
223
- ttl.className = 'ttl'; ttl.textContent = '我的团队';
250
+ ttl.className = 'ttl'; ttl.textContent = T('tabShell.myTeam', '我的团队');
224
251
  d.appendChild(ttl);
225
252
  tabsEl.appendChild(d);
226
253
  return;
@@ -243,11 +270,12 @@
243
270
  });
244
271
  if (!NO_NEW) {
245
272
  const add = document.createElement('div');
246
- add.id = 'newtab'; add.title = '新建标签';
273
+ add.id = 'newtab'; add.title = T('tabShell.newTabBtn', '新建标签');
247
274
  add.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
248
275
  add.onclick = () => window.tabAPI.newTab();
249
276
  tabsEl.appendChild(add);
250
277
  }
278
+ } // end tab-strip rebuild (skipped when sig unchanged → no flicker)
251
279
 
252
280
  const nav = state.nav || {};
253
281
  backEl.classList.toggle('off', !nav.canBack);
@@ -101,7 +101,11 @@ class TabManager {
101
101
  // global bar). Without this, Windows draws the File/Edit/View row where the
102
102
  // titlebar was, so the strip never reaches the top edge. Alt still reveals it.
103
103
  autoHideMenuBar: true,
104
- webPreferences: { preload: SHELL_PRELOAD, contextIsolation: true, nodeIntegration: false },
104
+ // sandbox:false —— shell 是我们自己的可信 chrome(标签条/工具栏),它的 preload
105
+ // 需要 require("../i18n")(fs/path/i18next)。不显式关沙箱的话现代 Electron 默认
106
+ // 开沙箱,require 会抛错 → tab-shell-preload 的 __i18n=null → tabAPI.t 永远只回
107
+ // fallback(实测「我的团队/新建标签」不走 i18n 的根因)。与 homepage 窗口一致。
108
+ webPreferences: { preload: SHELL_PRELOAD, contextIsolation: true, nodeIntegration: false, sandbox: false },
105
109
  };
106
110
  try { winOpts.icon = require("../utils/app-icon").appIconPath(); } catch (e) {}
107
111
  // 新开的非 0 profile 窗口相对原 profile(优先 profile 0)做级联偏移,避免完全压在
@@ -161,6 +165,9 @@ class TabManager {
161
165
  active: t.id === this.activeId,
162
166
  loading: !!t.loading,
163
167
  favicon: t.favicon || "",
168
+ avatar: t.avatar || "", // team 自定义头像 → tab icon 用它(优先于页面 favicon)
169
+ team: !!t.team, // 团队 tab:icon 只用 avatar,禁用页面 favicon
170
+ colorKey: t.colorKey || "", // 无头像时首字母色块的底色种子 = teamId(和卡片一致)
164
171
  home: !!t.home,
165
172
  })),
166
173
  nav: {
@@ -218,7 +225,9 @@ class TabManager {
218
225
  // home = the resident homepage tab (pinned, first, user-icon, no close).
219
226
  // fixedTitle = a caller-supplied tab name (e.g. the team title) that the
220
227
  // page's own document.title must NOT override.
221
- const tab = { id, view, title: "", url: target, home: !!opts.home, fixedTitle: opts.title || "" };
228
+ const tab = { id, view, title: "", url: target, home: !!opts.home, fixedTitle: opts.title || "", avatar: opts.avatar || "", team: !!opts.team, colorKey: opts.colorKey || "" };
229
+ // team tab 用团队自定义头像做 icon:调用方没给就按 URL 反查 teams.json(覆盖所有打开路径)。
230
+ if (!tab.avatar && !opts.home) { try { tab.avatar = require("../backends/local-teams").avatarForUrl(target) || ""; } catch (e) {} }
222
231
  if (opts.home) this.tabs.unshift(tab); else this.tabs.push(tab);
223
232
  wc.on("page-title-updated", (_e, title) => { if (!tab.fixedTitle) { tab.title = title; this.pushState(); } });
224
233
  wc.on("page-favicon-updated", (_e, favs) => { tab.favicon = (favs && favs[0]) || ""; this.pushState(); });
@@ -356,7 +365,7 @@ function findManagerByTab(webContentsId) {
356
365
  // button / electron_tab_open / the panel can add tabs to profile 0 too.
357
366
  async function openTab(accountIdx, url, opts = {}) {
358
367
  const m = ensureManager(accountIdx);
359
- const id = m.addTab(url, { trusted: !!opts.trusted, home: !!opts.home, title: opts.title || "", navigate: !!opts.navigate });
368
+ const id = m.addTab(url, { trusted: !!opts.trusted, home: !!opts.home, title: opts.title || "", navigate: !!opts.navigate, avatar: opts.avatar || "", team: !!opts.team, colorKey: opts.colorKey || "" });
360
369
  try { m.win.show(); m.win.focus(); } catch (e) {}
361
370
  // 记下这个团队 tab 的 webContentsId(打开 → set;关闭/销毁 → delete)。
362
371
  try {
@@ -639,6 +648,21 @@ function activateTabIfOpen(accountIdx, url) {
639
648
  return { ok: false, active: false };
640
649
  }
641
650
 
651
+ // 设头像后即时刷新已打开 tab 的 icon(否则要重开 tab 才变)。按 URL 反查 teams.json
652
+ // 的头像更新 tab.avatar 并 pushState。云端 tab(不在 teams.json)不在此列,需重开。
653
+ function refreshTabAvatars() {
654
+ let lt; try { lt = require("../backends/local-teams"); } catch (e) { return; }
655
+ for (const m of managers.values()) {
656
+ let changed = false;
657
+ for (const t of m.tabs) {
658
+ if (t.home) continue;
659
+ const a = lt.avatarForUrl(t.url) || "";
660
+ if (a !== (t.avatar || "")) { t.avatar = a; changed = true; }
661
+ }
662
+ if (changed) try { m.pushState(); } catch (e) {}
663
+ }
664
+ }
665
+ registerTabBrowserTools.refreshTabAvatars = refreshTabAvatars;
642
666
  registerTabBrowserTools.openTab = openTab;
643
667
  registerTabBrowserTools.reloadTabByUrl = reloadTabByUrl;
644
668
  registerTabBrowserTools.reloadTabIfOpen = reloadTabIfOpen;
@@ -363,6 +363,12 @@ export default function App() {
363
363
  // Used to distinguish "not yet probed" (unknown) from "probed and empty"
364
364
  // (cloud-only) in localHelperState below.
365
365
  const [localTeamsFetched, setLocalTeamsFetched] = useState(false);
366
+ // 通用头像映射 { id: dataUrl } —— 本地/Docker/云端团队都按 id 取头像(云端团队不在
367
+ // teams.json,所以单独拉一份映射,给所有卡片 + 打开 tab 时透传 avatar 用)。
368
+ const [avatars, setAvatars] = useState({});
369
+ const fetchAvatars = useCallback(async () => {
370
+ try { const m = await window.cicy?.localTeams?.avatars?.(); setAvatars(m && typeof m === "object" ? m : {}); } catch {}
371
+ }, []);
366
372
  // 「新加团队」下拉 + 自定义团队 modal: 点按钮出两个选项——自定义(本 modal 输 url/title)
367
373
  // 或私有云(跳云端团队中心)。自定义走 localTeams.add({base_url,name,api_token})。
368
374
  const [addMenuOpen, setAddMenuOpen] = useState(false);
@@ -575,7 +581,7 @@ export default function App() {
575
581
  // host_url/名字/状态的同步)。三件事并行。
576
582
  const reconcile = async () => {
577
583
  try { await window.cicy?.localTeams?.syncCloud?.(); } catch {}
578
- await Promise.all([fetchLocalTeams(), refreshCloudTeams()]);
584
+ await Promise.all([fetchLocalTeams(), refreshCloudTeams(), fetchAvatars()]);
579
585
  };
580
586
 
581
587
  const schedule = () => {
@@ -1073,8 +1079,10 @@ export default function App() {
1073
1079
  // Open as a TAB in the current profile (like the local card), NOT
1074
1080
  // the system browser.
1075
1081
  const url = t.kind === "private" ? t.host_url : (t.workspace_url || t.workspace_direct_url);
1076
- if (url) window.cicy?.tabs?.open?.(url, t.name || t.title || "");
1082
+ if (url) window.cicy?.tabs?.open?.(url, t.name || t.title || "", avatars[t.id] || "", true, t.id);
1077
1083
  }}
1084
+ avatar={avatars[t.id] || ""}
1085
+ onAvatar={fetchAvatars}
1078
1086
  onRename={renameCloudTeam}
1079
1087
  onEditUrl={updateCloudTeamUrl}
1080
1088
  onDelete={deleteCloudTeam}
@@ -2425,7 +2433,8 @@ function DockerCard({ dockerTeam, cloudTitle, cloudCode, onOpen, onRename, onRef
2425
2433
  <div className="bcard__body">
2426
2434
  {/* 8008 现在有独立云端 team(cloud_team_id 是它自己的,不再和 8008 串),所以
2427
2435
  标题可改名:本地节点名 + 云端 PATCH 双写(onRename 在父组件处理)。 */}
2428
- <div style={{ height: 28, display: "flex", alignItems: "center" }}>
2436
+ <div style={{ height: 28, display: "flex", alignItems: "center", gap: 8 }}>
2437
+ <TeamAvatar size={24} avatar={dockerTeam?.avatar} name={displayName} teamId={dockerTeam?.id} onChanged={onRefresh} />
2429
2438
  {editing ? (
2430
2439
  <input
2431
2440
  data-id="DockerCard-rename-input"
@@ -2938,7 +2947,8 @@ function LocalTeamCard({ team, cloudCode, onOpen, onRename, onRefresh }) {
2938
2947
  </div>
2939
2948
  <div className="bcard__body">
2940
2949
  {/* 固定高度容器:h3 与 input 同高,切换不引起卡片位移 */}
2941
- <div style={{ height: 28, display: "flex", alignItems: "center" }}>
2950
+ <div style={{ height: 28, display: "flex", alignItems: "center", gap: 8 }}>
2951
+ <TeamAvatar size={24} avatar={team?.avatar} name={team?.name} teamId={team?.id} onChanged={onRefresh} />
2942
2952
  {editing ? (
2943
2953
  <input
2944
2954
  data-id="LocalTeamCard-rename-input"
@@ -3095,7 +3105,7 @@ function ConfirmModal({ open, title, message, confirmLabel, danger, onConfirm, o
3095
3105
  // 私有云 / (历史)云端团队卡片。产品方向变更(w-10032):公有云不做了,主打 private
3096
3106
  // (用户自托管,数据不出企业)。private 字段:{name,kind:"private",status,apiKey,
3097
3107
  // gatewayUrl,host_url,titleVersion,deviceId:""}。卡片展示名字+host_url,点开可看/复制 apiKey。
3098
- function TeamCard({ team, onOpen, onRename, onEditUrl, onDelete }) {
3108
+ function TeamCard({ team, onOpen, onRename, onEditUrl, onDelete, avatar, onAvatar }) {
3099
3109
  const isPrivate = team.kind === "private";
3100
3110
  const statusOk = team.status === "active";
3101
3111
  const serverName = team.name || team.title || "—";
@@ -3228,7 +3238,8 @@ function TeamCard({ team, onOpen, onRename, onEditUrl, onDelete }) {
3228
3238
  </div>
3229
3239
  <div className="bcard__body">
3230
3240
  {/* 固定高度容器:h3 与 input 同高,切换不引起位移 */}
3231
- <div style={{ height: 28, display: "flex", alignItems: "center" }}>
3241
+ <div style={{ height: 28, display: "flex", alignItems: "center", gap: 8 }}>
3242
+ <TeamAvatar size={24} avatar={avatar} name={team?.name || team?.title} teamId={team?.id} onChanged={onAvatar} />
3232
3243
  {editing ? (
3233
3244
  <input
3234
3245
  data-id="TeamCard-rename-input"
@@ -3388,6 +3399,33 @@ function SkeletonCard() {
3388
3399
  </div>
3389
3400
  );
3390
3401
  }
3402
+ // 团队头像:有自定义图(data URL)就显示图,否则「团队名首字母 + 按名 hash 的稳定底色」
3403
+ // 圆角块。teamId 存在时点击可上传(resize 在主进程做,见 local-teams.setAvatar)。
3404
+ // 同一份用于卡片头像 + tab icon(tab 那边在 tab-shell.html faviconNode 用 t.avatar)。
3405
+ function hashHue(s) { let h = 0; for (let i = 0; i < (s || "").length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }
3406
+ function TeamAvatar({ avatar, name, teamId, onChanged, size = 34 }) {
3407
+ const fileRef = useRef(null);
3408
+ const initial = ((name || "?").trim()[0] || "?").toUpperCase();
3409
+ // 底色按**唯一的 teamId** 算(默认名都本地化成同一个「Local team」,按名字会同色)。
3410
+ const hue = hashHue(teamId || name || "");
3411
+ const pick = (e) => {
3412
+ const f = e.target.files && e.target.files[0];
3413
+ e.target.value = "";
3414
+ if (!f) return;
3415
+ const r = new FileReader();
3416
+ r.onload = async () => { try { await window.cicy?.localTeams?.setAvatar?.(teamId, r.result); onChanged && onChanged(); } catch (_) {} };
3417
+ r.readAsDataURL(f);
3418
+ };
3419
+ return (
3420
+ <div data-id="TeamAvatar" className="team-avatar"
3421
+ title={teamId ? tr("teamCard.changeAvatar", "点击更换头像") : ""}
3422
+ onClick={teamId ? (e) => { e.stopPropagation(); fileRef.current && fileRef.current.click(); } : undefined}
3423
+ style={{ width: size, height: size, borderRadius: 9, flex: "none", overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center", cursor: teamId ? "pointer" : "default", background: avatar ? "#0d1117" : `hsl(${hue} 52% 46%)`, color: "#fff", fontWeight: 700, fontSize: Math.round(size * 0.42), userSelect: "none" }}>
3424
+ {avatar ? <img src={avatar} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : initial}
3425
+ {teamId && <input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={pick} />}
3426
+ </div>
3427
+ );
3428
+ }
3391
3429
  function BrandGlyph() {
3392
3430
  // New CiCy mark (六芒星). Rendered white here because it sits on the brand
3393
3431
  // chip's blue→violet gradient square; the full-color gradient version is the