mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
fix(glass): port material accents to v3 (#660)
This commit is contained in:
@@ -3,7 +3,7 @@ import api from '@/api'
|
||||
import type { ApiResponse, Plugin } from '@/api/types'
|
||||
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useToast } from 'vue-toastification'
|
||||
@@ -37,11 +37,7 @@ const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值)
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||
const { accentStyle, imageRef, resetAccentColor, updateAccentColor } = usePluginCardAccent()
|
||||
|
||||
// 获取当前插件的标签
|
||||
const pluginLabels = computed(() => {
|
||||
@@ -73,9 +69,12 @@ function closeInstallProgress() {
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
await updateAccentColor()
|
||||
}
|
||||
|
||||
function imageFailed() {
|
||||
imageLoadError.value = true
|
||||
resetAccentColor()
|
||||
}
|
||||
|
||||
// 计算图标路径
|
||||
@@ -240,7 +239,7 @@ onUnmounted(() => {
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||
:style="accentStyle"
|
||||
>
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
@@ -283,7 +282,7 @@ onUnmounted(() => {
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
@error="imageFailed"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
|
||||
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -54,11 +54,7 @@ const cardRatingSummary = computed(() =>
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值)
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||
const { accentStyle, imageRef, resetAccentColor, updateAccentColor } = usePluginCardAccent()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -110,9 +106,12 @@ watch(
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
await updateAccentColor()
|
||||
}
|
||||
|
||||
function imageFailed() {
|
||||
imageLoadError.value = true
|
||||
resetAccentColor()
|
||||
}
|
||||
|
||||
// 显示更新日志
|
||||
@@ -651,7 +650,7 @@ watch(
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||
:style="accentStyle"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
@@ -682,7 +681,7 @@ watch(
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
@error="imageFailed"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||||
import { normalizePluginAccentColor } from '@/utils/glassColor'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
@@ -31,8 +32,8 @@ vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: mocks.accentFromImage,
|
||||
vi.mock('@/@core/utils/image', () => ({
|
||||
extractDominantColor: mocks.accentFromImage,
|
||||
}))
|
||||
|
||||
const plugin: Plugin = {
|
||||
@@ -47,12 +48,13 @@ const plugin: Plugin = {
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
||||
template:
|
||||
'<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')"><img /></button>',
|
||||
})
|
||||
|
||||
describe('PluginAppCard rating badge', () => {
|
||||
beforeEach(() => {
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('#123456')
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||
mocks.dialogCloses.length = 0
|
||||
@@ -232,7 +234,20 @@ describe('PluginAppCard rating badge', () => {
|
||||
const image = screen.getByTestId('plugin-image')
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||
).toBe(normalizePluginAccentColor('#123456')?.rgb)
|
||||
await fireEvent.contextMenu(image)
|
||||
mocks.accentFromImage.mockResolvedValueOnce('#654321')
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalledTimes(2))
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||
).toBe(normalizePluginAccentColor('#654321')?.rgb)
|
||||
await fireEvent.contextMenu(image)
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||
).toBe('')
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('项目主页'))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
import { normalizePluginAccentColor } from '@/utils/glassColor'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
@@ -34,8 +35,8 @@ vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: mocks.accentFromImage,
|
||||
vi.mock('@/@core/utils/image', () => ({
|
||||
extractDominantColor: mocks.accentFromImage,
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
@@ -55,12 +56,13 @@ const plugin: Plugin = {
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
||||
template:
|
||||
'<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')"><img /></button>',
|
||||
})
|
||||
|
||||
describe('PluginCard lifecycle actions', () => {
|
||||
beforeEach(() => {
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('#123456')
|
||||
mocks.apiDelete.mockReset()
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
@@ -362,6 +364,7 @@ describe('PluginCard lifecycle actions', () => {
|
||||
})
|
||||
|
||||
it('handles image lifecycle and ignores card clicks while sorting', async () => {
|
||||
mocks.accentFromImage.mockResolvedValueOnce(undefined).mockResolvedValueOnce('#123456')
|
||||
const { container } = await renderWithProviders(PluginCard, {
|
||||
props: {
|
||||
plugin: { ...plugin, plugin_icon: 'https://example.com/plugin.png' },
|
||||
@@ -372,7 +375,19 @@ describe('PluginCard lifecycle actions', () => {
|
||||
const [image, authorImage] = screen.getAllByTestId('plugin-image')
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||
).toBe('')
|
||||
await fireEvent.contextMenu(image)
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalledTimes(2))
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||
).toBe(normalizePluginAccentColor('#123456')?.rgb)
|
||||
await fireEvent.contextMenu(image)
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||
).toBe('')
|
||||
await fireEvent.click(authorImage)
|
||||
await fireEvent.click(container.querySelector('.v-card')!)
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
|
||||
@@ -25,8 +25,8 @@ vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: mocks.openSharedDialog,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
||||
vi.mock('@/@core/utils/image', () => ({
|
||||
extractDominantColor: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
|
||||
@@ -37,7 +37,7 @@ const props = defineProps<{
|
||||
translationStrength: number
|
||||
/** 路由变化标识,用于在页面内容稳定后重新发现高价值表面。 */
|
||||
routeKey: string
|
||||
/** 当前主题主色,用于同步色调材质的光学高光。 */
|
||||
/** 由用户主色派生的大面积玻璃材料色,用于同步色调材质的光学高光。 */
|
||||
tintColor: string
|
||||
/** 外层壁纸交叉淡化的时长,shader 使用同一时钟混合双纹理。 */
|
||||
transitionDuration: number
|
||||
|
||||
@@ -126,7 +126,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('GlassOpticalLayer', () => {
|
||||
it('uses exactly two visible presentation contexts with one interaction source', () => {
|
||||
it('uses exactly two visible presentation contexts with one interaction source', async () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
setRendererState.mockClear()
|
||||
@@ -159,6 +159,7 @@ describe('GlassOpticalLayer', () => {
|
||||
expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).toBe(true)
|
||||
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.map(options => (options.tintColor as () => string)())).toEqual(['#8D51F9', '#8D51F9'])
|
||||
expect(rendererCalls[0].pageMotion).toBeUndefined()
|
||||
expect(rendererCalls[1].pageMotion).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -167,6 +168,9 @@ describe('GlassOpticalLayer', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
await wrapper.setProps({ tintColor: '#00A6B8' })
|
||||
expect(rendererCalls.map(options => (options.tintColor as () => string)())).toEqual(['#00A6B8', '#00A6B8'])
|
||||
|
||||
wrapper.unmount()
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user