#版权 PROJECT_COPYRIGHT logo PROJECT_LOGO 配置项和页面优化
| | |
| | | |
| | | # Delete console |
| | | VITE_DROP_CONSOLE = false |
| | | |
| | | # 是否开启 Vue DevTools / Inspector(开启后会增加本地模块转换耗时) |
| | | VITE_ENABLE_VUE_DEVTOOLS = false |
| | |
| | | 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 }) |
| | | } |
| | |
| | | |
| | | 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) { |
| | |
| | | fetchExportOperationRecordReport, |
| | | fetchConfigPage, |
| | | fetchGetConfigDetail, |
| | | fetchPublicProjectLogoConfig, |
| | | fetchPublicProjectCopyrightConfig, |
| | | fetchSaveConfig, |
| | | fetchUpdateConfig, |
| | | fetchDeleteConfig, |
| | | fetchUploadProjectLogo, |
| | | fetchSerialRulePage, |
| | | fetchGetSerialRuleDetail, |
| | | fetchSaveSerialRule, |
| New file |
| | |
| | | <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> |
| | |
| | | <!-- 系统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> |
| | |
| | | <!-- 主题切换按钮 --> |
| | | <ArtIconButton |
| | | v-if="shouldShowThemeToggle" |
| | | @click="themeAnimation" |
| | | @click="handleThemeAnimation" |
| | | :icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'" |
| | | /> |
| | | |
| | |
| | | |
| | | <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' |
| | |
| | | const openChat = () => { |
| | | mittBus.emit('openChat') |
| | | } |
| | | const handleThemeAnimation = async (event) => { |
| | | const { themeAnimation } = await import('@/utils/ui/animation') |
| | | themeAnimation(event) |
| | | } |
| | | </script> |
| | | |
| | | <style lang="scss" scoped> |
| | |
| | | :class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`" |
| | | :style="{ background: getMenuTheme.background }" |
| | | > |
| | | <!-- Logo、系统名称 --> |
| | | <!-- Logo --> |
| | | <div |
| | | class="header" |
| | | @click="navigateToHome" |
| | |
| | | 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 |
| | |
| | | </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 |
| | |
| | | </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' |
| | | |
| | |
| | | |
| | | 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' |
| | | } |
| | | }) |
| | |
| | | .menu-left { |
| | | position: relative; |
| | | box-sizing: border-box; |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: 100vh; |
| | | |
| | | @media only screen and (width <= 640px) { |
| | |
| | | |
| | | .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 { |
| | |
| | | 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; |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | .el-menu { |
| | | height: calc(100vh - 60px); |
| | | height: auto; |
| | | } |
| | | |
| | | .el-menu--collapse { |
| | |
| | | // 折叠状态下的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; |
| | | } |
| | | } |
| | | |
| | |
| | | .dual-menu-left { |
| | | border-right: 1px solid rgb(255 255 255 / 9%) !important; |
| | | } |
| | | |
| | | .menu-left { |
| | | .footer { |
| | | border-top-color: rgb(255 255 255 / 9%); |
| | | } |
| | | } |
| | | } |
| | | } |
| | |
| | | <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"> |
| | |
| | | /> |
| | | </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" |
| | | <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" /> |
| | | </ElDropdownItem> |
| | | </div> |
| | | </ElDropdownMenu> |
| | | </template> |
| | | </ElDropdown> |
| | | </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'" |
| | |
| | | </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) |
| | |
| | | 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); |
| | |
| | | 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); |
| | | } |
| | |
| | | <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"> |
| | |
| | | </template> |
| | | |
| | | <script setup> |
| | | import AppConfig from '@/config' |
| | | import loginIcon from '@imgs/svg/login_icon.svg' |
| | | import { themeAnimation } from '@/utils/ui/animation' |
| | | defineProps({ |
| | |
| | | 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%; |
| | | } |
| | | } |
| | | |
| | |
| | | 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() |
| | |
| | | [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') |
| | |
| | | "buttons": { |
| | | "add": "Add Config" |
| | | }, |
| | | "actions": { |
| | | "uploadLogo": "Upload Logo" |
| | | }, |
| | | "table": { |
| | | "flag": "Flag", |
| | | "type": "Type", |
| | |
| | | "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": { |
| | |
| | | "buttons": { |
| | | "add": "新增配置" |
| | | }, |
| | | "actions": { |
| | | "uploadLogo": "上传 Logo" |
| | | }, |
| | | "table": { |
| | | "flag": "标识", |
| | | "type": "类型", |
| | |
| | | "titleDetail": "配置详情" |
| | | }, |
| | | "messages": { |
| | | "detailFailed": "获取配置详情失败" |
| | | "detailFailed": "获取配置详情失败", |
| | | "uploadOnlyImage": "只能上传图片文件", |
| | | "uploadSizeLimit": "图片大小不能超过 5MB", |
| | | "uploadSuccess": "Logo 上传成功", |
| | | "uploadFailed": "Logo 上传失败", |
| | | "projectLogoHint": "当前配置标识为 {flag},上传后会作为系统 Logo 使用", |
| | | "imagePreviewHint": "当前值识别为图片地址,可直接预览" |
| | | } |
| | | }, |
| | | "dictType": { |
| New file |
| | |
| | | import { LanguageEnum } from '@/enums/appEnum' |
| | | |
| | | const languageOptions = Object.freeze([ |
| | | { value: LanguageEnum.ZH, label: '简体中文' }, |
| | | { value: LanguageEnum.EN, label: 'English' } |
| | | ]) |
| | | |
| | | export { languageOptions } |
| | |
| | | { 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 { |
| | |
| | | } |
| | | } |
| | | |
| | | 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) |
| | |
| | | createConfigSearchState, |
| | | getConfigPaginationKey, |
| | | getConfigTypeOptions, |
| | | normalizeConfigListRow |
| | | normalizeConfigListRow, |
| | | PROJECT_COPYRIGHT_FLAG, |
| | | PROJECT_LOGO_FLAG |
| | | } from './configPage.helpers' |
| | | |
| | | defineOptions({ name: 'Config' }) |
| | |
| | | selectedRows, |
| | | handleSelectionChange, |
| | | showDialog, |
| | | handleDialogSubmit, |
| | | closeDialog, |
| | | handleDelete, |
| | | handleBatchDelete |
| | | } = useCrudPage({ |
| | |
| | | }) |
| | | 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() |
| | |
| | | <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"> |
| | |
| | | |
| | | <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 }, |
| | |
| | | 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"> |
| | |
| | | </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 }, |
| | |
| | | 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') |
| | | ) |
| | |
| | | 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 { |
| | |
| | | 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}`) |
| | |
| | | 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', |
| | |
| | | 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)) |
| New file |
| | |
| | | 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"; |
| | | } |
| | | } |