lingjing/app/report-preview/page.jsx

435 lines
21 KiB
JavaScript
Raw Permalink 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 { 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">
&ldquo;{data.closingLine}&rdquo;
</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>
);
}