feat(client): 添加品牌logo功能支持

- 在客户端账户实体中新增brandLogo字段用于存储品牌logo URL
- 实现品牌logo的上传、获取和删除接口
- 在Vue前端中集成品牌logo的展示和管理功能- 添加品牌logo的缓存机制提升访问性能
- 在设置对话框中增加品牌logo配置界面
- 实现品牌logo的预览、上传和删除操作
- 添加VIP权限控制确保只有VIP用户可使用该功能
- 增加品牌logo变更事件监听以实时更新界面显示- 更新数据库映射文件以支持brand_logo字段的读写- 在登录成功后异步加载品牌logo配置信息- 调整UI布局以适配品牌logo展示区域- 添加品牌logo相关的样式定义和响应式处理
- 实现品牌logo上传的文件类型和大小校验- 增加品牌logo删除确认提示增强用户体验
- 在App.vue中添加品牌logo的全局状态管理和展示逻辑- 优化品牌logo加载失败时的容错处理
- 完善品牌logo功能的相关错误处理和日志记录
This commit is contained in:
2025-11-10 15:18:38 +08:00
parent 92ab782943
commit cce281497b
9 changed files with 485 additions and 121 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -5,6 +5,7 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css'
import {authApi} from './api/auth'
import {deviceApi, type DeviceItem, type DeviceQuota} from './api/device'
import {splashApi} from './api/splash'
import {getOrCreateDeviceId} from './utils/deviceId'
import {getToken, setToken, removeToken, getUsernameFromToken, getClientIdFromToken} from './utils/token'
import {CONFIG} from './api/http'
@@ -61,7 +62,7 @@ const vipExpireTime = ref<Date | null>(null)
const deviceTrialExpired = ref(false)
const accountType = ref<string>('trial')
const vipStatus = computed(() => {
if (!vipExpireTime.value) return { isVip: false, daysLeft: 0, hoursLeft: 0, status: 'expired', expiredType: 'account' }
if (!vipExpireTime.value) return {isVip: false, daysLeft: 0, hoursLeft: 0, status: 'expired', expiredType: 'account'}
const now = new Date()
const expire = new Date(vipExpireTime.value)
@@ -76,7 +77,7 @@ const vipStatus = computed(() => {
else if (accountExpired) expiredType = 'account'
else if (deviceExpired) expiredType = 'device'
return { isVip: false, daysLeft: 0, hoursLeft: 0, status: 'expired', expiredType }
return {isVip: false, daysLeft: 0, hoursLeft: 0, status: 'expired', expiredType}
}
const hoursLeft = Math.floor(msLeft / (1000 * 60 * 60))
@@ -87,10 +88,10 @@ const vipStatus = computed(() => {
expiredType = 'device' // 试用账号且设备过期
}
if (daysLeft === 0) return { isVip: true, daysLeft, hoursLeft, status: 'warning', expiredType }
if (daysLeft <= 7) return { isVip: true, daysLeft, hoursLeft, status: 'warning', expiredType }
if (daysLeft <= 30) return { isVip: true, daysLeft, hoursLeft, status: 'normal', expiredType }
return { isVip: true, daysLeft, hoursLeft, status: 'active', expiredType }
if (daysLeft === 0) return {isVip: true, daysLeft, hoursLeft, status: 'warning', expiredType}
if (daysLeft <= 7) return {isVip: true, daysLeft, hoursLeft, status: 'warning', expiredType}
if (daysLeft <= 30) return {isVip: true, daysLeft, hoursLeft, status: 'normal', expiredType}
return {isVip: true, daysLeft, hoursLeft, status: 'active', expiredType}
})
// 功能可用性账号VIP + 设备试用期)
@@ -118,6 +119,9 @@ const showAccountManager = ref(false)
// 当前版本
const currentVersion = ref('')
// 品牌logo
const brandLogoUrl = ref('')
// 菜单配置 - 复刻ERP客户端格式
const menuConfig = [
{key: 'rakuten', name: 'Rakuten', index: 'rakuten', icon: 'R', iconImage: rakutenIcon},
@@ -198,7 +202,13 @@ function handleMenuSelect(key: string) {
addToHistory(key)
}
async function handleLoginSuccess(data: { token: string; permissions?: string; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }) {
async function handleLoginSuccess(data: {
token: string;
permissions?: string;
expireTime?: string;
accountType?: string;
deviceTrialExpired?: boolean
}) {
try {
setToken(data.token)
isAuthenticated.value = true
@@ -251,7 +261,7 @@ function clearLocalAuth() {
async function logout() {
try {
const deviceId = getClientIdFromToken()
if (deviceId) await deviceApi.offline({ deviceId, username: currentUsername.value })
if (deviceId) await deviceApi.offline({deviceId, username: currentUsername.value})
} catch (error) {
console.warn('离线通知失败:', error)
}
@@ -270,7 +280,8 @@ async function handleUserClick() {
cancelButtonText: '取消'
})
await logout()
} catch {}
} catch {
}
}
function showRegisterDialog() {
@@ -360,6 +371,19 @@ async function syncSettingsToElectron() {
}
}
// 加载品牌logo
async function loadBrandLogo() {
try {
const username = getUsernameFromToken()
if (!username) return
const res = await splashApi.getBrandLogo(username)
brandLogoUrl.value = res.data.url
} catch (error) {
brandLogoUrl.value = ''
}
}
// 提供给子组件使用
provide('refreshVipStatus', refreshVipStatus)
provide('vipStatus', vipStatus)
@@ -529,6 +553,14 @@ onMounted(async () => {
console.warn('获取当前版本失败:', error)
}
// 加载品牌logo
loadBrandLogo()
// 监听品牌logo变化
window.addEventListener('brandLogoChanged', (e: any) => {
brandLogoUrl.value = e.detail
})
// 全局阻止文件拖拽到窗口(避免意外打开文件)
// 只在指定的 dropzone 区域处理拖拽上传
document.addEventListener('dragover', (e) => {
@@ -584,6 +616,12 @@ onUnmounted(() => {
<div class="user-avatar">
<img src="/icon/icon.png" alt="logo"/>
</div>
<!-- 品牌logo区域有logo时显示 -->
<div v-if="brandLogoUrl" class="brand-logo-section">
<img :src="brandLogoUrl" alt="品牌logo" class="brand-logo"/>
</div>
<div class="menu-group-title">电商平台</div>
<ul class="menu">
<li
@@ -595,7 +633,7 @@ onUnmounted(() => {
>
<span class="menu-text">
<span class="menu-icon" :data-k="item.key">
<img v-if="item.iconImage" :src="item.iconImage" :alt="item.name" class="menu-icon-img" />
<img v-if="item.iconImage" :src="item.iconImage" :alt="item.name" class="menu-icon-img"/>
<template v-else>{{ item.icon }}</template>
</span>
{{ item.name }}
@@ -604,7 +642,8 @@ onUnmounted(() => {
</ul>
<!-- VIP状态卡片 -->
<div v-if="isAuthenticated" class="vip-status-card" :class="'vip-' + vipStatus.status" @click="openSubscriptionDialog">
<div v-if="isAuthenticated" class="vip-status-card" :class="'vip-' + vipStatus.status"
@click="openSubscriptionDialog">
<div class="vip-info">
<div class="vip-status-text">
<template v-if="vipStatus.isVip">
@@ -616,7 +655,13 @@ onUnmounted(() => {
</template>
</div>
<div class="vip-expire-date" v-if="vipExpireTime">
有效期至{{ new Date(vipExpireTime).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) }}
有效期至{{
new Date(vipExpireTime).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}}
</div>
</div>
</div>
@@ -672,16 +717,20 @@ onUnmounted(() => {
@back-to-login="backToLogin"/>
<!-- 更新对话框 -->
<UpdateDialog ref="updateDialogRef" v-model="showUpdateDialog" />
<UpdateDialog ref="updateDialogRef" v-model="showUpdateDialog"/>
<!-- 设置对话框 -->
<SettingsDialog v-model="showSettingsDialog" @auto-update-changed="handleAutoUpdateChanged" @open-update-dialog="handleOpenUpdateDialog" />
<SettingsDialog
v-model="showSettingsDialog"
:is-vip="canUseFunctions"
@auto-update-changed="handleAutoUpdateChanged"
@open-update-dialog="handleOpenUpdateDialog" />
<!-- 试用期过期弹框 -->
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType"/>
<!-- 账号管理弹框 -->
<AccountManager v-model="showAccountManager" platform="zebra" />
<AccountManager v-model="showAccountManager" platform="zebra"/>
<!-- 设备管理弹框 -->
<el-dialog
@@ -693,7 +742,9 @@ onUnmounted(() => {
<template #header>
<div class="device-dialog-header">
<img src="/icon/img.png" alt="devices" class="device-illustration"/>
<div class="device-title">设备管理 <span class="device-count">({{ deviceQuota.used || 0 }}/{{ deviceQuota.limit || 0 }})</span></div>
<div class="device-title">设备管理 <span class="device-count">({{
deviceQuota.used || 0
}}/{{ deviceQuota.limit || 0 }})</span></div>
<div class="device-subtitle">当前账号可以授权绑定 {{ deviceQuota.limit }} 台设备</div>
</div>
</template>
@@ -829,6 +880,24 @@ onUnmounted(() => {
text-align: left;
}
/* 品牌logo区域 */
.brand-logo-section {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 48px;
margin-bottom: 12px;
padding: 0 4px;
box-sizing: border-box;
}
.brand-logo {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.menu {
list-style: none;
padding: 0;
@@ -952,6 +1021,7 @@ onUnmounted(() => {
font-size: 13px;
color: #606266;
}
.device-dialog-header {
display: flex;
flex-direction: column;
@@ -959,31 +1029,45 @@ onUnmounted(() => {
padding: 12px 0 4px 0;
margin-left: 40px;
}
.device-dialog :deep(.el-dialog__header) {
text-align: center;
}
.device-dialog :deep(.el-dialog__body) { padding-top: 0; }
.device-dialog :deep(.el-dialog__body) {
padding-top: 0;
}
.device-illustration {
width: 180px;
height: auto;
object-fit: contain;
margin-bottom: 8px;
}
.device-title {
font-size: 18px;
font-weight: 600;
color: #303133;
margin-bottom: 6px;
}
.device-count { color: #909399; font-weight: 500; }
.device-subtitle { font-size: 12px; color: #909399; }
.device-count {
color: #909399;
font-weight: 500;
}
.device-subtitle {
font-size: 12px;
color: #909399;
}
/* 浮动版本信息 */
.version-info {
position: fixed;
right: 10px;
bottom: 10px;
background: rgba(255,255,255,0.9);
background: rgba(255, 255, 255, 0.9);
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;

View File

@@ -27,6 +27,24 @@ export const splashApi = {
// 删除自定义开屏图片(恢复默认)
async deleteSplashImage(username: string) {
return http.post<{ data: string }>(`/monitor/account/splash-image/delete?username=${username}`)
},
// 上传品牌logo
async uploadBrandLogo(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/brand-logo/upload', formData)
},
// 获取当前用户的品牌logo
async getBrandLogo(username: string) {
return http.get<{ data: { url: string } }>('/monitor/account/brand-logo', { username })
},
// 删除品牌logo
async deleteBrandLogo(username: string) {
return http.post<{ data: string }>(`/monitor/account/brand-logo/delete?username=${username}`)
}
}

View File

@@ -42,8 +42,9 @@ async function handleAuth() {
clientId: deviceId
})
// 保存开屏图片配置(不阻塞登录)
// 保存开屏图片配置和品牌logo(不阻塞登录)
saveSplashConfigInBackground(authForm.value.username)
saveBrandLogoInBackground(authForm.value.username)
emit('loginSuccess', {
token: loginRes.data.accessToken || loginRes.data.token,
@@ -90,6 +91,18 @@ async function saveSplashConfigInBackground(username: string) {
console.error('[开屏图片] 保存配置失败:', error)
}
}
// 保存品牌logo配置
async function saveBrandLogoInBackground(username: string) {
try {
const res = await splashApi.getBrandLogo(username)
const url = res?.data?.url || ''
// 触发App.vue加载品牌logo
window.dispatchEvent(new CustomEvent('brandLogoChanged', { detail: url }))
} catch (error) {
console.error('[品牌logo] 加载配置失败:', error)
}
}
</script>
<template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { ref, computed, onMounted, watch, nextTick, inject, defineAsyncComponent } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getSettings,
@@ -14,8 +14,13 @@ import { getOrCreateDeviceId } from '../../utils/deviceId'
import { updateApi } from '../../api/update'
import { splashApi } from '../../api/splash'
const TrialExpiredDialog = defineAsyncComponent(() => import('./TrialExpiredDialog.vue'))
const refreshVipStatus = inject<() => Promise<boolean>>('refreshVipStatus')
const vipStatus = inject<any>('vipStatus')
interface Props {
modelValue: boolean
isVip?: boolean
}
interface Emits {
@@ -75,6 +80,16 @@ const uploadingSplashImage = ref(false)
const deletingSplashImage = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
// 品牌logo相关
const brandLogoUrl = ref('')
const uploadingBrandLogo = ref(false)
const deletingBrandLogo = ref(false)
const brandLogoInputRef = ref<HTMLInputElement | null>(null)
// VIP过期弹窗
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both' | 'subscribe'>('account')
const show = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
@@ -86,6 +101,7 @@ watch(() => props.modelValue, (newVal) => {
loadAllSettings()
loadCurrentVersion()
loadSplashImage()
loadBrandLogo()
}
})
@@ -381,7 +397,7 @@ async function loadSplashImage() {
if (!username) return
const res = await splashApi.getSplashImage(username)
splashImageUrl.value = res?.data?.data?.url || res?.data?.url || ''
splashImageUrl.value = res.data.url
} catch (error) {
splashImageUrl.value = ''
}
@@ -392,6 +408,15 @@ async function handleSplashImageUpload(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files || input.files.length === 0) return
// VIP验证
if (refreshVipStatus) await refreshVipStatus()
if (!props.isVip) {
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
showTrialExpiredDialog.value = true
input.value = ''
return
}
const file = input.files[0]
const username = getUsernameFromToken()
if (!username) return ElMessage.warning('请先登录')
@@ -401,12 +426,9 @@ async function handleSplashImageUpload(event: Event) {
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('开屏图片设置成功,重启应用后生效')
}
splashImageUrl.value = res.url
await (window as any).electronAPI.saveSplashConfig(username, res.url)
ElMessage.success('开屏图片设置成功,重启应用后生效')
} catch (error: any) {
ElMessage.error(error?.message || '上传失败')
} finally {
@@ -439,11 +461,87 @@ async function handleDeleteSplashImage() {
}
}
// 加载品牌logo
async function loadBrandLogo() {
try {
const username = getUsernameFromToken()
if (!username) return
const res = await splashApi.getBrandLogo(username)
brandLogoUrl.value = res.data.url
} catch (error) {
brandLogoUrl.value = ''
}
}
// 上传品牌logo
async function handleBrandLogoUpload(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files || input.files.length === 0) return
// VIP验证
if (refreshVipStatus) await refreshVipStatus()
if (!props.isVip) {
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
showTrialExpiredDialog.value = true
input.value = ''
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 {
uploadingBrandLogo.value = true
const res = await splashApi.uploadBrandLogo(file, username)
brandLogoUrl.value = res.url
ElMessage.success('品牌logo设置成功')
window.dispatchEvent(new CustomEvent('brandLogoChanged', { detail: res.url }))
} catch (error: any) {
ElMessage.error(error?.message || '上传失败')
} finally {
uploadingBrandLogo.value = false
input.value = ''
}
}
// 删除品牌logo
async function handleDeleteBrandLogo() {
try {
await ElMessageBox.confirm(
'确定要删除品牌logo吗删除后将隐藏logo区域。',
'确认删除',
{ confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }
)
deletingBrandLogo.value = true
const username = getUsernameFromToken()
if (!username) return ElMessage.warning('请先登录')
await splashApi.deleteBrandLogo(username)
// 立即清空本地状态
brandLogoUrl.value = ''
ElMessage.success('品牌logo已删除')
// 立即触发App.vue清空logo
window.dispatchEvent(new CustomEvent('brandLogoChanged', { detail: '' }))
} catch (error: any) {
if (error !== 'cancel') ElMessage.error(error?.message || '删除失败')
} finally {
deletingBrandLogo.value = false
}
}
onMounted(() => {
loadAllSettings()
loadLogDates()
loadCurrentVersion()
loadSplashImage()
loadBrandLogo()
})
</script>
@@ -486,6 +584,12 @@ onMounted(() => {
<span class="sidebar-icon">🖼</span>
<span class="sidebar-text">开屏图片</span>
</div>
<div
:class="['sidebar-item', { active: activeTab === 'brand' }]"
@click="scrollToSection('brand')">
<span class="sidebar-icon">🏷</span>
<span class="sidebar-text">品牌logo</span>
</div>
<div
:class="['sidebar-item', { active: activeTab === 'feedback' }]"
@click="scrollToSection('feedback')">
@@ -624,48 +728,67 @@ onMounted(() => {
<!-- 开屏图片设置 -->
<div id="section-splash" class="setting-section" @mouseenter="activeTab = 'splash'">
<div class="section-title">开屏图片</div>
<div class="section-subtitle-text">自定义应用启动时的开屏图片</div>
<div class="section-title-row">
<div class="section-title">开屏图片</div>
<img src="/icon/vipExclusive.png" alt="VIP专享" class="vip-exclusive-logo" />
</div>
<div class="section-subtitle-text">支持.jpg .png格式最佳显示尺寸/比例 1200*736</div>
<div class="setting-item">
<div class="splash-preview-container" v-if="splashImageUrl">
<img :src="splashImageUrl" alt="开屏图片预览" class="splash-preview-image" />
<div class="image-preview-wrapper" v-if="splashImageUrl">
<img :src="splashImageUrl" alt="开屏图片预览" class="preview-image" />
<div class="image-buttons">
<input ref="fileInputRef" type="file" accept="image/*" @change="handleSplashImageUpload" style="display: none;" />
<button class="img-btn" :disabled="uploadingSplashImage" @click="triggerFileSelect">
{{ uploadingSplashImage ? '上传中' : '更换' }}
</button>
<button class="img-btn" :disabled="deletingSplashImage" @click="handleDeleteSplashImage">
{{ deletingSplashImage ? '删除中' : '删除' }}
</button>
</div>
</div>
<div class="splash-placeholder" v-else>
<span class="placeholder-icon">🖼</span>
<span class="placeholder-text">未设置自定义开屏图片</span>
<input ref="fileInputRef" type="file" accept="image/*" @change="handleSplashImageUpload" style="display: none;" />
<el-button type="primary" size="small" style="margin-top: 8px;" :loading="uploadingSplashImage" @click="triggerFileSelect">
{{ uploadingSplashImage ? '上传中' : '选择图片' }}
</el-button>
</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 ? '删除中...' : '删除' }}
</div>
</div>
<!-- 品牌logo页面 -->
<div id="section-brand" class="setting-section" @mouseenter="activeTab = 'brand'">
<div class="section-title-row">
<div class="section-title">品牌logo</div>
<img src="/icon/vipExclusive.png" alt="VIP专享" class="vip-exclusive-logo" />
</div>
<div class="section-subtitle-text"> 支持 JPGPNG 格式最佳显示尺寸/比例 1200*736</div>
<div class="setting-item">
<div class="image-preview-wrapper" v-if="brandLogoUrl">
<img :src="brandLogoUrl" alt="品牌logo" class="preview-image" />
<div class="image-buttons">
<input ref="brandLogoInputRef" type="file" accept="image/*" @change="handleBrandLogoUpload" style="display: none;" />
<button class="img-btn" :disabled="uploadingBrandLogo" @click="brandLogoInputRef?.click()">
{{ uploadingBrandLogo ? '上传中' : '更换' }}
</button>
<button class="img-btn" :disabled="deletingBrandLogo" @click="handleDeleteBrandLogo">
{{ deletingBrandLogo ? '删除中' : '删除' }}
</button>
</div>
</div>
<div class="splash-placeholder" v-else>
<span class="placeholder-icon">🏷</span>
<span class="placeholder-text">未设置品牌logo</span>
<input ref="brandLogoInputRef" type="file" accept="image/*" @change="handleBrandLogoUpload" style="display: none;" />
<el-button type="primary" size="small" style="margin-top: 8px;" :loading="uploadingBrandLogo" @click="brandLogoInputRef?.click()">
{{ uploadingBrandLogo ? '上传中' : '选择图片' }}
</el-button>
</div>
<div class="setting-desc" style="margin-top: 6px;">
支持 JPGPNG 格式大小不超过 5MB建议尺寸 1200x675
</div>
</div>
</div>
@@ -737,6 +860,9 @@ onMounted(() => {
</div>
</div>
</Transition>
<!-- VIP过期提示弹窗 -->
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
</Teleport>
</template>
@@ -940,9 +1066,21 @@ onMounted(() => {
font-weight: 600;
color: #1F2937;
text-align: left;
}
.section-title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.vip-exclusive-logo {
width: 60px;
height: auto;
vertical-align: middle;
}
.section-header .section-title {
margin-bottom: 0;
}
@@ -1100,6 +1238,53 @@ onMounted(() => {
margin-top: 8px;
}
/* 图片预览容器 */
.image-preview-wrapper {
position: relative;
width: 75%;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.image-buttons {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
}
.image-preview-wrapper:hover .image-buttons {
opacity: 1;
}
.img-btn {
padding: 6px 14px;
background: rgba(0, 0, 0, 0.6);
border: none;
border-radius: 4px;
color: #fff;
font-size: 12px;
cursor: pointer;
transition: background 0.2s ease;
}
.img-btn:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.8);
}
.img-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.feedback-form :deep(.el-textarea__inner) {
border-color: #E5E6EB;
border-radius: 6px;
@@ -1312,22 +1497,7 @@ onMounted(() => {
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;
@@ -1350,12 +1520,6 @@ onMounted(() => {
font-size: 13px;
color: #86909C;
}
.splash-actions {
display: flex;
align-items: center;
gap: 8px;
}
</style>
<script lang="ts">

View File

@@ -76,6 +76,7 @@ public class ClientAccountController extends BaseController {
private Auth auth;
private static final String SPLASH_IMAGE_CACHE_KEY = "splash_image:";
private static final String BRAND_LOGO_CACHE_KEY = "brand_logo:";
private AjaxResult checkDeviceLimit(Long accountId, String deviceId, int deviceLimit) {
int activeDeviceCount = accountDeviceMapper.countActiveDevicesByAccountId(accountId);
@@ -181,6 +182,11 @@ public class ClientAccountController extends BaseController {
redisCache.setCacheObject(SPLASH_IMAGE_CACHE_KEY + username, account.getSplashImage());
}
// 更新品牌logo缓存到 Redis
if (StringUtils.isNotEmpty(account.getBrandLogo())) {
redisCache.setCacheObject(BRAND_LOGO_CACHE_KEY + username, account.getBrandLogo());
}
String token = Jwts.builder()
.setHeaderParam("kid", jwtRsaKeyService.getKeyId())
.setSubject(username)
@@ -413,4 +419,66 @@ public class ClientAccountController extends BaseController {
}
}
/**
* 上传品牌logo
*/
@PostMapping("/brand-logo/upload")
public AjaxResult uploadBrandLogo(@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 = "brand-logo/" + 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.setBrandLogo(url);
clientAccountService.updateClientAccount(account);
redisCache.setCacheObject(BRAND_LOGO_CACHE_KEY + username, url);
return AjaxResult.success().put("url", url).put("fileName", fileName);
} catch (Exception e) {
return AjaxResult.error("上传失败");
}
}
/**
* 获取品牌logo
*/
@GetMapping("/brand-logo")
public AjaxResult getBrandLogo(@RequestParam("username") String username) {
String url = redisCache.getCacheObject(BRAND_LOGO_CACHE_KEY + username);
if (StringUtils.isEmpty(url)) {
ClientAccount account = clientAccountService.selectClientAccountByUsername(username);
if (account != null && StringUtils.isNotEmpty(account.getBrandLogo())) {
url = account.getBrandLogo();
redisCache.setCacheObject(BRAND_LOGO_CACHE_KEY + username, url);
}
}
return AjaxResult.success(Map.of("url", url != null ? url : ""));
}
/**
* 删除品牌logo
*/
@PostMapping("/brand-logo/delete")
public AjaxResult deleteBrandLogo(@RequestParam("username") String username) {
try {
ClientAccount account = clientAccountService.selectClientAccountByUsername(username);
if (account == null) return AjaxResult.error("账号不存在");
account.setBrandLogo(null);
clientAccountService.updateClientAccount(account);
redisCache.deleteObject(BRAND_LOGO_CACHE_KEY + username);
return AjaxResult.success("品牌logo已删除");
} catch (Exception e) {
e.printStackTrace();
return AjaxResult.error("删除失败: " + e.getMessage());
}
}
}

View File

@@ -58,6 +58,9 @@ public class ClientAccount extends BaseEntity
/** 开屏图片URL */
private String splashImage;
/** 品牌logo URL */
private String brandLogo;
public void setId(Long id)
{
this.id = id;
@@ -174,4 +177,14 @@ public class ClientAccount extends BaseEntity
{
return splashImage;
}
public void setBrandLogo(String brandLogo)
{
this.brandLogo = brandLogo;
}
public String getBrandLogo()
{
return brandLogo;
}
}

View File

@@ -17,6 +17,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="deviceLimit" column="device_limit" />
<result property="accountType" column="account_type" />
<result property="splashImage" column="splash_image" />
<result property="brandLogo" column="brand_logo" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
<result property="updateBy" column="update_by" />
@@ -25,7 +26,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, splash_image, create_by, create_time, update_by, update_time
allowed_ip_range, remark, permissions, device_limit, account_type, splash_image, brand_logo, create_by, create_time, update_by, update_time
from client_account
</sql>
@@ -63,6 +64,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="deviceLimit != null">device_limit,</if>
<if test="accountType != null">account_type,</if>
<if test="splashImage != null">splash_image,</if>
<if test="brandLogo != null">brand_logo,</if>
<if test="createBy != null">create_by,</if>
create_time
</trim>
@@ -78,6 +80,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="deviceLimit != null">#{deviceLimit},</if>
<if test="accountType != null">#{accountType},</if>
<if test="splashImage != null">#{splashImage},</if>
<if test="brandLogo != null">#{brandLogo},</if>
<if test="createBy != null">#{createBy},</if>
sysdate()
</trim>
@@ -97,6 +100,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<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="brandLogo != null">brand_logo = #{brandLogo},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
update_time = sysdate()
</trim>