新增 pc-client:畅联 PC 端工程(Electron+React,按确认效果图改造,B-59)

- 基于 openim-electron-demo 改造:工号+密码登录(自研账号服务)、会话列表/聊天窗/通讯录界面重做
- 文字/语音/文件/图片消息、文件拖拽发送、表情面板、一对一语音通话(LiveKit)
- RTC 令牌对接账号服务带鉴权版 /api/rtc_token(Bearer imToken + {room,identity})
- account-service: CORS Allow-Headers 补 authorization(浏览器/渲染进程跨域调 rtc_token 需要)
- 附本地 mock 账号服务(scripts/mock-account-server.js)与截图联调脚本
This commit is contained in:
KIMI
2026-08-09 09:11:06 +08:00
parent b95159f7eb
commit 7ce40e356f
284 changed files with 31599 additions and 7 deletions
+93
View File
@@ -0,0 +1,93 @@
import { app, powerMonitor } from "electron";
import { isExistMainWindow, sendEvent, showWindow } from "./windowManage";
import { join } from "node:path";
import fs from "fs";
import { isMac, isProd, isWin } from "../utils";
import { getStore } from "./storeManage";
import { IpcMainToRender } from "../constants";
import { logger } from ".";
const store = getStore();
export const setSingleInstance = () => {
if (!app.requestSingleInstanceLock()) {
app.quit();
process.exit(0);
}
app.on("second-instance", () => {
showWindow();
});
};
export const setAppListener = (startApp: () => void) => {
app.on("activate", () => {
if (isExistMainWindow()) {
showWindow();
} else {
startApp();
}
});
app.on("window-all-closed", () => {
if (isMac && !getIsForceQuit()) return;
app.quit();
});
powerMonitor.on("suspend", () => {
logger.debug("app suspend");
});
powerMonitor.on("resume", () => {
logger.debug("app resume");
sendEvent(IpcMainToRender.appResume);
});
};
export const setAppGlobalData = () => {
const electronDistPath = join(__dirname, "../");
const distPath = join(electronDistPath, "../dist");
const publicPath = isProd ? distPath : join(electronDistPath, "../public");
const asarPath = process.resourcesPath;
global.pathConfig = {
electronDistPath,
distPath,
publicPath,
asarPath,
logsPath: join(app.getPath("userData"), `/OpenIMData/logs`),
sdkResourcesPath: join(app.getPath("userData"), `/OpenIMData/sdkResources`),
imsdkLibPath: isProd
? join(
asarPath,
"/app.asar.unpacked/node_modules/@openim/electron-client-sdk/assets",
)
: join(__dirname, "../../node_modules/@openim/electron-client-sdk/assets"),
trayIcon: join(publicPath, `/icons/${isWin ? "icon.ico" : "tray.png"}`),
emptyTrayIcon: join(publicPath, `/icons/${"empty_tray.png"}`),
indexHtml: join(distPath, "index.html"),
splashHtml: join(distPath, "splash.html"),
preload: join(__dirname, "../preload/index.js"),
};
if (isProd) {
fs.promises
.readdir(global.pathConfig.logsPath)
.catch(
(err) =>
err.code === "ENOENT" &&
fs.promises.mkdir(global.pathConfig.logsPath, { recursive: true }),
);
fs.promises
.readdir(global.pathConfig.sdkResourcesPath)
.catch(
(err) =>
err.code === "ENOENT" &&
fs.promises.mkdir(global.pathConfig.sdkResourcesPath, { recursive: true }),
);
}
};
export const getIsForceQuit = () =>
store.get("closeAction") === "quit" || global.forceQuit;
+28
View File
@@ -0,0 +1,28 @@
import { app } from "electron";
import { join } from "node:path";
import { createMainWindow } from "./windowManage";
import { createTray } from "./trayManage";
import { setIpcMainListener } from "./ipcHandlerManage";
import { setAppGlobalData, setAppListener, setSingleInstance } from "./appManage";
import createAppMenu from "./menuManage";
import { isLinux } from "../utils";
import { getLogger } from "../utils/log";
import { initI18n } from "../i18n";
export const logger = getLogger(join(app.getPath("userData"), `/OpenIMData/logs`));
const init = () => {
initI18n();
createMainWindow();
createAppMenu();
createTray();
};
setAppGlobalData();
setIpcMainListener();
setSingleInstance();
setAppListener(init);
app.whenReady().then(() => {
isLinux ? setTimeout(init, 300) : init();
});
+102
View File
@@ -0,0 +1,102 @@
import { BrowserWindow, Menu, app, dialog, ipcMain } from "electron";
import {
clearCache,
closeWindow,
minimize,
showWindow,
splashEnd,
updateMaximize,
} from "./windowManage";
import { t } from "i18next";
import { IpcRenderToMain } from "../constants";
import { getStore } from "./storeManage";
import { changeLanguage } from "../i18n";
const store = getStore();
export const setIpcMainListener = () => {
ipcMain.handle(IpcRenderToMain.clearSession, () => {
clearCache();
});
// window manage
ipcMain.handle("changeLanguage", (_, locale) => {
store.set("language", locale);
changeLanguage(locale).then(() => {
app.relaunch();
app.exit(0);
});
});
ipcMain.handle("main-win-ready", () => {
splashEnd();
});
ipcMain.handle(IpcRenderToMain.showMainWindow, () => {
showWindow();
});
ipcMain.handle(IpcRenderToMain.minimizeWindow, () => {
minimize();
});
ipcMain.handle(IpcRenderToMain.maxmizeWindow, () => {
updateMaximize();
});
ipcMain.handle(IpcRenderToMain.closeWindow, () => {
closeWindow();
});
ipcMain.handle(IpcRenderToMain.showMessageBox, (_, options) => {
return dialog
.showMessageBox(BrowserWindow.getFocusedWindow(), options)
.then((res) => res.response);
});
// data transfer
ipcMain.handle(IpcRenderToMain.setKeyStore, (_, { key, data }) => {
store.set(key, data);
});
ipcMain.handle(IpcRenderToMain.getKeyStore, (_, { key }) => {
return store.get(key);
});
ipcMain.on(IpcRenderToMain.getKeyStoreSync, (e, { key }) => {
e.returnValue = store.get(key);
});
ipcMain.handle(IpcRenderToMain.showInputContextMenu, () => {
const menu = Menu.buildFromTemplate([
{
label: t("system.copy"),
type: "normal",
role: "copy",
accelerator: "CommandOrControl+c",
},
{
label: t("system.paste"),
type: "normal",
role: "paste",
accelerator: "CommandOrControl+v",
},
{
label: t("system.selectAll"),
type: "normal",
role: "selectAll",
accelerator: "CommandOrControl+a",
},
]);
menu.popup({
window: BrowserWindow.getFocusedWindow()!,
});
});
ipcMain.on(IpcRenderToMain.getDataPath, (e, key: string) => {
switch (key) {
case "public":
e.returnValue = global.pathConfig.publicPath;
break;
case "sdkResources":
e.returnValue = global.pathConfig.sdkResourcesPath;
break;
case "logsPath":
e.returnValue = global.pathConfig.logsPath;
break;
default:
e.returnValue = global.pathConfig.publicPath;
break;
}
});
};
+55
View File
@@ -0,0 +1,55 @@
import { app, Menu } from "electron";
import { t } from "i18next";
import { isMac } from "../utils";
const createAppMenu = () => {
if (isMac) {
const template: Electron.MenuItemConstructorOptions[] = [
{
label: app.getName(),
submenu: [
{ label: t("system.about"), role: "about" },
{ type: "separator" },
{ label: t("system.hide"), role: "hide" },
{ type: "separator" },
{
label: t("system.quit"),
click: () => {
global.forceQuit = true;
app.quit();
},
},
],
},
{
label: t("system.fastKeys"),
submenu: [
{ label: t("system.copy"), role: "copy", accelerator: "CmdOrCtrl+C" },
{ label: t("system.paste"), role: "paste", accelerator: "CmdOrCtrl+V" },
{ label: t("system.cut"), role: "cut", accelerator: "CmdOrCtrl+X" },
{ label: t("system.undo"), role: "undo", accelerator: "CmdOrCtrl+Z" },
{ label: t("system.redo"), role: "redo", accelerator: "CmdOrCtrl+Y" },
{
label: t("system.selectAll"),
role: "selectAll",
accelerator: "CmdOrCtrl+A",
},
],
},
{
label: t("system.window"),
role: "window",
submenu: [
{ label: t("system.minimize"), role: "minimize", accelerator: "CmdOrCtrl+W" },
{ label: t("system.close"), role: "close" },
],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
} else {
Menu.setApplicationMenu(null);
}
};
export default createAppMenu;
+10
View File
@@ -0,0 +1,10 @@
import { globalShortcut } from "electron";
import { toggleDevTools } from "./windowManage";
export const registerShortcuts = () => {
globalShortcut.register("CmdOrCtrl+F12", toggleDevTools);
};
export const unregisterShortcuts = () => {
globalShortcut.unregisterAll();
};
+12
View File
@@ -0,0 +1,12 @@
import Store from "electron-store";
let store: Store;
export const getStore = () => {
if (!store) {
store = new Store();
}
return store;
};
export { Store };
+41
View File
@@ -0,0 +1,41 @@
import { app, Menu, Tray } from "electron";
import { t } from "i18next";
import { hideWindow, showWindow } from "./windowManage";
let appTray: Tray;
export const createTray = () => {
const trayMenu = Menu.buildFromTemplate([
{
label: t("system.showWindow"),
click: showWindow,
},
{
label: t("system.hideWindow"),
click: hideWindow,
},
{
label: t("system.toggleDevTools"),
role: "toggleDevTools",
},
{
label: t("system.quit"),
click: () => {
global.forceQuit = true;
app.quit();
},
},
]);
appTray = new Tray(global.pathConfig.trayIcon);
appTray.setToolTip(app.getName());
appTray.setIgnoreDoubleClickEvents(true);
appTray.on("click", showWindow);
appTray.setContextMenu(trayMenu);
};
export const destroyTray = () => {
if (!appTray || appTray.isDestroyed()) return;
appTray.destroy();
appTray = null;
};
+259
View File
@@ -0,0 +1,259 @@
import { join } from "node:path";
import { BrowserWindow, dialog, shell } from "electron";
import { isLinux, isMac, isWin } from "../utils";
import { destroyTray } from "./trayManage";
import { getIsForceQuit } from "./appManage";
import { registerShortcuts, unregisterShortcuts } from "./shortcutManage";
import { initIMSDK } from "../utils/imsdk";
import OpenIMSDKMain from "@openim/electron-client-sdk";
const url = process.env.VITE_DEV_SERVER_URL;
let mainWindow: BrowserWindow | null = null;
let splashWindow: BrowserWindow | null = null;
let sdkInstance: OpenIMSDKMain | null = null;
function createSplashWindow() {
splashWindow = new BrowserWindow({
frame: false,
width: 200,
height: 200,
resizable: false,
transparent: true,
});
splashWindow.loadFile(global.pathConfig.splashHtml);
splashWindow.on("closed", () => {
splashWindow = null;
});
}
export function createMainWindow() {
createSplashWindow();
mainWindow = new BrowserWindow({
title: "畅联",
icon: join(global.pathConfig.publicPath, "favicon.ico"),
frame: false,
show: false,
width: 1200,
height: 800,
minWidth: 1024,
minHeight: 726,
titleBarStyle: "hiddenInset",
webPreferences: {
preload: global.pathConfig.preload,
// Warning: Enable nodeIntegration and disable contextIsolation is not secure in production
// Consider using contextBridge.exposeInMainWorld
// Read more on https://www.electronjs.org/docs/latest/tutorial/context-isolation
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
devTools: true,
webSecurity: false,
},
});
sdkInstance = initIMSDK(mainWindow.webContents);
if (process.env.VITE_DEV_SERVER_URL) {
// Open devTool if the app is not packaged
mainWindow.loadURL(url);
} else {
mainWindow.loadFile(global.pathConfig.indexHtml);
}
// Test actively push message to the Electron-Renderer
mainWindow.webContents.on("did-finish-load", () => {
mainWindow?.webContents.send("main-process-message", new Date().toLocaleString());
});
// // Make all links open with the browser, not with the application
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith("https:") || url.startsWith("http:")) shell.openExternal(url);
return { action: "deny" };
});
mainWindow.on("focus", () => {
mainWindow?.flashFrame(false);
registerShortcuts();
});
mainWindow.on("blur", () => {
unregisterShortcuts();
});
mainWindow.on("close", (e) => {
if (getIsForceQuit() || !mainWindow.isVisible()) {
mainWindow = null;
destroyTray();
} else {
e.preventDefault();
if (isMac && mainWindow.isFullScreen()) {
mainWindow.setFullScreen(false);
}
mainWindow?.hide();
}
});
return mainWindow;
}
export function splashEnd() {
splashWindow?.close();
mainWindow?.show();
}
// utils
export const isExistMainWindow = (): boolean =>
!!mainWindow && !mainWindow?.isDestroyed();
export const isShowMainWindow = (): boolean => {
if (!mainWindow) return false;
return mainWindow.isVisible() && (isWin ? true : mainWindow.isFocused());
};
export const closeWindow = () => {
if (!mainWindow) return;
mainWindow.close();
};
export const hotReload = () => {
if (!mainWindow) return;
mainWindow.reload();
};
export const sendEvent = (name: string, ...args: any[]) => {
if (!mainWindow) return;
mainWindow.webContents.send(name, ...args);
};
export const showSelectDialog = async (options: Electron.OpenDialogOptions) => {
if (!mainWindow) throw new Error("main window is undefined");
return await dialog.showOpenDialog(mainWindow, options);
};
export const showDialog = ({
type,
message,
detail,
}: Electron.MessageBoxSyncOptions) => {
if (!mainWindow) return;
dialog.showMessageBoxSync(mainWindow, {
type,
message,
detail,
});
};
export const showSaveDialog = async (options: Electron.SaveDialogOptions) => {
if (!mainWindow) throw new Error("main window is undefined");
return await dialog.showSaveDialog(mainWindow, options);
};
export const minimize = () => {
if (!mainWindow) return;
mainWindow.minimize();
};
export const updateMaximize = () => {
if (!mainWindow) return;
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
};
export const toggleHide = () => {
if (!mainWindow) return;
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
};
export const toggleMinimize = () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) {
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.restore();
mainWindow.focus();
} else {
mainWindow.minimize();
}
};
export const showWindow = () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (mainWindow.isVisible()) {
mainWindow.focus();
} else {
mainWindow.show();
}
};
export const hideWindow = () => {
if (!mainWindow) return;
mainWindow.hide();
};
export const toggleWindowVisible = (visible: boolean) => {
if (!mainWindow) return;
const opacity = mainWindow.getOpacity() ? 0 : 1;
if (Boolean(opacity) !== visible) return;
mainWindow.setOpacity(opacity);
};
export const setProgressBar = (
progress: number,
options?: Electron.ProgressBarOptions,
) => {
if (!mainWindow) return;
mainWindow.setProgressBar(progress, options);
};
export const taskFlicker = () => {
if (
isMac ||
(mainWindow.isVisible() && mainWindow.isFocused() && !isExistMainWindow())
)
return;
mainWindow?.flashFrame(true);
};
export const setIgnoreMouseEvents = (
ignore: boolean,
options?: Electron.IgnoreMouseEventsOptions,
) => {
if (!mainWindow) return;
mainWindow.setIgnoreMouseEvents(ignore, options);
};
export const toggleDevTools = () => {
if (!mainWindow) return;
if (mainWindow.webContents.isDevToolsOpened()) {
mainWindow.webContents.closeDevTools();
} else {
mainWindow.webContents.openDevTools({
mode: "detach",
});
}
};
export const setFullScreen = (isFullscreen: boolean): boolean => {
if (!mainWindow) return false;
if (isLinux) {
// linux It needs to be resizable before it can be full screen
if (isFullscreen) {
mainWindow.setResizable(isFullscreen);
mainWindow.setFullScreen(isFullscreen);
} else {
mainWindow.setFullScreen(isFullscreen);
mainWindow.setResizable(isFullscreen);
}
} else {
mainWindow.setFullScreen(isFullscreen);
}
return isFullscreen;
};
export const clearCache = async () => {
if (!mainWindow) throw new Error("main window is undefined");
await mainWindow.webContents.session.clearCache();
await mainWindow.webContents.session.clearStorageData();
};
export const getCacheSize = async () => {
if (!mainWindow) throw new Error("main window is undefined");
return await mainWindow.webContents.session.getCacheSize();
};
export const getWebContents = (): Electron.WebContents => {
if (!mainWindow) throw new Error("main window is undefined");
return mainWindow.webContents;
};