feat: init
This commit is contained in:
19
pages/components/BetaNotice.tsx
Normal file
19
pages/components/BetaNotice.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export default function BetaNotice() {
|
||||
return (
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4 mb-8">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-xl">🚧</span>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-orange-800 dark:text-orange-200 mb-1">
|
||||
Beta 阶段
|
||||
</h3>
|
||||
<p className="text-sm text-orange-700 dark:text-orange-300">
|
||||
当前功能仅用于演示,接口可能随时变更。正式版本发布前请勿用于生产环境。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
pages/components/CodeEditor.tsx
Normal file
126
pages/components/CodeEditor.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 代码编辑器组件,模拟现代代码编辑器的外观
|
||||
*/
|
||||
import React from 'react'
|
||||
|
||||
import HighlightSyntax from './HighlightSyntax'
|
||||
|
||||
interface CodeEditorProps {
|
||||
code: string
|
||||
language?: string
|
||||
title?: string
|
||||
showLineNumbers?: boolean
|
||||
showHeader?: boolean
|
||||
showFooter?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||
code,
|
||||
language = 'javascript',
|
||||
title,
|
||||
showLineNumbers = false,
|
||||
showHeader = false,
|
||||
showFooter = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const lines = code.split('\n')
|
||||
|
||||
// 使用 Tailwind 的 dark: 前缀实现自动主题切换
|
||||
const containerClasses =
|
||||
'bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 border-gray-300 dark:border-gray-700'
|
||||
const headerClasses = 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700'
|
||||
const headerTextClasses = 'text-gray-700 dark:text-gray-300'
|
||||
const languageTextClasses = 'text-gray-600 dark:text-gray-400'
|
||||
const lineNumbersClasses =
|
||||
'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-500'
|
||||
const codeAreaClasses = 'bg-white dark:bg-gray-900'
|
||||
const footerClasses =
|
||||
'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-400'
|
||||
const copyButtonClasses =
|
||||
'bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-600 hover:text-gray-800 dark:text-gray-300 dark:hover:text-white'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative ${containerClasses} rounded-xl border shadow-2xl overflow-hidden ${className}`}
|
||||
>
|
||||
{/* 编辑器顶部栏 */}
|
||||
{showHeader && (
|
||||
<div className={`flex items-center justify-between px-4 py-3 ${headerClasses} border-b`}>
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* 窗口控制按钮 */}
|
||||
<div className="flex space-x-2">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
</div>
|
||||
{title && (
|
||||
<span className={`text-sm ${headerTextClasses} font-medium ml-2`}>{title}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`text-xs ${languageTextClasses} uppercase tracking-wide`}>
|
||||
{language}
|
||||
</span>
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 代码内容区域 */}
|
||||
<div className="relative">
|
||||
<div className="flex">
|
||||
{/* 行号 */}
|
||||
{showLineNumbers && (
|
||||
<div className={`flex-shrink-0 px-4 py-4 ${lineNumbersClasses} border-r select-none`}>
|
||||
<div className="text-xs font-mono leading-6">
|
||||
{lines.map((_, index) => (
|
||||
<div key={index} className="text-right">
|
||||
{index + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 代码内容 */}
|
||||
<div className={`flex-1 px-4 py-4 ${codeAreaClasses} overflow-x-auto`}>
|
||||
<div className="text-sm font-mono leading-6">
|
||||
<HighlightSyntax code={code} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 复制按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(code).catch(console.error)
|
||||
}}
|
||||
className={`absolute top-3 right-3 p-2 ${copyButtonClasses} rounded-lg transition-all duration-200 opacity-0 group-hover:opacity-100`}
|
||||
title="复制代码"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 底部状态栏 */}
|
||||
{showFooter && (
|
||||
<div className={`px-4 py-2 ${footerClasses} border-t`}>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span>{lines.length} lines</span>
|
||||
<span>UTF-8</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodeEditor
|
||||
95
pages/components/DocsLayout.tsx
Normal file
95
pages/components/DocsLayout.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Link, useLocation } from 'wouter'
|
||||
|
||||
interface DocsLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
title: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
const navigationSections: NavSection[] = [
|
||||
{
|
||||
title: 'Introduction',
|
||||
items: [
|
||||
{ title: 'Overview', path: '/docs/introduction/overview' },
|
||||
{ title: 'Quick Start', path: '/docs/introduction/quick-start' },
|
||||
{ title: '使用限制', path: '/docs/introduction/limitations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Features',
|
||||
items: [
|
||||
{ title: '模型接入', path: '/docs/features/model-integration' },
|
||||
{ title: '安全与权限', path: '/docs/features/security-permissions' },
|
||||
{ title: '数据脱敏', path: '/docs/features/data-masking' },
|
||||
{ title: '知识库注入', path: '/docs/features/knowledge-injection' },
|
||||
{ title: '自定义工具', path: '/docs/features/custom-tools' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Integration',
|
||||
items: [
|
||||
{ title: 'CDN 引入', path: '/docs/integration/cdn-setup' },
|
||||
{ title: '配置选项', path: '/docs/integration/configuration' },
|
||||
{ title: 'API 参考', path: '/docs/integration/api-reference' },
|
||||
{ title: '最佳实践', path: '/docs/integration/best-practices' },
|
||||
{ title: '接入第三方 Agent', path: '/docs/integration/third-party-agent' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function DocsLayout({ children }: DocsLayoutProps) {
|
||||
const [location] = useLocation()
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 flex-shrink-0" role="complementary" aria-label="文档导航">
|
||||
<div className="sticky top-8">
|
||||
<nav className="space-y-8" role="navigation" aria-label="文档章节">
|
||||
{navigationSections.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h3 className="font-semibold uppercase tracking-wider mb-3">{section.title}</h3>
|
||||
<ul className="space-y-2" role="list">
|
||||
{section.items.map((item) => {
|
||||
const isActive = location === item.path
|
||||
return (
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
href={item.path}
|
||||
className={`block px-3 py-2 rounded-lg transition-colors duration-200 ${
|
||||
isActive
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-medium'
|
||||
: ' hover:text-foreground hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-w-0" id="main-content" role="main">
|
||||
<div className="prose prose-lg dark:prose-invert max-w-none">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
pages/components/Footer.tsx
Normal file
91
pages/components/Footer.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Link } from 'wouter'
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer
|
||||
className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700"
|
||||
role="contentinfo"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{/* Brand */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="text-white font-bold">P</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-foreground">page-agent</h3>
|
||||
</div>
|
||||
<p className="text-foreground/80 leading-relaxed">
|
||||
让你的 Web 应用拥有 AI 操作员。
|
||||
<br />
|
||||
一行代码,智能化你的网站。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Links */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="font-semibold text-foreground uppercase tracking-wider">资源</h4>
|
||||
<nav className="space-y-2" role="navigation" aria-label="页脚导航">
|
||||
<Link
|
||||
href="/docs/introduction/overview"
|
||||
className="block text-foreground/80 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
>
|
||||
文档
|
||||
</Link>
|
||||
<Link
|
||||
href="TODO"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-foreground/80 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
aria-label="查看源码(在新窗口打开)"
|
||||
>
|
||||
源码
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/introduction/quick-start"
|
||||
className="block text-foreground/80 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
>
|
||||
快速开始
|
||||
</Link>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
{/* Contact */}
|
||||
<section className="space-y-4">
|
||||
<h4 className="font-semibold text-foreground uppercase tracking-wider">联系我们</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-foreground/80 text-sm">钉钉:</span>
|
||||
<span className="text-blue-600 dark:text-blue-400 font-medium">@TODO</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Bottom */}
|
||||
<div className="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
|
||||
<p className="text-foreground/80 text-sm">© 2025 page-agent. All rights reserved.</p>
|
||||
<div className="flex items-center space-x-6">
|
||||
<a
|
||||
href="TODO"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground/80 hover:text-foreground transition-colors duration-200"
|
||||
aria-label="访问 GitHub 仓库"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
74
pages/components/Header.tsx
Normal file
74
pages/components/Header.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Link } from 'wouter'
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header
|
||||
className="relative z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-700"
|
||||
role="banner"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center space-x-3 group" aria-label="page-agent 首页">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-200">
|
||||
<span className="text-white font-bold text-2xl lg:text-2xl" aria-hidden="true">
|
||||
P
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xl font-bold text-foreground">page-agent</span>
|
||||
<p className="text-xs text-foreground/80">UI Agent in your webpage</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav
|
||||
className="hidden md:flex items-center space-x-8"
|
||||
role="navigation"
|
||||
aria-label="主导航"
|
||||
>
|
||||
<Link
|
||||
href="/docs/introduction/overview"
|
||||
className="text-foreground/80 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
>
|
||||
文档
|
||||
</Link>
|
||||
<a
|
||||
href="TODO"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground/80 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200"
|
||||
aria-label="查看源码(在新窗口打开)"
|
||||
>
|
||||
源码
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
type="button"
|
||||
className="md:hidden p-2 rounded-lg text-foreground/80 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-200"
|
||||
aria-label="打开移动端菜单"
|
||||
aria-expanded="false"
|
||||
aria-controls="mobile-menu"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
21
pages/components/HighlightSyntax.module.css
Normal file
21
pages/components/HighlightSyntax.module.css
Normal file
@@ -0,0 +1,21 @@
|
||||
.syntax {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.keyword {
|
||||
color: #d73a49;
|
||||
font-weight: 600;
|
||||
}
|
||||
.string {
|
||||
color: #1d6eca;
|
||||
}
|
||||
.number {
|
||||
color: #00c583;
|
||||
}
|
||||
.comment {
|
||||
color: #6a737d;
|
||||
font-style: italic;
|
||||
}
|
||||
77
pages/components/HighlightSyntax.tsx
Normal file
77
pages/components/HighlightSyntax.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* js 语法高亮组件,适合在文章中演示代码片段
|
||||
*/
|
||||
import React from 'react'
|
||||
|
||||
import styles from './HighlightSyntax.module.css'
|
||||
|
||||
interface HighlightSyntaxProps {
|
||||
code: string
|
||||
}
|
||||
|
||||
const keywords =
|
||||
'async|await|function|const|let|var|if|else|for|while|return|try|catch|finally|class|extends|from|import|export|default|undefined|throw|true|false|null|this|new|in|of|instanceof|break|continue|switch|case|default|do|while|with|yield'
|
||||
|
||||
// 语法高亮函数,先整体提取字符串/注释等token再高亮
|
||||
function highlightSyntax(code: string): string {
|
||||
// 先转义HTML特殊字符
|
||||
const escaped = code.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
|
||||
// 单行字符串,所有反斜杠双重转义,保证正则安全
|
||||
const pattern = new RegExp(
|
||||
'("([^"\\\\]|\\\\.)*"|\'([^\'\\\\]|\\\\.)*\'|`([^`\\\\]|\\\\.)*`|//[^\\n]*|/\\*[\\s\\S]*?\\*/|\\b\\d+\\.?\\d*\\b|\\b(?:' +
|
||||
keywords +
|
||||
')\\b)',
|
||||
'g'
|
||||
)
|
||||
|
||||
const tokens: string[] = []
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(escaped)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push(...escaped.slice(lastIndex, match.index).split(/([ \t\n\r.])/))
|
||||
}
|
||||
tokens.push(match[0])
|
||||
lastIndex = pattern.lastIndex
|
||||
}
|
||||
if (lastIndex < escaped.length) {
|
||||
tokens.push(...escaped.slice(lastIndex).split(/([ \t\n\r.])/))
|
||||
}
|
||||
|
||||
const highlighted = tokens
|
||||
.map((token) => {
|
||||
if (
|
||||
/^"([^"\\]|\\.)*"$/.test(token) ||
|
||||
/^'([^'\\]|\\.)*'$/.test(token) ||
|
||||
/^`([^`\\]|\\.)*`$/.test(token)
|
||||
) {
|
||||
return `<span style="color: #1d6eca;">${token}</span>`
|
||||
}
|
||||
if (/^\b\d+\.?\d*\b$/.test(token)) {
|
||||
return `<span style="color: #00c583;">${token}</span>`
|
||||
}
|
||||
if (/^\/\/.*$/.test(token)) {
|
||||
return `<span style="color: #6a737d; font-style: italic;">${token}</span>`
|
||||
}
|
||||
if (/^\/\*[\s\S]*?\*\/$/.test(token)) {
|
||||
return `<span style="color: #6a737d; font-style: italic;">${token}</span>`
|
||||
}
|
||||
if (new RegExp(`\\b(?:${keywords})\\b`).test(token)) {
|
||||
return `<span style="color: #d73a49; font-weight: 600;">${token}</span>`
|
||||
}
|
||||
return token
|
||||
})
|
||||
.join('')
|
||||
|
||||
return highlighted
|
||||
}
|
||||
|
||||
const HighlightSyntaxClient: React.FC<HighlightSyntaxProps> = ({ code }) => {
|
||||
const htmlContent = highlightSyntax(code)
|
||||
|
||||
// eslint-disable-next-line react-dom/no-dangerously-set-innerhtml
|
||||
return <code className={styles.syntax} dangerouslySetInnerHTML={{ __html: htmlContent }} />
|
||||
}
|
||||
|
||||
export default HighlightSyntaxClient
|
||||
164
pages/components/JSConsole.module.css
Normal file
164
pages/components/JSConsole.module.css
Normal file
@@ -0,0 +1,164 @@
|
||||
.console {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
scroll-behavior: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.historyArea {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
background-color: #fafafa;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
scroll-behavior: contain;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #d0d0d0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #b0b0b0;
|
||||
}
|
||||
|
||||
.historyItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: #ccdeeebd 1px solid;
|
||||
margin-bottom: 6px;
|
||||
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.input {
|
||||
}
|
||||
&.output {
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
flex: 1;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
/* 错误样式 */
|
||||
&.error .content {
|
||||
color: #dc2626;
|
||||
background-color: #fef2f2;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #dc2626;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prompt {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: flex-start;
|
||||
width: 12px;
|
||||
color: #666;
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.executing {
|
||||
color: #f59e0b;
|
||||
font-style: italic;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.inputArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding: 12px;
|
||||
background-color: #ffffff;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
|
||||
.prompt {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: auto;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #333;
|
||||
resize: none;
|
||||
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.console {
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.historyArea,
|
||||
.inputLine {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
369
pages/components/JSConsole.tsx
Normal file
369
pages/components/JSConsole.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* JS 调试台,适合在文档中直接让用户运行代码,并且实时查看运行结果
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-base-to-string */
|
||||
import { KeyboardEvent, forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||
|
||||
import HighlightSyntax from './HighlightSyntax'
|
||||
|
||||
import styles from './JSConsole.module.css'
|
||||
|
||||
// 全局console拦截管理器
|
||||
class ConsoleInterceptor {
|
||||
private static instance: ConsoleInterceptor
|
||||
private subscribers = new Set<(type: string, args: unknown[]) => void>()
|
||||
private originalConsole: {
|
||||
log: typeof console.log
|
||||
warn: typeof console.warn
|
||||
error: typeof console.error
|
||||
}
|
||||
private isIntercepting = false
|
||||
|
||||
private constructor() {
|
||||
this.originalConsole = {
|
||||
log: console.log.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
}
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!ConsoleInterceptor.instance) {
|
||||
ConsoleInterceptor.instance = new ConsoleInterceptor()
|
||||
}
|
||||
return ConsoleInterceptor.instance
|
||||
}
|
||||
|
||||
subscribe(callback: (type: string, args: unknown[]) => void) {
|
||||
this.subscribers.add(callback)
|
||||
this.startIntercepting()
|
||||
}
|
||||
|
||||
unsubscribe(callback: (type: string, args: unknown[]) => void) {
|
||||
this.subscribers.delete(callback)
|
||||
if (this.subscribers.size === 0) {
|
||||
this.stopIntercepting()
|
||||
}
|
||||
}
|
||||
|
||||
private startIntercepting() {
|
||||
if (this.isIntercepting) return
|
||||
|
||||
this.isIntercepting = true
|
||||
|
||||
console.log = (...args: unknown[]) => {
|
||||
this.originalConsole.log(...args)
|
||||
this.notifySubscribers('log', args)
|
||||
}
|
||||
|
||||
console.warn = (...args: unknown[]) => {
|
||||
this.originalConsole.warn(...args)
|
||||
this.notifySubscribers('warn', args)
|
||||
}
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
this.originalConsole.error(...args)
|
||||
this.notifySubscribers('error', args)
|
||||
}
|
||||
}
|
||||
|
||||
private stopIntercepting() {
|
||||
if (!this.isIntercepting) return
|
||||
|
||||
this.isIntercepting = false
|
||||
console.log = this.originalConsole.log
|
||||
console.warn = this.originalConsole.warn
|
||||
console.error = this.originalConsole.error
|
||||
}
|
||||
|
||||
private notifySubscribers(type: string, args: unknown[]) {
|
||||
this.subscribers.forEach((callback) => {
|
||||
callback(type, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
interface JSConsoleProps {
|
||||
context?: Record<string, unknown>
|
||||
height?: string
|
||||
onExecute?: (code: string, result: unknown) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export interface JSConsoleRef {
|
||||
executeCode: (code: string) => Promise<unknown>
|
||||
clear: () => void
|
||||
appendOutput: (content: string) => void
|
||||
}
|
||||
|
||||
interface OutputItem {
|
||||
type: 'input' | 'output' | 'error' | 'log'
|
||||
content: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const JSConsole = forwardRef<JSConsoleRef, JSConsoleProps>(
|
||||
(
|
||||
{ context = {}, height = '400px', onExecute, placeholder = 'Enter JavaScript code...' },
|
||||
ref
|
||||
) => {
|
||||
const [input, setInput] = useState('')
|
||||
const [outputs, setOutputs] = useState<OutputItem[]>([])
|
||||
const [isExecuting, setIsExecuting] = useState(false)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const outputRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 持久的执行上下文,用于多轮对话共享作用域
|
||||
const executionContextRef = useRef<Record<string, unknown>>({})
|
||||
|
||||
// 格式化结果
|
||||
const formatResult = (value: unknown): string => {
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return 'undefined'
|
||||
if (typeof value === 'string') return `"${value}"`
|
||||
if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]`
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return value.toString()
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// 全局console拦截处理
|
||||
useEffect(() => {
|
||||
const interceptor = ConsoleInterceptor.getInstance()
|
||||
|
||||
const handleGlobalConsole = (type: string, args: unknown[]) => {
|
||||
const content = args.map((arg) => formatResult(arg)).join(' ')
|
||||
|
||||
const outputItem: OutputItem = {
|
||||
type: type as any,
|
||||
content: content,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
setOutputs((prev) => [...prev, outputItem])
|
||||
|
||||
// 自动滚动到底部
|
||||
setTimeout(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
interceptor.subscribe(handleGlobalConsole)
|
||||
|
||||
return () => {
|
||||
interceptor.unsubscribe(handleGlobalConsole)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 执行代码
|
||||
const executeCode = async (code: string): Promise<unknown> => {
|
||||
if (!code.trim()) return
|
||||
|
||||
setIsExecuting(true)
|
||||
|
||||
// 添加输入到输出
|
||||
const inputItem: OutputItem = {
|
||||
type: 'input',
|
||||
content: code,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
setOutputs((prev) => [...prev, inputItem])
|
||||
|
||||
try {
|
||||
// 创建异步函数以支持 await
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
||||
|
||||
// 合并外部上下文和持久执行上下文
|
||||
const allContext = { ...context, ...executionContextRef.current }
|
||||
const contextKeys = Object.keys(allContext)
|
||||
const contextValues = Object.values(allContext)
|
||||
|
||||
// 注入 console.log 重定向
|
||||
const logs: string[] = []
|
||||
const mockConsole = {
|
||||
log: (...args: unknown[]) => {
|
||||
logs.push(args.map((arg) => formatResult(arg)).join(' '))
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
logs.push('ERROR: ' + args.map((arg) => formatResult(arg)).join(' '))
|
||||
},
|
||||
warn: (...args: unknown[]) => {
|
||||
logs.push('WARN: ' + args.map((arg) => formatResult(arg)).join(' '))
|
||||
},
|
||||
}
|
||||
|
||||
// 检测代码是否是表达式还是语句
|
||||
const trimmedCode = code.trim()
|
||||
const isExpression =
|
||||
!trimmedCode.includes(';') &&
|
||||
!trimmedCode.startsWith('const ') &&
|
||||
!trimmedCode.startsWith('let ') &&
|
||||
!trimmedCode.startsWith('var ') &&
|
||||
!trimmedCode.startsWith('function ') &&
|
||||
!trimmedCode.startsWith('class ') &&
|
||||
!trimmedCode.startsWith('if ') &&
|
||||
!trimmedCode.startsWith('for ') &&
|
||||
!trimmedCode.startsWith('while ') &&
|
||||
!trimmedCode.startsWith('try ') &&
|
||||
!trimmedCode.startsWith('{') &&
|
||||
!trimmedCode.includes('\n')
|
||||
|
||||
// 如果是表达式,自动添加 return
|
||||
const codeToExecute = isExpression ? `return ${code}` : code
|
||||
|
||||
const wrappedCode = `
|
||||
return (async function() {
|
||||
${codeToExecute}
|
||||
})();
|
||||
`
|
||||
|
||||
// 执行代码
|
||||
const func = new AsyncFunction('console', ...contextKeys, wrappedCode)
|
||||
const result = await func(mockConsole, ...contextValues)
|
||||
|
||||
// 添加 console.log 输出
|
||||
if (logs.length > 0) {
|
||||
const logItem: OutputItem = {
|
||||
type: 'log',
|
||||
content: logs.join('\n'),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setOutputs((prev) => [...prev, logItem])
|
||||
}
|
||||
|
||||
// 总是添加执行结果输出(包括 undefined)
|
||||
const outputItem: OutputItem = {
|
||||
type: 'output',
|
||||
content: formatResult(result),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setOutputs((prev) => [...prev, outputItem])
|
||||
|
||||
onExecute?.(code, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorItem: OutputItem = {
|
||||
type: 'error',
|
||||
content: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setOutputs((prev) => [...prev, errorItem])
|
||||
throw error
|
||||
} finally {
|
||||
setIsExecuting(false)
|
||||
// 滚动到底部
|
||||
setTimeout(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空控制台
|
||||
const clear = () => {
|
||||
setOutputs([])
|
||||
// 同时清空执行上下文
|
||||
executionContextRef.current = {}
|
||||
}
|
||||
|
||||
// 添加输出
|
||||
const appendOutput = (content: string) => {
|
||||
const outputItem: OutputItem = {
|
||||
type: 'output',
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setOutputs((prev) => [...prev, outputItem])
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
executeCode,
|
||||
clear,
|
||||
appendOutput,
|
||||
}))
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.shiftKey) {
|
||||
// Shift+Enter 换行
|
||||
return
|
||||
} else {
|
||||
// Enter 执行
|
||||
e.preventDefault()
|
||||
if (!isExecuting && input.trim()) {
|
||||
executeCode(input)
|
||||
setInput('')
|
||||
setTimeout(() => inputRef.current?.focus(), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPrompt(type: string) {
|
||||
let prompt = ' '
|
||||
if (type === 'input') {
|
||||
prompt = '>'
|
||||
} else if (type === 'output') {
|
||||
prompt = '<'
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.console} style={{ height }}>
|
||||
{/* 历史记录和输入区域 */}
|
||||
<div className={styles.historyArea} ref={outputRef}>
|
||||
{outputs.map((item, index) => (
|
||||
<div key={index} className={`${styles.historyItem} ${styles[item.type]}`}>
|
||||
<span className={styles.prompt}>{getPrompt(item.type)}</span>
|
||||
<pre className={styles.content}>
|
||||
<HighlightSyntax code={item.content} />
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
{isExecuting && (
|
||||
<div className={styles.historyItem}>
|
||||
<span className={styles.prompt}>{'> '}</span>
|
||||
<span className={styles.executing}>Executing...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前输入行 */}
|
||||
<div className={styles.inputArea}>
|
||||
<span className={styles.prompt}>{'> '}</span>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={isExecuting}
|
||||
rows={1}
|
||||
style={{
|
||||
height: Math.min(Math.max(20, input.split('\n').length * 20), 120),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
JSConsole.displayName = 'JSConsole'
|
||||
|
||||
export default JSConsole
|
||||
Reference in New Issue
Block a user