mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
5 Commits
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wechat-data-analysis-desktop",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"main": "src/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -s first \"cd ..\\\\frontend && npm run dev\" \"cross-env ELECTRON_START_URL=http://localhost:3000 electron .\"",
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
暂无可展示的排行榜数据。
|
||||
</div>
|
||||
|
||||
<div v-else class="race-scroll mt-4 max-h-[26rem] overflow-auto pr-1">
|
||||
<div v-else class="race-scroll mt-4 max-h-[26rem] overflow-y-auto overflow-x-hidden pr-1">
|
||||
<TransitionGroup
|
||||
name="race"
|
||||
tag="div"
|
||||
@@ -258,8 +258,7 @@ const props = defineProps({
|
||||
|
||||
const { theme } = useWrappedTheme()
|
||||
const isGameboy = computed(() => theme.value === 'gameboy')
|
||||
const isDos = computed(() => theme.value === 'dos')
|
||||
const isRetro = computed(() => isGameboy.value || isDos.value)
|
||||
const isRetro = computed(() => isGameboy.value)
|
||||
|
||||
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||||
@@ -756,6 +755,16 @@ onBeforeUnmount(() => {
|
||||
transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||||
}
|
||||
|
||||
.race-scroll {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.race-scroll::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.race-move {
|
||||
transition: transform 350ms cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="bits-card-swap absolute bottom-0 right-0 translate-x-[24%] translate-y-[-2%] origin-bottom-right overflow-visible [perspective:900px]"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(_, index) in visibleCardCount"
|
||||
:key="index"
|
||||
ref="cardRefs"
|
||||
class="bits-card-swap-item absolute top-1/2 left-1/2 rounded-xl [transform-style:preserve-3d] [will-change:transform] [backface-visibility:hidden]"
|
||||
:style="cardStyle"
|
||||
@click="onCardClick(index)"
|
||||
>
|
||||
<slot :name="`card-${index}`" :index="index" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import gsap from 'gsap'
|
||||
|
||||
const props = defineProps({
|
||||
cardCount: { type: Number, default: 3 },
|
||||
width: { type: [Number, String], default: 250 },
|
||||
height: { type: [Number, String], default: 300 },
|
||||
cardDistance: { type: Number, default: 24 },
|
||||
verticalDistance: { type: Number, default: 34 },
|
||||
delay: { type: Number, default: 4200 },
|
||||
pauseOnHover: { type: Boolean, default: false },
|
||||
onCardClick: { type: Function, default: null },
|
||||
skewAmount: { type: Number, default: 4 },
|
||||
easing: { type: String, default: 'elastic' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['cardClick'])
|
||||
|
||||
const containerRef = ref(null)
|
||||
const cardRefs = ref([])
|
||||
const order = ref([0, 1, 2])
|
||||
const timelineRef = ref(null)
|
||||
let intervalRef = null
|
||||
|
||||
const toPx = (value) => (typeof value === 'number' ? `${value}px` : value)
|
||||
|
||||
const containerStyle = computed(() => ({
|
||||
width: toPx(props.width),
|
||||
height: toPx(props.height)
|
||||
}))
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
width: toPx(props.width),
|
||||
height: toPx(props.height)
|
||||
}))
|
||||
|
||||
const visibleCardCount = computed(() => {
|
||||
const count = Number(props.cardCount)
|
||||
if (!Number.isFinite(count)) return 1
|
||||
const normalized = Math.floor(count)
|
||||
return Math.max(1, normalized)
|
||||
})
|
||||
|
||||
const config = computed(() => {
|
||||
if (props.easing === 'elastic') {
|
||||
return {
|
||||
ease: 'elastic.out(0.6,0.9)',
|
||||
dropDuration: 1.8,
|
||||
moveDuration: 1.8,
|
||||
returnDuration: 1.8,
|
||||
overlap: 0.85,
|
||||
returnDelay: 0.08
|
||||
}
|
||||
}
|
||||
return {
|
||||
ease: 'power1.inOut',
|
||||
dropDuration: 0.8,
|
||||
moveDuration: 0.8,
|
||||
returnDuration: 0.8,
|
||||
overlap: 0.45,
|
||||
returnDelay: 0.2
|
||||
}
|
||||
})
|
||||
|
||||
const makeSlot = (index, total) => ({
|
||||
x: index * props.cardDistance,
|
||||
y: -index * props.verticalDistance,
|
||||
z: -index * props.cardDistance * 1.5,
|
||||
zIndex: total - index
|
||||
})
|
||||
|
||||
const placeNow = (element, slot) => {
|
||||
gsap.set(element, {
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
xPercent: -50,
|
||||
yPercent: -50,
|
||||
skewY: props.skewAmount,
|
||||
transformOrigin: 'center center',
|
||||
zIndex: slot.zIndex,
|
||||
force3D: true
|
||||
})
|
||||
}
|
||||
|
||||
const initializeCards = () => {
|
||||
const list = cardRefs.value || []
|
||||
if (!list.length) return
|
||||
const total = visibleCardCount.value
|
||||
list.forEach((element, index) => {
|
||||
if (!element) return
|
||||
placeNow(element, makeSlot(index, total))
|
||||
})
|
||||
}
|
||||
|
||||
const updateCardPositions = () => {
|
||||
const list = cardRefs.value || []
|
||||
if (!list.length) return
|
||||
const total = visibleCardCount.value
|
||||
list.forEach((element, index) => {
|
||||
if (!element) return
|
||||
const slot = makeSlot(index, total)
|
||||
gsap.set(element, {
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
skewY: props.skewAmount
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const runSwap = () => {
|
||||
const total = visibleCardCount.value
|
||||
if (order.value.length !== total) {
|
||||
order.value = Array.from({ length: total }, (_, idx) => idx)
|
||||
}
|
||||
const activeOrder = order.value.slice(0, total)
|
||||
if (activeOrder.length < 2) return
|
||||
const [front, ...rest] = activeOrder
|
||||
const frontElement = cardRefs.value[front]
|
||||
if (!frontElement) return
|
||||
|
||||
const tl = gsap.timeline()
|
||||
timelineRef.value = tl
|
||||
|
||||
tl.to(frontElement, {
|
||||
y: '+=480',
|
||||
duration: config.value.dropDuration,
|
||||
ease: config.value.ease
|
||||
})
|
||||
|
||||
tl.addLabel('promote', `-=${config.value.dropDuration * config.value.overlap}`)
|
||||
|
||||
rest.forEach((index, slotIndex) => {
|
||||
const element = cardRefs.value[index]
|
||||
if (!element) return
|
||||
const slot = makeSlot(slotIndex, activeOrder.length)
|
||||
tl.set(element, { zIndex: slot.zIndex }, 'promote')
|
||||
tl.to(
|
||||
element,
|
||||
{
|
||||
x: slot.x,
|
||||
y: slot.y,
|
||||
z: slot.z,
|
||||
duration: config.value.moveDuration,
|
||||
ease: config.value.ease
|
||||
},
|
||||
`promote+=${slotIndex * 0.15}`
|
||||
)
|
||||
})
|
||||
|
||||
const backSlot = makeSlot(activeOrder.length - 1, activeOrder.length)
|
||||
|
||||
tl.addLabel('return', `promote+=${config.value.moveDuration * config.value.returnDelay}`)
|
||||
tl.call(() => {
|
||||
gsap.set(frontElement, { zIndex: backSlot.zIndex })
|
||||
}, undefined, 'return')
|
||||
tl.set(frontElement, { x: backSlot.x, z: backSlot.z }, 'return')
|
||||
tl.to(
|
||||
frontElement,
|
||||
{
|
||||
y: backSlot.y,
|
||||
duration: config.value.returnDuration,
|
||||
ease: config.value.ease
|
||||
},
|
||||
'return'
|
||||
)
|
||||
|
||||
tl.call(() => {
|
||||
order.value = [...rest, front]
|
||||
})
|
||||
}
|
||||
|
||||
const stopAnimation = () => {
|
||||
if (timelineRef.value) {
|
||||
timelineRef.value.kill()
|
||||
timelineRef.value = null
|
||||
}
|
||||
if (intervalRef) {
|
||||
clearInterval(intervalRef)
|
||||
intervalRef = null
|
||||
}
|
||||
}
|
||||
|
||||
const startAnimation = () => {
|
||||
stopAnimation()
|
||||
if (visibleCardCount.value < 2) {
|
||||
initializeCards()
|
||||
return
|
||||
}
|
||||
runSwap()
|
||||
intervalRef = window.setInterval(runSwap, props.delay)
|
||||
}
|
||||
|
||||
const resumeAnimation = () => {
|
||||
timelineRef.value?.play()
|
||||
if (!intervalRef) intervalRef = window.setInterval(runSwap, props.delay)
|
||||
}
|
||||
|
||||
const onMouseEnter = () => {
|
||||
stopAnimation()
|
||||
}
|
||||
|
||||
const onMouseLeave = () => {
|
||||
resumeAnimation()
|
||||
}
|
||||
|
||||
const setupHoverListeners = () => {
|
||||
if (!props.pauseOnHover || !containerRef.value) return
|
||||
containerRef.value.addEventListener('mouseenter', onMouseEnter)
|
||||
containerRef.value.addEventListener('mouseleave', onMouseLeave)
|
||||
}
|
||||
|
||||
const removeHoverListeners = () => {
|
||||
if (!containerRef.value) return
|
||||
containerRef.value.removeEventListener('mouseenter', onMouseEnter)
|
||||
containerRef.value.removeEventListener('mouseleave', onMouseLeave)
|
||||
}
|
||||
|
||||
const onCardClick = (index) => {
|
||||
emit('cardClick', index)
|
||||
if (typeof props.onCardClick === 'function') props.onCardClick(index)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.cardDistance, props.verticalDistance, props.skewAmount],
|
||||
() => {
|
||||
updateCardPositions()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.delay,
|
||||
() => {
|
||||
if (intervalRef) {
|
||||
clearInterval(intervalRef)
|
||||
intervalRef = window.setInterval(runSwap, props.delay)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.pauseOnHover,
|
||||
() => {
|
||||
removeHoverListeners()
|
||||
setupHoverListeners()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
initializeCards()
|
||||
startAnimation()
|
||||
setupHoverListeners()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => visibleCardCount.value,
|
||||
async () => {
|
||||
order.value = Array.from({ length: visibleCardCount.value }, (_, idx) => idx)
|
||||
await nextTick()
|
||||
initializeCards()
|
||||
startAnimation()
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAnimation()
|
||||
removeHoverListeners()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<div class="bits-grid-motion w-full h-full overflow-hidden">
|
||||
<section class="relative flex h-full w-full items-center justify-center overflow-hidden" :style="sectionStyle">
|
||||
<div class="bits-grid-motion-grid">
|
||||
<div
|
||||
v-for="rowIndex in safeRowCount"
|
||||
:key="`row-${rowIndex}`"
|
||||
class="bits-grid-motion-row"
|
||||
:style="rowInlineStyle"
|
||||
>
|
||||
<div
|
||||
v-for="columnIndex in renderColumnCount"
|
||||
:key="`cell-${rowIndex}-${columnIndex}`"
|
||||
class="bits-grid-motion-cell"
|
||||
v-show="loopedItems[resolveIndex(rowIndex, columnIndex)]"
|
||||
>
|
||||
<slot
|
||||
name="item"
|
||||
:item="loopedItems[resolveIndex(rowIndex, columnIndex)]"
|
||||
:index="resolveIndex(rowIndex, columnIndex)"
|
||||
>
|
||||
<div class="bits-grid-motion-fallback">
|
||||
{{ String(loopedItems[resolveIndex(rowIndex, columnIndex)] ?? '') }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bits-grid-motion-mask" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import gsap from 'gsap'
|
||||
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
gradientColor: { type: String, default: 'rgba(7, 193, 96, 0.2)' },
|
||||
rowCount: { type: Number, default: 8 },
|
||||
columnCount: { type: Number, default: 10 },
|
||||
scrollSpeed: { type: Number, default: 38 },
|
||||
baseOffsetX: { type: Number, default: 0 },
|
||||
itemWidth: { type: Number, default: 300 },
|
||||
rowGap: { type: Number, default: 12 }
|
||||
})
|
||||
|
||||
let removeTicker = null
|
||||
let lastTickAt = 0
|
||||
let loopDistance = 0
|
||||
const marqueeX = ref(0)
|
||||
|
||||
const safeRowCount = computed(() => Math.max(1, Number(props.rowCount) || 1))
|
||||
const safeColumnCount = computed(() => Math.max(1, Number(props.columnCount) || 1))
|
||||
const safeItemWidth = computed(() => Math.max(1, Number(props.itemWidth) || 1))
|
||||
const safeRowGap = computed(() => Math.max(0, Number(props.rowGap) || 0))
|
||||
const safeScrollSpeed = computed(() => Math.max(0, Number(props.scrollSpeed) || 0))
|
||||
|
||||
const renderColumnCount = computed(() => safeColumnCount.value * 2)
|
||||
const totalSlots = computed(() => safeRowCount.value * renderColumnCount.value)
|
||||
|
||||
const repeatedItems = computed(() => {
|
||||
const source = Array.isArray(props.items) ? props.items.filter(Boolean) : []
|
||||
if (!source.length) return []
|
||||
const output = []
|
||||
for (let idx = 0; idx < safeRowCount.value * safeColumnCount.value; idx += 1) {
|
||||
output.push(source[idx % source.length])
|
||||
}
|
||||
return output
|
||||
})
|
||||
|
||||
const loopedItems = computed(() => {
|
||||
const base = repeatedItems.value
|
||||
if (!base.length) return []
|
||||
const output = []
|
||||
for (let idx = 0; idx < totalSlots.value; idx += 1) {
|
||||
output.push(base[idx % base.length])
|
||||
}
|
||||
return output
|
||||
})
|
||||
|
||||
const rowInlineStyle = computed(() => ({
|
||||
willChange: 'transform',
|
||||
transform: `translate3d(${props.baseOffsetX + marqueeX.value}px, 0, 0)`
|
||||
}))
|
||||
|
||||
const sectionStyle = computed(() => ({
|
||||
background: `radial-gradient(circle at center, ${props.gradientColor} 0%, transparent 72%)`
|
||||
}))
|
||||
|
||||
const resolveIndex = (rowIndex, columnIndex) => (
|
||||
(Number(rowIndex) - 1) * renderColumnCount.value + (Number(columnIndex) - 1)
|
||||
)
|
||||
|
||||
const updateMotion = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||
const dt = lastTickAt > 0 ? Math.min((now - lastTickAt) / 1000, 0.08) : 0
|
||||
lastTickAt = now
|
||||
|
||||
if (loopDistance <= 0 || safeScrollSpeed.value <= 0 || dt <= 0) return
|
||||
|
||||
marqueeX.value -= safeScrollSpeed.value * dt
|
||||
if (marqueeX.value <= -loopDistance) {
|
||||
marqueeX.value += loopDistance
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
loopDistance = safeColumnCount.value * (safeItemWidth.value + safeRowGap.value)
|
||||
marqueeX.value = 0
|
||||
lastTickAt = 0
|
||||
|
||||
// Kick one frame immediately to avoid initial static delay.
|
||||
marqueeX.value = -Math.min(loopDistance * 0.02, 8)
|
||||
|
||||
gsap.ticker.lagSmoothing(1000, 33)
|
||||
removeTicker = gsap.ticker.add(updateMotion)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
loopDistance = 0
|
||||
lastTickAt = 0
|
||||
if (typeof removeTicker === 'function') removeTicker()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bits-grid-motion-grid {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
width: 180%;
|
||||
height: 165%;
|
||||
transform: rotate(-15deg);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.bits-grid-motion-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bits-grid-motion-cell {
|
||||
position: relative;
|
||||
height: 210px;
|
||||
min-width: 300px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bits-grid-motion-fallback {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(7, 193, 96, 0.2);
|
||||
background: #ffffff;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.bits-grid-motion-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(243, 255, 248, 0.78) 0%, rgba(243, 255, 248, 0.12) 20%, rgba(243, 255, 248, 0) 38%),
|
||||
linear-gradient(90deg, rgba(243, 255, 248, 0.86) 0%, rgba(243, 255, 248, 0.12) 24%, rgba(243, 255, 248, 0) 44%),
|
||||
linear-gradient(270deg, rgba(243, 255, 248, 0.9) 0%, rgba(243, 255, 248, 0.14) 30%, rgba(243, 255, 248, 0) 48%),
|
||||
linear-gradient(0deg, rgba(243, 255, 248, 0.88) 0%, rgba(243, 255, 248, 0.16) 36%, rgba(243, 255, 248, 0) 58%);
|
||||
z-index: 3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<p
|
||||
ref="textRef"
|
||||
class="bits-split-text inline-block overflow-hidden whitespace-normal"
|
||||
:class="className"
|
||||
:style="{ textAlign, wordWrap: 'break-word' }"
|
||||
>
|
||||
{{ text }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
import { SplitText as GSAPSplitText } from 'gsap/SplitText'
|
||||
|
||||
const props = defineProps({
|
||||
text: { type: String, required: true },
|
||||
className: { type: String, default: '' },
|
||||
delay: { type: Number, default: 100 },
|
||||
duration: { type: Number, default: 0.6 },
|
||||
ease: { type: [String, Function], default: 'power3.out' },
|
||||
splitType: { type: String, default: 'chars' },
|
||||
from: { type: Object, default: () => ({ opacity: 0, y: 32 }) },
|
||||
to: { type: Object, default: () => ({ opacity: 1, y: 0 }) },
|
||||
threshold: { type: Number, default: 0.1 },
|
||||
rootMargin: { type: String, default: '-100px' },
|
||||
textAlign: { type: String, default: 'center' },
|
||||
onLetterAnimationComplete: { type: Function, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['animationComplete'])
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger, GSAPSplitText)
|
||||
|
||||
const textRef = ref(null)
|
||||
let timeline = null
|
||||
let scrollTrigger = null
|
||||
let splitter = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeline) {
|
||||
timeline.kill()
|
||||
timeline = null
|
||||
}
|
||||
if (scrollTrigger) {
|
||||
scrollTrigger.kill()
|
||||
scrollTrigger = null
|
||||
}
|
||||
if (splitter) {
|
||||
splitter.revert()
|
||||
splitter = null
|
||||
}
|
||||
}
|
||||
|
||||
const createAnimation = async () => {
|
||||
if (!import.meta.client || !textRef.value || !props.text) return
|
||||
await nextTick()
|
||||
|
||||
const element = textRef.value
|
||||
const absoluteLines = props.splitType === 'lines'
|
||||
if (absoluteLines) element.style.position = 'relative'
|
||||
|
||||
try {
|
||||
splitter = new GSAPSplitText(element, {
|
||||
type: props.splitType,
|
||||
absolute: absoluteLines,
|
||||
linesClass: 'split-line'
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
let targets = splitter.chars
|
||||
if (props.splitType === 'words') targets = splitter.words
|
||||
if (props.splitType === 'lines') targets = splitter.lines
|
||||
if (!targets?.length) {
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
targets.forEach((target) => {
|
||||
target.style.willChange = 'transform, opacity'
|
||||
})
|
||||
|
||||
const startPercent = (1 - props.threshold) * 100
|
||||
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin)
|
||||
const marginValue = marginMatch ? Number.parseFloat(marginMatch[1]) : 0
|
||||
const marginUnit = marginMatch ? marginMatch[2] || 'px' : 'px'
|
||||
const sign = marginValue < 0
|
||||
? `-=${Math.abs(marginValue)}${marginUnit}`
|
||||
: `+=${marginValue}${marginUnit}`
|
||||
|
||||
timeline = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: element,
|
||||
start: `top ${startPercent}%${sign}`,
|
||||
toggleActions: 'play none none none',
|
||||
once: true,
|
||||
onToggle: (self) => {
|
||||
scrollTrigger = self
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
gsap.set(targets, {
|
||||
...props.to,
|
||||
clearProps: 'willChange',
|
||||
immediateRender: true
|
||||
})
|
||||
if (typeof props.onLetterAnimationComplete === 'function') {
|
||||
props.onLetterAnimationComplete()
|
||||
}
|
||||
emit('animationComplete')
|
||||
}
|
||||
})
|
||||
|
||||
timeline.set(targets, {
|
||||
...props.from,
|
||||
immediateRender: false,
|
||||
force3D: true
|
||||
})
|
||||
|
||||
timeline.to(targets, {
|
||||
...props.to,
|
||||
duration: props.duration,
|
||||
ease: props.ease,
|
||||
stagger: props.delay / 1000,
|
||||
force3D: true
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.text,
|
||||
props.delay,
|
||||
props.duration,
|
||||
props.ease,
|
||||
props.splitType,
|
||||
props.from,
|
||||
props.to,
|
||||
props.threshold,
|
||||
props.rootMargin,
|
||||
props.textAlign,
|
||||
props.onLetterAnimationComplete
|
||||
],
|
||||
async () => {
|
||||
cleanup()
|
||||
await createAnimation()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await createAnimation()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,17 @@
|
||||
<template>
|
||||
<!-- CRT 滤镜叠加层 - 复古主题使用 -->
|
||||
<div class="absolute inset-0 pointer-events-none select-none z-30" aria-hidden="true">
|
||||
<!-- Game Boy: noise 作为最前景层统一覆盖整个画面 -->
|
||||
<WrappedGameboyDither
|
||||
v-if="theme === 'gameboy'"
|
||||
class="opacity-[0.3]"
|
||||
style="filter: contrast(1.16)"
|
||||
:pattern-refresh-interval="1"
|
||||
:pattern-alpha="56"
|
||||
mix-blend-mode="overlay"
|
||||
:pattern-size="256"
|
||||
/>
|
||||
|
||||
<!-- 扫描线 / RGB 子像素 / 闪烁 / 暗角 / 曲率 -->
|
||||
<div class="absolute inset-0 crt-scanlines"></div>
|
||||
<div class="absolute inset-0 crt-rgb-pixels"></div>
|
||||
@@ -8,31 +19,9 @@
|
||||
<div class="absolute inset-0 crt-vignette"></div>
|
||||
<div class="absolute inset-0 crt-curvature"></div>
|
||||
|
||||
<!-- DOS: 语义化光标 -->
|
||||
<div v-if="theme === 'dos'" class="dos-cursor">█</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { theme } = useWrappedTheme()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* DOS 语义化光标 */
|
||||
.dos-cursor {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
color: #33ff33;
|
||||
font-size: 1.5rem;
|
||||
font-family: var(--font-pixel-10), 'Courier New', monospace;
|
||||
text-shadow: 0 0 8px rgba(51, 255, 51, 0.6);
|
||||
animation: dos-cursor-blink 530ms steps(1) infinite;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@keyframes dos-cursor-blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -87,8 +87,7 @@ defineProps({
|
||||
const { theme } = useWrappedTheme()
|
||||
const isWin98 = computed(() => theme.value === 'win98')
|
||||
const isGameboy = computed(() => theme.value === 'gameboy')
|
||||
const isDos = computed(() => theme.value === 'dos')
|
||||
const isCompactSlide = computed(() => isGameboy.value || isDos.value)
|
||||
const isCompactSlide = computed(() => isGameboy.value)
|
||||
|
||||
const slideTitleClass = computed(() => (
|
||||
isCompactSlide.value ? 'text-xl sm:text-2xl' : 'text-2xl sm:text-3xl'
|
||||
@@ -142,39 +141,4 @@ const slideContainerClass = computed(() => (
|
||||
border-color: #306230 !important;
|
||||
}
|
||||
|
||||
/* ========== DOS 主题 ========== */
|
||||
|
||||
/* 卡片背景 */
|
||||
.wrapped-theme-dos .bg-white {
|
||||
background: #0a0a0a !important;
|
||||
border-color: #33ff33 !important;
|
||||
box-shadow: 0 0 10px rgba(51, 255, 51, 0.3);
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.wrapped-theme-dos .wrapped-title {
|
||||
color: #33ff33 !important;
|
||||
text-shadow: 0 0 5px #33ff33;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* 描述文字 */
|
||||
.wrapped-theme-dos .wrapped-body {
|
||||
color: #22aa22 !important;
|
||||
text-shadow: 0 0 3px #22aa22;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* 数字高亮 */
|
||||
.wrapped-theme-dos .wrapped-number {
|
||||
color: #33ff33 !important;
|
||||
text-shadow: 0 0 8px #33ff33;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* 边框 */
|
||||
.wrapped-theme-dos .border-\[\#EDEDED\],
|
||||
.wrapped-theme-dos .border-\[\#F3F3F3\] {
|
||||
border-color: #33ff33 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -131,30 +131,6 @@ const yearOptions = computed(() => {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* DOS 特殊样式 */
|
||||
.wrapped-theme-dos .controls-panel {
|
||||
border-radius: 0;
|
||||
border: 2px solid #33ff33;
|
||||
box-shadow: 0 0 10px rgba(51, 255, 51, 0.3);
|
||||
}
|
||||
|
||||
.wrapped-theme-dos .controls-select {
|
||||
border-radius: 0;
|
||||
border: 1px solid #33ff33;
|
||||
text-shadow: 0 0 5px #33ff33;
|
||||
}
|
||||
|
||||
.wrapped-theme-dos .controls-btn {
|
||||
border-radius: 0;
|
||||
background-color: #33ff33;
|
||||
color: #000000;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.wrapped-theme-dos .controls-btn:hover:not(:disabled) {
|
||||
background-color: #44ff44;
|
||||
}
|
||||
|
||||
/* Win98 特殊样式 */
|
||||
.wrapped-theme-win98 .controls-panel {
|
||||
border-radius: 0;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<!-- Shared backdrop for all "Wrapped" slides (keeps cover + cards visually consistent). -->
|
||||
<!-- Shared backdrop for modern/gameboy "Wrapped" slides (keeps cover + cards visually consistent). -->
|
||||
<div v-if="theme !== 'win98'" class="absolute inset-0 pointer-events-none select-none z-0" aria-hidden="true">
|
||||
<!-- Soft color blobs (brand + warm highlights) -->
|
||||
<div class="absolute -top-24 -left-24 w-80 h-80 bg-[#07C160] opacity-[0.08] rounded-full blur-3xl"></div>
|
||||
@@ -11,8 +11,17 @@
|
||||
class="absolute inset-0 bg-[linear-gradient(rgba(7,193,96,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(7,193,96,0.05)_1px,transparent_1px)] bg-[size:52px_52px] opacity-[0.28]"
|
||||
></div>
|
||||
|
||||
<!-- Grain/noise: enhanced with dynamic jitter for CRT feel -->
|
||||
<div class="absolute inset-0 wrapped-noise-enhanced opacity-[0.08]"></div>
|
||||
<!-- Grain/noise: gameboy 使用动态 canvas 噪点,其它主题沿用现有纹理 -->
|
||||
<WrappedGameboyDither
|
||||
v-if="theme === 'gameboy'"
|
||||
class="opacity-[0.3]"
|
||||
style="filter: contrast(1.16)"
|
||||
:pattern-refresh-interval="1"
|
||||
:pattern-alpha="56"
|
||||
mix-blend-mode="overlay"
|
||||
:pattern-size="256"
|
||||
/>
|
||||
<div v-else class="absolute inset-0 wrapped-noise-enhanced opacity-[0.08]"></div>
|
||||
|
||||
<!-- Gentle vignette so typography stays readable on textured bg -->
|
||||
<div class="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-white/50 to-transparent"></div>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<canvas
|
||||
ref="grainRef"
|
||||
class="pointer-events-none absolute inset-0 h-full w-full"
|
||||
:style="canvasStyle"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
patternRefreshInterval: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
patternAlpha: {
|
||||
type: Number,
|
||||
default: 20
|
||||
},
|
||||
mixBlendMode: {
|
||||
type: String,
|
||||
default: 'multiply'
|
||||
},
|
||||
patternSize: {
|
||||
type: Number,
|
||||
default: 512
|
||||
}
|
||||
})
|
||||
|
||||
const grainRef = ref(null)
|
||||
|
||||
const canvasStyle = computed(() => `image-rendering: pixelated; mix-blend-mode: ${props.mixBlendMode};`)
|
||||
|
||||
let animationId = 0
|
||||
let frame = 0
|
||||
let noiseData
|
||||
let noise32
|
||||
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
|
||||
|
||||
const resize = () => {
|
||||
const canvas = grainRef.value
|
||||
if (!canvas) return
|
||||
const size = Math.max(64, Math.round(props.patternSize))
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
}
|
||||
|
||||
const initImageData = (ctx) => {
|
||||
const canvas = grainRef.value
|
||||
if (!canvas) return
|
||||
noiseData = ctx.createImageData(canvas.width, canvas.height)
|
||||
noise32 = new Uint32Array(noiseData.data.buffer)
|
||||
}
|
||||
|
||||
const drawGrain = () => {
|
||||
if (!noise32) return
|
||||
const alpha = clamp(Math.round(props.patternAlpha), 0, 255) << 24
|
||||
for (let i = 0; i < noise32.length; i++) {
|
||||
const value = (Math.random() * 255) | 0
|
||||
noise32[i] = alpha | (value << 16) | (value << 8) | value
|
||||
}
|
||||
}
|
||||
|
||||
const loop = (ctx) => {
|
||||
const refreshEvery = Math.max(1, Math.round(props.patternRefreshInterval))
|
||||
if (frame % refreshEvery === 0) {
|
||||
drawGrain()
|
||||
ctx.putImageData(noiseData, 0, 0)
|
||||
}
|
||||
|
||||
frame++
|
||||
animationId = window.requestAnimationFrame(() => loop(ctx))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = grainRef.value
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d', { alpha: true })
|
||||
if (!ctx) return
|
||||
|
||||
resize()
|
||||
initImageData(ctx)
|
||||
drawGrain()
|
||||
ctx.putImageData(noiseData, 0, 0)
|
||||
loop(ctx)
|
||||
|
||||
window.addEventListener('resize', resize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', resize)
|
||||
window.cancelAnimationFrame(animationId)
|
||||
})
|
||||
</script>
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-10 sm:mt-14">
|
||||
<h1 class="wrapped-title text-4xl sm:text-6xl text-[#000000e6] leading-[1.05]">
|
||||
<h1 class="wrapped-title text-3xl sm:text-5xl text-[#000000e6] leading-[1.05]">
|
||||
{{ randomTitle.main }}
|
||||
<span class="block mt-3 win98-hero-highlight">
|
||||
{{ randomTitle.highlight }}
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-10 sm:mt-14">
|
||||
<h1 class="wrapped-title text-4xl sm:text-6xl text-[#000000e6] leading-[1.05]">
|
||||
<h1 class="wrapped-title text-3xl sm:text-5xl text-[#000000e6] leading-[1.05]">
|
||||
{{ randomTitle.main }}
|
||||
<span class="block mt-3 text-[#07C160]">
|
||||
{{ randomTitle.highlight }}
|
||||
@@ -90,6 +90,84 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="previewQuestions.length > 0 && (isGameboy || isModern)"
|
||||
class="pointer-events-none absolute bottom-0 right-0 hidden xl:flex items-end"
|
||||
>
|
||||
<div class="pointer-events-auto relative" :class="previewStageClass">
|
||||
<div class="relative" :class="previewViewportClass">
|
||||
<template v-if="isGameboy">
|
||||
<BitsCardSwap
|
||||
:width="previewCardWidth"
|
||||
:height="previewCardHeight"
|
||||
:delay="previewSwapDelay"
|
||||
:card-count="previewQuestions.length"
|
||||
:card-distance="previewCardDistance"
|
||||
:vertical-distance="previewVerticalDistance"
|
||||
:skew-amount="4"
|
||||
easing="elastic"
|
||||
:pause-on-hover="true"
|
||||
>
|
||||
<template
|
||||
v-for="(previewItem, previewIndex) in previewQuestions"
|
||||
:key="`preview-${previewItem.order}-${previewIndex}`"
|
||||
v-slot:[`card-${previewIndex}`]
|
||||
>
|
||||
<WrappedCardShell
|
||||
:card-id="previewItem.order"
|
||||
:title="previewItem.title"
|
||||
variant="panel"
|
||||
class="h-full w-full"
|
||||
>
|
||||
<div
|
||||
class="flex h-[168px] items-center justify-center rounded-xl border border-dashed px-5"
|
||||
:class="previewQuestionPanelClass"
|
||||
>
|
||||
<p class="wrapped-body text-lg leading-relaxed text-center" :class="previewQuestionClass">
|
||||
{{ previewItem.question }}
|
||||
</p>
|
||||
</div>
|
||||
</WrappedCardShell>
|
||||
</template>
|
||||
</BitsCardSwap>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<BitsGridMotion
|
||||
:items="modernPreviewItems"
|
||||
gradient-color="rgba(7, 193, 96, 0.24)"
|
||||
:row-count="7"
|
||||
:column-count="8"
|
||||
:scroll-speed="42"
|
||||
:base-offset-x="46"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<WrappedCardShell
|
||||
:card-id="Number(item?.order || 0)"
|
||||
:title="String(item?.title || '年度卡片')"
|
||||
variant="panel"
|
||||
class="h-full w-full preview-grid-shell"
|
||||
>
|
||||
<div class="preview-grid-body">
|
||||
<div class="preview-grid-summary">
|
||||
{{ String(item?.summary || '年度线索') }}
|
||||
</div>
|
||||
<p class="preview-grid-question">
|
||||
{{ String(item?.question || '这一页会揭晓你的聊天答案。') }}
|
||||
</p>
|
||||
<div class="preview-grid-lines" aria-hidden="true">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</WrappedCardShell>
|
||||
</template>
|
||||
</BitsGridMotion>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -235,13 +313,126 @@ const subtitleIndex = useState('wrapped-subtitle-index', () => Math.floor(Math.r
|
||||
const randomTitle = computed(() => TITLES[titleIndex.value])
|
||||
const randomSubtitle = computed(() => SUBTITLES[subtitleIndex.value])
|
||||
|
||||
const PREVIEW_BY_KIND = {
|
||||
'global/overview': {
|
||||
summary: '年度全景',
|
||||
question: '这一年你最常把消息发给谁?'
|
||||
},
|
||||
'time/weekday_hour_heatmap': {
|
||||
summary: '聊天作息',
|
||||
question: '你是早八型还是夜猫子型聊天选手?'
|
||||
},
|
||||
'text/message_chars': {
|
||||
summary: '表达强度',
|
||||
question: '你这一年打出的字,能拼成几段故事?'
|
||||
},
|
||||
'chat/reply_speed': {
|
||||
summary: '回复速度',
|
||||
question: '谁是你愿意秒回的那个人?'
|
||||
}
|
||||
}
|
||||
|
||||
const PREVIEW_FALLBACK_SUMMARY = '年度线索'
|
||||
const PREVIEW_FALLBACK_QUESTION = '这一页会揭晓你的哪段聊天答案?'
|
||||
const PREVIEW_BOOTSTRAP_ITEMS = [
|
||||
{ summary: '年度全景', question: '这一年你最常把消息发给谁?' },
|
||||
{ summary: '聊天作息', question: '你是「早八人」还是「夜猫子」?' },
|
||||
{ summary: '表达强度', question: '你这一年打了多少字?' },
|
||||
{ summary: '回复速度', question: '谁是你愿意秒回的那个人?' }
|
||||
]
|
||||
|
||||
const resolvePreviewMeta = (kind, idx) => {
|
||||
const key = String(kind || '').trim()
|
||||
if (PREVIEW_BY_KIND[key]) return PREVIEW_BY_KIND[key]
|
||||
return {
|
||||
summary: PREVIEW_FALLBACK_SUMMARY,
|
||||
question: idx % 2 === 0
|
||||
? '这一页会揭晓你聊天里的哪种习惯?'
|
||||
: '你猜这页的答案会指向谁和哪段时光?'
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
year: { type: Number, required: true },
|
||||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||
variant: { type: String, default: 'panel' }, // 'panel' | 'slide'
|
||||
cardManifests: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const { theme } = useWrappedTheme()
|
||||
const isWin98 = computed(() => theme.value === 'win98')
|
||||
const isGameboy = computed(() => theme.value === 'gameboy')
|
||||
const isModern = computed(() => theme.value === 'off')
|
||||
|
||||
const previewQuestions = computed(() => {
|
||||
const manifests = Array.isArray(props.cardManifests) ? props.cardManifests : []
|
||||
if (!manifests.length) {
|
||||
return Array.from({ length: 8 }, (_, idx) => {
|
||||
const fallback = PREVIEW_BOOTSTRAP_ITEMS[idx % PREVIEW_BOOTSTRAP_ITEMS.length]
|
||||
return {
|
||||
order: idx + 1,
|
||||
title: `第 ${idx + 1} 张卡片`,
|
||||
summary: fallback.summary,
|
||||
question: fallback.question
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return manifests.map((item, idx) => {
|
||||
const meta = resolvePreviewMeta(item?.kind, idx)
|
||||
return {
|
||||
order: idx + 1,
|
||||
title: String(item?.title || `第 ${idx + 1} 张卡片`),
|
||||
summary: meta.summary,
|
||||
question: meta.question
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const previewSwapDelay = 4200
|
||||
const previewCardWidth = 420
|
||||
const previewCardHeight = 280
|
||||
|
||||
const modernPreviewItems = computed(() => {
|
||||
if (!previewQuestions.value.length) return []
|
||||
return previewQuestions.value.map((item) => ({
|
||||
order: item.order,
|
||||
title: item.title,
|
||||
summary: item.summary,
|
||||
question: item.question
|
||||
}))
|
||||
})
|
||||
|
||||
const previewStageClass = computed(() => (
|
||||
isGameboy.value
|
||||
? 'w-[500px] h-[360px] translate-x-24 -translate-y-8'
|
||||
: 'w-[620px] h-[420px] translate-x-32 -translate-y-10'
|
||||
))
|
||||
|
||||
const previewViewportClass = computed(() => (
|
||||
isGameboy.value
|
||||
? 'h-[340px] w-[460px]'
|
||||
: 'h-[390px] w-[580px]'
|
||||
))
|
||||
|
||||
const previewCardDistance = computed(() => {
|
||||
const total = previewQuestions.value.length
|
||||
return total >= 9 ? 9 : total >= 7 ? 11 : total >= 5 ? 13 : 18
|
||||
})
|
||||
|
||||
const previewVerticalDistance = computed(() => {
|
||||
const total = previewQuestions.value.length
|
||||
return total >= 9 ? 10 : total >= 7 ? 11 : total >= 5 ? 14 : 18
|
||||
})
|
||||
|
||||
const previewQuestionClass = computed(() => {
|
||||
if (isWin98.value) return 'text-[#111111]'
|
||||
return 'text-[#1F2937]'
|
||||
})
|
||||
|
||||
const previewQuestionPanelClass = computed(() => {
|
||||
if (isWin98.value) return 'border-[#B7B7B7] bg-[#FFFFFF]'
|
||||
return 'border-[#07C160]/30 bg-[#F7FFFB]'
|
||||
})
|
||||
|
||||
const yearText = computed(() => `${props.year}年`)
|
||||
|
||||
@@ -267,4 +458,64 @@ const innerClass = computed(() => {
|
||||
background: #000080;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.preview-grid-shell {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 24px rgba(7, 193, 96, 0.14);
|
||||
background: #f3fff8 !important;
|
||||
border-color: rgba(7, 193, 96, 0.24) !important;
|
||||
}
|
||||
|
||||
.preview-grid-shell :deep(.wrapped-title) {
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.preview-grid-body {
|
||||
height: 96px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(7, 193, 96, 0.2);
|
||||
background: rgba(243, 255, 248, 0.88);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.preview-grid-summary {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.04em;
|
||||
color: #07c160;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-grid-question {
|
||||
margin-top: 6px;
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
font-weight: 600;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-grid-lines {
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.preview-grid-lines span {
|
||||
display: block;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(7, 193, 96, 0.18), rgba(7, 193, 96, 0.08));
|
||||
}
|
||||
|
||||
.preview-grid-lines span:last-child {
|
||||
width: 68%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,6 @@ const themeSwitcherComponent = computed(() => {
|
||||
const map = {
|
||||
off: resolveComponent('WrappedThemeSwitcherModern'),
|
||||
gameboy: resolveComponent('WrappedThemeSwitcherGameboy'),
|
||||
dos: resolveComponent('WrappedThemeSwitcherDos'),
|
||||
win98: resolveComponent('WrappedThemeSwitcherWin98')
|
||||
}
|
||||
return map[theme.value] || map.off
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<template>
|
||||
<div class="dos-menu select-none">
|
||||
<!-- DOS 风格功能键菜单栏 -->
|
||||
<div class="dos-menu-bar">
|
||||
<button
|
||||
v-for="(t, idx) in themes"
|
||||
:key="t.value"
|
||||
class="dos-menu-item"
|
||||
:class="{ 'is-active': theme === t.value }"
|
||||
@click="setTheme(t.value)"
|
||||
>
|
||||
<span class="dos-fkey">F{{ idx + 1 }}</span>
|
||||
<span class="dos-label">{{ t.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 状态提示 -->
|
||||
<div class="dos-status">
|
||||
Press F1-F4 to switch theme
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { theme, setTheme } = useWrappedTheme()
|
||||
|
||||
const themes = [
|
||||
{ value: 'off', label: 'Modern' },
|
||||
{ value: 'gameboy', label: 'GameBoy' },
|
||||
{ value: 'dos', label: 'DOS' },
|
||||
{ value: 'win98', label: 'Win98' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dos-menu {
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dos-menu-bar {
|
||||
display: flex;
|
||||
background: #000000;
|
||||
border: 1px solid #33ff33;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dos-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
background: #000000;
|
||||
color: #33ff33;
|
||||
border: none;
|
||||
border-right: 1px solid #1a5c1a;
|
||||
cursor: pointer;
|
||||
text-shadow: 0 0 5px #33ff33;
|
||||
transition: all 0.1s;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.dos-menu-item:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.dos-menu-item:hover {
|
||||
background: #0a1a0a;
|
||||
}
|
||||
|
||||
.dos-menu-item.is-active {
|
||||
background: #33ff33;
|
||||
color: #000000;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.dos-fkey {
|
||||
font-weight: bold;
|
||||
padding: 1px 3px;
|
||||
background: #1a5c1a;
|
||||
color: #33ff33;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.dos-menu-item.is-active .dos-fkey {
|
||||
background: #000000;
|
||||
color: #33ff33;
|
||||
}
|
||||
|
||||
.dos-label {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.dos-status {
|
||||
margin-top: 4px;
|
||||
color: #1a5c1a;
|
||||
font-size: 9px;
|
||||
text-shadow: 0 0 3px #1a5c1a;
|
||||
}
|
||||
</style>
|
||||
@@ -25,7 +25,6 @@ const { theme, setTheme } = useWrappedTheme()
|
||||
const themes = [
|
||||
{ value: 'off', label: 'MODERN' },
|
||||
{ value: 'gameboy', label: 'GAME BOY' },
|
||||
{ value: 'dos', label: 'DOS' },
|
||||
{ value: 'win98', label: 'WIN98' }
|
||||
]
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ const { theme, setTheme } = useWrappedTheme()
|
||||
const themes = [
|
||||
{ value: 'off', label: 'Modern' },
|
||||
{ value: 'gameboy', label: 'Game Boy' },
|
||||
{ value: 'dos', label: 'DOS' },
|
||||
{ value: 'win98', label: 'Win98' }
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -23,7 +23,6 @@ const { theme, setTheme } = useWrappedTheme()
|
||||
const themes = [
|
||||
{ value: 'off', label: 'Modern' },
|
||||
{ value: 'gameboy', label: 'Game Boy' },
|
||||
{ value: 'dos', label: 'DOS' },
|
||||
{ value: 'win98', label: 'Win98' }
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -19,25 +19,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DOS 风格 -->
|
||||
<div v-else-if="theme === 'dos'" class="year-dos">
|
||||
<span class="dos-prompt">C:\WRAPPED></span>
|
||||
<span class="dos-label">YEAR:</span>
|
||||
<button
|
||||
class="dos-arrow"
|
||||
:disabled="!canGoPrev"
|
||||
@click="prevYear"
|
||||
aria-label="Previous year"
|
||||
>[-]</button>
|
||||
<span class="dos-value">{{ modelValue }}</span>
|
||||
<button
|
||||
class="dos-arrow"
|
||||
:disabled="!canGoNext"
|
||||
@click="nextYear"
|
||||
aria-label="Next year"
|
||||
>[+]</button>
|
||||
</div>
|
||||
|
||||
<!-- Win98 风格 -->
|
||||
<div v-else-if="theme === 'win98'" class="year-win98">
|
||||
<div class="win98-year-box">
|
||||
@@ -209,57 +190,6 @@ onBeforeUnmount(() => {
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* ========== DOS 风格 ========== */
|
||||
.year-dos {
|
||||
font-family: 'Courier New', 'Consolas', monospace;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #33ff33;
|
||||
text-shadow: 0 0 5px #33ff33;
|
||||
}
|
||||
|
||||
.dos-prompt {
|
||||
color: #1a5c1a;
|
||||
}
|
||||
|
||||
.dos-label {
|
||||
color: #33ff33;
|
||||
}
|
||||
|
||||
.dos-arrow {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #33ff33;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
text-shadow: 0 0 5px #33ff33;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
|
||||
.dos-arrow:hover:not(:disabled) {
|
||||
color: #66ff66;
|
||||
text-shadow: 0 0 8px #66ff66;
|
||||
}
|
||||
|
||||
.dos-arrow:disabled {
|
||||
color: #1a5c1a;
|
||||
cursor: not-allowed;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.dos-value {
|
||||
background: #0a1a0a;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #1a5c1a;
|
||||
letter-spacing: 1px;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ========== Win98 风格 ========== */
|
||||
.year-win98 {
|
||||
font-family: "MS Sans Serif", Tahoma, "Microsoft Sans Serif", "Segoe UI", sans-serif;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* 年度总结页面主题管理 composable
|
||||
* 支持三种主题:modern(现代)、gameboy(Game Boy)、dos(DOS终端)
|
||||
* 支持三种主题:modern(现代)、gameboy(Game Boy)、win98(Windows 98)
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'wrapped-theme'
|
||||
const VALID_THEMES = ['off', 'gameboy', 'dos', 'win98']
|
||||
const RETRO_THEMES = new Set(['gameboy', 'dos'])
|
||||
const VALID_THEMES = ['off', 'gameboy', 'win98']
|
||||
const RETRO_THEMES = new Set(['gameboy'])
|
||||
|
||||
// 全局响应式状态(跨组件共享)
|
||||
const theme = ref('off')
|
||||
@@ -15,19 +15,12 @@ let keyboardInitialized = false
|
||||
export function useWrappedTheme() {
|
||||
// 初始化:从 localStorage 读取(仅执行一次)
|
||||
const initTheme = () => {
|
||||
if (initialized) return
|
||||
if (import.meta.client) {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved && VALID_THEMES.includes(saved)) {
|
||||
theme.value = saved
|
||||
}
|
||||
initialized = true
|
||||
if (initialized || !import.meta.client) return
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved && VALID_THEMES.includes(saved)) {
|
||||
theme.value = saved
|
||||
}
|
||||
}
|
||||
|
||||
// 立即初始化(客户端)
|
||||
if (import.meta.client) {
|
||||
initTheme()
|
||||
initialized = true
|
||||
}
|
||||
|
||||
// 设置主题
|
||||
@@ -66,7 +59,6 @@ export function useWrappedTheme() {
|
||||
const names = {
|
||||
off: 'Modern',
|
||||
gameboy: 'Game Boy',
|
||||
dos: 'DOS Terminal',
|
||||
win98: 'Windows 98'
|
||||
}
|
||||
return names[theme.value] || 'Modern'
|
||||
@@ -91,9 +83,6 @@ export function useWrappedTheme() {
|
||||
e.preventDefault()
|
||||
setTheme('gameboy')
|
||||
} else if (e.key === 'F3') {
|
||||
e.preventDefault()
|
||||
setTheme('dos')
|
||||
} else if (e.key === 'F4') {
|
||||
e.preventDefault()
|
||||
setTheme('win98')
|
||||
}
|
||||
@@ -102,10 +91,11 @@ export function useWrappedTheme() {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
|
||||
// 自动初始化键盘快捷键
|
||||
if (import.meta.client) {
|
||||
// 客户端挂载后再初始化:避免 SSR 与首帧 hydration 不一致
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
initKeyboardShortcuts()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
theme: readonly(theme),
|
||||
|
||||
@@ -28,7 +28,8 @@ export default defineNuxtConfig({
|
||||
{ name: 'description', content: '微信4.x版本数据库解密工具' }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/png', href: '/logo.png' }
|
||||
{ rel: 'icon', type: 'image/png', href: '/logo.png' },
|
||||
{ rel: 'stylesheet', href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css' }
|
||||
]
|
||||
},
|
||||
pageTransition: { name: 'page', mode: 'out-in' }
|
||||
|
||||
Generated
+295
-8
@@ -11,7 +11,9 @@
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"axios": "^1.11.0",
|
||||
"gsap": "^3.14.2",
|
||||
"nuxt": "^4.0.1",
|
||||
"ogl": "^1.0.11",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1"
|
||||
}
|
||||
@@ -7363,6 +7365,12 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmmirror.com/gsap/-/gsap-3.14.2.tgz",
|
||||
"integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/gzip-size": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/gzip-size/-/gzip-size-7.0.0.tgz",
|
||||
@@ -8394,6 +8402,279 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.30.2",
|
||||
"lightningcss-darwin-arm64": "1.30.2",
|
||||
"lightningcss-darwin-x64": "1.30.2",
|
||||
"lightningcss-freebsd-x64": "1.30.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||
"lightningcss-linux-x64-musl": "1.30.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss/node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -8449,14 +8730,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/local-pkg": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.1.tgz",
|
||||
"integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.2.tgz",
|
||||
"integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mlly": "^1.7.4",
|
||||
"pkg-types": "^2.0.1",
|
||||
"quansync": "^0.2.8"
|
||||
"pkg-types": "^2.3.0",
|
||||
"quansync": "^0.2.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -9406,6 +9687,12 @@
|
||||
"ufo": "^1.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ogl": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/ogl/-/ogl-1.0.11.tgz",
|
||||
"integrity": "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz",
|
||||
@@ -10740,9 +11027,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.10.tgz",
|
||||
"integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==",
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"axios": "^1.11.0",
|
||||
"gsap": "^3.14.2",
|
||||
"nuxt": "^4.0.1",
|
||||
"ogl": "^1.0.11",
|
||||
"vue": "^3.5.17",
|
||||
"vue-router": "^4.5.1"
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
ref="deckEl"
|
||||
class="relative h-screen w-full overflow-hidden transition-colors duration-500"
|
||||
class="wrapped-deck-root relative h-screen w-full overflow-hidden transition-colors duration-500"
|
||||
:class="themeClass"
|
||||
:style="{ backgroundColor: currentBg }"
|
||||
>
|
||||
<!-- PPT 风格:单张卡片占据全页面,鼠标滚轮切换 -->
|
||||
<WrappedDeckBackground />
|
||||
<!-- CRT 叠加层仅用于“像素屏/终端”类主题,Win98 等桌面 GUI 主题不应开启 -->
|
||||
<WrappedCRTOverlay v-if="theme === 'gameboy' || theme === 'dos'" />
|
||||
<!-- CRT 叠加层仅用于“像素屏”类主题,Win98 等桌面 GUI 主题不应开启 -->
|
||||
<WrappedCRTOverlay v-if="theme === 'gameboy'" />
|
||||
|
||||
<!-- 左上角:刷新 + 复古模式开关 -->
|
||||
<div class="absolute top-6 left-6 z-20 select-none">
|
||||
@@ -81,13 +81,19 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative z-10 h-full w-full will-change-transform transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
class="relative h-full w-full will-change-transform transition-transform duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
:class="deckTrackClass"
|
||||
:style="trackStyle"
|
||||
>
|
||||
<!-- Cover -->
|
||||
<section class="w-full" :style="slideStyle">
|
||||
<div class="h-full w-full relative">
|
||||
<WrappedHero :year="year" variant="slide" class="h-full w-full" />
|
||||
<WrappedHero
|
||||
:year="year"
|
||||
:card-manifests="report?.cards || []"
|
||||
variant="slide"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -196,7 +202,7 @@ const year = ref(Number(route.query?.year) || new Date().getFullYear())
|
||||
// 分享视图不展示账号信息:默认让后端自动选择;需要指定时可用 query ?account=wxid_xxx
|
||||
const account = ref(typeof route.query?.account === 'string' ? route.query.account : '')
|
||||
|
||||
// 主题管理:modern / gameboy / dos
|
||||
// 主题管理:modern / gameboy / win98
|
||||
const { theme, cycleTheme, isRetro, themeClass } = useWrappedTheme()
|
||||
|
||||
const accounts = ref([])
|
||||
@@ -227,12 +233,12 @@ const activeIndex = ref(0)
|
||||
const navLocked = ref(false)
|
||||
const wheelAcc = ref(0)
|
||||
let navUnlockTimer = null
|
||||
let deckResizeObserver = null
|
||||
|
||||
// 各主题的背景颜色
|
||||
const THEME_BG = {
|
||||
off: '#F3FFF8', // Modern: 浅绿
|
||||
gameboy: '#9bbc0f', // Game Boy: 亮绿
|
||||
dos: '#0a0a0a', // DOS: 黑色
|
||||
win98: '#008080' // Win98: 经典桌面青色
|
||||
}
|
||||
|
||||
@@ -252,6 +258,14 @@ const taskbarTitle = computed(() => {
|
||||
})
|
||||
|
||||
const currentBg = computed(() => THEME_BG[theme.value] || THEME_BG.off)
|
||||
const deckTrackClass = computed(() => 'z-10')
|
||||
|
||||
const applyViewportBg = () => {
|
||||
if (!import.meta.client) return
|
||||
const bg = currentBg.value
|
||||
document.documentElement.style.backgroundColor = bg
|
||||
document.body.style.backgroundColor = bg
|
||||
}
|
||||
|
||||
const slideStyle = computed(() => (
|
||||
viewportHeight.value > 0 ? { height: `${viewportHeight.value}px` } : { height: '100%' }
|
||||
@@ -376,7 +390,7 @@ const onTouchEnd = (e) => {
|
||||
}
|
||||
|
||||
const updateViewport = () => {
|
||||
const h = deckEl.value?.clientHeight || window.innerHeight || 0
|
||||
const h = Math.round(deckEl.value?.getBoundingClientRect?.().height || deckEl.value?.clientHeight || window.innerHeight || 0)
|
||||
if (!h) return
|
||||
// Reserve space for the Win98 taskbar at the bottom.
|
||||
const offset = theme.value === 'win98' ? 40 : 0
|
||||
@@ -525,7 +539,14 @@ watch(activeIndex, (i) => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
applyViewportBg()
|
||||
updateViewport()
|
||||
if (import.meta.client && typeof ResizeObserver !== 'undefined' && deckEl.value) {
|
||||
deckResizeObserver = new ResizeObserver(() => {
|
||||
updateViewport()
|
||||
})
|
||||
deckResizeObserver.observe(deckEl.value)
|
||||
}
|
||||
window.addEventListener('resize', updateViewport)
|
||||
// passive:false 才能 preventDefault,避免外层容器产生滚动/回弹
|
||||
deckEl.value?.addEventListener('wheel', onWheel, { passive: false })
|
||||
@@ -542,10 +563,17 @@ onMounted(async () => {
|
||||
|
||||
// Theme switch may change reserved UI space (e.g., Win98 taskbar)
|
||||
watch(theme, () => {
|
||||
applyViewportBg()
|
||||
updateViewport()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (import.meta.client) {
|
||||
document.documentElement.style.backgroundColor = ''
|
||||
document.body.style.backgroundColor = ''
|
||||
}
|
||||
deckResizeObserver?.disconnect()
|
||||
deckResizeObserver = null
|
||||
window.removeEventListener('resize', updateViewport)
|
||||
deckEl.value?.removeEventListener('wheel', onWheel)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
@@ -573,3 +601,15 @@ watch(year, async (newYear, oldYear) => {
|
||||
await reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.wrapped-deck-root {
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.wechat-desktop .wechat-desktop-content > .wrapped-deck-root {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "wechat-decrypt-tool"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
description = "Modern WeChat database decryption tool with React frontend"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
Reference in New Issue
Block a user