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" />

View 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,
}
}

View File

@@ -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 {

View File

@@ -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));

View File

@@ -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;