feat(splash): 添加全局开屏图片功能并优化客户端启动流程

- 新增全局开屏图片上传、获取、删除接口
- 实现客户端全局开屏图片优先加载机制
- 集成七牛云存储配置从 pxdj 切换到 bydj
- 优化 splash 窗口显示逻辑确保至少显示 2 秒
- 添加全局开屏图片管理界面组件
- 更新应用配置和构建设置
- 修复多处图片缓存和加载问题
- 调整服务端 API 地址配置
- 修改微信客服联系方式
This commit is contained in:
2026-01-19 17:33:43 +08:00
parent 02858146b3
commit 358203b11d
19 changed files with 12157 additions and 175 deletions

View File

@@ -1,77 +0,0 @@
<div align="center">
# Electron Vue Template
<img width="794" alt="image" src="https://user-images.githubusercontent.com/32544586/222748627-ee10c9a6-70d2-4e21-b23f-001dd8ec7238.png">
A simple starter template for a **Vue3** + **Electron** TypeScript based application, including **ViteJS** and **Electron Builder**.
</div>
## About
This template utilizes [ViteJS](https://vitejs.dev) for building and serving your (Vue powered) front-end process, it provides Hot Reloads (HMR) to make development fast and easy ⚡
Building the Electron (main) process is done with [Electron Builder](https://www.electron.build/), which makes your application easily distributable and supports cross-platform compilation 😎
## Getting started
Click the green **Use this template** button on top of the repository, and clone your own newly created repository.
**Or..**
Clone this repository: `git clone git@github.com:Deluze/electron-vue-template.git`
### Install dependencies ⏬
```bash
npm install
```
### Start developing ⚒️
```bash
npm run dev
```
## Additional Commands
```bash
npm run dev # starts application with hot reload
npm run build # builds application, distributable files can be found in "dist" folder
# OR
npm run build:win # uses windows as build target
npm run build:mac # uses mac as build target
npm run build:linux # uses linux as build target
```
Optional configuration options can be found in the [Electron Builder CLI docs](https://www.electron.build/cli.html).
## Project Structure
```bash
- scripts/ # all the scripts used to build or serve your application, change as you like.
- src/
- main/ # Main thread (Electron application source)
- renderer/ # Renderer thread (VueJS application source)
```
## Using static files
If you have any files that you want to copy over to the app directory after installation, you will need to add those files in your `src/main/static` directory.
Files in said directory are only accessible to the `main` process, similar to `src/renderer/assets` only being accessible to the `renderer` process. Besides that, the concept is the same as to what you're used to in your other front-end projects.
#### Referencing static files from your main process
```ts
/* Assumes src/main/static/myFile.txt exists */
import {app} from 'electron';
import {join} from 'path';
import {readFileSync} from 'fs';
const path = join(app.getAppPath(), 'static', 'myFile.txt');
const buffer = readFileSync(path);
```

7281
electron-vue-template/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "erpClient",
"version": "0.1.0",
"description": "A minimal Electron + Vue application",
"main": "main/main.js",
"main": "build/main/main.js",
"scripts": {
"dev": "node scripts/dev-server.js",
"build": "node scripts/build.js && electron-builder --dir",
@@ -17,6 +17,7 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.4.1",
"binary-extensions": "^3.1.0",
"chalk": "^4.1.2",
"chokidar": "^3.5.3",
"electron": "^32.1.2",
@@ -32,5 +33,26 @@
"element-plus": "^2.11.3",
"exceljs": "^4.4.0",
"vue": "^3.3.8"
},
"build": {
"appId": "com.tashow.erp",
"productName": "天骄智能电商",
"files": [
"build/**/*",
"node_modules/**/*",
"package.json"
],
"directories": {
"buildResources": "assets",
"output": "dist"
},
"win": {
"target": [
{
"target": "dir",
"arch": ["x64"]
}
]
}
}
}

4417
electron-vue-template/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ let springProcess: ChildProcess | null = null;
let mainWindow: BrowserWindow | null = null;
let splashWindow: BrowserWindow | null = null;
let appOpened = false;
let splashStartTime = 0; // 记录 splash 窗口显示时间
let downloadProgress = {percentage: 0, current: '0 MB', total: '0 MB'};
let isDownloading = false;
let downloadedFilePath: string | null = null;
@@ -24,8 +25,9 @@ function openAppIfNotOpened() {
return;
}
appOpened = true;
const url = `http://localhost:${process.argv[2] || 8083}`;
isDev
? mainWindow.loadURL(`http://localhost:${process.argv[2] || 8083}`)
? mainWindow.loadURL(url)
: mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
mainWindow.webContents.once('did-finish-load', () => {
@@ -33,7 +35,11 @@ function openAppIfNotOpened() {
splashWindow.webContents.send('splash-complete');
}
// 先显示主窗口再关闭splash避免白屏
// 计算 splash 已显示的时间,确保至少显示 2 秒
const splashElapsed = Date.now() - splashStartTime;
const minSplashTime = 2000;
const remainingTime = Math.max(0, minSplashTime - splashElapsed);
setTimeout(() => {
if (mainWindow && !mainWindow.isDestroyed()) {
const shouldMinimize = loadConfig().launchMinimized || false;
@@ -41,17 +47,16 @@ function openAppIfNotOpened() {
mainWindow.show();
mainWindow.focus();
}
if (isDev) mainWindow.webContents.openDevTools();
}
// 延迟关闭splash,确保主窗口已显示
// 延迟关闭 splash
setTimeout(() => {
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.close();
splashWindow = null;
}
}, 100);
}, 200);
}, remainingTime + 200);
});
}
@@ -93,31 +98,34 @@ const getImageCacheDir = () => {
};
// 下载图片到本地
async function downloadImageToLocal(imageUrl: string, username: string, type: 'splash' | 'logo'): Promise<void> {
async function downloadImageToLocal(imageUrl: string, username: string, type: 'splash' | 'logo' | 'global_splash'): Promise<void> {
return new Promise((resolve) => {
const protocol = imageUrl.startsWith('https') ? https : http;
protocol.get(imageUrl, (res) => {
const handleResponse = (res: http.IncomingMessage) => {
if (res.statusCode !== 200) return resolve();
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const buffer = Buffer.concat(chunks);
const ext = imageUrl.match(/\.(jpg|jpeg|png|gif|webp)$/i)?.[1] || 'png';
const filepath = join(getImageCacheDir(), `${username}_${type}.${ext}`);
const filename = type === 'global_splash' ? `global_splash.${ext}` : `${username}_${type}.${ext}`;
const filepath = join(getImageCacheDir(), filename);
writeFileSync(filepath, buffer);
console.log(`[图片缓存] 已保存: ${username}_${type}.${ext}`);
console.log(`[图片缓存] 已保存: ${filename}`);
resolve();
});
res.on('error', () => resolve());
}).on('error', () => resolve());
};
const req = imageUrl.startsWith('https') ? https.get(imageUrl, handleResponse) : http.get(imageUrl, handleResponse);
req.on('error', () => resolve());
});
}
// 加载本地缓存图片
function loadCachedImage(username: string, type: 'splash' | 'logo'): string | null {
function loadCachedImage(username: string, type: 'splash' | 'logo' | 'global_splash'): string | null {
try {
const files = readdirSync(getImageCacheDir());
const file = files.find(f => f.startsWith(`${username}_${type}.`));
const prefix = type === 'global_splash' ? 'global_splash.' : `${username}_${type}.`;
const file = files.find(f => f.startsWith(prefix));
if (file) {
const buffer = readFileSync(join(getImageCacheDir(), file));
const ext = extname(file).slice(1);
@@ -129,10 +137,11 @@ function loadCachedImage(username: string, type: 'splash' | 'logo'): string | nu
}
// 删除本地缓存图片
function deleteCachedImage(username: string, type: 'splash' | 'logo'): void {
function deleteCachedImage(username: string, type: 'splash' | 'logo' | 'global_splash'): void {
try {
const files = readdirSync(getImageCacheDir());
const file = files.find(f => f.startsWith(`${username}_${type}.`));
const prefix = type === 'global_splash' ? 'global_splash.' : `${username}_${type}.`;
const file = files.find(f => f.startsWith(prefix));
if (file) {
const filepath = join(getImageCacheDir(), file);
if (existsSync(filepath)) {
@@ -143,6 +152,66 @@ function deleteCachedImage(username: string, type: 'splash' | 'logo'): void {
} catch (err) {}
}
// 从服务器同步获取全局开屏图片URL带超时
async function fetchGlobalSplashImageUrl(): Promise<string | null> {
return new Promise((resolve) => {
const config = loadConfig();
const serverUrl = config.serverUrl || 'http://8.138.23.49:8085';
const url = `${serverUrl}/monitor/account/global-splash-image`;
const handleResponse = (res: http.IncomingMessage) => {
if (res.statusCode !== 200) return resolve(null);
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve(json.code === 200 && json.data?.url ? json.data.url : null);
} catch {
resolve(null);
}
});
res.on('error', () => resolve(null));
};
const req = url.startsWith('https') ? https.get(url, handleResponse) : http.get(url, handleResponse);
req.on('error', () => resolve(null));
req.setTimeout(3000, () => {
req.destroy();
resolve(null);
});
});
}
// 从远程URL下载图片并转为base64
async function downloadImageAsBase64(imageUrl: string): Promise<string | null> {
return new Promise((resolve) => {
const handleResponse = (res: http.IncomingMessage) => {
if (res.statusCode !== 200) return resolve(null);
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
const buffer = Buffer.concat(chunks);
const ext = imageUrl.match(/\.(jpg|jpeg|png|gif|webp)$/i)?.[1] || 'png';
const mime = { jpg: 'jpeg', jpeg: 'jpeg', png: 'png', gif: 'gif', webp: 'webp' }[ext] || 'png';
resolve(`url('data:image/${mime};base64,${buffer.toString('base64')}')`);
} catch {
resolve(null);
}
});
res.on('error', () => resolve(null));
};
const req = imageUrl.startsWith('https') ? https.get(imageUrl, handleResponse) : http.get(imageUrl, handleResponse);
req.on('error', () => resolve(null));
req.setTimeout(5000, () => {
req.destroy();
resolve(null);
});
});
}
// 获取默认开屏图片
function getDefaultSplashImage(): string {
const path = getResourcePath('../../public/image/splash_screen.png', 'public/image/splash_screen.png');
@@ -179,6 +248,7 @@ interface AppConfig {
lastUsername?: string;
splashImageUrl?: string;
brandLogoUrl?: string;
serverUrl?: string;
}
function getConfigPath(): string {
@@ -246,11 +316,10 @@ function startSpringBoot() {
springProcess = spawn(javaPath, springArgs, {
cwd: dataDir,
detached: false,
stdio: 'ignore'
stdio: 'ignore',
windowsHide: true
});
let startupCompleted = false;
springProcess.on('close', () => mainWindow ? mainWindow.close() : app.quit());
springProcess.on('error', (error) => {
dialog.showErrorBox('启动失败', error.message.includes('ENOENT')
@@ -388,7 +457,7 @@ if (!gotTheLock) {
});
}
app.whenReady().then(() => {
app.whenReady().then(async () => {
if (!isDev) {
protocol.interceptFileProtocol('file', (request, callback) => {
// 使用 fileURLToPath 正确解码 URL处理空格和特殊字符
@@ -436,17 +505,50 @@ app.whenReady().then(() => {
if (!shouldMinimize) {
const config = loadConfig();
const username = config.lastUsername || '';
const imageUrl = config.splashImageUrl || '';
const userSplashUrl = config.splashImageUrl || '';
// 图片加载:本地缓存 > 默认图片
let splashImage = (imageUrl && username && loadCachedImage(username, 'splash')) || getDefaultSplashImage();
let splashImage: string | null = null;
// 如果有URL但缓存不存在后台下载
if (imageUrl && username && !loadCachedImage(username, 'splash')) {
downloadImageToLocal(imageUrl, username, 'splash');
// 开屏图片加载优先级:全局图片(实时) > 用户自定义图片 > 默认本地图片
console.log('[开屏图片] 开始获取全局开屏图片...');
// 1. 获取全局开屏图片
const globalUrl = await fetchGlobalSplashImageUrl();
if (globalUrl) {
console.log('[开屏图片] 获取到全局图片URL:', globalUrl);
splashImage = await downloadImageAsBase64(globalUrl);
if (splashImage) {
console.log('[开屏图片] 使用实时全局图片');
downloadImageToLocal(globalUrl, '', 'global_splash').catch(() => {});
} else {
splashImage = loadCachedImage('', 'global_splash');
}
} else {
splashImage = loadCachedImage('', 'global_splash');
}
// 2. 如果没有全局图片,尝试用户自定义图片
if (!splashImage && userSplashUrl && username) {
console.log('[开屏图片] 使用用户自定义图片');
splashImage = loadCachedImage(username, 'splash');
if (!splashImage) {
downloadImageToLocal(userSplashUrl, username, 'splash').catch(() => {});
}
}
// 3. 使用默认本地图片
if (!splashImage) {
console.log('[开屏图片] 使用默认本地图片');
splashImage = getDefaultSplashImage();
}
// 将图片数据写入临时文件,避免 data URL 过长
const tempSplashPath = join(app.getPath('temp'), 'splash-temp.html');
const splashHtml = readFileSync(getSplashPath(), 'utf-8').replace('__SPLASH_IMAGE__', splashImage);
writeFileSync(tempSplashPath, splashHtml);
// 记录 splash 显示时间
splashStartTime = Date.now();
splashWindow = new BrowserWindow({
width: 1200,
@@ -454,8 +556,8 @@ app.whenReady().then(() => {
frame: false,
transparent: false,
resizable: false,
alwaysOnTop: false,
show: false,
alwaysOnTop: true, // 设置为置顶,确保在主窗口之上
show: false, // 创建时不显示,等待内容加载完成
center: true,
icon: getIconPath(),
backgroundColor: '#ffffff',
@@ -466,15 +568,24 @@ app.whenReady().then(() => {
});
splashWindow.on('closed', () => splashWindow = null);
// 加载预注入的 HTML图片已base64内联无跨域问题
splashWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(splashHtml)}`);
splashWindow.once('ready-to-show', () => splashWindow?.show());
// 监听页面加载完成后显示
splashWindow.webContents.on('did-finish-load', () => {
setTimeout(() => {
if (splashWindow && !splashWindow.isDestroyed()) {
splashWindow.show();
}
}, 50);
});
// 加载临时 HTML 文件
splashWindow.loadFile(tempSplashPath);
}
// 已手动启动后端
// setTimeout(() => {
// startSpringBoot();
// }, 200);
setTimeout(() => {
startSpringBoot();
}, 200);
setTimeout(() => {
openAppIfNotOpened();
@@ -955,9 +1066,7 @@ ipcMain.handle('load-config', () => {
async function getFileSize(url: string): Promise<number> {
return new Promise((resolve) => {
const protocol = url.startsWith('https') ? https : http;
const request = protocol.get(url, {method: 'HEAD'}, (response) => {
const handleResponse = (response: http.IncomingMessage) => {
if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
@@ -965,11 +1074,16 @@ async function getFileSize(url: string): Promise<number> {
return;
}
}
const size = parseInt(response.headers['content-length'] || '0', 10);
resolve(size);
}).on('error', () => resolve(0));
};
const request = url.startsWith('https')
? https.get(url, {method: 'HEAD'}, handleResponse)
: http.get(url, {method: 'HEAD'}, handleResponse);
request.on('error', () => resolve(0));
request.setTimeout(10000, () => {
request.destroy();
resolve(0);
@@ -979,9 +1093,9 @@ async function getFileSize(url: string): Promise<number> {
async function downloadFile(url: string, filePath: string, onProgress: (progress: {downloaded: number, total: number}) => void): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http;
let request: http.ClientRequest;
const request = protocol.get(url, (response) => {
const handleResponse = (response: http.IncomingMessage) => {
if (response.statusCode === 301 || response.statusCode === 302 || response.statusCode === 307) {
const redirectUrl = response.headers.location;
if (redirectUrl) {
@@ -989,7 +1103,7 @@ async function downloadFile(url: string, filePath: string, onProgress: (progress
return;
}
}
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode}`));
return;
@@ -1024,7 +1138,12 @@ async function downloadFile(url: string, filePath: string, onProgress: (progress
fs.unlink(filePath).catch(() => {});
reject(error);
});
};
}).on('error', reject);
request = url.startsWith('https')
? https.get(url, handleResponse)
: http.get(url, handleResponse);
request.on('error', reject);
});
}

View File

@@ -656,8 +656,8 @@ onUnmounted(() => {
</div>
</div>
<div v-if="brandLogoUrl" class="brand-logo-section">
<img :src="brandLogoUrl" alt="品牌 Banner" class="brand-logo"/>
<div class="brand-logo-section">
<img src="https://qiniu.bydj.tashowz.com/brand-logo/2026/01/4bfd767a56a54351a6db284563b4f83d.png" alt="品牌 Banner" class="brand-logo"/>
</div>
<div class="menu-group-title">电商平台</div>

View File

@@ -45,6 +45,11 @@ export const splashApi = {
// 删除品牌logo
async deleteBrandLogo(username: string) {
return http.post<{ data: string }>(`/monitor/account/brand-logo/delete?username=${username}`)
},
// 获取全局开屏图片
async getGlobalSplashImage() {
return http.get<{ data: { url: string } }>('/monitor/account/global-splash-image')
}
}

View File

@@ -607,12 +607,14 @@ onMounted(() => {
<span class="sidebar-text">启动</span>
</div>
<div
v-show="false"
:class="['sidebar-item', { active: activeTab === 'splash' }]"
@click="scrollToSection('splash')">
<span class="sidebar-icon">🖼</span>
<span class="sidebar-text">开屏图片</span>
</div>
<div
v-show="false"
:class="['sidebar-item', { active: activeTab === 'brand' }]"
@click="scrollToSection('brand')">
<span class="sidebar-icon">🏷</span>
@@ -754,8 +756,8 @@ onMounted(() => {
</div>
</div>
<!-- 开屏图片设置 -->
<div id="section-splash" class="setting-section" @mouseenter="activeTab = 'splash'">
<!-- 开屏图片设置 (暂时隐藏) -->
<div id="section-splash" class="setting-section" @mouseenter="activeTab = 'splash'" style="display: none;">
<div class="section-title-row">
<div class="section-title">开屏图片</div>
<img src="/icon/vipExclusive.png" alt="VIP专享" class="vip-exclusive-logo" />
@@ -785,8 +787,8 @@ onMounted(() => {
</div>
</div>
<!-- 品牌logo页面 -->
<div id="section-brand" class="setting-section" @mouseenter="activeTab = 'brand'">
<!-- 品牌logo页面 (暂时隐藏) -->
<div id="section-brand" class="setting-section" @mouseenter="activeTab = 'brand'" style="display: none;">
<div class="section-title-row">
<div class="section-title">品牌 Banner</div>
<img src="/icon/vipExclusive.png" alt="VIP专享" class="vip-exclusive-logo" />

View File

@@ -38,7 +38,7 @@ function handleConfirm() {
}
function copyWechat() {
navigator.clipboard.writeText('_linhong').then(() => {
navigator.clipboard.writeText('butaihaoba001').then(() => {
ElMessage.success('微信号已复制')
}).catch(() => {
ElMessage.error('复制失败,请手动复制')
@@ -75,7 +75,7 @@ function copyWechat() {
</div>
<div class="wechat-info">
<div class="wechat-label">客服微信</div>
<div class="wechat-id">_linhong</div>
<div class="wechat-id">butaihaoba001</div>
</div>
<div class="copy-icon">📋</div>
</div>

View File

@@ -3,7 +3,7 @@
*/
export const AppConfig = {
CLIENT_BASE: 'http://localhost:8081',
RUOYI_BASE: 'http://192.168.1.89:8085',
RUOYI_BASE: 'http://8.138.23.49:8085',
get SSE_URL() {
return `${this.RUOYI_BASE}/monitor/account/events`
}