Files
nl-blogs/client/src/components/CodePreview.vue
2026-01-20 08:58:44 +08:00

508 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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"
:options="languageOptions"
@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
})
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || '代码执行失败')
}
const data = await response.json()
const result = data.result // 从统一响应格式中提取 result
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>
`
}
let runTimer: ReturnType<typeof setTimeout> | null = null
const handleCodeChange = () => {
highlightCode()
// 对于非 Web 语言(如 Python/React不自动运行等待用户点击 Run
if (['html', 'css', 'javascript'].includes(selectedLanguage.value)) {
// Debounce auto-run for lightweight languages
if (runTimer) clearTimeout(runTimer)
runTimer = setTimeout(() => runCode(), 1000)
}
}
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>