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.
- package/package.json +6 -6
- package/src/backends/homepage-preload.js +3 -1
- package/src/backends/homepage-react/assets/index-BVmu-4t_.js +377 -0
- package/src/backends/homepage-react/index.html +1 -1
- package/src/backends/ipc.js +2 -2
- package/src/backends/local-teams.js +77 -6
- package/src/i18n/locales/en.json +6 -1
- package/src/i18n/locales/fr.json +6 -1
- package/src/i18n/locales/ja.json +6 -1
- package/src/i18n/locales/zh-CN.json +6 -1
- package/src/main.js +6 -0
- package/src/tabbrowser/tab-shell-preload.js +15 -0
- package/src/tabbrowser/tab-shell.html +36 -8
- package/src/tools/tab-browser-tools.js +27 -3
- package/workers/render/src/App.jsx +44 -6
- package/src/backends/homepage-react/assets/index-DXv0ScMw.js +0 -377
|
@@ -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-
|
|
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>
|
package/src/backends/ipc.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/src/i18n/locales/en.json
CHANGED
|
@@ -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
|
}
|
package/src/i18n/locales/fr.json
CHANGED
|
@@ -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
|
}
|
package/src/i18n/locales/ja.json
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|