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