mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-25 09:33:51 +08:00
feat: implement dynamic accent color extraction and styling for UI cards with standardized shadow removal
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
38
src/composables/useCardAccentColor.ts
Normal file
38
src/composables/useCardAccentColor.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
|
||||
const DEFAULT_ACCENT_RGB = '145, 85, 253'
|
||||
|
||||
/** 将图标主色转换为卡片 CSS 变量可直接使用的 RGB 字符串。 */
|
||||
function hexToRgbString(hexColor: string) {
|
||||
const normalizedColor = hexColor.replace('#', '')
|
||||
const colorValue = Number.parseInt(normalizedColor, 16)
|
||||
|
||||
if (Number.isNaN(colorValue) || normalizedColor.length !== 6) return DEFAULT_ACCENT_RGB
|
||||
|
||||
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
|
||||
}
|
||||
|
||||
/** 从指定图片中提取卡片强调色,返回 CSS 变量可直接使用的 RGB 字符串。 */
|
||||
export async function getCardAccentRgbFromImage(image: HTMLImageElement | undefined | null, fallback = '#9155FD') {
|
||||
const dominantColor = await getDominantColor(image, { fallback })
|
||||
|
||||
return hexToRgbString(dominantColor)
|
||||
}
|
||||
|
||||
/** 从卡片图标中提取强调色,保证设置页卡片颜色跟随各自图标。 */
|
||||
export function useCardAccentColor(fallback = '#9155FD') {
|
||||
const accentRgb = ref(DEFAULT_ACCENT_RGB)
|
||||
const imageRef = ref<any>()
|
||||
|
||||
async function updateAccentColor() {
|
||||
const imageElement = imageRef.value?.$el?.querySelector('img') as HTMLImageElement | undefined
|
||||
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, fallback)
|
||||
}
|
||||
|
||||
return {
|
||||
accentRgb,
|
||||
imageRef,
|
||||
updateAccentColor,
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,12 @@ html.v-overlay-scroll-blocked body {
|
||||
}
|
||||
}
|
||||
|
||||
// 全局卡片阴影 token:卡片统一不使用投影,避免透明主题和密集布局下出现脏边。
|
||||
html {
|
||||
--app-card-rest-shadow: none;
|
||||
--app-card-hover-shadow: none;
|
||||
}
|
||||
|
||||
// 进度条样式
|
||||
#nprogress .bar {
|
||||
background: rgb(var(--v-theme-primary)) !important;
|
||||
@@ -48,12 +54,103 @@ html.v-overlay-scroll-blocked body {
|
||||
}
|
||||
}
|
||||
|
||||
// 应用类信息卡片:固定右侧媒体槽位,避免图片被左侧文字挤压变形
|
||||
// 统一系统内卡片阴影,显式覆盖 Vuetify elevation 或局部卡片默认投影。
|
||||
.v-card,
|
||||
.v-application .v-card.v-card[class] {
|
||||
box-shadow: none !important;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.v-card:hover,
|
||||
.v-application .v-card.v-card[class]:hover {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 应用类信息卡片:固定右侧媒体槽位,避免图片被左侧文字挤压变形。
|
||||
.app-card-shell {
|
||||
position: relative;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
// 设置项强调卡片:复用通知模板入口的强调条、轻渐变与悬浮反馈。
|
||||
.app-card-colorful {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--app-card-accent-rgb), var(--app-card-border-opacity)) !important;
|
||||
border-radius: 8px !important;
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--app-card-accent-rgb), var(--app-card-accent-start-opacity)),
|
||||
rgba(var(--app-card-accent-end-rgb), var(--app-card-accent-end-opacity)) 46%,
|
||||
rgba(var(--v-theme-surface), 0) 76%
|
||||
),
|
||||
rgba(var(--v-theme-surface), var(--app-card-surface-opacity)) !important;
|
||||
box-shadow: var(--app-card-rest-shadow) !important;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
--app-card-accent-rgb: var(--v-theme-primary);
|
||||
--app-card-accent-end-rgb: var(--app-card-accent-rgb);
|
||||
--app-card-accent-start-opacity: 0.09;
|
||||
--app-card-accent-end-opacity: 0.02;
|
||||
--app-card-border-opacity: 0.2;
|
||||
--app-card-hover-border-opacity: 0.34;
|
||||
--app-card-stripe-opacity: 0.78;
|
||||
--app-card-surface-opacity: 0.92;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.app-card-colorful::before {
|
||||
position: absolute;
|
||||
background: rgb(var(--app-card-accent-rgb));
|
||||
block-size: 100%;
|
||||
content: "";
|
||||
inline-size: 0.25rem;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: var(--app-card-stripe-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-card-colorful:hover {
|
||||
border-color: rgba(var(--app-card-accent-rgb), var(--app-card-hover-border-opacity)) !important;
|
||||
box-shadow: var(--app-card-hover-shadow) !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.app-card-colorful:focus-visible {
|
||||
outline: 2px solid rgba(var(--app-card-accent-rgb), 0.7);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.app-card-color-probe {
|
||||
position: absolute;
|
||||
block-size: 3rem;
|
||||
inline-size: 3rem;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
html[data-theme="transparent"] .app-card-colorful,
|
||||
.v-theme--transparent .app-card-colorful {
|
||||
backdrop-filter: blur(var(--transparent-blur, 10px));
|
||||
border: 0 !important;
|
||||
--app-card-accent-start-opacity: 0.04;
|
||||
--app-card-accent-end-opacity: 0.012;
|
||||
--app-card-border-opacity: 0;
|
||||
--app-card-hover-border-opacity: 0;
|
||||
--app-card-stripe-opacity: 0.42;
|
||||
--app-card-surface-opacity: var(--transparent-opacity-light, 0.2);
|
||||
}
|
||||
|
||||
html[data-theme="transparent"],
|
||||
.v-theme--transparent {
|
||||
--app-card-rest-shadow: none;
|
||||
--app-card-hover-shadow: none;
|
||||
}
|
||||
|
||||
// 保证卡片右上角的浮动操作区始终高于可点击的卡片内容层,避免误触发详情打开。
|
||||
.app-card-top-action {
|
||||
z-index: 2;
|
||||
@@ -256,7 +353,7 @@ html.v-overlay-scroll-blocked body {
|
||||
}
|
||||
|
||||
.grid-directory-card {
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 24rem), 1fr));
|
||||
}
|
||||
|
||||
.grid-filterrule-card {
|
||||
|
||||
@@ -37,6 +37,19 @@ html[data-theme="transparent"] {
|
||||
}
|
||||
}
|
||||
|
||||
// 设置页彩色卡片保留透明主题的玻璃质感,只叠加非常轻的图标主色。
|
||||
.app-card-colorful {
|
||||
background:
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--app-card-accent-rgb), var(--app-card-accent-start-opacity, 0.04)),
|
||||
rgba(var(--app-card-accent-end-rgb, var(--app-card-accent-rgb)), var(--app-card-accent-end-opacity, 0.012)) 46%,
|
||||
rgba(var(--v-theme-surface), 0) 76%
|
||||
),
|
||||
rgba(var(--v-theme-surface), var(--transparent-opacity-light)) !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
// 工具栏
|
||||
.v-toolbar {
|
||||
backdrop-filter: blur(var(--transparent-blur));
|
||||
|
||||
@@ -30,22 +30,22 @@ const templateTypeDefaults = [
|
||||
{
|
||||
type: 'organizeSuccess',
|
||||
icon: 'mdi-folder-check',
|
||||
color: 'primary',
|
||||
accentRgb: 'var(--v-theme-primary)',
|
||||
},
|
||||
{
|
||||
type: 'downloadAdded',
|
||||
icon: 'mdi-download-box',
|
||||
color: 'info',
|
||||
accentRgb: 'var(--v-theme-info)',
|
||||
},
|
||||
{
|
||||
type: 'subscribeAdded',
|
||||
icon: 'mdi-rss-box',
|
||||
color: 'warning',
|
||||
accentRgb: 'var(--v-theme-warning)',
|
||||
},
|
||||
{
|
||||
type: 'subscribeComplete',
|
||||
icon: 'mdi-check-circle',
|
||||
color: 'success',
|
||||
accentRgb: 'var(--v-theme-success)',
|
||||
},
|
||||
] as const
|
||||
|
||||
@@ -67,6 +67,10 @@ const templateTypes = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
function getTemplateAccentStyle(item: (typeof templateTypes.value)[number]) {
|
||||
return { '--app-card-accent-rgb': item.accentRgb }
|
||||
}
|
||||
|
||||
// Ace 直接跟随 Vuetify 当前生效主题,auto 模式下也能按实际明暗色切换。
|
||||
const { global: globalTheme } = useTheme()
|
||||
const editorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
|
||||
@@ -484,8 +488,8 @@ useSilentSettingRefresh(loadPageData, {
|
||||
v-for="item in templateTypes"
|
||||
:key="item.type"
|
||||
type="button"
|
||||
class="notification-template-card"
|
||||
:class="`template-accent-${item.color}`"
|
||||
class="notification-template-card app-card-shell app-card-colorful"
|
||||
:style="getTemplateAccentStyle(item)"
|
||||
@click="openEditor(item.type)"
|
||||
>
|
||||
<span class="template-card-icon">
|
||||
@@ -585,7 +589,7 @@ useSilentSettingRefresh(loadPageData, {
|
||||
</VRow>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* 模板入口保持设置页的紧凑密度,同时用轻量强调色区分不同通知场景。 */
|
||||
/* 模板入口保持设置页的紧凑密度,卡片壳层复用全局 app-card-shell。 */
|
||||
.notification-template-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -595,43 +599,13 @@ useSilentSettingRefresh(loadPageData, {
|
||||
.notification-template-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--template-accent), 0.18);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(var(--template-accent), 0.12), rgba(var(--v-theme-surface), 0) 58%),
|
||||
rgba(var(--v-theme-surface), 0.72);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
cursor: pointer;
|
||||
gap: 0.875rem;
|
||||
inline-size: 100%;
|
||||
min-block-size: 5.25rem;
|
||||
padding: 1rem;
|
||||
text-align: start;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.notification-template-card::before {
|
||||
position: absolute;
|
||||
background: rgb(var(--template-accent));
|
||||
block-size: 100%;
|
||||
content: "";
|
||||
inline-size: 0.25rem;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.notification-template-card:hover {
|
||||
border-color: rgba(var(--template-accent), 0.36);
|
||||
box-shadow: 0 0.75rem 1.75rem rgba(var(--template-accent), 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.notification-template-card:focus-visible {
|
||||
outline: 2px solid rgba(var(--template-accent), 0.7);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.template-card-icon {
|
||||
@@ -640,9 +614,9 @@ useSilentSettingRefresh(loadPageData, {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--template-accent), 0.16);
|
||||
background: rgba(var(--app-card-accent-rgb), 0.16);
|
||||
block-size: 2.75rem;
|
||||
color: rgb(var(--template-accent));
|
||||
color: rgb(var(--app-card-accent-rgb));
|
||||
inline-size: 2.75rem;
|
||||
}
|
||||
|
||||
@@ -677,26 +651,10 @@ useSilentSettingRefresh(loadPageData, {
|
||||
}
|
||||
|
||||
.notification-template-card:hover .template-card-arrow {
|
||||
color: rgb(var(--template-accent));
|
||||
color: rgb(var(--app-card-accent-rgb));
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.template-accent-primary {
|
||||
--template-accent: var(--v-theme-primary);
|
||||
}
|
||||
|
||||
.template-accent-info {
|
||||
--template-accent: var(--v-theme-info);
|
||||
}
|
||||
|
||||
.template-accent-warning {
|
||||
--template-accent: var(--v-theme-warning);
|
||||
}
|
||||
|
||||
.template-accent-success {
|
||||
--template-accent: var(--v-theme-success);
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.notification-template-grid {
|
||||
gap: 0.75rem;
|
||||
|
||||
Reference in New Issue
Block a user