506 lines
18 KiB
Vue
506 lines
18 KiB
Vue
<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.filter(opt => !opt.disabled)"
|
||
@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>
|
||
`
|
||
}
|
||
|
||
const handleCodeChange = () => {
|
||
highlightCode()
|
||
// 对于非 Web 语言(如 Python/React),不自动运行,等待用户点击 Run
|
||
if (['html', 'css', 'javascript'].includes(selectedLanguage.value)) {
|
||
// Debounce auto-run for lightweight languages
|
||
clearTimeout(window.runTimer)
|
||
window.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> |