Compare commits
5 Commits
5307255844
...
1ef4a31d5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef4a31d5d | ||
|
|
600d899532 | ||
|
|
372946de9d | ||
|
|
1c114a764e | ||
|
|
959fedf1d0 |
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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)]">
|
||||||
释放以添加文件
|
释放以添加文件
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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,27 +84,41 @@ 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>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={handleCopy}
|
{/* HTML 预览按钮 */}
|
||||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-white/10 transition-colors"
|
{isHtmlPreviewable && (
|
||||||
title="Copy code"
|
<button
|
||||||
>
|
onClick={() => setPreviewOpen(true)}
|
||||||
{copied ? (
|
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"
|
||||||
<Check size={14} className="text-green-400" />
|
>
|
||||||
<span className="text-green-400">Copied!</span>
|
<Eye size={14} />
|
||||||
</>
|
<span>预览</span>
|
||||||
) : (
|
</button>
|
||||||
<>
|
|
||||||
<Copy size={14} />
|
|
||||||
<span>Copy</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
{/* 复制按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-white/10 transition-colors"
|
||||||
|
title="Copy code"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check size={14} className="text-green-400" />
|
||||||
|
<span className="text-green-400">Copied!</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy size={14} />
|
||||||
|
<span>Copy</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 代码区域 */}
|
{/* 代码区域 */}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
392
src/components/ui/HtmlPreviewModal.tsx
Normal file
392
src/components/ui/HtmlPreviewModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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" />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user