Compare commits

..

10 Commits

12 changed files with 360 additions and 83 deletions

113
README.md Normal file
View File

@ -0,0 +1,113 @@
# EIAC Desktop Application
一个基于 Electron 框架的跨平台桌面应用程序,用于访问和管理企业内部应用系统。
## 概述
EIAC Desktop Application 是一个基于 Electron 框架的桌面应用程序。
主要功能包括:
- 🏢 统一的企业应用访问入口
- 📑 多标签页管理
- 🔍 自定义页面缩放
- 🔐 统一的用户认证
- 🐛 故障上报功能
- 🖥️ 跨平台支持Windows、macOS、Linux
本项目使用 `npx create-electron-app@latest china-telecom-app --template=vite-typescript` 创建。
## 开发
### 环境要求
- Node.js >= 22.11.0
- npm >= 11.3.0
- Git
### 开发环境设置
1. 克隆仓库
```bash
git clone [repository-url]
cd china-telecom-app
```
2. 安装依赖
```bash
npm install
```
3. 启动开发服务器
```bash
npm run start
```
### 开发指南
- 使用 TypeScript 进行开发
- 遵循 ESLint 规范
- 使用 Prettier 进行代码格式化
- 主要开发文件位于 `src` 目录下
- 使用 IPC 通信进行主进程和渲染进程的通信
## 构建
### 构建命令
```bash
npm run package
```
### 构建配置
- 构建配置位于 `forge.config.js`
- 支持自定义应用图标
- 支持自定义应用名称
- 支持自定义构建目标平台
## 发布
### 发布流程
1. 更新版本号
在确认仓库没有任何未提交的更改后,执行以下命令更新版本号:
```bash
npm version [patch|minor|major]
```
如有未更改的提交,执行以上命令会报错:`npm error Git working directory not clean.`
或者手工编辑 `package.json` 文件,将 `version` 字段更新为新版本号。
2. 构建版本
```bash
npm run package
```
3. 打包版本
```bash
npm run make
```
4. 发布到发布服务器
```bash
npm run publish
```
### 发布注意事项
- 确保版本号正确更新
- 确保所有依赖都是最新的稳定版本
- 确保构建配置正确
- 测试发布版本的功能完整性
## 参考文档
- [Electron](https://www.electronjs.org/) - 跨平台桌面应用框架
- [Electron Forge](https://www.electronforge.io/) - Electron 应用打包工具
- [TypeScript](https://www.typescriptlang.org/) - JavaScript 的超集
- [electron-tabs](https://github.com/brrd/electron-tabs) - Electron 标签页管理
- [vite](https://vite.dev/) - 现代前端构建工具

BIN
assets/help.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -13,10 +13,25 @@ const config: ForgeConfig = {
extraResource: [
'assets/'
],
icon: 'assets/icon.ico',
icon: 'assets/icon',
},
rebuildConfig: {},
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
makers: [
new MakerSquirrel({
authors: 'Allen Cai',
owners: 'Allen Cai',
exe: 'china-telecom-app.exe',
name: 'china-telecom-app',
version: require('./package.json').version,
description: 'China Telecom App',
copyright: 'Copyright © 2025 Allen Cai',
noMsi: true,
setupIcon: 'assets/icon.ico',
}),
new MakerZIP({}, ['darwin']),
new MakerRpm({}),
new MakerDeb({})
],
plugins: [
new VitePlugin({
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.

View File

@ -19,7 +19,7 @@
</div>
<div class="header-right">
<span id="userInfo"></span>
<button id="btnLogout" class="logout-btn">退出登录</button>
<button id="btnExit" class="exit-btn">退出</button>
</div>
</header>
@ -48,7 +48,11 @@
</div>
</div>
<script src="node_modules/electron-tabs/dist/electron-tabs.js"></script>
<!-- 帮助图标 -->
<img id="helpIcon" src="./assets/help.png" alt="帮助图标" class="help-icon">
<script type="module">
import 'electron-tabs';
</script>
<script type="module" src="./src/index.ts"></script>
</body>

View File

@ -1,8 +1,8 @@
{
"name": "china-telecom-app",
"productName": "china-telecom-app",
"version": "1.0.0",
"description": "My Electron application description",
"version": "1.1.1",
"description": "China Telecom App",
"main": ".vite/build/main.js",
"scripts": {
"start": "cross-env NODE_ENV=development electron-forge start",

View File

@ -1,4 +1,4 @@
import { BrowserWindow, ipcMain, screen, session } from "electron";
import { BrowserWindow, ipcMain, screen, session, app } from "electron";
import http from 'http';
import https from 'https';
import os from 'os';
@ -8,6 +8,11 @@ import { ApiResponse, MenuItem, TagResolutionConfig, EIACDesktopApi, SpecialPUrl
const memoryCache = new Map<string, any>();
export function initialize(): void {
// Close app
ipcMain.handle('app:close', (): void => {
app.exit(0); // 使用 exit 而不是 quit确保立即退出
});
// Set cache
ipcMain.handle('cache:set', (_event, key: string, value: any): void => {
memoryCache.set(key, value);
@ -40,7 +45,7 @@ export function initialize(): void {
return helperDescrip ? helperDescrip.Descrip : null;
});
// Get zoom factor
// Get zoom factor by url
ipcMain.handle('get-zoom-factor-by-url', async (event, url: string): Promise<number> => {
const display: Electron.Display = screen.getPrimaryDisplay();
const physicalSize: Electron.Size = {
@ -113,7 +118,13 @@ export function initialize(): void {
},
(res) => {
console.log('check-url-available HEAD', url.toString(), res.statusCode, res.statusMessage);
if (res.statusCode === 403 || res.statusCode === 404) {
/**
* 403: 禁止访问
* 404: 未找到
* 405: 方法不允许
*/
const requiresRetryStatusCodes: number[] = [403, 404, 405];
if (requiresRetryStatusCodes.includes(res.statusCode)) {
headReq.destroy()
const getReq = lib.get({
method: 'GET',
@ -153,7 +164,7 @@ export function initialize(): void {
}
});
// Set webviews cookie
// Set webview's cookie
ipcMain.handle('set-webview-cookie', async (event, url: string, cookie: string): Promise<boolean> => {
try {
const parsedUrl = new URL(url);
@ -188,12 +199,24 @@ export function initialize(): void {
}
const base64 = await captureWindowAsBase64(win);
console.log('base64:', base64);
console.debug('base64:', base64);
const account: string = memoryCache.get('Account');
if (!account) {
throw new Error('Not found account');
}
console.log('account:', account);
const ip: string = getLocalIPAddress();
if (!ip) {
throw new Error('Not found ip');
}
console.log('ip:', ip);
try {
const response: ApiResponse<FaultReportingResponse> = await EIACDesktopApi.Help.FaultReportingAsync({
Account: memoryCache.get('Account'),
IP: getLocalIPAddress(),
Account: account,
IP: ip,
Url: url,
ImgBase64: base64,
Explain: `message: ${message}, status: ${status}`

View File

@ -44,7 +44,7 @@ body {
gap: 20px;
}
.logout-btn {
.exit-btn {
padding: 8px 16px;
background-color: #f44336;
color: white;
@ -53,7 +53,7 @@ body {
cursor: pointer;
}
.logout-btn:hover {
.exit-btn:hover {
background-color: #d32f2f;
}
@ -191,3 +191,24 @@ body {
height: 100%;
width: 100%;
}
/* Help icon */
.help-icon {
min-width: 32px;
min-height: 32px;
max-width: 64px;
max-height: 64px;
position: fixed;
top: 50%;
right: 20px;
transform: translateY(-50%);
z-index: 9999;
transition: transform 0.6s ease;
cursor: pointer;
opacity: 0.7;
}
.help-icon:hover {
transform: translateY(-50%) scale(1.2) rotate(360deg);
opacity: 1;
}

View File

@ -116,21 +116,23 @@ function renderMenu(menuList: MenuItem[]): void {
*
* @param tabGroup
* @param menuItem
* @param allowCloseTab
* @returns
*/
async function addTabAsync(tabGroup: TabGroup, menuItem: MenuItem): Promise<Tab | null> {
async function addTabAsync(tabGroup: TabGroup, menuItem: MenuItem, allowCloseTab: boolean = true): Promise<Tab | null> {
const url: string = menuItem.Url.startsWith("http") ? menuItem.Url : `http://${menuItem.Url}`;
const result: { ok: boolean; status: number; message?: string } = await window.electronAPI.checkUrlAvailable(url);
if (result.ok && result.status >= 200 && result.status < 400) {
console.log(`✅ URL ${url} 可访问:`, result.status);
lastInvalidUrlResult = null;
const cookies: string = window.electronAPI.getSessionStorage('cookies');
await window.electronAPI.setWebviewCookie(url, cookies);
} else {
console.warn(`❌ URL ${url} 不可访问:`, result.message ?? `status ${result.status}`);
lastInvalidUrlResult = { url, message: result.message, status: result.status };
const helpDescrip: string = await window.electronAPI.getHelperDescripAsync(result.status.toString()) ?? `无法访问 {URL}\r\n异常原因${result.message ?? `status ${result.status}`}\r\n${helpDescrip ?? ''}`;
const helpDescrip: string = await window.electronAPI.getHelperDescripAsync(result.status.toString()) ?? `无法访问{URL}\r\n异常原因${result.message ?? `status ${result.status}`}\r\n请联系技术支持。`;
showErrorModal(helpDescrip.replace('{URL}', url));
return null;
}
@ -139,7 +141,7 @@ async function addTabAsync(tabGroup: TabGroup, menuItem: MenuItem): Promise<Tab
const tab: Tab = tabGroup.addTab({
active: true,
closable: true,
closable: allowCloseTab,
title: menuItem.ShowName,
src: url,
iconURL: menuItem.IconConfig._1x.Default,
@ -153,7 +155,9 @@ async function addTabAsync(tabGroup: TabGroup, menuItem: MenuItem): Promise<Tab
const webview: Electron.WebviewTag = tab.webview as Electron.WebviewTag;
listenWebviewTitleChange(webview, tab);
// 监听 webview 的 DOM 加载完成事件
tab.once('webview-dom-ready', () => {
// 设置 webview 的缩放比例
const webview: Electron.WebviewTag = tab.webview as Electron.WebviewTag;
const defaultZoomFactor: number = webview.getZoomFactor();
console.log('Default zoom factor:', defaultZoomFactor);
@ -164,6 +168,18 @@ async function addTabAsync(tabGroup: TabGroup, menuItem: MenuItem): Promise<Tab
} else {
console.log('Default zoom factor is the same as the zoom factor:', zoomFactor);
}
// 监听 webview 的关闭事件
webview.addEventListener('destroyed', (_event: Event) => {
console.log('Webview destroyed, closing tab:', tab.title);
tab.close(true);
});
// 监听 webview 的关闭事件(当页面调用 window.close() 时触发)
webview.addEventListener('close', (_event: Event) => {
console.log('Webview close event triggered, closing tab:', tab.title);
tab.close(true);
});
});
}
});
@ -177,7 +193,31 @@ async function addTabAsync(tabGroup: TabGroup, menuItem: MenuItem): Promise<Tab
* @param tab
*/
function listenWebviewTitleChange(webview: Electron.WebviewTag, tab: Tab): void {
// 在webview加载完成后获取并设置标签页的标题和图标
// 监听 URL 变化事件
webview.addEventListener('did-navigate', async (event: Electron.DidNavigateEvent) => {
const url: string = event.url;
const zoomFactor: number = await window.electronAPI.getZoomFactorByUrl(url);
const currentZoomFactor: number = webview.getZoomFactor();
if (currentZoomFactor !== zoomFactor) {
webview.setZoomFactor(zoomFactor);
console.log('URL changed, modify zoom factor:', zoomFactor);
}
});
// 监听 URL 在同一个页面内的变化事件(如 hash 变化)
webview.addEventListener('did-navigate-in-page', async (event: Electron.DidNavigateInPageEvent) => {
const url: string = event.url;
const zoomFactor: number = await window.electronAPI.getZoomFactorByUrl(url);
const currentZoomFactor: number = webview.getZoomFactor();
if (currentZoomFactor !== zoomFactor) {
webview.setZoomFactor(zoomFactor);
console.log('URL in-page changed, modify zoom factor:', zoomFactor);
}
});
// 监听webview的标题变化并更新标签页的标题和图标
webview.addEventListener('did-finish-load', async () => {
const title: string = webview.getTitle();
tab.setTitle(title);
@ -227,6 +267,29 @@ async function getFaviconUrl(webview: Electron.WebviewTag): Promise<string> {
return defaultFaviconUrl;
}
/**
* Help图标点击事件
*/
function bindHelpIconClickEvent(menuItem: MenuItem): void {
const helpIcon: HTMLImageElement = document.getElementById('helpIcon') as HTMLImageElement;
helpIcon.src = menuItem.IconConfig._1x.Default;
helpIcon.addEventListener('mouseenter', (event) => helpIcon.src = menuItem.IconConfig._1x.Selected);
helpIcon.addEventListener('mouseleave', () => helpIcon.src = menuItem.IconConfig._1x.Default);
helpIcon.addEventListener('click', async (event) => {
if (lastInvalidUrlResult) {
const helpDescrip: string = await window.electronAPI.getHelperDescripAsync(lastInvalidUrlResult.status.toString()) ?? `无法访问{URL}\r\n异常原因${lastInvalidUrlResult.message ?? `status ${lastInvalidUrlResult.status}`}\r\n请联系技术支持。`;
showErrorModal(helpDescrip.replace('{URL}', lastInvalidUrlResult.url));
} else {
const tab: Tab | null = tabGroup.tabs.find(tab => (tab.webview as Electron.WebviewTag).getURL() === menuItem.Url);
if (tab) {
tab.activate();
} else {
await addTabAsync(tabGroup, menuItem);
}
}
});
}
/**
* logo点击事件
*/
@ -249,18 +312,19 @@ function bindLogoClickEvent(tabGroup: TabGroup, menuItem: MenuItem): void {
}
/**
*
* 退
*/
function bindLogoutEvent(): void {
const logoutBtn: HTMLButtonElement = document.getElementById('btnLogout') as HTMLButtonElement;
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
function bindExitEvent(): void {
const exitBtn: HTMLButtonElement = document.getElementById('btnExit') as HTMLButtonElement;
if (exitBtn) {
exitBtn.addEventListener('click', async () => {
window.electronAPI.removeSessionStorage('cookies');
window.location.href = 'login.html';
await window.electronAPI.closeApp();
});
}
}
let closeCount: number = 0;
/**
*
*/
@ -269,17 +333,31 @@ function bindErrorModalEvent(): void {
const closeErrorModal: HTMLButtonElement = document.getElementById('closeErrorModal') as HTMLButtonElement;
const faultReporting: HTMLButtonElement = document.getElementById('faultReporting') as HTMLButtonElement;
faultReporting.addEventListener('click', () => faultReportingAsync());
faultReporting.addEventListener('click', async () => await faultReportingAsync());
// Close button click event
closeErrorModal.addEventListener('click', () => {
errorModal.style.display = 'none';
closeCount++;
// 如果关闭次数大于等于2则重置计数并且清空lastInvalidUrlResult
if (closeCount >= 2) {
closeCount = 0;
lastInvalidUrlResult = null;
}
});
// Click outside to close
window.addEventListener('click', (event: Event) => {
if (event.target === errorModal) {
errorModal.style.display = 'none';
closeCount++;
// 如果关闭次数大于等于2则重置计数并且清空lastInvalidUrlResult
if (closeCount >= 2) {
closeCount = 0;
lastInvalidUrlResult = null;
}
}
});
}
@ -316,6 +394,7 @@ async function faultReportingAsync(): Promise<void> {
// 显示请求结果
if (result.ok) {
lastInvalidUrlResult = null;
faultReportingBtn.textContent = '上报成功';
faultReportingBtn.style.backgroundColor = '#4CAF50';
setTimeout(() => {
@ -361,14 +440,18 @@ async function initialize(): Promise<void> {
// Create initial tab
const firstMenuItem: MenuItem = menuList[0];
await addTabAsync(tabGroup, firstMenuItem);
await addTabAsync(tabGroup, firstMenuItem, false);
// Bind help icon click event
const helpMenuItem: MenuItem = menuList[menuList.length - 2];
bindHelpIconClickEvent(helpMenuItem);
// Bind logo click event
const logoMenuItem: MenuItem = menuList[menuList.length - 1];
bindLogoClickEvent(tabGroup, logoMenuItem);
// Bind logout event
bindLogoutEvent();
// Bind exit event
bindExitEvent();
// Bind error modal event
bindErrorModalEvent();

View File

@ -5,43 +5,48 @@ import { contextBridge, ipcRenderer } from 'electron';
import { ApiResponse, MenuItem, TagResolutionConfig } from './EIAC_Desktop_Api';
contextBridge.exposeInMainWorld('electronAPI', {
/**
*
*/
closeApp: (): Promise<void> => ipcRenderer.invoke('app:close'),
/**
*
* @param key
* @returns
*/
getCacheAsync: (key: string) => ipcRenderer.invoke('cache:get', key) as Promise<any>,
getCacheAsync: (key: string): Promise<any> => ipcRenderer.invoke('cache:get', key),
/**
*
* @param key
* @param value
*/
setCacheAsync: (key: string, value: any) => ipcRenderer.invoke('cache:set', key, value) as Promise<void>,
setCacheAsync: (key: string, value: any): Promise<void> => ipcRenderer.invoke('cache:set', key, value),
/**
*
* @returns
*/
getMenuCacheAsync: () => ipcRenderer.invoke('get-menu-cache') as Promise<ApiResponse<MenuItem[]>>,
getMenuCacheAsync: (): Promise<ApiResponse<MenuItem[]>> => ipcRenderer.invoke('get-menu-cache'),
/**
*
* @param code
* @returns
*/
getHelperDescripAsync: (code: string) => ipcRenderer.invoke('get-helper-descrip', code) as Promise<string | null>,
getHelperDescripAsync: (code: string): Promise<string | null> => ipcRenderer.invoke('get-helper-descrip', code),
/**
* URL的缩放比例
* @param url URL
* @returns
*/
getZoomFactorByUrl: (url: string) => ipcRenderer.invoke('get-zoom-factor-by-url', url) as Promise<number>,
getZoomFactorByUrl: (url: string): Promise<number> => ipcRenderer.invoke('get-zoom-factor-by-url', url),
/**
* URL
* @param callback webContentId和urlwebContentId是请求打开URL的webview的id
*/
onOpenTab: (callback: (webContentId: number, url: string) => void) => {
onOpenTab: (callback: (webContentId: number, url: string) => void): void => {
ipcRenderer.on('webview-new-window', (_event, webContentId, url) => callback(webContentId, url));
},
@ -50,14 +55,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
* @param url URL
* @returns
*/
checkUrlAvailable: (url: string) => ipcRenderer.invoke('check-url-available', url) as Promise<boolean>,
checkUrlAvailable: (url: string): Promise<boolean> => ipcRenderer.invoke('check-url-available', url),
/**
* webview的cookie
* @param url cookie的URL
* @param cookie cookie字符串
*/
setWebviewCookie: (url: string, cookie: string) => ipcRenderer.invoke('set-webview-cookie', url, cookie) as Promise<boolean>,
setWebviewCookie: (url: string, cookie: string): Promise<boolean> => ipcRenderer.invoke('set-webview-cookie', url, cookie),
/**
*
@ -65,14 +70,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
* @param message
* @param status
*/
faultReporting: (url: string, message: string, status: number) => ipcRenderer.invoke('fault-reporting', url, message, status) as Promise<{ ok: boolean; status: number; message?: string }>,
faultReporting: (url: string, message: string, status: number): Promise<{ ok: boolean; status: number; message?: string }> => ipcRenderer.invoke('fault-reporting', url, message, status),
/**
* sessionStorage
* @param key
* @param value
*/
setSessionStorage: (key: string, value: string) => {
setSessionStorage: (key: string, value: string): void => {
window.sessionStorage.setItem(key, value);
},
@ -81,7 +86,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
* @param key
* @returns
*/
getSessionStorage: (key: string) => {
getSessionStorage: (key: string): string | null => {
return window.sessionStorage.getItem(key);
},
@ -89,14 +94,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
* sessionStorage中删除指定键的值
* @param key
*/
removeSessionStorage: (key: string) => {
removeSessionStorage: (key: string): void => {
window.sessionStorage.removeItem(key);
},
/**
* sessionStorage
*/
clearSessionStorage: () => {
clearSessionStorage: (): void => {
window.sessionStorage.clear();
}
});

View File

@ -1,6 +1,10 @@
import { ApiResponse, MenuItem, TagResolutionConfig } from '../EIAC_Desktop_Api';
export interface ElectronAPI {
/**
*
*/
closeApp: () => Promise<void>;
/**
*
* @param key

View File

@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "commonjs",
"module": "ESNext",
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,

View File

@ -20,8 +20,17 @@ export default defineConfig({
input: {
login: 'login.html',
index: 'index.html'
},
output: {
// 确保 electron-tabs.js 被正确打包
manualChunks: {
'electron-tabs': ['electron-tabs']
}
}
}
},
optimizeDeps: {
include: ['electron-tabs']
},
// 使用 Vite 的 env 配置
envPrefix: ['EIAC_DESKTOP_API_HOST', 'NODE_ENV'],