feat: P0f 会话分享链接 + 全栈验证

- 后端: POST /sessions/:id/share 生成 HMAC 签名分享令牌
- 后端: GET /sandbox/shared/:token 公开查看分享会话
- 前端: sandbox/page.tsx 新增「复制分享链接」按钮
- 前端: /sandbox/share?token=xxx 分享查看页面
- 全栈: 后端 build + 前端 61 页 + 92 tests 全部通过
This commit is contained in:
yuzhiran-dev
2026-05-18 10:43:25 +08:00
parent f2a6bb93f9
commit cba785e6bd
9 changed files with 187 additions and 4 deletions
+20 -1
View File
@@ -224,6 +224,21 @@ export default function SandboxPage() {
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
}
async function shareSessionLink() {
if (!getToken() || !currentSessionId) { alert('请先登录'); return; }
try {
const tk = getToken();
const res = await fetch(`${API_BASE}/sandbox/sessions/${currentSessionId}/share`, {
method: 'POST', headers: { Authorization: `Bearer ${tk}` },
});
const data = await res.json();
if (data.shareUrl) {
await navigator.clipboard.writeText(data.shareUrl);
alert('链接已复制');
}
} catch { alert('生成分享链接失败'); }
}
function shareToCommunity(content: string, title?: string) {
if (!getToken()) { alert('请先登录'); return; }
apiFetch('/community/posts', {
@@ -421,7 +436,11 @@ export default function SandboxPage() {
</div>
)}
{!sending && messages.length > 2 && (
<div className="flex justify-end">
<div className="flex justify-end gap-2">
<button onClick={shareSessionLink}
className="text-xs text-brand-600 hover:underline">
{t.sandbox.copyShareLink}
</button>
<button onClick={() => {
const lastAssistantMsg = [...messages].reverse().find(m => m.role === 'assistant');
if (lastAssistantMsg) shareToCommunity(lastAssistantMsg.content);
+102
View File
@@ -0,0 +1,102 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface SharedMessage {
role: string;
content: string;
}
interface SharedSession {
id: number;
model: string;
title: string;
createdAt: string;
messages: SharedMessage[];
}
function SharedSessionInner() {
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [session, setSession] = useState<SharedSession | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!token) { setError('缺少分享参数'); setLoading(false); return; }
fetch(`${API_BASE}/sandbox/shared/${encodeURIComponent(token)}`)
.then(r => { if (!r.ok) throw new Error('分享链接无效'); return r.json(); })
.then(data => setSession(data))
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [token]);
if (loading) return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-4 w-72 mb-8" />
<Skeleton className="h-64 w-full rounded-2xl" />
</div>
);
if (error || !session) return (
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
<div className="text-4xl mb-4">🔗</div>
<h1 className="text-xl font-bold text-foreground mb-2"></h1>
<p className="text-muted-foreground mb-6">{error || '该分享链接可能已过期或不存在'}</p>
<Link href="/sandbox" className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
AI
</Link>
</div>
);
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<Link href="/sandbox" className="text-sm text-muted-foreground hover:text-brand-600 mb-2 inline-block">
&larr; AI
</Link>
<h1 className="text-2xl font-bold text-foreground">{session.title}</h1>
<p className="text-sm text-muted-foreground mt-1">
: {session.model} · {new Date(session.createdAt).toLocaleDateString()}
</p>
</div>
<div className="space-y-4">
{session.messages.map((msg, i) => (
<div key={i} className={`flex items-start gap-3 ${msg.role === 'user' ? 'justify-end' : ''}`}>
{msg.role === 'assistant' && (
<div className="w-8 h-8 bg-brand-600 rounded-xl flex items-center justify-center text-white text-sm font-bold shrink-0">Y</div>
)}
<div className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
msg.role === 'user' ? 'bg-brand-600 text-white' : 'bg-muted text-foreground'
}`}>
{msg.content}
</div>
{msg.role === 'user' && (
<div className="w-8 h-8 bg-muted-foreground/20 rounded-xl flex items-center justify-center text-xs font-bold shrink-0"></div>
)}
</div>
))}
</div>
</div>
);
}
export default function SharedSessionPage() {
return (
<Suspense fallback={
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-64 w-full rounded-2xl" />
</div>
}>
<SharedSessionInner />
</Suspense>
);
}