435 lines
21 KiB
JavaScript
435 lines
21 KiB
JavaScript
'use client';
|
||
|
||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
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);
|
||
const [showShare, setShowShare] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
const [showRestart, setShowRestart] = useState(false);
|
||
const [isOwner, setIsOwner] = useState(false);
|
||
const router = useRouter();
|
||
|
||
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') {
|
||
if (d?.error === 'session_not_found') {
|
||
throw new Error('SESSION_NOT_FOUND');
|
||
}
|
||
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(() => {
|
||
if (sid) {
|
||
const localSid = localStorage.getItem('lingjing_sid') || '';
|
||
setIsOwner(localSid === sid);
|
||
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">{isOwner ? '你的灵镜报告' : 'ta的灵镜报告'}</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">
|
||
{error === 'SESSION_NOT_FOUND' ? (
|
||
<>
|
||
<p className="text-white/70 mb-2">找不到你的报告记录</p>
|
||
<p className="text-xs text-white/40 mb-4">可能是之前的对话已过期</p>
|
||
<button className="rounded-xl border border-white/20 bg-white/10 px-5 py-2.5 text-sm text-white" onClick={() => { localStorage.removeItem('lingjing_sid'); router.replace('/chat'); }}>重新开始</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<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">
|
||
“{data.closingLine}”
|
||
</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
</Section>
|
||
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 底部操作区(仅本人可见) */}
|
||
{!loading && !error && data && isOwner ? (
|
||
<div className="mt-10 flex flex-col items-center gap-4 pb-12">
|
||
<button
|
||
onClick={() => setShowShare(true)}
|
||
className="w-full max-w-xs rounded-full border border-white/30 bg-white/10 py-3 text-sm tracking-widest text-white/90 backdrop-blur-sm transition hover:bg-white/20"
|
||
>
|
||
分享给朋友
|
||
</button>
|
||
<button
|
||
onClick={() => setShowRestart(true)}
|
||
className="text-xs text-white/30 underline underline-offset-4 hover:text-white/50 transition"
|
||
>
|
||
重新开始
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
</div>
|
||
|
||
{/* 分享弹窗 */}
|
||
{showShare ? (
|
||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowShare(false)}>
|
||
<div className="w-full max-w-lg rounded-t-3xl bg-[#0e0b1f] border-t border-white/10 p-6 pb-10" onClick={e => e.stopPropagation()}>
|
||
<h3 className="text-center text-base font-semibold text-white mb-1">分享给朋友</h3>
|
||
<p className="text-center text-xs text-white/40 mb-5">朋友可以直接看到你的完整报告</p>
|
||
<div className="flex items-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-3 mb-4">
|
||
<p className="flex-1 truncate text-xs text-white/60 font-mono">
|
||
{typeof window !== 'undefined' ? window.location.href : ''}
|
||
</p>
|
||
<button
|
||
onClick={() => {
|
||
if (typeof window !== 'undefined') {
|
||
navigator.clipboard.writeText(window.location.href).then(() => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
});
|
||
}
|
||
}}
|
||
className="shrink-0 rounded-lg bg-white/15 px-3 py-1.5 text-xs text-white hover:bg-white/25 transition"
|
||
>
|
||
{copied ? '已复制 ✓' : '复制链接'}
|
||
</button>
|
||
</div>
|
||
<p className="text-center text-xs text-white/30">朋友看完可以在页面底部做自己的报告</p>
|
||
<button onClick={() => setShowShare(false)} className="mt-5 w-full rounded-full border border-white/15 py-3 text-sm text-white/50 hover:text-white/80 transition">关闭</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 重做确认弹窗 */}
|
||
{showRestart ? (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowRestart(false)}>
|
||
<div className="w-full max-w-sm mx-4 rounded-2xl bg-[#0e0b1f] border border-white/10 p-6" onClick={e => e.stopPropagation()}>
|
||
<h3 className="text-base font-semibold text-white mb-2">重新开始?</h3>
|
||
<p className="text-sm text-white/50 leading-6 mb-6">重新开始会生成全新报告,当前报告不会保留,确定吗?</p>
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={() => setShowRestart(false)}
|
||
className="flex-1 rounded-full border border-white/20 py-2.5 text-sm text-white/60 hover:text-white/90 transition"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
localStorage.removeItem('lingjing_sid');
|
||
router.replace('/chat');
|
||
}}
|
||
className="flex-1 rounded-full bg-white/15 py-2.5 text-sm text-white hover:bg-white/25 transition"
|
||
>
|
||
确定重新开始
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* 分享页底部引导(仅非本人查看时显示) */}
|
||
{!isOwner ? (
|
||
<div className="relative z-10 border-t border-white/5 bg-[#05030f] px-4 py-8 text-center">
|
||
<p className="text-sm text-white/40 mb-4">看完 ta 的报告?来做你自己的</p>
|
||
<button
|
||
onClick={() => { localStorage.removeItem('lingjing_sid'); router.push('/chat'); }}
|
||
className="rounded-full border border-white/20 px-8 py-3 text-sm text-white/80 hover:bg-white/10 transition"
|
||
>
|
||
开始我的灵镜报告 →
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
</main>
|
||
);
|
||
}
|