feat(页面): 添加对话分享展示页面
- 新增 /share/[code] 分享页面 - 支持 Markdown 渲染和代码高亮 - 集成导航组件便于浏览长对话 - 添加分享信息展示和错误处理
This commit is contained in:
parent
b0ecf51700
commit
2acce36dbd
394
src/app/share/[code]/page.tsx
Normal file
394
src/app/share/[code]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user