323 lines
12 KiB
JavaScript
323 lines
12 KiB
JavaScript
'use client';
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
|
||
function Starfield() {
|
||
const canvasRef = useRef(null);
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
const ctx = canvas.getContext('2d');
|
||
let animId;
|
||
const STARS = 180;
|
||
const stars = Array.from({ length: STARS }, () => ({
|
||
x: Math.random() * window.innerWidth,
|
||
y: Math.random() * window.innerHeight,
|
||
z: Math.random() * window.innerWidth,
|
||
pz: 0,
|
||
}));
|
||
|
||
const resize = () => {
|
||
canvas.width = window.innerWidth;
|
||
canvas.height = window.innerHeight;
|
||
};
|
||
resize();
|
||
window.addEventListener('resize', resize);
|
||
|
||
const cx = () => canvas.width / 2;
|
||
const cy = () => canvas.height / 2;
|
||
|
||
const tick = () => {
|
||
ctx.fillStyle = 'rgba(5,3,15,0.25)';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
stars.forEach((s) => {
|
||
s.pz = s.z;
|
||
s.z -= 4;
|
||
if (s.z <= 0) {
|
||
s.x = Math.random() * canvas.width;
|
||
s.y = Math.random() * canvas.height;
|
||
s.z = canvas.width;
|
||
s.pz = s.z;
|
||
}
|
||
const sx = (s.x - cx()) * (canvas.width / s.z) + cx();
|
||
const sy = (s.y - cy()) * (canvas.width / s.z) + cy();
|
||
const px = (s.x - cx()) * (canvas.width / s.pz) + cx();
|
||
const py = (s.y - cy()) * (canvas.width / s.pz) + cy();
|
||
const size = Math.max(0.3, (1 - s.z / canvas.width) * 2.5);
|
||
const alpha = 1 - s.z / canvas.width;
|
||
|
||
ctx.strokeStyle = `rgba(200,190,255,${alpha})`;
|
||
ctx.lineWidth = size;
|
||
ctx.beginPath();
|
||
ctx.moveTo(px, py);
|
||
ctx.lineTo(sx, sy);
|
||
ctx.stroke();
|
||
});
|
||
animId = requestAnimationFrame(tick);
|
||
};
|
||
tick();
|
||
return () => {
|
||
cancelAnimationFrame(animId);
|
||
window.removeEventListener('resize', resize);
|
||
};
|
||
}, []);
|
||
return <canvas ref={canvasRef} className="fixed inset-0 z-0" style={{ background: '#05030f' }} />;
|
||
}
|
||
|
||
function FadeText({ text, visible, className = '' }) {
|
||
return (
|
||
<div
|
||
className={`transition-all duration-1000 ${className}`}
|
||
style={{ opacity: visible ? 1 : 0, transform: visible ? 'translateY(0)' : 'translateY(12px)' }}
|
||
>
|
||
{text}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Intro({ onDone }) {
|
||
const [phase, setPhase] = useState(0);
|
||
useEffect(() => {
|
||
const timers = [
|
||
setTimeout(() => setPhase(1), 600),
|
||
setTimeout(() => setPhase(2), 2200),
|
||
setTimeout(() => setPhase(3), 3000),
|
||
setTimeout(() => setPhase(4), 4800),
|
||
setTimeout(() => setPhase(5), 5600),
|
||
];
|
||
return () => timers.forEach(clearTimeout);
|
||
}, []);
|
||
|
||
return (
|
||
<div className="relative z-10 flex h-screen h-dvh flex-col items-center justify-center px-8 text-center">
|
||
<FadeText text="灵镜" visible={phase === 1 || phase === 2} className="text-5xl font-light tracking-[0.3em] text-white/90" />
|
||
<FadeText text="一场温柔但有穿透力的对话" visible={phase === 3 || phase === 4} className="text-lg font-light tracking-widest text-white/60" />
|
||
<div className="transition-all duration-1000" style={{ opacity: phase >= 5 ? 1 : 0, transform: phase >= 5 ? 'translateY(0)' : 'translateY(12px)' }}>
|
||
<button onClick={onDone} style={{ color: 'white', textDecoration: 'underline', textUnderlineOffset: '4px', fontSize: '0.875rem', letterSpacing: '0.2em', background: 'none', border: 'none', cursor: 'pointer' }}>
|
||
开始探索
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const fixedOptions = {
|
||
fixed_gender: ['男生', '女生', '不想说'],
|
||
fixed_age: ['20以下', '20-25', '26-30', '31-40', '40以上'],
|
||
fixed_birth_month: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
|
||
fixed_birth_day: Array.from({ length: 31 }, (_, i) => `${i + 1}日`),
|
||
};
|
||
|
||
function QuestionView({ prompt, onAnswer }) {
|
||
const [visible, setVisible] = useState(false);
|
||
const [input, setInput] = useState('');
|
||
const [leaving, setLeaving] = useState(false);
|
||
const [picked, setPicked] = useState('');
|
||
const inputRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
setVisible(false);
|
||
setInput('');
|
||
setLeaving(false);
|
||
setPicked('');
|
||
const t = setTimeout(() => {
|
||
setVisible(true);
|
||
if ((prompt?.type || 'text') === 'text') {
|
||
setTimeout(() => inputRef.current?.focus(), 600);
|
||
}
|
||
}, 100);
|
||
return () => clearTimeout(t);
|
||
}, [prompt]);
|
||
|
||
const submit = (val) => {
|
||
const answer = (val || '').trim();
|
||
if (!answer) return;
|
||
setLeaving(true);
|
||
setTimeout(() => onAnswer(answer), 700);
|
||
};
|
||
|
||
const type = prompt?.type || 'text';
|
||
const isChoice = type === 'choice' || type.startsWith('fixed_');
|
||
const question = prompt?.question || prompt?.reply || '';
|
||
const options = type === 'choice' ? (prompt.options || []) : (fixedOptions[type] || []);
|
||
const compact = type === 'fixed_birth_day';
|
||
|
||
return (
|
||
<div className="chat-page relative z-10 flex h-screen h-dvh flex-col items-center justify-center px-8">
|
||
<div className="w-full max-w-2xl space-y-8">
|
||
<div
|
||
className="text-center text-lg font-light leading-relaxed tracking-wide text-white/85 transition-all duration-700"
|
||
style={{
|
||
opacity: visible && !leaving ? 1 : 0,
|
||
transform: visible && !leaving ? 'translateY(0)' : leaving ? 'translateY(-16px)' : 'translateY(16px)',
|
||
}}
|
||
>
|
||
{question}
|
||
</div>
|
||
|
||
{isChoice ? (
|
||
<div
|
||
className="flex flex-wrap justify-center gap-3 transition-all duration-700"
|
||
style={{ opacity: visible && !leaving ? 1 : 0, transform: visible && !leaving ? 'translateY(0)' : 'translateY(16px)' }}
|
||
>
|
||
{options.map((op, i) => {
|
||
const selected = picked === op;
|
||
const othersFade = picked && picked !== op;
|
||
return (
|
||
<button
|
||
key={op}
|
||
type="button"
|
||
onClick={() => {
|
||
if (picked) return;
|
||
setPicked(op);
|
||
const ms = selected ? 300 : 300;
|
||
setTimeout(() => submit(op), ms);
|
||
}}
|
||
className={`rounded-full border border-white/30 text-white/90 transition-all duration-300 ${compact ? 'px-3 py-1.5 text-sm' : 'px-5 py-2.5 text-base'} ${selected ? 'bg-white/25' : 'bg-white/10 hover:bg-white/20'}`}
|
||
style={{
|
||
animation: `bubbleFloat ${2 + (i % 3) * 0.5}s ease-in-out infinite`,
|
||
animationDelay: `${i * 0.12}s`,
|
||
opacity: selected ? 0 : othersFade ? 0 : 1,
|
||
transform: selected ? 'scale(1.3)' : othersFade ? 'scale(0.8)' : 'scale(1)',
|
||
}}
|
||
>
|
||
{op}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="transition-all duration-700"
|
||
style={{ opacity: visible && !leaving ? 1 : 0, transform: visible && !leaving ? 'translateY(0)' : 'translateY(16px)' }}
|
||
>
|
||
<textarea
|
||
ref={inputRef}
|
||
rows={3}
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
submit(input);
|
||
}
|
||
}}
|
||
placeholder="输入你的回答,或输入 跳过"
|
||
className="w-full resize-none rounded-2xl border border-white/10 bg-white/5 px-5 py-4 text-sm text-white/80 placeholder-white/20 outline-none focus:border-white/25 transition-colors"
|
||
/>
|
||
<div className="mt-4 flex justify-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => submit(input)}
|
||
disabled={!input.trim()}
|
||
className="continue-btn !text-white underline underline-offset-4 text-sm tracking-widest disabled:opacity-100"
|
||
style={{ color: '#fff', background: 'none', border: 'none', cursor: input.trim() ? 'pointer' : 'not-allowed', letterSpacing: '0.1em' }}
|
||
>
|
||
继续 →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<style>{`
|
||
@keyframes bubbleFloat {
|
||
0%, 100% { transform: translateY(0); }
|
||
50% { transform: translateY(-8px); }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ChatPage() {
|
||
const [stage, setStage] = useState('intro');
|
||
const [prompt, setPrompt] = useState({ type: 'text', reply: '' });
|
||
const [sid, setSid] = useState('');
|
||
const router = useRouter();
|
||
|
||
// 启动时检查:如果已有session且报告done,直接跳报告页
|
||
useEffect(() => {
|
||
const savedSid = localStorage.getItem('lingjing_sid');
|
||
if (!savedSid) return;
|
||
fetch(`/api/report?sessionId=${encodeURIComponent(savedSid)}`)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d?.status === 'done') {
|
||
router.replace(`/report-preview?sessionId=${encodeURIComponent(savedSid)}`);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}, [router]);
|
||
|
||
const startChat = async () => {
|
||
setStage('loading');
|
||
try {
|
||
const r = await fetch('/api/session/new', { method: 'POST' });
|
||
const d = await r.json();
|
||
const sessionId = d.sessionId || '';
|
||
setSid(sessionId);
|
||
localStorage.setItem('lingjing_sid', sessionId);
|
||
setPrompt({ type: 'text', reply: d.opening || d.reply || '' });
|
||
setStage('chat');
|
||
} catch {
|
||
setStage('chat');
|
||
setPrompt({ type: 'text', reply: '开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?' });
|
||
}
|
||
};
|
||
|
||
const handleAnswer = async (answer) => {
|
||
setStage('waiting');
|
||
try {
|
||
const r = await fetch('/api/chat', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ sessionId: sid, answer }),
|
||
});
|
||
const d = await r.json();
|
||
if (d.done) {
|
||
setStage('done');
|
||
const targetSid = d.sessionId || sid;
|
||
setTimeout(() => router.push(`/waiting?sessionId=${encodeURIComponent(targetSid || '')}`), 1200);
|
||
return;
|
||
}
|
||
if (d.type === 'choice') {
|
||
setPrompt({ type: 'choice', question: d.question, options: d.options || [] });
|
||
} else if (d.type && d.type.startsWith('fixed_')) {
|
||
setPrompt({ type: d.type, question: d.question });
|
||
} else {
|
||
setPrompt({ type: 'text', reply: d.reply || d.question || '我听到了,我们继续。' });
|
||
}
|
||
setStage('chat');
|
||
} catch {
|
||
setPrompt({ type: 'text', reply: '网络有点问题,我们继续。你刚才的回答我已经记住了。' });
|
||
setStage('chat');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Starfield />
|
||
{stage === 'intro' && <Intro onDone={startChat} />}
|
||
{stage === 'waiting' && (
|
||
<div className="relative z-10 flex h-screen h-dvh items-center justify-center">
|
||
<div className="text-base tracking-[0.3em] text-white/60" style={{ animation: 'breathe 2.4s ease-in-out infinite' }}>
|
||
探索中……
|
||
</div>
|
||
<style>{`@keyframes breathe { 0%,100%{opacity:0.2;} 50%{opacity:0.9;} }`}</style>
|
||
</div>
|
||
)}
|
||
{stage === 'loading' && (
|
||
<div className="relative z-10 flex h-screen h-dvh items-center justify-center">
|
||
<div className="text-sm tracking-widest text-white/30 animate-pulse">正在连接...</div>
|
||
</div>
|
||
)}
|
||
{stage === 'chat' && <QuestionView prompt={prompt} onAnswer={handleAnswer} />}
|
||
{stage === 'done' && (
|
||
<div className="relative z-10 flex h-screen h-dvh items-center justify-center">
|
||
<div className="text-sm tracking-widest text-white/30 animate-pulse">正在生成你的报告...</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|