Compare commits

..

3 Commits

Author SHA1 Message Date
gaoziman
2e5120dc72 fix(HTML预览): 修复链接点击影响父窗口问题
- 注入 <base target="_blank"> 标签
- 智能检测现有 head/html 标签位置注入
- 确保预览中的链接在新标签页打开
- 避免链接影响主应用页面
2025-12-21 02:47:27 +08:00
gaoziman
b3d5a47072 feat(代码块): 添加长代码折叠/展开功能
- 超过60行代码自动折叠,预览前30行
- 添加渐变遮罩和展开按钮交互
- 显示总行数和剩余行数信息
- 展开状态下显示收起按钮
- 复制功能始终复制完整代码
- 优化代码区域布局使用 flex 结构
2025-12-21 02:47:21 +08:00
gaoziman
4eab17155e style(字体): 优化字体系统使用相对单位
- 移除 ChatInput 和 MessageBubble 中的固定字体大小类
- MarkdownRenderer 标题和内容使用 em 相对单位
- 表格字体大小改为相对单位保持比例
- 聊天输入框添加 z-20 层级避免被代码块遮挡
2025-12-21 02:47:13 +08:00
6 changed files with 155 additions and 48 deletions

View File

@ -490,7 +490,7 @@ export default function ChatPage({ params }: PageProps) {
{/* 固定底部输入框 */}
<div
className="sticky bottom-0 pt-4"
className="sticky bottom-0 pt-4 z-20"
style={{
background: `linear-gradient(to top, var(--color-bg-secondary) 0%, var(--color-bg-secondary) 80%, transparent 100%)`
}}

View File

@ -127,7 +127,7 @@ export function ChatInput({
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={files.length > 0 ? '添加描述(可选)...' : placeholder}
className="w-full border-none outline-none text-base text-[var(--color-text-primary)] bg-transparent py-2 placeholder:text-[var(--color-text-placeholder)]"
className="w-full border-none outline-none text-[var(--color-text-primary)] bg-transparent py-2 placeholder:text-[var(--color-text-placeholder)]"
/>
</div>

View File

@ -152,7 +152,7 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
})}
</div>
)}
<div className="bg-[var(--color-message-user)] text-[var(--color-text-primary)] px-4 py-3 rounded-md text-base leading-relaxed">
<div className="bg-[var(--color-message-user)] text-[var(--color-text-primary)] px-4 py-3 rounded-md leading-relaxed">
{message.content || ((uploadedImages && uploadedImages.length > 0) || (uploadedDocuments && uploadedDocuments.length > 0) ? '(附件)' : '')}
</div>
{/* 悬停显示复制按钮 */}
@ -216,7 +216,7 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
</button>
{thinkingExpanded && (
<div className="mt-2 p-4 bg-purple-50 border border-purple-200 rounded-lg">
<pre className="text-sm text-purple-800 whitespace-pre-wrap font-mono">
<pre className="text-purple-800 whitespace-pre-wrap font-mono">
{thinkingContent}
</pre>
</div>
@ -228,13 +228,13 @@ export function MessageBubble({ message, user, thinkingContent, isStreaming, err
{error && (
<div className="mb-3 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<AlertCircle size={20} className="text-red-500 flex-shrink-0 mt-0.5" />
<div className="text-sm text-red-700">{error}</div>
<div className="text-red-700">{error}</div>
</div>
)}
{/* 主要内容 */}
<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-[var(--color-text-primary)] leading-[1.75]">
{message.content ? (
<MarkdownRenderer content={message.content} />
) : isStreaming ? (

View File

@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef, useState, useMemo } from 'react';
import { Copy, Check, Eye } from 'lucide-react';
import { Copy, Check, Eye, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import Prism from 'prismjs';
import { HtmlPreviewModal } from '@/components/ui/HtmlPreviewModal';
@ -30,6 +30,10 @@ interface CodeBlockProps {
language?: string;
showLineNumbers?: boolean;
className?: string;
/** 折叠阈值,超过此行数自动折叠,默认 60 */
maxCollapsedLines?: number;
/** 折叠时预览的行数,默认 30 */
previewLines?: number;
}
// 语言别名映射
@ -51,9 +55,12 @@ export function CodeBlock({
language = 'text',
showLineNumbers = true,
className,
maxCollapsedLines = 60,
previewLines = 30,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
// 规范化语言名称
const normalizedLanguage = languageAliases[language.toLowerCase()] || language.toLowerCase();
@ -62,17 +69,33 @@ export function CodeBlock({
const isHtmlPreviewable = ['html', 'htm', 'markup'].includes(normalizedLanguage) ||
['html', 'htm'].includes(language.toLowerCase());
const lines = code.split('\n');
const totalLines = lines.length;
const shouldCollapse = totalLines > maxCollapsedLines;
const remainingLines = totalLines - previewLines;
// 根据折叠状态获取显示的代码
const displayCode = useMemo(() => {
if (shouldCollapse && !isExpanded) {
return lines.slice(0, previewLines).join('\n');
}
return code;
}, [code, lines, shouldCollapse, isExpanded, previewLines]);
const displayLines = displayCode.split('\n');
// 使用 useMemo 缓存高亮后的 HTML避免频繁重新高亮
const highlightedCode = useMemo(() => {
const grammar = Prism.languages[normalizedLanguage];
if (grammar) {
return Prism.highlight(code, grammar, normalizedLanguage);
return Prism.highlight(displayCode, grammar, normalizedLanguage);
}
return code;
}, [code, normalizedLanguage]);
return displayCode;
}, [displayCode, normalizedLanguage]);
const handleCopy = async () => {
try {
// 始终复制完整代码
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
@ -81,13 +104,15 @@ export function CodeBlock({
}
};
const lines = code.split('\n');
const toggleExpand = () => {
setIsExpanded(!isExpanded);
};
return (
<div className={cn('relative group rounded overflow-hidden my-4', className)}
style={{ boxShadow: 'var(--color-code-shadow)' }}>
{/* 顶部工具栏 */}
<div className="flex items-center justify-between px-4 py-2.5 text-sm"
<div className="flex items-center justify-between px-4 py-2.5 text-[0.9em]"
style={{
backgroundColor: 'var(--color-code-toolbar-bg)',
color: 'var(--color-code-toolbar-text)',
@ -106,6 +131,12 @@ export function CodeBlock({
style={{ backgroundColor: '#27c93f', boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.1) inset' }} />
</div>
<span className="font-mono text-[13px]">{language || 'code'}</span>
{/* 显示总行数 */}
{shouldCollapse && (
<span className="text-[12px] opacity-60">
({totalLines} )
</span>
)}
</div>
{/* 右侧:操作按钮 */}
@ -145,40 +176,93 @@ export function CodeBlock({
</div>
{/* 代码区域 */}
<div className="relative overflow-x-auto"
style={{ backgroundColor: 'var(--color-code-bg)' }}>
{showLineNumbers && (
<div className="absolute left-0 top-0 bottom-0 w-12 select-none"
style={{
backgroundColor: 'var(--color-code-bg)',
borderRight: '1px solid var(--color-code-line-border)'
}}>
<div className="py-4 px-2 text-right">
{lines.map((_, index) => (
<div style={{ backgroundColor: 'var(--color-code-bg)' }}>
{/* 代码内容 - 使用 flex 布局 */}
<div className="flex overflow-x-auto">
{/* 行号列 */}
{showLineNumbers && (
<div
className="flex-shrink-0 w-12 select-none py-4 px-2 text-right"
style={{
backgroundColor: 'var(--color-code-bg)',
borderRight: '1px solid var(--color-code-line-border)'
}}
>
{displayLines.map((_, index) => (
<div
key={index}
className="text-sm font-mono leading-6"
className="font-mono leading-6"
style={{ color: 'var(--color-code-line-number)' }}
>
{index + 1}
</div>
))}
</div>
)}
{/* 代码列 */}
<pre
className="flex-1 p-4 overflow-x-auto"
style={{ margin: 0, background: 'transparent' }}
>
<code
className={`language-${normalizedLanguage} leading-6`}
style={{ color: 'var(--color-code-text)' }}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
</pre>
</div>
{/* 渐变遮罩和展开按钮(仅在折叠状态下显示) */}
{shouldCollapse && !isExpanded && (
<>
{/* 渐变遮罩 */}
<div
className="h-16 -mt-16 pointer-events-none relative z-10"
style={{
background: `linear-gradient(to bottom, transparent, var(--color-code-bg))`
}}
/>
{/* 展开按钮 */}
<div
className="py-3 text-center cursor-pointer transition-colors hover:opacity-80"
style={{ backgroundColor: 'var(--color-code-bg)' }}
onClick={toggleExpand}
>
<button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-[0.9em] font-medium transition-colors"
style={{
color: 'var(--color-primary)',
backgroundColor: 'var(--color-primary-alpha)'
}}
>
<ChevronDown size={16} />
<span> {remainingLines} </span>
</button>
</div>
</>
)}
{/* 收起按钮(仅在展开状态下显示) */}
{shouldCollapse && isExpanded && (
<div
className="py-3 text-center cursor-pointer transition-colors"
style={{
backgroundColor: 'var(--color-code-bg)',
borderTop: '1px solid var(--color-code-line-border)'
}}
onClick={toggleExpand}
>
<button
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-[0.9em] font-medium transition-colors"
style={{
color: 'var(--color-code-toolbar-text)'
}}
>
<ChevronUp size={16} />
<span></span>
</button>
</div>
)}
<pre
className={cn(
'p-4 overflow-x-auto',
showLineNumbers && 'pl-16'
)}
style={{ margin: 0, background: 'transparent' }}
>
<code
className={`language-${normalizedLanguage} text-sm leading-6`}
style={{ color: 'var(--color-code-text)' }}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
</pre>
</div>
{/* HTML 预览模态框 */}

View File

@ -48,31 +48,31 @@ const markdownComponents = {
);
},
// 标题
// 标题 - 使用相对单位保持与全局字体的比例
h1({ children }: { children?: React.ReactNode }) {
return (
<h1 className="text-lg font-bold mt-5 mb-3 text-[var(--color-text-primary)]">
<h1 className="text-[1.25em] font-bold mt-5 mb-3 text-[var(--color-text-primary)]">
{children}
</h1>
);
},
h2({ children }: { children?: React.ReactNode }) {
return (
<h2 className="text-base font-bold mt-4 mb-2 text-[var(--color-text-primary)]">
<h2 className="text-[1.125em] font-bold mt-4 mb-2 text-[var(--color-text-primary)]">
{children}
</h2>
);
},
h3({ children }: { children?: React.ReactNode }) {
return (
<h3 className="text-sm font-semibold mt-3 mb-2 text-[var(--color-text-primary)]">
<h3 className="text-[1em] font-semibold mt-3 mb-2 text-[var(--color-text-primary)]">
{children}
</h3>
);
},
h4({ children }: { children?: React.ReactNode }) {
return (
<h4 className="text-sm font-semibold mt-2 mb-1 text-[var(--color-text-primary)]">
<h4 className="text-[1em] font-semibold mt-2 mb-1 text-[var(--color-text-primary)]">
{children}
</h4>
);
@ -95,7 +95,7 @@ const markdownComponents = {
},
li({ children }: { children?: React.ReactNode }) {
return (
<li className="leading-relaxed text-sm">
<li className="leading-relaxed">
{children}
</li>
);
@ -136,7 +136,7 @@ const markdownComponents = {
// 引用
blockquote({ children }: { children?: React.ReactNode }) {
return (
<blockquote className="border-l-3 border-[var(--color-primary)] pl-3 my-3 italic text-sm text-[var(--color-text-secondary)]">
<blockquote className="border-l-3 border-[var(--color-primary)] pl-3 my-3 italic text-[var(--color-text-secondary)]">
{children}
</blockquote>
);
@ -153,7 +153,7 @@ const markdownComponents = {
table({ children }: { children?: React.ReactNode }) {
return (
<div className="overflow-x-auto my-3">
<table className="min-w-full border border-[var(--color-border)] rounded-lg overflow-hidden text-sm">
<table className="min-w-full border border-[var(--color-border)] rounded-lg overflow-hidden text-[0.9em]">
{children}
</table>
</div>
@ -182,14 +182,14 @@ const markdownComponents = {
},
th({ children }: { children?: React.ReactNode }) {
return (
<th className="px-3 py-1.5 text-left text-xs font-semibold text-[var(--color-text-primary)]">
<th className="px-3 py-1.5 text-left text-[0.85em] font-semibold text-[var(--color-text-primary)]">
{children}
</th>
);
},
td({ children }: { children?: React.ReactNode }) {
return (
<td className="px-3 py-1.5 text-xs text-[var(--color-text-secondary)]">
<td className="px-3 py-1.5 text-[0.85em] text-[var(--color-text-secondary)]">
{children}
</td>
);

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import {
X,
Monitor,
@ -83,6 +83,29 @@ export function HtmlPreviewModal({
// 获取当前设备配置
const currentDevice = deviceConfigs.find((d) => d.type === device) || deviceConfigs[0];
// 处理 HTML 代码,注入 <base target="_blank"> 防止链接影响父窗口
const processedHtmlCode = useMemo(() => {
const baseTag = '<base target="_blank">';
// 检查是否已经有 <base> 标签
if (/<base\s/i.test(htmlCode)) {
return htmlCode;
}
// 尝试在 <head> 标签后注入
if (/<head[^>]*>/i.test(htmlCode)) {
return htmlCode.replace(/<head[^>]*>/i, `$&\n ${baseTag}`);
}
// 尝试在 <html> 标签后注入
if (/<html[^>]*>/i.test(htmlCode)) {
return htmlCode.replace(/<html[^>]*>/i, `$&\n <head>\n ${baseTag}\n </head>`);
}
// 如果都没有,在最前面添加
return `<head>${baseTag}</head>\n${htmlCode}`;
}, [htmlCode]);
// ESC 关闭 / F11 全屏切换
useEffect(() => {
if (!isOpen) return;
@ -348,7 +371,7 @@ export function HtmlPreviewModal({
{/* iframe 预览 */}
<iframe
key={iframeKey}
srcDoc={htmlCode}
srcDoc={processedHtmlCode}
sandbox="allow-scripts allow-same-origin"
className="w-full bg-white"
style={{