lingjing/app/report-preview/page.jsx

318 lines
16 KiB
JavaScript
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.

'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import Starfield from '../../components/Starfield';
const ZODIAC_SYMBOL = {
aries: '♈', taurus: '♉', gemini: '♊', cancer: '♋', leo: '♌', virgo: '♍', libra: '♎', scorpio: '♏', sagittarius: '♐', capricorn: '♑', aquarius: '♒', pisces: '♓',
};
function mbtiColor(type = '') {
const t = type.toUpperCase();
if (['INTJ', 'INTP', 'ENTJ', 'ENTP'].includes(t)) return 'text-violet-300 border-violet-400/50';
if (['INFJ', 'INFP', 'ENFJ', 'ENFP'].includes(t)) return 'text-emerald-300 border-emerald-400/50';
if (['ISTJ', 'ISFJ', 'ESTJ', 'ESFJ'].includes(t)) return 'text-sky-300 border-sky-400/50';
return 'text-orange-300 border-orange-400/50';
}
function Radar({ scores }) {
const labels = ['心力', '行力', '感知', '洞见', '定力'];
const values = [scores?.xinli || 0, scores?.xingli || 0, scores?.ganzhi || 0, scores?.dongjian || 0, scores?.dingli || 0];
const cx = 160; const cy = 160; const r = 110;
const points = values.map((v, i) => {
const a = -Math.PI / 2 + (Math.PI * 2 * i) / 5;
return [cx + Math.cos(a) * (r * v / 100), cy + Math.sin(a) * (r * v / 100)];
});
const polygon = points.map((p) => p.join(',')).join(' ');
return (
<svg viewBox="0 0 320 320" className="mx-auto w-full max-w-[380px]">
{[20, 40, 60, 80, 100].map((step) => {
const rr = r * step / 100;
const ring = labels.map((_, i) => { const a = -Math.PI / 2 + Math.PI * 2 * i / 5; return `${cx + Math.cos(a) * rr},${cy + Math.sin(a) * rr}`; }).join(' ');
return <polygon key={step} points={ring} fill="none" stroke="rgba(255,255,255,0.12)" strokeWidth="1" />;
})}
{labels.map((l, i) => {
const a = -Math.PI / 2 + Math.PI * 2 * i / 5;
return (
<g key={l}>
<line x1={cx} y1={cy} x2={cx + Math.cos(a) * r} y2={cy + Math.sin(a) * r} stroke="rgba(255,255,255,0.15)" />
<text x={cx + Math.cos(a) * (r + 18)} y={cy + Math.sin(a) * (r + 18)} textAnchor="middle" dominantBaseline="middle" fill="rgba(255,255,255,0.75)" fontSize="11">{l}</text>
</g>
);
})}
<polygon points={polygon} fill="rgba(104,130,255,0.35)" stroke="rgba(152,176,255,0.9)" strokeWidth="2" />
{points.map((p, i) => (
<g key={labels[i]}>
<circle cx={p[0]} cy={p[1]} r="3" fill="white" />
<text x={p[0]} y={p[1] - 8} textAnchor="middle" fill="rgba(255,255,255,0.85)" fontSize="10">{values[i]}</text>
</g>
))}
</svg>
);
}
// Section: 滑入视口时触发动画children可以是函数(vis)=>JSX拿到visible状态做子元素动画
function Section({ title, children }) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) { setVisible(true); obs.disconnect(); } },
{ threshold: 0.12 }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<section ref={ref} className="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm"
style={{ opacity: visible ? 1 : 0, transform: visible ? 'translateY(0)' : 'translateY(28px)', transition: 'opacity 0.7s ease, transform 0.7s ease' }}>
<h2 className="mb-3 text-lg font-semibold text-white">{title}</h2>
{typeof children === 'function' ? children(visible) : children}
</section>
);
}
export default function ReportPreviewPage() {
const [querySid, setQuerySid] = useState('');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const headerRef = useRef(null);
const [headerVisible, setHeaderVisible] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
setQuerySid(new URLSearchParams(window.location.search).get('sessionId') || '');
}, []);
useEffect(() => {
const el = headerRef.current;
if (!el) return;
const obs = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setHeaderVisible(true); obs.disconnect(); } }, { threshold: 0.1 });
obs.observe(el);
return () => obs.disconnect();
}, []);
const isMock = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('mock') === '1';
const sid = useMemo(() => querySid || (typeof window !== 'undefined' ? localStorage.getItem('lingjing_sid') || '' : ''), [querySid]);
const load = async () => {
setLoading(true); setError('');
try {
const url = isMock ? '/api/report/mock' : `/api/report?sessionId=${encodeURIComponent(sid)}`;
if (!isMock && !sid) { setError('缺少 sessionId'); setLoading(false); return; }
// 轮询直到报告完成最多60秒
for (let attempt = 0; attempt < 30; attempt++) {
const r = await fetch(url);
const d = await r.json();
if (d?.status === 'done' && d?.report) {
setData(d.report);
return;
}
if (d?.status === 'error') throw new Error(d?.error || '生成失败');
// generating 或 not_started → 等2秒再试
if (attempt < 29) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
throw new Error('报告生成超时,请重试');
} catch (e) { setError(e.message || '加载失败'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, [sid]);
return (
<main className="relative min-h-screen overflow-hidden bg-[#05030f] text-white">
<Starfield className="absolute inset-0 h-[320px] w-full opacity-90" animated={false} />
<div className="relative z-10 mx-auto w-full max-w-2xl px-4 py-10">
<header ref={headerRef} className="mb-8 text-center"
style={{ opacity: headerVisible ? 1 : 0, transform: headerVisible ? 'translateY(0)' : 'translateY(-20px)', transition: 'opacity 0.8s ease, transform 0.8s ease' }}>
<p className="text-xs tracking-[0.3em] text-white/35">LINGJING REPORT</p>
<h1 className="mt-2 text-3xl font-light tracking-widest">你的灵镜报告</h1>
</header>
{loading ? (
<div className="text-center">
<p className="text-white/50 tracking-widest" style={{ animation: 'breathe 2.4s ease-in-out infinite' }}>正在生成你的灵镜报告</p>
<p className="mt-4 text-xs text-white/30">通常需要30-60</p>
<style>{`@keyframes breathe { 0%,100% { opacity: 0.3; } 50% { opacity: 0.9; } }`}</style>
</div>
) : null}
{error ? (
<div className="mx-auto max-w-xl rounded-2xl border border-white/10 bg-white/5 p-6 text-center">
<p className="text-red-300">{error}</p>
<button className="mt-4 rounded-xl border border-white/20 px-4 py-2 text-sm" onClick={load}>重试</button>
</div>
) : null}
{!loading && !error && data ? (
<div className="space-y-5">
{/* ① 灵魂标签 */}
<Section title="灵魂标签">
{(vis) => (
<div className="flex flex-wrap justify-center gap-2">
{(data.soulTags || []).map((t, i) => (
<span key={i} className="rounded-full border border-white/30 px-4 py-1.5 text-sm text-white/90"
style={{ opacity: vis ? 1 : 0, transform: vis ? 'scale(1)' : 'scale(0.8)', transition: `opacity 0.4s ease ${0.2 + i * 0.1}s, transform 0.4s cubic-bezier(0.34,1.56,0.64,1) ${0.2 + i * 0.1}s` }}>
{t}
</span>
))}
</div>
)}
</Section>
{/* ② 当下处境 */}
<Section title="当下处境">
{(vis) => (
<>
<h3 className="text-base font-medium text-white/95">{data.currentState?.title}</h3>
<p className="mt-2 text-sm leading-7 text-white/80">{data.currentState?.summary}</p>
<div className="mt-4">
<div className="h-1.5 overflow-hidden rounded-full bg-white/10">
<div className="h-full rounded-full bg-gradient-to-r from-sky-400 via-fuchsia-400 to-orange-400"
style={{ width: vis ? `${Math.max(0, Math.min(100, data.currentState?.intensity || 0))}%` : '0%', transition: 'width 1.4s cubic-bezier(0.4,0,0.2,1) 0.3s' }} />
</div>
<p className="mt-1.5 text-xs text-white/40">状态强度 {data.currentState?.intensity || 0} / 100</p>
</div>
</>
)}
</Section>
{/* ③ 五维镜像 */}
<Section title="五维镜像">
{() => (
<>
<Radar scores={data.fiveDim?.scores} />
<p className="mt-3 text-sm leading-7 text-white/80">{data.fiveDim?.interpretation}</p>
</>
)}
</Section>
{/* ④ 性格解读 */}
<Section title="性格解读">
{(vis) => (
<div className="space-y-3">
{(data.personalityReading || []).map((item, i) => (
<article key={i} className="rounded-xl border border-white/10 bg-black/20 p-4"
style={{ opacity: vis ? 1 : 0, transform: vis ? 'translateX(0)' : 'translateX(-20px)', transition: `opacity 0.6s ease ${i * 0.15}s, transform 0.6s ease ${i * 0.15}s` }}>
<h3 className="text-sm font-semibold text-white">{item.point}</h3>
<blockquote className="mt-2 border-l-2 border-white/25 pl-3 text-sm italic text-white/55">{item.quote}</blockquote>
<p className="mt-2 text-sm leading-7 text-white/80">{item.explain}</p>
</article>
))}
</div>
)}
</Section>
{/* ⑤ 潜能与盲区 */}
<Section title="潜能与盲区">
{(vis) => (
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-xl border border-emerald-400/30 bg-emerald-500/10 p-4"
style={{ opacity: vis ? 1 : 0, transform: vis ? 'translateX(0)' : 'translateX(-16px)', transition: 'opacity 0.6s ease 0.1s, transform 0.6s ease 0.1s' }}>
<h3 className="text-sm font-semibold text-emerald-200 mb-2"> 潜能</h3>
<ul className="space-y-2">{(data.potentialBlindspots?.potentials || []).map((p, i) => <li key={i} className="text-sm text-white/80 leading-6">{p}</li>)}</ul>
</div>
<div className="rounded-xl border border-amber-400/30 bg-amber-500/10 p-4"
style={{ opacity: vis ? 1 : 0, transform: vis ? 'translateX(0)' : 'translateX(16px)', transition: 'opacity 0.6s ease 0.2s, transform 0.6s ease 0.2s' }}>
<h3 className="text-sm font-semibold text-amber-200 mb-2"> 盲区</h3>
<ul className="space-y-2">{(data.potentialBlindspots?.blindspots || []).map((p, i) => <li key={i} className="text-sm text-white/80 leading-6">{p}</li>)}</ul>
</div>
</div>
)}
</Section>
{/* ⑥ MBTI */}
<Section title="MBTI 人格">
{(vis) => (
<>
<div className="flex items-center gap-4 mb-3">
<div className={`rounded-2xl border-2 px-5 py-3 text-center ${mbtiColor(data.mbti?.type)}`}
style={{ opacity: vis ? 1 : 0, transform: vis ? 'scale(1)' : 'scale(0.6)', transition: 'opacity 0.5s ease 0.1s, transform 0.6s cubic-bezier(0.34,1.56,0.64,1) 0.1s' }}>
<p className="text-2xl font-bold tracking-widest">{data.mbti?.type}</p>
<p className="text-xs mt-0.5 opacity-70">{data.mbti?.typeName}</p>
</div>
</div>
<p className="text-sm leading-7 text-white/80">{data.mbti?.description}</p>
</>
)}
</Section>
{/* ⑦ 星座共鸣(可选) */}
{data.zodiac ? (
<Section title="星座共鸣">
{(vis) => (
<>
<div className="flex items-center gap-4 mb-3">
<span className="text-5xl leading-none"
style={{ opacity: vis ? 1 : 0, transform: vis ? 'rotate(0deg) scale(1)' : 'rotate(-20deg) scale(0.5)', transition: 'opacity 0.6s ease 0.15s, transform 0.7s cubic-bezier(0.34,1.56,0.64,1) 0.15s' }}>
{ZODIAC_SYMBOL[data.zodiac.sign] || '✦'}
</span>
<div>
<p className="text-lg font-semibold">{data.zodiac.name}</p>
<p className="text-xs text-white/45 mt-0.5 italic">{data.zodiac.lingjingLine}</p>
</div>
</div>
<p className="text-sm leading-7 text-white/80">{data.zodiac.fusionText}</p>
</>
)}
</Section>
) : null}
{/* ⑧ 当下信号 */}
<Section title="当下信号">
{() => (
<>
<div className="flex items-center justify-between gap-3 rounded-xl border border-white/10 bg-black/20 p-4 mb-3">
<p className="text-base font-semibold">{data.presentSignal?.signalName}</p>
<span className={`rounded-full px-3 py-1 text-xs font-medium ${data.presentSignal?.urgency === 'high' ? 'bg-red-500/20 text-red-300' : data.presentSignal?.urgency === 'medium' ? 'bg-yellow-500/20 text-yellow-300' : 'bg-sky-500/20 text-sky-300'}`}>
{data.presentSignal?.urgency === 'high' ? '紧迫' : data.presentSignal?.urgency === 'medium' ? '适时' : '从容'}
</span>
</div>
<div className="space-y-2.5 text-sm text-white/80">
<p><span className="text-white/45 mr-1">触发信号</span>{data.presentSignal?.trigger}</p>
<p><span className="text-white/45 mr-1">含义</span>{data.presentSignal?.meaning}</p>
<p><span className="text-white/45 mr-1">错过代价</span>{data.presentSignal?.riskIfMissed}</p>
</div>
</>
)}
</Section>
{/* ⑨ 支点行动 + 收束金句 */}
<Section title="支点行动">
{(vis) => (
<>
<div className="rounded-xl border border-indigo-400/40 bg-indigo-500/10 p-4 text-white font-medium leading-7 mb-4">
{data.pivotAction?.onePivot}
</div>
<div className="grid gap-3 md:grid-cols-3">
{(data.pivotAction?.threeStarts || []).map((a, i) => (
<div key={i} className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-white/85"
style={{ opacity: vis ? 1 : 0, transform: vis ? 'translateY(0)' : 'translateY(16px)', transition: `opacity 0.5s ease ${i * 0.12}s, transform 0.5s ease ${i * 0.12}s` }}>
<span className="text-white/30 text-xs mr-1.5">0{i + 1}</span>{a}
</div>
))}
</div>
<div className="mt-12 mb-4 text-center px-4"
style={{ opacity: vis ? 1 : 0, transition: 'opacity 1.4s ease 0.6s' }}>
<p className="text-xl font-light tracking-wide text-white/85 leading-relaxed">
&ldquo;{data.closingLine}&rdquo;
</p>
</div>
</>
)}
</Section>
</div>
) : null}
</div>
</main>
);
}