feat: implement dynamic accent color extraction and styling for UI cards with standardized shadow removal

This commit is contained in:
jxxghp
2026-05-18 11:20:58 +08:00
parent 91dbf065db
commit d7f74a3a8a
11 changed files with 379 additions and 73 deletions

View File

@@ -2,8 +2,10 @@
import type { CustomRule } from '@/api/types'
import filter_svg from '@images/svg/filter.svg'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
const CustomRuleInfoDialog = defineAsyncComponent(() => import('@/components/dialog/CustomRuleInfoDialog.vue'))
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#8A8D93')
// 输入参数
const props = defineProps({
@@ -45,7 +47,12 @@ function onClose() {
</script>
<template>
<VCard variant="tonal" class="app-card-shell" @click="openRuleInfoDialog">
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openRuleInfoDialog"
>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
@@ -58,7 +65,7 @@ function onClose() {
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="filter_svg" contain class="app-card-summary__image" />
<VImg ref="imageRef" :src="filter_svg" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>

View File

@@ -4,9 +4,38 @@ import api from '@/api'
import { nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { storageRemoteDict } from '@/api/constants'
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
import storage_png from '@images/misc/storage.png'
import alipan_png from '@images/misc/alipan.webp'
import u115_png from '@images/misc/u115.png'
import rclone_png from '@images/misc/rclone.png'
import alist_png from '@images/misc/openlist.svg'
import smb_png from '@images/misc/smb.png'
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
const STORAGE_ICON_MAP = {
local: storage_png,
alipan: alipan_png,
u115: u115_png,
rclone: rclone_png,
alist: alist_png,
smb: smb_png,
}
const STORAGE_FALLBACK_COLOR_MAP = {
local: '#FFB400',
alipan: '#00A7F2',
u115: '#17B26A',
rclone: '#6675FF',
alist: '#12B8D7',
smb: '#3B82F6',
}
// 国际化
const { t } = useI18n()
const downloadAccentRgb = ref(DEFAULT_DIRECTORY_ACCENT_RGB)
const libraryAccentRgb = ref(DEFAULT_DIRECTORY_ACCENT_RGB)
let accentUpdateToken = 0
// 输入参数
const props = defineProps({
@@ -63,6 +92,117 @@ const transferSourceItems = computed(() => [
{ title: t('directory.manualTransfer'), value: 'manual' },
])
function hasKnownStorageType(storageType?: string): storageType is keyof typeof STORAGE_ICON_MAP {
return !!storageType && Object.prototype.hasOwnProperty.call(STORAGE_ICON_MAP, storageType)
}
function getStorageIcon(storageType?: string) {
return hasKnownStorageType(storageType) ? STORAGE_ICON_MAP[storageType] : storage_png
}
function hexToRgbString(hexColor: string) {
const normalizedColor = hexColor.replace('#', '')
const colorValue = Number.parseInt(normalizedColor, 16)
if (Number.isNaN(colorValue) || normalizedColor.length !== 6) return DEFAULT_DIRECTORY_ACCENT_RGB
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
}
function rgbToHex(value: number) {
return Math.round(value).toString(16).padStart(2, '0')
}
function hslToHex(hue: number, saturation: number, lightness: number) {
const normalizedSaturation = saturation / 100
const normalizedLightness = lightness / 100
const chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation
const secondComponent = chroma * (1 - Math.abs(((hue / 60) % 2) - 1))
const lightnessMatch = normalizedLightness - chroma / 2
let red = 0
let green = 0
let blue = 0
if (hue < 60) [red, green, blue] = [chroma, secondComponent, 0]
else if (hue < 120) [red, green, blue] = [secondComponent, chroma, 0]
else if (hue < 180) [red, green, blue] = [0, chroma, secondComponent]
else if (hue < 240) [red, green, blue] = [0, secondComponent, chroma]
else if (hue < 300) [red, green, blue] = [secondComponent, 0, chroma]
else [red, green, blue] = [chroma, 0, secondComponent]
return `#${rgbToHex((red + lightnessMatch) * 255)}${rgbToHex((green + lightnessMatch) * 255)}${rgbToHex((blue + lightnessMatch) * 255)}`
}
function getStableStorageColor(storageType?: string) {
const source = storageType || 'custom'
let hash = 0
for (let index = 0; index < source.length; index += 1) {
hash = Math.imul(31, hash) + source.charCodeAt(index)
}
return hslToHex(Math.abs(hash) % 360, 66, 54)
}
function getStorageFallbackColor(storageType?: string) {
if (hasKnownStorageType(storageType)) return STORAGE_FALLBACK_COLOR_MAP[storageType]
// 自定义存储没有固定品牌图标,按类型生成稳定颜色,保证切换 custom1/custom2 时也有变化。
return getStableStorageColor(storageType)
}
// 目录卡片用下载存储和媒体库存储两端的图标主色生成轻渐变,体现整理链路的两个存储端点。
const directoryAccentStyle = computed(() => ({
'--app-card-accent-rgb': downloadAccentRgb.value,
'--app-card-accent-end-rgb': libraryAccentRgb.value,
}))
function loadStorageIconImage(storageType?: string) {
return new Promise<HTMLImageElement | null>(resolve => {
if (typeof Image === 'undefined') {
resolve(null)
return
}
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => resolve(null)
image.src = getStorageIcon(storageType)
if (image.complete) resolve(image)
})
}
async function getStorageAccentRgb(storageType?: string) {
const fallbackColor = getStorageFallbackColor(storageType)
if (!hasKnownStorageType(storageType)) return hexToRgbString(fallbackColor)
const image = await loadStorageIconImage(storageType)
return getCardAccentRgbFromImage(image, fallbackColor)
}
async function updateDirectoryAccentColors() {
const currentToken = ++accentUpdateToken
const downloadStorage = props.directory.storage
const libraryStorage = props.directory.library_storage || props.directory.storage
downloadAccentRgb.value = hexToRgbString(getStorageFallbackColor(downloadStorage))
libraryAccentRgb.value = hexToRgbString(getStorageFallbackColor(libraryStorage))
const [downloadRgb, libraryRgb] = await Promise.all([
getStorageAccentRgb(downloadStorage),
getStorageAccentRgb(libraryStorage),
])
if (currentToken !== accentUpdateToken) return
downloadAccentRgb.value = downloadRgb
libraryAccentRgb.value = libraryRgb
}
// 监控模式下拉字典
const MonitorModeItems = computed(() => [
{ title: t('directory.performanceMode'), value: 'fast' },
@@ -168,6 +308,15 @@ watch(
{ immediate: true },
)
// 存储类型切换后主动重新提取图标色,避免图片缓存导致 load 事件不触发。
watch(
[() => props.directory.storage, () => props.directory.library_storage],
() => {
updateDirectoryAccentColors()
},
{ immediate: true },
)
// 媒体类别和类型变更非空时将按类型分类和按类别分类置为false
watch(
[() => props.directory.media_type, () => props.directory.media_category],
@@ -195,7 +344,13 @@ watch(
</script>
<template>
<VCard variant="tonal" class="app-card-shell" :width="props.width" :height="props.height">
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="directoryAccentStyle"
:width="props.width"
:height="props.height"
>
<VDialogCloseBtn @click="onClose" />
<VCardItem>
<VTextField

View File

@@ -7,12 +7,14 @@ import { useI18n } from 'vue-i18n'
import { downloaderDict } from '@/api/constants'
import { useBackground } from '@/composables/useBackground'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
const DownloaderInfoDialog = defineAsyncComponent(() => import('@/components/dialog/DownloaderInfoDialog.vue'))
// 获取i18n实例
const { t } = useI18n()
const { useConditionalDataRefresh } = useBackground()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor()
// 定义输入
const props = defineProps({
@@ -122,9 +124,9 @@ onUnmounted(() => {
<VCard
v-bind="hover.props"
variant="tonal"
class="app-card-shell"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openDownloaderInfoDialog"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<VDialogCloseBtn @click="onClose" />
<span class="app-card-top-action absolute top-3 right-12">
@@ -153,7 +155,7 @@ onUnmounted(() => {
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>

View File

@@ -3,11 +3,13 @@ import type { CustomRule, FilterRuleGroup } from '@/api/types'
import filter_group_svg from '@images/svg/filter-group.svg'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
const FilterRuleGroupInfoDialog = defineAsyncComponent(() => import('@/components/dialog/FilterRuleGroupInfoDialog.vue'))
// 获取i18n实例
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#8A8D93')
// 输入参数
const props = defineProps({
@@ -58,7 +60,12 @@ function onClose() {
</script>
<template>
<VCard variant="tonal" class="app-card-shell" @click="openGroupInfoDialog">
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openGroupInfoDialog"
>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
@@ -74,7 +81,7 @@ function onClose() {
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="filter_group_svg" contain class="app-card-summary__image" />
<VImg ref="imageRef" :src="filter_group_svg" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>

View File

@@ -5,11 +5,13 @@ import { getLogoUrl } from '@/utils/imageUtils'
import { useI18n } from 'vue-i18n'
import { mediaServerDict } from '@/api/constants'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
const MediaServerInfoDialog = defineAsyncComponent(() => import('@/components/dialog/MediaServerInfoDialog.vue'))
// 获取i18n实例
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#56CA00')
// 定义输入
const props = defineProps({
@@ -127,7 +129,12 @@ onMounted(() => {
</script>
<template>
<VCard variant="tonal" class="app-card-shell" @click="openMediaServerInfoDialog">
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openMediaServerInfoDialog"
>
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--single-action">
<div class="app-card-summary__content">
@@ -146,7 +153,7 @@ onMounted(() => {
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>

View File

@@ -3,10 +3,12 @@ import type { NotificationConf } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
const NotificationChannelInfoDialog = defineAsyncComponent(() => import('@/components/dialog/NotificationChannelInfoDialog.vue'))
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor()
// 定义输入
const props = defineProps({
@@ -91,7 +93,12 @@ function onClose() {
</script>
<template>
<VCard variant="tonal" class="app-card-shell" @click="openNotificationInfoDialog">
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openNotificationInfoDialog"
>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
@@ -107,7 +114,7 @@ function onClose() {
<div class="app-card-summary__subtitle text-body-1">{{ notificationTypeNames[notification.type] }}</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>

View File

@@ -13,6 +13,7 @@ import { useToast } from 'vue-toastification'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
const AliyunAuthDialog = defineAsyncComponent(() => import('../dialog/AliyunAuthDialog.vue'))
const U115AuthDialog = defineAsyncComponent(() => import('../dialog/U115AuthDialog.vue'))
@@ -23,6 +24,7 @@ const StorageCustomConfigDialog = defineAsyncComponent(() => import('../dialog/S
// 国际化
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#FFB400')
// 定义输入
const props = defineProps({
@@ -142,15 +144,28 @@ function onClose() {
</script>
<template>
<VCard variant="tonal" @click="openStorageDialog">
<VDialogCloseBtn @click="onClose" class="absolute top-1 right-1" />
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openStorageDialog"
>
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
</div>
<VImg :src="getIcon" cover class="mt-8" max-width="3rem" min-width="3rem" />
<VImg
ref="imageRef"
:src="getIcon"
cover
class="mt-8"
max-width="3rem"
min-width="3rem"
@load="updateAccentColor"
/>
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />