diff --git a/app/chat/page.jsx b/app/chat/page.jsx index 75f2590..4ef9611 100644 --- a/app/chat/page.jsx +++ b/app/chat/page.jsx @@ -246,7 +246,8 @@ export default function ChatPage() { const d = await r.json(); if (d.done) { setStage('done'); - setTimeout(() => router.push('/waiting'), 1200); + const targetSid = d.sessionId || sid; + setTimeout(() => router.push(`/waiting?sessionId=${encodeURIComponent(targetSid || '')}`), 1200); return; } setQuestion(d.reply); diff --git a/app/report-preview/page.jsx b/app/report-preview/page.jsx index 095e746..fb771fa 100644 --- a/app/report-preview/page.jsx +++ b/app/report-preview/page.jsx @@ -1,30 +1,317 @@ 'use client'; -import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import Shell from '../../components/Shell'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import Starfield from '../../components/Starfield'; -export default function ReportPreviewPage() { - const [data, setData] = useState(null); +const ZODIAC_SYMBOL = { + aries: '♈', taurus: '♉', gemini: '♊', cancer: '♋', leo: '♌', virgo: '♍', libra: '♎', scorpio: '♏', sagittarius: '♐', capricorn: '♑', aquarius: '♒', pisces: '♓', +}; - useEffect(() => { - const sid = localStorage.getItem('lingjing_sid'); - if (!sid) return; - fetch(`/api/report/preview?sessionId=${encodeURIComponent(sid)}`) - .then((r) => r.json()) - .then(setData); - }, []); +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 ( - -
-

{data?.userSnapshot?.summary || '你现在处在“想改变,但还没找到最顺手路径”的阶段。'}

-

{data?.highlight?.content || '你对变化是有行动意愿的,只是容易在选择上分散精力。'}

-

{data?.teaser?.lockedHint || '完整版将告诉你哪条路线最适合你现在的节奏。'}

- - 查看完整报告(演示) - -
-
+ + {[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 ; + })} + {labels.map((l, i) => { + const a = -Math.PI / 2 + Math.PI * 2 * i / 5; + return ( + + + {l} + + ); + })} + + {points.map((p, i) => ( + + + {values[i]} + + ))} + + ); +} + +// 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 ( +
+

{title}

