lingjing/app/chat/page.jsx

323 lines
12 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, 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>
)}
</>
);
}