mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-27 11:10:12 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff871e7049 | ||
|
|
20f80d0f1f | ||
|
|
36bb99cf06 | ||
|
|
24c2e3b49c | ||
|
|
486a471c0c | ||
|
|
ce32da9176 | ||
|
|
39dd1b9e00 | ||
|
|
807711cae8 | ||
|
|
00feeb86ca |
210
index.html
210
index.html
@@ -4,7 +4,7 @@
|
|||||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||||
--safe-area-inset-top: env(safe-area-inset-top);
|
--safe-area-inset-top: env(safe-area-inset-top);
|
||||||
--initial-loader-bg: #0E1116;
|
--initial-loader-bg: #0E1116;
|
||||||
--initial-loader-color: #9155FD;
|
--initial-loader-color: #8D51F9;
|
||||||
--initial-loader-height: 100svh;
|
--initial-loader-height: 100svh;
|
||||||
--initial-loader-width: 100vw;
|
--initial-loader-width: 100vw;
|
||||||
--initial-color-scheme: dark;
|
--initial-color-scheme: dark;
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
<meta name="referrer" content="no-referrer" />
|
<meta name="referrer" content="no-referrer" />
|
||||||
|
|
||||||
<!-- PWA - 基础图标 -->
|
<!-- PWA - 基础图标 -->
|
||||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="64x64" />
|
||||||
<link rel="icon" type="image/png" href="/logo.png" sizes="any" />
|
<link rel="icon" type="image/png" href="/logo.png" sizes="192x192" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
<link id="theme-favicon" rel="icon" type="image/svg+xml" href="/logo.svg" sizes="any" />
|
||||||
|
|
||||||
<!-- iOS Safari PWA 优化 -->
|
<!-- iOS Safari PWA 优化 -->
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
@@ -171,6 +171,11 @@
|
|||||||
display: block;
|
display: block;
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-logo img[data-theme-ready='true'] {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-footer {
|
.loading-footer {
|
||||||
@@ -273,11 +278,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#timeout-btn {
|
#timeout-btn {
|
||||||
color: var(--initial-loader-color, #9155FD);
|
color: var(--initial-loader-color, #8D51F9);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-inline-start: 8px;
|
margin-inline-start: 8px;
|
||||||
border-bottom: 1px solid var(--initial-loader-color, #9155FD);
|
border-bottom: 1px solid var(--initial-loader-color, #8D51F9);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -303,7 +308,7 @@
|
|||||||
const launchThemePalettes = {
|
const launchThemePalettes = {
|
||||||
light: {
|
light: {
|
||||||
background: '#F4F5FA',
|
background: '#F4F5FA',
|
||||||
primary: '#9155FD',
|
primary: '#8D51F9',
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
background: '#0E1116',
|
background: '#0E1116',
|
||||||
@@ -311,7 +316,7 @@
|
|||||||
},
|
},
|
||||||
purple: {
|
purple: {
|
||||||
background: '#28243D',
|
background: '#28243D',
|
||||||
primary: '#9155FD',
|
primary: '#8D51F9',
|
||||||
},
|
},
|
||||||
transparent: {
|
transparent: {
|
||||||
background: '#1C1C1C',
|
background: '#1C1C1C',
|
||||||
@@ -362,6 +367,194 @@
|
|||||||
document.head.appendChild(meta)
|
document.head.appendChild(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let logoSvgSourcePromise
|
||||||
|
let pendingFaviconColor = '#8D51F9'
|
||||||
|
const themeLogoCacheKey = 'moviepilot-themed-logo-cache'
|
||||||
|
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cachedLogo = JSON.parse(localStorage.getItem(themeLogoCacheKey) || 'null')
|
||||||
|
if (cachedLogo?.color === primaryColor && typeof cachedLogo.url === 'string') applyThemedLogoUrl(cachedLogo.url)
|
||||||
|
} catch {
|
||||||
|
// 缓存异常不影响根据品牌源文件重新生成主题标识。
|
||||||
|
}
|
||||||
|
|
||||||
|
logoSvgSourcePromise ||= fetch('/logo.svg').then(response => {
|
||||||
|
if (!response.ok) throw new Error(`Logo SVG request failed: ${response.status}`)
|
||||||
|
return response.text()
|
||||||
|
})
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
syncThemeFavicon(event.detail?.color)
|
||||||
|
})
|
||||||
|
|
||||||
function applyLaunchThemeChrome() {
|
function applyLaunchThemeChrome() {
|
||||||
const themePreference = getSavedThemePreference()
|
const themePreference = getSavedThemePreference()
|
||||||
const resolvedLaunchTheme = resolveLaunchTheme(themePreference)
|
const resolvedLaunchTheme = resolveLaunchTheme(themePreference)
|
||||||
@@ -391,6 +584,7 @@
|
|||||||
|
|
||||||
setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark')
|
setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark')
|
||||||
syncThemeColorMeta(loaderColor)
|
syncThemeColorMeta(loaderColor)
|
||||||
|
syncThemeFavicon(primaryColor)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
background: loaderColor,
|
background: loaderColor,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moviepilot",
|
"name": "moviepilot",
|
||||||
"version": "2.14.4",
|
"version": "2.14.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #9155FD;
|
--primary-color: #8D51F9;
|
||||||
--surface-color: #FFFFFF;
|
--surface-color: #FFFFFF;
|
||||||
--text-color: #333333;
|
--text-color: #333333;
|
||||||
--border-color: rgba(0, 0, 0, 0.12);
|
--border-color: rgba(0, 0, 0, 0.12);
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
margin: 0 auto 32px;
|
margin: 0 auto 32px;
|
||||||
background: rgba(145, 85, 253, 0.1);
|
background: rgba(141, 81, 249, 0.1);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: rgba(145, 85, 253, 0.1);
|
background: rgba(141, 81, 249, 0.1);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import logo from '@images/logo.svg?raw'
|
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tag?: string | Component
|
tag?: string | Component
|
||||||
@@ -51,7 +51,7 @@ function handleNavScroll(evt: Event) {
|
|||||||
<div class="nav-header">
|
<div class="nav-header">
|
||||||
<slot name="nav-header">
|
<slot name="nav-header">
|
||||||
<RouterLink to="/" class="app-logo d-flex align-center app-title-wrapper">
|
<RouterLink to="/" class="app-logo d-flex align-center app-title-wrapper">
|
||||||
<div class="d-flex" v-html="logo" />
|
<ThemeLogoMark />
|
||||||
|
|
||||||
<h1 class="font-weight-bold leading-normal text-xl">
|
<h1 class="font-weight-bold leading-normal text-xl">
|
||||||
MOVIEPILOT <span class="text-sm text-gray-500">v2</span>
|
MOVIEPILOT <span class="text-sm text-gray-500">v2</span>
|
||||||
|
|||||||
@@ -273,16 +273,7 @@ export default defineComponent({
|
|||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-logo > div {
|
.app-logo .theme-logo-mark {
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
block-size: 2.75rem;
|
|
||||||
inline-size: 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-logo svg {
|
|
||||||
block-size: 2.5rem;
|
block-size: 2.5rem;
|
||||||
inline-size: 2.5rem;
|
inline-size: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1539,8 +1539,8 @@ defineExpose({
|
|||||||
.agent-assistant-fab {
|
.agent-assistant-fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
||||||
/* 保持高于菜单浮层,但低于 agent 会话面板(2101)。 */
|
/* 保持机器人和提示气泡高于 Vuetify 弹窗(2400)及全局 Toast(2500)。 */
|
||||||
z-index: 2100;
|
z-index: 2600;
|
||||||
|
|
||||||
--agent-assistant-robot-outline: #5b00c5;
|
--agent-assistant-robot-outline: #5b00c5;
|
||||||
--agent-assistant-robot-outline-soft: #7432df;
|
--agent-assistant-robot-outline-soft: #7432df;
|
||||||
|
|||||||
@@ -1959,6 +1959,7 @@ onScopeDispose(() => {
|
|||||||
:style="drawerStyle"
|
:style="drawerStyle"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
:aria-label="t('agentAssistant.title')"
|
:aria-label="t('agentAssistant.title')"
|
||||||
|
@focusin.stop
|
||||||
>
|
>
|
||||||
<div class="agent-assistant-shell">
|
<div class="agent-assistant-shell">
|
||||||
<header class="agent-assistant-header">
|
<header class="agent-assistant-header">
|
||||||
@@ -1990,7 +1991,7 @@ onScopeDispose(() => {
|
|||||||
location="bottom end"
|
location="bottom end"
|
||||||
offset="8"
|
offset="8"
|
||||||
max-width="360"
|
max-width="360"
|
||||||
:z-index="2103"
|
:z-index="2603"
|
||||||
>
|
>
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<IconBtn v-bind="props" :title="t('agentAssistant.history')" :aria-label="t('agentAssistant.history')">
|
<IconBtn v-bind="props" :title="t('agentAssistant.history')" :aria-label="t('agentAssistant.history')">
|
||||||
@@ -2346,7 +2347,7 @@ onScopeDispose(() => {
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.agent-assistant-history-overlay {
|
.agent-assistant-history-overlay {
|
||||||
z-index: 2103 !important;
|
z-index: 2603 !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -2356,7 +2357,9 @@ onScopeDispose(() => {
|
|||||||
|
|
||||||
.agent-assistant-panel {
|
.agent-assistant-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 2101;
|
|
||||||
|
/* Agent 会话层保持高于入口(2600)和业务弹窗,同时低于自身弹出菜单。 */
|
||||||
|
z-index: 2601;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgb(var(--v-theme-surface));
|
background: rgb(var(--v-theme-surface));
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { nextTick } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { storageRemoteDict } from '@/api/constants'
|
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 = {
|
const STORAGE_ACCENT_COLOR_MAP = {
|
||||||
local: '#FFB400',
|
local: '#FFB400',
|
||||||
alipan: '#00A7F2',
|
alipan: '#00A7F2',
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ async function removeSubscribe() {
|
|||||||
emit('remove')
|
emit('remove')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
$toast.error(t('subscribe.requestFailed'))
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +185,9 @@ async function searchSubscribe() {
|
|||||||
|
|
||||||
// 提示
|
// 提示
|
||||||
if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功!`)
|
if (result.success) $toast.success(`${props.media?.name} 提交搜索请求成功!`)
|
||||||
|
else $toast.error(t('subscribe.requestFailed'))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
$toast.error(t('subscribe.requestFailed'))
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +214,7 @@ async function toggleSubscribeStatus(state: 'R' | 'S') {
|
|||||||
$toast.error(t('subscribe.toggleFailed', { action, message: result.message }))
|
$toast.error(t('subscribe.toggleFailed', { action, message: result.message }))
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
$toast.error(t('subscribe.requestFailed'))
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,6 +237,7 @@ async function resetSubscribe() {
|
|||||||
emit('save')
|
emit('save')
|
||||||
} else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message }))
|
} else $toast.error(t('subscribe.resetFailed', { name: props.media?.name, message: result.message }))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
$toast.error(t('subscribe.requestFailed'))
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
438
src/components/cards/__tests__/SubscribeCard.spec.ts
Normal file
438
src/components/cards/__tests__/SubscribeCard.spec.ts
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||||
|
import type { Subscribe } from '@/api/types'
|
||||||
|
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { createSubscribe } from '@tests/support/factories/subscribe'
|
||||||
|
import {
|
||||||
|
deleteSubscribeByIdHandler,
|
||||||
|
resetSubscribeByIdHandler,
|
||||||
|
searchSubscribeByIdHandler,
|
||||||
|
updateSubscribeStatusHandler,
|
||||||
|
} from '@tests/support/msw/handlers/subscribe'
|
||||||
|
import { server } from '@tests/support/msw/server'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
confirm: vi.fn(),
|
||||||
|
openSharedDialog: vi.fn(),
|
||||||
|
routerPush: vi.fn(),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
toastSuccess: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-toastification', () => ({
|
||||||
|
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useConfirm', () => ({
|
||||||
|
useConfirm: () => mocks.confirm,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSharedDialog', () => ({
|
||||||
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/router', () => ({
|
||||||
|
default: { push: (...args: unknown[]) => mocks.routerPush(...args) },
|
||||||
|
}))
|
||||||
|
|
||||||
|
function setViewport(width: number) {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeElementsImmediately() {
|
||||||
|
class ImmediateIntersectionObserver {
|
||||||
|
readonly root = null
|
||||||
|
readonly rootMargin = '0px'
|
||||||
|
readonly thresholds = [0]
|
||||||
|
|
||||||
|
constructor(private readonly callback: IntersectionObserverCallback) {}
|
||||||
|
|
||||||
|
disconnect() {}
|
||||||
|
|
||||||
|
observe(target: Element) {
|
||||||
|
this.callback([{ intersectionRatio: 1, isIntersecting: true, target } as IntersectionObserverEntry], this)
|
||||||
|
}
|
||||||
|
|
||||||
|
takeRecords(): IntersectionObserverEntry[] {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
unobserve() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.stubGlobal('IntersectionObserver', ImmediateIntersectionObserver)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCard(
|
||||||
|
mediaOverrides: Partial<Subscribe> = {},
|
||||||
|
props: Partial<{ batchMode: boolean; selected: boolean; sortable: boolean }> = {},
|
||||||
|
globalImageCache = false,
|
||||||
|
) {
|
||||||
|
const media = createSubscribe({
|
||||||
|
backdrop: 'https://images.example.com/backdrop.jpg',
|
||||||
|
id: 2501,
|
||||||
|
last_update: '2026-07-16 12:00:00',
|
||||||
|
name: '卡片测试媒体',
|
||||||
|
poster: 'https://images.example.com/poster.jpg',
|
||||||
|
...mediaOverrides,
|
||||||
|
})
|
||||||
|
const result = await renderWithProviders(SubscribeCard, {
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: { GLOBAL_IMAGE_CACHE: globalImageCache },
|
||||||
|
initialized: true,
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: { media, ...props },
|
||||||
|
})
|
||||||
|
return { ...result, media }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMenuButton(container: Element) {
|
||||||
|
const selector = window.innerWidth < 600 ? '.subscribe-card-mobile-menu' : '.absolute.top-1.right-4 .v-btn'
|
||||||
|
const button = container.querySelector<HTMLButtonElement>(selector)
|
||||||
|
expect(button).not.toBeNull()
|
||||||
|
return button as HTMLButtonElement
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMenu(container: Element) {
|
||||||
|
await fireEvent.click(getMenuButton(container))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseMenuItem(container: Element, label: string) {
|
||||||
|
await openMenu(container)
|
||||||
|
await fireEvent.click(await screen.findByText(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDialogCall(index = 0) {
|
||||||
|
const [, props, events, options] = mocks.openSharedDialog.mock.calls[index] as [
|
||||||
|
unknown,
|
||||||
|
Record<string, unknown>,
|
||||||
|
Record<string, () => void>,
|
||||||
|
Record<string, unknown>,
|
||||||
|
]
|
||||||
|
return { events, options, props }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SubscribeCard display and progress', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setViewport(1024)
|
||||||
|
observeElementsImmediately()
|
||||||
|
mocks.confirm.mockResolvedValue(true)
|
||||||
|
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders stable movie metadata and omits episode progress without a total', async () => {
|
||||||
|
const { container, media } = await renderCard({ total_episode: undefined, type: '电影', year: '2025' }, {}, true)
|
||||||
|
|
||||||
|
expect(screen.getByText(media.name)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('2025')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(media.username)).toHaveAttribute('title', media.username)
|
||||||
|
const image = container.querySelector<HTMLImageElement>('img')
|
||||||
|
expect(image).not.toBeNull()
|
||||||
|
expect((image as HTMLImageElement).src).toContain('system/cache/image?url=')
|
||||||
|
expect((image as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop || ''))
|
||||||
|
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['regular progress', 10, 4, '6 / 10', '60'],
|
||||||
|
['negative missing episodes', 10, -2, '10 / 10', '100'],
|
||||||
|
['missing episodes above the total', 10, 12, '0 / 10', null],
|
||||||
|
['zero total', 0, 0, null, null],
|
||||||
|
])('normalizes %s', async (_case, totalEpisode, lackEpisode, expectedText, expectedProgress) => {
|
||||||
|
await renderCard({ lack_episode: lackEpisode, season: 2, total_episode: totalEpisode, type: '电视剧' })
|
||||||
|
|
||||||
|
if (expectedText) expect(screen.getByText(expectedText)).toBeInTheDocument()
|
||||||
|
else expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
if (expectedProgress) expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', expectedProgress)
|
||||||
|
else expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/卡片测试媒体 S02/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['boolean flag with tv type', true, false, 3, 'tv', 30, true, false],
|
||||||
|
['numeric flags with negative completed episodes', 1, 1, -2, '电视剧', 0, true, true],
|
||||||
|
['string flags with completed episodes above the total', '1', '1', 12, '电影', 100, true, true],
|
||||||
|
['disabled flag', false, true, 3, '电视剧', 80, false, false],
|
||||||
|
])(
|
||||||
|
'normalizes %s for wash progress and badges',
|
||||||
|
async (_case, bestVersion, bestVersionFull, completedEpisode, type, expectedProgress, expectedWash, expectedFull) => {
|
||||||
|
const { container } = await renderCard({
|
||||||
|
best_version: bestVersion,
|
||||||
|
best_version_full: bestVersionFull,
|
||||||
|
completed_episode: completedEpisode,
|
||||||
|
lack_episode: 2,
|
||||||
|
total_episode: 10,
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
const image = container.querySelector<HTMLImageElement>('img')
|
||||||
|
expect(image).not.toBeNull()
|
||||||
|
await fireEvent.load(image as HTMLImageElement)
|
||||||
|
|
||||||
|
const progress = screen.getByRole('progressbar')
|
||||||
|
expect(progress).toHaveAttribute('aria-valuenow', String(expectedProgress))
|
||||||
|
expect(progress.querySelector('.v-progress-linear__buffer')).toHaveStyle({ width: expectedWash ? '80%' : '0%' })
|
||||||
|
expect(Boolean(container.querySelector('.best-version-badge'))).toBe(expectedWash)
|
||||||
|
expect(Boolean(container.querySelector('.best-version-badge-full'))).toBe(expectedFull)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('keeps mobile wash progress compact while preserving P, S, and R metadata', async () => {
|
||||||
|
setViewport(480)
|
||||||
|
const { media, rerender } = await renderCard({
|
||||||
|
best_version: true,
|
||||||
|
completed_episode: 3,
|
||||||
|
lack_episode: 2,
|
||||||
|
state: 'P',
|
||||||
|
total_episode: 10,
|
||||||
|
type: '电视剧',
|
||||||
|
})
|
||||||
|
const lastUpdateText = formatDateDifference(media.last_update)
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('待定中')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('8 / 10')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '30')
|
||||||
|
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
|
||||||
|
expect(document.querySelector('.subscribe-card-mobile-menu')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await rerender({ media: { ...media, state: 'S' } })
|
||||||
|
expect(screen.getByLabelText('已暂停')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
|
||||||
|
|
||||||
|
await rerender({ media: { ...media, state: 'R' } })
|
||||||
|
expect(screen.getByLabelText('订阅中')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('synchronizes desktop P, S, and R state from updated media props', async () => {
|
||||||
|
const { container, media, rerender } = await renderCard({ state: 'P' })
|
||||||
|
const lastUpdateText = formatDateDifference(media.last_update)
|
||||||
|
|
||||||
|
expect(screen.getByText('待定中')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText(lastUpdateText)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await rerender({ media: { ...media, state: 'S' } })
|
||||||
|
expect(screen.getByText('已暂停')).toBeInTheDocument()
|
||||||
|
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
|
||||||
|
|
||||||
|
await rerender({ media: { ...media, state: 'R' } })
|
||||||
|
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('已暂停')).not.toBeInTheDocument()
|
||||||
|
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SubscribeCard interaction boundaries', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setViewport(1024)
|
||||||
|
observeElementsImmediately()
|
||||||
|
mocks.confirm.mockResolvedValue(true)
|
||||||
|
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes normal, batch, selected, and sortable card clicks without overlap', async () => {
|
||||||
|
const { container, emitted, media, rerender } = await renderCard()
|
||||||
|
const card = container.querySelector('.subscribe-card') as HTMLElement
|
||||||
|
|
||||||
|
await fireEvent.click(card)
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
|
await rerender({ batchMode: true, media, selected: true })
|
||||||
|
await fireEvent.click(card)
|
||||||
|
expect(emitted('select')).toHaveLength(1)
|
||||||
|
expect(container.querySelector('.subscribe-card-shell')).toHaveClass('subscribe-card-shell--selected')
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||||
|
expect(container.querySelector('.absolute.top-1.right-4 .v-btn')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await rerender({ batchMode: true, media, selected: true, sortable: true })
|
||||||
|
await fireEvent.click(card)
|
||||||
|
expect(emitted('select')).toHaveLength(1)
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||||
|
expect(container.querySelector('.absolute.top-1.right-4 .v-btn')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens page-selected editing and forwards only save and remove events', async () => {
|
||||||
|
const { emitted, media } = await renderCard({ page_open: true })
|
||||||
|
|
||||||
|
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||||
|
const dialog = getDialogCall()
|
||||||
|
expect(dialog.props).toEqual({ subid: media.id })
|
||||||
|
expect(dialog.options).toEqual({ closeOn: ['close', 'save', 'remove'] })
|
||||||
|
|
||||||
|
dialog.events.save()
|
||||||
|
dialog.events.remove()
|
||||||
|
expect(emitted('save')).toHaveLength(1)
|
||||||
|
expect(emitted('remove')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes exact file and TV share data while keeping compatibility TV values unshared', async () => {
|
||||||
|
const { container, media, rerender } = await renderCard({ season: 1, total_episode: 12, type: '电视剧' })
|
||||||
|
|
||||||
|
await chooseMenuItem(container, '分享')
|
||||||
|
expect(getDialogCall().props).toEqual({ sub: media })
|
||||||
|
expect(getDialogCall().options).toEqual({ closeOn: ['close'] })
|
||||||
|
|
||||||
|
await chooseMenuItem(container, '文件统计')
|
||||||
|
expect(getDialogCall(1).props).toEqual({ subid: media.id })
|
||||||
|
expect(getDialogCall(1).options).toEqual({ closeOn: ['close'] })
|
||||||
|
|
||||||
|
await rerender({ media: { ...media, type: 'tv' } })
|
||||||
|
await openMenu(container)
|
||||||
|
expect(screen.queryByText('分享')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['TMDB before all fallbacks', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 11 }, 'tmdb:11'],
|
||||||
|
['Douban before Bangumi', { bangumiid: '33', doubanid: '22', mediaid: 'custom:44', tmdbid: 0 }, 'douban:22'],
|
||||||
|
['Bangumi before custom', { bangumiid: '33', doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'bangumi:33'],
|
||||||
|
['custom media ID last', { bangumiid: undefined, doubanid: undefined, mediaid: 'custom:44', tmdbid: 0 }, 'custom:44'],
|
||||||
|
])('routes media details with %s', async (_case, identifiers, expectedMediaId) => {
|
||||||
|
const { container, media } = await renderCard(identifiers)
|
||||||
|
|
||||||
|
await chooseMenuItem(container, '媒体详情')
|
||||||
|
|
||||||
|
expect(mocks.routerPush).toHaveBeenCalledWith({
|
||||||
|
path: '/media',
|
||||||
|
query: {
|
||||||
|
mediaid: expectedMediaId,
|
||||||
|
title: media.name,
|
||||||
|
type: media.type,
|
||||||
|
year: media.year,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SubscribeCard item operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setViewport(1024)
|
||||||
|
observeElementsImmediately()
|
||||||
|
mocks.confirm.mockResolvedValue(true)
|
||||||
|
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['success', 200, { success: true }, 'success', '卡片测试媒体 提交搜索请求成功!'],
|
||||||
|
['business failure', 200, { message: 'rejected', success: false }, 'error', '请求失败,请稍后重试'],
|
||||||
|
['HTTP failure', 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
|
||||||
|
] as const)('reports search %s through the exact endpoint', async (_case, status, response, toastType, message) => {
|
||||||
|
const requested = vi.fn()
|
||||||
|
const { container, media } = await renderCard()
|
||||||
|
server.use(searchSubscribeByIdHandler(media.id, response, status, requested))
|
||||||
|
|
||||||
|
await chooseMenuItem(container, '搜索')
|
||||||
|
|
||||||
|
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
|
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
|
||||||
|
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pauses and enables only after confirmed successful status responses', async () => {
|
||||||
|
const requested: URL[] = []
|
||||||
|
const { container, emitted, media } = await renderCard({ state: 'R' })
|
||||||
|
server.use(
|
||||||
|
updateSubscribeStatusHandler(media.id, { success: true }, 200, url => {
|
||||||
|
requested.push(url)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await chooseMenuItem(container, '暂停')
|
||||||
|
await waitFor(() => expect(requested).toHaveLength(1))
|
||||||
|
expect(requested[0].searchParams.get('state')).toBe('S')
|
||||||
|
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${media.name} 已暂停!`)
|
||||||
|
|
||||||
|
await chooseMenuItem(container, '启用')
|
||||||
|
await waitFor(() => expect(requested).toHaveLength(2))
|
||||||
|
expect(requested[1].searchParams.get('state')).toBe('R')
|
||||||
|
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||||
|
expect(mocks.toastSuccess).toHaveBeenCalledWith(`${media.name} 已启用!`)
|
||||||
|
expect(emitted('save')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['confirmation cancellation', false, 200, { success: true }, null],
|
||||||
|
['business failure', true, 200, { message: 'rejected', success: false }, '暂停失败:rejected'],
|
||||||
|
['HTTP failure', true, 500, { message: 'server down', success: false }, '请求失败,请稍后重试'],
|
||||||
|
] as const)(
|
||||||
|
'keeps status unchanged after %s',
|
||||||
|
async (_case, confirmed, status, response, expectedError) => {
|
||||||
|
const requested = vi.fn()
|
||||||
|
mocks.confirm.mockResolvedValue(confirmed)
|
||||||
|
const { container, emitted, media } = await renderCard({ state: 'R' })
|
||||||
|
server.use(updateSubscribeStatusHandler(media.id, response, status, requested))
|
||||||
|
|
||||||
|
await chooseMenuItem(container, '暂停')
|
||||||
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
|
else expect(requested).not.toHaveBeenCalled()
|
||||||
|
if (expectedError) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expectedError))
|
||||||
|
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||||
|
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||||
|
expect(emitted('save') ?? []).toHaveLength(0)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['success', true, 200, { success: true }, 'success', '卡片测试媒体 重置成功!'],
|
||||||
|
['confirmation cancellation', false, 200, { success: true }, null, null],
|
||||||
|
['business failure', true, 200, { message: 'rejected', success: false }, 'error', '卡片测试媒体 重置失败:rejected'],
|
||||||
|
['HTTP failure', true, 500, { message: 'server down', success: false }, 'error', '请求失败,请稍后重试'],
|
||||||
|
] as const)(
|
||||||
|
'handles reset %s without speculative state',
|
||||||
|
async (_case, confirmed, status, response, toastType, message) => {
|
||||||
|
const requested = vi.fn()
|
||||||
|
mocks.confirm.mockResolvedValue(confirmed)
|
||||||
|
const { container, emitted, media } = await renderCard({ state: 'S' })
|
||||||
|
server.use(resetSubscribeByIdHandler(media.id, response, status, requested))
|
||||||
|
|
||||||
|
await chooseMenuItem(container, '重置')
|
||||||
|
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce())
|
||||||
|
|
||||||
|
if (confirmed) await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
|
else expect(requested).not.toHaveBeenCalled()
|
||||||
|
if (toastType && message) {
|
||||||
|
const toast = toastType === 'success' ? mocks.toastSuccess : mocks.toastError
|
||||||
|
await waitFor(() => expect(toast).toHaveBeenCalledWith(message))
|
||||||
|
} else {
|
||||||
|
expect(mocks.toastSuccess).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_case === 'success') {
|
||||||
|
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-paused')
|
||||||
|
expect(emitted('save')).toHaveLength(1)
|
||||||
|
} else {
|
||||||
|
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-paused')
|
||||||
|
expect(emitted('save') ?? []).toHaveLength(0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['success', 200, { success: true }, true, null],
|
||||||
|
['HTTP failure', 500, { message: 'server down', success: false }, false, '请求失败,请稍后重试'],
|
||||||
|
] as const)('handles delete %s without a synthetic business-failure branch', async (_case, status, response, removed, error) => {
|
||||||
|
const requested = vi.fn()
|
||||||
|
const { container, emitted, media } = await renderCard()
|
||||||
|
server.use(deleteSubscribeByIdHandler(media.id, response, status, requested))
|
||||||
|
|
||||||
|
await chooseMenuItem(container, '取消订阅')
|
||||||
|
|
||||||
|
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||||
|
expect(emitted('remove') ?? []).toHaveLength(removed ? 1 : 0)
|
||||||
|
if (error) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(error))
|
||||||
|
else expect(mocks.toastError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,647 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|
||||||
import * as THREE from 'three'
|
|
||||||
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js'
|
|
||||||
import logoUrl from '@images/logo.png'
|
|
||||||
|
|
||||||
type LogoPoint = readonly [number, number]
|
|
||||||
|
|
||||||
interface LogoFacetDefinition {
|
|
||||||
points: readonly LogoPoint[]
|
|
||||||
color: number
|
|
||||||
cornerRadius?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LogoPieceDefinition {
|
|
||||||
points: readonly LogoPoint[]
|
|
||||||
faceColor: number
|
|
||||||
sideColor: number
|
|
||||||
depth: number
|
|
||||||
offsetZ: number
|
|
||||||
cornerRadius: number
|
|
||||||
facets: readonly LogoFacetDefinition[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOGO_VIEWBOX_CENTER = 96
|
|
||||||
const LOGO_COORDINATE_SCALE = 1 / 80
|
|
||||||
const LOGO_BEVEL_SIZE = 0.08 // 倒角水平扩张尺寸,增大以捕获更宽的高光带
|
|
||||||
const LOGO_BEVEL_THICKNESS = 0.06 // 倒角绝对厚度
|
|
||||||
const AUTO_ROTATION_SPEED = 0.3
|
|
||||||
const MAX_TILT = 0.4
|
|
||||||
const INITIAL_ROTATION_X = -0.09
|
|
||||||
const INITIAL_ROTATION_Y = -0.16
|
|
||||||
const LOGO_BASE_Y = 0.1
|
|
||||||
|
|
||||||
const LOGO_PIECES: readonly LogoPieceDefinition[] = [
|
|
||||||
{
|
|
||||||
points: [
|
|
||||||
[96, 15],
|
|
||||||
[24, 57],
|
|
||||||
[24, 133],
|
|
||||||
[48, 147],
|
|
||||||
[48, 76],
|
|
||||||
[96, 48],
|
|
||||||
[120, 62],
|
|
||||||
[120, 35],
|
|
||||||
],
|
|
||||||
faceColor: 0x9652e6,
|
|
||||||
sideColor: 0x5c27ae,
|
|
||||||
depth: 0.2,
|
|
||||||
offsetZ: 0,
|
|
||||||
cornerRadius: 4.8,
|
|
||||||
facets: [
|
|
||||||
{
|
|
||||||
points: [
|
|
||||||
[96, 19],
|
|
||||||
[29, 58],
|
|
||||||
[48, 72],
|
|
||||||
[96, 44],
|
|
||||||
[116, 56],
|
|
||||||
[116, 38],
|
|
||||||
],
|
|
||||||
color: 0xb978ff,
|
|
||||||
cornerRadius: 2.4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
points: [
|
|
||||||
[29, 61],
|
|
||||||
[29, 130],
|
|
||||||
[44, 139],
|
|
||||||
[44, 78],
|
|
||||||
],
|
|
||||||
color: 0x7030ca,
|
|
||||||
cornerRadius: 2.2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
points: [
|
|
||||||
[144, 43],
|
|
||||||
[168, 57],
|
|
||||||
[168, 134],
|
|
||||||
[96, 176],
|
|
||||||
[72, 162],
|
|
||||||
[72, 135],
|
|
||||||
[96, 149],
|
|
||||||
[144, 121],
|
|
||||||
],
|
|
||||||
faceColor: 0x8140d5,
|
|
||||||
sideColor: 0x54229f,
|
|
||||||
depth: 0.21,
|
|
||||||
offsetZ: 0.006,
|
|
||||||
cornerRadius: 4.8,
|
|
||||||
facets: [
|
|
||||||
{
|
|
||||||
points: [
|
|
||||||
[148, 49],
|
|
||||||
[163, 59],
|
|
||||||
[163, 130],
|
|
||||||
[148, 121],
|
|
||||||
],
|
|
||||||
color: 0xa15cef,
|
|
||||||
cornerRadius: 2.2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
points: [
|
|
||||||
[162, 134],
|
|
||||||
[96, 171],
|
|
||||||
[77, 159],
|
|
||||||
[77, 141],
|
|
||||||
[96, 153],
|
|
||||||
[144, 125],
|
|
||||||
],
|
|
||||||
color: 0x722bd0,
|
|
||||||
cornerRadius: 2.4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
points: [
|
|
||||||
[76, 64],
|
|
||||||
[136, 96],
|
|
||||||
[76, 128],
|
|
||||||
],
|
|
||||||
faceColor: 0x9a50eb,
|
|
||||||
sideColor: 0x622cb4,
|
|
||||||
depth: 0.23,
|
|
||||||
offsetZ: 0.026,
|
|
||||||
cornerRadius: 3.6,
|
|
||||||
facets: [
|
|
||||||
{
|
|
||||||
points: [
|
|
||||||
[80, 70],
|
|
||||||
[130, 96],
|
|
||||||
[80, 94],
|
|
||||||
],
|
|
||||||
color: 0xb978ff,
|
|
||||||
cornerRadius: 1.8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
points: [
|
|
||||||
[80, 98],
|
|
||||||
[130, 96],
|
|
||||||
[80, 122],
|
|
||||||
],
|
|
||||||
color: 0x6f29d1,
|
|
||||||
cornerRadius: 1.8,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const rootRef = ref<HTMLDivElement | null>(null)
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|
||||||
const isReady = ref(false)
|
|
||||||
const hasWebGLError = ref(false)
|
|
||||||
const isDragging = ref(false)
|
|
||||||
|
|
||||||
let renderer: THREE.WebGLRenderer | null = null
|
|
||||||
let scene: THREE.Scene | null = null
|
|
||||||
let camera: THREE.PerspectiveCamera | null = null
|
|
||||||
let logoGroup: THREE.Group | null = null
|
|
||||||
let environmentTexture: THREE.Texture | null = null
|
|
||||||
let glowTexture: THREE.CanvasTexture | null = null
|
|
||||||
let glowMaterial: THREE.SpriteMaterial | null = null
|
|
||||||
let resizeObserver: ResizeObserver | null = null
|
|
||||||
let intersectionObserver: IntersectionObserver | null = null
|
|
||||||
let reducedMotionQuery: MediaQueryList | null = null
|
|
||||||
let animationFrameId = 0
|
|
||||||
let previousFrameTime = 0
|
|
||||||
let targetRotationX = INITIAL_ROTATION_X
|
|
||||||
let targetRotationY = INITIAL_ROTATION_Y
|
|
||||||
let dragVelocityY = 0
|
|
||||||
let lastPointerX = 0
|
|
||||||
let lastPointerY = 0
|
|
||||||
let isIntersecting = true
|
|
||||||
let prefersReducedMotion = false
|
|
||||||
|
|
||||||
/** 将原 Logo 的二维坐标转换到以画布中心为原点的 Three.js 坐标系。 */
|
|
||||||
function convertLogoPoint([sourceX, sourceY]: LogoPoint) {
|
|
||||||
return new THREE.Vector2(
|
|
||||||
(sourceX - LOGO_VIEWBOX_CENTER) * LOGO_COORDINATE_SCALE,
|
|
||||||
(LOGO_VIEWBOX_CENTER - sourceY) * LOGO_COORDINATE_SCALE,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据多边形轮廓生成带圆角的二维 Logo 形状。 */
|
|
||||||
function createRoundedLogoShape(points: readonly LogoPoint[], sourceCornerRadius: number) {
|
|
||||||
const vertices = points.map(convertLogoPoint)
|
|
||||||
const cornerRadius = sourceCornerRadius * LOGO_COORDINATE_SCALE
|
|
||||||
const corners = vertices.map((current, index) => {
|
|
||||||
const previous = vertices[(index - 1 + vertices.length) % vertices.length]
|
|
||||||
const next = vertices[(index + 1) % vertices.length]
|
|
||||||
const incoming = previous.clone().sub(current)
|
|
||||||
const outgoing = next.clone().sub(current)
|
|
||||||
const entryDistance = Math.min(cornerRadius, incoming.length() * 0.32)
|
|
||||||
const exitDistance = Math.min(cornerRadius, outgoing.length() * 0.32)
|
|
||||||
|
|
||||||
return {
|
|
||||||
current,
|
|
||||||
entry: current.clone().add(incoming.normalize().multiplyScalar(entryDistance)),
|
|
||||||
exit: current.clone().add(outgoing.normalize().multiplyScalar(exitDistance)),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const shape = new THREE.Shape()
|
|
||||||
shape.moveTo(corners[0].exit.x, corners[0].exit.y)
|
|
||||||
|
|
||||||
for (let step = 1; step <= corners.length; step += 1) {
|
|
||||||
const corner = corners[step % corners.length]
|
|
||||||
shape.lineTo(corner.entry.x, corner.entry.y)
|
|
||||||
shape.quadraticCurveTo(corner.current.x, corner.current.y, corner.exit.x, corner.exit.y)
|
|
||||||
}
|
|
||||||
|
|
||||||
shape.closePath()
|
|
||||||
return shape
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建紫色哑光金属的正面材质:去除清漆与虹彩等“油润感”因素,仅依靠适中的粗糙度与环境反射呈现哑光滞面金属质感。 */
|
|
||||||
function createFaceMaterial(color: number) {
|
|
||||||
return new THREE.MeshPhysicalMaterial({
|
|
||||||
color,
|
|
||||||
metalness: 1.0, // 物理纯金属
|
|
||||||
roughness: 0.32, // 哑光滞面,避免镜面般的过亮高光
|
|
||||||
envMapIntensity: 1.6, // 适度的环境反射强度,避免出现过曝的大面积亮班
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建偏深紫的挤出侧面材质,比正面稍粗糙,与光洁正面形成自然层次对比。 */
|
|
||||||
function createSideMaterial(color: number) {
|
|
||||||
return new THREE.MeshPhysicalMaterial({
|
|
||||||
color,
|
|
||||||
metalness: 0.95, // 纯粹侧边金属
|
|
||||||
roughness: 0.42, // 侧面比正面更哑光,提升层次感
|
|
||||||
envMapIntensity: 1.8, // 增强侧面在旋转时对环境光的敏感度
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 在挤出主体正面叠加略微内收的金属折面,复现设计图中的明暗分区。 */
|
|
||||||
function createFacetMesh(definition: LogoFacetDefinition, frontZ: number) {
|
|
||||||
const geometry = new THREE.ShapeGeometry(createRoundedLogoShape(definition.points, definition.cornerRadius ?? 1.8), 8)
|
|
||||||
geometry.translate(0, 0, frontZ)
|
|
||||||
const material = createFaceMaterial(definition.color)
|
|
||||||
material.polygonOffset = true
|
|
||||||
material.polygonOffsetFactor = -1
|
|
||||||
material.polygonOffsetUnits = -1
|
|
||||||
const mesh = new THREE.Mesh(geometry, material)
|
|
||||||
mesh.renderOrder = 2
|
|
||||||
return mesh
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建一段带厚挤出、宽倒角和分区高光的紫色金属 Logo。 */
|
|
||||||
function createLogoPiece(definition: LogoPieceDefinition) {
|
|
||||||
const pieceGroup = new THREE.Group()
|
|
||||||
const geometry = new THREE.ExtrudeGeometry(createRoundedLogoShape(definition.points, definition.cornerRadius), {
|
|
||||||
depth: definition.depth,
|
|
||||||
steps: 1,
|
|
||||||
curveSegments: 12, // 提升折点平滑度
|
|
||||||
bevelEnabled: true,
|
|
||||||
bevelSegments: 12, // 大幅度提升倒角分段,打造极其圆润圆滑的边缘过渡
|
|
||||||
bevelSize: LOGO_BEVEL_SIZE,
|
|
||||||
bevelThickness: LOGO_BEVEL_THICKNESS,
|
|
||||||
bevelOffset: -0.016, // 微调倒角向内偏移,控制体积膨胀感
|
|
||||||
})
|
|
||||||
geometry.translate(0, 0, definition.offsetZ - definition.depth / 2)
|
|
||||||
geometry.computeVertexNormals()
|
|
||||||
|
|
||||||
const body = new THREE.Mesh(geometry, [
|
|
||||||
createFaceMaterial(definition.faceColor),
|
|
||||||
createSideMaterial(definition.sideColor),
|
|
||||||
])
|
|
||||||
body.renderOrder = 1
|
|
||||||
pieceGroup.add(body)
|
|
||||||
|
|
||||||
const frontZ = definition.offsetZ + definition.depth / 2 + LOGO_BEVEL_THICKNESS + 0.004
|
|
||||||
definition.facets.forEach(facet => pieceGroup.add(createFacetMesh(facet, frontZ)))
|
|
||||||
return pieceGroup
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 组合断开六边形折带与双折面播放符号,形成完整 MoviePilot Logo。 */
|
|
||||||
function createLogoModel() {
|
|
||||||
const group = new THREE.Group()
|
|
||||||
LOGO_PIECES.forEach(definition => group.add(createLogoPiece(definition)))
|
|
||||||
group.position.y = LOGO_BASE_Y
|
|
||||||
group.rotation.set(targetRotationX, targetRotationY, 0)
|
|
||||||
return group
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 生成透明椭圆光斑纹理,作为 Logo 下方的紫色悬浮投影。 */
|
|
||||||
function createGroundGlowTexture() {
|
|
||||||
const glowCanvas = document.createElement('canvas')
|
|
||||||
glowCanvas.width = 256
|
|
||||||
glowCanvas.height = 256
|
|
||||||
const context = glowCanvas.getContext('2d')
|
|
||||||
if (!context) return null
|
|
||||||
|
|
||||||
const gradient = context.createRadialGradient(128, 128, 0, 128, 128, 128)
|
|
||||||
gradient.addColorStop(0, 'rgba(177, 116, 255, 0.55)')
|
|
||||||
gradient.addColorStop(0.34, 'rgba(119, 48, 255, 0.28)')
|
|
||||||
gradient.addColorStop(1, 'rgba(65, 15, 132, 0)')
|
|
||||||
context.fillStyle = gradient
|
|
||||||
context.fillRect(0, 0, 256, 256)
|
|
||||||
|
|
||||||
const texture = new THREE.CanvasTexture(glowCanvas)
|
|
||||||
texture.colorSpace = THREE.SRGBColorSpace
|
|
||||||
return texture
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 在场景中添加始终位于模型下方的柔和紫色光斑。 */
|
|
||||||
function addGroundGlow(activeScene: THREE.Scene) {
|
|
||||||
glowTexture = createGroundGlowTexture()
|
|
||||||
if (!glowTexture) return
|
|
||||||
|
|
||||||
glowMaterial = new THREE.SpriteMaterial({
|
|
||||||
map: glowTexture,
|
|
||||||
color: 0xb06dff,
|
|
||||||
opacity: 0.58,
|
|
||||||
transparent: true,
|
|
||||||
blending: THREE.AdditiveBlending,
|
|
||||||
depthWrite: false,
|
|
||||||
})
|
|
||||||
const glow = new THREE.Sprite(glowMaterial)
|
|
||||||
glow.position.set(0, -1.08, -0.7)
|
|
||||||
glow.scale.set(2.25, 0.34, 1)
|
|
||||||
glow.renderOrder = 0
|
|
||||||
activeScene.add(glow)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 配置突出紫色镜面、银紫倒角与背部轮廓的摄影棚布光。 */
|
|
||||||
function configureLighting(activeRenderer: THREE.WebGLRenderer, activeScene: THREE.Scene) {
|
|
||||||
const pmremGenerator = new THREE.PMREMGenerator(activeRenderer)
|
|
||||||
const roomEnvironment = new RoomEnvironment()
|
|
||||||
environmentTexture = pmremGenerator.fromScene(roomEnvironment, 0.035).texture
|
|
||||||
activeScene.environment = environmentTexture
|
|
||||||
activeScene.environmentIntensity = 1.15
|
|
||||||
roomEnvironment.dispose()
|
|
||||||
pmremGenerator.dispose()
|
|
||||||
|
|
||||||
const keyLight = new THREE.DirectionalLight(0xfff8ef, 4.6)
|
|
||||||
keyLight.position.set(-3.6, 4.7, 5.4)
|
|
||||||
activeScene.add(keyLight)
|
|
||||||
|
|
||||||
const coolFillLight = new THREE.DirectionalLight(0x9bc7ff, 1.35)
|
|
||||||
coolFillLight.position.set(-4.4, -1.2, 3.2)
|
|
||||||
activeScene.add(coolFillLight)
|
|
||||||
|
|
||||||
const rimLight = new THREE.DirectionalLight(0xe6b7ff, 4.1)
|
|
||||||
rimLight.position.set(3.8, 2.8, -4.8)
|
|
||||||
activeScene.add(rimLight)
|
|
||||||
|
|
||||||
const violetBounceLight = new THREE.PointLight(0x6422c9, 3.2, 7, 2)
|
|
||||||
violetBounceLight.position.set(2.5, -2.4, 2.4)
|
|
||||||
activeScene.add(violetBounceLight)
|
|
||||||
|
|
||||||
activeScene.add(new THREE.HemisphereLight(0xe9f2ff, 0x260441, 0.62))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按容器实际尺寸与设备像素比同步渲染器。 */
|
|
||||||
function resizeRenderer() {
|
|
||||||
if (!renderer || !camera || !rootRef.value) return
|
|
||||||
|
|
||||||
const { width, height } = rootRef.value.getBoundingClientRect()
|
|
||||||
if (!width || !height) return
|
|
||||||
|
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
|
||||||
renderer.setSize(width, height, false)
|
|
||||||
camera.aspect = width / height
|
|
||||||
camera.updateProjectionMatrix()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 逐帧更新完整转台旋转、拖拽惯性与轻微悬浮位移。 */
|
|
||||||
function renderFrame(frameTime: number) {
|
|
||||||
animationFrameId = window.requestAnimationFrame(renderFrame)
|
|
||||||
if (!renderer || !scene || !camera || !logoGroup || !isIntersecting) return
|
|
||||||
|
|
||||||
const delta = previousFrameTime ? Math.min((frameTime - previousFrameTime) / 1000, 0.05) : 0
|
|
||||||
previousFrameTime = frameTime
|
|
||||||
|
|
||||||
if (!isDragging.value && !prefersReducedMotion) {
|
|
||||||
targetRotationY += (AUTO_ROTATION_SPEED + dragVelocityY) * delta
|
|
||||||
dragVelocityY *= Math.exp(-4.2 * delta)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prefersReducedMotion) {
|
|
||||||
logoGroup.rotation.set(targetRotationX, targetRotationY, 0)
|
|
||||||
logoGroup.position.y = LOGO_BASE_Y
|
|
||||||
} else {
|
|
||||||
const easing = 1 - Math.exp(-12 * delta)
|
|
||||||
logoGroup.rotation.x += (targetRotationX - logoGroup.rotation.x) * easing
|
|
||||||
logoGroup.rotation.y += (targetRotationY - logoGroup.rotation.y) * easing
|
|
||||||
logoGroup.position.y = LOGO_BASE_Y + Math.sin(frameTime * 0.0011) * 0.018
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.render(scene, camera)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 初始化 Three.js 场景;不支持 WebGL 时切换到静态 Logo。 */
|
|
||||||
function initializeScene() {
|
|
||||||
const canvas = canvasRef.value
|
|
||||||
if (!canvas) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
renderer = new THREE.WebGLRenderer({
|
|
||||||
canvas,
|
|
||||||
alpha: true,
|
|
||||||
antialias: true,
|
|
||||||
powerPreference: 'high-performance',
|
|
||||||
})
|
|
||||||
renderer.setClearColor(0x000000, 0)
|
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
|
||||||
renderer.toneMappingExposure = 1.12
|
|
||||||
|
|
||||||
scene = new THREE.Scene()
|
|
||||||
camera = new THREE.PerspectiveCamera(28, 1, 0.1, 100)
|
|
||||||
camera.position.set(0, 0.02, 5.05)
|
|
||||||
camera.lookAt(0, 0, 0)
|
|
||||||
configureLighting(renderer, scene)
|
|
||||||
addGroundGlow(scene)
|
|
||||||
|
|
||||||
logoGroup = createLogoModel()
|
|
||||||
scene.add(logoGroup)
|
|
||||||
resizeRenderer()
|
|
||||||
renderer.render(scene, camera)
|
|
||||||
isReady.value = true
|
|
||||||
animationFrameId = window.requestAnimationFrame(renderFrame)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('无法初始化登录页 3D Logo,已回退到静态图标。', error)
|
|
||||||
hasWebGLError.value = true
|
|
||||||
isReady.value = false
|
|
||||||
disposeScene()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 处理拖拽开始并捕获指针,保证触屏滑动连续。 */
|
|
||||||
function handlePointerDown(event: PointerEvent) {
|
|
||||||
if (!isReady.value) return
|
|
||||||
isDragging.value = true
|
|
||||||
dragVelocityY = 0
|
|
||||||
lastPointerX = event.clientX
|
|
||||||
lastPointerY = event.clientY
|
|
||||||
rootRef.value?.setPointerCapture(event.pointerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 根据指针位移更新 Logo 水平旋转与受限俯仰角。 */
|
|
||||||
function handlePointerMove(event: PointerEvent) {
|
|
||||||
if (!isDragging.value) return
|
|
||||||
|
|
||||||
const deltaX = event.clientX - lastPointerX
|
|
||||||
const deltaY = event.clientY - lastPointerY
|
|
||||||
targetRotationY += deltaX * 0.012
|
|
||||||
targetRotationX = THREE.MathUtils.clamp(targetRotationX + deltaY * 0.008, -MAX_TILT, MAX_TILT)
|
|
||||||
dragVelocityY = THREE.MathUtils.clamp(deltaX * 0.08, -2.4, 2.4)
|
|
||||||
lastPointerX = event.clientX
|
|
||||||
lastPointerY = event.clientY
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 结束指针拖拽并释放捕获。 */
|
|
||||||
function handlePointerUp(event: PointerEvent) {
|
|
||||||
if (!isDragging.value) return
|
|
||||||
isDragging.value = false
|
|
||||||
if (rootRef.value?.hasPointerCapture(event.pointerId)) rootRef.value.releasePointerCapture(event.pointerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 支持方向键旋转 Logo,提供无鼠标交互能力。 */
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
|
||||||
const rotationStep = Math.PI / 10
|
|
||||||
if (event.key === 'ArrowLeft') targetRotationY -= rotationStep
|
|
||||||
else if (event.key === 'ArrowRight') targetRotationY += rotationStep
|
|
||||||
else if (event.key === 'ArrowUp') targetRotationX = Math.max(targetRotationX - rotationStep / 2, -MAX_TILT)
|
|
||||||
else if (event.key === 'ArrowDown') targetRotationX = Math.min(targetRotationX + rotationStep / 2, MAX_TILT)
|
|
||||||
else return
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 同步系统减少动态偏好,关闭自动旋转但保留手动交互。 */
|
|
||||||
function handleReducedMotionChange(event?: MediaQueryListEvent) {
|
|
||||||
prefersReducedMotion = event?.matches ?? reducedMotionQuery?.matches ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 仅在组件进入视口时持续渲染,降低后台 GPU 占用。 */
|
|
||||||
function handleIntersection(entries: IntersectionObserverEntry[]) {
|
|
||||||
isIntersecting = entries[0]?.isIntersecting ?? true
|
|
||||||
if (isIntersecting) previousFrameTime = performance.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** WebGL 上下文丢失时停止渲染并显示静态回退 Logo。 */
|
|
||||||
function handleContextLost(event: Event) {
|
|
||||||
event.preventDefault()
|
|
||||||
if (animationFrameId) window.cancelAnimationFrame(animationFrameId)
|
|
||||||
animationFrameId = 0
|
|
||||||
hasWebGLError.value = true
|
|
||||||
isReady.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 释放指定 3D 对象树中的几何体、材质与贴图资源。 */
|
|
||||||
function disposeObjectResources(root: THREE.Object3D) {
|
|
||||||
const disposedGeometries = new Set<THREE.BufferGeometry>()
|
|
||||||
const disposedMaterials = new Set<THREE.Material>()
|
|
||||||
const disposedTextures = new Set<THREE.Texture>()
|
|
||||||
|
|
||||||
root.traverse(object => {
|
|
||||||
if (!(object instanceof THREE.Mesh)) return
|
|
||||||
|
|
||||||
if (!disposedGeometries.has(object.geometry)) {
|
|
||||||
object.geometry.dispose()
|
|
||||||
disposedGeometries.add(object.geometry)
|
|
||||||
}
|
|
||||||
|
|
||||||
const materials = Array.isArray(object.material) ? object.material : [object.material]
|
|
||||||
materials.forEach(material => {
|
|
||||||
if (disposedMaterials.has(material)) return
|
|
||||||
const mappedMaterial = material as THREE.Material & { map?: THREE.Texture | null }
|
|
||||||
if (mappedMaterial.map && !disposedTextures.has(mappedMaterial.map)) {
|
|
||||||
mappedMaterial.map.dispose()
|
|
||||||
disposedTextures.add(mappedMaterial.map)
|
|
||||||
}
|
|
||||||
material.dispose()
|
|
||||||
disposedMaterials.add(material)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 释放场景中的几何体、材质、环境贴图和 WebGL 上下文。 */
|
|
||||||
function disposeScene() {
|
|
||||||
if (animationFrameId) window.cancelAnimationFrame(animationFrameId)
|
|
||||||
animationFrameId = 0
|
|
||||||
|
|
||||||
if (scene) disposeObjectResources(scene)
|
|
||||||
environmentTexture?.dispose()
|
|
||||||
glowMaterial?.dispose()
|
|
||||||
glowTexture?.dispose()
|
|
||||||
renderer?.dispose()
|
|
||||||
scene = null
|
|
||||||
camera = null
|
|
||||||
logoGroup = null
|
|
||||||
renderer = null
|
|
||||||
environmentTexture = null
|
|
||||||
glowTexture = null
|
|
||||||
glowMaterial = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 注册尺寸、可见性和动态偏好监听并启动场景。 */
|
|
||||||
function handleMounted() {
|
|
||||||
reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
|
|
||||||
handleReducedMotionChange()
|
|
||||||
reducedMotionQuery.addEventListener('change', handleReducedMotionChange)
|
|
||||||
|
|
||||||
if (rootRef.value) {
|
|
||||||
resizeObserver = new ResizeObserver(resizeRenderer)
|
|
||||||
resizeObserver.observe(rootRef.value)
|
|
||||||
intersectionObserver = new IntersectionObserver(handleIntersection, { threshold: 0.05 })
|
|
||||||
intersectionObserver.observe(rootRef.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
canvasRef.value?.addEventListener('webglcontextlost', handleContextLost)
|
|
||||||
initializeScene()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 移除监听器并完整销毁 Three.js 场景。 */
|
|
||||||
function handleBeforeUnmount() {
|
|
||||||
resizeObserver?.disconnect()
|
|
||||||
intersectionObserver?.disconnect()
|
|
||||||
reducedMotionQuery?.removeEventListener('change', handleReducedMotionChange)
|
|
||||||
canvasRef.value?.removeEventListener('webglcontextlost', handleContextLost)
|
|
||||||
disposeScene()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(handleMounted)
|
|
||||||
onBeforeUnmount(handleBeforeUnmount)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
ref="rootRef"
|
|
||||||
class="metal-logo-3d"
|
|
||||||
:class="{ 'metal-logo-3d--dragging': isDragging, 'metal-logo-3d--ready': isReady }"
|
|
||||||
role="img"
|
|
||||||
tabindex="0"
|
|
||||||
aria-label="MoviePilot 3D metal logo"
|
|
||||||
@keydown="handleKeydown"
|
|
||||||
@pointerdown="handlePointerDown"
|
|
||||||
@pointermove="handlePointerMove"
|
|
||||||
@pointerup="handlePointerUp"
|
|
||||||
@pointercancel="handlePointerUp"
|
|
||||||
>
|
|
||||||
<canvas ref="canvasRef" class="metal-logo-3d__canvas" aria-hidden="true" />
|
|
||||||
<img v-if="hasWebGLError" :src="logoUrl" class="metal-logo-3d__fallback" alt="MoviePilot" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.metal-logo-3d {
|
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
overflow: visible;
|
|
||||||
block-size: 112px;
|
|
||||||
cursor: grab;
|
|
||||||
inline-size: 112px;
|
|
||||||
outline: none;
|
|
||||||
touch-action: none;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
border-radius: 8px;
|
|
||||||
outline: 2px solid rgba(var(--v-theme-primary), 0.78);
|
|
||||||
outline-offset: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.metal-logo-3d--dragging {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metal-logo-3d__canvas,
|
|
||||||
.metal-logo-3d__fallback {
|
|
||||||
display: block;
|
|
||||||
block-size: 100%;
|
|
||||||
inline-size: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metal-logo-3d__canvas {
|
|
||||||
filter: drop-shadow(0 9px 9px rgba(16, 6, 34, 24%)) drop-shadow(0 0 7px rgba(132, 70, 255, 16%));
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 350ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metal-logo-3d--ready .metal-logo-3d__canvas {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metal-logo-3d__fallback {
|
|
||||||
padding: 12px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width <= 480px) {
|
|
||||||
.metal-logo-3d {
|
|
||||||
block-size: 104px;
|
|
||||||
inline-size: 104px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.metal-logo-3d__canvas {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -126,6 +126,10 @@ const props = withDefaults(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'logo-click': []
|
||||||
|
}>()
|
||||||
|
|
||||||
const STORAGE_KEY = 'moviepilot-optical-logo-lab-v2'
|
const STORAGE_KEY = 'moviepilot-optical-logo-lab-v2'
|
||||||
const LOGO_VIEWBOX_CENTER = 96
|
const LOGO_VIEWBOX_CENTER = 96
|
||||||
const LOGO_COORDINATE_SCALE = 1 / 80
|
const LOGO_COORDINATE_SCALE = 1 / 80
|
||||||
@@ -964,7 +968,7 @@ function registerTexture<T extends ThreeTexture>(texture: T) {
|
|||||||
function getThemeColors() {
|
function getThemeColors() {
|
||||||
const T = requireThree()
|
const T = requireThree()
|
||||||
const colors = vuetifyTheme.global.current.value.colors
|
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 surface = new T.Color(colors.surface || colors.background || '#14161F')
|
||||||
const onSurface = new T.Color(colors['on-surface'] || '#FFFFFF')
|
const onSurface = new T.Color(colors['on-surface'] || '#FFFFFF')
|
||||||
const hsl = { h: 0, l: 0, s: 0 }
|
const hsl = { h: 0, l: 0, s: 0 }
|
||||||
@@ -2353,6 +2357,7 @@ function handleStageClick() {
|
|||||||
dragState.suppressClick = false
|
dragState.suppressClick = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
emit('logo-click')
|
||||||
if (selectedMaterial.value === 'prismatic') {
|
if (selectedMaterial.value === 'prismatic') {
|
||||||
if (selectedEntrance.value !== 'none') prismaticReplayKey.value += 1
|
if (selectedEntrance.value !== 'none') prismaticReplayKey.value += 1
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
import logoUrl from '@images/logo.svg'
|
import logoUrl from '@images/logo.svg'
|
||||||
|
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -71,7 +72,7 @@ onBeforeUnmount(() => {
|
|||||||
@pointermove="handlePointerMove"
|
@pointermove="handlePointerMove"
|
||||||
@pointerleave="resetPointerResponse"
|
@pointerleave="resetPointerResponse"
|
||||||
>
|
>
|
||||||
<img :src="logoUrl" class="prismatic-logo__base" alt="" draggable="false" aria-hidden="true" />
|
<ThemeLogoMark class="prismatic-logo__base" decorative />
|
||||||
<span class="prismatic-logo__spectrum" aria-hidden="true" />
|
<span class="prismatic-logo__spectrum" aria-hidden="true" />
|
||||||
<span class="prismatic-logo__specular" aria-hidden="true" />
|
<span class="prismatic-logo__specular" aria-hidden="true" />
|
||||||
<span class="prismatic-logo__reveal" aria-hidden="true" />
|
<span class="prismatic-logo__reveal" aria-hidden="true" />
|
||||||
@@ -117,7 +118,6 @@ onBeforeUnmount(() => {
|
|||||||
transform: translate3d(0, 8px, -16px) scaleX(1.18);
|
transform: translate3d(0, 8px, -16px) scaleX(1.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prismatic-logo__base,
|
|
||||||
.prismatic-logo__spectrum,
|
.prismatic-logo__spectrum,
|
||||||
.prismatic-logo__specular,
|
.prismatic-logo__specular,
|
||||||
.prismatic-logo__reveal {
|
.prismatic-logo__reveal {
|
||||||
@@ -131,12 +131,17 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prismatic-logo__base {
|
.prismatic-logo__base {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
block-size: calc(100% - 14px);
|
||||||
filter:
|
filter:
|
||||||
drop-shadow(0 8px 12px rgba(24, 8, 52, 0.3))
|
drop-shadow(0 8px 12px rgba(24, 8, 52, 0.3))
|
||||||
drop-shadow(0 0 10px rgba(139, 92, 246, 0.22));
|
drop-shadow(0 0 10px rgba(var(--v-theme-primary), 0.22));
|
||||||
object-fit: contain;
|
inline-size: calc(100% - 14px);
|
||||||
padding: 7px;
|
inset: 7px;
|
||||||
|
pointer-events: none;
|
||||||
transform: translateZ(10px);
|
transform: translateZ(10px);
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prismatic-logo__spectrum,
|
.prismatic-logo__spectrum,
|
||||||
@@ -151,16 +156,15 @@ onBeforeUnmount(() => {
|
|||||||
radial-gradient(
|
radial-gradient(
|
||||||
circle at var(--logo-light-x) var(--logo-light-y),
|
circle at var(--logo-light-x) var(--logo-light-y),
|
||||||
rgba(255, 255, 255, 0.95),
|
rgba(255, 255, 255, 0.95),
|
||||||
rgba(210, 188, 255, 0.66) 13%,
|
color-mix(in srgb, rgb(var(--v-theme-primary)) 68%, white 32%) 13%,
|
||||||
transparent 36%
|
transparent 36%
|
||||||
),
|
),
|
||||||
conic-gradient(
|
conic-gradient(
|
||||||
from 218deg at var(--logo-light-x) var(--logo-light-y),
|
from 218deg at var(--logo-light-x) var(--logo-light-y),
|
||||||
rgba(255, 105, 210, 0.72),
|
color-mix(in srgb, rgb(var(--v-theme-primary)) 64%, #ff69d2 36%),
|
||||||
rgba(117, 212, 255, 0.72),
|
color-mix(in srgb, rgb(var(--v-theme-primary)) 64%, #75d4ff 36%),
|
||||||
rgba(177, 139, 255, 0.82),
|
color-mix(in srgb, rgb(var(--v-theme-primary)) 78%, white 22%),
|
||||||
rgba(255, 214, 246, 0.7),
|
color-mix(in srgb, rgb(var(--v-theme-primary)) 64%, #ff69d2 36%)
|
||||||
rgba(255, 105, 210, 0.72)
|
|
||||||
);
|
);
|
||||||
mix-blend-mode: screen;
|
mix-blend-mode: screen;
|
||||||
opacity: calc(0.22 + var(--prism-intensity) * 0.58);
|
opacity: calc(0.22 + var(--prism-intensity) * 0.58);
|
||||||
@@ -171,7 +175,7 @@ onBeforeUnmount(() => {
|
|||||||
background: radial-gradient(
|
background: radial-gradient(
|
||||||
ellipse 28% 20% at var(--logo-light-x) var(--logo-light-y),
|
ellipse 28% 20% at var(--logo-light-x) var(--logo-light-y),
|
||||||
rgba(255, 255, 255, 0.96),
|
rgba(255, 255, 255, 0.96),
|
||||||
rgba(232, 219, 255, 0.52) 28%,
|
color-mix(in srgb, rgb(var(--v-theme-primary)) 54%, white 46%) 28%,
|
||||||
transparent 72%
|
transparent 72%
|
||||||
);
|
);
|
||||||
mix-blend-mode: screen;
|
mix-blend-mode: screen;
|
||||||
@@ -185,7 +189,7 @@ onBeforeUnmount(() => {
|
|||||||
transparent 30%,
|
transparent 30%,
|
||||||
rgba(255, 255, 255, 0.18) 39%,
|
rgba(255, 255, 255, 0.18) 39%,
|
||||||
rgba(255, 255, 255, 0.98) 48%,
|
rgba(255, 255, 255, 0.98) 48%,
|
||||||
rgba(125, 211, 252, 0.62) 54%,
|
color-mix(in srgb, rgb(var(--v-theme-primary)) 62%, #7dd3fc 38%) 54%,
|
||||||
transparent 66%
|
transparent 66%
|
||||||
);
|
);
|
||||||
background-position: 100% 50%;
|
background-position: 100% 50%;
|
||||||
|
|||||||
50
src/components/misc/ThemeLogoMark.vue
Normal file
50
src/components/misc/ThemeLogoMark.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import logoSvg from '@images/logo.svg?raw'
|
||||||
|
import { applyThemeLogoPalette, createThemeLogoPalette } from '@/utils/themeLogo'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
|
||||||
|
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"
|
||||||
|
: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;
|
||||||
|
block-size: 3em;
|
||||||
|
inline-size: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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'
|
import { getDominantColor } from '@/@core/utils/image'
|
||||||
|
|
||||||
const DEFAULT_ACCENT_RGB = '145, 85, 253'
|
const DEFAULT_ACCENT_RGB = '141, 81, 249'
|
||||||
|
|
||||||
/** 将图标主色转换为卡片 CSS 变量可直接使用的 RGB 字符串。 */
|
/** 将图标主色转换为卡片 CSS 变量可直接使用的 RGB 字符串。 */
|
||||||
function hexToRgbString(hexColor: string) {
|
function hexToRgbString(hexColor: string) {
|
||||||
@@ -13,14 +13,14 @@ function hexToRgbString(hexColor: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 从指定图片中提取卡片强调色,返回 CSS 变量可直接使用的 RGB 字符串。 */
|
/** 从指定图片中提取卡片强调色,返回 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 })
|
const dominantColor = await getDominantColor(image, { fallback })
|
||||||
|
|
||||||
return hexToRgbString(dominantColor)
|
return hexToRgbString(dominantColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从卡片图标中提取强调色,保证设置页卡片颜色跟随各自图标。 */
|
/** 从卡片图标中提取强调色,保证设置页卡片颜色跟随各自图标。 */
|
||||||
export function useCardAccentColor(fallback = '#9155FD') {
|
export function useCardAccentColor(fallback = '#8D51F9') {
|
||||||
const accentRgb = ref(DEFAULT_ACCENT_RGB)
|
const accentRgb = ref(DEFAULT_ACCENT_RGB)
|
||||||
const imageRef = ref<any>()
|
const imageRef = ref<any>()
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
|||||||
import { saveLocalTheme } from '@/@core/utils/theme'
|
import { saveLocalTheme } from '@/@core/utils/theme'
|
||||||
import vuetify from '@/plugins/vuetify'
|
import vuetify from '@/plugins/vuetify'
|
||||||
import { themeManager } from '@/utils/themeManager'
|
import { themeManager } from '@/utils/themeManager'
|
||||||
|
import { syncThemeFavicon } from '@/utils/themePalette'
|
||||||
|
|
||||||
export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer'
|
export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer'
|
||||||
export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change'
|
export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change'
|
||||||
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
|
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
|
||||||
|
|
||||||
export const themeCustomizerPrimaryColors = [
|
export const themeCustomizerPrimaryColors = [
|
||||||
{ name: 'Purple', value: '#9155FD' },
|
{ name: 'Purple', value: '#8D51F9' },
|
||||||
{ name: 'Indigo', value: '#3F51B5' },
|
{ name: 'Indigo', value: '#3F51B5' },
|
||||||
{ name: 'Blue', value: '#1976D2' },
|
{ name: 'Blue', value: '#1976D2' },
|
||||||
{ name: 'Cyan', value: '#00BCD4' },
|
{ name: 'Cyan', value: '#00BCD4' },
|
||||||
@@ -125,12 +126,13 @@ function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSetti
|
|||||||
const fallback = getDefaultThemeCustomizerSettings()
|
const fallback = getDefaultThemeCustomizerSettings()
|
||||||
const storedRadius = settings.radius as string | undefined
|
const storedRadius = settings.radius as string | undefined
|
||||||
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
|
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
|
||||||
|
const primaryColor = isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
||||||
? (settings.layout as ThemeCustomizerLayout)
|
? (settings.layout as ThemeCustomizerLayout)
|
||||||
: fallback.layout,
|
: fallback.layout,
|
||||||
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
|
primaryColor,
|
||||||
radius: validRadii.includes(radius as ThemeCustomizerRadius)
|
radius: validRadii.includes(radius as ThemeCustomizerRadius)
|
||||||
? (radius as ThemeCustomizerRadius)
|
? (radius as ThemeCustomizerRadius)
|
||||||
: fallback.radius,
|
: fallback.radius,
|
||||||
@@ -154,7 +156,6 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
|
|||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
|
const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
|
||||||
const parsed = stored ? JSON.parse(stored) : {}
|
const parsed = stored ? JSON.parse(stored) : {}
|
||||||
|
|
||||||
return normalizeThemeCustomizerSettings({
|
return normalizeThemeCustomizerSettings({
|
||||||
...fallback,
|
...fallback,
|
||||||
...parsed,
|
...parsed,
|
||||||
@@ -209,6 +210,7 @@ export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyTheme
|
|||||||
|
|
||||||
document.documentElement.style.setProperty('--initial-loader-color', color)
|
document.documentElement.style.setProperty('--initial-loader-color', color)
|
||||||
localStorage.setItem('materio-initial-loader-color', color)
|
localStorage.setItem('materio-initial-loader-color', color)
|
||||||
|
syncThemeFavicon(color)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 布局、圆角、阴影、皮肤和局部菜单风格只依赖根节点属性,CSS 可以在不刷新页面的情况下即时响应。 */
|
/** 布局、圆角、阴影、皮肤和局部菜单风格只依赖根节点属性,CSS 可以在不刷新页面的情况下即时响应。 */
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
THEME_CUSTOMIZER_OPEN_EVENT,
|
THEME_CUSTOMIZER_OPEN_EVENT,
|
||||||
type ThemeCustomizerSettings,
|
type ThemeCustomizerSettings,
|
||||||
} from '@/composables/useThemeCustomizer'
|
} from '@/composables/useThemeCustomizer'
|
||||||
import logo from '@images/logo.svg?raw'
|
import ThemeLogoMark from '@/components/misc/ThemeLogoMark.vue'
|
||||||
|
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
// PWA模式检测
|
// PWA模式检测
|
||||||
@@ -510,7 +510,7 @@ onMounted(async () => {
|
|||||||
:class="{ 'theme-navbar-row--horizontal': showHorizontalThemeNav }"
|
:class="{ 'theme-navbar-row--horizontal': showHorizontalThemeNav }"
|
||||||
>
|
>
|
||||||
<RouterLink v-if="showHorizontalThemeNav" :to="canAdmin ? '/dashboard' : '/apps'" class="theme-horizontal-logo">
|
<RouterLink v-if="showHorizontalThemeNav" :to="canAdmin ? '/dashboard' : '/apps'" class="theme-horizontal-logo">
|
||||||
<span class="theme-horizontal-logo__mark" v-html="logo" />
|
<ThemeLogoMark class="theme-horizontal-logo__mark" />
|
||||||
<span class="theme-horizontal-logo__text">MOVIEPILOT</span>
|
<span class="theme-horizontal-logo__text">MOVIEPILOT</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<!-- 👉 Vertical Nav Toggle -->
|
<!-- 👉 Vertical Nav Toggle -->
|
||||||
@@ -770,14 +770,6 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-horizontal-logo__mark {
|
.theme-horizontal-logo__mark {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
block-size: 2rem;
|
|
||||||
inline-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-horizontal-logo__mark :deep(svg) {
|
|
||||||
display: block;
|
display: block;
|
||||||
block-size: 1.8rem;
|
block-size: 1.8rem;
|
||||||
inline-size: 1.8rem;
|
inline-size: 1.8rem;
|
||||||
|
|||||||
@@ -17,11 +17,42 @@ import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federa
|
|||||||
const LoginMfaDialog = defineAsyncComponent(() => import('@/components/dialog/LoginMfaDialog.vue'))
|
const LoginMfaDialog = defineAsyncComponent(() => import('@/components/dialog/LoginMfaDialog.vue'))
|
||||||
|
|
||||||
const loginRootRef = ref<HTMLElement | null>(null)
|
const loginRootRef = ref<HTMLElement | null>(null)
|
||||||
|
type LabTapTarget = 'logo' | 'title'
|
||||||
|
|
||||||
|
const LAB_TAP_COUNT = 5
|
||||||
|
const LAB_TAP_WINDOW_MS = 2000
|
||||||
|
const labTapSequences: Record<LabTapTarget, { count: number; startedAt: number }> = {
|
||||||
|
logo: { count: 0, startedAt: 0 },
|
||||||
|
title: { count: 0, startedAt: 0 },
|
||||||
|
}
|
||||||
let cardLightFrame: number | null = null
|
let cardLightFrame: number | null = null
|
||||||
let pendingCardLightX = 0.5
|
let pendingCardLightX = 0.5
|
||||||
let pendingCardLightY = 0
|
let pendingCardLightY = 0
|
||||||
let pendingCardLightEnergy = 0
|
let pendingCardLightEnergy = 0
|
||||||
|
|
||||||
|
/** 在指定区域连续点击五次时进入隐藏的 Logo 实验室。 */
|
||||||
|
function handleLabTap(target: LabTapTarget) {
|
||||||
|
if (router.currentRoute.value.query.lab === '1') return
|
||||||
|
|
||||||
|
const now = performance.now()
|
||||||
|
const sequence = labTapSequences[target]
|
||||||
|
if (sequence.count === 0 || now - sequence.startedAt > LAB_TAP_WINDOW_MS) {
|
||||||
|
sequence.count = 1
|
||||||
|
sequence.startedAt = now
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence.count += 1
|
||||||
|
if (sequence.count < LAB_TAP_COUNT) return
|
||||||
|
|
||||||
|
labTapSequences.logo.count = 0
|
||||||
|
labTapSequences.title.count = 0
|
||||||
|
void router.push({
|
||||||
|
path: '/login',
|
||||||
|
query: { ...router.currentRoute.value.query, lab: '1' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 卡片顶部反射与指针共用光源位置,避免通过响应式状态触发页面重渲染。 */
|
/** 卡片顶部反射与指针共用光源位置,避免通过响应式状态触发页面重渲染。 */
|
||||||
function renderCardLight() {
|
function renderCardLight() {
|
||||||
cardLightFrame = null
|
cardLightFrame = null
|
||||||
@@ -33,6 +64,7 @@ function renderCardLight() {
|
|||||||
root?.style.setProperty('--login-card-top-prism-alpha', (0.2 + pendingCardLightEnergy * 0.32).toFixed(3))
|
root?.style.setProperty('--login-card-top-prism-alpha', (0.2 + pendingCardLightEnergy * 0.32).toFixed(3))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 根据指针在登录卡片中的位置更新光照目标。 */
|
||||||
function handlePointerLight(event: PointerEvent) {
|
function handlePointerLight(event: PointerEvent) {
|
||||||
if (event.pointerType === 'touch') return
|
if (event.pointerType === 'touch') return
|
||||||
const bounds = loginRootRef.value?.querySelector<HTMLElement>('.login-card')?.getBoundingClientRect()
|
const bounds = loginRootRef.value?.querySelector<HTMLElement>('.login-card')?.getBoundingClientRect()
|
||||||
@@ -45,6 +77,7 @@ function handlePointerLight(event: PointerEvent) {
|
|||||||
if (cardLightFrame === null) cardLightFrame = window.requestAnimationFrame(renderCardLight)
|
if (cardLightFrame === null) cardLightFrame = window.requestAnimationFrame(renderCardLight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 指针离开登录页后恢复卡片的默认光照位置。 */
|
||||||
function resetPointerLight() {
|
function resetPointerLight() {
|
||||||
pendingCardLightX = 0.5
|
pendingCardLightX = 0.5
|
||||||
pendingCardLightY = 0
|
pendingCardLightY = 0
|
||||||
@@ -782,8 +815,12 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- 卡片头部:Logo + 标题 + 欢迎语 -->
|
<!-- 卡片头部:Logo + 标题 + 欢迎语 -->
|
||||||
<div class="login-head">
|
<div class="login-head">
|
||||||
<OpticalLogoLab class="login-logo" :locale="currentLocale">
|
<OpticalLogoLab
|
||||||
<h1 class="login-title">MoviePilot</h1>
|
class="login-logo"
|
||||||
|
:locale="currentLocale"
|
||||||
|
@logo-click="handleLabTap('logo')"
|
||||||
|
>
|
||||||
|
<h1 class="login-title" @click="handleLabTap('title')">MoviePilot</h1>
|
||||||
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
|
<p class="login-subtitle">{{ t('login.welcomeBack') || 'Welcome Back' }}</p>
|
||||||
</OpticalLogoLab>
|
</OpticalLogoLab>
|
||||||
</div>
|
</div>
|
||||||
@@ -969,6 +1006,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
box-sizing: border-box;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1081,6 +1119,7 @@ onUnmounted(() => {
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
|
min-block-size: 0;
|
||||||
padding-inline: 16px;
|
padding-inline: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1224,6 +1263,7 @@ onUnmounted(() => {
|
|||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
touch-action: manipulation;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
@@ -1241,7 +1281,6 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
/* ===================== 卡片主体 ===================== */
|
/* ===================== 卡片主体 ===================== */
|
||||||
.login-body {
|
.login-body {
|
||||||
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 420ms both;
|
|
||||||
padding-block: 8px !important;
|
padding-block: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1568,7 +1607,6 @@ onUnmounted(() => {
|
|||||||
/* ===================== 无障碍:尊重减少动态偏好 ===================== */
|
/* ===================== 无障碍:尊重减少动态偏好 ===================== */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.login-card--enter,
|
.login-card--enter,
|
||||||
.login-body,
|
|
||||||
.login-foot,
|
.login-foot,
|
||||||
.login-title,
|
.login-title,
|
||||||
.login-subtitle {
|
.login-subtitle {
|
||||||
@@ -1619,12 +1657,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (height <= 760px) {
|
|
||||||
.login-root {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================== 小屏适配 ===================== */
|
/* ===================== 小屏适配 ===================== */
|
||||||
@media (width <= 480px) {
|
@media (width <= 480px) {
|
||||||
.auth-wrapper {
|
.auth-wrapper {
|
||||||
@@ -1654,4 +1686,14 @@ onUnmounted(() => {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (width <= 480px) and (height <= 600px) {
|
||||||
|
.lang-switch-btn {
|
||||||
|
inset-block-start: calc(env(safe-area-inset-top, 0px) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
padding-block: 0.75rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const theme: VuetifyOptions['theme'] = {
|
|||||||
light: {
|
light: {
|
||||||
dark: false,
|
dark: false,
|
||||||
colors: {
|
colors: {
|
||||||
'primary': '#9155FD',
|
'primary': '#8D51F9',
|
||||||
'secondary': '#8A8D93',
|
'secondary': '#8A8D93',
|
||||||
'on-secondary': '#FFFFFF',
|
'on-secondary': '#FFFFFF',
|
||||||
'success': '#56CA00',
|
'success': '#56CA00',
|
||||||
@@ -107,7 +107,7 @@ const theme: VuetifyOptions['theme'] = {
|
|||||||
purple: {
|
purple: {
|
||||||
dark: true,
|
dark: true,
|
||||||
colors: {
|
colors: {
|
||||||
'primary': '#9155FD',
|
'primary': '#8D51F9',
|
||||||
'secondary': '#8A8D93',
|
'secondary': '#8A8D93',
|
||||||
'on-secondary': '#FFFFFF',
|
'on-secondary': '#FFFFFF',
|
||||||
'success': '#56CA00',
|
'success': '#56CA00',
|
||||||
|
|||||||
42
src/utils/__tests__/themeLogo.spec.ts
Normal file
42
src/utils/__tests__/themeLogo.spec.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
25
src/utils/__tests__/themePalette.spec.ts
Normal file
25
src/utils/__tests__/themePalette.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { applyDocumentThemeChrome } from '@/utils/themePalette'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
describe('theme palette', () => {
|
||||||
|
it('notifies the favicon renderer with the applied primary color', () => {
|
||||||
|
const handleFaviconChange = vi.fn()
|
||||||
|
|
||||||
|
window.addEventListener('moviepilot-theme-primary-color-change', handleFaviconChange)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = applyDocumentThemeChrome('dark', {
|
||||||
|
background: '#0E1116',
|
||||||
|
primary: '#00BCD4',
|
||||||
|
})
|
||||||
|
const event = handleFaviconChange.mock.calls[0]?.[0] as CustomEvent<{ color: string }>
|
||||||
|
|
||||||
|
expect(result.primary).toBe('#00BCD4')
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--initial-loader-color')).toBe('#00BCD4')
|
||||||
|
expect(handleFaviconChange).toHaveBeenCalledOnce()
|
||||||
|
expect(event.detail).toEqual({ color: '#00BCD4' })
|
||||||
|
} finally {
|
||||||
|
window.removeEventListener('moviepilot-theme-primary-color-change', handleFaviconChange)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
138
src/utils/themeLogo.ts
Normal file
138
src/utils/themeLogo.ts
Normal file
@@ -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> = {
|
export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
|
||||||
light: {
|
light: {
|
||||||
background: '#F4F5FA',
|
background: '#F4F5FA',
|
||||||
primary: '#9155FD',
|
primary: '#8D51F9',
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
background: '#0E1116',
|
background: '#0E1116',
|
||||||
@@ -27,7 +27,7 @@ export const themeRootPalettes: Record<ResolvedThemeName, ThemeRootPalette> = {
|
|||||||
},
|
},
|
||||||
purple: {
|
purple: {
|
||||||
background: '#28243D',
|
background: '#28243D',
|
||||||
primary: '#9155FD',
|
primary: '#8D51F9',
|
||||||
},
|
},
|
||||||
transparent: {
|
transparent: {
|
||||||
background: '#1C1C1C',
|
background: '#1C1C1C',
|
||||||
@@ -80,6 +80,15 @@ function ensureThemeColorMeta(themeColor: string) {
|
|||||||
document.head.appendChild(meta)
|
document.head.appendChild(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 通知启动层刷新浏览器 Tab 图标,图标颜色与当前主题主色保持一致。 */
|
||||||
|
export function syncThemeFavicon(primaryColor: string) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('moviepilot-theme-primary-color-change', {
|
||||||
|
detail: { color: primaryColor },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步浏览器首帧会使用的根节点底色和系统控件配色。
|
* 同步浏览器首帧会使用的根节点底色和系统控件配色。
|
||||||
* iOS PWA 从后台恢复时可能先绘制 WebView 外壳,再等 Vue 响应式主题更新。
|
* iOS PWA 从后台恢复时可能先绘制 WebView 外壳,再等 Vue 响应式主题更新。
|
||||||
@@ -110,6 +119,7 @@ export function applyDocumentThemeChrome(
|
|||||||
|
|
||||||
setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark')
|
setMetaContent('meta[name="color-scheme"]', colorScheme === 'dark' ? 'dark light' : 'light dark')
|
||||||
ensureThemeColorMeta(background)
|
ensureThemeColorMeta(background)
|
||||||
|
syncThemeFavicon(primary)
|
||||||
|
|
||||||
if (options.persistLoaderColors) {
|
if (options.persistLoaderColors) {
|
||||||
localStorage.setItem('materio-initial-loader-bg', background)
|
localStorage.setItem('materio-initial-loader-bg', background)
|
||||||
|
|||||||
@@ -688,7 +688,10 @@ function normalizeMarketText(value: unknown) {
|
|||||||
/** 将插件市场逗号分隔字段转换为去重前的文本数组。 */
|
/** 将插件市场逗号分隔字段转换为去重前的文本数组。 */
|
||||||
function splitMarketValues(value: unknown) {
|
function splitMarketValues(value: unknown) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map(normalizeMarketText).map(item => item.trim()).filter(Boolean)
|
return value
|
||||||
|
.map(normalizeMarketText)
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeMarketText(value)
|
return normalizeMarketText(value)
|
||||||
@@ -933,7 +936,10 @@ watch([marketList, filterForm, activeSort, PluginStatistics], () => {
|
|||||||
marketList.value.forEach(value => {
|
marketList.value.forEach(value => {
|
||||||
if (value) {
|
if (value) {
|
||||||
if (
|
if (
|
||||||
filterText(filterForm.name, `${normalizeMarketText(value.plugin_name)} ${normalizeMarketText(value.plugin_desc)}`) &&
|
filterText(
|
||||||
|
filterForm.name,
|
||||||
|
`${normalizeMarketText(value.plugin_name)} ${normalizeMarketText(value.plugin_desc)}`,
|
||||||
|
) &&
|
||||||
match(filterForm.author, value.plugin_author) &&
|
match(filterForm.author, value.plugin_author) &&
|
||||||
matchMultiple(filterForm.label, value.plugin_label) &&
|
matchMultiple(filterForm.label, value.plugin_label) &&
|
||||||
match(filterForm.repo, handleRepoUrl(value))
|
match(filterForm.repo, handleRepoUrl(value))
|
||||||
@@ -1114,9 +1120,7 @@ const canAdmin = computed(() =>
|
|||||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||||
)
|
)
|
||||||
const showNewFolderAction = computed(() => activeTab.value === 'installed' && !currentFolder.value && canAdmin.value)
|
const showNewFolderAction = computed(() => activeTab.value === 'installed' && !currentFolder.value && canAdmin.value)
|
||||||
const showMarketSettingAction = computed(
|
const showMarketSettingAction = computed(() => activeTab.value === 'market' && canAdmin.value)
|
||||||
() => activeTab.value === 'market' && canAdmin.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
const pluginDynamicMenuItems = computed(() => {
|
const pluginDynamicMenuItems = computed(() => {
|
||||||
if (!appMode.value) return undefined
|
if (!appMode.value) return undefined
|
||||||
@@ -1654,50 +1658,57 @@ function onDragStartPlugin(evt: any) {
|
|||||||
</VList>
|
</VList>
|
||||||
<!-- 下拉多选筛选项 -->
|
<!-- 下拉多选筛选项 -->
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<div class="px-3 py-2 d-flex flex-column gap-2">
|
<VList density="compact" class="market-filter-options-list px-2 py-1">
|
||||||
<VSelect
|
<VListSubheader>{{ t('common.filter') }}</VListSubheader>
|
||||||
v-if="authorFilterOptions.length > 0"
|
<VListItem>
|
||||||
v-model="filterForm.author"
|
<VSelect
|
||||||
:items="authorFilterOptions"
|
v-if="authorFilterOptions.length > 0"
|
||||||
:label="t('plugin.author')"
|
v-model="filterForm.author"
|
||||||
mobile-control-width="72%"
|
:items="authorFilterOptions"
|
||||||
multiple
|
:label="t('plugin.author')"
|
||||||
chips
|
mobile-control-width="75%"
|
||||||
closable-chips
|
multiple
|
||||||
density="compact"
|
chips
|
||||||
variant="outlined"
|
closable-chips
|
||||||
hide-details
|
density="compact"
|
||||||
clearable
|
variant="outlined"
|
||||||
/>
|
hide-details
|
||||||
<VSelect
|
clearable
|
||||||
v-if="labelFilterOptions.length > 0"
|
/>
|
||||||
v-model="filterForm.label"
|
</VListItem>
|
||||||
:items="labelFilterOptions"
|
<VListItem>
|
||||||
:label="t('plugin.label')"
|
<VSelect
|
||||||
mobile-control-width="72%"
|
v-if="labelFilterOptions.length > 0"
|
||||||
multiple
|
v-model="filterForm.label"
|
||||||
chips
|
:items="labelFilterOptions"
|
||||||
closable-chips
|
:label="t('plugin.label')"
|
||||||
density="compact"
|
mobile-control-width="75%"
|
||||||
variant="outlined"
|
multiple
|
||||||
hide-details
|
chips
|
||||||
clearable
|
closable-chips
|
||||||
/>
|
density="compact"
|
||||||
<VSelect
|
variant="outlined"
|
||||||
v-if="repoFilterOptions.length > 0"
|
hide-details
|
||||||
v-model="filterForm.repo"
|
clearable
|
||||||
:items="repoFilterOptions"
|
/>
|
||||||
:label="t('plugin.repository')"
|
</VListItem>
|
||||||
mobile-control-width="72%"
|
<VListItem>
|
||||||
multiple
|
<VSelect
|
||||||
chips
|
v-if="repoFilterOptions.length > 0"
|
||||||
closable-chips
|
v-model="filterForm.repo"
|
||||||
density="compact"
|
:items="repoFilterOptions"
|
||||||
variant="outlined"
|
:label="t('plugin.repository')"
|
||||||
hide-details
|
mobile-control-width="75%"
|
||||||
clearable
|
multiple
|
||||||
/>
|
chips
|
||||||
</div>
|
closable-chips
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -1924,3 +1935,19 @@ function onDragStartPlugin(evt: any) {
|
|||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||||
|
|
||||||
|
@media (width < 960px) {
|
||||||
|
// 弹出菜单使用紧凑录入行,避免叠加全局移动表单高度与列表项纵向留白。
|
||||||
|
.market-filter-options-list :deep(.v-list-item) {
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-filter-options-list :deep(.app-responsive-input) {
|
||||||
|
min-block-size: 3rem;
|
||||||
|
padding-block: 0.125rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -643,11 +643,10 @@ async function eventsHander(subscribe: Subscribe) {
|
|||||||
// 调用API查询所有订阅
|
// 调用API查询所有订阅
|
||||||
async function getSubscribes() {
|
async function getSubscribes() {
|
||||||
if (!isLoaded.value && display.mdAndUp.value) openProgressDialog()
|
if (!isLoaded.value && display.mdAndUp.value) openProgressDialog()
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
// 订阅
|
// 订阅
|
||||||
loading.value = true
|
|
||||||
const subscribes: Subscribe[] = await api.get('subscribe/')
|
const subscribes: Subscribe[] = await api.get('subscribe/')
|
||||||
loading.value = false
|
|
||||||
const subEvents = await Promise.allSettled(subscribes.map(async sub => eventsHander(sub)))
|
const subEvents = await Promise.allSettled(subscribes.map(async sub => eventsHander(sub)))
|
||||||
const succEvents = subEvents.filter(result => result.status === 'fulfilled').map(result => result.value)
|
const succEvents = subEvents.filter(result => result.status === 'fulfilled').map(result => result.value)
|
||||||
rawCalendarEvents.value = normalizeCalendarEventOrder(succEvents.flat().filter(event => event.start))
|
rawCalendarEvents.value = normalizeCalendarEventOrder(succEvents.flat().filter(event => event.start))
|
||||||
@@ -656,6 +655,7 @@ async function getSubscribes() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
} finally {
|
||||||
|
loading.value = false
|
||||||
closeProgressDialog()
|
closeProgressDialog()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
446
src/views/subscribe/__tests__/FullCalendarView.spec.ts
Normal file
446
src/views/subscribe/__tests__/FullCalendarView.spec.ts
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import type { MediaInfo, Subscribe } from '@/api/types'
|
||||||
|
import FullCalendarView from '@/views/subscribe/FullCalendarView.vue'
|
||||||
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { createMediaInfo, createTmdbEpisode } from '@tests/support/factories/media'
|
||||||
|
import { createSubscribe } from '@tests/support/factories/subscribe'
|
||||||
|
import { mediaDetailsHandler, tmdbSeasonEpisodesHandler } from '@tests/support/msw/handlers/media'
|
||||||
|
import { subscribeApiUrls, subscribeListHandler } from '@tests/support/msw/handlers/subscribe'
|
||||||
|
import { server } from '@tests/support/msw/server'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { HttpResponse, http } from 'msw'
|
||||||
|
import { defineComponent, ref } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getEventById: vi.fn(),
|
||||||
|
openSharedDialog: vi.fn(),
|
||||||
|
setExtendedProp: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSharedDialog', () => ({
|
||||||
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@fullcalendar/vue3', async () => {
|
||||||
|
const { defineComponent, h } = await import('vue')
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: defineComponent({
|
||||||
|
name: 'FullCalendarTestDouble',
|
||||||
|
props: {
|
||||||
|
options: {
|
||||||
|
required: true,
|
||||||
|
type: Object,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props, { expose, slots }) {
|
||||||
|
const getEventById = (id: string) => {
|
||||||
|
mocks.getEventById(id)
|
||||||
|
const options = props.options as { events?: Record<string, unknown>[] }
|
||||||
|
const event = options.events?.find(item => item.id === id)
|
||||||
|
if (!event) return undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
setExtendedProp(key: string, value: unknown) {
|
||||||
|
mocks.setExtendedProp(id, key, value)
|
||||||
|
event[key] = value
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expose({
|
||||||
|
getApi: () => ({ getEventById }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const options = props.options as { events?: Record<string, unknown>[] }
|
||||||
|
const events = Array.isArray(options.events) ? options.events : []
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{ 'data-testid': 'full-calendar' },
|
||||||
|
events.map(event =>
|
||||||
|
h(
|
||||||
|
'section',
|
||||||
|
{ 'data-calendar-event-id': String(event.id) },
|
||||||
|
slots.eventContent?.({
|
||||||
|
event: {
|
||||||
|
extendedProps: event,
|
||||||
|
id: event.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function setViewport(width: number) {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width, writable: true })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryMobileCalendarEventCard(title: string) {
|
||||||
|
return (
|
||||||
|
Array.from(document.querySelectorAll<HTMLElement>('.mobile-calendar-event-card')).find(card =>
|
||||||
|
card.title.startsWith(title),
|
||||||
|
) ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function movieSubscribe(id: number, name: string, overrides: Partial<Subscribe> = {}) {
|
||||||
|
return createSubscribe({ id, name, tmdbid: id, type: '电影', username: `user-${id}`, ...overrides })
|
||||||
|
}
|
||||||
|
|
||||||
|
function tvSubscribe(id: number, name: string, overrides: Partial<Subscribe> = {}) {
|
||||||
|
return createSubscribe({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
season: 1,
|
||||||
|
tmdbid: id,
|
||||||
|
total_episode: 4,
|
||||||
|
type: '电视剧',
|
||||||
|
username: `user-${id}`,
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCalendar(component = FullCalendarView) {
|
||||||
|
return renderWithProviders(component, { initialRoute: '/calendar' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepAliveHarness() {
|
||||||
|
return defineComponent({
|
||||||
|
components: { FullCalendarView },
|
||||||
|
setup() {
|
||||||
|
const active = ref(true)
|
||||||
|
return { active }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<button type="button" @click="active = false">停用日历</button>
|
||||||
|
<button type="button" @click="active = true">启用日历</button>
|
||||||
|
<KeepAlive><FullCalendarView v-if="active" /></KeepAlive>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sequenceSubscribeList(responses: Array<{ body: Subscribe[]; status?: number }>, onRequest = vi.fn()) {
|
||||||
|
let index = 0
|
||||||
|
return http.get(subscribeApiUrls.list, () => {
|
||||||
|
onRequest()
|
||||||
|
const response = responses[Math.min(index, responses.length - 1)]
|
||||||
|
index += 1
|
||||||
|
return HttpResponse.json(response.body, { status: response.status ?? 200 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FullCalendarView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setViewport(1280)
|
||||||
|
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps movie and TV requests into ordered desktop calendar events', async () => {
|
||||||
|
const earlyMovie = movieSubscribe(3101, '较早电影')
|
||||||
|
const tv = tvSubscribe(3102, 'Zulu剧集', {
|
||||||
|
episode_group: 'group-a',
|
||||||
|
lack_episode: 2,
|
||||||
|
note: [1],
|
||||||
|
})
|
||||||
|
const sameDayMovie = movieSubscribe(3103, 'Alpha电影')
|
||||||
|
const movieRequest = vi.fn<(url: URL) => void>()
|
||||||
|
const tvRequest = vi.fn<(url: URL) => void>()
|
||||||
|
const progressClose = vi.fn()
|
||||||
|
mocks.openSharedDialog.mockReturnValue({ close: progressClose, id: 1, updateProps: vi.fn() })
|
||||||
|
server.use(
|
||||||
|
subscribeListHandler([tv, sameDayMovie, earlyMovie]),
|
||||||
|
mediaDetailsHandler(
|
||||||
|
3101,
|
||||||
|
createMediaInfo({ release_date: '2026-07-20', runtime: 121, title: earlyMovie.name, tmdb_id: 3101 }),
|
||||||
|
200,
|
||||||
|
movieRequest,
|
||||||
|
),
|
||||||
|
mediaDetailsHandler(
|
||||||
|
3103,
|
||||||
|
createMediaInfo({ release_date: '2026-07-21', runtime: 110, title: sameDayMovie.name, tmdb_id: 3103 }),
|
||||||
|
),
|
||||||
|
tmdbSeasonEpisodesHandler(
|
||||||
|
3102,
|
||||||
|
1,
|
||||||
|
[
|
||||||
|
createTmdbEpisode({ air_date: '2026-07-21', episode_number: 1, name: '第一集', runtime: 45 }),
|
||||||
|
createTmdbEpisode({ air_date: '2026-07-21', episode_number: 2, name: '第二集', runtime: 48 }),
|
||||||
|
],
|
||||||
|
200,
|
||||||
|
tvRequest,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderCalendar()
|
||||||
|
|
||||||
|
expect(await screen.findByText('较早电影')).toBeInTheDocument()
|
||||||
|
expect(await screen.findByText('Zulu剧集')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Alpha电影')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('第1-2集')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('部分入库 (2/4)')).toBeInTheDocument()
|
||||||
|
expect(document.querySelector('.calendar-event-card[title*="第一集 / 第二集"]')).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
Array.from(document.querySelectorAll('.calendar-event-title')).map(element => element.textContent?.trim()),
|
||||||
|
).toEqual(['较早电影', 'Alpha电影', 'Zulu剧集'])
|
||||||
|
expect(movieRequest).toHaveBeenCalledOnce()
|
||||||
|
expect(movieRequest.mock.calls[0][0].searchParams.get('type_name')).toBe('电影')
|
||||||
|
expect(tvRequest).toHaveBeenCalledOnce()
|
||||||
|
expect(tvRequest.mock.calls[0][0].searchParams.get('episode_group')).toBe('group-a')
|
||||||
|
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||||
|
expect(progressClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes none, partial, complete, and best-version library states', async () => {
|
||||||
|
const subscriptions = [
|
||||||
|
tvSubscribe(3201, '未入库', { lack_episode: 4, note: [] }),
|
||||||
|
tvSubscribe(3202, '部分入库', { lack_episode: 2, note: [1] }),
|
||||||
|
tvSubscribe(3203, '全部入库', { lack_episode: 0, note: [1, 2] }),
|
||||||
|
tvSubscribe(3204, '洗版部分入库', {
|
||||||
|
best_version: '1',
|
||||||
|
episode_priority: { '1': 100, '2': 50 },
|
||||||
|
lack_episode: 0,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const episodes = [
|
||||||
|
createTmdbEpisode({ air_date: '2026-07-22', episode_number: 1 }),
|
||||||
|
createTmdbEpisode({ air_date: '2026-07-22', episode_number: 2 }),
|
||||||
|
]
|
||||||
|
server.use(
|
||||||
|
subscribeListHandler(subscriptions),
|
||||||
|
...subscriptions.map(subscribe => tmdbSeasonEpisodesHandler(subscribe.tmdbid as number, 1, episodes)),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderCalendar()
|
||||||
|
|
||||||
|
const noneCard = (await screen.findByText('未入库')).closest('.calendar-event-card')
|
||||||
|
const partialCard = (await screen.findByText('部分入库')).closest('.calendar-event-card')
|
||||||
|
const completeCard = (await screen.findByText('全部入库')).closest('.calendar-event-card')
|
||||||
|
const washCard = (await screen.findByText('洗版部分入库')).closest('.calendar-event-card')
|
||||||
|
expect(noneCard).toHaveClass('calendar-event-card--none')
|
||||||
|
expect(partialCard).toHaveClass('calendar-event-card--partial')
|
||||||
|
expect(completeCard).toHaveClass('calendar-event-card--complete')
|
||||||
|
expect(washCard).toHaveClass('calendar-event-card--partial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps successful events when another detail request fails and drops invalid dates', async () => {
|
||||||
|
const valid = movieSubscribe(3301, '有效电影')
|
||||||
|
const failed = movieSubscribe(3302, '失败电影')
|
||||||
|
const undated = movieSubscribe(3303, '无日期电影')
|
||||||
|
const undatedTv = tvSubscribe(3304, '无日期剧集')
|
||||||
|
server.use(
|
||||||
|
subscribeListHandler([failed, undated, undatedTv, valid]),
|
||||||
|
mediaDetailsHandler(3301, createMediaInfo({ release_date: '2026-07-23', tmdb_id: 3301 })),
|
||||||
|
mediaDetailsHandler(3302, createMediaInfo({ tmdb_id: 3302 }), 500),
|
||||||
|
mediaDetailsHandler(3303, createMediaInfo({ release_date: '', tmdb_id: 3303 })),
|
||||||
|
tmdbSeasonEpisodesHandler(3304, 1, [
|
||||||
|
createTmdbEpisode({ air_date: undefined, episode_number: undefined, name: undefined, runtime: undefined }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderCalendar()
|
||||||
|
|
||||||
|
expect(await screen.findByText('有效电影')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('失败电影')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('无日期电影')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('无日期剧集')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates only the expanded day through the FullCalendar API and restores scroll', async () => {
|
||||||
|
const sameDaySubscriptions = Array.from({ length: 6 }, (_, index) =>
|
||||||
|
tvSubscribe(3400 + index, `同日项目 ${index + 1}`),
|
||||||
|
)
|
||||||
|
const nextDaySubscription = tvSubscribe(3499, '次日项目')
|
||||||
|
const subscriptions = [...sameDaySubscriptions, nextDaySubscription]
|
||||||
|
const sameDayEpisode = createTmdbEpisode({ air_date: '2026-08-01', episode_number: 1 })
|
||||||
|
server.use(
|
||||||
|
subscribeListHandler(subscriptions),
|
||||||
|
...sameDaySubscriptions.map(subscribe =>
|
||||||
|
tmdbSeasonEpisodesHandler(subscribe.tmdbid as number, 1, [sameDayEpisode]),
|
||||||
|
),
|
||||||
|
tmdbSeasonEpisodesHandler(3499, 1, [
|
||||||
|
createTmdbEpisode({ air_date: '2026-08-02', episode_number: 1 }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
Object.defineProperty(window, 'scrollY', { configurable: true, value: 240 })
|
||||||
|
Object.defineProperty(window, 'scrollX', { configurable: true, value: 16 })
|
||||||
|
const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||||
|
callback(0)
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
|
||||||
|
await renderCalendar()
|
||||||
|
|
||||||
|
expect(await screen.findByText('同日项目 1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('次日项目')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('同日项目 6')).not.toBeInTheDocument()
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '展开当天剩余 1 个条目' }))
|
||||||
|
|
||||||
|
const eventId = 'calendar-day-group-2026-08-01'
|
||||||
|
expect(mocks.getEventById).toHaveBeenCalledWith(eventId)
|
||||||
|
expect(mocks.setExtendedProp).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
eventId,
|
||||||
|
'visibleEvents',
|
||||||
|
expect.arrayContaining([expect.objectContaining({ title: '同日项目 6' })]),
|
||||||
|
)
|
||||||
|
expect(mocks.setExtendedProp).toHaveBeenNthCalledWith(2, eventId, 'hiddenEventCount', 0)
|
||||||
|
expect(mocks.setExtendedProp).toHaveBeenCalledTimes(2)
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith({ left: 16, top: 240 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders mobile date boundaries and filters without restoring events older than 30 days', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] })
|
||||||
|
vi.setSystemTime(new Date('2026-07-17T12:00:00+08:00'))
|
||||||
|
setViewport(480)
|
||||||
|
const today = movieSubscribe(3501, '今日电影', { year: '' })
|
||||||
|
const future = movieSubscribe(3502, '未来电影')
|
||||||
|
const recent = movieSubscribe(3503, '近期过期电影')
|
||||||
|
const old = movieSubscribe(3504, '过久电影')
|
||||||
|
const boundary = movieSubscribe(3505, '边界电影')
|
||||||
|
const details: Array<[Subscribe, MediaInfo]> = [
|
||||||
|
[today, createMediaInfo({ release_date: '2026-07-17', tmdb_id: 3501 })],
|
||||||
|
[future, createMediaInfo({ release_date: '2026-07-18', tmdb_id: 3502 })],
|
||||||
|
[recent, createMediaInfo({ release_date: '2026-07-12', tmdb_id: 3503 })],
|
||||||
|
[old, createMediaInfo({ release_date: '2026-06-16', tmdb_id: 3504 })],
|
||||||
|
[boundary, createMediaInfo({ release_date: '2026-06-17', tmdb_id: 3505 })],
|
||||||
|
]
|
||||||
|
server.use(
|
||||||
|
subscribeListHandler(details.map(([subscribe]) => subscribe)),
|
||||||
|
...details.map(([subscribe, media]) => mediaDetailsHandler(subscribe.tmdbid as number, media)),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderCalendar()
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryMobileCalendarEventCard('今日电影')).toBeInTheDocument())
|
||||||
|
expect(
|
||||||
|
queryMobileCalendarEventCard('今日电影')?.querySelector('.mobile-calendar-event-content > p'),
|
||||||
|
).toHaveTextContent('电影')
|
||||||
|
expect(queryMobileCalendarEventCard('未来电影')).toBeInTheDocument()
|
||||||
|
expect(queryMobileCalendarEventCard('近期过期电影')).not.toBeInTheDocument()
|
||||||
|
expect(queryMobileCalendarEventCard('边界电影')).not.toBeInTheDocument()
|
||||||
|
expect(queryMobileCalendarEventCard('过久电影')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('5 项')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('即将播出')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '隐藏过期' }))
|
||||||
|
await waitFor(() => expect(queryMobileCalendarEventCard('近期过期电影')).toBeInTheDocument())
|
||||||
|
expect(queryMobileCalendarEventCard('边界电影')).toBeInTheDocument()
|
||||||
|
expect(queryMobileCalendarEventCard('过久电影')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('已播出')).toHaveLength(2)
|
||||||
|
|
||||||
|
await fireEvent.click(screen.getByRole('option', { name: '未来电影' }))
|
||||||
|
expect(queryMobileCalendarEventCard('未来电影')).toBeInTheDocument()
|
||||||
|
expect(queryMobileCalendarEventCard('今日电影')).not.toBeInTheDocument()
|
||||||
|
expect(queryMobileCalendarEventCard('近期过期电影')).not.toBeInTheDocument()
|
||||||
|
expect(queryMobileCalendarEventCard('边界电影')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders cross-year TV metadata and all mobile library states', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] })
|
||||||
|
vi.setSystemTime(new Date('2026-12-31T12:00:00+08:00'))
|
||||||
|
setViewport(480)
|
||||||
|
const aggregate = tvSubscribe(3551, '年度剧集', {
|
||||||
|
lack_episode: 3,
|
||||||
|
note: [],
|
||||||
|
total_episode: 4,
|
||||||
|
})
|
||||||
|
const partial = tvSubscribe(3552, '部分剧集', {
|
||||||
|
lack_episode: 2,
|
||||||
|
note: [1],
|
||||||
|
total_episode: 4,
|
||||||
|
})
|
||||||
|
server.use(
|
||||||
|
subscribeListHandler([aggregate, partial]),
|
||||||
|
tmdbSeasonEpisodesHandler(3551, 1, [
|
||||||
|
createTmdbEpisode({ air_date: '2027-01-01', episode_number: 1, name: '跨年首集', runtime: 50 }),
|
||||||
|
createTmdbEpisode({ air_date: '2027-01-02', episode_number: 2, name: undefined, runtime: undefined }),
|
||||||
|
]),
|
||||||
|
tmdbSeasonEpisodesHandler(3552, 1, [
|
||||||
|
createTmdbEpisode({ air_date: '2027-01-03', episode_number: 1, name: '第一集', runtime: undefined }),
|
||||||
|
createTmdbEpisode({ air_date: '2027-01-03', episode_number: 2, name: '第二集' }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderCalendar()
|
||||||
|
|
||||||
|
const completeCard = (await screen.findByRole('heading', { name: '跨年首集' })).closest(
|
||||||
|
'.mobile-calendar-event-card',
|
||||||
|
)
|
||||||
|
const noneCard = screen.getByRole('heading', { name: '第 2 集' }).closest('.mobile-calendar-event-card')
|
||||||
|
const partialCard = screen
|
||||||
|
.getByRole('heading', { name: '第一集 / 第二集' })
|
||||||
|
.closest('.mobile-calendar-event-card')
|
||||||
|
expect(completeCard).toHaveClass('mobile-calendar-event-card--complete')
|
||||||
|
expect(noneCard).toHaveClass('mobile-calendar-event-card--none')
|
||||||
|
expect(partialCard).toHaveClass('mobile-calendar-event-card--partial')
|
||||||
|
expect(screen.getByText('2027/01/01')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('50 分钟')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('45 分钟')).toBeInTheDocument()
|
||||||
|
expect(screen.getAllByText('S01E01').length).toBeGreaterThan(0)
|
||||||
|
expect(screen.getByRole('option', { name: '年度剧集' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets a stale mobile title filter after keep-alive refresh replaces the data', async () => {
|
||||||
|
setViewport(480)
|
||||||
|
const first = movieSubscribe(3601, '第一轮电影')
|
||||||
|
const second = movieSubscribe(3602, '第二轮电影')
|
||||||
|
server.use(
|
||||||
|
sequenceSubscribeList([{ body: [first] }, { body: [second] }]),
|
||||||
|
mediaDetailsHandler(3601, createMediaInfo({ release_date: '2026-08-10', tmdb_id: 3601 })),
|
||||||
|
mediaDetailsHandler(3602, createMediaInfo({ release_date: '2026-08-11', tmdb_id: 3602 })),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderCalendar(keepAliveHarness())
|
||||||
|
await waitFor(() => expect(queryMobileCalendarEventCard('第一轮电影')).toBeInTheDocument())
|
||||||
|
await fireEvent.click(screen.getByRole('option', { name: '第一轮电影' }))
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '停用日历' }))
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '启用日历' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryMobileCalendarEventCard('第二轮电影')).toBeInTheDocument())
|
||||||
|
expect(screen.getByRole('option', { name: '全部' })).toHaveAttribute('aria-selected', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recovers from a failed list request when the kept-alive view is activated again', async () => {
|
||||||
|
setViewport(480)
|
||||||
|
const recovered = movieSubscribe(3701, '恢复后的电影')
|
||||||
|
const onListRequest = vi.fn()
|
||||||
|
server.use(
|
||||||
|
sequenceSubscribeList(
|
||||||
|
[
|
||||||
|
{ body: [], status: 500 },
|
||||||
|
{ body: [recovered] },
|
||||||
|
],
|
||||||
|
onListRequest,
|
||||||
|
),
|
||||||
|
mediaDetailsHandler(3701, createMediaInfo({ release_date: '2026-08-12', tmdb_id: 3701 })),
|
||||||
|
)
|
||||||
|
|
||||||
|
await renderCalendar(keepAliveHarness())
|
||||||
|
await waitFor(() => expect(onListRequest).toHaveBeenCalledOnce())
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '停用日历' }))
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: '启用日历' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryMobileCalendarEventCard('恢复后的电影')).toBeInTheDocument())
|
||||||
|
expect(onListRequest).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the mobile empty state for an empty subscription list', async () => {
|
||||||
|
setViewport(480)
|
||||||
|
server.use(subscribeListHandler([]))
|
||||||
|
|
||||||
|
await renderCalendar()
|
||||||
|
|
||||||
|
expect(await screen.findByText('暂无符合筛选条件的日历内容')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('加载中 ...')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
import type { MediaInfo } from '@/api/types'
|
import type { MediaInfo, TmdbEpisode } from '@/api/types'
|
||||||
|
|
||||||
|
let episodeSeed = 0
|
||||||
let mediaSeed = 0
|
let mediaSeed = 0
|
||||||
|
|
||||||
|
export function createTmdbEpisode(overrides: Partial<TmdbEpisode> = {}): TmdbEpisode {
|
||||||
|
episodeSeed += 1
|
||||||
|
return {
|
||||||
|
air_date: '2026-01-01',
|
||||||
|
crew: [],
|
||||||
|
episode_number: episodeSeed,
|
||||||
|
guest_stars: [],
|
||||||
|
name: `测试剧集 ${episodeSeed}`,
|
||||||
|
runtime: 45,
|
||||||
|
season_number: 1,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createMediaInfo(overrides: Partial<MediaInfo> = {}): MediaInfo {
|
export function createMediaInfo(overrides: Partial<MediaInfo> = {}): MediaInfo {
|
||||||
mediaSeed += 1
|
mediaSeed += 1
|
||||||
return {
|
return {
|
||||||
|
|||||||
29
tests/support/msw/handlers/media.ts
Normal file
29
tests/support/msw/handlers/media.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { MediaInfo, TmdbEpisode } from '@/api/types'
|
||||||
|
import { HttpResponse, http, type JsonBodyType } from 'msw'
|
||||||
|
|
||||||
|
const API_BASE_URL = 'http://localhost/api/v1/'
|
||||||
|
|
||||||
|
export function mediaDetailsHandler(
|
||||||
|
tmdbId: number,
|
||||||
|
response: MediaInfo,
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(new URL(`media/tmdb:${tmdbId}`, API_BASE_URL).href, ({ request }) => {
|
||||||
|
onRequest(new URL(request.url))
|
||||||
|
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tmdbSeasonEpisodesHandler(
|
||||||
|
tmdbId: number,
|
||||||
|
season: number,
|
||||||
|
response: TmdbEpisode[],
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(new URL(`tmdb/${tmdbId}/${season}`, API_BASE_URL).href, ({ request }) => {
|
||||||
|
onRequest(new URL(request.url))
|
||||||
|
return HttpResponse.json(response as unknown as JsonBodyType, { status })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ export const subscribeApiUrls = {
|
|||||||
list: new URL('subscribe/', API_BASE_URL).href,
|
list: new URL('subscribe/', API_BASE_URL).href,
|
||||||
orderConfig: (type: SubscribeMediaType) =>
|
orderConfig: (type: SubscribeMediaType) =>
|
||||||
new URL(`user/config/${type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'}`, API_BASE_URL).href,
|
new URL(`user/config/${type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'}`, API_BASE_URL).href,
|
||||||
|
resetById: (id: number) => new URL(`subscribe/reset/${id}`, API_BASE_URL).href,
|
||||||
|
searchById: (id: number) => new URL(`subscribe/search/${id}`, API_BASE_URL).href,
|
||||||
sites: new URL('site/rss', API_BASE_URL).href,
|
sites: new URL('site/rss', API_BASE_URL).href,
|
||||||
statusById: (id: number) => new URL(`subscribe/status/${id}`, API_BASE_URL).href,
|
statusById: (id: number) => new URL(`subscribe/status/${id}`, API_BASE_URL).href,
|
||||||
update: new URL('subscribe/', API_BASE_URL).href,
|
update: new URL('subscribe/', API_BASE_URL).href,
|
||||||
@@ -86,6 +88,30 @@ export function updateSubscribeStatusHandler(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function searchSubscribeByIdHandler(
|
||||||
|
id: number,
|
||||||
|
response: SubscribeMutationResponse = { success: true },
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(subscribeApiUrls.searchById(id), ({ request }) => {
|
||||||
|
onRequest(new URL(request.url))
|
||||||
|
return jsonResponse(response, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSubscribeByIdHandler(
|
||||||
|
id: number,
|
||||||
|
response: SubscribeMutationResponse = { success: true },
|
||||||
|
status = 200,
|
||||||
|
onRequest: (url: URL) => void = () => {},
|
||||||
|
) {
|
||||||
|
return http.get(subscribeApiUrls.resetById(id), ({ request }) => {
|
||||||
|
onRequest(new URL(request.url))
|
||||||
|
return jsonResponse(response, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function createSubscribeHandler(
|
export function createSubscribeHandler(
|
||||||
response: SubscribeMutationResponse = { data: { id: 1 }, success: true },
|
response: SubscribeMutationResponse = { data: { id: 1 }, success: true },
|
||||||
status = 200,
|
status = 200,
|
||||||
|
|||||||
@@ -278,8 +278,10 @@ export default defineConfig(({ mode }) => ({
|
|||||||
'src/pages/recommend.vue',
|
'src/pages/recommend.vue',
|
||||||
'src/pages/subscribe.vue',
|
'src/pages/subscribe.vue',
|
||||||
'src/views/dashboard/MediaRecommend.vue',
|
'src/views/dashboard/MediaRecommend.vue',
|
||||||
|
'src/views/subscribe/FullCalendarView.vue',
|
||||||
'src/views/subscribe/SubscribeListView.vue',
|
'src/views/subscribe/SubscribeListView.vue',
|
||||||
'src/composables/useMediaSubscribe.ts',
|
'src/composables/useMediaSubscribe.ts',
|
||||||
|
'src/components/cards/SubscribeCard.vue',
|
||||||
'src/components/dialog/SubscribeEditDialog.vue',
|
'src/components/dialog/SubscribeEditDialog.vue',
|
||||||
],
|
],
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
@@ -290,6 +292,12 @@ export default defineConfig(({ mode }) => ({
|
|||||||
functions: 85,
|
functions: 85,
|
||||||
lines: 85,
|
lines: 85,
|
||||||
statements: 85,
|
statements: 85,
|
||||||
|
'src/components/cards/SubscribeCard.vue': {
|
||||||
|
branches: 75,
|
||||||
|
functions: 80,
|
||||||
|
lines: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
'src/components/dialog/SubscribeEditDialog.vue': {
|
'src/components/dialog/SubscribeEditDialog.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
@@ -338,6 +346,12 @@ export default defineConfig(({ mode }) => ({
|
|||||||
lines: 80,
|
lines: 80,
|
||||||
statements: 80,
|
statements: 80,
|
||||||
},
|
},
|
||||||
|
'src/views/subscribe/FullCalendarView.vue': {
|
||||||
|
branches: 85,
|
||||||
|
functions: 90,
|
||||||
|
lines: 90,
|
||||||
|
statements: 90,
|
||||||
|
},
|
||||||
'src/views/subscribe/SubscribeListView.vue': {
|
'src/views/subscribe/SubscribeListView.vue': {
|
||||||
branches: 75,
|
branches: 75,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user