feat(页面): 添加对话分享展示页面

- 新增 /share/[code] 分享页面
- 支持 Markdown 渲染和代码高亮
- 集成导航组件便于浏览长对话
- 添加分享信息展示和错误处理
This commit is contained in:
gaoziman 2025-12-24 15:59:02 +08:00
parent b0ecf51700
commit 2acce36dbd

View File

@ -0,0 +1,394 @@
'use client';
import { useState, useEffect, use, useCallback, useRef } from 'react';
import Link from 'next/link';
import {
Loader2,
Eye,
MessageSquare,
Clock,
Sparkles,
AlertCircle,
Copy,
Check,
ExternalLink,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownRenderer } from '@/components/markdown/MarkdownRenderer';
import { ShareNavigator } from '@/components/features/ShareNavigator';
interface PageProps {
params: Promise<{ code: string }>;
}
interface ShareData {
share: {
title: string;
description?: string;
viewCount: number;
createdAt: string;
};
conversation: {
id: string;
title: string;
model: string;
messageCount: number;
totalTokens: number;
createdAt: string;
};
messages: Array<{
id: string;
role: string;
content: string;
thinkingContent?: string | null;
toolCalls?: unknown[] | null;
images?: string[] | null;
searchImages?: Array<{
title: string;
imageUrl: string;
width: number;
height: number;
}> | null;
uploadedImages?: string[] | null;
usedTools?: string[] | null;
inputTokens?: number;
outputTokens?: number;
createdAt: string;
}>;
}
// 工具名称中文映射
const TOOL_NAMES: Record<string, string> = {
web_search: '网络搜索',
web_fetch: '网页读取',
mita_search: '秘塔搜索',
mita_reader: '秘塔阅读',
code_execution: '代码执行',
youdao_translate: '有道翻译',
image_search: '图片搜索',
video_search: '视频搜索',
};
// 格式化日期
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
export default function SharePage({ params }: PageProps) {
const { code } = use(params);
const [data, setData] = useState<ShareData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
const fetchShare = async () => {
try {
const response = await fetch(`/api/share/${code}`);
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.error || '加载失败');
}
const shareData = await response.json();
setData(shareData);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
};
fetchShare();
}, [code]);
// 页面加载后处理 URL hash 定位
useEffect(() => {
if (!data || loading) return;
const hash = window.location.hash;
if (hash) {
// 支持 #msg-{id} 或 #msg-{index} 格式
const targetId = hash.slice(1); // 移除 #
const element = document.getElementById(targetId);
if (element) {
// 延迟滚动以确保页面渲染完成
setTimeout(() => {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}
}, [data, loading]);
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Copy error:', err);
}
};
// 加载中
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin text-[#DB6639]" />
<span className="text-gray-500">...</span>
</div>
</div>
);
}
// 错误
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<AlertCircle className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h1 className="text-xl font-medium text-gray-700 mb-2">{error}</h1>
<p className="text-gray-500 mb-6"></p>
<Link
href="/"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#DB6639] text-white rounded-lg hover:bg-[#C25A33] transition-colors"
>
<Sparkles size={18} />
使 LionCode
</Link>
</div>
</div>
);
}
if (!data) return null;
return (
<div className="min-h-screen bg-gray-50">
{/* 顶部导航 */}
<header className="sticky top-0 z-10 bg-white border-b border-gray-200">
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<span className="font-semibold text-gray-800">LionCode</span>
</Link>
<div className="flex items-center gap-2">
<button
onClick={handleCopyLink}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg transition-colors',
copied
? 'bg-green-100 text-green-600'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
)}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
<span>{copied ? '已复制' : '复制链接'}</span>
</button>
<Link
href="/"
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-[#DB6639] text-white rounded-lg hover:bg-[#C25A33] transition-colors"
>
<ExternalLink size={14} />
<span>使</span>
</Link>
</div>
</div>
</header>
{/* 分享信息头部 */}
<div className="bg-gradient-to-r from-[#DB6639] to-[#E8845C] text-white">
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-3">{data.share.title}</h1>
{data.share.description && (
<p className="text-white/80 mb-4">{data.share.description}</p>
)}
<div className="flex flex-wrap items-center gap-4 text-sm text-white/70">
<span className="flex items-center gap-1">
<Clock size={14} />
{formatDate(data.share.createdAt)}
</span>
<span className="flex items-center gap-1">
<Eye size={14} />
{data.share.viewCount}
</span>
<span className="flex items-center gap-1">
<MessageSquare size={14} />
{data.conversation.messageCount}
</span>
<span className="px-2 py-0.5 bg-white/20 rounded text-xs">
{data.conversation.model}
</span>
</div>
</div>
</div>
{/* 消息列表 */}
<main className="max-w-4xl mx-auto px-4 py-6">
<div className="space-y-4">
{data.messages.map((message, idx) => {
// 计算用户消息索引(用于目录导航)
const userMsgIndex = message.role === 'user'
? data.messages.slice(0, idx + 1).filter(m => m.role === 'user').length
: null;
return (
<div
key={message.id}
id={`msg-${message.id}`}
data-msg-index={userMsgIndex}
className={cn(
'rounded-lg overflow-hidden scroll-mt-20',
message.role === 'user'
? 'ml-12'
: 'bg-white border border-gray-200 shadow-sm'
)}
>
{message.role === 'user' ? (
// 用户消息
<div className="flex items-start gap-3">
<div className="flex-1 bg-gray-200 rounded-lg p-4">
{/* 用户上传的图片 */}
{message.uploadedImages && message.uploadedImages.length > 0 && (
<div className="grid grid-cols-3 gap-2 mb-3">
{message.uploadedImages.map((img, idx) => (
<img
key={idx}
src={img}
alt={`上传图片 ${idx + 1}`}
className="w-full h-24 object-cover rounded-lg"
/>
))}
</div>
)}
<div className="text-gray-800 whitespace-pre-wrap">
{message.content}
</div>
</div>
<div className="w-9 h-9 bg-[#DB6639] rounded-full flex items-center justify-center text-white text-sm font-medium flex-shrink-0">
U
</div>
</div>
) : (
// AI 消息
<div>
{/* 消息头部 */}
<div className="flex items-center gap-2 px-4 py-3 bg-gray-50 border-b border-gray-100">
<div className="w-7 h-7 bg-[#DB6639] rounded-lg flex items-center justify-center">
<Sparkles size={14} className="text-white" />
</div>
<span className="font-medium text-gray-800 text-sm">LionCode AI</span>
{message.usedTools && message.usedTools.length > 0 && (
<div className="flex items-center gap-1.5 ml-2">
{message.usedTools.map((tool, idx) => (
<span
key={idx}
className="px-2 py-0.5 bg-orange-100 text-orange-700 text-xs rounded-full"
>
{TOOL_NAMES[tool] || tool}
</span>
))}
</div>
)}
</div>
{/* 思考内容 */}
{message.thinkingContent && (
<details className="mx-4 mt-3 bg-orange-50 rounded-lg border border-orange-100">
<summary className="px-4 py-2 cursor-pointer text-sm font-medium text-orange-700 hover:bg-orange-100 rounded-lg">
💭
</summary>
<div className="px-4 py-3 text-sm text-gray-600 whitespace-pre-wrap border-t border-orange-100">
{message.thinkingContent}
</div>
</details>
)}
{/* 消息内容 */}
<div className="px-4 py-4">
<MarkdownRenderer
content={message.content}
className="text-gray-700"
/>
</div>
{/* 代码执行图片 */}
{message.images && message.images.length > 0 && (
<div className="px-4 pb-4">
<div className="text-sm font-medium text-gray-600 mb-2">📊 </div>
<div className="grid grid-cols-2 gap-3">
{message.images.map((img, idx) => (
<img
key={idx}
src={img.startsWith('data:') ? img : `data:image/png;base64,${img}`}
alt={`图表 ${idx + 1}`}
className="w-full rounded-lg border border-gray-200"
/>
))}
</div>
</div>
)}
{/* 搜索图片 */}
{message.searchImages && message.searchImages.length > 0 && (
<div className="px-4 pb-4">
<div className="text-sm font-medium text-gray-600 mb-2">🖼 </div>
<div className="grid grid-cols-4 gap-2">
{message.searchImages.slice(0, 8).map((img, idx) => (
<a
key={idx}
href={img.imageUrl}
target="_blank"
rel="noopener noreferrer"
className="block rounded-lg overflow-hidden hover:opacity-80 transition-opacity"
>
<img
src={img.imageUrl}
alt={img.title || `图片 ${idx + 1}`}
className="w-full h-20 object-cover"
/>
</a>
))}
</div>
</div>
)}
{/* Token 统计 */}
{(message.inputTokens || message.outputTokens) && (
<div className="px-4 pb-3 text-xs text-gray-400">
Token: 输入 {message.inputTokens?.toLocaleString() || 0} / {message.outputTokens?.toLocaleString() || 0}
</div>
)}
</div>
)}
</div>
);
})}
</div>
</main>
{/* 分享导航组件 */}
<ShareNavigator messages={data.messages} />
{/* 底部 */}
<footer className="bg-white border-t border-gray-200 mt-8">
<div className="max-w-4xl mx-auto px-4 py-8 text-center">
<div className="flex items-center justify-center gap-2 mb-3">
<span className="font-semibold text-gray-800">LionCode AI</span>
</div>
<p className="text-gray-500 text-sm mb-4"></p>
<Link
href="/"
className="inline-flex items-center gap-2 px-6 py-2.5 bg-[#DB6639] text-white rounded-lg hover:bg-[#C25A33] transition-colors"
>
<Sparkles size={18} />
使 LionCode
</Link>
</div>
</footer>
</div>
);
}