mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +08:00
feat: 让应用 Logo 跟随主题色 (#538)
* fix(login): show form with logo * feat(theme): sync logos with primary color * test(theme): cover favicon color sync * chore(login): remove unused metal logo component --------- Co-authored-by: jxxghp <jxxghp@gmail.com>
This commit is contained in:
+83
-3
@@ -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" />
|
||||||
@@ -173,6 +173,30 @@
|
|||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-logo__mark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports ((-webkit-mask-image: url('/logo.svg')) or (mask-image: url('/logo.svg'))) {
|
||||||
|
.loading-logo img {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-logo__mark {
|
||||||
|
display: block;
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
color-mix(in srgb, var(--initial-loader-color) 48%, white) 0%,
|
||||||
|
var(--initial-loader-color) 48%,
|
||||||
|
color-mix(in srgb, var(--initial-loader-color) 72%, black) 100%
|
||||||
|
);
|
||||||
|
block-size: min(160px, 36vw);
|
||||||
|
inline-size: min(160px, 36vw);
|
||||||
|
mask: url('/logo.svg') center / contain no-repeat;
|
||||||
|
-webkit-mask: url('/logo.svg') center / contain no-repeat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.loading-footer {
|
.loading-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -362,6 +386,60 @@
|
|||||||
document.head.appendChild(meta)
|
document.head.appendChild(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let faviconSourceImage
|
||||||
|
let pendingFaviconColor = '#9155FD'
|
||||||
|
|
||||||
|
function mixFaviconColor(hexColor, target, amount) {
|
||||||
|
const normalized = hexColor.replace('#', '')
|
||||||
|
const source = [0, 2, 4].map(offset => Number.parseInt(normalized.slice(offset, offset + 2), 16))
|
||||||
|
|
||||||
|
return `rgb(${source.map((channel, index) => Math.round(channel + (target[index] - channel) * amount)).join(', ')})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab 图标使用小尺寸位图承载主题色,避免 favicon 内部 SVG 无法继承页面 CSS 变量。
|
||||||
|
function syncThemeFavicon(primaryColor) {
|
||||||
|
if (!/^#[0-9a-f]{6}$/i.test(primaryColor)) return
|
||||||
|
|
||||||
|
pendingFaviconColor = primaryColor
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
const faviconLink = document.querySelector('#theme-favicon')
|
||||||
|
if (!faviconLink || !faviconSourceImage?.naturalWidth) return
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) return
|
||||||
|
|
||||||
|
canvas.width = 64
|
||||||
|
canvas.height = 64
|
||||||
|
context.drawImage(faviconSourceImage, 0, 0, 64, 64)
|
||||||
|
context.globalCompositeOperation = 'source-in'
|
||||||
|
|
||||||
|
const gradient = context.createLinearGradient(10, 6, 54, 58)
|
||||||
|
gradient.addColorStop(0, mixFaviconColor(pendingFaviconColor, [255, 255, 255], 0.38))
|
||||||
|
gradient.addColorStop(0.48, pendingFaviconColor)
|
||||||
|
gradient.addColorStop(1, mixFaviconColor(pendingFaviconColor, [0, 0, 0], 0.28))
|
||||||
|
context.fillStyle = gradient
|
||||||
|
context.fillRect(0, 0, 64, 64)
|
||||||
|
|
||||||
|
faviconLink.setAttribute('type', 'image/png')
|
||||||
|
faviconLink.setAttribute('href', canvas.toDataURL('image/png'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!faviconSourceImage) {
|
||||||
|
faviconSourceImage = new Image()
|
||||||
|
faviconSourceImage.onload = render
|
||||||
|
faviconSourceImage.src = '/logo.svg'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (faviconSourceImage.complete) render()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +469,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,
|
||||||
@@ -551,6 +630,7 @@
|
|||||||
<div class="loading-logo">
|
<div class="loading-logo">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
|
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
|
||||||
|
<span class="loading-logo__mark" role="img" aria-label="MoviePilot"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="loading-footer">
|
<div class="loading-footer">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -71,7 +71,7 @@ onBeforeUnmount(() => {
|
|||||||
@pointermove="handlePointerMove"
|
@pointermove="handlePointerMove"
|
||||||
@pointerleave="resetPointerResponse"
|
@pointerleave="resetPointerResponse"
|
||||||
>
|
>
|
||||||
<img :src="logoUrl" class="prismatic-logo__base" alt="" draggable="false" aria-hidden="true" />
|
<span class="prismatic-logo__base" aria-hidden="true" />
|
||||||
<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" />
|
||||||
@@ -131,14 +131,19 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prismatic-logo__base {
|
.prismatic-logo__base {
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
color-mix(in srgb, rgb(var(--v-theme-primary)) 46%, white) 0%,
|
||||||
|
rgb(var(--v-theme-primary)) 48%,
|
||||||
|
color-mix(in srgb, rgb(var(--v-theme-primary)) 74%, black) 100%
|
||||||
|
);
|
||||||
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;
|
|
||||||
padding: 7px;
|
|
||||||
transform: translateZ(10px);
|
transform: translateZ(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prismatic-logo__base,
|
||||||
.prismatic-logo__spectrum,
|
.prismatic-logo__spectrum,
|
||||||
.prismatic-logo__specular,
|
.prismatic-logo__specular,
|
||||||
.prismatic-logo__reveal {
|
.prismatic-logo__reveal {
|
||||||
@@ -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%;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import logoUrl from '@images/logo.svg'
|
||||||
|
|
||||||
|
/** 使用当前 Vuetify 主题主色渲染非 WebGL 场景中的 MoviePilot 标识。 */
|
||||||
|
const logoMaskStyle = {
|
||||||
|
'--theme-logo-mask': `url("${logoUrl}")`,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="theme-logo-mark" :style="logoMaskStyle" role="img" aria-label="MoviePilot" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.theme-logo-mark {
|
||||||
|
display: inline-block;
|
||||||
|
flex: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
color-mix(in srgb, rgb(var(--v-theme-primary)) 48%, white) 0%,
|
||||||
|
rgb(var(--v-theme-primary)) 48%,
|
||||||
|
color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, black) 100%
|
||||||
|
);
|
||||||
|
block-size: 3em;
|
||||||
|
inline-size: 3em;
|
||||||
|
mask: var(--theme-logo-mask) center / contain no-repeat;
|
||||||
|
-webkit-mask: var(--theme-logo-mask) center / contain no-repeat;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@ 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'
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1217,7 +1217,6 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.login-title {
|
.login-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 200ms both;
|
|
||||||
background: linear-gradient(135deg, rgb(var(--v-theme-on-surface)) 30%, rgba(var(--v-theme-primary), 1) 100%);
|
background: linear-gradient(135deg, rgb(var(--v-theme-on-surface)) 30%, rgba(var(--v-theme-primary), 1) 100%);
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
font-size: 1.85rem;
|
font-size: 1.85rem;
|
||||||
@@ -1231,7 +1230,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-subtitle {
|
.login-subtitle {
|
||||||
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 300ms both;
|
|
||||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -1243,7 +1241,6 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
/* ===================== 卡片主体 ===================== */
|
/* ===================== 卡片主体 ===================== */
|
||||||
.login-body {
|
.login-body {
|
||||||
/* 表单控件必须首帧可见,卡片自身的入场动画已提供整体过渡。 */
|
|
||||||
padding-block: 8px !important;
|
padding-block: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1530,7 +1527,6 @@ onUnmounted(() => {
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
margin-block-start: 14px;
|
margin-block-start: 14px;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
animation: text-enter 600ms cubic-bezier(0.16, 1, 0.3, 1) 520ms both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-version {
|
.login-version {
|
||||||
@@ -1555,18 +1551,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes text-enter {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===================== 无障碍:尊重减少动态偏好 ===================== */
|
/* ===================== 无障碍:尊重减少动态偏好 ===================== */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.login-card--enter,
|
.login-card--enter,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user