mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 01:06:41 +08:00
fix(theme): preserve logo detail across theme colors (#540)
This commit is contained in:
+179
-65
@@ -4,7 +4,7 @@
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
--initial-loader-bg: #0E1116;
|
||||
--initial-loader-color: #9155FD;
|
||||
--initial-loader-color: #8D51F9;
|
||||
--initial-loader-height: 100svh;
|
||||
--initial-loader-width: 100vw;
|
||||
--initial-color-scheme: dark;
|
||||
@@ -171,30 +171,11 @@
|
||||
display: block;
|
||||
block-size: auto;
|
||||
inline-size: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.loading-logo__mark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@supports ((-webkit-mask-image: url('/logo.svg')) or (mask-image: url('/logo.svg'))) {
|
||||
.loading-logo img {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading-logo__mark {
|
||||
display: block;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
color-mix(in srgb, var(--initial-loader-color) 48%, white) 0%,
|
||||
var(--initial-loader-color) 48%,
|
||||
color-mix(in srgb, var(--initial-loader-color) 72%, black) 100%
|
||||
);
|
||||
block-size: min(160px, 36vw);
|
||||
inline-size: min(160px, 36vw);
|
||||
mask: url('/logo.svg') center / contain no-repeat;
|
||||
-webkit-mask: url('/logo.svg') center / contain no-repeat;
|
||||
}
|
||||
.loading-logo img[data-theme-ready='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.loading-footer {
|
||||
@@ -297,11 +278,11 @@
|
||||
}
|
||||
|
||||
#timeout-btn {
|
||||
color: var(--initial-loader-color, #9155FD);
|
||||
color: var(--initial-loader-color, #8D51F9);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
margin-inline-start: 8px;
|
||||
border-bottom: 1px solid var(--initial-loader-color, #9155FD);
|
||||
border-bottom: 1px solid var(--initial-loader-color, #8D51F9);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -327,7 +308,7 @@
|
||||
const launchThemePalettes = {
|
||||
light: {
|
||||
background: '#F4F5FA',
|
||||
primary: '#9155FD',
|
||||
primary: '#8D51F9',
|
||||
},
|
||||
dark: {
|
||||
background: '#0E1116',
|
||||
@@ -335,7 +316,7 @@
|
||||
},
|
||||
purple: {
|
||||
background: '#28243D',
|
||||
primary: '#9155FD',
|
||||
primary: '#8D51F9',
|
||||
},
|
||||
transparent: {
|
||||
background: '#1C1C1C',
|
||||
@@ -386,54 +367,188 @@
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
|
||||
let faviconSourceImage
|
||||
let pendingFaviconColor = '#9155FD'
|
||||
let logoSvgSourcePromise
|
||||
let pendingFaviconColor = '#8D51F9'
|
||||
const themeLogoCacheKey = 'moviepilot-themed-logo-cache'
|
||||
|
||||
function mixFaviconColor(hexColor, target, amount) {
|
||||
const normalized = hexColor.replace('#', '')
|
||||
const source = [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16))
|
||||
|
||||
return `rgb(${source.map((channel, index) => Math.round(channel + (target[index] - channel) * amount)).join(', ')})`
|
||||
const sourceLogoPalette = {
|
||||
'rgb(141,81,249)': 'primary',
|
||||
'rgb(165,118,255)': 'light',
|
||||
'rgb(211,187,255)': 'highlight',
|
||||
'rgb(116,50,223)': 'dark',
|
||||
'rgb(110,38,217)': 'darker',
|
||||
'rgb(104,0,197)': 'deep',
|
||||
'rgb(91,0,197)': 'deepest',
|
||||
}
|
||||
|
||||
// Tab 图标使用小尺寸位图承载主题色,避免 favicon 内部 SVG 无法继承页面 CSS 变量。
|
||||
const sourceLogoRgb = {
|
||||
primary: [141, 81, 249],
|
||||
light: [165, 118, 255],
|
||||
highlight: [211, 187, 255],
|
||||
dark: [116, 50, 223],
|
||||
darker: [110, 38, 217],
|
||||
deep: [104, 0, 197],
|
||||
deepest: [91, 0, 197],
|
||||
}
|
||||
|
||||
function clampLogoChannel(value, min = 0, max = 1) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function logoRgbToHsl([red, green, blue]) {
|
||||
const r = red / 255
|
||||
const g = green / 255
|
||||
const b = blue / 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
const delta = max - min
|
||||
const l = (max + min) / 2
|
||||
|
||||
if (delta === 0) return { h: 0, l, s: 0 }
|
||||
|
||||
const s = delta / (1 - Math.abs(2 * l - 1))
|
||||
let h = 0
|
||||
|
||||
if (max === r) h = ((g - b) / delta) % 6
|
||||
else if (max === g) h = (b - r) / delta + 2
|
||||
else h = (r - g) / delta + 4
|
||||
|
||||
return { h: (h * 60 + 360) % 360, l, s }
|
||||
}
|
||||
|
||||
function logoHslToRgb({ h, l, s }) {
|
||||
const chroma = (1 - Math.abs(2 * l - 1)) * s
|
||||
const segment = h / 60
|
||||
const secondary = chroma * (1 - Math.abs((segment % 2) - 1))
|
||||
let channels
|
||||
|
||||
if (segment < 1) channels = [chroma, secondary, 0]
|
||||
else if (segment < 2) channels = [secondary, chroma, 0]
|
||||
else if (segment < 3) channels = [0, chroma, secondary]
|
||||
else if (segment < 4) channels = [0, secondary, chroma]
|
||||
else if (segment < 5) channels = [secondary, 0, chroma]
|
||||
else channels = [chroma, 0, secondary]
|
||||
|
||||
const offset = l - chroma / 2
|
||||
const rgb = channels.map(channel => Math.round((channel + offset) * 255))
|
||||
|
||||
return `rgb(${rgb.join(',')})`
|
||||
}
|
||||
|
||||
function shiftLogoTone(color, hueOffset, lightnessOffset, saturationScale = 1) {
|
||||
return logoHslToRgb({
|
||||
h: (color.h + hueOffset + 360) % 360,
|
||||
l: clampLogoChannel(color.l + lightnessOffset, 0.08, 0.92),
|
||||
s: clampLogoChannel(color.s * saturationScale),
|
||||
})
|
||||
}
|
||||
|
||||
function createLaunchLogoPalette(primaryColor) {
|
||||
const normalized = primaryColor.slice(1)
|
||||
const rgb = [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16))
|
||||
const hsl = logoRgbToHsl(rgb)
|
||||
const sourcePrimaryHsl = logoRgbToHsl(sourceLogoRgb.primary)
|
||||
const lightDirection = hsl.l >= 0.78 ? -1 : 1
|
||||
const darkDirection = hsl.l <= 0.22 ? 1 : -1
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(sourceLogoRgb).map(([key, sourceRgb]) => {
|
||||
if (key === 'primary') return [key, `rgb(${rgb.join(',')})`]
|
||||
|
||||
const sourceHsl = logoRgbToHsl(sourceRgb)
|
||||
const hueOffset = sourceHsl.h - sourcePrimaryHsl.h
|
||||
const sourceLightnessDelta = sourceHsl.l - sourcePrimaryHsl.l
|
||||
const lightnessDelta =
|
||||
Math.abs(sourceLightnessDelta) * (sourceLightnessDelta >= 0 ? lightDirection : darkDirection)
|
||||
const saturationScale = sourcePrimaryHsl.s ? sourceHsl.s / sourcePrimaryHsl.s : 1
|
||||
|
||||
return [key, shiftLogoTone(hsl, hueOffset, lightnessDelta, saturationScale)]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function createThemedLogoDataUrl(svgSource, primaryColor) {
|
||||
const palette = createLaunchLogoPalette(primaryColor)
|
||||
const themedSvg = Object.entries(sourceLogoPalette).reduce(
|
||||
(svg, [sourceColor, paletteKey]) => svg.replaceAll(sourceColor, palette[paletteKey]),
|
||||
svgSource,
|
||||
)
|
||||
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(themedSvg)}`
|
||||
}
|
||||
|
||||
function withLoadingLogo(callback) {
|
||||
const loadingLogo = document.querySelector('.loading-logo img')
|
||||
if (loadingLogo) {
|
||||
callback(loadingLogo)
|
||||
return
|
||||
}
|
||||
|
||||
if (document.readyState !== 'loading') return
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const nextLoadingLogo = document.querySelector('.loading-logo img')
|
||||
if (!nextLoadingLogo) return
|
||||
|
||||
observer.disconnect()
|
||||
callback(nextLoadingLogo)
|
||||
})
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
function applyThemedLogoUrl(themedLogoUrl) {
|
||||
const faviconLink = document.querySelector('#theme-favicon')
|
||||
|
||||
faviconLink?.setAttribute('type', 'image/svg+xml')
|
||||
faviconLink?.setAttribute('href', themedLogoUrl)
|
||||
|
||||
withLoadingLogo(loadingLogo => {
|
||||
const revealLogo = () => loadingLogo.setAttribute('data-theme-ready', 'true')
|
||||
loadingLogo.addEventListener('load', revealLogo, { once: true })
|
||||
loadingLogo.setAttribute('src', themedLogoUrl)
|
||||
if (loadingLogo.complete) revealLogo()
|
||||
})
|
||||
}
|
||||
|
||||
function revealOriginalLoadingLogo() {
|
||||
withLoadingLogo(loadingLogo => loadingLogo.setAttribute('data-theme-ready', 'true'))
|
||||
}
|
||||
|
||||
// 启动层和 Tab 图标共享原始矢量结构,主题切换只替换色阶,不破坏分面和透明高光。
|
||||
function syncThemeFavicon(primaryColor) {
|
||||
if (!/^#[0-9a-f]{6}$/i.test(primaryColor)) return
|
||||
|
||||
pendingFaviconColor = primaryColor
|
||||
|
||||
const render = () => {
|
||||
const faviconLink = document.querySelector('#theme-favicon')
|
||||
if (!faviconLink || !faviconSourceImage?.naturalWidth) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return
|
||||
|
||||
canvas.width = 64
|
||||
canvas.height = 64
|
||||
context.drawImage(faviconSourceImage, 0, 0, 64, 64)
|
||||
context.globalCompositeOperation = 'source-in'
|
||||
|
||||
const gradient = context.createLinearGradient(10, 6, 54, 58)
|
||||
gradient.addColorStop(0, mixFaviconColor(pendingFaviconColor, [255, 255, 255], 0.38))
|
||||
gradient.addColorStop(0.48, pendingFaviconColor)
|
||||
gradient.addColorStop(1, mixFaviconColor(pendingFaviconColor, [0, 0, 0], 0.28))
|
||||
context.fillStyle = gradient
|
||||
context.fillRect(0, 0, 64, 64)
|
||||
|
||||
faviconLink.setAttribute('type', 'image/png')
|
||||
faviconLink.setAttribute('href', canvas.toDataURL('image/png'))
|
||||
try {
|
||||
const cachedLogo = JSON.parse(localStorage.getItem(themeLogoCacheKey) || 'null')
|
||||
if (cachedLogo?.color === primaryColor && typeof cachedLogo.url === 'string') applyThemedLogoUrl(cachedLogo.url)
|
||||
} catch {
|
||||
// 缓存异常不影响根据品牌源文件重新生成主题标识。
|
||||
}
|
||||
|
||||
if (!faviconSourceImage) {
|
||||
faviconSourceImage = new Image()
|
||||
faviconSourceImage.onload = render
|
||||
faviconSourceImage.src = '/logo.svg'
|
||||
return
|
||||
}
|
||||
logoSvgSourcePromise ||= fetch('/logo.svg').then(response => {
|
||||
if (!response.ok) throw new Error(`Logo SVG request failed: ${response.status}`)
|
||||
return response.text()
|
||||
})
|
||||
|
||||
if (faviconSourceImage.complete) render()
|
||||
logoSvgSourcePromise
|
||||
.then(svgSource => {
|
||||
if (primaryColor !== pendingFaviconColor) return
|
||||
|
||||
const themedLogoUrl = createThemedLogoDataUrl(svgSource, primaryColor)
|
||||
applyThemedLogoUrl(themedLogoUrl)
|
||||
|
||||
try {
|
||||
localStorage.setItem(themeLogoCacheKey, JSON.stringify({ color: primaryColor, url: themedLogoUrl }))
|
||||
} catch {
|
||||
// 存储空间不可用时仍保留当前页面内的主题标识。
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 原始 SVG 始终保留为无网络或解析异常时的可见回退。
|
||||
revealOriginalLoadingLogo()
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('moviepilot-theme-primary-color-change', event => {
|
||||
@@ -630,7 +745,6 @@
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
|
||||
<span class="loading-logo__mark" role="img" aria-label="MoviePilot"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading-footer">
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #9155FD;
|
||||
--primary-color: #8D51F9;
|
||||
--surface-color: #FFFFFF;
|
||||
--text-color: #333333;
|
||||
--border-color: rgba(0, 0, 0, 0.12);
|
||||
@@ -52,7 +52,7 @@
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 32px;
|
||||
background: rgba(145, 85, 253, 0.1);
|
||||
background: rgba(141, 81, 249, 0.1);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -100,7 +100,7 @@
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(145, 85, 253, 0.1);
|
||||
background: rgba(141, 81, 249, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storageRemoteDict } from '@/api/constants'
|
||||
|
||||
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
|
||||
const DEFAULT_DIRECTORY_ACCENT_RGB = '141, 81, 249'
|
||||
const STORAGE_ACCENT_COLOR_MAP = {
|
||||
local: '#FFB400',
|
||||
alipan: '#00A7F2',
|
||||
|
||||
@@ -964,7 +964,7 @@ function registerTexture<T extends ThreeTexture>(texture: T) {
|
||||
function getThemeColors() {
|
||||
const T = requireThree()
|
||||
const colors = vuetifyTheme.global.current.value.colors
|
||||
const primary = new T.Color(colors.primary || '#9155FD')
|
||||
const primary = new T.Color(colors.primary || '#8D51F9')
|
||||
const surface = new T.Color(colors.surface || colors.background || '#14161F')
|
||||
const onSurface = new T.Color(colors['on-surface'] || '#FFFFFF')
|
||||
const hsl = { h: 0, l: 0, s: 0 }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import logoUrl from '@images/logo.svg'
|
||||
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -71,7 +72,7 @@ onBeforeUnmount(() => {
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerleave="resetPointerResponse"
|
||||
>
|
||||
<span class="prismatic-logo__base" aria-hidden="true" />
|
||||
<ThemeLogoMark class="prismatic-logo__base" decorative />
|
||||
<span class="prismatic-logo__spectrum" aria-hidden="true" />
|
||||
<span class="prismatic-logo__specular" aria-hidden="true" />
|
||||
<span class="prismatic-logo__reveal" aria-hidden="true" />
|
||||
@@ -117,7 +118,6 @@ onBeforeUnmount(() => {
|
||||
transform: translate3d(0, 8px, -16px) scaleX(1.18);
|
||||
}
|
||||
|
||||
.prismatic-logo__base,
|
||||
.prismatic-logo__spectrum,
|
||||
.prismatic-logo__specular,
|
||||
.prismatic-logo__reveal {
|
||||
@@ -131,19 +131,19 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.prismatic-logo__base {
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
color-mix(in srgb, rgb(var(--v-theme-primary)) 46%, white) 0%,
|
||||
rgb(var(--v-theme-primary)) 48%,
|
||||
color-mix(in srgb, rgb(var(--v-theme-primary)) 74%, black) 100%
|
||||
);
|
||||
position: absolute;
|
||||
display: block;
|
||||
block-size: calc(100% - 14px);
|
||||
filter:
|
||||
drop-shadow(0 8px 12px rgba(24, 8, 52, 0.3))
|
||||
drop-shadow(0 0 10px rgba(var(--v-theme-primary), 0.22));
|
||||
inline-size: calc(100% - 14px);
|
||||
inset: 7px;
|
||||
pointer-events: none;
|
||||
transform: translateZ(10px);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.prismatic-logo__base,
|
||||
.prismatic-logo__spectrum,
|
||||
.prismatic-logo__specular,
|
||||
.prismatic-logo__reveal {
|
||||
|
||||
@@ -1,29 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import logoUrl from '@images/logo.svg'
|
||||
import logoSvg from '@images/logo.svg?raw'
|
||||
import { applyThemeLogoPalette, createThemeLogoPalette } from '@/utils/themeLogo'
|
||||
import { computed } from 'vue'
|
||||
import { useTheme } from 'vuetify'
|
||||
|
||||
/** 使用当前 Vuetify 主题主色渲染非 WebGL 场景中的 MoviePilot 标识。 */
|
||||
const logoMaskStyle = {
|
||||
'--theme-logo-mask': `url("${logoUrl}")`,
|
||||
}
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 装饰模式不重复暴露图像语义,适用于已有外层可访问名称的复合标识。 */
|
||||
decorative?: boolean
|
||||
}>(),
|
||||
{
|
||||
decorative: false,
|
||||
},
|
||||
)
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
/** 保留品牌 SVG 的分面与高光结构,只将原始紫色色阶映射到当前主题色家族。 */
|
||||
const themedLogoSvg = computed(() =>
|
||||
applyThemeLogoPalette(logoSvg, createThemeLogoPalette(theme.current.value.colors.primary)),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="theme-logo-mark" :style="logoMaskStyle" role="img" aria-label="MoviePilot" />
|
||||
<span
|
||||
class="theme-logo-mark"
|
||||
:role="props.decorative ? undefined : 'img'"
|
||||
:aria-label="props.decorative ? undefined : 'MoviePilot'"
|
||||
:aria-hidden="props.decorative || undefined"
|
||||
>
|
||||
<span class="theme-logo-mark__svg" v-html="themedLogoSvg" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.theme-logo-mark {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
color-mix(in srgb, rgb(var(--v-theme-primary)) 48%, white) 0%,
|
||||
rgb(var(--v-theme-primary)) 48%,
|
||||
color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, black) 100%
|
||||
);
|
||||
block-size: 3em;
|
||||
inline-size: 3em;
|
||||
mask: var(--theme-logo-mask) center / contain no-repeat;
|
||||
-webkit-mask: var(--theme-logo-mask) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.theme-logo-mark__svg,
|
||||
.theme-logo-mark__svg :deep(svg) {
|
||||
display: block;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
|
||||
const DEFAULT_ACCENT_RGB = '145, 85, 253'
|
||||
const DEFAULT_ACCENT_RGB = '141, 81, 249'
|
||||
|
||||
/** 将图标主色转换为卡片 CSS 变量可直接使用的 RGB 字符串。 */
|
||||
function hexToRgbString(hexColor: string) {
|
||||
@@ -13,14 +13,14 @@ function hexToRgbString(hexColor: string) {
|
||||
}
|
||||
|
||||
/** 从指定图片中提取卡片强调色,返回 CSS 变量可直接使用的 RGB 字符串。 */
|
||||
export async function getCardAccentRgbFromImage(image: HTMLImageElement | undefined | null, fallback = '#9155FD') {
|
||||
export async function getCardAccentRgbFromImage(image: HTMLImageElement | undefined | null, fallback = '#8D51F9') {
|
||||
const dominantColor = await getDominantColor(image, { fallback })
|
||||
|
||||
return hexToRgbString(dominantColor)
|
||||
}
|
||||
|
||||
/** 从卡片图标中提取强调色,保证设置页卡片颜色跟随各自图标。 */
|
||||
export function useCardAccentColor(fallback = '#9155FD') {
|
||||
export function useCardAccentColor(fallback = '#8D51F9') {
|
||||
const accentRgb = ref(DEFAULT_ACCENT_RGB)
|
||||
const imageRef = ref<any>()
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change
|
||||
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
|
||||
|
||||
export const themeCustomizerPrimaryColors = [
|
||||
{ name: 'Purple', value: '#9155FD' },
|
||||
{ name: 'Purple', value: '#8D51F9' },
|
||||
{ name: 'Indigo', value: '#3F51B5' },
|
||||
{ name: 'Blue', value: '#1976D2' },
|
||||
{ name: 'Cyan', value: '#00BCD4' },
|
||||
@@ -126,12 +126,13 @@ function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSetti
|
||||
const fallback = getDefaultThemeCustomizerSettings()
|
||||
const storedRadius = settings.radius as string | undefined
|
||||
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
|
||||
const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor
|
||||
|
||||
return {
|
||||
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
||||
? (settings.layout as ThemeCustomizerLayout)
|
||||
: fallback.layout,
|
||||
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
|
||||
primaryColor,
|
||||
radius: validRadii.includes(radius as ThemeCustomizerRadius)
|
||||
? (radius as ThemeCustomizerRadius)
|
||||
: fallback.radius,
|
||||
@@ -155,7 +156,6 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
|
||||
const parsed = stored ? JSON.parse(stored) : {}
|
||||
|
||||
return normalizeThemeCustomizerSettings({
|
||||
...fallback,
|
||||
...parsed,
|
||||
|
||||
@@ -6,7 +6,7 @@ const theme: VuetifyOptions['theme'] = {
|
||||
light: {
|
||||
dark: false,
|
||||
colors: {
|
||||
'primary': '#9155FD',
|
||||
'primary': '#8D51F9',
|
||||
'secondary': '#8A8D93',
|
||||
'on-secondary': '#FFFFFF',
|
||||
'success': '#56CA00',
|
||||
@@ -107,7 +107,7 @@ const theme: VuetifyOptions['theme'] = {
|
||||
purple: {
|
||||
dark: true,
|
||||
colors: {
|
||||
'primary': '#9155FD',
|
||||
'primary': '#8D51F9',
|
||||
'secondary': '#8A8D93',
|
||||
'on-secondary': '#FFFFFF',
|
||||
'success': '#56CA00',
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import logoSvg from '@images/logo.svg?raw'
|
||||
import { applyThemeLogoPalette, createThemeLogoPalette } from '@/utils/themeLogo'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('theme logo palette', () => {
|
||||
it('reproduces the source artwork when its original primary color is selected', () => {
|
||||
const result = applyThemeLogoPalette(logoSvg, createThemeLogoPalette('#8D51F9'))
|
||||
|
||||
expect(result).toBe(logoSvg)
|
||||
})
|
||||
|
||||
it('replaces every original logo color without flattening its gradient structure', () => {
|
||||
const palette = createThemeLogoPalette('#00BCD4')
|
||||
const result = applyThemeLogoPalette(logoSvg, palette)
|
||||
const originalColors = [
|
||||
'rgb(141,81,249)',
|
||||
'rgb(165,118,255)',
|
||||
'rgb(211,187,255)',
|
||||
'rgb(116,50,223)',
|
||||
'rgb(110,38,217)',
|
||||
'rgb(104,0,197)',
|
||||
'rgb(91,0,197)',
|
||||
]
|
||||
|
||||
expect(result.match(/<(?:linear|radial)Gradient/g)).toHaveLength(6)
|
||||
expect(result.match(/<path/g)).toHaveLength(12)
|
||||
expect(result).toContain('stop-opacity:1')
|
||||
expect(result).toContain(palette.primary)
|
||||
expect(result).toContain(palette.highlight)
|
||||
expect(result).toContain(palette.deepest)
|
||||
originalColors.forEach(color => expect(result).not.toContain(color))
|
||||
})
|
||||
|
||||
it.each(['#000000', '#808080', '#FFFFFF'])('keeps visible facet contrast for neutral theme color %s', primary => {
|
||||
const palette = createThemeLogoPalette(primary)
|
||||
const distinctColors = new Set(Object.values(palette))
|
||||
|
||||
expect(distinctColors.size).toBeGreaterThanOrEqual(5)
|
||||
expect(palette.primary).not.toBe(palette.highlight)
|
||||
expect(palette.primary).not.toBe(palette.deepest)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
export interface ThemeLogoPalette {
|
||||
/** 标识主体色,保持与当前主题主色一致。 */
|
||||
primary: string
|
||||
/** 标识迎光面色阶。 */
|
||||
light: string
|
||||
/** 标识高光渐变色阶。 */
|
||||
highlight: string
|
||||
/** 标识第一层背光面色阶。 */
|
||||
dark: string
|
||||
/** 标识第二层背光面色阶。 */
|
||||
darker: string
|
||||
/** 标识内侧深色面色阶。 */
|
||||
deep: string
|
||||
/** 标识最深的内侧面色阶。 */
|
||||
deepest: string
|
||||
}
|
||||
|
||||
interface HslColor {
|
||||
h: number
|
||||
l: number
|
||||
s: number
|
||||
}
|
||||
|
||||
const sourceLogoPalette: Record<string, keyof ThemeLogoPalette> = {
|
||||
'rgb(141,81,249)': 'primary',
|
||||
'rgb(165,118,255)': 'light',
|
||||
'rgb(211,187,255)': 'highlight',
|
||||
'rgb(116,50,223)': 'dark',
|
||||
'rgb(110,38,217)': 'darker',
|
||||
'rgb(104,0,197)': 'deep',
|
||||
'rgb(91,0,197)': 'deepest',
|
||||
}
|
||||
|
||||
const sourceLogoRgb: Record<keyof ThemeLogoPalette, [number, number, number]> = {
|
||||
primary: [141, 81, 249],
|
||||
light: [165, 118, 255],
|
||||
highlight: [211, 187, 255],
|
||||
dark: [116, 50, 223],
|
||||
darker: [110, 38, 217],
|
||||
deep: [104, 0, 197],
|
||||
deepest: [91, 0, 197],
|
||||
}
|
||||
|
||||
function clamp(value: number, min = 0, max = 1) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function parseHexColor(hexColor: string) {
|
||||
const normalized = hexColor.trim().replace('#', '')
|
||||
if (!/^[\da-f]{6}$/i.test(normalized)) return null
|
||||
|
||||
return [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16)) as [number, number, number]
|
||||
}
|
||||
|
||||
function rgbToHsl([red, green, blue]: [number, number, number]): HslColor {
|
||||
const r = red / 255
|
||||
const g = green / 255
|
||||
const b = blue / 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
const delta = max - min
|
||||
const l = (max + min) / 2
|
||||
|
||||
if (delta === 0) return { h: 0, l, s: 0 }
|
||||
|
||||
const s = delta / (1 - Math.abs(2 * l - 1))
|
||||
let h = 0
|
||||
|
||||
if (max === r) h = ((g - b) / delta) % 6
|
||||
else if (max === g) h = (b - r) / delta + 2
|
||||
else h = (r - g) / delta + 4
|
||||
|
||||
return { h: (h * 60 + 360) % 360, l, s }
|
||||
}
|
||||
|
||||
function hslToRgb({ h, l, s }: HslColor) {
|
||||
const chroma = (1 - Math.abs(2 * l - 1)) * s
|
||||
const segment = h / 60
|
||||
const secondary = chroma * (1 - Math.abs((segment % 2) - 1))
|
||||
let channels: [number, number, number]
|
||||
|
||||
if (segment < 1) channels = [chroma, secondary, 0]
|
||||
else if (segment < 2) channels = [secondary, chroma, 0]
|
||||
else if (segment < 3) channels = [0, chroma, secondary]
|
||||
else if (segment < 4) channels = [0, secondary, chroma]
|
||||
else if (segment < 5) channels = [secondary, 0, chroma]
|
||||
else channels = [chroma, 0, secondary]
|
||||
|
||||
const offset = l - chroma / 2
|
||||
const rgb = channels.map(channel => Math.round((channel + offset) * 255))
|
||||
|
||||
return `rgb(${rgb.join(',')})`
|
||||
}
|
||||
|
||||
function shiftLogoTone(color: HslColor, hueOffset: number, lightnessOffset: number, saturationScale = 1) {
|
||||
return hslToRgb({
|
||||
h: (color.h + hueOffset + 360) % 360,
|
||||
l: clamp(color.l + lightnessOffset, 0.08, 0.92),
|
||||
s: clamp(color.s * saturationScale),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 从主题主色生成完整的标识明暗色阶。
|
||||
* 接近黑、白的主题色会反向拉开部分色阶,避免分面收敛成同一颜色。
|
||||
*/
|
||||
export function createThemeLogoPalette(primaryColor: string): ThemeLogoPalette {
|
||||
const rgb = parseHexColor(primaryColor) || [141, 81, 249]
|
||||
const hsl = rgbToHsl(rgb)
|
||||
const sourcePrimaryHsl = rgbToHsl(sourceLogoRgb.primary)
|
||||
const lightDirection = hsl.l >= 0.78 ? -1 : 1
|
||||
const darkDirection = hsl.l <= 0.22 ? 1 : -1
|
||||
const palette = Object.fromEntries(
|
||||
Object.entries(sourceLogoRgb).map(([key, sourceRgb]) => {
|
||||
const paletteKey = key as keyof ThemeLogoPalette
|
||||
if (paletteKey === 'primary') return [paletteKey, `rgb(${rgb.join(',')})`]
|
||||
|
||||
const sourceHsl = rgbToHsl(sourceRgb)
|
||||
const hueOffset = sourceHsl.h - sourcePrimaryHsl.h
|
||||
const sourceLightnessDelta = sourceHsl.l - sourcePrimaryHsl.l
|
||||
const lightnessDelta =
|
||||
Math.abs(sourceLightnessDelta) * (sourceLightnessDelta >= 0 ? lightDirection : darkDirection)
|
||||
const saturationScale = sourcePrimaryHsl.s ? sourceHsl.s / sourcePrimaryHsl.s : 1
|
||||
|
||||
return [paletteKey, shiftLogoTone(hsl, hueOffset, lightnessDelta, saturationScale)]
|
||||
}),
|
||||
) as unknown as ThemeLogoPalette
|
||||
|
||||
return palette
|
||||
}
|
||||
|
||||
/** 将原始品牌 SVG 的色阶逐层映射到当前主题色家族,保留路径、渐变和透明高光。 */
|
||||
export function applyThemeLogoPalette(svgSource: string, palette: ThemeLogoPalette) {
|
||||
return Object.entries(sourceLogoPalette).reduce(
|
||||
(svg, [sourceColor, paletteKey]) => svg.replaceAll(sourceColor, palette[paletteKey]),
|
||||
svgSource,
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ interface ApplyDocumentThemeChromeOptions {
|
||||
export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
|
||||
light: {
|
||||
background: '#F4F5FA',
|
||||
primary: '#9155FD',
|
||||
primary: '#8D51F9',
|
||||
},
|
||||
dark: {
|
||||
background: '#0E1116',
|
||||
@@ -27,7 +27,7 @@ export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
|
||||
},
|
||||
purple: {
|
||||
background: '#28243D',
|
||||
primary: '#9155FD',
|
||||
primary: '#8D51F9',
|
||||
},
|
||||
transparent: {
|
||||
background: '#1C1C1C',
|
||||
|
||||
Reference in New Issue
Block a user