feat(client): 实现自定义开屏图片功能

- 在 ClientAccount 实体中新增 splashImage 字段用于存储开屏图片URL
- 在 ClientAccountController 中添加上传、获取和删除开屏图片的接口
- 集成七牛云存储实现图片上传功能,支持图片格式和大小校验
- 使用 Redis 缓存开屏图片URL,提升访问性能
- 在客户端登录成功后异步加载并保存开屏图片配置
- 新增 splashApi 模块封装开屏图片相关HTTP请求- 在主进程中实现开屏图片配置的持久化存储和读取
- 在设置页面中增加开屏图片管理界面,支持上传、预览和删除操作
- 修改 splash.html 支持动态加载自定义开屏图片
- 调整 CSP 策略允许加载本地和HTTPS图片资源
This commit is contained in:
2025-11-08 10:23:45 +08:00
parent 7c7009ffed
commit c2e1617a99
13 changed files with 374 additions and 19 deletions

View File

@@ -8,7 +8,7 @@
html, body { height: 100%; margin: 0; }
body {
background: #fff; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;
background-image: url('./image/splash_screen.png');
background-image: var(--splash-image, url('./image/splash_screen.png'));
background-repeat: no-repeat;
background-position: center;
background-size: cover;
@@ -21,7 +21,7 @@
<link rel="icon" href="icon/icon.png">
<link rel="apple-touch-icon" href="icon/icon.png">
<meta name="theme-color" content="#ffffff">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline';">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: file: https:; style-src 'self' 'unsafe-inline';">
</head>
<body>
<div class="box">

View File

@@ -94,10 +94,13 @@ function getLogDirectoryPath(): string {
return logDir;
}
interface AppConfig {
closeAction?: 'quit' | 'minimize' | 'tray';
autoLaunch?: boolean;
launchMinimized?: boolean;
lastUsername?: string;
splashImageUrl?: string;
}
function getConfigPath(): string {
@@ -210,7 +213,7 @@ function startSpringBoot() {
}
}
startSpringBoot();
// startSpringBoot();
function stopSpringBoot() {
if (!springProcess) return;
try {
@@ -372,13 +375,26 @@ app.whenReady().then(() => {
const splashPath = getSplashPath();
if (existsSync(splashPath)) {
const config = loadConfig();
const imageUrl = config.splashImageUrl || '';
console.log('[开屏图片] 启动配置:', { username: config.lastUsername, imageUrl, configPath: getConfigPath() });
splashWindow.loadFile(splashPath);
if (imageUrl) {
splashWindow.webContents.once('did-finish-load', () => {
splashWindow?.webContents.executeJavaScript(`
document.body.style.setProperty('--splash-image', "url('${imageUrl}')");
`).then(() => console.log('[开屏图片] 注入成功:', imageUrl))
.catch(err => console.error('[开屏图片] 注入失败:', err));
});
}
}
}
//666
// setTimeout(() => {
// openAppIfNotOpened();
// }, 100);
setTimeout(() => {
openAppIfNotOpened();
}, 100);
app.on('activate', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -769,6 +785,22 @@ ipcMain.handle('window-is-maximized', () => {
return mainWindow && !mainWindow.isDestroyed() ? mainWindow.isMaximized() : false;
});
// 保存开屏图片配置(用户名 + URL
ipcMain.handle('save-splash-config', (event, username: string, imageUrl: string) => {
const config = loadConfig();
config.lastUsername = username;
config.splashImageUrl = imageUrl;
saveConfig(config);
console.log('[开屏图片] 已保存配置:', { username, imageUrl, path: getConfigPath() });
return { success: true };
});
// 获取开屏图片配置
ipcMain.handle('get-splash-config', () => {
const config = loadConfig();
return { username: config.lastUsername || '', imageUrl: config.splashImageUrl || '' };
});
async function getFileSize(url: string): Promise<number> {
return new Promise((resolve) => {

View File

@@ -43,6 +43,10 @@ const electronAPI = {
windowClose: () => ipcRenderer.invoke('window-close'),
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
// 开屏图片相关 API
saveSplashConfig: (username: string, imageUrl: string) => ipcRenderer.invoke('save-splash-config', username, imageUrl),
getSplashConfig: () => ipcRenderer.invoke('get-splash-config'),
onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.removeAllListeners('download-progress')
ipcRenderer.on('download-progress', (event, progress) => callback(progress))

View File

@@ -1,6 +1,6 @@
export type HttpMethod = 'GET' | 'POST' | 'DELETE';
const RUOYI_BASE = 'http://8.138.23.49:8085';
// const RUOYI_BASE = 'http://192.168.1.89:8085';
//const RUOYI_BASE = 'http://8.138.23.49:8085';
const RUOYI_BASE = 'http://192.168.1.89:8085';
export const CONFIG = {
CLIENT_BASE: 'http://localhost:8081',
RUOYI_BASE,

View File

@@ -0,0 +1,32 @@
import { http } from './http'
export interface SplashImageResponse {
splashImage: string
url: string
}
export const splashApi = {
// 上传开屏图片
async uploadSplashImage(file: File, username: string) {
const formData = new FormData()
formData.append('file', file)
formData.append('username', username)
return http.upload<{ data: { url: string; fileName: string } }>('/monitor/account/splash-image/upload', formData)
},
// 获取当前用户的开屏图片
async getSplashImage(username: string) {
return http.get<{ data: SplashImageResponse }>('/monitor/account/splash-image', { username })
},
// 根据用户名获取开屏图片(用于启动时)
async getSplashImageByUsername(username: string) {
return http.get<{ data: SplashImageResponse }>('/monitor/account/splash-image/by-username', { username })
},
// 删除自定义开屏图片(恢复默认)
async deleteSplashImage(username: string) {
return http.post<{ data: string }>(`/monitor/account/splash-image/delete?username=${username}`)
}
}

View File

@@ -359,7 +359,6 @@ async function startTrademarkQuery() {
// 其他错误(网络错误等)继续等待
}
}
clearInterval(progressTimer)
return taskResult
}

View File

@@ -4,6 +4,7 @@ import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue'
import { authApi } from '../../api/auth'
import { getOrCreateDeviceId } from '../../utils/deviceId'
import { splashApi } from '../../api/splash'
interface Props {
modelValue: boolean
@@ -41,6 +42,9 @@ async function handleAuth() {
clientId: deviceId
})
// 保存开屏图片配置(不阻塞登录)
saveSplashConfigInBackground(authForm.value.username)
emit('loginSuccess', {
token: loginRes.data.accessToken || loginRes.data.token,
permissions: loginRes.data.permissions,
@@ -75,6 +79,17 @@ function resetForm() {
function showRegister() {
emit('showRegister')
}
// 保存开屏图片配置
async function saveSplashConfigInBackground(username: string) {
try {
const res = await splashApi.getSplashImage(username)
const url = res?.data?.data?.url || res?.data?.url || ''
await (window as any).electronAPI.saveSplashConfig(username, url)
} catch (error) {
console.error('[开屏图片] 保存配置失败:', error)
}
}
</script>
<template>

View File

@@ -12,6 +12,7 @@ import { feedbackApi } from '../../api/feedback'
import { getToken, getUsernameFromToken } from '../../utils/token'
import { getOrCreateDeviceId } from '../../utils/deviceId'
import { updateApi } from '../../api/update'
import { splashApi } from '../../api/splash'
interface Props {
modelValue: boolean
@@ -68,6 +69,12 @@ const checkingUpdate = ref(false)
const hasUpdate = ref(false)
const autoUpdate = ref(false)
// 开屏图片相关
const splashImageUrl = ref('')
const uploadingSplashImage = ref(false)
const deletingSplashImage = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const show = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
@@ -78,6 +85,7 @@ watch(() => props.modelValue, (newVal) => {
if (newVal) {
loadAllSettings()
loadCurrentVersion()
loadSplashImage()
}
})
@@ -361,10 +369,81 @@ async function submitFeedback() {
}
}
// 触发文件选择
function triggerFileSelect() {
fileInputRef.value?.click()
}
// 加载开屏图片
async function loadSplashImage() {
try {
const username = getUsernameFromToken()
if (!username) return
const res = await splashApi.getSplashImage(username)
splashImageUrl.value = res?.data?.data?.url || res?.data?.url || ''
} catch (error) {
splashImageUrl.value = ''
}
}
// 上传开屏图片
async function handleSplashImageUpload(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files || input.files.length === 0) return
const file = input.files[0]
const username = getUsernameFromToken()
if (!username) return ElMessage.warning('请先登录')
if (!file.type.startsWith('image/')) return ElMessage.error('只支持图片文件')
if (file.size > 5 * 1024 * 1024) return ElMessage.error('图片大小不能超过5MB')
try {
uploadingSplashImage.value = true
const res = await splashApi.uploadSplashImage(file, username)
const url = res?.data?.data?.url || res?.data?.url
if (url) {
splashImageUrl.value = url
await (window as any).electronAPI.saveSplashConfig(username, url)
ElMessage.success('开屏图片设置成功,重启应用后生效')
}
} catch (error: any) {
ElMessage.error(error?.message || '上传失败')
} finally {
uploadingSplashImage.value = false
input.value = ''
}
}
// 删除开屏图片
async function handleDeleteSplashImage() {
try {
await ElMessageBox.confirm(
'确定要删除自定义开屏图片吗?将恢复为默认开屏图片。',
'确认删除',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
deletingSplashImage.value = true
const username = getUsernameFromToken()
if (!username) return ElMessage.warning('请先登录')
await splashApi.deleteSplashImage(username)
await (window as any).electronAPI.saveSplashConfig(username, '')
splashImageUrl.value = ''
ElMessage.success('开屏图片已删除,重启应用后恢复默认')
} catch (error: any) {
if (error !== 'cancel') ElMessage.error(error?.message || '删除失败')
} finally {
deletingSplashImage.value = false
}
}
onMounted(() => {
loadAllSettings()
loadLogDates()
loadCurrentVersion()
loadSplashImage()
})
</script>
@@ -401,6 +480,12 @@ onMounted(() => {
<span class="sidebar-icon">🚀</span>
<span class="sidebar-text">启动</span>
</div>
<div
:class="['sidebar-item', { active: activeTab === 'splash' }]"
@click="scrollToSection('splash')">
<span class="sidebar-icon">🖼</span>
<span class="sidebar-text">开屏图片</span>
</div>
<div
:class="['sidebar-item', { active: activeTab === 'feedback' }]"
@click="scrollToSection('feedback')">
@@ -537,6 +622,53 @@ onMounted(() => {
</div>
</div>
<!-- 开屏图片设置 -->
<div id="section-splash" class="setting-section" @mouseenter="activeTab = 'splash'">
<div class="section-title">开屏图片</div>
<div class="section-subtitle-text">自定义应用启动时的开屏图片</div>
<div class="setting-item">
<div class="splash-preview-container" v-if="splashImageUrl">
<img :src="splashImageUrl" alt="开屏图片预览" class="splash-preview-image" />
</div>
<div class="splash-placeholder" v-else>
<span class="placeholder-icon">🖼</span>
<span class="placeholder-text">未设置自定义开屏图片</span>
</div>
</div>
<div class="setting-item" style="margin-top: 10px;">
<div class="splash-actions">
<input
ref="fileInputRef"
type="file"
accept="image/*"
@change="handleSplashImageUpload"
style="display: none;"
/>
<el-button
type="primary"
size="small"
:loading="uploadingSplashImage"
:disabled="uploadingSplashImage"
@click="triggerFileSelect">
{{ uploadingSplashImage ? '上传中...' : '选择图片' }}
</el-button>
<el-button
v-if="splashImageUrl"
size="small"
:loading="deletingSplashImage"
:disabled="deletingSplashImage"
@click="handleDeleteSplashImage">
{{ deletingSplashImage ? '删除中...' : '删除' }}
</el-button>
</div>
<div class="setting-desc" style="margin-top: 6px;">
支持 JPGPNG 格式大小不超过 5MB建议尺寸 1200x675
</div>
</div>
</div>
<!-- 反馈页面 -->
<div id="section-feedback" class="setting-section" @mouseenter="activeTab = 'feedback'">
<div class="section-title">反馈</div>
@@ -1179,6 +1311,51 @@ onMounted(() => {
font-weight: 600;
color: #1F2937;
}
/* 开屏图片样式 */
.splash-preview-container {
width: 100%;
height: 180px;
border-radius: 6px;
overflow: hidden;
border: 1px solid #E5E6EB;
background: #F8F9FA;
}
.splash-preview-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.splash-placeholder {
width: 100%;
height: 180px;
border-radius: 6px;
border: 2px dashed #E5E6EB;
background: #F8F9FA;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.placeholder-icon {
font-size: 32px;
opacity: 0.5;
}
.placeholder-text {
font-size: 13px;
color: #86909C;
}
.splash-actions {
display: flex;
align-items: center;
gap: 8px;
}
</style>
<script lang="ts">

View File

@@ -64,7 +64,6 @@ public class FangzhouApiServiceImpl implements IFangzhouApiService {
try {
logger.info("刷新 Token");
ResponseEntity<?> response = apiForwarder.post("/tool/mark/refreshToken", null, null);
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();

View File

@@ -1,9 +1,6 @@
package com.ruoyi.web.controller.monitor;
import java.util.Date;
import java.util.List;
import java.util.Map;
import com.ruoyi.common.annotation.Anonymous;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -33,6 +30,17 @@ import com.ruoyi.system.mapper.ClientDeviceMapper;
import com.ruoyi.system.domain.ClientDevice;
import com.ruoyi.system.mapper.ClientAccountDeviceMapper;
import com.ruoyi.system.domain.ClientAccountDevice;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.config.Qiniu;
import com.qiniu.storage.UploadManager;
import com.qiniu.util.Auth;
import com.qiniu.http.Response;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import java.io.InputStream;
import java.util.Date;
/**
@@ -47,11 +55,9 @@ public class ClientAccountController extends BaseController {
@Autowired
private IClientAccountService clientAccountService;
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
// 访问令牌3天
private final long JWT_EXPIRATION = 3L * 24 * 60 * 60 * 1000;
@Autowired
private JwtRsaKeyService jwtRsaKeyService;
@Autowired
@@ -60,6 +66,16 @@ public class ClientAccountController extends BaseController {
private ClientDeviceMapper clientDeviceMapper;
@Autowired
private ClientAccountDeviceMapper accountDeviceMapper;
@Autowired
private RedisCache redisCache;
@Autowired
private Qiniu qiniu;
@Autowired
private UploadManager uploadManager;
@Autowired
private Auth auth;
private static final String SPLASH_IMAGE_CACHE_KEY = "splash_image:";
private AjaxResult checkDeviceLimit(Long accountId, String deviceId, int deviceLimit) {
int activeDeviceCount = accountDeviceMapper.countActiveDevicesByAccountId(accountId);
@@ -160,6 +176,11 @@ public class ClientAccountController extends BaseController {
AjaxResult limitCheck = checkDeviceLimit(account.getId(), clientId, account.getDeviceLimit());
if (limitCheck != null) return limitCheck;
// 更新开屏图片缓存到 Redis
if (StringUtils.isNotEmpty(account.getSplashImage())) {
redisCache.setCacheObject(SPLASH_IMAGE_CACHE_KEY + username, account.getSplashImage());
}
String token = Jwts.builder()
.setHeaderParam("kid", jwtRsaKeyService.getKeyId())
.setSubject(username)
@@ -191,7 +212,6 @@ public class ClientAccountController extends BaseController {
.getBody();
String username = (String) claims.get("sub");
String clientId = (String) claims.get("clientId");
ClientAccount account = clientAccountService.selectClientAccountByUsername(username);
if (account == null || !"0".equals(account.getStatus())) {
return AjaxResult.error("token无效");
@@ -331,4 +351,66 @@ public class ClientAccountController extends BaseController {
return AjaxResult.success(Map.of("expireTime", newExpireTime));
}
/**
* 上传开屏图片
*/
@PostMapping("/splash-image/upload")
public AjaxResult uploadSplashImage(@RequestParam("file") MultipartFile file, @RequestParam("username") String username) {
try {
ClientAccount account = clientAccountService.selectClientAccountByUsername(username);
if (account == null) return AjaxResult.error("账号不存在");
if (!file.getContentType().startsWith("image/")) return AjaxResult.error("只支持图片文件");
if (file.getSize() > 5 * 1024 * 1024) return AjaxResult.error("图片大小不能超过5MB");
String fileName = "splash/" + DateUtil.format(new Date(), "yyyy/MM/") + IdUtil.simpleUUID() + "." + FileUtil.extName(file.getOriginalFilename());
try (InputStream is = file.getInputStream()) {
Response res = uploadManager.put(is, fileName, auth.uploadToken(qiniu.getBucket()), null, "");
if (!res.isOK()) return AjaxResult.error("上传失败");
}
String url = qiniu.getResourcesUrl() + fileName;
account.setSplashImage(url);
clientAccountService.updateClientAccount(account);
redisCache.setCacheObject(SPLASH_IMAGE_CACHE_KEY + username, url);
return AjaxResult.success().put("url", url).put("fileName", fileName);
} catch (Exception e) {
return AjaxResult.error("上传失败");
}
}
/**
* 获取开屏图片
*/
@GetMapping({"/splash-image", "/splash-image/by-username"})
public AjaxResult getSplashImage(@RequestParam("username") String username) {
String url = redisCache.getCacheObject(SPLASH_IMAGE_CACHE_KEY + username);
if (StringUtils.isEmpty(url)) {
ClientAccount account = clientAccountService.selectClientAccountByUsername(username);
if (account != null && StringUtils.isNotEmpty(account.getSplashImage())) {
url = account.getSplashImage();
redisCache.setCacheObject(SPLASH_IMAGE_CACHE_KEY + username, url);
}
}
return AjaxResult.success(Map.of("splashImage", url != null ? url : "", "url", url != null ? url : ""));
}
/**
* 删除开屏图片
*/
@PostMapping("/splash-image/delete")
public AjaxResult deleteSplashImage(@RequestParam("username") String username) {
try {
ClientAccount account = clientAccountService.selectClientAccountByUsername(username);
if (account == null) return AjaxResult.error("账号不存在");
account.setSplashImage(null);
clientAccountService.updateClientAccount(account);
redisCache.deleteObject(SPLASH_IMAGE_CACHE_KEY + username);
return AjaxResult.success("开屏图片已删除");
} catch (Exception e) {
e.printStackTrace();
return AjaxResult.error("删除失败: " + e.getMessage());
}
}
}

View File

@@ -1,5 +1,4 @@
package com.ruoyi.system.domain;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
@@ -9,7 +8,6 @@ import com.ruoyi.common.core.domain.BaseEntity;
*/
public class BanmaAccount extends BaseEntity {
private static final long serialVersionUID = 1L;
private Long id;
/** 显示名 */
private String name;

View File

@@ -55,6 +55,9 @@ public class ClientAccount extends BaseEntity
@Excel(name = "账号类型")
private String accountType; // trial试用, paid付费
/** 开屏图片URL */
private String splashImage;
public void setId(Long id)
{
this.id = id;
@@ -161,4 +164,14 @@ public class ClientAccount extends BaseEntity
{
return accountType;
}
public void setSplashImage(String splashImage)
{
this.splashImage = splashImage;
}
public String getSplashImage()
{
return splashImage;
}
}

View File

@@ -16,6 +16,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="permissions" column="permissions" />
<result property="deviceLimit" column="device_limit" />
<result property="accountType" column="account_type" />
<result property="splashImage" column="splash_image" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
@@ -24,7 +25,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="selectClientAccountVo">
select id, account_name, username, password, status, expire_time,
allowed_ip_range, remark, permissions, device_limit, account_type, create_by, create_time, update_by, update_time
allowed_ip_range, remark, permissions, device_limit, account_type, splash_image, create_by, create_time, update_by, update_time
from client_account
</sql>
@@ -61,6 +62,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="permissions != null">permissions,</if>
<if test="deviceLimit != null">device_limit,</if>
<if test="accountType != null">account_type,</if>
<if test="splashImage != null">splash_image,</if>
<if test="createBy != null">create_by,</if>
create_time
</trim>
@@ -75,6 +77,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="permissions != null">#{permissions},</if>
<if test="deviceLimit != null">#{deviceLimit},</if>
<if test="accountType != null">#{accountType},</if>
<if test="splashImage != null">#{splashImage},</if>
<if test="createBy != null">#{createBy},</if>
sysdate()
</trim>
@@ -93,6 +96,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="permissions != null">permissions = #{permissions},</if>
<if test="deviceLimit != null">device_limit = #{deviceLimit},</if>
<if test="accountType != null">account_type = #{accountType},</if>
<if test="splashImage != null">splash_image = #{splashImage},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
update_time = sysdate()
</trim>