+ {typeof children === 'function' ? children(visible) : children} +
+ ); +} + +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 ( +
+ +
+ +
+

LINGJING REPORT

+

你的灵镜报告

+
+ + {loading ? ( +
+

正在生成你的灵镜报告……

+

通常需要30-60秒

+ +
+ ) : null} + {error ? ( +
+

{error}

+ +
+ ) : null} + + {!loading && !error && data ? ( +
+ + {/* ① 灵魂标签 */} +
+ {(vis) => ( +
+ {(data.soulTags || []).map((t, i) => ( + + {t} + + ))} +
+ )} +
+ + {/* ② 当下处境 */} +
+ {(vis) => ( + <> +

{data.currentState?.title}

+

{data.currentState?.summary}

+
+
+
+
+

状态强度 {data.currentState?.intensity || 0} / 100

+
+ + )} +
+ + {/* ③ 五维镜像 */} +
+ {() => ( + <> + +

{data.fiveDim?.interpretation}

+ + )} +
+ + {/* ④ 性格解读 */} +
+ {(vis) => ( +
+ {(data.personalityReading || []).map((item, i) => ( +
+

{item.point}

+
{item.quote}
+

{item.explain}

+
+ ))} +
+ )} +
+ + {/* ⑤ 潜能与盲区 */} +
+ {(vis) => ( +
+
+

✦ 潜能

+
    {(data.potentialBlindspots?.potentials || []).map((p, i) =>
  • {p}
  • )}
+
+
+

◈ 盲区

+
    {(data.potentialBlindspots?.blindspots || []).map((p, i) =>
  • {p}
  • )}
+
+
+ )} +
+ + {/* ⑥ MBTI */} +
+ {(vis) => ( + <> +
+
+

{data.mbti?.type}

+

{data.mbti?.typeName}

+
+
+

{data.mbti?.description}

+ + )} +
+ + {/* ⑦ 星座共鸣(可选) */} + {data.zodiac ? ( +
+ {(vis) => ( + <> +
+ + {ZODIAC_SYMBOL[data.zodiac.sign] || '✦'} + +
+

{data.zodiac.name}

+

{data.zodiac.lingjingLine}

+
+
+

{data.zodiac.fusionText}

+ + )} +
+ ) : null} + + {/* ⑧ 当下信号 */} +
+ {() => ( + <> +
+

{data.presentSignal?.signalName}

+ + {data.presentSignal?.urgency === 'high' ? '紧迫' : data.presentSignal?.urgency === 'medium' ? '适时' : '从容'} + +
+
+

触发信号:{data.presentSignal?.trigger}

+

含义:{data.presentSignal?.meaning}

+

错过代价:{data.presentSignal?.riskIfMissed}

+
+ + )} +
+ + {/* ⑨ 支点行动 + 收束金句 */} +
+ {(vis) => ( + <> +
+ {data.pivotAction?.onePivot} +
+
+ {(data.pivotAction?.threeStarts || []).map((a, i) => ( +
+ 0{i + 1}{a} +
+ ))} +
+
+

+ “{data.closingLine}” +

+
+ + )} +
+ +
+ ) : null} +
+
); } diff --git a/app/waiting/page.jsx b/app/waiting/page.jsx index d720c94..1178ab6 100644 --- a/app/waiting/page.jsx +++ b/app/waiting/page.jsx @@ -1,35 +1,137 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; -import Shell from '../../components/Shell'; +import Starfield from '../../components/Starfield'; + +const MESSAGES = [ + '正在整理你的回答线索……', + '正在校准你的五维镜像……', + '正在提炼你的当下信号……', + '正在生成你的支点行动……', +]; export default function WaitingPage() { - const [progress, setProgress] = useState(8); const router = useRouter(); + const [querySid, setQuerySid] = useState(''); + const [idx, setIdx] = useState(0); + const [timedOut, setTimedOut] = useState(false); + const [error, setError] = useState(''); + useEffect(() => { + if (typeof window === 'undefined') return; + const sidFromQuery = new URLSearchParams(window.location.search).get('sessionId') || ''; + setQuerySid(sidFromQuery); + }, []); + + const sid = useMemo(() => { + return querySid || (typeof window !== 'undefined' ? localStorage.getItem('lingjing_sid') || '' : ''); + }, [querySid]); useEffect(() => { - const t = setInterval(() => { - setProgress((p) => Math.min(p + 12, 100)); - }, 600); - const done = setTimeout(() => { - const sid = typeof window !== 'undefined' ? localStorage.getItem('lingjing_sid') : ''; - router.push(`/report-preview?sid=${encodeURIComponent(sid || '')}`); - }, 5000); - return () => { - clearInterval(t); - clearTimeout(done); + const t = setInterval(() => setIdx((n) => (n + 1) % MESSAGES.length), 3000); + return () => clearInterval(t); + }, []); + + useEffect(() => { + if (!sid) return; + localStorage.setItem('lingjing_sid', sid); + + let alive = true; + const timeout = setTimeout(() => setTimedOut(true), 90000); // 90秒超时 + + const run = async () => { + try { + const r = await fetch('/api/report', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: sid }), + }); + const d = await r.json(); + if (!alive) return; + if (d?.ok || d?.status === 'done') { + router.replace(`/report-preview?sessionId=${encodeURIComponent(sid)}`); + return; + } + if (d?.status === 'generating') { + poll(); + return; + } + setError(d?.error || '生成失败,请重试'); + } catch { + if (alive) setError('生成失败,请重试'); + } }; - }, [router]); + + const poll = async () => { + let notStartedCount = 0; + for (let i = 0; i < 45; i += 1) { // 45次x2秒=90秒窗口 + await new Promise((r) => setTimeout(r, 2000)); + if (!alive) return; + try { + const g = await fetch(`/api/report?sessionId=${encodeURIComponent(sid)}`); + const d = await g.json(); + if (d?.status === 'done') { + router.replace(`/report-preview?sessionId=${encodeURIComponent(sid)}`); + return; + } + if (d?.status === 'error') { + setError(d?.error || '生成失败,请重试'); + return; + } + if (d?.status === 'not_started') { + notStartedCount += 1; + // 超过5次还没开始,主动触发一次生成 + if (notStartedCount >= 5) { + fetch('/api/report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: sid }) }).catch(() => {}); + notStartedCount = 0; + } + } + } catch { + // ignore single poll failure + } + } + setError('报告生成超时,请重试'); + }; + + run(); + + return () => { + alive = false; + clearTimeout(timeout); + }; + }, [router, sid]); + + const retry = () => { + setTimedOut(false); + setError(''); + window.location.reload(); + }; return ( - -
-
-
-
-

{progress}%

+
+ +
+

灵镜正在为你生成报告

+

{MESSAGES[idx]}

+

已保存你的对话,可放心离开。

+ + {timedOut && !error ? ( +
+ + +
+ ) : null} + + {error ? ( +
+

{error}

+
+ + +
+
+ ) : null}
- +
); } diff --git a/components/Starfield.jsx b/components/Starfield.jsx new file mode 100644 index 0000000..b72a9ef --- /dev/null +++ b/components/Starfield.jsx @@ -0,0 +1,78 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +export default function Starfield({ className = '', animated = false }) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let raf = null; + let width = 0; + let height = 0; + let stars = []; + + const createStars = () => { + const count = Math.max(90, Math.floor((width * height) / 16000)); + stars = Array.from({ length: count }, () => ({ + x: Math.random() * width, + y: Math.random() * height, + r: Math.random() * 1.4 + 0.4, + a: Math.random() * 0.7 + 0.2, + v: Math.random() * 0.08 + 0.02, + })); + }; + + const resize = () => { + const dpr = window.devicePixelRatio || 1; + width = canvas.clientWidth; + height = canvas.clientHeight; + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + createStars(); + draw(); + }; + + const draw = () => { + ctx.clearRect(0, 0, width, height); + const bg = ctx.createLinearGradient(0, 0, 0, height); + bg.addColorStop(0, '#0b0720'); + bg.addColorStop(1, '#05030f'); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, width, height); + + for (const s of stars) { + ctx.beginPath(); + ctx.fillStyle = `rgba(220,225,255,${s.a})`; + ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); + ctx.fill(); + } + }; + + const tick = () => { + for (const s of stars) { + s.a += (Math.random() - 0.5) * s.v; + if (s.a < 0.15) s.a = 0.15; + if (s.a > 0.95) s.a = 0.95; + } + draw(); + raf = window.requestAnimationFrame(tick); + }; + + resize(); + window.addEventListener('resize', resize); + if (animated) raf = window.requestAnimationFrame(tick); + + return () => { + window.removeEventListener('resize', resize); + if (raf) window.cancelAnimationFrame(raf); + }; + }, [animated]); + + return