topfans/frontend/components/lenticular/LenticularCard.vue

353 lines
7.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="card-container">
<view
:id="cardId"
class="card-frame"
@touchstart.stop="onFrameTouchStart"
@touchmove.stop.prevent="onFrameTouchMove"
@touchend.stop="onFrameTouchEnd"
@touchcancel.stop="onFrameTouchEnd"
>
<view class="card-body" :style="cardRotateStyle">
<view
v-for="layer in layers"
:key="layer.id"
class="card-layer"
:style="getLayerStyle(layer)"
>
<image
v-if="layer.src"
class="card-layer-img"
:src="layer.src"
mode="aspectFill"
/>
<template v-else>
<view
v-for="(dot, i) in layer.dots || []"
:key="i"
class="card-layer-dot"
:style="getDotStyle(dot)"
/>
</template>
</view>
<view class="lenticular-shimmer" :style="shimmerStyle" />
<view class="lenticular-tint" />
<view class="glass-rim" />
<view class="vignette" />
</view>
<view v-if="showHint && tiltHintText" class="tilt-hint">
<text class="tilt-hint-icon">↻</text>
<text class="tilt-hint-text">{{ tiltHintText }}</text>
<text v-if="approximatePreview" class="tilt-hint-sub">叠化近似预览倾斜或拖动体验光栅效果</text>
</view>
</view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, onMounted, ref, watch } from 'vue'
const props = defineProps({
layers: { type: Array, required: true },
transforms: { type: Object, required: true },
gyroSource: { type: String, default: 'simulation' },
approximatePreview: { type: Boolean, default: true },
skipBuiltInTouch: { type: Boolean, default: false },
tiltHintText: { type: String, default: '倾斜手机预览' },
})
const emit = defineEmits(['simulate'])
const showHint = ref(true)
const cardId = `lcard-${Math.random().toString(36).slice(2, 9)}`
const cardRect = ref(null)
/** Vue3 + unicreateSelectorQuery().in() 必须传组件 public 实例proxy传 internal instance 会运行时报错导致整页白屏 */
const pageProxy = getCurrentInstance()?.proxy
const MOTION_HIDE_PX = 0.45
function shouldHideTiltHint() {
if (
props.gyroSource === 'accelerometer' ||
props.gyroSource === 'gyroscope' ||
props.gyroSource === 'orientation'
) {
return true
}
return Object.values(props.transforms || {}).some((t) => Math.abs(t.x) > MOTION_HIDE_PX)
}
watch(
() => props.gyroSource,
() => {
if (shouldHideTiltHint()) showHint.value = false
},
{ immediate: true }
)
watch(
() => props.transforms,
() => {
if (shouldHideTiltHint()) showHint.value = false
},
{ deep: true }
)
const tiltVisualX = computed(() => {
let num = 0
let den = 0
for (const layer of props.layers) {
const t = props.transforms[layer.id]
if (!t) continue
const w = Math.max(0.06, t.opacity)
num += t.x * w
den += w
}
return den > 1e-6 ? num / den : 0
})
const cardRotateStyle = computed(() => {
const x = tiltVisualX.value
const rotateY = (x / 120) * 12
return {
transform: `perspective(1000px) rotateY(${rotateY}deg) rotateX(0deg)`,
}
})
const shimmerStyle = computed(() => {
const x = tiltVisualX.value
const angle = 135 + (x / 120) * 60
return {
background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,0.10) 50%, transparent 70%)`,
}
})
function getLayerStyle(layer) {
const t = props.transforms[layer.id]
const baseOpacity = t ? t.opacity : layer.opacity
const x = t ? t.x : 0
// 不在行内样式写 mix-blend-mode部分小程序 / WebView 对非 normal 支持差,可能引发渲染异常
return {
opacity: baseOpacity,
transform: `translate3d(${x}px, 0, 0)`,
background: layer.background || 'transparent',
}
}
function getDotStyle(dot) {
const color = dot.color || '#dae2fd'
return {
left: `${dot.x}%`,
top: `${dot.y}%`,
width: `${dot.r * 2}px`,
height: `${dot.r * 2}px`,
background: color,
opacity: dot.opacity != null ? dot.opacity : 1,
boxShadow: `0 0 ${dot.r * 3}px ${color}`,
}
}
function refreshRect() {
return new Promise((resolve) => {
try {
const query = pageProxy ? uni.createSelectorQuery().in(pageProxy) : uni.createSelectorQuery()
query
.select(`#${cardId}`)
.boundingClientRect((rect) => {
if (rect && rect.width) {
cardRect.value = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}
}
resolve()
})
.exec()
} catch (e) {
console.warn('[LenticularCard] refreshRect failed', e)
resolve()
}
})
}
function dispatch(clientX, clientY) {
const rect = cardRect.value
if (!rect || !rect.width || !rect.height) {
void refreshRect()
return
}
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const nx = ((clientX - cx) / (rect.width / 2)) * 1.1
const ny = ((clientY - cy) / (rect.height / 2)) * 1.1
emit(
'simulate',
Math.max(-1, Math.min(1, nx)),
Math.max(-1, Math.min(1, ny))
)
}
function onTouchMove(e) {
if (props.skipBuiltInTouch) return
const touch = e.touches && e.touches[0] ? e.touches[0] : e.changedTouches && e.changedTouches[0] ? e.changedTouches[0] : null
if (!touch) return
dispatch(touch.clientX, touch.clientY)
}
function onFrameTouchStart(e) {
if (props.skipBuiltInTouch) return
onTouchMove(e)
}
function onFrameTouchMove(e) {
if (props.skipBuiltInTouch) return
onTouchMove(e)
}
function onFrameTouchEnd() {
if (props.skipBuiltInTouch) return
emit('simulate', 0, 0)
}
onMounted(() => {
setTimeout(() => {
void refreshRect()
}, 0)
})
</script>
<style scoped>
.card-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
perspective: 1000px;
}
.card-frame {
position: relative;
height: 100%;
max-height: 100%;
aspect-ratio: 3 / 4;
width: auto;
max-width: min(420px, 94vw);
}
.card-body {
position: absolute;
inset: 0;
border-radius: 24px;
overflow: hidden;
transform-style: preserve-3d;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.18);
background-color: #060e20;
will-change: transform;
}
.card-layer {
position: absolute;
top: -6%;
left: -6%;
width: 112%;
height: 112%;
will-change: transform, opacity;
background-size: cover;
background-position: center;
}
.card-layer-img {
width: 100%;
height: 100%;
}
.card-layer-dot {
position: absolute;
border-radius: 50%;
transform: translate(-50%, -50%);
}
.lenticular-shimmer {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.85;
}
.lenticular-tint {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
135deg,
rgba(221, 183, 255, 0.1) 0%,
transparent 35%,
transparent 65%,
rgba(76, 215, 246, 0.1) 100%
);
opacity: 0.6;
}
.glass-rim {
position: absolute;
inset: 0;
border-radius: 24px;
border-top: 1px solid rgba(221, 183, 255, 0.42);
box-shadow: inset 0 0 40px rgba(255, 255, 255, 0.05);
pointer-events: none;
}
.vignette {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(6, 14, 32, 0.82), transparent 50%);
pointer-events: none;
}
.tilt-hint {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
background: rgba(45, 52, 73, 0.6);
backdrop-filter: blur(12px);
padding: 14px 22px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
pointer-events: none;
}
.tilt-hint-icon {
font-size: 28px;
line-height: 1;
color: #ddb7ff;
}
.tilt-hint-text {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
color: #dae2fd;
}
.tilt-hint-sub {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
color: rgba(207, 194, 214, 0.75);
text-align: center;
max-width: 200px;
line-height: 1.35;
}
</style>