Compare commits

..

5 Commits

Author SHA1 Message Date
gaoziman
1ef4a31d5d style(界面): 优化聊天页面布局和用户菜单显示
- 调整聊天输入区域左内边距以适配侧边栏
- 移除用户菜单中的 plan 信息显示
- 简化用户信息展示
2025-12-21 01:15:55 +08:00
gaoziman
600d899532 refactor(聊天头部): 使用 IconRenderer 组件渲染助手图标
- 替换原有的 emoji 图标为 IconRenderer 组件
- 支持 Lucide 图标和表情符号的统一渲染
- 优化图标样式和主题色适配
2025-12-21 01:15:35 +08:00
gaoziman
372946de9d style(聊天): 统一消息气泡和输入框圆角样式
- 将输入框圆角从 rounded-[18px] 改为 rounded-md
- 将用户消息气泡圆角从 rounded-[18px] 改为 rounded-md
- 将助手消息气泡圆角从 rounded-2xl 改为 rounded-md
- 保持界面风格一致性
2025-12-21 01:15:19 +08:00
gaoziman
1c114a764e feat(代码块): 添加 HTML 代码预览功能
- 在代码块工具栏添加预览按钮
- 支持 HTML/HTM 类型代码的实时预览
- 集成 HtmlPreviewModal 模态框组件
2025-12-21 01:15:02 +08:00
gaoziman
959fedf1d0 feat(组件): 新增 HTML 预览模态框组件
- 支持桌面端、平板、手机三种设备预览模式
- 提供代码视图和预览视图切换
- 支持全屏预览和代码下载功能
- 添加键盘快捷键 ESC 关闭和 F11 全屏
2025-12-21 01:14:44 +08:00
7 changed files with 452 additions and 27 deletions

View File

