304 lines
10 KiB
JavaScript
304 lines
10 KiB
JavaScript
'use client';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
|
|
// ── 星空Canvas ──────────────────────────────────────────
|
|
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);
|
|
// 0: 黑 → 1: 灵镜出现 → 2: 灵镜消失 → 3: 副标题出现 → 4: 副标题消失 → 5: 按钮出现
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ── 单问题沉浸视图 ────────────────────────────────────────
|
|
function QuestionView({ question, onAnswer, questionIndex, total }) {
|
|
const [visible, setVisible] = useState(false);
|
|
const [input, setInput] = useState('');
|
|
const [leaving, setLeaving] = useState(false);
|
|
const inputRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
setVisible(false);
|
|
setInput('');
|
|
setLeaving(false);
|
|
const t = setTimeout(() => {
|
|
setVisible(true);
|
|
setTimeout(() => inputRef.current?.focus(), 600);
|
|
}, 100);
|
|
return () => clearTimeout(t);
|
|
}, [question]);
|
|
|
|
const submit = () => {
|
|
if (!input.trim()) return;
|
|
setLeaving(true);
|
|
setTimeout(() => onAnswer(input.trim()), 800);
|
|
};
|
|
|
|
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-md 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>
|
|
|
|
{/* 输入 */}
|
|
<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();
|
|
}
|
|
}}
|
|
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}
|
|
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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 主页面 ────────────────────────────────────────────────
|
|
export default function ChatPage() {
|
|
const [stage, setStage] = useState('intro'); // intro | chat | waiting | loading | done
|
|
const [question, setQuestion] = useState('');
|
|
const [qIndex, setQIndex] = useState(0);
|
|
const [total, setTotal] = useState(30);
|
|
const [sid, setSid] = useState('');
|
|
const router = useRouter();
|
|
|
|
const startChat = async () => {
|
|
setStage('loading');
|
|
try {
|
|
const r = await fetch('/api/session/new', { method: 'POST' });
|
|
const d = await r.json();
|
|
setSid(d.sessionId);
|
|
localStorage.setItem('lingjing_sid', d.sessionId);
|
|
setQuestion(d.opening);
|
|
setQIndex(1);
|
|
setTotal(d.total || 30);
|
|
setStage('chat');
|
|
} catch {
|
|
setStage('chat');
|
|
setQuestion('开始前,我想先简单认识你一下。就像朋友聊天,想到什么说什么就行,没有标准答案。咱们先从几个轻松的小问题开始,可以吗?');
|
|
setQIndex(1);
|
|
}
|
|
};
|
|
|
|
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;
|
|
}
|
|
setQuestion(d.reply);
|
|
setQIndex((i) => i + 1);
|
|
setStage('chat');
|
|
} catch {
|
|
setQuestion('网络有点问题,我们继续。你刚才的回答我已经记住了。');
|
|
setQIndex((i) => i + 1);
|
|
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
|
|
question={question}
|
|
onAnswer={handleAnswer}
|
|
questionIndex={qIndex}
|
|
total={total}
|
|
/>
|
|
)}
|
|
{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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|