From 5454bbe86b1a22e9f05b6bc43f7ed7e9d6c4dc14 Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期五, 03 四月 2026 09:34:15 +0800
Subject: [PATCH] #版权 PROJECT_COPYRIGHT logo PROJECT_LOGO 配置项和页面优化
---
rsf-design/src/api/system-manage.js | 23 +
rsf-design/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue | 31 +-
rsf-design/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss | 60 ++-
rsf-design/src/components/core/base/art-logo/index.vue | 70 +++++
rsf-design/vite.config.js | 5
rsf-design/src/components/core/views/login/AuthTopBar.vue | 111 ++++++-
rsf-design/src/components/core/views/login/LoginLeftView.vue | 13
rsf-design/src/locales/langs/en.json | 11
rsf-design/src/views/system/config/index.vue | 46 +++
rsf-design/src/views/system/config/modules/config-dialog.vue | 87 ++++++
rsf-design/src/locales/langs/zh.json | 11
rsf-design/src/locales/index.js | 5
rsf-design/src/components/core/base/art-copyright/index.vue | 67 +++++
rsf-design/.env.development | 3
rsf-server/src/main/java/com/vincent/rsf/server/common/security/SecurityConfig.java | 2
rsf-design/src/locales/language-options.js | 8
rsf-design/src/views/system/config/modules/config-detail-drawer.vue | 13
rsf-server/src/main/java/com/vincent/rsf/server/system/controller/ProjectLogoController.java | 183 ++++++++++++++
rsf-design/src/components/core/layouts/art-header-bar/index.vue | 10
rsf-design/src/views/system/config/configPage.helpers.js | 8
20 files changed, 674 insertions(+), 93 deletions(-)
diff --git a/rsf-design/.env.development b/rsf-design/.env.development
index e68456a..8b3e566 100644
--- a/rsf-design/.env.development
+++ b/rsf-design/.env.development
@@ -11,3 +11,6 @@
# Delete console
VITE_DROP_CONSOLE = false
+
+# 鏄惁寮�鍚� Vue DevTools / Inspector锛堝紑鍚悗浼氬鍔犳湰鍦版ā鍧楄浆鎹㈣�楁椂锛�
+VITE_ENABLE_VUE_DEVTOOLS = false
diff --git a/rsf-design/src/api/system-manage.js b/rsf-design/src/api/system-manage.js
index 407b47c..2e62426 100644
--- a/rsf-design/src/api/system-manage.js
+++ b/rsf-design/src/api/system-manage.js
@@ -371,6 +371,14 @@
return request.get({ url: `/config/${id}` })
}
+function fetchPublicProjectLogoConfig() {
+ return request.get({ url: '/config/public/project-logo' })
+}
+
+function fetchPublicProjectCopyrightConfig() {
+ return request.get({ url: '/config/public/project-copyright' })
+}
+
function fetchSaveConfig(params) {
return request.post({ url: '/config/save', params })
}
@@ -381,6 +389,18 @@
function fetchDeleteConfig(id) {
return request.post({ url: `/config/remove/${id}` })
+}
+
+function fetchUploadProjectLogo(file) {
+ const formData = new FormData()
+ formData.append('file', file)
+ return request.post({
+ url: '/config/logo/upload',
+ data: formData,
+ headers: {
+ 'Content-Type': 'multipart/form-data'
+ }
+ })
}
function fetchGetOperationRecordDetail(id) {
@@ -868,9 +888,12 @@
fetchExportOperationRecordReport,
fetchConfigPage,
fetchGetConfigDetail,
+ fetchPublicProjectLogoConfig,
+ fetchPublicProjectCopyrightConfig,
fetchSaveConfig,
fetchUpdateConfig,
fetchDeleteConfig,
+ fetchUploadProjectLogo,
fetchSerialRulePage,
fetchGetSerialRuleDetail,
fetchSaveSerialRule,
diff --git a/rsf-design/src/components/core/base/art-copyright/index.vue b/rsf-design/src/components/core/base/art-copyright/index.vue
new file mode 100644
index 0000000..a247991
--- /dev/null
+++ b/rsf-design/src/components/core/base/art-copyright/index.vue
@@ -0,0 +1,67 @@
+<template>
+ <div class="art-copyright">{{ copyrightText }}</div>
+</template>
+
+<script setup>
+ import AppConfig from '@/config'
+ import { fetchPublicProjectCopyrightConfig } from '@/api/system-manage'
+
+ const PROJECT_COPYRIGHT_UPDATED_EVENT = 'project-copyright-updated'
+ let cachedCopyrightText = ''
+ let copyrightRequest = null
+
+ defineOptions({ name: 'ArtCopyright' })
+
+ const copyrightText = ref(cachedCopyrightText || getDefaultCopyright())
+
+ function getDefaultCopyright() {
+ return `鐗堟潈鎵�鏈� 漏 ${AppConfig.systemInfo.name}`
+ }
+
+ function normalizeCopyright(value) {
+ const normalized = String(value || '').trim()
+ return normalized || getDefaultCopyright()
+ }
+
+ async function loadProjectCopyright(force = false) {
+ if (cachedCopyrightText && !force) {
+ copyrightText.value = cachedCopyrightText
+ return
+ }
+
+ if (!copyrightRequest || force) {
+ copyrightRequest = fetchPublicProjectCopyrightConfig()
+ .then((response) => normalizeCopyright(response?.val))
+ .catch(() => getDefaultCopyright())
+ .then((resolvedCopyright) => {
+ cachedCopyrightText = resolvedCopyright
+ return resolvedCopyright
+ })
+ }
+
+ copyrightText.value = await copyrightRequest
+ }
+
+ function handleProjectCopyrightUpdated(event) {
+ const nextCopyright = normalizeCopyright(event?.detail?.text)
+ cachedCopyrightText = nextCopyright
+ copyrightRequest = Promise.resolve(nextCopyright)
+ copyrightText.value = nextCopyright
+ }
+
+ onMounted(() => {
+ loadProjectCopyright()
+ window.addEventListener(PROJECT_COPYRIGHT_UPDATED_EVENT, handleProjectCopyrightUpdated)
+ })
+
+ onBeforeUnmount(() => {
+ window.removeEventListener(PROJECT_COPYRIGHT_UPDATED_EVENT, handleProjectCopyrightUpdated)
+ })
+</script>
+
+<style lang="scss" scoped>
+ .art-copyright {
+ white-space: pre-line;
+ word-break: break-word;
+ }
+</style>
diff --git a/rsf-design/src/components/core/base/art-logo/index.vue b/rsf-design/src/components/core/base/art-logo/index.vue
index c7b2a8f..f00e13d 100644
--- a/rsf-design/src/components/core/base/art-logo/index.vue
+++ b/rsf-design/src/components/core/base/art-logo/index.vue
@@ -1,14 +1,76 @@
<!-- 绯荤粺logo -->
<template>
- <div class="flex-cc">
- <img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" />
+ <div class="flex-cc" :style="wrapperStyle">
+ <img :style="logoStyle" :src="logoSrc" alt="logo" class="w-full h-full object-contain" />
</div>
</template>
<script setup>
+ import defaultLogo from '@imgs/common/logo.webp'
+ import { fetchPublicProjectLogoConfig } from '@/api/system-manage'
+
+ const PROJECT_LOGO_UPDATED_EVENT = 'project-logo-updated'
+ let cachedLogoSrc = ''
+ let logoRequest = null
+
defineOptions({ name: 'ArtLogo' })
const props = defineProps({
- size: { required: false, default: 36 }
+ size: { required: false, default: 36 },
+ fill: { type: Boolean, default: false }
})
- const logoStyle = computed(() => ({ width: `${props.size}px` }))
+ const logoSrc = ref(cachedLogoSrc || defaultLogo)
+ const wrapperStyle = computed(() => (props.fill ? { width: '100%', height: '100%' } : {}))
+ const logoStyle = computed(() => {
+ if (props.fill) {
+ return { width: '100%', height: '100%' }
+ }
+ return { width: resolveLogoSize(props.size) }
+ })
+
+ function resolveLogoSize(size) {
+ if (typeof size === 'number') return `${size}px`
+ const normalizedSize = String(size || '').trim()
+ if (!normalizedSize) return '36px'
+ return /^\d+(\.\d+)?$/.test(normalizedSize) ? `${normalizedSize}px` : normalizedSize
+ }
+
+ function normalizeLogoSrc(value) {
+ const normalized = String(value || '').trim()
+ return normalized || defaultLogo
+ }
+
+ async function loadProjectLogo(force = false) {
+ if (cachedLogoSrc && !force) {
+ logoSrc.value = cachedLogoSrc
+ return
+ }
+
+ if (!logoRequest || force) {
+ logoRequest = fetchPublicProjectLogoConfig()
+ .then((response) => normalizeLogoSrc(response?.val))
+ .catch(() => defaultLogo)
+ .then((resolvedLogo) => {
+ cachedLogoSrc = resolvedLogo
+ return resolvedLogo
+ })
+ }
+
+ logoSrc.value = await logoRequest
+ }
+
+ function handleProjectLogoUpdated(event) {
+ const nextLogoSrc = normalizeLogoSrc(event?.detail?.url)
+ cachedLogoSrc = nextLogoSrc
+ logoRequest = Promise.resolve(nextLogoSrc)
+ logoSrc.value = nextLogoSrc
+ }
+
+ onMounted(() => {
+ loadProjectLogo()
+ window.addEventListener(PROJECT_LOGO_UPDATED_EVENT, handleProjectLogoUpdated)
+ })
+
+ onBeforeUnmount(() => {
+ window.removeEventListener(PROJECT_LOGO_UPDATED_EVENT, handleProjectLogoUpdated)
+ })
</script>
diff --git a/rsf-design/src/components/core/layouts/art-header-bar/index.vue b/rsf-design/src/components/core/layouts/art-header-bar/index.vue
index 0e2cb59..2b37e88 100644
--- a/rsf-design/src/components/core/layouts/art-header-bar/index.vue
+++ b/rsf-design/src/components/core/layouts/art-header-bar/index.vue
@@ -124,7 +124,7 @@
<!-- 涓婚鍒囨崲鎸夐挳 -->
<ArtIconButton
v-if="shouldShowThemeToggle"
- @click="themeAnimation"
+ @click="handleThemeAnimation"
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
/>
@@ -140,9 +140,7 @@
<script setup>
import AppConfig from '@/config'
- import { languageOptions } from '@/locales'
-
- import { themeAnimation } from '@/utils/ui/animation'
+ import { languageOptions } from '@/locales/language-options'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
@@ -225,6 +223,10 @@
const openChat = () => {
mittBus.emit('openChat')
}
+ const handleThemeAnimation = async (event) => {
+ const { themeAnimation } = await import('@/utils/ui/animation')
+ themeAnimation(event)
+ }
</script>
<style lang="scss" scoped>
diff --git a/rsf-design/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue b/rsf-design/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue
index cabd2ff..4b5b501 100644
--- a/rsf-design/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue
+++ b/rsf-design/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue
@@ -66,7 +66,7 @@
:class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`"
:style="{ background: getMenuTheme.background }"
>
- <!-- Logo銆佺郴缁熷悕绉� -->
+ <!-- Logo -->
<div
class="header"
@click="navigateToHome"
@@ -74,17 +74,7 @@
background: getMenuTheme.background
}"
>
- <ArtLogo v-if="!isDualMenu" class="logo" />
-
- <p
- :class="{ 'is-dual-menu-name': isDualMenu }"
- :style="{
- color: getMenuTheme.systemNameColor,
- opacity: !menuOpen ? 0 : 1
- }"
- >
- {{ AppConfig.systemInfo.name }}
- </p>
+ <ArtLogo v-if="!isDualMenu" class="logo" fill />
</div>
<ElScrollbar :style="scrollbarStyle">
<ElMenu
@@ -109,6 +99,16 @@
</ElMenu>
</ElScrollbar>
+ <div
+ v-if="showSidebarFooter"
+ class="footer"
+ :style="{
+ background: getMenuTheme.background
+ }"
+ >
+ <ArtCopyright class="copyright" />
+ </div>
+
<!-- 鍙屽垪鑿滃崟鍙充晶鎶樺彔鎸夐挳 -->
<div class="dual-menu-collapse-btn" v-if="isDualMenu" @click="toggleMenuVisibility">
<ArtSvgIcon
@@ -130,9 +130,9 @@
</template>
<script setup>
- import AppConfig from '@/config'
import { handleMenuJump } from '@/utils/navigation'
import { formatMenuTitle } from '@/utils/router'
+ import ArtCopyright from '@/components/core/base/art-copyright/index.vue'
import SidebarSubmenu from './widget/SidebarSubmenu.vue'
@@ -216,11 +216,14 @@
return `${menuType.value}:static`
})
+ const showSidebarFooter = computed(() => !isMobileScreen.value && menuOpen.value)
const scrollbarStyle = computed(() => {
const isCollapsed = isDualMenu.value && !menuOpen.value
return {
+ flex: isCollapsed ? '0 0 calc(100% + 50px)' : '1 1 auto',
+ minHeight: 0,
transform: isCollapsed ? 'translateY(-50px)' : 'translateY(0)',
- height: isCollapsed ? 'calc(100% + 50px)' : 'calc(100% - 60px)',
+ height: isCollapsed ? 'calc(100% + 50px)' : 'auto',
transition: 'transform 0.3s ease'
}
})
diff --git a/rsf-design/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss b/rsf-design/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss
index 2385966..12b9ded 100644
--- a/rsf-design/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss
+++ b/rsf-design/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss
@@ -102,6 +102,8 @@
.menu-left {
position: relative;
box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
height: 100vh;
@media only screen and (width <= 640px) {
@@ -110,6 +112,24 @@
.el-menu {
height: 100%;
+ }
+
+ .footer {
+ flex-shrink: 0;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 8px 14px 10px;
+ text-align: center;
+ border-top: 1px solid var(--art-card-border);
+
+ .copyright {
+ width: 100%;
+ font-size: 12px;
+ line-height: 1.6;
+ color: var(--art-gray-500);
+ }
}
&:hover {
@@ -155,29 +175,19 @@
box-sizing: border-box;
display: flex;
align-items: center;
+ justify-content: center;
width: 100%;
height: 60px;
+ padding: 10px 18px;
overflow: hidden;
line-height: 60px;
cursor: pointer;
.logo {
- margin-left: 22px;
- }
-
- p {
- position: absolute;
- top: 0;
- bottom: 0;
- left: 58px;
- box-sizing: border-box;
- margin-left: 10px;
- font-size: 18px;
-
- &.is-dual-menu-name {
- left: 25px;
- margin: auto;
- }
+ width: 100%;
+ height: 100%;
+ max-width: 126px;
+ max-height: 40px;
}
}
@@ -209,7 +219,7 @@
}
.el-menu {
- height: calc(100vh - 60px);
+ height: auto;
}
.el-menu--collapse {
@@ -219,13 +229,9 @@
// 鎶樺彔鐘舵�佷笅鐨刪eader鏍峰紡
.menu-left-close .header {
.logo {
- display: none;
- }
-
- p {
- left: 16px;
- font-size: 0;
- opacity: 0 !important;
+ width: 100%;
+ max-width: 108px;
+ max-height: 34px;
}
}
@@ -260,5 +266,11 @@
.dual-menu-left {
border-right: 1px solid rgb(255 255 255 / 9%) !important;
}
+
+ .menu-left {
+ .footer {
+ border-top-color: rgb(255 255 255 / 9%);
+ }
+ }
}
}
diff --git a/rsf-design/src/components/core/views/login/AuthTopBar.vue b/rsf-design/src/components/core/views/login/AuthTopBar.vue
index 9e086e0..4ce50ab 100644
--- a/rsf-design/src/components/core/views/login/AuthTopBar.vue
+++ b/rsf-design/src/components/core/views/login/AuthTopBar.vue
@@ -3,9 +3,8 @@
<div
class="absolute w-full flex-cb top-4.5 z-10 flex-c !justify-end max-[1180px]:!justify-between"
>
- <div class="flex-cc !hidden max-[1180px]:!flex ml-2 max-sm:ml-6">
- <ArtLogo class="icon" size="46" />
- <h1 class="text-xl ont-mediumf ml-2">{{ AppConfig.systemInfo.name }}</h1>
+ <div class="brand flex-cc !hidden max-[1180px]:!flex ml-2 max-sm:ml-6">
+ <ArtLogo class="icon" fill />
</div>
<div class="flex-cc gap-1.5 mr-2 max-sm:mr-5">
@@ -31,35 +30,35 @@
/>
</div>
</div>
- <ElDropdown
+ <div
v-if="shouldShowLanguage"
- @command="changeLanguage"
- popper-class="langDropDownStyle"
+ class="language-picker relative flex-c"
+ @mouseenter="showLanguageMenu = true"
+ @mouseleave="showLanguageMenu = false"
>
- <div class="btn language-btn h-8 w-8 c-p flex-cc tad-300">
+ <div class="btn language-btn h-8 w-8 c-p flex-cc tad-300" @click="toggleLanguageMenu">
<ArtSvgIcon
icon="ri:translate-2"
class="text-[19px] text-g-800 transition-colors duration-300"
/>
</div>
- <template #dropdown>
- <ElDropdownMenu>
- <div v-for="lang in languageOptions" :key="lang.value" class="lang-btn-item">
- <ElDropdownItem
- :command="lang.value"
- :class="{ 'is-selected': locale === lang.value }"
- >
- <span class="menu-txt">{{ lang.label }}</span>
- <ArtSvgIcon icon="ri:check-fill" class="text-base" v-if="locale === lang.value" />
- </ElDropdownItem>
- </div>
- </ElDropdownMenu>
- </template>
- </ElDropdown>
+ <div class="language-menu absolute right-0 top-10" :class="{ 'is-open': showLanguageMenu }">
+ <div
+ v-for="lang in languageOptions"
+ :key="lang.value"
+ class="language-menu-item flex-cb c-p"
+ :class="{ 'is-selected': locale === lang.value }"
+ @click="changeLanguage(lang.value)"
+ >
+ <span class="menu-txt">{{ lang.label }}</span>
+ <ArtSvgIcon icon="ri:check-fill" class="text-base" v-if="locale === lang.value" />
+ </div>
+ </div>
+ </div>
<div
v-if="shouldShowThemeToggle"
class="btn theme-btn h-8 w-8 c-p flex-cc tad-300"
- @click="themeAnimation"
+ @click="handleThemeAnimation"
>
<ArtSvgIcon
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
@@ -71,22 +70,23 @@
</template>
<script setup>
+ import AppConfig from '@/config'
import { useI18n } from 'vue-i18n'
+ import { languageOptions } from '@/locales/language-options'
import { useSettingStore } from '@/store/modules/setting'
import { useUserStore } from '@/store/modules/user'
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
- import { themeAnimation } from '@/utils/ui/animation'
- import { languageOptions } from '@/locales'
- import AppConfig from '@/config'
defineOptions({ name: 'AuthTopBar' })
const settingStore = useSettingStore()
const userStore = useUserStore()
const { isDark, systemThemeColor } = storeToRefs(settingStore)
const { shouldShowThemeToggle, shouldShowLanguage } = useHeaderBar()
const { locale } = useI18n()
+ const showLanguageMenu = ref(false)
const mainColors = AppConfig.systemMainColor
const color = systemThemeColor
const changeLanguage = (lang) => {
+ showLanguageMenu.value = false
if (locale.value === lang) return
locale.value = lang
userStore.setLanguage(lang)
@@ -96,9 +96,26 @@
settingStore.setElementTheme(color2)
settingStore.reload()
}
+ const handleThemeAnimation = async (event) => {
+ const { themeAnimation } = await import('@/utils/ui/animation')
+ themeAnimation(event)
+ }
+ const toggleLanguageMenu = () => {
+ showLanguageMenu.value = !showLanguageMenu.value
+ }
</script>
<style scoped>
+ .brand {
+ width: 132px;
+ height: 34px;
+ }
+
+ .icon {
+ width: 100%;
+ height: 100%;
+ }
+
.color-dots {
pointer-events: none;
backdrop-filter: blur(10px);
@@ -137,6 +154,50 @@
box-shadow: none;
}
+ .language-menu {
+ min-width: 130px;
+ padding: 6px;
+ pointer-events: none;
+ background-color: var(--default-box-color);
+ border-radius: 12px;
+ box-shadow: 0 2px 12px var(--art-gray-300);
+ opacity: 0;
+ transform: translateY(8px);
+ transition:
+ opacity 0.2s ease,
+ transform 0.2s ease;
+ }
+
+ .language-menu.is-open {
+ pointer-events: auto;
+ opacity: 1;
+ transform: translateY(0);
+ }
+
+ .language-menu-item {
+ height: 34px;
+ padding: 0 12px;
+ color: var(--art-text-gray-800);
+ border-radius: 10px;
+ transition:
+ background-color 0.2s ease,
+ color 0.2s ease;
+ }
+
+ .language-menu-item:hover,
+ .language-menu-item.is-selected {
+ background-color: var(--art-gray-100);
+ }
+
+ .menu-txt {
+ font-size: 13px;
+ }
+
+ .dark .language-menu {
+ background-color: var(--art-gray-200);
+ box-shadow: none;
+ }
+
.color-picker-expandable:hover .palette-btn :deep(.art-svg-icon) {
color: v-bind(color);
}
diff --git a/rsf-design/src/components/core/views/login/LoginLeftView.vue b/rsf-design/src/components/core/views/login/LoginLeftView.vue
index 287bb52..2de894e 100644
--- a/rsf-design/src/components/core/views/login/LoginLeftView.vue
+++ b/rsf-design/src/components/core/views/login/LoginLeftView.vue
@@ -2,8 +2,7 @@
<template>
<div class="login-left-view">
<div class="logo">
- <ArtLogo class="icon" size="46" />
- <h1 class="title">{{ AppConfig.systemInfo.name }}</h1>
+ <ArtLogo class="icon" fill />
</div>
<div class="left-img">
@@ -71,7 +70,6 @@
</template>
<script setup>
- import AppConfig from '@/config'
import loginIcon from '@imgs/svg/login_icon.svg'
import { themeAnimation } from '@/utils/ui/animation'
defineProps({
@@ -106,11 +104,12 @@
z-index: 100;
display: flex;
align-items: center;
+ width: 176px;
+ height: 44px;
- .title {
- margin-left: 10px;
- font-size: 20px;
- font-weight: 400;
+ .icon {
+ width: 100%;
+ height: 100%;
}
}
diff --git a/rsf-design/src/locales/index.js b/rsf-design/src/locales/index.js
index 5f46dce..626d745 100644
--- a/rsf-design/src/locales/index.js
+++ b/rsf-design/src/locales/index.js
@@ -2,6 +2,7 @@
import { LanguageEnum } from '@/enums/appEnum'
import { getSystemStorage } from '@/utils/storage'
import { StorageKeyManager } from '@/utils/storage/storage-key-manager'
+import { languageOptions } from './language-options'
import enMessages from './langs/en.json'
import zhMessages from './langs/zh.json'
const storageKeyManager = new StorageKeyManager()
@@ -9,10 +10,6 @@
[LanguageEnum.EN]: enMessages,
[LanguageEnum.ZH]: zhMessages
}
-const languageOptions = [
- { value: LanguageEnum.ZH, label: '绠�浣撲腑鏂�' },
- { value: LanguageEnum.EN, label: 'English' }
-]
const getDefaultLanguage = () => {
try {
const storageKey = storageKeyManager.getStorageKey('user')
diff --git a/rsf-design/src/locales/langs/en.json b/rsf-design/src/locales/langs/en.json
index 0741f93..898594e 100644
--- a/rsf-design/src/locales/langs/en.json
+++ b/rsf-design/src/locales/langs/en.json
@@ -3073,6 +3073,9 @@
"buttons": {
"add": "Add Config"
},
+ "actions": {
+ "uploadLogo": "Upload Logo"
+ },
"table": {
"flag": "Flag",
"type": "Type",
@@ -3110,7 +3113,13 @@
"titleDetail": "Config Detail"
},
"messages": {
- "detailFailed": "Failed to fetch config detail"
+ "detailFailed": "Failed to fetch config detail",
+ "uploadOnlyImage": "Only image files can be uploaded",
+ "uploadSizeLimit": "Image size must be less than 5MB",
+ "uploadSuccess": "Logo uploaded successfully",
+ "uploadFailed": "Failed to upload logo",
+ "projectLogoHint": "This config uses the {flag} flag and will be applied as the system logo",
+ "imagePreviewHint": "The current value looks like an image URL and can be previewed directly"
}
},
"dictType": {
diff --git a/rsf-design/src/locales/langs/zh.json b/rsf-design/src/locales/langs/zh.json
index 1a9a3ff..cc520d7 100644
--- a/rsf-design/src/locales/langs/zh.json
+++ b/rsf-design/src/locales/langs/zh.json
@@ -3075,6 +3075,9 @@
"buttons": {
"add": "鏂板閰嶇疆"
},
+ "actions": {
+ "uploadLogo": "涓婁紶 Logo"
+ },
"table": {
"flag": "鏍囪瘑",
"type": "绫诲瀷",
@@ -3112,7 +3115,13 @@
"titleDetail": "閰嶇疆璇︽儏"
},
"messages": {
- "detailFailed": "鑾峰彇閰嶇疆璇︽儏澶辫触"
+ "detailFailed": "鑾峰彇閰嶇疆璇︽儏澶辫触",
+ "uploadOnlyImage": "鍙兘涓婁紶鍥剧墖鏂囦欢",
+ "uploadSizeLimit": "鍥剧墖澶у皬涓嶈兘瓒呰繃 5MB",
+ "uploadSuccess": "Logo 涓婁紶鎴愬姛",
+ "uploadFailed": "Logo 涓婁紶澶辫触",
+ "projectLogoHint": "褰撳墠閰嶇疆鏍囪瘑涓� {flag}锛屼笂浼犲悗浼氫綔涓虹郴缁� Logo 浣跨敤",
+ "imagePreviewHint": "褰撳墠鍊艰瘑鍒负鍥剧墖鍦板潃锛屽彲鐩存帴棰勮"
}
},
"dictType": {
diff --git a/rsf-design/src/locales/language-options.js b/rsf-design/src/locales/language-options.js
new file mode 100644
index 0000000..cedd608
--- /dev/null
+++ b/rsf-design/src/locales/language-options.js
@@ -0,0 +1,8 @@
+import { LanguageEnum } from '@/enums/appEnum'
+
+const languageOptions = Object.freeze([
+ { value: LanguageEnum.ZH, label: '绠�浣撲腑鏂�' },
+ { value: LanguageEnum.EN, label: 'English' }
+])
+
+export { languageOptions }
diff --git a/rsf-design/src/views/system/config/configPage.helpers.js b/rsf-design/src/views/system/config/configPage.helpers.js
index 02c3ff6..6233637 100644
--- a/rsf-design/src/views/system/config/configPage.helpers.js
+++ b/rsf-design/src/views/system/config/configPage.helpers.js
@@ -5,6 +5,10 @@
{ labelKey: 'pages.system.config.types.json', fallback: 'json', value: 4 },
{ labelKey: 'pages.system.config.types.date', fallback: 'date', value: 5 }
]
+const IMAGE_VALUE_RE = /(^data:image\/)|(\.(png|jpe?g|gif|bmp|webp|svg|ico)(\?.*)?$)|(([?&]path=).*?\.(png|jpe?g|gif|bmp|webp|svg|ico)($|&))/i
+
+export const PROJECT_LOGO_FLAG = 'PROJECT_LOGO'
+export const PROJECT_COPYRIGHT_FLAG = 'PROJECT_COPYRIGHT'
export function createConfigSearchState() {
return {
@@ -108,6 +112,10 @@
}
}
+export function isImageConfigValue(value) {
+ return IMAGE_VALUE_RE.test(String(value || '').trim())
+}
+
export function normalizeConfigListRow(record = {}) {
const typeMeta = getConfigTypeMeta(record.type)
const statusMeta = getConfigStatusMeta(record.status)
diff --git a/rsf-design/src/views/system/config/index.vue b/rsf-design/src/views/system/config/index.vue
index 9d591b9..ae6527c 100644
--- a/rsf-design/src/views/system/config/index.vue
+++ b/rsf-design/src/views/system/config/index.vue
@@ -77,7 +77,9 @@
createConfigSearchState,
getConfigPaginationKey,
getConfigTypeOptions,
- normalizeConfigListRow
+ normalizeConfigListRow,
+ PROJECT_COPYRIGHT_FLAG,
+ PROJECT_LOGO_FLAG
} from './configPage.helpers'
defineOptions({ name: 'Config' })
@@ -202,7 +204,7 @@
selectedRows,
handleSelectionChange,
showDialog,
- handleDialogSubmit,
+ closeDialog,
handleDelete,
handleBatchDelete
} = useCrudPage({
@@ -220,6 +222,46 @@
})
handleDeleteAction = handleDelete
+ function notifyProjectLogoUpdated(payload) {
+ if (payload?.flag !== PROJECT_LOGO_FLAG) {
+ return
+ }
+ window.dispatchEvent(new CustomEvent('project-logo-updated', { detail: { url: payload.val } }))
+ }
+
+ function notifyProjectCopyrightUpdated(payload) {
+ if (payload?.flag !== PROJECT_COPYRIGHT_FLAG) {
+ return
+ }
+ window.dispatchEvent(
+ new CustomEvent('project-copyright-updated', { detail: { text: payload.val } })
+ )
+ }
+
+ async function handleDialogSubmit(formData) {
+ const payload = buildConfigSavePayload(formData)
+ try {
+ if (dialogType.value === 'edit') {
+ await fetchUpdateConfig(payload)
+ ElMessage.success(t('crud.messages.updateSuccess'))
+ closeDialog()
+ notifyProjectLogoUpdated(payload)
+ notifyProjectCopyrightUpdated(payload)
+ await refreshUpdate?.()
+ return
+ }
+
+ await fetchSaveConfig(payload)
+ ElMessage.success(t('crud.messages.createSuccess'))
+ closeDialog()
+ notifyProjectLogoUpdated(payload)
+ notifyProjectCopyrightUpdated(payload)
+ await refreshCreate?.()
+ } catch (error) {
+ ElMessage.error(error?.message || t('crud.messages.submitFailed'))
+ }
+ }
+
function handleSearch(params) {
replaceSearchParams(buildConfigSearchParams(params))
getData()
diff --git a/rsf-design/src/views/system/config/modules/config-detail-drawer.vue b/rsf-design/src/views/system/config/modules/config-detail-drawer.vue
index af86445..9a99f3f 100644
--- a/rsf-design/src/views/system/config/modules/config-detail-drawer.vue
+++ b/rsf-design/src/views/system/config/modules/config-detail-drawer.vue
@@ -13,7 +13,16 @@
<ElDescriptionsItem :label="t('pages.system.config.table.type')">
{{ t(displayData.typeTextKey || 'common.placeholder.empty') }}
</ElDescriptionsItem>
- <ElDescriptionsItem :label="t('pages.system.config.table.value')">{{ displayData.val || t('common.placeholder.empty') }}</ElDescriptionsItem>
+ <ElDescriptionsItem :label="t('pages.system.config.table.value')">
+ <div class="break-all">{{ displayData.val || t('common.placeholder.empty') }}</div>
+ <ElImage
+ v-if="isImageConfigValue(displayData.val)"
+ :src="displayData.val"
+ fit="contain"
+ preview-teleported
+ class="mt-3 h-24 w-24 rounded border border-[var(--art-border-color)] bg-[var(--art-main-bg-color)]"
+ />
+ </ElDescriptionsItem>
<ElDescriptionsItem :label="t('pages.system.config.table.content')">{{ displayData.content || t('common.placeholder.empty') }}</ElDescriptionsItem>
<ElDescriptionsItem :label="t('table.status')">
<ElTag :type="displayData.statusType" effect="light">
@@ -30,7 +39,7 @@
<script setup>
import { useI18n } from 'vue-i18n'
- import { normalizeConfigListRow } from '../configPage.helpers'
+ import { isImageConfigValue, normalizeConfigListRow } from '../configPage.helpers'
const props = defineProps({
visible: { type: Boolean, default: false },
diff --git a/rsf-design/src/views/system/config/modules/config-dialog.vue b/rsf-design/src/views/system/config/modules/config-dialog.vue
index 535f503..6870997 100644
--- a/rsf-design/src/views/system/config/modules/config-dialog.vue
+++ b/rsf-design/src/views/system/config/modules/config-dialog.vue
@@ -17,7 +17,47 @@
label-width="100px"
:show-reset="false"
:show-submit="false"
- />
+ >
+ <template #val>
+ <div class="w-full">
+ <ElInput
+ v-model="form.val"
+ clearable
+ :placeholder="t('pages.system.config.placeholders.value')"
+ />
+
+ <div v-if="isProjectLogoConfig || isImageConfigValue(form.val)" class="mt-3">
+ <div class="flex flex-wrap items-center gap-3">
+ <ElUpload
+ accept="image/*"
+ :show-file-list="false"
+ :before-upload="beforeLogoUpload"
+ :http-request="handleLogoUpload"
+ >
+ <ElButton :loading="uploading">
+ {{ t('pages.system.config.actions.uploadLogo') }}
+ </ElButton>
+ </ElUpload>
+ <span class="text-xs text-g-500">
+ {{
+ isProjectLogoConfig
+ ? t('pages.system.config.messages.projectLogoHint', { flag: projectLogoFlag })
+ : t('pages.system.config.messages.imagePreviewHint')
+ }}
+ </span>
+ </div>
+
+ <ElImage
+ v-if="isImageConfigValue(form.val)"
+ :src="form.val"
+ fit="contain"
+ preview-teleported
+ class="mt-3 h-24 w-24 rounded border border-[var(--art-border-color)] bg-[var(--art-main-bg-color)]"
+ />
+ </div>
+ </div>
+ </template>
+ </ArtForm>
<template #footer>
<span class="dialog-footer">
@@ -29,9 +69,17 @@
</template>
<script setup>
+ import { ElMessage } from 'element-plus'
import ArtForm from '@/components/core/forms/art-form/index.vue'
+ import { fetchUploadProjectLogo } from '@/api/system-manage'
import { useI18n } from 'vue-i18n'
- import { buildConfigDialogModel, createConfigFormState, getConfigTypeOptions } from '../configPage.helpers'
+ import {
+ buildConfigDialogModel,
+ createConfigFormState,
+ getConfigTypeOptions,
+ isImageConfigValue,
+ PROJECT_LOGO_FLAG
+ } from '../configPage.helpers'
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -42,8 +90,11 @@
const { t } = useI18n()
const formRef = ref()
const form = reactive(createConfigFormState())
+ const uploading = ref(false)
const isEdit = computed(() => Boolean(form.id))
+ const isProjectLogoConfig = computed(() => String(form.flag || '').trim().toUpperCase() === PROJECT_LOGO_FLAG)
+ const projectLogoFlag = PROJECT_LOGO_FLAG
const dialogTitle = computed(() =>
isEdit.value ? t('pages.system.config.dialog.titleEdit') : t('pages.system.config.dialog.titleCreate')
)
@@ -144,6 +195,38 @@
Object.assign(form, buildConfigDialogModel(props.configData))
}
+ function beforeLogoUpload(rawFile) {
+ const isImageFile =
+ String(rawFile?.type || '').startsWith('image/') || /\.(png|jpe?g|gif|bmp|webp|svg)$/i.test(rawFile?.name || '')
+ if (!isImageFile) {
+ ElMessage.error(t('pages.system.config.messages.uploadOnlyImage'))
+ return false
+ }
+
+ const isLt5MB = Number(rawFile?.size || 0) / 1024 / 1024 < 5
+ if (!isLt5MB) {
+ ElMessage.error(t('pages.system.config.messages.uploadSizeLimit'))
+ return false
+ }
+
+ return true
+ }
+
+ async function handleLogoUpload(option) {
+ uploading.value = true
+ try {
+ const response = await fetchUploadProjectLogo(option.file)
+ form.val = response?.url || ''
+ option.onSuccess?.(response)
+ ElMessage.success(t('pages.system.config.messages.uploadSuccess'))
+ } catch (error) {
+ option.onError?.(error)
+ ElMessage.error(error?.message || t('pages.system.config.messages.uploadFailed'))
+ } finally {
+ uploading.value = false
+ }
+ }
+
async function handleSubmit() {
if (!formRef.value) return
try {
diff --git a/rsf-design/vite.config.js b/rsf-design/vite.config.js
index e1dd2a1..a142af7 100644
--- a/rsf-design/vite.config.js
+++ b/rsf-design/vite.config.js
@@ -16,6 +16,7 @@
const root = process.cwd()
const env = loadEnv(mode, root)
const { VITE_VERSION, VITE_PORT, VITE_BASE_URL, VITE_API_URL, VITE_API_PROXY_URL } = env
+ const enableVueDevTools = env.VITE_ENABLE_VUE_DEVTOOLS === 'true'
console.log(`API_URL = ${VITE_API_URL}`)
console.log(`VERSION = ${VITE_VERSION}`)
@@ -96,14 +97,14 @@
threshold: 10240,
deleteOriginFile: false
}),
- vueDevTools()
+ enableVueDevTools ? vueDevTools() : null
// visualizer({
// open: true,
// gzipSize: true,
// brotliSize: true,
// filename: 'dist/stats.html'
// }),
- ],
+ ].filter(Boolean),
optimizeDeps: {
include: [
'echarts/core',
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/common/security/SecurityConfig.java b/rsf-server/src/main/java/com/vincent/rsf/server/common/security/SecurityConfig.java
index 15e7d60..769dff1 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/common/security/SecurityConfig.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/common/security/SecurityConfig.java
@@ -73,7 +73,7 @@
http.authorizeHttpRequests(authorize -> authorize
.dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
- .requestMatchers(HttpMethod.GET, "/file/**", "/captcha", "/").permitAll()
+ .requestMatchers(HttpMethod.GET, "/file/**", "/captcha", "/", "/config/public/project-logo", "/config/public/project-copyright").permitAll()
.requestMatchers(FILTER_PATH).permitAll()
.anyRequest().authenticated())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/ProjectLogoController.java b/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/ProjectLogoController.java
new file mode 100644
index 0000000..dff5dee
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/system/controller/ProjectLogoController.java
@@ -0,0 +1,183 @@
+package com.vincent.rsf.server.system.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.vincent.rsf.framework.common.R;
+import com.vincent.rsf.framework.exception.CoolException;
+import com.vincent.rsf.server.common.utils.FileServerUtil;
+import com.vincent.rsf.server.system.entity.Config;
+import com.vincent.rsf.server.system.enums.StatusType;
+import com.vincent.rsf.server.system.service.ConfigService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.util.StringUtils;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+
+@RestController
+public class ProjectLogoController extends BaseController {
+
+ private static final String PROJECT_LOGO_FLAG = "PROJECT_LOGO";
+ private static final String PROJECT_COPYRIGHT_FLAG = "PROJECT_COPYRIGHT";
+ private static final Set<String> ALLOWED_IMAGE_TYPES = Set.of(
+ "image/png",
+ "image/jpeg",
+ "image/jpg",
+ "image/gif",
+ "image/bmp",
+ "image/webp",
+ "image/svg+xml",
+ "image/x-icon",
+ "image/vnd.microsoft.icon"
+ );
+ private static final Set<String> ALLOWED_IMAGE_EXTENSIONS = Set.of(
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".gif",
+ ".bmp",
+ ".webp",
+ ".svg",
+ ".ico"
+ );
+ private static final Path PROJECT_LOGO_ROOT = Paths.get(
+ System.getProperty("user.home"),
+ ".rsf",
+ "uploads",
+ "logo"
+ ).toAbsolutePath().normalize();
+
+ @Autowired
+ private ConfigService configService;
+
+ @GetMapping("/config/public/project-logo")
+ public R getProjectLogoConfig() {
+ return getEnabledConfigByFlag(PROJECT_LOGO_FLAG);
+ }
+
+ @GetMapping("/config/public/project-copyright")
+ public R getProjectCopyrightConfig() {
+ return getEnabledConfigByFlag(PROJECT_COPYRIGHT_FLAG);
+ }
+
+ private R getEnabledConfigByFlag(String flag) {
+ List<Config> configs = configService.list(new LambdaQueryWrapper<Config>()
+ .eq(Config::getFlag, flag)
+ .eq(Config::getStatus, StatusType.ENABLE.val)
+ .last("limit 1"));
+ return R.ok().add(configs.stream().findFirst().orElse(null));
+ }
+
+ @PreAuthorize("hasAnyAuthority('system:config:save','system:config:update')")
+ @PostMapping("/config/logo/upload")
+ public R uploadProjectLogo(@RequestParam("file") MultipartFile file, HttpServletRequest request) throws IOException {
+ validateImageFile(file);
+ File savedFile = FileServerUtil.upload(file, PROJECT_LOGO_ROOT.toString(), true);
+ String relativePath = PROJECT_LOGO_ROOT.relativize(savedFile.toPath().toAbsolutePath().normalize())
+ .toString()
+ .replace(File.separatorChar, '/');
+ String url = request.getContextPath() + "/file/logo?path=" + relativePath;
+
+ Map<String, Object> payload = new HashMap<>();
+ payload.put("name", savedFile.getName());
+ payload.put("path", relativePath);
+ payload.put("url", url);
+ return R.ok().add(payload);
+ }
+
+ @GetMapping("/file/logo")
+ public void previewProjectLogo(@RequestParam("path") String path, HttpServletRequest request, HttpServletResponse response) throws IOException {
+ File file = resolveLogoPath(path).toFile();
+ if (!file.exists() || !file.isFile()) {
+ response.sendError(HttpServletResponse.SC_NOT_FOUND);
+ return;
+ }
+
+ response.setContentType(resolveImageContentType(file.getName()));
+ response.setHeader("Cache-Control", "public, max-age=86400");
+ response.setContentLengthLong(file.length());
+
+ try (FileInputStream inputStream = new FileInputStream(file);
+ OutputStream outputStream = response.getOutputStream()) {
+ inputStream.transferTo(outputStream);
+ outputStream.flush();
+ }
+ }
+
+ private void validateImageFile(MultipartFile file) {
+ if (file == null || file.isEmpty()) {
+ throw new CoolException("涓婁紶鏂囦欢涓嶈兘涓虹┖");
+ }
+ if (!isAllowedImageType(file.getContentType()) && !isAllowedImageExtension(file.getOriginalFilename())) {
+ throw new CoolException("鍙敮鎸佷笂浼犲浘鐗囨枃浠�");
+ }
+ }
+
+ private boolean isAllowedImageType(String contentType) {
+ if (!StringUtils.hasText(contentType)) {
+ return false;
+ }
+ return ALLOWED_IMAGE_TYPES.contains(contentType.toLowerCase(Locale.ROOT));
+ }
+
+ private boolean isAllowedImageExtension(String fileName) {
+ if (!StringUtils.hasText(fileName) || !fileName.contains(".")) {
+ return false;
+ }
+ String extension = fileName.substring(fileName.lastIndexOf(".")).toLowerCase(Locale.ROOT);
+ return ALLOWED_IMAGE_EXTENSIONS.contains(extension);
+ }
+
+ private Path resolveLogoPath(String relativePath) {
+ if (!StringUtils.hasText(relativePath)) {
+ throw new CoolException("鏂囦欢璺緞涓嶈兘涓虹┖");
+ }
+ Path resolvedPath = PROJECT_LOGO_ROOT.resolve(relativePath).normalize();
+ if (!resolvedPath.startsWith(PROJECT_LOGO_ROOT)) {
+ throw new CoolException("闈炴硶鏂囦欢璺緞");
+ }
+ return resolvedPath;
+ }
+
+ private String resolveImageContentType(String fileName) {
+ String normalizedName = StringUtils.hasText(fileName) ? fileName.toLowerCase(Locale.ROOT) : "";
+ if (normalizedName.endsWith(".png")) {
+ return "image/png";
+ }
+ if (normalizedName.endsWith(".jpg") || normalizedName.endsWith(".jpeg")) {
+ return "image/jpeg";
+ }
+ if (normalizedName.endsWith(".gif")) {
+ return "image/gif";
+ }
+ if (normalizedName.endsWith(".bmp")) {
+ return "image/bmp";
+ }
+ if (normalizedName.endsWith(".webp")) {
+ return "image/webp";
+ }
+ if (normalizedName.endsWith(".svg")) {
+ return "image/svg+xml";
+ }
+ if (normalizedName.endsWith(".ico")) {
+ return "image/x-icon";
+ }
+ return "application/octet-stream";
+ }
+}
--
Gitblit v1.9.1