初始化
This commit is contained in:
500
client/src/components/CodePreview.vue
Normal file
500
client/src/components/CodePreview.vue
Normal file
@@ -0,0 +1,500 @@
|
||||
<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
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
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>
|
||||
188
client/src/components/CustomSelect.vue
Normal file
188
client/src/components/CustomSelect.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div class="custom-select-container relative group" @click="toggleDropdown" ref="containerRef">
|
||||
<!-- 选中值显示区域 -->
|
||||
<div class="selected-value flex items-center" :class="{ 'placeholder': !selectedOption }">
|
||||
<span class="flex-1 truncate">{{ selectedOption ? selectedOption.label : placeholder }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 下拉箭头 -->
|
||||
<div class="dropdown-arrow ml-2" :class="{ 'open': isDropdownOpen }">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 下拉选项列表 -->
|
||||
<div v-if="isDropdownOpen" class="dropdown-options absolute top-full left-0 right-0 mt-1 z-[9999]" ref="dropdownRef">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="option-item"
|
||||
:class="{ 'selected': option.value === modelValue }"
|
||||
@click.stop="selectOption(option)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
// Props 定义
|
||||
interface Option {
|
||||
value: string | number
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | number
|
||||
options: Option[]
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
// Emits 定义
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
}>()
|
||||
|
||||
// 组件状态
|
||||
const isDropdownOpen = ref(false)
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const dropdownRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
// 计算属性:当前选中的选项
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find(option => option.value === props.modelValue)
|
||||
})
|
||||
|
||||
// 切换下拉列表显示/隐藏
|
||||
const toggleDropdown = () => {
|
||||
isDropdownOpen.value = !isDropdownOpen.value
|
||||
}
|
||||
|
||||
// 选择选项
|
||||
const selectOption = (option: Option) => {
|
||||
emit('update:modelValue', option.value)
|
||||
isDropdownOpen.value = false
|
||||
}
|
||||
|
||||
// 点击外部关闭下拉列表
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
|
||||
isDropdownOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听modelValue变化,更新选中状态
|
||||
watch(() => props.modelValue, () => {
|
||||
// 当外部更新modelValue时,不需要额外操作,selectedOption会自动更新
|
||||
})
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
// 添加点击外部关闭事件监听
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 移除事件监听
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-select-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
color: #ececec;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.custom-select-container:hover {
|
||||
border-color: #d4b383;
|
||||
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
|
||||
}
|
||||
|
||||
.selected-value {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.selected-value.placeholder {
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.3s ease;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.dropdown-arrow.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown-options {
|
||||
z-index: 99999999999999999999;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.option-item:hover {
|
||||
background: rgba(212, 179, 131, 0.1);
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.option-item.selected {
|
||||
background: rgba(212, 179, 131, 0.2);
|
||||
color: #d4b383;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.dropdown-options::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.dropdown-options::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dropdown-options::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dropdown-options::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
12
client/src/components/Footer.vue
Normal file
12
client/src/components/Footer.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<footer class="py-8 text-center border-t border-white/5 relative z-10">
|
||||
<div class="flex justify-center items-center gap-4 mb-4">
|
||||
<a @click="window.open('admin.html', '_blank')" class="text-xs text-art-muted/30 hover:text-art-accent cursor-pointer">管理入口</a>
|
||||
</div>
|
||||
<p class="text-art-muted text-xs font-mono tracking-widest opacity-50">© 2024 年糕崽崽. 保留所有权利.</p>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Footer组件的逻辑可以在这里添加
|
||||
</script>
|
||||
122
client/src/components/Header.vue
Normal file
122
client/src/components/Header.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<header class="fixed top-0 w-full z-50 glass-nav transition-all duration-300" id="main-header">
|
||||
<div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<div class="cursor-pointer group" @click="goTo('/')">
|
||||
<span class="font-serif text-2xl italic tracking-wider text-white group-hover:text-art-accent transition-colors">年糕崽崽.Dev</span>
|
||||
</div>
|
||||
<nav class="hidden md:flex items-center gap-10">
|
||||
<button
|
||||
@click="goTo('/')"
|
||||
class="nav-item text-sm font-medium tracking-wide text-art-muted hover:text-white transition-colors link-underline"
|
||||
:class="{ 'text-white after:w-full': activeNav === 'home' }"
|
||||
data-target="home"
|
||||
>
|
||||
首页
|
||||
</button>
|
||||
<button
|
||||
@click="goTo('/blog')"
|
||||
class="nav-item text-sm font-medium tracking-wide text-art-muted hover:text-white transition-colors link-underline"
|
||||
:class="{ 'text-white after:w-full': activeNav === 'blog' }"
|
||||
data-target="blog"
|
||||
>
|
||||
思考
|
||||
</button>
|
||||
<button
|
||||
@click="goTo('/works')"
|
||||
class="nav-item text-sm font-medium tracking-wide text-art-muted hover:text-white transition-colors link-underline"
|
||||
:class="{ 'text-white after:w-full': activeNav === 'works' }"
|
||||
data-target="works"
|
||||
>
|
||||
作品
|
||||
</button>
|
||||
<button
|
||||
@click="goTo('/snippets')"
|
||||
class="nav-item text-sm font-medium tracking-wide text-art-muted hover:text-white transition-colors link-underline"
|
||||
:class="{ 'text-white after:w-full': activeNav === 'snippets' }"
|
||||
data-target="snippets"
|
||||
>
|
||||
代码
|
||||
</button>
|
||||
<button
|
||||
@click="goTo('/about')"
|
||||
class="nav-item text-sm font-medium tracking-wide text-art-muted hover:text-white transition-colors link-underline"
|
||||
:class="{ 'text-white after:w-full': activeNav === 'about' }"
|
||||
data-target="about"
|
||||
>
|
||||
关于
|
||||
</button>
|
||||
</nav>
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
<a href="#" class="text-art-muted hover:text-white transition-colors"><i data-lucide="github" class="w-5 h-5"></i></a>
|
||||
<button @click="goTo('/services')" class="px-5 py-2 text-xs font-bold tracking-widest uppercase border border-white/20 hover:border-art-accent hover:text-art-accent transition-all rounded-full">
|
||||
合作
|
||||
</button>
|
||||
</div>
|
||||
<button class="md:hidden text-white" @click="toggleMobileMenu">
|
||||
<i data-lucide="menu" class="w-6 h-6"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const activeNav = ref('home')
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
const menu = document.getElementById('mobile-menu')
|
||||
if (menu) {
|
||||
if (menu.classList.contains('menu-closed')) {
|
||||
menu.classList.remove('menu-closed')
|
||||
menu.classList.add('menu-open')
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
menu.classList.remove('menu-open')
|
||||
menu.classList.add('menu-closed')
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const navigate = (target: string) => {
|
||||
router.push(target)
|
||||
toggleMobileMenu()
|
||||
}
|
||||
|
||||
const updateActiveNav = () => {
|
||||
const path = route.path
|
||||
if (path === '/') activeNav.value = 'home'
|
||||
else if (path === '/blog') activeNav.value = 'blog'
|
||||
else if (path === '/works' || path.startsWith('/works/')) activeNav.value = 'works'
|
||||
else if (path === '/snippets') activeNav.value = 'snippets'
|
||||
else if (path === '/services') activeNav.value = 'services'
|
||||
else if (path === '/about') activeNav.value = 'about'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateActiveNav()
|
||||
// 初始化Lucide图标
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
updateActiveNav()
|
||||
}
|
||||
)
|
||||
|
||||
const goTo = (target: string) => {
|
||||
router.push(target)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式可以在这里添加 */
|
||||
</style>
|
||||
40
client/src/components/Icon.vue
Normal file
40
client/src/components/Icon.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<i
|
||||
:data-lucide="name"
|
||||
:class="className"
|
||||
:style="style"
|
||||
ref="iconRef"
|
||||
></i>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
className?: string
|
||||
style?: Record<string, any>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
className: '',
|
||||
style: () => ({})
|
||||
})
|
||||
|
||||
const iconRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const updateIcon = () => {
|
||||
if (window.lucide && iconRef.value) {
|
||||
// 重新创建图标
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateIcon()
|
||||
})
|
||||
|
||||
watch(() => props.name, () => {
|
||||
updateIcon()
|
||||
})
|
||||
</script>
|
||||
194
client/src/components/InquiryModal.vue
Normal file
194
client/src/components/InquiryModal.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div id="inquiry-modal" class="fixed inset-0 z-[100] bg-[#050505]/95 backdrop-blur-xl hidden items-center justify-center p-4 transition-opacity duration-300">
|
||||
<div class="relative w-full max-w-4xl bg-[#080808] border border-white/10 overflow-hidden flex flex-col md:flex-row shadow-2xl">
|
||||
<div class="hidden md:flex w-1/3 bg-[#0c0c0c] border-r border-white/5 p-12 flex-col justify-between relative overflow-hidden">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-noise opacity-10"></div>
|
||||
<div class="absolute -top-20 -left-20 w-64 h-64 bg-art-accent/10 rounded-full blur-[80px]"></div>
|
||||
<div class="relative z-10">
|
||||
<h3 class="font-serif text-3xl text-white italic mb-6 leading-tight">
|
||||
Let's build<br>something<br>unreal.
|
||||
</h3>
|
||||
<p class="font-mono text-xs text-white/40 leading-relaxed">
|
||||
告诉我你的构想。<br>
|
||||
我将提供架构与代码,<br>
|
||||
将想象变为现实。
|
||||
</p>
|
||||
</div>
|
||||
<div class="relative z-10 font-mono text-[10px] text-white/20 tracking-widest">
|
||||
LAT: 30.2741° N<br>
|
||||
LONG: 120.1551° E<br>
|
||||
HANGZHOU, CN
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/3 p-10 md:p-16 relative bg-[#080808]">
|
||||
<button @click="closeModal" class="absolute top-6 right-6 text-white/30 hover:text-white transition-colors z-20">
|
||||
<i data-lucide="x" class="w-6 h-6"></i>
|
||||
</button>
|
||||
<form id="inquiry-form" class="space-y-10 relative z-10" @submit.prevent="handleSubmit">
|
||||
<div class="space-y-8">
|
||||
<p class="font-mono text-xs text-art-accent uppercase tracking-widest border-b border-white/10 pb-2 mb-6">01 // 身份识别</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="relative group/input">
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
class="peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent"
|
||||
placeholder="Name"
|
||||
v-model="formData.name"
|
||||
>
|
||||
<label class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-not-placeholder-shown:-top-4 peer-not-placeholder-shown:text-white/50 pointer-events-none">您的称呼</label>
|
||||
</div>
|
||||
<div class="relative group/input">
|
||||
<input
|
||||
name="company"
|
||||
type="text"
|
||||
class="peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent"
|
||||
placeholder="Company"
|
||||
v-model="formData.company"
|
||||
>
|
||||
<label class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-not-placeholder-shown:-top-4 peer-not-placeholder-shown:text-white/50 pointer-events-none">公司 / 组织</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative group/input">
|
||||
<input
|
||||
name="contact"
|
||||
type="text"
|
||||
class="peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent"
|
||||
placeholder="Contact"
|
||||
v-model="formData.contact"
|
||||
>
|
||||
<label class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-not-placeholder-shown:-top-4 peer-not-placeholder-shown:text-white/50 pointer-events-none">联系方式 (Email / WeChat)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<p class="font-mono text-xs text-art-accent uppercase tracking-widest border-b border-white/10 pb-2 mb-6">02 // 项目细节</p>
|
||||
<div class="space-y-3">
|
||||
<label class="text-xs font-mono text-white/30 block mb-2">预估预算范围</label>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label class="cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
name="budget"
|
||||
value="10k-50k"
|
||||
class="peer hidden"
|
||||
v-model="formData.budget"
|
||||
>
|
||||
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">10k - 50k</span>
|
||||
</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
name="budget"
|
||||
value="50k-200k"
|
||||
class="peer hidden"
|
||||
v-model="formData.budget"
|
||||
>
|
||||
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">50k - 200k</span>
|
||||
</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
name="budget"
|
||||
value="200k+"
|
||||
class="peer hidden"
|
||||
v-model="formData.budget"
|
||||
>
|
||||
<span class="px-4 py-2 border border-white/10 rounded-none text-xs font-mono text-white/50 hover:border-art-accent hover:text-white transition-all peer-checked:bg-art-accent peer-checked:text-black peer-checked:border-art-accent">200k +</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative group/input mt-8">
|
||||
<textarea
|
||||
name="description"
|
||||
rows="1"
|
||||
class="peer w-full bg-transparent border-b border-white/20 py-2 text-white font-serif text-lg focus:border-art-accent outline-none transition-colors placeholder-transparent resize-none min-h-[40px]"
|
||||
placeholder="Brief"
|
||||
v-model="formData.description"
|
||||
@input="autoResizeTextarea"
|
||||
></textarea>
|
||||
<label class="absolute left-0 top-2 text-white/30 text-xs font-mono transition-all peer-focus:-top-4 peer-focus:text-art-accent peer-not-placeholder-shown:-top-4 peer-not-placeholder-shown:text-white/50 pointer-events-none">一句话描述需求</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
class="group relative w-full overflow-hidden bg-white text-black rounded-full font-bold hover:scale-105 transition-transform"
|
||||
>
|
||||
<div class="absolute inset-0 w-0 bg-art-accent transition-all duration-[250ms] ease-out group-hover:w-full"></div>
|
||||
<span class="relative flex items-center justify-between z-10 px-8 py-4">
|
||||
<span class="font-mono uppercase tracking-widest text-sm">INITIATE_PROTOCOL // 发送</span>
|
||||
<i data-lucide="arrow-right" class="w-4 h-4 transition-transform group-hover:translate-x-1"></i>
|
||||
</span>
|
||||
</button>
|
||||
<p class="mt-4 text-[10px] text-white/20 font-mono text-center tracking-widest">SECURED BY DIGITAL FINGERPRINTING</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const formData = ref({
|
||||
name: '',
|
||||
company: '',
|
||||
contact: '',
|
||||
budget: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const openModal = () => {
|
||||
const modal = document.getElementById('inquiry-modal')
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden')
|
||||
modal.classList.add('flex')
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
const modal = document.getElementById('inquiry-modal')
|
||||
if (modal) {
|
||||
modal.classList.remove('flex')
|
||||
modal.classList.add('hidden')
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
console.log('Form submitted:', formData.value)
|
||||
// 这里可以添加表单提交逻辑
|
||||
closeModal()
|
||||
showToast('合作咨询已发送,我们会尽快与您联系!')
|
||||
}
|
||||
|
||||
const autoResizeTextarea = (event: Event) => {
|
||||
const textarea = event.target as HTMLTextAreaElement
|
||||
textarea.style.height = ''
|
||||
textarea.style.height = textarea.scrollHeight + 'px'
|
||||
}
|
||||
|
||||
const showToast = (message: string, type: string = 'success') => {
|
||||
const container = document.getElementById('toast-container')
|
||||
if (container) {
|
||||
const toast = document.createElement('div')
|
||||
toast.className = `toast ${type === 'success' ? 'toast-success' : 'toast-error'}`
|
||||
toast.innerHTML = `<i data-lucide="${type === 'success' ? 'check-circle' : 'alert-circle'}" class="w-5 h-5 ${type === 'success' ? 'text-[#d4b383]' : 'text-red-500'}"></i><span class="text-sm font-medium">${message}</span>`
|
||||
container.appendChild(toast)
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
requestAnimationFrame(() => toast.classList.add('show'))
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show')
|
||||
setTimeout(() => toast.remove(), 400)
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法给全局使用
|
||||
window.openInquiry = openModal
|
||||
window.closeInquiry = closeModal
|
||||
</script>
|
||||
31
client/src/components/MobileMenu.vue
Normal file
31
client/src/components/MobileMenu.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div id="mobile-menu" class="fixed inset-0 z-40 bg-art-bg/95 backdrop-blur-xl flex flex-col items-center justify-center space-y-8 transition-all duration-300 menu-closed md:hidden">
|
||||
<button @click="toggleMobileMenu" class="absolute top-6 right-6 text-white"><i data-lucide="x" class="w-8 h-8"></i></button>
|
||||
<a @click="navigate('/')" class="font-serif text-3xl italic text-white hover:text-art-accent cursor-pointer">首页</a>
|
||||
<a @click="navigate('/blog')" class="font-serif text-3xl italic text-white hover:text-art-accent cursor-pointer">思考</a>
|
||||
<a @click="navigate('/works')" class="font-serif text-3xl italic text-white hover:text-art-accent cursor-pointer">作品</a>
|
||||
<a @click="navigate('/snippets')" class="font-serif text-3xl italic text-white hover:text-art-accent cursor-pointer">代码</a>
|
||||
<a @click="navigate('/about')" class="font-serif text-3xl italic text-white hover:text-art-accent cursor-pointer">关于</a>
|
||||
<a @click="window.open('admin.html', '_blank')" class="font-mono text-sm text-art-muted mt-8">管理入口</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
const menu = document.getElementById('mobile-menu')
|
||||
if (menu) {
|
||||
menu.classList.remove('menu-open')
|
||||
menu.classList.add('menu-closed')
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
|
||||
const navigate = (target: string) => {
|
||||
router.push(target)
|
||||
toggleMobileMenu()
|
||||
}
|
||||
</script>
|
||||
97
client/src/components/SnippetModal.vue
Normal file
97
client/src/components/SnippetModal.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div id="snippet-modal" class="fixed inset-0 z-[100] modal-overlay hidden flex items-center justify-center p-4 md:p-10 opacity-0 transition-opacity duration-300">
|
||||
<div class="bg-[#0a0a0c] border border-white/10 w-full max-w-6xl h-full md:h-[80vh] rounded-2xl flex flex-col overflow-hidden shadow-2xl transform scale-95 transition-transform duration-300" id="modal-content">
|
||||
<div class="h-16 border-b border-white/10 flex items-center justify-between px-6 bg-white/5">
|
||||
<div class="flex items-center gap-3"><i data-lucide="code-2" class="text-art-accent"></i><span class="font-bold text-white" id="modal-title">{{ modalTitle }}</span></div>
|
||||
<button @click="closeSnippet" class="p-2 hover:bg-white/10 rounded-full text-white/50 hover:text-white transition-colors"><i data-lucide="x" class="w-5 h-5"></i></button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<!-- 使用CodePreview组件 -->
|
||||
<CodePreview
|
||||
:initial-code="modalCode"
|
||||
:initial-language="detectLanguage(modalCode)"
|
||||
@code-change="handleCodeChange"
|
||||
@language-change="handleLanguageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import CodePreview from './CodePreview.vue'
|
||||
|
||||
const modalTitle = ref('Code')
|
||||
const modalCode = ref('')
|
||||
|
||||
// 检测代码语言
|
||||
const detectLanguage = (code: string): string => {
|
||||
// 简单的语言检测逻辑,可以根据需要扩展
|
||||
if (code.includes('<html') || code.includes('<div') || code.includes('<body')) {
|
||||
return 'html'
|
||||
} else if (code.includes('import React') || code.includes('React.')) {
|
||||
return 'react'
|
||||
} else if (code.includes('<template') || code.includes('export default')) {
|
||||
return 'vue'
|
||||
} else if (code.includes('class ') && code.includes('{')) {
|
||||
return 'typescript'
|
||||
} else if (code.includes('function ') || code.includes('const ') || code.includes('let ') || code.includes('var ')) {
|
||||
return 'javascript'
|
||||
} else if (code.includes('@import') || code.includes('{') && code.includes(':') && code.includes(';')) {
|
||||
return 'css'
|
||||
} else if (code.includes('print(') || code.includes('def ')) {
|
||||
return 'python'
|
||||
} else {
|
||||
return 'html'
|
||||
}
|
||||
}
|
||||
|
||||
const showSnippet = (title: string, code: string) => {
|
||||
modalTitle.value = title
|
||||
modalCode.value = code
|
||||
|
||||
const modal = document.getElementById('snippet-modal')
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden')
|
||||
modal.classList.add('flex')
|
||||
setTimeout(() => {
|
||||
modal.style.opacity = '1'
|
||||
const content = document.getElementById('modal-content')
|
||||
if (content) {
|
||||
content.style.transform = 'scale(1)'
|
||||
}
|
||||
}, 10)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
const closeSnippet = () => {
|
||||
const modal = document.getElementById('snippet-modal')
|
||||
if (modal) {
|
||||
modal.style.opacity = '0'
|
||||
const content = document.getElementById('modal-content')
|
||||
if (content) {
|
||||
content.style.transform = 'scale(0.95)'
|
||||
}
|
||||
setTimeout(() => {
|
||||
modal.classList.remove('flex')
|
||||
modal.classList.add('hidden')
|
||||
document.body.style.overflow = ''
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCodeChange = (newCode: string) => {
|
||||
modalCode.value = newCode
|
||||
}
|
||||
|
||||
const handleLanguageChange = (newLanguage: string) => {
|
||||
// 可以在这里处理语言变化事件
|
||||
console.log('Language changed to:', newLanguage)
|
||||
}
|
||||
|
||||
// 暴露方法给全局使用
|
||||
window.showSnippet = showSnippet
|
||||
window.closeSnippet = closeSnippet
|
||||
</script>
|
||||
335
client/src/components/admin/AdminLayout.vue
Normal file
335
client/src/components/admin/AdminLayout.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="admin-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h1 class="sidebar-title">管理后台</h1>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link to="/admin/dashboard" class="nav-link" active-class="active">
|
||||
<span class="nav-icon">📊</span>
|
||||
<span class="nav-text">仪表盘</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/admin/users" class="nav-link" active-class="active">
|
||||
<span class="nav-icon">👥</span>
|
||||
<span class="nav-text">用户管理</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/admin/roles" class="nav-link" active-class="active">
|
||||
<span class="nav-icon">🔒</span>
|
||||
<span class="nav-text">角色管理</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/admin/posts" class="nav-link" active-class="active">
|
||||
<span class="nav-icon">📝</span>
|
||||
<span class="nav-text">文章管理</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/admin/works" class="nav-link" active-class="active">
|
||||
<span class="nav-icon">🎨</span>
|
||||
<span class="nav-text">作品管理</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/admin/snippets" class="nav-link" active-class="active">
|
||||
<span class="nav-icon">💻</span>
|
||||
<span class="nav-text">代码片段</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/admin/settings" class="nav-link" active-class="active">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span class="nav-text">系统配置</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link to="/admin/logs" class="nav-link" active-class="active">
|
||||
<span class="nav-icon">📋</span>
|
||||
<span class="nav-text">操作日志</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<button @click="handleLogout" class="logout-btn">
|
||||
<span class="nav-icon">🚪</span>
|
||||
<span class="nav-text">退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="admin-main">
|
||||
<!-- Top Navigation Bar -->
|
||||
<header class="admin-header">
|
||||
<div class="header-left">
|
||||
<button class="toggle-btn" @click="toggleSidebar">
|
||||
☰
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="user-info">
|
||||
<span class="username">{{ currentUser.username }}</span>
|
||||
<span class="role">({{ currentUser.role }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Content Container -->
|
||||
<div class="admin-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const isSidebarOpen = ref(true)
|
||||
const currentUser = ref(JSON.parse(localStorage.getItem('user') || '{}'))
|
||||
|
||||
const toggleSidebar = () => {
|
||||
isSidebarOpen.value = !isSidebarOpen.value
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
toast.showToast('退出登录成功', 'success')
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
onMounted(() => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) {
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background-color: #050505;
|
||||
color: #ececec;
|
||||
}
|
||||
|
||||
/* Sidebar Styles */
|
||||
.admin-sidebar {
|
||||
width: 250px;
|
||||
background: rgba(5, 5, 5, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.03);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #d4b383;
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.sidebar-nav ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
border-left: 3px solid transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: rgba(212, 179, 131, 0.1);
|
||||
color: white;
|
||||
border-left-color: rgba(212, 179, 131, 0.3);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: rgba(212, 179, 131, 0.15);
|
||||
color: #d4b383;
|
||||
border-left-color: #d4b383;
|
||||
box-shadow: 0 0 15px rgba(212, 179, 131, 0.1);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-size: 0.95rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: transparent;
|
||||
color: #d4b383;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
font-size: 0.95rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background-color: rgba(212, 179, 131, 0.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(212, 179, 131, 0.1);
|
||||
}
|
||||
|
||||
/* Main Content Styles */
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header Styles */
|
||||
.admin-header {
|
||||
background: rgba(5, 5, 5, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
padding: 0 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background-color: rgba(212, 179, 131, 0.1);
|
||||
color: #d4b383;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 600;
|
||||
color: #d4b383;
|
||||
}
|
||||
|
||||
.role {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Content Area */
|
||||
.admin-content {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Scrollbar Styles */
|
||||
.admin-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.admin-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.admin-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.admin-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #d4b383;
|
||||
box-shadow: 0 0 10px rgba(212, 179, 131, 0.4);
|
||||
}
|
||||
|
||||
.admin-content::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.admin-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
z-index: 1000;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.admin-sidebar.collapsed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user