topfans/frontend/components/lenticular/LenticularCard.vue

506 lines
14 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"
@error="onImageError"
@load="onImageLoad"
/>
<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>
<ManualTiltSlider
v-if="showSlider"
:value="sliderValue"
@change="onSliderChange"
@reset="onSliderReset"
/>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, onMounted, onUnmounted, ref, watch } from 'vue'
import ManualTiltSlider from './ManualTiltSlider.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: '倾斜手机预览' },
/** 条纹高光中线不透明度 0~1略提可增强光栅「栅感」 */
shimmerMidOpacity: { type: Number, default: 0.1 },
// 新增手动滑块接入spec §3.3
showSlider: { type: Boolean, default: false },
// 新增:引擎接口(由父组件从 useLenticularCraftTiltPreview / useLenticularPreview 等注入)
// Function 类型 prop 必须用 factory 返回默认函数,避免所有实例共享同一引用
simulateTilt: { type: Function, default: () => () => {} },
simulateTiltFromNormalized:{ type: Function, default: () => () => {} },
simulateTiltReset: { type: Function, default: () => () => {} },
// 新增:自动控制
autoEnabled: { type: Boolean, default: true },
// auto 周期sin 波一个完整循环的毫秒数。10000 = 10s 一周,再慢会感觉拖沓/卡帧
autoPeriodMs: { type: Number, default: 15000 },
idleResumeMs: { type: Number, default: 1500 },
})
// 保留 @simulate 事件以兼容其他不传引擎 props 的消费者
// (他们 listen @simulate 自己驱动引擎;本组件内已用 props.simulateTilt* 直接驱动)
// 新增 @mode-change控制模式切换时通知父组件auto ↔ manual
const emit = defineEmits(['simulate', 'modeChange'])
/** Vue3 + unicreateSelectorQuery().in() 必须传组件 public 实例proxy传 internal instance 会运行时报错导致整页白屏 */
const pageProxy = getCurrentInstance()?.proxy
const showHint = ref(true)
const cardId = `lcard-${Math.random().toString(36).slice(2, 9)}`
const cardRect = ref(null)
// 内部滑块值auto / 触摸 / 滑块拖动 / 复位 都在这里写)
const sliderValue = ref(0.5)
function onImageError(e) {
// console.log('[LenticularCard] image error:', e)
}
function onImageLoad(e) {
// console.log('[LenticularCard] image loaded')
}
const MOTION_HIDE_PX = 0.45
/** 仅在画面因倾斜产生可见位移后隐藏提示(非陀螺仪连接即隐藏) */
function shouldHideTiltHint() {
return Object.values(props.transforms || {}).some((t) => Math.abs(t.x) > MOTION_HIDE_PX)
}
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
const a = Math.max(0, Math.min(0.35, Number(props.shimmerMidOpacity) || 0.1))
return {
background: `linear-gradient(${angle}deg, transparent 30%, rgba(221,183,255,${a}) 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()
}
})
}
// ====================================================================
// 自动控制状态机auto ↔ manual + idle 恢复)
// ====================================================================
const controlMode = ref('auto') // 'auto' | 'manual'
const lastAutoWriteTime = ref(0) // auto 最近一次写滑块值的时间戳v-model 回声时间窗检测用)
let idleAccumMs = 0 // manual 模式下空闲累计时长
let autoPhase = 0 // sin 相位
let lastTickTime = null // 上一次 tick 的时间戳
let rafId = null // rAF 句柄
// uniapp 小程序运行时app-service.jsperformance 和 requestAnimationFrame 都不存在
// 用 setTimeout 递归调度60fps ≈ 16ms/帧),跨平台稳
// 时间戳统一用 Date.now()(不依赖 performance
const nextFrame = (cb) => setTimeout(cb, 16)
const cancelFrame = (id) => clearTimeout(id)
/**
* auto tickrAF 循环的唯一调度点
* - auto 模式:按 dt 推进 sin 相位,写滑块 + 引擎
* - manual 模式:累计空闲时长,达到 idleResumeMs 时 snap 相位到当前滑块并切回 auto
*/
function autoTick(t) {
if (!props.autoEnabled) return
if (lastTickTime == null) lastTickTime = t
const dt = t - lastTickTime
lastTickTime = t
if (controlMode.value !== 'auto') {
idleAccumMs += dt
if (idleAccumMs >= props.idleResumeMs) {
const targetSin = Math.max(-1, Math.min(1, 2 * sliderValue.value - 1))
autoPhase = Math.asin(targetSin)
lastTickTime = null
idleAccumMs = 0
controlMode.value = 'auto'
emit('modeChange', 'auto')
}
return
}
idleAccumMs = 0
autoPhase += (dt / props.autoPeriodMs) * 2 * Math.PI
if (Math.abs(autoPhase) > 100 * 2 * Math.PI) {
autoPhase = autoPhase % (2 * Math.PI)
}
// 用 sin 波 + 宽摆幅AMP=0.5)让 auto 真正触到 A=100% / B=100% 状态
// sin 在极值点速度=0会"停留"在端点,让用户看清 A=100% / B=100%
// 配合 blendBaseScale=5 + 线性权重,端点附近叠化也很明显,不是硬切
const AUTO_SWING_AMP = 0.3 // 满摆幅v ∈ [0, 1],覆盖完整 0/100% 状态
const sinVal = Math.sin(autoPhase)
const v = 0.5 + sinVal * AUTO_SWING_AMP
sliderValue.value = v
lastAutoWriteTime.value = Date.now()
// 走归一化版本(兼容两种消费者:
// - 传 simulateTiltFromNormalized离散 snap直接 snap
// - 只传 simulate连续simulateTiltFromNormalized 默认是 no-op
// 这种场景下消费者需要额外传 simulate但我们让 auto 优先尝试归一化接口)
const x = v * 2 - 1
props.simulateTiltFromNormalized?.(x, 0)
}
/** 启动 rAF 循环(幂等)。
* 时间戳统一用 Date.now(),避免 uniapp 真机 performance 缺失导致的 ReferenceError。
*/
function startAutoLoop() {
if (rafId != null) return
const tickFn = () => {
autoTick(Date.now())
rafId = nextFrame(tickFn)
}
rafId = nextFrame(tickFn)
}
/** 停止 rAF 循环 */
function stopAutoLoop() {
if (rafId != null) {
cancelFrame(rafId)
rafId = null
}
}
/** 用户接管 → 暂停 auto + 重置空闲计时器 */
function pauseAutoAndScheduleResume() {
if (controlMode.value !== 'manual') {
controlMode.value = 'manual'
emit('modeChange', 'manual')
}
idleAccumMs = 0
}
// ====================================================================
// 触摸 / 滑块 / 复位:组件内部直接调引擎函数(通过 props 注入)
// ====================================================================
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
handleTouch(Math.max(-1, Math.min(1, nx)), Math.max(-1, Math.min(1, ny)))
}
function handleTouch(x, y) {
pauseAutoAndScheduleResume()
sliderValue.value = Math.max(0, Math.min(1, (x + 1) / 2))
props.simulateTiltFromNormalized?.(x, y)
// 向后兼容emit 给其他不传引擎 props 的消费者
emit('simulate', x, y)
}
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
// 不发 (0,0),由 auto / 引擎自身状态决定
}
/**
* 滑块变化:
* - auto 自己写滑块触发的 v-model/原生 change 回声(时间窗 100ms 内)→ 不暂停 auto
* - 用户拖动 → 暂停 auto + 驱动引擎
*/
function onSliderChange(v) {
sliderValue.value = v
const isAutoEcho = Date.now() - lastAutoWriteTime.value < 100
if (!isAutoEcho) {
pauseAutoAndScheduleResume()
const dx = (v - 0.5) * 90
props.simulateTilt?.(dx, 0)
}
}
/** 复位按钮:暂停 auto + 滑块回 0.5 + 引擎归中 */
function onSliderReset() {
pauseAutoAndScheduleResume()
sliderValue.value = 0.5
props.simulateTiltReset?.()
}
onMounted(() => {
setTimeout(() => {
void refreshRect()
}, 0)
if (props.autoEnabled) {
startAutoLoop()
}
})
onUnmounted(() => {
stopAutoLoop()
})
</script>
<style scoped>
.card-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
perspective: 1000px;
position: relative;
}
.card-frame {
position: relative;
height: 100%;
width: 100%;
overflow: visible;
}
.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: 100%;
height: 100%;
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>