zhou zhou
11 小时以前 5454bbe86b1a22e9f05b6bc43f7ed7e9d6c4dc14
#版权 PROJECT_COPYRIGHT logo PROJECT_LOGO 配置项和页面优化
3个文件已添加
17个文件已修改
767 ■■■■ 已修改文件
rsf-design/.env.development 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/api/system-manage.js 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/core/base/art-copyright/index.vue 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/core/base/art-logo/index.vue 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/core/layouts/art-header-bar/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue 31 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/core/views/login/AuthTopBar.vue 111 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/components/core/views/login/LoginLeftView.vue 13 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/index.js 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/en.json 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/zh.json 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/language-options.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/config/configPage.helpers.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/config/index.vue 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/config/modules/config-detail-drawer.vue 13 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/views/system/config/modules/config-dialog.vue 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/vite.config.js 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/common/security/SecurityConfig.java 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/system/controller/ProjectLogoController.java 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/.env.development
@@ -11,3 +11,6 @@
# Delete console
VITE_DROP_CONSOLE = false
# 是否开启 Vue DevTools / Inspector(开启后会增加本地模块转换耗时)
VITE_ENABLE_VUE_DEVTOOLS = false
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,
rsf-design/src/components/core/base/art-copyright/index.vue
New file
@@ -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>
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>
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>
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'
    }
  })
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 @@
    // 折叠状态下的header样式
    .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%);
      }
    }
  }
}
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);
  }
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%;
      }
    }
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')
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": {
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": {
rsf-design/src/locales/language-options.js
New file
@@ -0,0 +1,8 @@
import { LanguageEnum } from '@/enums/appEnum'
const languageOptions = Object.freeze([
  { value: LanguageEnum.ZH, label: '简体中文' },
  { value: LanguageEnum.EN, label: 'English' }
])
export { languageOptions }
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)
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()
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 },
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 {
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',
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))
rsf-server/src/main/java/com/vincent/rsf/server/system/controller/ProjectLogoController.java
New file
@@ -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";
    }
}