442 lines
18 KiB
Vue
442 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { usePreferredReducedMotion } from '@vueuse/core'
|
||
import { computed, ref } from 'vue'
|
||
import type { Memory } from '../api/client'
|
||
import { getStandardNepalTheme } from '../constants/nepalThemes'
|
||
import PolaroidCard from './PolaroidCard.vue'
|
||
import { renderMarkdown } from '../utils/renderMarkdown'
|
||
|
||
const props = defineProps<{
|
||
memory: Memory
|
||
}>()
|
||
|
||
const bodyHtml = computed(() => renderMarkdown(props.memory.main_text))
|
||
const theme = computed(() => getStandardNepalTheme(props.memory.nepal_art_style))
|
||
const reduceMotion = usePreferredReducedMotion()
|
||
|
||
const phase = ref<'idle' | 'running' | 'met'>('idle')
|
||
|
||
const ticketMotionClass = computed(() => {
|
||
if (reduceMotion.value === 'reduce' || phase.value !== 'idle') return ''
|
||
return 'fm-ticket-float'
|
||
})
|
||
|
||
function startJourney() {
|
||
if (phase.value !== 'idle') return
|
||
if (reduceMotion.value === 'reduce') {
|
||
phase.value = 'met'
|
||
return
|
||
}
|
||
phase.value = 'running'
|
||
}
|
||
|
||
function onTrainAnimationEnd(ev: AnimationEvent) {
|
||
if (ev.animationName !== 'fm-train-xa-cd') return
|
||
if (phase.value === 'running') {
|
||
phase.value = 'met'
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="relative mx-auto max-w-lg overflow-x-visible overflow-y-hidden rounded-3xl bg-gradient-to-br px-3 pb-8 pt-6 sm:px-5 sm:pb-10 sm:pt-8"
|
||
:class="theme.gradient"
|
||
>
|
||
<div
|
||
class="pointer-events-none absolute -left-1/4 -top-1/4 h-1/2 w-[70%] rounded-full bg-gradient-to-br from-amber-300/40 via-orange-200/20 to-transparent blur-3xl"
|
||
aria-hidden="true"
|
||
/>
|
||
<div
|
||
class="pointer-events-none absolute -bottom-8 right-0 h-40 w-40 rounded-full bg-gradient-to-tl from-rose-300/30 to-transparent blur-3xl"
|
||
aria-hidden="true"
|
||
/>
|
||
|
||
<div
|
||
class="pointer-events-none absolute inset-2 rounded-3xl opacity-[0.18] mix-blend-multiply"
|
||
aria-hidden="true"
|
||
style="
|
||
background-image: radial-gradient(circle at 18% 22%, rgb(251 191 36 / 0.5) 0 2px, transparent 3px),
|
||
radial-gradient(circle at 78% 28%, rgb(253 186 116 / 0.4) 0 2px, transparent 3px);
|
||
background-size: 26px 26px, 30px 30px;
|
||
"
|
||
/>
|
||
|
||
<p
|
||
class="relative z-[2] mb-3 flex items-center justify-center gap-3 text-[11px] font-bold tracking-[0.3em] text-amber-900/45"
|
||
>
|
||
<span class="h-px w-8 bg-gradient-to-r from-transparent to-amber-400/60" aria-hidden="true" />
|
||
<span class="uppercase">第一章</span>
|
||
<span class="h-px w-8 bg-gradient-to-l from-transparent to-amber-400/60" aria-hidden="true" />
|
||
</p>
|
||
|
||
<div
|
||
class="relative z-[1] mx-auto mb-8 min-h-[min(68vw,300px)] max-w-[min(100%,400px)] overflow-hidden rounded-[1.5rem] border-[6px] border-white/90 bg-gradient-to-b from-amber-50/80 via-amber-100/40 to-orange-50/40 px-2 pb-3 pt-3 shadow-[inset_0_10px_25px_rgba(120,53,15,0.2),inset_0_2px_4px_rgba(0,0,0,0.05),0_15px_40px_-15px_rgba(180,83,9,0.35)] sm:min-h-[320px] sm:px-3 sm:pb-4 sm:pt-4"
|
||
>
|
||
<div
|
||
class="pointer-events-none absolute -right-1 top-2 z-[2] sm:-right-2 sm:top-3"
|
||
:class="phase === 'idle' && !reduceMotion ? 'fm-sun-glow' : ''"
|
||
aria-hidden="true"
|
||
>
|
||
<svg
|
||
class="h-40 w-36 drop-shadow-[0_8px_16px_rgba(180,83,9,0.25)] sm:h-48 sm:w-44"
|
||
viewBox="0 0 140 160"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<defs>
|
||
<linearGradient id="fm-petal" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" stop-color="#fcd34d" />
|
||
<stop offset="100%" stop-color="#f59e0b" />
|
||
</linearGradient>
|
||
</defs>
|
||
<ellipse cx="88" cy="132" rx="11" ry="38" fill="#15803d" stroke="#14532d" stroke-width="1.3" />
|
||
<g transform="translate(88 58)">
|
||
<ellipse
|
||
v-for="p in 12"
|
||
:key="p"
|
||
cx="0"
|
||
cy="-30"
|
||
rx="12"
|
||
ry="24"
|
||
:transform="`rotate(${(p - 1) * 30})`"
|
||
fill="url(#fm-petal)"
|
||
stroke="#b45309"
|
||
stroke-width="1.05"
|
||
/>
|
||
</g>
|
||
<circle cx="88" cy="58" r="16" fill="#92400e" stroke="#451a03" stroke-width="1.2" />
|
||
<circle cx="88" cy="58" r="7" fill="#451a03" />
|
||
<circle cx="92" cy="54" r="2.5" fill="#fffbeb" opacity="0.5" />
|
||
</svg>
|
||
<svg
|
||
class="pointer-events-none absolute bottom-8 left-0 h-24 w-20 opacity-90 drop-shadow-lg sm:bottom-10"
|
||
viewBox="0 0 80 96"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
aria-hidden="true"
|
||
>
|
||
<ellipse cx="44" cy="82" rx="7" ry="22" fill="#166534" stroke="#14532d" stroke-width="1" />
|
||
<g transform="translate(44 44)">
|
||
<ellipse
|
||
v-for="q in 10"
|
||
:key="q"
|
||
cx="0"
|
||
cy="-18"
|
||
rx="7"
|
||
ry="15"
|
||
:transform="`rotate(${(q - 1) * 36})`"
|
||
fill="#fde68a"
|
||
stroke="#ca8a04"
|
||
stroke-width="0.9"
|
||
/>
|
||
</g>
|
||
<circle cx="44" cy="44" r="9" fill="#a16207" stroke="#713f12" stroke-width="1" />
|
||
</svg>
|
||
</div>
|
||
|
||
<div
|
||
class="pointer-events-none absolute left-3 top-[42%] z-[3] -translate-y-1/2 sm:left-5"
|
||
aria-hidden="true"
|
||
>
|
||
<div
|
||
class="rounded-md border border-amber-900/40 bg-gradient-to-b from-amber-50 to-amber-100 px-2.5 py-1.5 text-center shadow-[2px_4px_0_rgb(120,53,15,0.3)] ring-1 ring-white"
|
||
>
|
||
<span class="mb-0.5 block text-[8px] font-bold text-amber-800/70">始发</span>
|
||
<p class="font-brush text-xl leading-none text-amber-950">西安</p>
|
||
</div>
|
||
<div class="mx-auto mt-0.5 h-6 w-1.5 rounded-b-sm bg-gradient-to-r from-amber-800 to-amber-900 shadow-sm" />
|
||
</div>
|
||
<div
|
||
class="pointer-events-none absolute right-3 top-[42%] z-[3] -translate-y-1/2 sm:right-5"
|
||
aria-hidden="true"
|
||
>
|
||
<div
|
||
class="rounded-md border border-rose-900/40 bg-gradient-to-b from-rose-50 to-rose-100 px-2.5 py-1.5 text-center shadow-[2px_4px_0_rgb(157,23,77,0.25)] ring-1 ring-white"
|
||
>
|
||
<span class="mb-0.5 block text-[8px] font-bold text-rose-800/70">终到</span>
|
||
<p class="font-brush text-xl leading-none text-rose-950">成都</p>
|
||
</div>
|
||
<div class="mx-auto mt-0.5 h-6 w-1.5 rounded-b-sm bg-gradient-to-r from-rose-800 to-rose-900 shadow-sm" />
|
||
</div>
|
||
|
||
<div
|
||
class="pointer-events-none absolute bottom-[32%] left-[10%] right-[10%] z-0 h-10 rounded-t-[100%] bg-gradient-to-t from-amber-300/30 to-transparent opacity-80 blur-[2px]"
|
||
aria-hidden="true"
|
||
/>
|
||
<div class="pointer-events-none absolute bottom-[17%] left-[-5%] right-[-5%] z-[1]" aria-hidden="true">
|
||
<div class="h-1.5 rounded-full bg-gradient-to-r from-slate-500/30 via-slate-700/60 to-slate-500/30 shadow-sm" />
|
||
<div class="mt-1 flex justify-between px-2 opacity-60">
|
||
<span v-for="n in 35" :key="n" class="h-1.5 w-[3px] rounded-full bg-slate-700" />
|
||
</div>
|
||
<div class="mt-0.5 h-0.5 rounded-full bg-slate-500/40" />
|
||
</div>
|
||
|
||
<div
|
||
class="fm-train-wrap absolute bottom-[12%] left-0 z-[4] w-[46%] max-w-[210px] will-change-[left] sm:bottom-[13%]"
|
||
:class="{
|
||
'fm-train--run': phase === 'running',
|
||
'fm-train--end': phase === 'met',
|
||
}"
|
||
@animationend.self="onTrainAnimationEnd($event)"
|
||
>
|
||
<div :class="phase === 'running' ? 'fm-train-bump' : ''">
|
||
<svg
|
||
class="w-full drop-shadow-[0_6px_2px_rgb(15,23,42,0.15),0_8px_24px_rgba(14,116,144,0.2)]"
|
||
viewBox="0 0 220 80"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
aria-hidden="true"
|
||
>
|
||
<defs>
|
||
<linearGradient id="fm-train-body" x1="0%" y1="0%" x2="0%" y2="100%">
|
||
<stop offset="0%" stop-color="#f0f9ff" />
|
||
<stop offset="50%" stop-color="#bae6fd" />
|
||
<stop offset="100%" stop-color="#7dd3fc" />
|
||
</linearGradient>
|
||
</defs>
|
||
<path
|
||
d="M6 42 L58 30 L182 30 L202 38 L212 48 L212 62 L6 62 Z"
|
||
fill="url(#fm-train-body)"
|
||
stroke="#0c4a6e"
|
||
stroke-width="1.7"
|
||
/>
|
||
<path d="M182 30 L202 38 L202 56 L182 56 Z" fill="#38bdf8" stroke="#0c4a6e" stroke-width="1.35" />
|
||
<rect x="26" y="40" width="30" height="16" rx="1.5" fill="#0ea5e9" stroke="#075985" stroke-width="1" />
|
||
<rect x="62" y="40" width="30" height="16" rx="1.5" fill="#0ea5e9" stroke="#075985" stroke-width="1" />
|
||
<rect x="98" y="40" width="30" height="16" rx="1.5" fill="#0ea5e9" stroke="#075985" stroke-width="1" />
|
||
<path d="M34 44h18M70 44h18M106 44h18" stroke="#e0f2fe" stroke-width="1.2" opacity="0.55" />
|
||
<circle cx="198" cy="46" r="3" fill="#fef08a" stroke="#ca8a04" stroke-width="0.8" />
|
||
<path d="M188 48 L206 48" stroke="#0c4a6e" stroke-width="1.3" stroke-linecap="round" />
|
||
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
class="group absolute bottom-1.5 left-2 z-[6] max-w-[min(230px,60vw)] overflow-hidden rounded-lg border border-rose-900/30 bg-gradient-to-br from-[#fffdfa] via-rose-50 to-amber-50 py-2.5 pl-4 pr-3 text-left shadow-[5px_6px_0_rgb(136,19,55,0.18),0_0_0_1px_rgba(255,255,255,0.8)_inset] ring-1 ring-rose-200/50 transition-all duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 focus-visible:ring-offset-2 disabled:cursor-default sm:bottom-3 sm:left-4"
|
||
:class="[
|
||
ticketMotionClass,
|
||
phase === 'idle'
|
||
? 'rotate-[-3deg] hover:-translate-y-1 hover:rotate-[-1deg] hover:shadow-[8px_10px_0_rgb(136,19,55,0.22)] active:translate-y-0'
|
||
: 'rotate-[-2deg]',
|
||
]"
|
||
:disabled="phase !== 'idle'"
|
||
:aria-label="phase === 'idle' ? '点击发车:西安开往成都' : phase === 'running' ? '列车行驶中' : '已相遇'"
|
||
@click="startJourney"
|
||
>
|
||
<div class="pointer-events-none absolute -left-2 top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-amber-100 shadow-[inset_1px_0_3px_rgba(0,0,0,0.15)]" aria-hidden="true" />
|
||
<div class="pointer-events-none absolute -right-2 top-1/2 h-4 w-4 -translate-y-1/2 rounded-full bg-amber-100 shadow-[inset_-1px_0_3px_rgba(0,0,0,0.15)]" aria-hidden="true" />
|
||
|
||
<div class="pointer-events-none absolute bottom-0 left-2 top-0 w-px border-l-2 border-dashed border-rose-200/60" aria-hidden="true" />
|
||
|
||
<div class="relative pl-1.5">
|
||
<div class="flex items-end justify-between gap-3 border-b border-rose-200/80 pb-2">
|
||
<div class="min-w-0 flex-1">
|
||
<p class="font-brush text-[1.4rem] leading-tight text-rose-950 sm:text-2xl">
|
||
西安
|
||
<span class="mx-1 inline-block -translate-y-1 text-sm text-amber-600/80">➔</span>
|
||
成都
|
||
</p>
|
||
<p class="mt-1 font-mono text-[9px] font-bold tracking-widest text-rose-800/70">G 心动直达 · D1915车</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-2 flex items-center justify-between gap-2">
|
||
<span class="font-mono text-[13px] font-bold text-slate-500/90">04.30 </span>
|
||
<span class="font-mono text-[10px] font-bold text-slate-500/90">08:50-12:11</span>
|
||
<div class="flex h-3 flex-1 items-end justify-end gap-[1.5px] opacity-40 mix-blend-multiply">
|
||
<div class="h-full w-1 bg-rose-900" />
|
||
<div class="h-[70%] w-[1px] bg-rose-900" />
|
||
<div class="h-full w-[2px] bg-rose-900" />
|
||
<div class="h-[90%] w-[1.5px] bg-rose-900" />
|
||
<div class="h-full w-[1px] bg-rose-900" />
|
||
<div class="h-[60%] w-[2.5px] bg-rose-900" />
|
||
<div class="h-full w-[1px] bg-rose-900" />
|
||
</div>
|
||
</div>
|
||
|
||
<p
|
||
v-if="phase === 'idle'"
|
||
class="mt-2.5 text-center text-[10px] font-bold tracking-wider text-rose-600 group-hover:text-rose-700"
|
||
>
|
||
<span class="rounded bg-rose-500/10 px-2 py-0.5">检票发车</span>
|
||
</p>
|
||
<p v-else-if="phase === 'running'" class="mt-2.5 text-center text-[10px] font-bold tracking-wider text-amber-700">
|
||
<span class="inline-block animate-pulse">行驶中...</span>
|
||
</p>
|
||
<p v-else class="mt-2.5 text-center text-[10px] font-bold tracking-wider text-emerald-700">
|
||
已到站 ✦
|
||
</p>
|
||
</div>
|
||
</button>
|
||
|
||
<div
|
||
class="pointer-events-none absolute bottom-[28%] left-1/2 z-[5] flex -translate-x-1/2 items-end gap-1 transition-opacity duration-500 sm:gap-2"
|
||
:class="phase === 'met' ? 'opacity-100' : 'opacity-0'"
|
||
aria-hidden="true"
|
||
>
|
||
<div
|
||
class="relative flex flex-col items-center transition-all duration-700 ease-out"
|
||
:class="phase === 'met' ? 'translate-x-0 opacity-100' : '-translate-x-12 opacity-0'"
|
||
>
|
||
<div class="absolute -bottom-1 left-1/2 h-2 w-12 -translate-x-1/2 rounded-full bg-slate-900/20 blur-[2px]" />
|
||
<div class="z-10 h-7 w-7 rounded-full border-[1.5px] border-slate-700 bg-[#fde0d0] shadow-sm" />
|
||
<div class="absolute top-[26px] z-20 h-2 w-8 rounded-full border border-slate-700 bg-slate-300 shadow-sm" />
|
||
<div class="mt-[-2px] h-12 w-10 rounded-b-xl rounded-t-md border-[1.5px] border-slate-700 bg-gradient-to-b from-sky-200 to-sky-400 shadow-sm" />
|
||
</div>
|
||
|
||
<div
|
||
v-show="phase === 'met'"
|
||
class="relative z-30 animate-[fmHeart_0.65s_cubic-bezier(0.34,1.56,0.64,1)_both] pb-8 text-3xl drop-shadow-[0_2px_8px_rgba(225,29,72,0.4)] sm:text-4xl"
|
||
>
|
||
<span class="bg-gradient-to-br from-rose-400 via-rose-500 to-pink-600 bg-clip-text text-transparent">♥</span>
|
||
<span class="fm-sparkle absolute -right-2 -top-2 text-amber-300 opacity-90">✦</span>
|
||
<span class="fm-sparkle-delay absolute -left-3 top-1 text-rose-200 opacity-80">✦</span>
|
||
</div>
|
||
|
||
<div
|
||
class="relative flex flex-col items-center transition-all duration-700 ease-out"
|
||
:class="phase === 'met' ? 'translate-x-0 opacity-100' : 'translate-x-12 opacity-0'"
|
||
>
|
||
<div class="absolute -bottom-1 left-1/2 h-2 w-12 -translate-x-1/2 rounded-full bg-slate-900/20 blur-[2px]" />
|
||
<div class="z-10 h-6 w-6 rounded-full border-[1.5px] border-slate-700 bg-[#fde0d0] shadow-sm" />
|
||
<div class="absolute top-[22px] z-20 h-2.5 w-9 rounded-full border border-slate-700 bg-amber-200 shadow-sm" />
|
||
<div class="mt-[-2px] h-11 w-9 rounded-b-xl rounded-t-md border-[1.5px] border-slate-700 bg-gradient-to-b from-rose-200 to-rose-400 shadow-sm" />
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-20 bg-gradient-to-t from-amber-200/50 to-transparent"
|
||
aria-hidden="true"
|
||
/>
|
||
</div>
|
||
|
||
<div class="relative z-[2] flex flex-col items-stretch gap-6 sm:gap-8">
|
||
<PolaroidCard :memory="memory" :index="memory.story_order" compact />
|
||
|
||
<div
|
||
class="nepal-torn-panel relative mx-auto max-w-md border border-amber-200/60 bg-gradient-to-b from-[#fffdfa] to-amber-50/40 px-5 py-6 text-left shadow-[0_12px_30px_-15px_rgba(180,83,9,0.2)] sm:px-6 sm:py-7"
|
||
>
|
||
<div class="absolute -top-3 left-1/2 h-6 w-20 -translate-x-1/2 rotate-[2deg] bg-rose-200/40 shadow-[0_1px_2px_rgba(0,0,0,0.05)] backdrop-blur-sm" />
|
||
|
||
<div
|
||
class="mb-4 h-1 w-12 rounded-full bg-gradient-to-r from-amber-400 to-rose-400/80"
|
||
aria-hidden="true"
|
||
/>
|
||
<div
|
||
class="text-[15px] leading-[1.8] tracking-wide text-amber-950/90 [&_a]:font-bold [&_a]:text-rose-600 [&_a]:underline [&_a]:decoration-rose-300 [&_a]:underline-offset-4 [&_blockquote]:border-l-4 [&_blockquote]:border-rose-300/60 [&_blockquote]:bg-rose-50/30 [&_blockquote]:py-1 [&_blockquote]:pl-3 [&_blockquote]:text-rose-900/70 [&_code]:rounded [&_code]:bg-amber-100/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_strong]:text-rose-900 [&_p+p]:mt-4"
|
||
v-html="bodyHtml"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.fm-train-wrap {
|
||
left: -40%;
|
||
}
|
||
|
||
.fm-train--run {
|
||
animation-name: fm-train-xa-cd;
|
||
animation-duration: 2.85s;
|
||
animation-timing-function: cubic-bezier(0.33, 1, 0.68, 1);
|
||
animation-fill-mode: forwards;
|
||
}
|
||
|
||
.fm-train--end {
|
||
left: 58%;
|
||
}
|
||
|
||
@keyframes fm-train-xa-cd {
|
||
0% {
|
||
left: -40%;
|
||
}
|
||
100% {
|
||
left: 58%;
|
||
}
|
||
}
|
||
|
||
/* 动车行驶中的颠簸感 */
|
||
.fm-train-bump {
|
||
animation: train-bump 0.15s ease-in-out infinite alternate;
|
||
}
|
||
|
||
@keyframes train-bump {
|
||
0% {
|
||
transform: translateY(0);
|
||
}
|
||
100% {
|
||
transform: translateY(-1.5px);
|
||
}
|
||
}
|
||
|
||
@keyframes fmHeart {
|
||
0% {
|
||
transform: scale(0) rotate(-12deg) translateY(10px);
|
||
opacity: 0;
|
||
}
|
||
50% {
|
||
transform: scale(1.25) rotate(5deg) translateY(-5px);
|
||
opacity: 1;
|
||
}
|
||
100% {
|
||
transform: scale(1) rotate(0deg) translateY(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.fm-ticket-float {
|
||
animation: fm-ticket-bob 4.5s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes fm-ticket-bob {
|
||
0%,
|
||
100% {
|
||
transform: rotate(-3deg) translateY(0);
|
||
}
|
||
50% {
|
||
transform: rotate(-2deg) translateY(-5px);
|
||
}
|
||
}
|
||
|
||
.fm-sun-glow {
|
||
animation: fm-sun-sway 6s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes fm-sun-sway {
|
||
0%,
|
||
100% {
|
||
transform: rotate(0deg);
|
||
}
|
||
50% {
|
||
transform: rotate(2deg);
|
||
}
|
||
}
|
||
|
||
.fm-sparkle {
|
||
animation: fm-spark 1.2s ease-out 0.4s both;
|
||
}
|
||
|
||
.fm-sparkle-delay {
|
||
animation: fm-spark 1s ease-out 0.65s both;
|
||
}
|
||
|
||
@keyframes fm-spark {
|
||
0% {
|
||
transform: scale(0) rotate(-20deg);
|
||
opacity: 0;
|
||
}
|
||
100% {
|
||
transform: scale(1) rotate(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* 撕纸边缘容器样式补充 */
|
||
.nepal-torn-panel {
|
||
border-radius: 4px 4px 12px 12px;
|
||
}
|
||
</style> |