Files
nl-blogs/client/src/components/CodePreview.vue

508 lines
18 KiB
Vue
Raw Normal View History

2026-01-15 13:51:44 +08:00
<template>
<div class="code-preview-wrapper relative w-full h-full min-h-[600px] flex flex-col font-sans text-gray-200">
<!-- 背景噪点层 -->
<div class="absolute inset-0 pointer-events-none z-0 opacity-20 mix-blend-overlay bg-noise"></div>
<!-- 顶部工具栏 -->
<div class="relative z-10 flex items-center justify-between px-6 py-4 bg-black/40 backdrop-blur-xl border-b border-white/5 rounded-t-2xl">
<div class="flex items-center gap-4">
<!-- 装饰性 Mac 窗口按钮 -->
<div class="flex gap-2">
<div class="w-3 h-3 rounded-full bg-[#ff5f56]"></div>
<div class="w-3 h-3 rounded-full bg-[#ffbd2e]"></div>
<div class="w-3 h-3 rounded-full bg-[#27c93f]"></div>
</div>
<span class="text-xs font-mono text-white/40 tracking-widest uppercase ml-2">Code Playground</span>
<!-- 运行状态指示器 -->
<div v-if="isRunning" class="flex items-center gap-2 px-2 py-0.5 rounded-full bg-yellow-500/10 border border-yellow-500/20">
<span class="block w-1.5 h-1.5 rounded-full bg-yellow-500 animate-pulse"></span>
<span class="text-[10px] font-mono text-yellow-500">Compiling...</span>
</div>
</div>
<!-- 语言选择器 & 操作区 -->
<div class="flex items-center gap-4">
<CustomSelect
v-model="selectedLanguage"
2026-01-20 08:58:44 +08:00
:options="languageOptions"
2026-01-15 13:51:44 +08:00
@update:modelValue="handleLanguageChange"
placeholder="选择语言"
style="width: 180px; font-family: monospace; font-size: 0.75rem;"
/>
<button
@click="runCode"
class="flex items-center gap-2 px-3 py-1.5 bg-[#d4b383] hover:bg-[#c4a373] text-black text-xs font-bold rounded-lg transition-colors"
title="运行代码 (Ctrl + Enter)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M5 3l14 9-14 9V3z"/></svg>
<span>RUN</span>
</button>
</div>
</div>
<!-- 主体内容区 -->
<div class="relative z-10 flex-1 flex flex-col md:flex-row bg-[#050505]/80 backdrop-blur-sm rounded-b-2xl overflow-hidden border border-t-0 border-white/5">
<!-- 左侧代码编辑器 -->
<div class="w-full md:w-1/2 relative flex flex-col border-b md:border-b-0 md:border-r border-white/5 group">
<div class="absolute inset-0 bg-[#09090b]">
<!-- 编辑器容器 -->
<div class="relative w-full h-full font-mono text-sm leading-relaxed custom-scrollbar overflow-hidden">
<pre
ref="highlightBlock"
class="absolute inset-0 p-6 m-0 pointer-events-none z-10 overflow-hidden whitespace-pre-wrap break-words"
aria-hidden="true"
><code :class="`language-${selectedLanguage} !bg-transparent !p-0 font-mono`" v-html="highlightedCode"></code></pre>
<textarea
ref="textarea"
v-model="code"
@input="handleCodeChange"
@scroll="syncScroll"
@keydown.ctrl.enter.prevent="runCode"
@keydown.meta.enter.prevent="runCode"
class="absolute inset-0 w-full h-full p-6 m-0 bg-transparent text-transparent caret-[#d4b383] z-20 resize-none border-none outline-none font-mono whitespace-pre-wrap break-words"
spellcheck="false"
placeholder="// Type your code here..."
></textarea>
</div>
</div>
</div>
<!-- 右侧实时预览 / 终端 -->
<div class="w-full md:w-1/2 bg-[#121214] flex flex-col relative">
<div class="h-8 flex items-center justify-between px-4 bg-white/5 border-b border-white/5 shrink-0">
<span class="text-[10px] font-mono text-[#d4b383] tracking-widest uppercase">
{{ isConsoleMode ? 'TERMINAL OUTPUT' : 'WEB PREVIEW' }}
</span>
<div class="flex items-center gap-2">
<button @click="clearConsole" v-if="isConsoleMode" class="text-[10px] text-white/40 hover:text-white mr-2">CLEAR</button>
<span v-if="error" class="flex items-center gap-1 text-[#ef4444] text-[10px]">
<span class="w-1.5 h-1.5 rounded-full bg-[#ef4444]"></span> ERROR
</span>
<span v-else class="flex items-center gap-1 text-green-500 text-[10px]">
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span> READY
</span>
</div>
</div>
<!-- Iframe 容器 -->
<div class="flex-1 relative bg-white w-full h-full">
<iframe
ref="previewFrame"
sandbox="allow-scripts allow-modals allow-same-origin"
title="Code Preview"
class="w-full h-full border-none"
:class="{ 'bg-[#1e1e1e]': isConsoleMode }"
></iframe>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import hljs from 'highlight.js'
import 'highlight.js/styles/atom-one-dark.css'
import CustomSelect from './CustomSelect.vue'
// Props & Emits
const props = defineProps<{
initialCode?: string
initialLanguage?: string
}>()
// State
const code = ref('')
const selectedLanguage = ref(props.initialLanguage || 'html')
const error = ref('')
const isRunning = ref(false)
const previewFrame = ref<HTMLIFrameElement | null>(null)
const textarea = ref<HTMLTextAreaElement | null>(null)
const highlightBlock = ref<HTMLElement | null>(null)
const highlightedCode = ref('')
// Language options for CustomSelect
const languageOptions = [
{ value: 'html', label: 'HTML5' },
{ value: 'css', label: 'CSS3' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'vue', label: 'Vue 3 (SFC-ish)' },
{ value: 'react', label: 'React (JSX)' },
{ value: 'python', label: 'Python (Pyodide)' },
{ value: 'go', label: 'Go (Backend)' },
{ value: 'php', label: 'PHP (Backend)' },
{ value: 'node', label: 'Node.js (Backend)' }
]
// Default Templates
const templates: Record<string, string> = {
html: `<h1>Hello HTML</h1>\n<p>Edit me!</p>`,
css: `.box { \n width: 100px; \n height: 100px; \n background: gold; \n}`,
javascript: `console.log("Hello JS");\nconst x = 10;\nconsole.log(x * 2);`,
vue: `<div id="app">\n <h1>{{ message }}</h1>\n <button @click="count++">Count: {{ count }}</button>\n</div>\n\n<script type="module">\n import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'\n createApp({\n data() {\n return {\n message: 'Hello Vue 3!',\n count: 0\n }\n }\n }).mount('#app')\n<\/script>`,
react: `// React Component\nfunction App() {\n const [count, setCount] = React.useState(0);\n return (\n <div style={{padding: 20, textAlign: 'center'}}>\n <h1>Hello React</h1>\n <p>Count: {count}</p>\n <button \n onClick={() => setCount(count + 1)}\n style={{padding: '8px 16px', background: '#61dafb', border: 'none', borderRadius: 4}}\n >\n Click Me\n </button>\n </div>\n );\n}\n\n// Render\nconst root = ReactDOM.createRoot(document.getElementById('root'));\nroot.render(<App />);`,
python: `# Python via Pyodide (Wasm)\nimport sys\n\nprint(f"Hello from Python {sys.version.split()[0]}")\n\ndef fib(n):\n if n <= 1: return n\n return fib(n-1) + fib(n-2)\n\nprint(f"Fib(10) = {fib(10)}")`,
go: `package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("Hello from Go!")\n}`,
php: `<?php\n\necho "Hello from PHP " . phpversion();\n`,
node: `console.log("Hello from Node.js " + process.version);`
}
// Computed
const isConsoleMode = computed(() => {
return ['javascript', 'python', 'go', 'php', 'node'].includes(selectedLanguage.value)
})
// Initialize Code Logic - 修复:优先使用传入的代码
if (props.initialCode) {
code.value = props.initialCode
} else {
code.value = templates[selectedLanguage.value] || ''
}
// Watchers - 修复:监听 props 变化以支持从数据库动态加载
watch(() => props.initialCode, (newVal) => {
if (newVal) {
code.value = newVal
highlightCode()
// 延迟运行,给 DOM 一点时间
setTimeout(() => runCode(), 100)
}
})
watch(() => props.initialLanguage, (newVal) => {
if (newVal) {
selectedLanguage.value = newVal
}
})
// Syntax Highlight
const highlightCode = () => {
if (!code.value) {
highlightedCode.value = ''
return
}
try {
const lang = selectedLanguage.value === 'vue' || selectedLanguage.value === 'react' ? 'javascript' : selectedLanguage.value
const result = hljs.highlight(code.value, { language: lang })
highlightedCode.value = result.value
} catch (err) {
highlightedCode.value = code.value
}
}
const syncScroll = (e: Event) => {
const target = e.target as HTMLTextAreaElement
if (highlightBlock.value) {
highlightBlock.value.scrollTop = target.scrollTop
highlightBlock.value.scrollLeft = target.scrollLeft
}
}
// --- Execution Engine ---
const runCode = async () => {
if (!previewFrame.value) return
isRunning.value = true
error.value = ''
const iframe = previewFrame.value
const doc = iframe.contentDocument || iframe.contentWindow?.document
if (!doc) return
// 重置
doc.open()
// 1. HTML / CSS
if (selectedLanguage.value === 'html') {
doc.write(code.value)
doc.close()
isRunning.value = false
}
else if (selectedLanguage.value === 'css') {
doc.write(`<html><head><style>${code.value}</style></head><body><div class="box">CSS Demo</div></body></html>`)
doc.close()
isRunning.value = false
}
// 2. JavaScript / Console
else if (selectedLanguage.value === 'javascript') {
const consoleTemplate = getConsoleTemplate(code.value)
doc.write(consoleTemplate)
doc.close()
isRunning.value = false
}
// 3. Vue 3 (Global Build)
else if (selectedLanguage.value === 'vue') {
const vueTemplate = `
<!DOCTYPE html>
<html>
<head>
<style>body { font-family: sans-serif; background: #fff; color: #333; padding: 20px; }</style>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"><\/script>
</head>
<body>
${code.value}
</body>
</html>
`
doc.write(vueTemplate)
doc.close()
isRunning.value = false
}
// 4. React (Babel Standalone)
else if (selectedLanguage.value === 'react') {
const reactTemplate = `
<!DOCTYPE html>
<html>
<head>
<style>body { font-family: sans-serif; background: #fff; color: #333; margin: 0; }</style>
<script src="https://unpkg.com/react@18/umd/react.development.js"><\/script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"><\/script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"><\/script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
${code.value}
<\/script>
</body>
</html>
`
doc.write(reactTemplate)
doc.close()
isRunning.value = false
}
// 5. Python (Pyodide Wasm)
else if (selectedLanguage.value === 'python') {
// Python 加载比较慢,显示 Loading
const pythonTemplate = `
<!DOCTYPE html>
<html>
<head>
<style>
body { background: #1e1e1e; color: #fff; font-family: monospace; padding: 20px; font-size: 14px; }
.log { border-bottom: 1px solid #333; padding: 4px 0; white-space: pre-wrap; }
.error { color: #ff6b6b; }
.loading { color: #d4b383; }
</style>
<script src="https://cdn.jsdelivr.net/pyodide/v0.25.0/full/pyodide.js"><\/script>
</head>
<body>
<div id="output">
<div class="loading">Initializing Python Environment (Pyodide)... This may take a moment.</div>
</div>
<script>
const output = document.getElementById('output');
function print(text, type = '') {
const div = document.createElement('div');
div.className = 'log ' + type;
div.textContent = text;
output.appendChild(div);
}
async function main() {
try {
if (!window.pyodide) {
window.pyodide = await loadPyodide();
// 清除 Loading 文字
output.innerHTML = '';
print("Python 3.11 Ready.", "loading");
print("------------------");
}
// 重定向 stdout
pyodide.setStdout({ batched: (msg) => print(msg) });
// 运行用户代码
await pyodide.runPythonAsync(\`${code.value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$')}\`);
} catch (err) {
print(err, 'error');
}
}
main();
<\/script>
</body>
</html>
`
doc.write(pythonTemplate)
doc.close()
isRunning.value = false
}
// 6. Backend Languages (Go, PHP, Node.js)
else if (['go', 'php', 'node'].includes(selectedLanguage.value)) {
// 显示 Loading 状态
const loadingTemplate = `
<!DOCTYPE html>
<html>
<body style="background:#1e1e1e;padding:20px;font-family:'JetBrains Mono', monospace;font-size:13px;color:#d4d4d8;margin:0;">
<div style="color: #d4b383;">Running code on server...</div>
</body>
</html>
`
doc.write(loadingTemplate)
try {
const response = await fetch('http://localhost:8081/api/run', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
language: selectedLanguage.value,
code: code.value
})
})
2026-01-16 17:03:34 +08:00
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '代码执行失败')
}
const data = await response.json()
const result = data.result // 从统一响应格式中提取 result
2026-01-15 13:51:44 +08:00
let outputHtml = ''
if (result.error) {
outputHtml = `<div style="color: #ef4444; white-space: pre-wrap;">Error:\n${result.error}</div>`
} else {
outputHtml = `<div style="white-space: pre-wrap;">${result.output}</div>`
if (result.exitCode !== 0) {
outputHtml += `<div style="color: #ef4444; margin-top: 10px; border-top: 1px solid #333; padding-top: 5px;">Process exited with code ${result.exitCode}</div>`
}
}
const resultTemplate = `
<!DOCTYPE html>
<html>
<body style="background:#1e1e1e;padding:20px;font-family:'JetBrains Mono', monospace;font-size:13px;color:#d4d4d8;margin:0;">
${outputHtml}
<div style="margin-top: 20px; font-size: 11px; color: #666;">Execution time: ${result.duration}ms</div>
</body>
</html>
`
// 重新写入结果
doc.open()
doc.write(resultTemplate)
doc.close()
} catch (err) {
const errorTemplate = `
<!DOCTYPE html>
<html>
<body style="background:#1e1e1e;padding:20px;font-family:'JetBrains Mono', monospace;font-size:13px;color:#ef4444;margin:0;">
<div>Failed to connect to server. Ensure backend is running at http://localhost:8081</div>
<div style="margin-top:10px;">${err}</div>
</body>
</html>
`
doc.open()
doc.write(errorTemplate)
doc.close()
} finally {
isRunning.value = false
}
}
}
// Helper: Custom Console for JS
const getConsoleTemplate = (jsCode: string) => {
return `
<!DOCTYPE html>
<html>
<body style="background:#1e1e1e;padding:20px;font-family:'JetBrains Mono', monospace;font-size:13px;color:#d4d4d8;margin:0;">
<div id="console"></div>
<script type="module">
const logDiv = document.getElementById('console');
const originalLog = console.log;
// 劫持 console.log
console.log = function(...args) {
const line = document.createElement('div');
line.style.borderBottom = '1px solid #333';
line.style.padding = '6px 0';
// 简单的对象转字符串
line.textContent = '> ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
logDiv.appendChild(line);
originalLog.apply(console, args);
};
window.onerror = function(msg, url, line) {
const err = document.createElement('div');
err.style.color = '#ef4444';
err.style.marginTop = '8px';
err.textContent = 'Error: ' + msg;
logDiv.appendChild(err);
};
try {
${jsCode}
} catch(e) { console.error(e); }
<\/script>
</body>
</html>
`
}
2026-01-20 08:58:44 +08:00
let runTimer: ReturnType<typeof setTimeout> | null = null
2026-01-15 13:51:44 +08:00
const handleCodeChange = () => {
highlightCode()
// 对于非 Web 语言(如 Python/React不自动运行等待用户点击 Run
if (['html', 'css', 'javascript'].includes(selectedLanguage.value)) {
// Debounce auto-run for lightweight languages
2026-01-20 08:58:44 +08:00
if (runTimer) clearTimeout(runTimer)
runTimer = setTimeout(() => runCode(), 1000)
2026-01-15 13:51:44 +08:00
}
}
const handleLanguageChange = () => {
code.value = templates[selectedLanguage.value] || ''
highlightCode()
runCode()
}
const clearConsole = () => {
if (previewFrame.value) {
const doc = previewFrame.value.contentDocument
if (doc) doc.body.innerHTML = '<div id="console"></div>' // 简单清空
}
}
onMounted(() => {
highlightCode()
// Initial Run
setTimeout(() => runCode(), 500)
})
</script>
<style scoped>
.bg-noise {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.07'/%3E%3C/svg%3E");
}
pre, textarea, code {
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #d4b383;
}
</style>