@ -495,7 +495,7 @@ export default function ChatPage({ params }: PageProps) {
background: `linear-gradient(to top, var(--color-bg-secondary) 0%, var(--color-bg-secondary) 80%, transparent 100%)` background: `linear-gradient(to top, var(--color-bg-secondary) 0%, var(--color-bg-secondary) 80%, transparent 100%)`
}} }}
> >
<div className="max-w-[900px] mx-auto px-4 pb-4"> <div className="max-w-[900px] mx-auto px-4 pb-4 pl-[60px]">
{isStreaming && ( {isStreaming && (
<div className="flex justify-center mb-3"> <div className="flex justify-center mb-3">
<button <button

View File

@ -3,6 +3,7 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { ChevronDown, Check, Bot } from 'lucide-react'; import { ChevronDown, Check, Bot } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { IconRenderer } from '@/components/ui/IconRenderer';
interface Assistant { interface Assistant {
id: number; id: number;
@ -82,7 +83,12 @@ export function ChatHeaderInfo({
<div className="flex items-center gap-1.5 text-[var(--color-text-secondary)]"> <div className="flex items-center gap-1.5 text-[var(--color-text-secondary)]">
{assistant ? ( {assistant ? (
<> <>
<span className="text-base">{assistant.icon || '🤖'}</span> <IconRenderer
icon={assistant.icon}
size={16}
fallback="Bot"
className="text-[var(--color-primary)]"
/>
<span className="font-medium">{assistant.name}</span> <span className="font-medium">{assistant.name}</span>
</> </>
) : ( ) : (

View File

@ -88,7 +88,7 @@ export function ChatInput({
<div className={cn('w-full max-w-[var(--input-max-width)] mx-auto', className)}> <div className={cn('w-full max-w-[var(--input-max-width)] mx-auto', className)}>
<div <div
className={cn( className={cn(
'relative flex flex-col bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-[18px] p-4 shadow-[var(--shadow-input)]', 'relative flex flex-col bg-[var(--color-bg-primary)] border border-[var(--color-border)] rounded-md p-4 shadow-[var(--shadow-input)]',
'transition-all duration-150', 'transition-all duration-150',
'focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-input-focus)]', 'focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-input-focus)]',
isDragging && 'border-[var(--color-primary)] border-2 bg-[var(--color-primary)]/5' isDragging && 'border-[var(--color-primary)] border-2 bg-[var(--color-primary)]/5'
@ -100,7 +100,7 @@ export function ChatInput({
> >
{/* 拖拽覆盖层 */} {/* 拖拽覆盖层 */}
{isDragging && ( {isDragging && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-[var(--color-bg-primary)]/90 rounded-[18px] border-2 border-dashed border-[var(--color-primary)]"> <div className="absolute inset-0 z-10 flex flex-col items-center justify-center bg-[var(--color-bg-primary)]/90 rounded-md border-2 border-dashed border-[var(--color-primary)]">
<Upload className="w-8 h-8 text-[var(--color-primary)] mb-2" /> <Upload className="w-8 h-8 text-[var(--color-primary)] mb-2" />
<p className="text-sm font-medium text-[var(--color-primary)]"> <p className="text-sm font-medium text-[var(--color-primary)]">

View File

@ -152,7 +152,7 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
})} })}
</div> </div>
)} )}
<div className="bg-[var(--color-message-user)] text-[var(--color-text-primary)] px-4 py-3 rounded-[18px] text-base leading-relaxed"> <div className="bg-[var(--color-message-user)] text-[var(--color-text-primary)] px-4 py-3 rounded-md text-base leading-relaxed">
{message.content || ((uploadedImages && uploadedImages.length > 0) || (uploadedDocuments && uploadedDocuments.length > 0) ? '(附件)' : '')} {message.content || ((uploadedImages && uploadedImages.length > 0) || (uploadedDocuments && uploadedDocuments.length > 0) ? '(附件)' : '')}
</div> </div>
{/* 悬停显示复制按钮 */} {/* 悬停显示复制按钮 */}
@ -233,7 +233,7 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
)} )}
{/* 主要内容 */} {/* 主要内容 */}
<div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-2xl px-5 py-4 shadow-sm"> <div className="bg-[var(--color-message-assistant-bg)] border border-[var(--color-message-assistant-border)] rounded-md px-5 py-4 shadow-sm">
<div className="text-sm text-[var(--color-text-primary)] leading-[1.75]"> <div className="text-sm text-[var(--color-text-primary)] leading-[1.75]">
{message.content ? ( {message.content ? (
<MarkdownRenderer content={message.content} /> <MarkdownRenderer content={message.content} />

View File

@ -1,9 +1,10 @@
'use client'; 'use client';
import { useEffect, useRef, useState, useMemo } from 'react'; import { useEffect, useRef, useState, useMemo } from 'react';
import { Copy, Check } from 'lucide-react'; import { Copy, Check, Eye } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import Prism from 'prismjs'; import Prism from 'prismjs';
import { HtmlPreviewModal } from '@/components/ui/HtmlPreviewModal';
import 'prismjs/components/prism-javascript'; import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-jsx'; import 'prismjs/components/prism-jsx';
@ -52,10 +53,15 @@ export function CodeBlock({
className, className,
}: CodeBlockProps) { }: CodeBlockProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
// 规范化语言名称 // 规范化语言名称
const normalizedLanguage = languageAliases[language.toLowerCase()] || language.toLowerCase(); const normalizedLanguage = languageAliases[language.toLowerCase()] || language.toLowerCase();
// 判断是否支持 HTML 预览
const isHtmlPreviewable = ['html', 'htm', 'markup'].includes(normalizedLanguage) ||
['html', 'htm'].includes(language.toLowerCase());
// 使用 useMemo 缓存高亮后的 HTML避免频繁重新高亮 // 使用 useMemo 缓存高亮后的 HTML避免频繁重新高亮
const highlightedCode = useMemo(() => { const highlightedCode = useMemo(() => {
const grammar = Prism.languages[normalizedLanguage]; const grammar = Prism.languages[normalizedLanguage];
@ -78,10 +84,23 @@ export function CodeBlock({
const lines = code.split('\n'); const lines = code.split('\n');
return ( return (
<div className={cn('relative group rounded-lg overflow-hidden my-4', className)}> <div className={cn('relative group rounded overflow-hidden my-4', className)}>
{/* 顶部工具栏 */} {/* 顶部工具栏 */}
<div className="flex items-center justify-between px-4 py-2 bg-[#2d2d2d] text-gray-400 text-sm"> <div className="flex items-center justify-between px-4 py-2 bg-[#2d2d2d] text-gray-400 text-sm">
<span className="font-mono">{language || 'code'}</span> <span className="font-mono">{language || 'code'}</span>
<div className="flex items-center gap-2">
{/* HTML 预览按钮 */}
{isHtmlPreviewable && (
<button
onClick={() => setPreviewOpen(true)}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-white/10 transition-colors text-blue-400 hover:text-blue-300"
title="预览 HTML"
>
<Eye size={14} />
<span></span>
</button>
)}
{/* 复制按钮 */}
<button <button
onClick={handleCopy} onClick={handleCopy}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-white/10 transition-colors" className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-white/10 transition-colors"
@ -100,6 +119,7 @@ export function CodeBlock({
)} )}
</button> </button>
</div> </div>
</div>
{/* 代码区域 */} {/* 代码区域 */}
<div className="relative overflow-x-auto bg-[#1e1e1e]"> <div className="relative overflow-x-auto bg-[#1e1e1e]">
@ -130,6 +150,16 @@ export function CodeBlock({
/> />
</pre> </pre>
</div> </div>
{/* HTML 预览模态框 */}
{isHtmlPreviewable && (
<HtmlPreviewModal
htmlCode={code}
isOpen={previewOpen}
onClose={() => setPreviewOpen(false)}
language={language}
/>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,392 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import {
X,
Monitor,
Tablet,
Smartphone,
Maximize2,
Minimize2,
RefreshCw,
Code,
Eye,
Copy,
Check,
Download,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { CodeBlock } from '@/components/markdown/CodeBlock';
interface HtmlPreviewModalProps {
/** HTML 代码内容 */
htmlCode: string;
/** 是否打开 */
isOpen: boolean;
/** 关闭回调 */
onClose: () => void;
/** 语言类型,默认 html */
language?: string;
}
// 设备尺寸配置
type DeviceType = 'desktop' | 'tablet' | 'mobile';
interface DeviceConfig {
type: DeviceType;
label: string;
icon: React.ComponentType<{ size?: number; className?: string }>;
width: string;
frameClass: string;
}
const deviceConfigs: DeviceConfig[] = [
{
type: 'desktop',
label: '桌面端',
icon: Monitor,
width: '100%',
frameClass: 'rounded',
},
{
type: 'tablet',
label: '平板',
icon: Tablet,
width: '768px',
frameClass: 'rounded-lg',
},
{
type: 'mobile',
label: '手机',
icon: Smartphone,
width: '375px',
frameClass: 'rounded-xl',
},
];
// 视图模式
type ViewMode = 'preview' | 'code';
export function HtmlPreviewModal({
htmlCode,
isOpen,
onClose,
language = 'html',
}: HtmlPreviewModalProps) {
const [device, setDevice] = useState<DeviceType>('desktop');
const [viewMode, setViewMode] = useState<ViewMode>('preview');
const [isFullscreen, setIsFullscreen] = useState(false);
const [copied, setCopied] = useState(false);
const [iframeKey, setIframeKey] = useState(0); // 用于刷新 iframe
const modalRef = useRef<HTMLDivElement>(null);
// 获取当前设备配置
const currentDevice = deviceConfigs.find((d) => d.type === device) || deviceConfigs[0];
// ESC 关闭 / F11 全屏切换
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (isFullscreen) {
setIsFullscreen(false);
} else {
onClose();
}
}
// F11 切换全屏
if (e.key === 'F11') {
e.preventDefault();
setIsFullscreen((prev) => !prev);
}
};
window.document.addEventListener('keydown', handleKeyDown);
window.document.body.style.overflow = 'hidden';
return () => {
window.document.removeEventListener('keydown', handleKeyDown);
window.document.body.style.overflow = '';
};
}, [isOpen, isFullscreen, onClose]);
// 复制代码
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(htmlCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
}, [htmlCode]);
// 下载 HTML 文件
const handleDownload = useCallback(() => {
const blob = new Blob([htmlCode], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = window.document.createElement('a');
link.href = url;
link.download = 'preview.html';
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [htmlCode]);
// 刷新预览
const handleRefresh = useCallback(() => {
setIframeKey((prev) => prev + 1);
}, []);
// 切换全屏
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev);
}, []);
if (!isOpen) return null;
return (
<div
className={cn(
'fixed inset-0 z-50 flex items-center justify-center',
'bg-black/70 backdrop-blur-sm',
'animate-fade-in-fast'
)}
onClick={onClose}
>
{/* 主容器 */}
<div
ref={modalRef}
className={cn(
'relative bg-[var(--color-bg-primary)] shadow-2xl flex flex-col overflow-hidden',
'animate-scale-in-fast',
isFullscreen
? 'w-full h-full rounded-none'
: 'w-[95vw] max-w-6xl h-[90vh] rounded-md'
)}
onClick={(e) => e.stopPropagation()}
>
{/* 头部工具栏 */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--color-border)] bg-[var(--color-bg-secondary)]">
{/* 左侧:标题和视图切换 */}
<div className="flex items-center gap-4">
{/* 标题 */}
<div className="flex items-center gap-2">
<Eye size={18} className="text-[var(--color-primary)]" />
<span className="font-medium text-[var(--color-text-primary)]">
HTML
</span>
</div>
{/* 视图切换 Tab */}
<div className="flex items-center bg-[var(--color-bg-tertiary)] rounded-lg p-1">
<button
onClick={() => setViewMode('preview')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-150',
viewMode === 'preview'
? 'bg-[var(--color-bg-primary)] text-[var(--color-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
)}
>
<Eye size={14} />
<span></span>
</button>
<button
onClick={() => setViewMode('code')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-all duration-150',
viewMode === 'code'
? 'bg-[var(--color-bg-primary)] text-[var(--color-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
)}
>
<Code size={14} />
<span></span>
</button>
</div>
</div>
{/* 中间:设备切换器(仅预览模式显示) */}
{viewMode === 'preview' && (
<div className="flex items-center gap-1 bg-[var(--color-bg-tertiary)] rounded-lg p-1">
{deviceConfigs.map((config) => {
const Icon = config.icon;
return (
<button
key={config.type}
onClick={() => setDevice(config.type)}
className={cn(
'flex items-center justify-center w-9 h-9 rounded-md transition-all duration-150',
device === config.type
? 'bg-[var(--color-bg-primary)] text-[var(--color-primary)] shadow-sm'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)]'
)}
title={config.label}
>
<Icon size={18} />
</button>
);
})}
</div>
)}
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-1">
{/* 复制按钮 */}
<button
onClick={handleCopy}
className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm transition-colors duration-150',
'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]',
'hover:bg-[var(--color-bg-hover)]'
)}
title="复制代码"
>
{copied ? (
<>
<Check size={16} className="text-green-500" />
<span className="text-green-500"></span>
</>
) : (
<>
<Copy size={16} />
<span></span>
</>
)}
</button>
{/* 下载按钮 */}
<button
onClick={handleDownload}
className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm transition-colors duration-150',
'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]',
'hover:bg-[var(--color-bg-hover)]'
)}
title="下载 HTML 文件"
>
<Download size={16} />
<span></span>
</button>
{/* 分隔线 */}
<div className="w-px h-6 bg-[var(--color-border)] mx-1" />
{/* 刷新按钮(仅预览模式) */}
{viewMode === 'preview' && (
<button
onClick={handleRefresh}
className={cn(
'w-9 h-9 flex items-center justify-center rounded-lg transition-colors duration-150',
'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]',
'hover:bg-[var(--color-bg-hover)]'
)}
title="刷新预览"
>
<RefreshCw size={16} />
</button>
)}
{/* 全屏切换按钮 */}
<button
onClick={toggleFullscreen}
className={cn(
'w-9 h-9 flex items-center justify-center rounded-lg transition-colors duration-150',
'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]',
'hover:bg-[var(--color-bg-hover)]'
)}
title={isFullscreen ? '退出全屏 (ESC)' : '全屏 (F11)'}
>
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
</button>
{/* 关闭按钮 */}
<button
onClick={onClose}
className={cn(
'w-9 h-9 flex items-center justify-center rounded-lg transition-colors duration-150',
'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]',
'hover:bg-[var(--color-bg-hover)]'
)}
title="关闭 (ESC)"
>
<X size={18} />
</button>
</div>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-hidden bg-[var(--color-bg-tertiary)]">
{viewMode === 'preview' ? (
/* 预览模式 */
<div className="w-full h-full flex items-center justify-center p-4 overflow-auto">
{/* 设备框架 */}
<div
className={cn(
'bg-white shadow-2xl transition-all duration-300 ease-out overflow-hidden',
currentDevice.frameClass,
// 非桌面模式添加设备边框效果
device !== 'desktop' && 'border-8 border-gray-800'
)}
style={{
width: currentDevice.width,
height: device === 'desktop' ? '100%' : device === 'tablet' ? '85%' : '90%',
maxWidth: '100%',
maxHeight: '100%',
}}
>
{/* 手机顶部刘海 */}
{device === 'mobile' && (
<div className="h-6 bg-gray-800 flex items-center justify-center">
<div className="w-20 h-4 bg-black rounded-full" />
</div>
)}
{/* iframe 预览 */}
<iframe
key={iframeKey}
srcDoc={htmlCode}
sandbox="allow-scripts allow-same-origin"
className="w-full bg-white"
style={{
height: device === 'mobile' ? 'calc(100% - 24px)' : '100%',
border: 'none',
}}
title="HTML 预览"
/>
</div>
</div>
) : (
/* 代码模式 */
<div className="w-full h-full overflow-auto">
<CodeBlock
code={htmlCode}
language={language}
showLineNumbers={true}
className="my-0 rounded-none h-full"
/>
</div>
)}
</div>
{/* 底部状态栏 */}
<div className="flex items-center justify-between px-4 py-2 border-t border-[var(--color-border)] bg-[var(--color-bg-secondary)]">
<div className="flex items-center gap-4 text-xs text-[var(--color-text-tertiary)]">
<span>
{htmlCode.length.toLocaleString()}
</span>
<span>
{htmlCode.split('\n').length}
</span>
</div>
<div className="text-xs text-[var(--color-text-tertiary)]">
ESC · F11
</div>
</div>
</div>
</div>
);
}

View File

@ -98,9 +98,6 @@ export function UserMenu() {
<div className="text-sm text-[var(--color-text-primary)] truncate"> <div className="text-sm text-[var(--color-text-primary)] truncate">
{user.email} {user.email}
</div> </div>
<div className="text-xs text-[var(--color-text-tertiary)] capitalize">
{user.plan} plan
</div>
</div> </div>
{isOpen ? ( {isOpen ? (
<ChevronUp size={16} className="text-[var(--color-text-tertiary)] flex-shrink-0" /> <ChevronUp size={16} className="text-[var(--color-text-tertiary)] flex-shrink-0" />