初始化

This commit is contained in:
李琦
2026-01-15 13:51:44 +08:00
commit b7b6d3e39e
156 changed files with 38913 additions and 0 deletions

37
client/src/App.vue Normal file
View File

@@ -0,0 +1,37 @@
<template>
<div class="min-h-screen bg-art-bg text-art-text">
<!-- Global Noise -->
<div class="fixed inset-0 pointer-events-none z-[60] bg-noise opacity-30 mix-blend-overlay"></div>
<!-- Toast Container -->
<div id="toast-container" class="toast-container"></div>
<!-- Header -->
<Header />
<!-- Mobile Menu -->
<MobileMenu />
<!-- Main Content -->
<main class="pt-32 pb-20 px-6 max-w-7xl mx-auto relative z-10">
<router-view />
</main>
<!-- Footer -->
<Footer />
<!-- Snippet Modal -->
<SnippetModal />
<!-- Inquiry Modal -->
<InquiryModal />
</div>
</template>
<script setup lang="ts">
import Header from './components/Header.vue'
import MobileMenu from './components/MobileMenu.vue'
import Footer from './components/Footer.vue'
import SnippetModal from './components/SnippetModal.vue'
import InquiryModal from './components/InquiryModal.vue'
</script>

View 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>

View 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>

View 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">&copy; 2024 年糕崽崽. 保留所有权利.</p>
</footer>
</template>
<script setup lang="ts">
// Footer组件的逻辑可以在这里添加
</script>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,65 @@
import { onMounted, onBeforeUnmount } from 'vue'
export const useInteractions = () => {
// 滚动进度条
const updateScrollProgress = () => {
const scrollTop = window.scrollY
const docHeight = document.documentElement.scrollHeight
const winHeight = window.innerHeight
const scrollPercent = scrollTop / (docHeight - winHeight)
const progressBar = document.getElementById('scroll-progress')
if (progressBar) {
progressBar.style.width = `${scrollPercent * 100}%`
}
}
// 鼠标倾斜效果
const initTiltEffect = () => {
const tiltCards = document.querySelectorAll('.tilt-card')
tiltCards.forEach(card => {
card.addEventListener('mousemove', (e) => {
const cardRect = card.getBoundingClientRect()
const x = e.clientX - cardRect.left
const y = e.clientY - cardRect.top
const xPercent = (x / cardRect.width) * 100
const yPercent = (y / cardRect.height) * 100
card.style.setProperty('--mouse-x', `${xPercent}%`)
card.style.setProperty('--mouse-y', `${yPercent}%`)
})
})
}
// 初始化所有交互功能
const init = () => {
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
// 添加滚动事件监听
window.addEventListener('scroll', updateScrollProgress)
// 初始化倾斜效果
initTiltEffect()
}
// 清理资源
const cleanup = () => {
window.removeEventListener('scroll', updateScrollProgress)
}
onMounted(() => {
init()
})
onBeforeUnmount(() => {
cleanup()
})
return {
updateScrollProgress,
initTiltEffect
}
}

View File

@@ -0,0 +1,35 @@
import { onMounted, onUnmounted } from 'vue'
export function useScrollAnimation() {
let observer: IntersectionObserver | null = null
const observe = () => {
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible')
// Optional: Stop observing once visible
// observer?.unobserve(entry.target)
}
})
}, {
threshold: 0.1,
rootMargin: '50px' // Pre-load slightly before element comes into view
})
document.querySelectorAll('.animate-slide-down, .animate-reveal').forEach(el => {
observer?.observe(el)
})
}
onMounted(() => {
// Small delay to ensure DOM is ready
setTimeout(observe, 100)
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
}

View File

@@ -0,0 +1,43 @@
export const useToast = () => {
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
const container = document.getElementById('toast-container')
if (!container) return
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)
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
// 显示动画
requestAnimationFrame(() => toast.classList.add('show'))
// 3秒后自动隐藏
setTimeout(() => {
toast.classList.remove('show')
setTimeout(() => toast.remove(), 400)
}, 3000)
}
// 便捷方法
const success = (message: string) => {
showToast(message, 'success')
}
const error = (message: string) => {
showToast(message, 'error')
}
return {
showToast,
success,
error
}
}

8
client/src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createApp } from "vue"
import App from "./App.vue"
import "./style.css"
import router from "./router"
const app = createApp(App)
app.use(router)
app.mount("#app")

159
client/src/pages/About.vue Normal file
View File

@@ -0,0 +1,159 @@
<template>
<section id="about" class="page-section block animate-slide-down">
<div class="max-w-6xl mx-auto pt-32 px-6 pb-20">
<!-- Loading state -->
<div v-if="loading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-center text-red-500 py-20">
<p class="mb-4">{{ error }}</p>
<button @click="fetchProfileData" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
重试
</button>
</div>
<!-- Profile content -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-12">
<div class="space-y-8">
<div>
<span class="text-art-accent font-mono text-sm tracking-widest uppercase">个人档案</span>
<h2 class="font-serif text-5xl italic text-white mt-2 mb-6">关于我</h2>
</div>
<div class="flex items-center gap-6">
<div class="w-24 h-24 rounded-full overflow-hidden border-2 border-art-accent/50 shadow-2xl">
<img
:src="profile.avatar"
alt="Avatar"
class="w-full h-full object-cover"
>
</div>
<div class="space-y-2">
<h3 class="text-2xl font-bold text-white">{{ profile.name }}</h3>
<div class="flex items-center gap-2 text-sm text-art-muted">
<i data-lucide="map-pin" class="w-4 h-4 text-art-accent"></i>
<span>{{ profile.location }}</span>
</div>
</div>
</div>
<div class="prose prose-invert text-art-muted font-light leading-relaxed">
<p v-html="profile.bio"></p>
</div>
<div class="bg-white/5 border border-white/5 p-6 rounded-xl backdrop-blur-sm mt-6">
<h4 class="text-white font-bold mb-4 flex items-center gap-2">
<i data-lucide="contact" class="w-4 h-4 text-art-accent"></i> 联系方式
</h4>
<div class="space-y-3 text-sm">
<a :href="'mailto:' + profile.contact.email" class="flex items-center gap-2 text-art-muted hover:text-white transition-colors">
<i data-lucide="mail" class="w-4 h-4 text-art-accent"></i>
<span>{{ profile.contact.email }}</span>
</a>
<div class="flex items-center gap-2 text-sm text-art-muted">
<i data-lucide="message-circle" class="w-4 h-4 text-art-accent"></i>
<span>WeChat: {{ profile.contact.wechat }}</span>
</div>
</div>
</div>
</div>
<div class="space-y-8">
<div class="bg-white/5 border border-white/5 p-8 rounded-2xl backdrop-blur-sm">
<h3 class="text-xl font-bold text-white mb-6 flex items-center gap-2">
<i data-lucide="cpu" class="w-5 h-5 text-art-accent"></i> 技术栈
</h3>
<div class="flex flex-wrap gap-2 mb-10">
<span
v-for="(tech, index) in profile.techStack"
:key="index"
class="px-3 py-1 bg-white/5 rounded-full text-xs text-white/80 border border-white/10"
>
{{ tech }}
</span>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeMount } from 'vue'
import { useScrollAnimation } from '../composables/useScrollAnimation'
// 定义类型
interface Profile {
id: string
name: string
avatar: string
location: string
bio: string
contact: {
email: string
wechat: string
}
techStack: string[]
}
const profile = ref<Profile>({
id: '',
name: '年糕崽崽',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao&backgroundColor=b6e3f4',
location: '中国 · 浙江杭州',
bio: '嗨!我是年糕崽崽,一名热衷于探索数字边界的前端架构师。我的职业生涯始于对像素级完美的执念。目前,我专注于高性能 B 端应用的体验升级。',
contact: {
email: 'hello@niangao.dev',
wechat: 'Niangao_Dev'
},
techStack: ['Vue 3', 'React', 'TypeScript', 'Three.js', 'Golang', 'Tailwind CSS', 'Vite', 'Gin']
})
const loading = ref(false)
const error = ref('')
// 模拟API调用实际项目中应替换为真实API
const fetchProfileData = async () => {
loading.value = true
error.value = ''
try {
// 实际项目中应调用真实API
// const response = await fetch(`${API_BASE}/profile`)
// const data = await response.json()
// profile.value = data
// 使用模拟数据作为备选
profile.value = {
id: '1',
name: '年糕崽崽',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao&backgroundColor=b6e3f4',
location: '中国 · 浙江杭州',
bio: '嗨!我是年糕崽崽,一名热衷于探索数字边界的前端架构师。我的职业生涯始于对像素级完美的执念。目前,我专注于高性能 B 端应用的体验升级。',
contact: {
email: 'hello@niangao.dev',
wechat: 'Niangao_Dev'
},
techStack: ['Vue 3', 'React', 'TypeScript', 'Three.js', 'Golang', 'Tailwind CSS', 'Vite', 'Gin']
}
} catch (err) {
console.error('Error fetching profile data:', err)
error.value = '获取个人资料失败,请稍后重试'
// 保持原有静态数据
} finally {
loading.value = false
}
}
onBeforeMount(() => {
fetchProfileData()
})
onMounted(() => {
// 启用滚动动画
useScrollAnimation()
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
})
</script>

82
client/src/pages/Blog.vue Normal file
View File

@@ -0,0 +1,82 @@
<template>
<section id="blog" class="page-section block animate-slide-down">
<div class="max-w-4xl mx-auto pt-32 px-6 pb-20">
<div class="mb-16 text-center">
<h2 class="font-serif text-5xl italic text-white mb-6">深度思考</h2>
<p class="text-art-muted max-w-lg mx-auto">关于前端技术交互设计以及数字艺术的深度思考</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-center text-red-500 py-20">
<p class="mb-4">{{ error }}</p>
<button @click="fetchBlogPosts" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
重试
</button>
</div>
<!-- Blog posts list -->
<div v-else class="space-y-12" id="blog-list-container">
<!-- Blog posts will be rendered here -->
<div
v-for="post in blogPosts"
:key="post.id"
class="art-card rounded-2xl p-8 group cursor-pointer transition-all"
@click="$router.push('/blog/' + post.id)"
>
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-4">
<div class="flex items-center gap-3">
<span class="px-2 py-1 text-[10px] font-mono border border-white/20 rounded-full text-white backdrop-blur-sm">{{ post.category }}</span>
<span class="text-xs font-mono text-art-muted">{{ post.date }}</span>
</div>
<i data-lucide="arrow-up-right" class="w-5 h-5 text-white/50 transform group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform"></i>
</div>
<h3 class="text-2xl md:text-3xl font-serif text-white mb-4 group-hover:text-art-accent transition-colors">{{ post.title }}</h3>
<p class="text-art-muted leading-relaxed">{{ post.excerpt }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeMount } from 'vue'
import { fetchPosts, Post } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
const blogPosts = ref<Post[]>([])
const loading = ref(false)
const error = ref('')
const fetchBlogPosts = async () => {
loading.value = true
error.value = ''
try {
const posts = await fetchPosts()
blogPosts.value = posts
} catch (err) {
console.error('Error fetching blog posts:', err)
error.value = '获取博客文章失败,请稍后重试'
} finally {
loading.value = false
}
}
onBeforeMount(() => {
fetchBlogPosts()
})
onMounted(() => {
// 启用滚动动画
useScrollAnimation()
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
})
</script>

View File

@@ -0,0 +1,115 @@
<template>
<section id="blog-detail" class="page-section block animate-slide-down">
<div class="max-w-4xl mx-auto pt-32 px-6 pb-20">
<!-- 返回按钮 -->
<button
@click="$router.push('/blog')"
class="group flex items-center gap-2 text-art-muted hover:text-white transition-colors mb-12"
>
<i data-lucide="arrow-left" class="w-4 h-4 transform group-hover:-translate-x-1 transition-transform"></i>
<span class="text-sm font-medium tracking-wide">返回文章列表</span>
</button>
<!-- Loading state -->
<div v-if="loading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-center text-red-500 py-20">
<p class="mb-4">{{ error }}</p>
<button @click="fetchBlogDetail" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
重试
</button>
<button @click="$router.push('/blog')" class="mt-4 px-4 py-2 border border-white/30 text-white rounded hover:bg-white/5 transition-colors">
返回文章列表
</button>
</div>
<!-- Blog detail content -->
<div v-else class="space-y-12">
<!-- 文章信息 -->
<div class="border-b border-white/5 pb-10">
<div class="flex items-center gap-4 mb-6">
<span class="px-3 py-1 border border-white/20 rounded-full text-xs font-mono text-art-accent uppercase tracking-wider">{{ post.category }}</span>
<span class="text-sm text-art-muted">{{ post.date }}</span>
</div>
<h1 class="font-serif text-4xl md:text-5xl lg:text-6xl text-white leading-tight mb-8">{{ post.title }}</h1>
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-full overflow-hidden border border-white/20">
<img
src="https://api.dicebear.com/7.x/avataaars/svg?seed=NianGao"
alt="Avatar"
class="w-full h-full object-cover"
>
</div>
<div>
<div class="text-sm font-medium text-white">年糕崽崽</div>
<div class="text-xs text-art-muted">前端架构师</div>
</div>
</div>
</div>
<!-- 文章内容 -->
<article class="blog-content prose prose-invert prose-lg max-w-none text-art-muted leading-relaxed">
<div v-html="post.content"></div>
</article>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchPost, Post } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
const route = useRoute()
const router = useRouter()
const postId = route.params.id as string
const post = ref<Post>({
id: postId,
title: '',
category: '',
date: '',
excerpt: '',
content: ''
})
const loading = ref(false)
const error = ref('')
const fetchBlogDetail = async () => {
loading.value = true
error.value = ''
try {
const postData = await fetchPost(postId)
if (postData) {
post.value = postData
} else {
error.value = '未找到该文章'
}
} catch (err) {
console.error('Error fetching blog detail:', err)
error.value = '获取文章详情失败,请稍后重试'
} finally {
loading.value = false
}
}
onBeforeMount(() => {
fetchBlogDetail()
})
onMounted(() => {
// 启用滚动动画
useScrollAnimation()
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
})
</script>

167
client/src/pages/Home.vue Normal file
View File

@@ -0,0 +1,167 @@
<template>
<section id="home" class="page-section block">
<!-- Hero -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12 mb-24 items-center animate-reveal">
<div class="lg:col-span-7 space-y-8">
<p class="text-art-accent font-mono text-sm tracking-widest uppercase mb-4">前端架构师 & 创意开发者</p>
<h1 class="font-serif text-5xl md:text-7xl lg:text-8xl leading-[1.1] text-white text-glow">
<span class="block italic opacity-80">设计</span>
<span class="block font-bold">铸造数字</span>
<span class="block font-bold pl-12 md:pl-24">灵魂</span>
</h1>
<p class="text-art-muted text-lg md:text-xl max-w-xl leading-relaxed mt-8 font-light">
在代码的逻辑与设计的感性之间寻找平衡我是<span class="text-white font-bold">年糕崽崽</span>不仅仅构建页面更在构建<span class="text-white border-b border-white/20 pb-0.5">沉浸式体验</span>
</p>
<div class="pt-8 flex items-center gap-6">
<button @click="$router.push('/works')" class="group flex items-center gap-2 text-white border-b border-white pb-1 hover:text-art-accent hover:border-art-accent transition-all">
<span>浏览作品集</span>
<i data-lucide="arrow-right" class="w-4 h-4 transform group-hover:translate-x-1 transition-transform"></i>
</button>
</div>
</div>
<div class="lg:col-span-5 relative h-[400px] lg:h-[600px] w-full flex items-center justify-center">
<div class="relative w-full h-full">
<div class="absolute inset-0 border border-white/10 rounded-full rotate-12 scale-90"></div>
<div class="absolute inset-0 border border-white/5 rounded-full -rotate-6 scale-75"></div>
<div
id="home-hero-work"
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-80 bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 rounded-sm shadow-2xl rotate-6 hover:rotate-0 transition-transform duration-700 z-10 flex flex-col p-6 justify-between cursor-pointer"
@click="$router.push('/works/' + latestWork.id)"
>
<div class="text-white/50 text-xs">最新作品</div>
<div class="font-serif text-3xl italic text-white">{{ latestWork.title }}</div>
<div class="flex justify-end"><i data-lucide="arrow-up-right" class="text-white"></i></div>
</div>
</div>
</div>
</div>
<!-- Bento Grid -->
<div class="space-y-6 animate-reveal" style="animation-delay: 0.2s;">
<div class="flex items-end justify-between border-b border-white/10 pb-4 mb-8">
<h2 class="font-serif text-3xl italic text-white">精选内容</h2>
<span class="font-mono text-xs text-art-muted">下滑探索更多</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 auto-rows-[300px]" id="bento-grid">
<!-- Bento items will be rendered here -->
<div
class="md:col-span-2 art-card rounded-2xl p-8 flex flex-col justify-end group cursor-pointer"
@click="$router.push('/blog/' + featuredPost.id)"
>
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent z-10"></div>
<div class="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=2564&auto=format&fit=crop')] bg-cover bg-center transition-transform duration-700 group-hover:scale-105 opacity-60 mix-blend-overlay"></div>
<div class="relative z-20 space-y-2">
<div class="flex items-center gap-3">
<span class="px-2 py-1 text-[10px] font-mono border border-white/20 rounded-full text-white backdrop-blur-sm">{{ featuredPost.category }}</span>
<span class="text-xs text-white/60">{{ featuredPost.date }}</span>
</div>
<h3 class="font-serif text-3xl text-white group-hover:text-art-accent transition-colors">当极简主义遇见复杂数据Dashboard 设计哲学</h3>
</div>
</div>
<div
class="art-card rounded-2xl p-8 flex flex-col justify-between group"
>
<div class="flex justify-between items-start">
<i data-lucide="code-2" class="w-8 h-8 text-white/40 group-hover:text-art-accent transition-colors"></i>
<i data-lucide="arrow-up-right" class="w-5 h-5 text-white/20"></i>
</div>
<div>
<div class="text-4xl font-mono font-bold text-white mb-2">120+</div>
<div class="text-sm text-art-muted">开源提交 (Commits)</div>
<div class="text-xs text-art-muted mt-2 opacity-60">Vue, React, Three.js</div>
</div>
</div>
<div
class="art-card rounded-2xl p-6 md:p-8 flex flex-col justify-between group cursor-pointer bg-[#050505]"
@click="$router.push('/snippets')"
>
<div class="font-mono text-xs text-art-muted mb-4">// React Hook: useArt</div>
<div class="font-mono text-sm text-gray-400 overflow-hidden opacity-60 group-hover:opacity-100 transition-opacity">
<span class="code-keyword">const</span> <span class="code-func">useCreative</span> = () => {<br>
&nbsp;&nbsp;<span class="code-keyword">return</span> <span class="code-string">"Innovation"</span>;<br>
}
</div>
<div class="mt-4 flex items-center gap-2 text-sm font-medium text-white">
<span>访问代码实验室</span>
<i data-lucide="chevron-right" class="w-4 h-4 text-art-accent"></i>
</div>
</div>
<div
class="md:col-span-2 art-card rounded-2xl p-8 md:p-12 flex flex-col md:flex-row items-center justify-between gap-8 group cursor-pointer bg-gradient-to-r from-art-surface to-transparent"
@click="$router.push('/about')"
>
<div class="space-y-4">
<h3 class="font-serif text-3xl text-white">需要独特的前端架构</h3>
<p class="text-art-muted max-w-sm">不论是 WebGL 3D 交互网站还是高性能的 SaaS 管理系统我都能提供专业的解决方案</p>
</div>
<div class="h-16 w-16 rounded-full border border-white/20 flex items-center justify-center group-hover:bg-white group-hover:text-black transition-all duration-300">
<i data-lucide="arrow-right" class="w-6 h-6"></i>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeMount } from 'vue'
import { fetchWorks, fetchPosts, Work, Post } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
const latestWork = ref<Work>({
id: 'nova',
title: 'Nova 交易平台',
category: '金融科技',
year: '2023',
heroImg: '',
desc: '',
techStack: [],
gallery: [],
links: { live: '#' },
next: ''
})
const featuredPost = ref<Post>({
id: 'refactor',
title: '当极简主义遇见复杂数据',
category: '设计思维',
date: '2025-10-24',
excerpt: '',
content: ''
})
const loading = ref(false)
const error = ref('')
const fetchHomeData = async () => {
loading.value = true
error.value = ''
try {
// 获取最新作品
const works = await fetchWorks()
if (works.length > 0) {
latestWork.value = works[0]
}
// 获取精选文章
const posts = await fetchPosts()
if (posts.length > 0) {
featuredPost.value = posts[0]
}
} catch (err) {
console.error('Error fetching home data:', err)
error.value = '获取首页数据失败'
} finally {
loading.value = false
}
}
onBeforeMount(() => {
fetchHomeData()
})
onMounted(() => {
// 启用滚动动画
useScrollAnimation()
})
</script>

View File

@@ -0,0 +1,96 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="text-center mb-8">
<h2 class="font-serif text-3xl italic text-white mb-2">后台管理</h2>
<p class="text-art-muted text-sm">请输入您的管理员账号</p>
</div>
<form @submit.prevent="handleLogin" class="space-y-6">
<div class="form-group">
<label for="username" class="block text-sm font-medium text-art-muted mb-2">用户名</label>
<input
type="text"
id="username"
v-model="form.username"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-art-accent focus:ring-1 focus:ring-art-accent transition-all"
placeholder="Username"
required
/>
</div>
<div class="form-group">
<label for="password" class="block text-sm font-medium text-art-muted mb-2">密码</label>
<input
type="password"
id="password"
v-model="form.password"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-art-accent focus:ring-1 focus:ring-art-accent transition-all"
placeholder="Password"
required
/>
</div>
<button
type="submit"
class="w-full bg-white text-black font-bold py-3 rounded-lg hover:bg-art-accent hover:text-white transition-all transform hover:scale-[1.02] active:scale-[0.98]"
>
</button>
<div v-if="error" class="text-red-500 text-sm text-center mt-4 bg-red-500/10 py-2 rounded border border-red-500/20">{{ error }}</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from '../composables/useToast'
import { login } from '../services/api'
const router = useRouter()
const toast = useToast()
const form = ref({
username: '',
password: ''
})
const error = ref('')
const handleLogin = async () => {
try {
const response = await login(form.value)
// 保存token到本地存储
localStorage.setItem('token', response.token)
localStorage.setItem('user', JSON.stringify(response.user))
toast.showToast('登录成功', 'success')
// 登录成功后重定向到管理后台
router.push('/admin')
} catch (err: any) {
error.value = err.response?.data?.error || '登录失败,请检查用户名和密码'
toast.showToast('登录失败', 'error')
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #050505;
background-image: radial-gradient(circle at 50% 50%, rgba(212, 179, 131, 0.05) 0%, transparent 50%);
}
.login-card {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
padding: 3rem;
border-radius: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
width: 100%;
max-width: 420px;
}
</style>

View File

@@ -0,0 +1,283 @@
<template>
<section id="services" class="page-section block animate-slide-down">
<div class="py-16 text-center">
<h2 class="font-serif text-5xl italic text-white mb-6">共创数字未来</h2>
<p class="text-art-muted text-lg max-w-2xl mx-auto">用代码构建骨架用设计触动灵魂</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mb-32 px-4" id="services-grid">
<div class="tilt-card group h-96">
<div class="tilt-inner flex flex-col justify-between">
<div class="w-16 h-16 rounded-2xl bg-blue-500/10 flex items-center justify-center text-blue-400 mb-6 border border-blue-500/20 group-hover:scale-110 transition-transform duration-500">
<i data-lucide="layers" class="w-8 h-8"></i>
</div>
<div>
<h3 class="text-2xl font-bold text-white mb-4">前端架构设计</h3>
<p class="text-art-muted text-sm leading-relaxed">为复杂的大型应用提供可扩展的架构方案</p>
</div>
</div>
</div>
<div class="tilt-card group h-96">
<div class="tilt-inner flex flex-col justify-between border-art-accent/20 bg-white/5">
<div class="w-16 h-16 rounded-2xl bg-art-accent/10 flex items-center justify-center text-art-accent mb-6 border border-art-accent/20 group-hover:scale-110 transition-transform duration-500 shadow-[0_0_30px_-10px_rgba(212,179,131,0.3)]">
<i data-lucide="wand-2" class="w-8 h-8"></i>
</div>
<div>
<h3 class="text-2xl font-bold text-white mb-4">创意交互开发</h3>
<p class="text-art-muted text-sm leading-relaxed">利用 WebGL (Three.js) GSAP 打造令人过目难忘的着陆页</p>
</div>
</div>
</div>
<div class="tilt-card group h-96">
<div class="tilt-inner flex flex-col justify-between">
<div class="w-16 h-16 rounded-2xl bg-purple-500/10 flex items-center justify-center text-purple-400 mb-6 border border-purple-500/20 group-hover:scale-110 transition-transform duration-500">
<i data-lucide="smartphone" class="w-8 h-8"></i>
</div>
<div>
<h3 class="text-2xl font-bold text-white mb-4">跨平台应用</h3>
<p class="text-art-muted text-sm leading-relaxed">使用 UniApp React Native 开发高质量的移动端应用</p>
</div>
</div>
</div>
</div>
<!-- Process Accordion -->
<div class="mb-32 px-4">
<h3 class="font-serif text-3xl text-white text-center mb-12 italic">创作流程</h3>
<div class="process-accordion">
<div class="process-step">
<img src="https://images.unsplash.com/photo-1531403009284-440f080d1e12?q=80&w=2670&auto=format&fit=crop" class="step-bg" alt="Discovery">
<div class="step-content">
<div class="step-number">01</div>
<h4 class="text-xl font-bold text-white mb-2">灵感 & 探索</h4>
<p class="step-desc">深入理解业务需求进行竞品分析寻找视觉灵感确定设计方向这是地基</p>
</div>
</div>
<div class="process-step">
<img src="https://images.unsplash.com/photo-1581291518633-83b4ebd1d83e?q=80&w=2670&auto=format&fit=crop" class="step-bg" alt="Design">
<div class="step-content">
<div class="step-number">02</div>
<h4 class="text-xl font-bold text-white mb-2">架构 & 设计</h4>
<p class="step-desc">设计高保真原型规划技术架构确定数据流向将抽象的想法具象化</p>
</div>
</div>
<div class="process-step">
<img src="https://images.unsplash.com/photo-1555099962-4199c345e5dd?q=80&w=2670&auto=format&fit=crop" class="step-bg" alt="Code">
<div class="step-content">
<div class="step-number">03</div>
<h4 class="text-xl font-bold text-white mb-2">编码 & 雕琢</h4>
<p class="step-desc">编写干净可维护的代码添加微交互和动画让页面"活"起来</p>
</div>
</div>
<div class="process-step">
<img src="https://images.unsplash.com/photo-1460925895917-afdab827c52f?q=80&w=2426&auto=format&fit=crop" class="step-bg" alt="Launch">
<div class="step-content">
<div class="step-number">04</div>
<h4 class="text-xl font-bold text-white mb-2">测试 & 交付</h4>
<p class="step-desc">多设备测试性能优化SEO 配置确保最终交付物完美无瑕</p>
</div>
</div>
</div>
</div>
<!-- Danmaku & Partners -->
<div class="py-20 mb-32 overflow-hidden relative">
<div class="absolute left-0 top-0 w-20 h-full bg-gradient-to-r from-art-bg to-transparent z-10"></div>
<div class="absolute right-0 top-0 w-20 h-full bg-gradient-to-l from-art-bg to-transparent z-10"></div>
<h3 class="text-3xl font-serif text-white text-center mb-12 italic">客户原声</h3>
<div v-if="loading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
</div>
<div v-else-if="error" class="text-center text-red-500 py-20">
<p class="mb-4">{{ error }}</p>
<button @click="fetchServicesData" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
重试
</button>
</div>
<div v-else class="flex flex-col gap-6" id="danmaku-container">
<!-- Danmaku items will be rendered here -->
<div class="danmaku-row animate-marquee hover:[animation-play-state:paused]">
<div v-for="(testimonial, index) in testimonials" :key="testimonial.id" class="danmaku-item">
<div class="w-6 h-6 rounded-full bg-blue-500/20"></div>
<span>"{{ testimonial.content }}"</span>
</div>
<!-- 复制一份数据以实现无缝滚动 -->
<div v-for="(testimonial, index) in testimonials" :key="testimonial.id + '-copy'" class="danmaku-item">
<div class="w-6 h-6 rounded-full bg-blue-500/20"></div>
<span>"{{ testimonial.content }}"</span>
</div>
</div>
<div class="danmaku-row animate-marquee-reverse hover:[animation-play-state:paused]">
<div v-for="(testimonial, index) in testimonials" :key="testimonial.id" class="danmaku-item">
<div class="w-6 h-6 rounded-full bg-purple-500/20"></div>
<span>"{{ testimonial.content }}"</span>
</div>
<!-- 复制一份数据以实现无缝滚动 -->
<div v-for="(testimonial, index) in testimonials" :key="testimonial.id + '-copy'" class="danmaku-item">
<div class="w-6 h-6 rounded-full bg-purple-500/20"></div>
<span>"{{ testimonial.content }}"</span>
</div>
</div>
</div>
</div>
<!-- CTA -->
<div class="max-w-4xl mx-auto text-center px-6 mb-20">
<div class="p-12 rounded-3xl bg-gradient-to-b from-white/10 to-transparent border border-white/10 relative overflow-hidden">
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-64 h-64 bg-art-accent/20 blur-[100px] -z-10"></div>
<h2 class="text-4xl md:text-5xl font-serif text-white mb-6">准备好开始了吗</h2>
<p class="text-art-muted mb-8 max-w-xl mx-auto">无论是一个疯狂的想法还是一个具体的业务需求我都乐意倾听</p>
<button @click="openInquiry" class="inline-flex items-center gap-2 px-8 py-4 bg-white text-black rounded-full font-bold hover:scale-105 transition-transform"><span>发起合作咨询</span><i data-lucide="arrow-right" class="w-5 h-5"></i></button>
</div>
</div>
<!-- Partners -->
<div class="max-w-4xl mx-auto px-6 mb-32 border-t border-white/5 pt-12">
<p class="text-center text-xs font-mono text-art-muted tracking-widest uppercase mb-8">Trusted By</p>
<div v-if="loading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
</div>
<div v-else-if="error" class="text-center text-red-500 py-20">
<p class="mb-4">{{ error }}</p>
<button @click="fetchServicesData" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
重试
</button>
</div>
<div v-else class="flex flex-wrap justify-center gap-8 md:gap-16 opacity-50">
<span
v-for="partner in partners"
:key="partner.id"
class="partner-logo text-xl font-bold text-white cursor-pointer"
:class="getPartnerFontClass(partner.name)"
>
{{ partner.name }}
</span>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeMount } from 'vue'
import { useScrollAnimation } from '../composables/useScrollAnimation'
// 定义类型
interface Testimonial {
id: string
content: string
avatar: string
}
interface Partner {
id: string
name: string
logo?: string
}
const testimonials = ref<Testimonial[]>([])
const partners = ref<Partner[]>([])
const loading = ref(false)
const error = ref('')
// 模拟API调用实际项目中应替换为真实API
const fetchServicesData = async () => {
loading.value = true
error.value = ''
try {
// 实际项目中应调用真实API
// const testimonialsRes = await fetch(`${API_BASE}/testimonials`)
// const partnersRes = await fetch(`${API_BASE}/partners`)
// testimonials.value = await testimonialsRes.json()
// partners.value = await partnersRes.json()
// 使用模拟数据作为备选
testimonials.value = [
{ id: '1', content: '从未见过如此丝滑的 WebGL 体验!', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test1' },
{ id: '2', content: '代码质量非常高,易于维护。', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test2' },
{ id: '3', content: '细节把控令人惊叹,强烈推荐!', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test3' },
{ id: '4', content: '交付速度快,超出预期!', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test4' }
]
partners.value = [
{ id: '1', name: 'VOGUE' },
{ id: '2', name: 'WIRED' },
{ id: '3', name: 'stripe' },
{ id: '4', name: 'Monocle' }
]
} catch (err) {
console.error('Error fetching services data:', err)
error.value = '获取服务数据失败,请稍后重试'
// 使用静态数据作为备选
testimonials.value = [
{ id: '1', content: '从未见过如此丝滑的 WebGL 体验!', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test1' },
{ id: '2', content: '代码质量非常高,易于维护。', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test2' },
{ id: '3', content: '细节把控令人惊叹,强烈推荐!', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test3' },
{ id: '4', content: '交付速度快,超出预期!', avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=test4' }
]
partners.value = [
{ id: '1', name: 'VOGUE' },
{ id: '2', name: 'WIRED' },
{ id: '3', name: 'stripe' },
{ id: '4', name: 'Monocle' }
]
} finally {
loading.value = false
}
}
// 获取合作伙伴字体类
const getPartnerFontClass = (name: string): string => {
if (name === 'VOGUE' || name === 'Monocle') {
return 'font-serif italic'
} else if (name === 'WIRED') {
return 'font-mono'
} else if (name === 'stripe') {
return 'font-sans'
}
return 'font-bold'
}
const openInquiry = () => {
// 使用全局函数打开咨询模态框
if (window.openInquiry) {
window.openInquiry()
}
}
onBeforeMount(() => {
fetchServicesData()
})
onMounted(() => {
// 启用滚动动画
useScrollAnimation()
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
// 添加鼠标倾斜效果
const tiltCards = document.querySelectorAll('.tilt-card')
tiltCards.forEach(card => {
card.addEventListener('mousemove', (e) => {
const cardRect = card.getBoundingClientRect()
const x = e.clientX - cardRect.left
const y = e.clientY - cardRect.top
const xPercent = (x / cardRect.width) * 100
const yPercent = (y / cardRect.height) * 100
card.style.setProperty('--mouse-x', `${xPercent}%`)
card.style.setProperty('--mouse-y', `${yPercent}%`)
// 3D Rotation
const rotateX = (y / cardRect.height - 0.5) * 20
const rotateY = (x / cardRect.width - 0.5) * -20
card.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`
})
card.addEventListener('mouseleave', () => {
card.style.transform = 'perspective(1000px) rotateX(0) rotateY(0)'
})
})
})
</script>

View File

@@ -0,0 +1,152 @@
<template>
<section id="snippets" class="page-section block animate-slide-down">
<div class="max-w-7xl mx-auto pt-32 px-6 pb-20">
<div class="mb-12 text-center">
<h2 class="font-serif text-5xl italic text-white mb-6">代码实验室</h2>
<p class="text-art-muted">点击下方卡片查看代码与实时效果</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-center text-red-500 py-20">
<p class="mb-4">{{ error }}</p>
<button @click="fetchSnippetsData" class="px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
重试
</button>
</div>
<!-- Snippets list -->
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-8" id="snippets-grid">
<!-- Snippets will be rendered here -->
<div
v-for="snippet in snippets"
:key="snippet.id"
class="art-card rounded-2xl p-8 group cursor-pointer transition-all hover:translate-y-[-5px]"
@click="openSnippet(snippet)"
>
<div class="flex items-center gap-3 mb-4">
<i data-lucide="code-2" class="w-6 h-6 text-art-accent"></i>
<span class="font-mono text-sm text-art-muted">{{ getSnippetType(snippet.type) }}</span>
</div>
<h3 class="text-2xl font-serif text-white mb-4 group-hover:text-art-accent transition-colors">{{ snippet.title }}</h3>
<pre class="font-mono text-sm text-art-muted leading-relaxed overflow-hidden text-ellipsis line-clamp-4">{{ snippet.code }}</pre>
<div class="flex items-center justify-end mt-6">
<i data-lucide="arrow-up-right" class="w-5 h-5 text-white/50 transform group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform"></i>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeMount } from 'vue'
import { fetchSnippets, Snippet } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
const snippets = ref<Snippet[]>([])
const loading = ref(false)
const error = ref('')
const fetchSnippetsData = async () => {
loading.value = true
error.value = ''
try {
const snippetsData = await fetchSnippets()
snippets.value = snippetsData
} catch (err) {
console.error('Error fetching snippets:', err)
error.value = '获取代码片段失败,请稍后重试'
// 如果API调用失败使用静态数据作为备选
snippets.value = [
{
id: 'mouse',
title: 'React 鼠标追踪 Hook',
code: `import { useState, useEffect } from 'react';
export const useMousePosition = () => {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const update = (e) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', update);
return () => window.removeEventListener('mousemove', update);
}, []);
return pos;
};`,
type: 'mouse'
},
{
id: 'glass',
title: 'CSS 极致毛玻璃效果',
code: `.glass-panel {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(16px);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
}`,
type: 'glass'
},
{
id: 'noise',
title: 'SVG 噪点纹理滤镜',
code: `<filter id="noise">
<feTurbulence type="fractalNoise" baseFrequency="0.8" /
</filter>`,
type: 'noise'
},
{
id: 'animate',
title: 'CSS 流畅动画',
code: `.animate-float {
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-20px); }
}`,
type: 'animate'
}
]
} finally {
loading.value = false
}
}
const getSnippetType = (type: string) => {
const typeMap: Record<string, string> = {
javascript: 'JavaScript',
css: 'CSS',
html: 'HTML',
mouse: 'React Hook',
glass: 'CSS',
noise: 'SVG',
animate: 'CSS Animation'
}
return typeMap[type] || type
}
const openSnippet = (snippet: any) => {
// 使用全局函数打开代码片段模态框
if (window.showSnippet) {
window.showSnippet(snippet.title, snippet.code)
}
}
onBeforeMount(() => {
fetchSnippetsData()
})
onMounted(() => {
// 启用滚动动画
useScrollAnimation()
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
})
</script>

View File

@@ -0,0 +1,173 @@
<template>
<section id="work-detail" class="work-detail-page page-section block">
<button @click="$router.push('/works')" class="fixed top-8 right-8 z-[60] mix-blend-difference text-white hover:scale-110 transition-transform">
<div class="rounded-full border border-white/20 p-4 backdrop-blur-md bg-white/5"><i data-lucide="x" class="w-6 h-6"></i></div>
</button>
<div class="fixed top-0 left-0 h-1 bg-art-accent z-[60] w-0 transition-all duration-100" id="scroll-progress"></div>
<!-- Loading state -->
<div v-if="loading" class="fixed inset-0 bg-black flex items-center justify-center z-[99]">
<div class="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-art-accent"></div>
</div>
<!-- Error state -->
<div v-else-if="error" class="fixed inset-0 bg-black flex flex-col items-center justify-center z-[99] p-8">
<p class="text-red-500 text-xl mb-4">{{ error }}</p>
<button @click="fetchWorkDetails" class="px-6 py-3 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
重试
</button>
<button @click="$router.push('/works')" class="mt-4 px-6 py-3 border border-white/30 text-white rounded hover:bg-white/5 transition-colors">
返回作品列表
</button>
</div>
<!-- Work content -->
<div v-else class="relative w-full" id="work-detail-content">
<div class="relative h-[85vh] w-full overflow-hidden group">
<img
id="work-hero-img"
:src="work.heroImg"
class="absolute inset-0 w-full h-full object-cover filter grayscale group-hover:grayscale-0 transition-all duration-1000 scale-105"
:alt="work.title"
>
<div class="absolute inset-0 bg-black/40"></div>
<div class="absolute bottom-0 left-0 p-8 md:p-20 w-full">
<div class="flex items-end justify-between border-t border-white/30 pt-8 animate-slide-down">
<div>
<span id="work-category" class="font-mono text-art-accent text-sm tracking-[0.2em] uppercase mb-2 block">{{ work.category }}</span>
<h1 id="work-title" class="font-serif text-5xl md:text-8xl text-white leading-[0.9] mix-blend-overlay">{{ work.title }}</h1>
</div>
<span id="work-year" class="hidden md:block font-mono text-white/50 text-xl">{{ work.year }}</span>
</div>
</div>
</div>
<div class="flex flex-col md:flex-row max-w-[1920px] mx-auto min-h-screen">
<div class="md:w-1/3 p-8 md:p-16 md:sticky md:top-0 md:h-screen md:max-h-screen overflow-y-auto custom-scrollbar flex flex-col justify-between border-r border-white/5 bg-[#050505]">
<div class="space-y-12">
<div>
<h3 class="font-mono text-xs text-art-muted uppercase tracking-widest mb-4">关于项目</h3>
<div id="work-desc" class="text-white/80 font-light leading-relaxed text-lg font-serif" v-html="work.desc"></div>
</div>
<div>
<h3 class="font-mono text-xs text-art-muted uppercase tracking-widest mb-6">技术全景</h3>
<div id="work-tech-stack" class="space-y-6">
<div v-for="(tech, index) in work.techStack" :key="index">
<div class="tech-category-title">{{ tech.category }}</div>
<div class="tech-grid">
<span v-for="(item, itemIndex) in tech.items" :key="itemIndex" class="tech-item">{{ item }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="mt-12">
<a
id="work-link-live"
:href="work.links.live"
target="_blank"
class="group flex items-center justify-between w-full py-6 border-t border-white/10 hover:bg-white/5 transition-colors"
>
<span class="font-serif text-2xl italic text-white group-hover:text-art-accent transition-colors">访问线上项目</span>
<i data-lucide="arrow-up-right" class="w-6 h-6 text-white group-hover:rotate-45 transition-transform"></i>
</a>
</div>
</div>
<div class="md:w-2/3 bg-[#080808] flex flex-col">
<div id="work-gallery" class="flex flex-col flex-grow">
<img
v-for="(img, index) in work.gallery"
:key="index"
:src="img"
:alt="`${work.title} - 图片 ${index + 1}`"
class="gallery-image"
>
</div>
<div
class="h-[40vh] flex items-center justify-center border-t border-white/5 bg-[#050505] cursor-pointer group hover:bg-white/5 transition-colors"
@click="$router.push('/works')"
>
<div class="text-center">
<p class="font-mono text-xs text-art-muted mb-4 tracking-widest">下一个作品</p>
<h2 class="font-serif text-5xl text-white group-hover:italic transition-all">返回作品列表</h2>
</div>
</div>
<footer class="py-8 text-center border-t border-white/5 relative z-10 bg-[#050505]"><p class="text-art-muted text-xs font-mono tracking-widest opacity-50">&copy; 2024 年糕崽崽. 保留所有权利.</p></footer>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchWork, Work } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
const route = useRoute()
const router = useRouter()
const workId = route.params.id as string
const work = ref<Work>({
id: workId,
title: '加载中...',
category: '',
year: '',
heroImg: 'https://via.placeholder.com/1600x900',
desc: '<p>加载中...</p>',
techStack: [],
gallery: [],
links: { live: '#' },
next: ''
})
const loading = ref(true)
const error = ref('')
const updateScrollProgress = () => {
const scrollTop = window.scrollY
const docHeight = document.documentElement.scrollHeight
const winHeight = window.innerHeight
const scrollPercent = scrollTop / (docHeight - winHeight)
const progressBar = document.getElementById('scroll-progress')
if (progressBar) {
progressBar.style.width = `${scrollPercent * 100}%`
}
}
const fetchWorkDetails = async () => {
loading.value = true
error.value = ''
try {
const workData = await fetchWork(workId)
if (workData) {
work.value = workData
} else {
error.value = '未找到该作品'
}
} catch (err) {
error.value = '获取作品详情失败,请稍后重试'
console.error('Error fetching work details:', err)
} finally {
loading.value = false
}
}
onMounted(() => {
// 启用滚动动画
useScrollAnimation()
fetchWorkDetails()
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
// 添加滚动事件监听
window.addEventListener('scroll', updateScrollProgress)
})
onBeforeUnmount(() => {
// 移除滚动事件监听
window.removeEventListener('scroll', updateScrollProgress)
})
</script>

116
client/src/pages/Works.vue Normal file
View File

@@ -0,0 +1,116 @@
<template>
<section id="works" class="page-section block animate-slide-down">
<div class="max-w-7xl mx-auto pt-32 px-6 pb-20">
<div class="mb-32">
<h2 class="font-serif text-5xl italic text-white mb-6">精选作品</h2>
<p class="text-art-muted">这里展示了我参与设计和开发的核心项目</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-center text-red-500">
<p>{{ error }}</p>
<button @click="fetchWorksData" class="mt-4 px-4 py-2 bg-art-accent text-white rounded hover:bg-opacity-80 transition-colors">
重试
</button>
</div>
<!-- Works list -->
<div v-else class="grid grid-cols-1 gap-20" id="works-list-container">
<div
v-for="(work, index) in works"
:key="work.id"
class="group grid grid-cols-1 md:grid-cols-2 gap-10 items-center cursor-pointer"
@click="$router.push('/works/' + work.id)"
>
<!-- 作品图片 -->
<div
:class="['md:order-1', index % 2 === 1 ? 'md:order-2' : 'md:order-1']"
class="relative aspect-[4/3] overflow-hidden rounded-sm bg-gray-900 border border-white/5"
>
<div
class="absolute inset-0 z-10"
:class="[
index % 2 === 0 ? 'bg-gradient-to-tr from-purple-900/40 to-blue-900/40' : 'bg-gradient-to-tr from-orange-900/40 to-red-900/40',
'mix-blend-color-burn'
]"
></div>
<div class="absolute inset-0 bg-black/30 group-hover:bg-transparent transition-colors z-20"></div>
<img
:src="work.heroImg"
:alt="work.title"
class="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700 opacity-80"
>
</div>
<!-- 作品信息 -->
<div
:class="['md:order-2', index % 2 === 1 ? 'md:order-1' : 'md:order-2']"
class="space-y-6"
>
<!-- 年份和分类 -->
<div class="font-mono text-xs text-art-accent">{{ work.year }} · {{ work.category }}</div>
<!-- 作品标题 -->
<h3
class="text-4xl font-serif text-white group-hover:text-art-accent transition-colors"
>
{{ work.title }}
</h3>
<!-- 作品描述 -->
<p class="text-art-muted font-light leading-relaxed" v-html="work.desc"></p>
<!-- 技术栈 -->
<div class="flex flex-wrap gap-3">
<span
v-for="(stack, stackIndex) in work.techStack.flatMap(tech => tech.items)"
:key="stackIndex"
class="px-3 py-1 border border-white/10 rounded-full text-xs text-white/70"
>
{{ stack }}
</span>
</div>
<!-- 查看详情按钮 -->
<div class="pt-4">
<span class="inline-flex items-center gap-2 text-sm font-medium text-white border-b border-transparent hover:border-art-accent hover:text-art-accent transition-all">
查看项目详情 <i data-lucide="arrow-right" class="w-4 h-4"></i>
</span>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { fetchWorks, Work } from '../services/api'
import { useScrollAnimation } from '../composables/useScrollAnimation'
const works = ref<Work[]>([])
const loading = ref(false)
const error = ref('')
const fetchWorksData = async () => {
loading.value = true
error.value = ''
try {
works.value = await fetchWorks()
} catch (err) {
error.value = '获取作品失败,请稍后重试'
console.error('Error fetching works:', err)
} finally {
loading.value = false
}
}
onMounted(() => {
// 启用滚动动画
useScrollAnimation()
fetchWorksData()
// 初始化Lucide图标
if (window.lucide) {
window.lucide.createIcons()
}
})
</script>

View File

@@ -0,0 +1,362 @@
<template>
<div class="dashboard-container">
<h1 class="page-title">仪表盘</h1>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon users">👥</div>
<div class="stat-content">
<h3 class="stat-title">用户总数</h3>
<p class="stat-value">{{ stats.users }}</p>
<span class="stat-change positive">+2.5%</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon posts">📝</div>
<div class="stat-content">
<h3 class="stat-title">文章总数</h3>
<p class="stat-value">{{ stats.posts }}</p>
<span class="stat-change positive">+5.2%</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon works">🎨</div>
<div class="stat-content">
<h3 class="stat-title">作品总数</h3>
<p class="stat-value">{{ stats.works }}</p>
<span class="stat-change positive">+3.1%</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon snippets">💻</div>
<div class="stat-content">
<h3 class="stat-title">代码片段</h3>
<p class="stat-value">{{ stats.snippets }}</p>
<span class="stat-change negative">-1.2%</span>
</div>
</div>
</div>
<!-- Recent Activities -->
<div class="dashboard-grid">
<div class="panel">
<h2 class="panel-title">最近操作</h2>
<div class="activity-list">
<div class="activity-item" v-for="activity in recentActivities" :key="activity.id">
<div class="activity-icon">
{{ activity.icon }}
</div>
<div class="activity-content">
<p class="activity-text">{{ activity.text }}</p>
<span class="activity-time">{{ activity.time }}</span>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="panel">
<h2 class="panel-title">快速操作</h2>
<div class="quick-actions">
<button class="action-btn" @click="router.push('/admin/posts/create')">
<span class="action-icon">📝</span>
<span class="action-text">新建文章</span>
</button>
<button class="action-btn" @click="router.push('/admin/works/create')">
<span class="action-icon">🎨</span>
<span class="action-text">新建作品</span>
</button>
<button class="action-btn" @click="router.push('/admin/snippets/create')">
<span class="action-icon">💻</span>
<span class="action-text">新建代码片段</span>
</button>
<button class="action-btn" @click="router.push('/admin/users/create')">
<span class="action-icon">👥</span>
<span class="action-text">新建用户</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getDashboardStats, getRecentActivities } from '../../services/api'
const router = useRouter()
// Stats data
const stats = ref({
users: 0,
posts: 0,
works: 0,
snippets: 0
})
// Recent activities data
const recentActivities = ref([])
// Fetch dashboard data
const fetchDashboardData = async () => {
try {
// Get stats
const statsData = await getDashboardStats()
stats.value = statsData
// Get recent activities
const activitiesData = await getRecentActivities()
recentActivities.value = activitiesData
} catch (error) {
console.error('Failed to fetch dashboard data:', error)
}
}
onMounted(() => {
fetchDashboardData()
})
</script>
<style scoped>
.dashboard-container {
width: 100%;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin-bottom: 1.5rem;
font-family: 'Inter', sans-serif;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: 1.5rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(212, 179, 131, 0.3);
}
.stat-icon {
font-size: 2.5rem;
margin-right: 1rem;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
background-color: rgba(212, 179, 131, 0.1);
color: #d4b383;
}
.stat-icon.users {
background-color: rgba(59, 130, 246, 0.1);
color: rgba(59, 130, 246, 0.8);
}
.stat-icon.posts {
background-color: rgba(16, 185, 129, 0.1);
color: rgba(16, 185, 129, 0.8);
}
.stat-icon.works {
background-color: rgba(251, 191, 36, 0.1);
color: rgba(251, 191, 36, 0.8);
}
.stat-icon.snippets {
background-color: rgba(139, 92, 246, 0.1);
color: rgba(139, 92, 246, 0.8);
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 0.875rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
margin: 0 0 0.25rem 0;
font-family: 'Inter', sans-serif;
}
.stat-value {
font-size: 1.75rem;
font-weight: 600;
color: white;
margin: 0 0 0.25rem 0;
font-family: 'Inter', sans-serif;
}
.stat-change {
font-size: 0.75rem;
font-weight: 500;
}
.stat-change.positive {
color: #10b981;
}
.stat-change.negative {
color: #ef4444;
}
/* Dashboard Grid */
.dashboard-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
}
/* Panel Styles */
.panel {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.panel-title {
font-size: 1.25rem;
font-weight: 600;
color: #d4b383;
margin: 0 0 1rem 0;
font-family: 'Playfair Display', serif;
}
/* Activity List */
.activity-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.activity-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.activity-icon {
font-size: 1.25rem;
margin-top: 0.25rem;
width: auto;
height: auto;
background: transparent;
color: #d4b383;
}
.activity-content {
flex: 1;
}
.activity-text {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.8);
margin: 0 0 0.25rem 0;
font-family: 'Inter', sans-serif;
}
.activity-time {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.4);
}
/* Quick Actions */
.quick-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.action-btn {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
text-align: left;
color: rgba(255, 255, 255, 0.8);
font-family: 'Inter', sans-serif;
}
.action-btn:hover {
background: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
transform: translateX(4px);
box-shadow: 0 5px 15px rgba(212, 179, 131, 0.1);
}
.action-icon {
font-size: 1.25rem;
background: transparent;
color: #d4b383;
}
.action-text {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
/* Responsive Design */
@media (max-width: 1024px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,378 @@
<template>
<div class="admin-logs">
<h1 class="page-title">操作日志管理</h1>
<div class="toolbar">
<div class="filter-section">
<div class="filter-group">
<label for="pageSize">每页显示:</label>
<CustomSelect
v-model.number="pageSize"
:options="pageSizeOptions"
@update:modelValue="fetchLogs"
style="width: 80px;"
/>
</div>
</div>
</div>
<div class="table-container">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>操作人</th>
<th>IP地址</th>
<th>路径</th>
<th>方法</th>
<th>状态</th>
<th>耗时(ms)</th>
<th>操作时间</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs.list" :key="log.id">
<td>{{ log.id }}</td>
<td>{{ log.username }}</td>
<td>{{ log.ip }}</td>
<td class="log-path">{{ log.path }}</td>
<td>
<span :class="['method-badge', `method-${log.method.toLowerCase()}`]">
{{ log.method }}
</span>
</td>
<td>
<span :class="['status-badge', getStatusClass(log.status)]">
{{ log.status }}
</span>
</td>
<td>{{ log.duration }}</td>
<td>{{ formatDate(log.createdAt) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="logs.list.length === 0" class="empty-state">
<p>暂无操作日志</p>
</div>
<!-- Pagination -->
<div v-if="logs.list.length > 0" class="pagination">
<button
class="btn btn-sm btn-secondary"
:disabled="currentPage === 1"
@click="changePage(currentPage - 1)"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} {{ totalPages }} 总计 {{ logs.total }} 条记录
</span>
<button
class="btn btn-sm btn-secondary"
:disabled="currentPage === totalPages"
@click="changePage(currentPage + 1)"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { getOperationLogs, PaginationResponse, OperationLog } from '../../services/api'
import { useToast } from '../../composables/useToast'
import CustomSelect from '../../components/CustomSelect.vue'
const toast = useToast()
const logs = ref<PaginationResponse<OperationLog>>({
list: [],
total: 0,
page: 1,
size: 10
})
const currentPage = ref(1)
const pageSize = ref(10)
// Select options
const pageSizeOptions = [
{ value: 10, label: '10' },
{ value: 20, label: '20' },
{ value: 50, label: '50' },
{ value: 100, label: '100' }
]
const totalPages = computed(() => {
return Math.ceil(logs.value.total / pageSize.value)
})
const fetchLogs = async () => {
try {
logs.value = await getOperationLogs(currentPage.value, pageSize.value)
} catch (error) {
console.error('Error fetching operation logs:', error)
toast.error('获取操作日志失败')
}
}
const changePage = (page: number) => {
currentPage.value = page
fetchLogs()
}
const getStatusClass = (status: number) => {
if (status >= 200 && status < 300) {
return 'success'
} else if (status >= 400 && status < 500) {
return 'warning'
} else if (status >= 500) {
return 'error'
}
return ''
}
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
onMounted(() => {
fetchLogs()
})
</script>
<style scoped>
.admin-logs {
max-width: 1200px;
margin: 0 auto;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #d4b383;
font-family: 'Inter', sans-serif;
}
.toolbar {
margin-bottom: 1rem;
display: flex;
justify-content: flex-end;
}
.filter-section {
display: flex;
gap: 1rem;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-group label {
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
font-size: 0.875rem;
font-family: 'Inter', sans-serif;
}
.form-select {
padding: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.375rem;
font-size: 0.875rem;
background: rgba(255, 255, 255, 0.05);
color: white;
font-family: 'Inter', sans-serif;
}
.table-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow-x: auto;
margin-bottom: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-table {
width: 100%;
border-collapse: collapse;
min-width: 800px;
color: white;
}
.admin-table th,
.admin-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.875rem;
font-family: 'Inter', sans-serif;
}
.admin-table th {
background: rgba(255, 255, 255, 0.08);
font-weight: 600;
color: #d4b383;
white-space: nowrap;
}
.admin-table tr:hover {
background: rgba(212, 179, 131, 0.05);
}
.log-path {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.method-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
color: white;
min-width: 40px;
text-align: center;
font-family: 'Inter', sans-serif;
}
.method-get {
background-color: #3b82f6;
}
.method-post {
background-color: #10b981;
}
.method-put {
background-color: #f59e0b;
}
.method-delete {
background-color: #ef4444;
}
.method-patch {
background-color: #8b5cf6;
}
.method-options {
background-color: #6b7280;
}
.status-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
min-width: 40px;
text-align: center;
font-family: 'Inter', sans-serif;
}
.status-badge.success {
background-color: rgba(16, 185, 129, 0.2);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.status-badge.warning {
background-color: rgba(251, 191, 36, 0.2);
color: #f59e0b;
border: 1px solid rgba(251, 191, 36, 0.3);
}
.status-badge.error {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid transparent;
font-family: 'Inter', sans-serif;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.btn-primary {
background-color: #d4b383;
color: #050505;
border-color: #d4b383;
}
.btn-primary:hover {
background-color: transparent;
color: #d4b383;
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.btn-secondary {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.2);
}
.btn-secondary:hover:not(:disabled) {
background-color: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.btn-secondary:disabled {
background-color: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.4);
border-color: rgba(255, 255, 255, 0.1);
cursor: not-allowed;
}
.empty-state {
text-align: center;
padding: 2rem;
color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.05);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
font-family: 'Inter', sans-serif;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1rem;
}
.page-info {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.6);
font-family: 'Inter', sans-serif;
}
</style>

View File

@@ -0,0 +1,368 @@
<template>
<div class="post-form-container">
<h1 class="page-title">{{ isEditing ? '编辑文章' : '新建文章' }}</h1>
<div class="form-container">
<form @submit.prevent="handleSubmit" class="post-form">
<!-- Title Field -->
<div class="form-group">
<label for="title">文章标题</label>
<input
type="text"
id="title"
v-model="form.title"
placeholder="请输入文章标题"
required
/>
<div class="error-message" v-if="errors.title">
{{ errors.title }}
</div>
</div>
<!-- Category Field -->
<div class="form-group">
<label for="category">分类</label>
<input
type="text"
id="category"
v-model="form.category"
placeholder="请输入文章分类"
required
/>
<div class="error-message" v-if="errors.category">
{{ errors.category }}
</div>
</div>
<!-- Date Field -->
<div class="form-group">
<label for="date">发布日期</label>
<input
type="date"
id="date"
v-model="form.date"
required
/>
<div class="error-message" v-if="errors.date">
{{ errors.date }}
</div>
</div>
<!-- Excerpt Field -->
<div class="form-group">
<label for="excerpt">文章摘要</label>
<textarea
id="excerpt"
v-model="form.excerpt"
placeholder="请输入文章摘要"
rows="3"
></textarea>
<div class="error-message" v-if="errors.excerpt">
{{ errors.excerpt }}
</div>
</div>
<!-- Content Field -->
<div class="form-group">
<label for="content">文章内容</label>
<textarea
id="content"
v-model="form.content"
placeholder="请输入文章内容"
rows="10"
required
></textarea>
<div class="error-message" v-if="errors.content">
{{ errors.content }}
</div>
</div>
<!-- Is Published Field -->
<div class="form-group">
<label for="isPublished">发布状态</label>
<CustomSelect
v-model="form.isPublished"
:options="publishStatusOptions"
placeholder="请选择发布状态"
/>
<div class="error-message" v-if="errors.isPublished">
{{ errors.isPublished }}
</div>
</div>
<!-- Submit Buttons -->
<div class="form-actions">
<button type="button" class="cancel-btn" @click="handleCancel">
取消
</button>
<button type="submit" class="submit-btn" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : (isEditing ? '更新文章' : '创建文章') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createPost, updatePost, fetchPost } from '../../services/api'
import CustomSelect from '../../components/CustomSelect.vue'
const router = useRouter()
const route = useRoute()
const toast = useToast()
// Form state
const isSubmitting = ref(false)
const isEditing = computed(() => !!route.params.id)
const errors = reactive<Record<string, string>>({})
// Form data
const form = reactive({
title: '',
category: '',
date: new Date().toISOString().split('T')[0],
excerpt: '',
content: '',
isPublished: 1
})
// Select options
const publishStatusOptions = [
{ value: 1, label: '已发布' },
{ value: 0, label: '草稿' }
]
// Validation function
const validateForm = (): boolean => {
// Reset errors
Object.keys(errors).forEach(key => delete errors[key])
let isValid = true
// Validate title
if (!form.title.trim()) {
errors.title = '文章标题不能为空'
isValid = false
}
// Validate category
if (!form.category.trim()) {
errors.category = '文章分类不能为空'
isValid = false
}
// Validate date
if (!form.date) {
errors.date = '发布日期不能为空'
isValid = false
}
// Validate content
if (!form.content.trim()) {
errors.content = '文章内容不能为空'
isValid = false
}
return isValid
}
// Submit handler
const handleSubmit = async () => {
if (!validateForm()) {
return
}
isSubmitting.value = true
try {
if (isEditing.value) {
// Update existing post
await updatePost(route.params.id as string, form)
toast.success('文章更新成功')
} else {
// Create new post
await createPost(form)
toast.success('文章创建成功')
}
// Redirect to posts list
router.push('/admin/posts')
} catch (error: any) {
console.error('Error submitting form:', error)
toast.error(error.message || (isEditing.value ? '更新文章失败' : '创建文章失败'))
} finally {
isSubmitting.value = false
}
}
// Cancel handler
const handleCancel = () => {
router.push('/admin/posts')
}
// Lifecycle
onMounted(async () => {
// If editing, load post data from API
if (isEditing.value) {
try {
const postId = route.params.id as string
const post = await fetchPost(postId)
// Populate form with post data
form.title = post.title
form.category = post.category
form.date = post.date
form.excerpt = post.excerpt || ''
form.content = post.content || ''
form.isPublished = post.isPublished === 1 ? 1 : 0
} catch (error: any) {
console.error('Failed to fetch post data:', error)
toast.error('加载文章数据失败: ' + (error.message || '未知错误'))
}
}
})
</script>
<style scoped>
.post-form-container {
width: 100%;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin-bottom: 1.5rem;
font-family: 'Inter', sans-serif;
}
.form-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 2rem;
}
.post-form {
max-width: 800px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.95rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 0.5rem;
font-family: 'Inter', sans-serif;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
font-size: 0.95rem;
background: rgba(255, 255, 255, 0.05);
color: white;
font-family: 'Inter', sans-serif;
resize: vertical;
}
.form-group input::placeholder,
.form-group textarea::placeholder,
.form-group select::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
font-family: 'Inter', sans-serif;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.cancel-btn {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.cancel-btn:hover {
background: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.submit-btn {
padding: 0.75rem 1.5rem;
background-color: #d4b383;
color: #050505;
border: 1px solid #d4b383;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.submit-btn:hover:not(:disabled) {
background-color: transparent;
color: #d4b383;
transform: translateY(-1px);
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive Design */
@media (max-width: 768px) {
.form-container {
padding: 1.5rem;
}
.form-actions {
flex-direction: column;
}
.cancel-btn,
.submit-btn {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<div class="admin-posts">
<h1 class="page-title">文章管理</h1>
<div class="toolbar">
<router-link to="/admin/posts/create" class="btn btn-primary">
+ 新增文章
</router-link>
</div>
<div class="table-container">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>分类</th>
<th>发布日期</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.id">
<td>{{ post.id }}</td>
<td>{{ post.title }}</td>
<td>{{ post.category }}</td>
<td>{{ post.date }}</td>
<td>
<span :class="['status-badge', post.isPublished === 1 ? 'published' : 'draft']">
{{ post.isPublished === 1 ? '已发布' : '草稿' }}
</span>
</td>
<td class="actions">
<router-link :to="`/admin/posts/${post.id}/edit`" class="btn btn-sm btn-secondary">
编辑
</router-link>
<button @click="deletePost(post.id)" class="btn btn-sm btn-danger">
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="posts.length === 0" class="empty-state">
<p>暂无文章请点击上方按钮新增</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getAdminPosts, deletePost as deletePostApi, Post } from '../../services/api'
import { useToast } from '../../composables/useToast'
const router = useRouter()
const toast = useToast()
const posts = ref<Post[]>([])
const fetchPosts = async () => {
try {
posts.value = await getAdminPosts()
} catch (error) {
console.error('Error fetching posts:', error)
toast.error('获取文章列表失败')
}
}
const deletePost = async (id: string) => {
if (confirm('确定要删除这篇文章吗?')) {
try {
await deletePostApi(id)
toast.success('文章删除成功')
fetchPosts()
} catch (error) {
console.error('Error deleting post:', error)
toast.error('删除文章失败')
}
}
}
onMounted(() => {
fetchPosts()
})
</script>
<style scoped>
.admin-posts {
max-width: 1200px;
margin: 0 auto;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #d4b383;
font-family: 'Inter', sans-serif;
}
.toolbar {
margin-bottom: 1rem;
display: flex;
justify-content: flex-end;
}
.table-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-table {
width: 100%;
border-collapse: collapse;
color: white;
}
.admin-table th,
.admin-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-table th {
background: rgba(255, 255, 255, 0.08);
font-weight: 600;
color: #d4b383;
font-family: 'Inter', sans-serif;
}
.admin-table tr:hover {
background: rgba(212, 179, 131, 0.05);
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.status-badge.published {
background-color: rgba(16, 185, 129, 0.2);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.status-badge.draft {
background-color: rgba(251, 191, 36, 0.2);
color: #f59e0b;
border: 1px solid rgba(251, 191, 36, 0.3);
}
.actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: 'Inter', sans-serif;
border: 1px solid transparent;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.btn-primary {
background-color: #d4b383;
color: #050505;
border-color: #d4b383;
}
.btn-primary:hover {
background-color: transparent;
color: #d4b383;
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.btn-secondary {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.2);
}
.btn-secondary:hover {
background-color: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.btn-danger {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.btn-danger:hover {
background-color: rgba(239, 68, 68, 0.3);
border-color: #ef4444;
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
}
.empty-state {
text-align: center;
padding: 2rem;
color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.05);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
font-family: 'Inter', sans-serif;
}
</style>

View File

@@ -0,0 +1,273 @@
<template>
<div class="role-form-container">
<h1 class="page-title">{{ isEditing ? '编辑角色' : '新建角色' }}</h1>
<div class="form-container">
<form @submit.prevent="handleSubmit" class="role-form">
<!-- Role Name Field -->
<div class="form-group">
<label for="name">角色名称</label>
<input
type="text"
id="name"
v-model="form.name"
placeholder="请输入角色名称"
required
/>
<div class="error-message" v-if="errors.name">
{{ errors.name }}
</div>
</div>
<!-- Description Field -->
<div class="form-group">
<label for="description">描述</label>
<textarea
id="description"
v-model="form.description"
placeholder="请输入角色描述"
rows="4"
></textarea>
<div class="error-message" v-if="errors.description">
{{ errors.description }}
</div>
</div>
<!-- Submit Buttons -->
<div class="form-actions">
<button type="button" class="cancel-btn" @click="handleCancel">
取消
</button>
<button type="submit" class="submit-btn" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : (isEditing ? '更新角色' : '创建角色') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createRole, updateRole, fetchRole } from '../../services/api'
const router = useRouter()
const route = useRoute()
const toast = useToast()
// Form state
const isSubmitting = ref(false)
const isEditing = computed(() => !!route.params.id)
const errors = reactive<Record<string, string>>({})
// Form data
const form = reactive({
name: '',
description: ''
})
// Validation function
const validateForm = (): boolean => {
// Reset errors
Object.keys(errors).forEach(key => delete errors[key])
let isValid = true
// Validate name
if (!form.name.trim()) {
errors.name = '角色名称不能为空'
isValid = false
}
return isValid
}
// Submit handler
const handleSubmit = async () => {
if (!validateForm()) {
return
}
isSubmitting.value = true
try {
if (isEditing.value) {
// Update existing role
await updateRole(parseInt(route.params.id as string), form)
toast.success('角色更新成功')
} else {
// Create new role
await createRole(form)
toast.success('角色创建成功')
}
// Redirect to roles list
router.push('/admin/roles')
} catch (error: any) {
console.error('Error submitting form:', error)
toast.error(error.message || (isEditing.value ? '更新角色失败' : '创建角色失败'))
} finally {
isSubmitting.value = false
}
}
// Cancel handler
const handleCancel = () => {
router.push('/admin/roles')
}
// Lifecycle
onMounted(async () => {
if (isEditing.value) {
try {
const roleId = parseInt(route.params.id as string)
const role = await fetchRole(roleId)
// Populate form with role data
form.name = role.name
form.description = role.description || ''
} catch (error: any) {
console.error('Failed to fetch role data:', error)
toast.error('加载角色数据失败: ' + (error.message || '未知错误'))
}
}
})
</script>
<style scoped>
.role-form-container {
width: 100%;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin-bottom: 1.5rem;
font-family: 'Inter', sans-serif;
}
.form-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.role-form {
max-width: 600px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.95rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 0.5rem;
font-family: 'Inter', sans-serif;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
font-size: 0.95rem;
background: rgba(255, 255, 255, 0.05);
color: white;
font-family: 'Inter', sans-serif;
resize: vertical;
}
.form-group input::placeholder,
.form-group textarea::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
font-family: 'Inter', sans-serif;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.cancel-btn {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.cancel-btn:hover {
background: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.submit-btn {
padding: 0.75rem 1.5rem;
background-color: #d4b383;
color: #050505;
border: 1px solid #d4b383;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.submit-btn:hover:not(:disabled) {
background-color: transparent;
color: #d4b383;
transform: translateY(-1px);
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive Design */
@media (max-width: 768px) {
.form-container {
padding: 1.5rem;
}
.form-actions {
flex-direction: column;
}
.cancel-btn,
.submit-btn {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,417 @@
<template>
<div class="roles-container">
<div class="page-header">
<h1 class="page-title">角色管理</h1>
<button class="create-btn" @click="router.push('/admin/roles/create')">
<span class="btn-icon">+</span>
<span class="btn-text">新建角色</span>
</button>
</div>
<!-- Search -->
<div class="search-box">
<input
type="text"
placeholder="搜索角色名称"
v-model="searchQuery"
@input="handleSearch"
/>
<button class="search-btn">🔍</button>
</div>
<!-- Roles Table -->
<div class="table-container">
<table class="roles-table">
<thead>
<tr>
<th>ID</th>
<th>角色名称</th>
<th>描述</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="role in filteredRoles" :key="role.id">
<td>{{ role.id }}</td>
<td>{{ role.name }}</td>
<td>{{ role.description || '无描述' }}</td>
<td>{{ formatDate(role.createdAt) }}</td>
<td class="actions">
<button class="action-btn edit" @click="router.push(`/admin/roles/${role.id}/edit`)" title="编辑">
</button>
<button class="action-btn delete" @click="handleDelete(role.id)" title="删除">
🗑
</button>
</td>
</tr>
</tbody>
</table>
<!-- Empty State -->
<div class="empty-state" v-if="filteredRoles.length === 0">
<div class="empty-icon">🔒</div>
<h3>暂无角色数据</h3>
<p>点击右上角按钮创建新角色</p>
</div>
</div>
<!-- Pagination -->
<div class="pagination" v-if="filteredRoles.length > 0">
<button class="page-btn" @click="currentPage--" :disabled="currentPage === 1">
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button class="page-btn" @click="currentPage++" :disabled="currentPage === totalPages">
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { getRoles, deleteRole, Role } from '../../services/api'
const router = useRouter()
const toast = useToast()
// State
const roles = ref<Role[]>([])
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const isLoading = ref(false)
// Computed properties
const filteredRoles = computed(() => {
let result = roles.value
// Apply search filter
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(role =>
role.name.toLowerCase().includes(query) ||
(role.description && role.description.toLowerCase().includes(query))
)
}
return result
})
const totalPages = computed(() => {
return Math.ceil(filteredRoles.value.length / pageSize.value)
})
// Methods
const fetchRoles = async () => {
isLoading.value = true
try {
const data = await getRoles()
roles.value = data
} catch (error) {
toast.error('获取角色列表失败')
console.error('Error fetching roles:', error)
} finally {
isLoading.value = false
}
}
const handleSearch = () => {
currentPage.value = 1
}
const handleDelete = async (id: number) => {
if (confirm('确定要删除这个角色吗?')) {
try {
await deleteRole(id)
toast.success('角色删除成功')
fetchRoles() // Refresh the list
} catch (error) {
toast.error('删除角色失败')
console.error('Error deleting role:', error)
}
}
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// Lifecycle
onMounted(() => {
fetchRoles()
})
</script>
<style scoped>
.roles-container {
width: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin: 0;
font-family: 'Inter', sans-serif;
}
.create-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: rgba(212, 179, 131, 0.1);
color: #d4b383;
border: 1px solid #d4b383;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.create-btn:hover {
background-color: transparent;
transform: translateY(-1px);
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.btn-icon {
font-size: 1.25rem;
}
/* Search */
.search-box {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
max-width: 400px;
}
.search-box input {
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
flex: 1;
font-size: 0.95rem;
background: rgba(255, 255, 255, 0.05);
color: white;
font-family: 'Inter', sans-serif;
}
.search-box input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.search-box input:focus {
outline: none;
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
}
.search-btn {
padding: 0.75rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
color: rgba(255, 255, 255, 0.8);
}
.search-btn:hover {
background: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
/* Table Styles */
.table-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
margin-bottom: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.roles-table {
width: 100%;
border-collapse: collapse;
color: white;
}
.roles-table th,
.roles-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-family: 'Inter', sans-serif;
}
.roles-table th {
background: rgba(255, 255, 255, 0.08);
font-weight: 600;
color: #d4b383;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.roles-table tr:hover {
background: rgba(212, 179, 131, 0.05);
}
/* Actions */
.actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.5rem;
border: 1px solid transparent;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn.edit {
background-color: rgba(212, 179, 131, 0.2);
color: #d4b383;
border-color: rgba(212, 179, 131, 0.3);
}
.action-btn.edit:hover {
background-color: rgba(212, 179, 131, 0.3);
border-color: #d4b383;
box-shadow: 0 0 10px rgba(212, 179, 131, 0.3);
}
.action-btn.delete {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.action-btn.delete:hover {
background-color: rgba(239, 68, 68, 0.3);
border-color: #ef4444;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
color: rgba(255, 255, 255, 0.6);
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
color: #d4b383;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
color: white;
font-family: 'Inter', sans-serif;
}
.empty-state p {
margin: 0;
font-size: 0.95rem;
font-family: 'Inter', sans-serif;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
.page-btn {
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
color: rgba(255, 255, 255, 0.8);
font-family: 'Inter', sans-serif;
}
.page-btn:hover:not(:disabled) {
background: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.page-info {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.6);
font-family: 'Inter', sans-serif;
}
/* Responsive Design */
@media (max-width: 768px) {
.search-box {
max-width: 100%;
}
.roles-table {
display: block;
overflow-x: auto;
}
.roles-table th,
.roles-table td {
white-space: nowrap;
}
}
</style>

View File

@@ -0,0 +1,412 @@
<template>
<div class="admin-settings">
<h1 class="page-title">系统配置管理</h1>
<div class="table-container">
<table class="admin-table">
<thead>
<tr>
<th>键名</th>
<th></th>
<th>描述</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="setting in settings" :key="setting.id">
<td>{{ setting.keyName }}</td>
<td class="setting-value">{{ setting.value }}</td>
<td>{{ setting.description }}</td>
<td class="actions">
<button @click="editSetting(setting)" class="btn btn-sm btn-secondary">
编辑
</button>
<button @click="deleteSetting(setting.keyName)" class="btn btn-sm btn-danger">
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="settings.length === 0" class="empty-state">
<p>暂无系统配置</p>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>{{ editingSetting ? '编辑配置' : '新增配置' }}</h2>
<button @click="closeModal" class="close-btn">&times;</button>
</div>
<div class="modal-body">
<form @submit.prevent="saveSetting">
<div class="form-group">
<label for="keyName">键名</label>
<input
type="text"
id="keyName"
v-model="form.keyName"
:disabled="editingSetting"
required
class="form-control"
>
</div>
<div class="form-group">
<label for="value"></label>
<input
type="text"
id="value"
v-model="form.value"
required
class="form-control"
>
</div>
<div class="form-group">
<label for="description">描述</label>
<textarea
id="description"
v-model="form.description"
rows="3"
class="form-control"
></textarea>
</div>
<div class="form-actions">
<button type="button" @click="closeModal" class="btn btn-secondary">取消</button>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getSettings, createSetting, updateSetting, deleteSetting as deleteSettingApi, Setting } from '../../services/api'
import { useToast } from '../../composables/useToast'
const toast = useToast()
const settings = ref<Setting[]>([])
const showModal = ref(false)
const editingSetting = ref(false)
const form = ref({
id: 0,
keyName: '',
value: '',
description: ''
})
const fetchSettings = async () => {
try {
settings.value = await getSettings()
} catch (error) {
console.error('Error fetching settings:', error)
toast.error('获取系统配置失败')
}
}
const editSetting = (setting: Setting) => {
editingSetting.value = true
form.value = {
id: setting.id,
keyName: setting.keyName,
value: setting.value,
description: setting.description
}
showModal.value = true
}
const saveSetting = async () => {
try {
if (editingSetting.value) {
await updateSetting(form.value)
toast.success('配置更新成功')
} else {
await createSetting({
keyName: form.value.keyName,
value: form.value.value,
description: form.value.description
})
toast.success('配置创建成功')
}
closeModal()
fetchSettings()
} catch (error) {
console.error('Error saving setting:', error)
toast.error(editingSetting.value ? '更新配置失败' : '创建配置失败')
}
}
const deleteSetting = async (keyName: string) => {
if (confirm(`确定要删除配置项 "${keyName}" 吗?`)) {
try {
await deleteSettingApi(keyName)
toast.success('配置删除成功')
fetchSettings()
} catch (error) {
console.error('Error deleting setting:', error)
toast.error('删除配置失败')
}
}
}
const closeModal = () => {
showModal.value = false
editingSetting.value = false
form.value = {
id: 0,
keyName: '',
value: '',
description: ''
}
}
onMounted(() => {
fetchSettings()
})
</script>
<style scoped>
.admin-settings {
max-width: 1200px;
margin: 0 auto;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #d4b383;
font-family: 'Inter', sans-serif;
}
.table-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
margin-bottom: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-table {
width: 100%;
border-collapse: collapse;
color: white;
}
.admin-table th,
.admin-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-family: 'Inter', sans-serif;
}
.admin-table th {
background: rgba(255, 255, 255, 0.08);
font-weight: 600;
color: #d4b383;
white-space: nowrap;
}
.admin-table tr:hover {
background: rgba(212, 179, 131, 0.05);
}
.setting-value {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid transparent;
font-family: 'Inter', sans-serif;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.btn-primary {
background-color: #d4b383;
color: #050505;
border-color: #d4b383;
}
.btn-primary:hover {
background-color: transparent;
color: #d4b383;
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.btn-secondary {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.2);
}
.btn-secondary:hover {
background-color: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.btn-danger {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.btn-danger:hover {
background-color: rgba(239, 68, 68, 0.3);
border-color: #ef4444;
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
}
.empty-state {
text-align: center;
padding: 2rem;
color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.05);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
font-family: 'Inter', sans-serif;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #d4b383;
font-family: 'Inter', sans-serif;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: rgba(255, 255, 255, 0.6);
padding: 0;
line-height: 1;
transition: all 0.3s ease;
}
.close-btn:hover {
color: #d4b383;
transform: rotate(90deg);
}
.modal-body {
padding: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
font-family: 'Inter', sans-serif;
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.375rem;
font-size: 1rem;
background: rgba(255, 255, 255, 0.05);
color: white;
font-family: 'Inter', sans-serif;
}
.form-control::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.form-control:focus {
outline: none;
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
}
.form-control:disabled {
background-color: rgba(255, 255, 255, 0.08);
cursor: not-allowed;
border-color: rgba(255, 255, 255, 0.1);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.5rem;
}
</style>

View File

@@ -0,0 +1,322 @@
<template>
<div class="snippet-form-container">
<h1 class="page-title">{{ isEditing ? '编辑代码片段' : '新建代码片段' }}</h1>
<div class="form-container">
<form @submit.prevent="handleSubmit" class="snippet-form">
<!-- Title Field -->
<div class="form-group">
<label for="title">标题</label>
<input
type="text"
id="title"
v-model="form.title"
placeholder="请输入代码片段标题"
required
/>
<div class="error-message" v-if="errors.title">
{{ errors.title }}
</div>
</div>
<!-- Type Field -->
<div class="form-group">
<label for="type">类型</label>
<input
type="text"
id="type"
v-model="form.type"
placeholder="请输入代码类型js、css、html等"
required
/>
<div class="error-message" v-if="errors.type">
{{ errors.type }}
</div>
</div>
<!-- Code Field -->
<div class="form-group">
<label for="code">代码内容</label>
<textarea
id="code"
v-model="form.code"
placeholder="请输入代码内容"
rows="10"
required
></textarea>
<div class="error-message" v-if="errors.code">
{{ errors.code }}
</div>
</div>
<!-- Description Field -->
<div class="form-group">
<label for="description">描述</label>
<textarea
id="description"
v-model="form.description"
placeholder="请输入代码片段描述"
rows="3"
></textarea>
<div class="error-message" v-if="errors.description">
{{ errors.description }}
</div>
</div>
<!-- Submit Buttons -->
<div class="form-actions">
<button type="button" class="cancel-btn" @click="handleCancel">
取消
</button>
<button type="submit" class="submit-btn" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : (isEditing ? '更新代码片段' : '创建代码片段') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createSnippet, updateSnippet, fetchSnippet } from '../../services/api'
const router = useRouter()
const route = useRoute()
const toast = useToast()
// Form state
const isSubmitting = ref(false)
const isEditing = computed(() => !!route.params.id)
const errors = reactive<Record<string, string>>({})
// Form data
const form = reactive({
title: '',
code: '',
type: 'js',
description: ''
})
// Validation function
const validateForm = (): boolean => {
// Reset errors
Object.keys(errors).forEach(key => delete errors[key])
let isValid = true
// Validate title
if (!form.title.trim()) {
errors.title = '代码片段标题不能为空'
isValid = false
}
// Validate type
if (!form.type.trim()) {
errors.type = '代码类型不能为空'
isValid = false
}
// Validate code
if (!form.code.trim()) {
errors.code = '代码内容不能为空'
isValid = false
}
return isValid
}
// Submit handler
const handleSubmit = async () => {
if (!validateForm()) {
return
}
isSubmitting.value = true
try {
if (isEditing.value) {
// Update existing snippet
await updateSnippet(route.params.id as string, form)
toast.success('代码片段更新成功')
} else {
// Create new snippet
await createSnippet(form)
toast.success('代码片段创建成功')
}
// Redirect to snippets list
router.push('/admin/snippets')
} catch (error: any) {
console.error('Error submitting form:', error)
toast.error(error.message || (isEditing.value ? '更新代码片段失败' : '创建代码片段失败'))
} finally {
isSubmitting.value = false
}
}
// Cancel handler
const handleCancel = () => {
router.push('/admin/snippets')
}
// Lifecycle
onMounted(async () => {
if (isEditing.value) {
try {
const snippetId = route.params.id as string
const snippet = await fetchSnippet(snippetId)
// Populate form with snippet data
form.title = snippet.title
form.code = snippet.code
form.type = snippet.type
form.description = snippet.description || ''
} catch (error: any) {
console.error('Failed to fetch snippet data:', error)
toast.error('加载代码片段数据失败: ' + (error.message || '未知错误'))
}
}
})
</script>
<style scoped>
.snippet-form-container {
width: 100%;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin-bottom: 1.5rem;
font-family: 'Inter', sans-serif;
}
.form-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.snippet-form {
max-width: 800px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.95rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 0.5rem;
font-family: 'Inter', sans-serif;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
font-size: 0.95rem;
background: rgba(255, 255, 255, 0.05);
color: white;
font-family: 'Inter', sans-serif;
resize: vertical;
}
.form-group input::placeholder,
.form-group textarea::placeholder,
.form-group select::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
font-family: 'Inter', sans-serif;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.cancel-btn {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.cancel-btn:hover {
background: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.submit-btn {
padding: 0.75rem 1.5rem;
background-color: #d4b383;
color: #050505;
border: 1px solid #d4b383;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.submit-btn:hover:not(:disabled) {
background-color: transparent;
color: #d4b383;
transform: translateY(-1px);
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive Design */
@media (max-width: 768px) {
.form-container {
padding: 1.5rem;
}
.form-actions {
flex-direction: column;
}
.cancel-btn,
.submit-btn {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,218 @@
<template>
<div class="admin-snippets">
<h1 class="page-title">代码片段管理</h1>
<div class="toolbar">
<router-link to="/admin/snippets/create" class="btn btn-primary">
+ 新增代码片段
</router-link>
</div>
<div class="table-container">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>类型</th>
<th>查看次数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="snippet in snippets" :key="snippet.id">
<td>{{ snippet.id }}</td>
<td>{{ snippet.title }}</td>
<td>
<span class="type-badge">{{ snippet.type }}</span>
</td>
<td>{{ snippet.viewCount || 0 }}</td>
<td class="actions">
<router-link :to="`/admin/snippets/${snippet.id}/edit`" class="btn btn-sm btn-secondary">
编辑
</router-link>
<button @click="deleteSnippet(snippet.id)" class="btn btn-sm btn-danger">
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="snippets.length === 0" class="empty-state">
<p>暂无代码片段请点击上方按钮新增</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getAdminSnippets, deleteSnippet as deleteSnippetApi, Snippet } from '../../services/api'
import { useToast } from '../../composables/useToast'
const toast = useToast()
const snippets = ref<Snippet[]>([])
const fetchSnippets = async () => {
try {
snippets.value = await getAdminSnippets()
} catch (error) {
console.error('Error fetching snippets:', error)
toast.error('获取代码片段列表失败')
}
}
const deleteSnippet = async (id: string) => {
if (confirm('确定要删除这个代码片段吗?')) {
try {
await deleteSnippetApi(id)
toast.success('代码片段删除成功')
fetchSnippets()
} catch (error) {
console.error('Error deleting snippet:', error)
toast.error('删除代码片段失败')
}
}
}
onMounted(() => {
fetchSnippets()
})
</script>
<style scoped>
.admin-snippets {
max-width: 1200px;
margin: 0 auto;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #d4b383;
font-family: 'Inter', sans-serif;
}
.toolbar {
margin-bottom: 1rem;
display: flex;
justify-content: flex-end;
}
.table-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-table {
width: 100%;
border-collapse: collapse;
color: white;
}
.admin-table th,
.admin-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-family: 'Inter', sans-serif;
}
.admin-table th {
background: rgba(255, 255, 255, 0.08);
font-weight: 600;
color: #d4b383;
}
.admin-table tr:hover {
background: rgba(212, 179, 131, 0.05);
}
.type-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
background-color: rgba(212, 179, 131, 0.2);
color: #d4b383;
border: 1px solid rgba(212, 179, 131, 0.3);
}
.actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: 'Inter', sans-serif;
border: 1px solid transparent;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.btn-primary {
background: rgba(212, 179, 131, 0.1);
color: #d4b383;
border-color: #d4b383;
}
.btn-primary:hover {
background-color: transparent;
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.btn-secondary {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.2);
}
.btn-secondary:hover {
background-color: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.btn-danger {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.btn-danger:hover {
background-color: rgba(239, 68, 68, 0.3);
border-color: #ef4444;
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
}
.empty-state {
text-align: center;
padding: 2rem;
color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.05);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
font-family: 'Inter', sans-serif;
}
</style>

View File

@@ -0,0 +1,219 @@
<template>
<div class="tag-form-container">
<h1 class="page-title">{{ isEdit ? '编辑标签' : '新建标签' }}</h1>
<div class="form-card">
<form @submit.prevent="submitForm">
<!-- 名称字段 -->
<div class="form-group">
<label for="name" class="form-label">名称</label>
<input
type="text"
id="name"
v-model="tagForm.name"
class="form-input"
placeholder="请输入标签名称"
required
/>
</div>
<!-- 描述字段 -->
<div class="form-group">
<label for="description" class="form-label">描述</label>
<textarea
id="description"
v-model="tagForm.description"
class="form-input"
placeholder="请输入标签描述"
rows="3"
></textarea>
</div>
<!-- 表单按钮 -->
<div class="form-actions">
<button type="button" class="btn-secondary" @click="router.back()">
取消
</button>
<button type="submit" class="btn-primary">
{{ isEdit ? '保存修改' : '创建标签' }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createTag, updateTag, adminGetTag } from '../../services/api'
const router = useRouter()
const route = useRoute()
const toast = useToast()
// 判断是编辑还是创建
const isEdit = computed(() => !!route.params.id)
// 标签表单数据
const tagForm = ref({
name: '',
description: ''
})
// 获取标签详情
const fetchTagDetail = async () => {
if (!isEdit.value) return
try {
const tagId = parseInt(route.params.id as string, 10)
const tag = await adminGetTag(tagId)
tagForm.value = {
name: tag.name,
description: tag.description
}
} catch (error: any) {
console.error('Failed to fetch tag detail:', error)
toast.error('加载标签数据失败: ' + (error.message || '未知错误'))
}
}
// 提交表单
const submitForm = async () => {
try {
if (isEdit.value) {
const tagId = parseInt(route.params.id as string, 10)
await updateTag(tagId, tagForm.value)
toast.success('标签更新成功')
} else {
await createTag(tagForm.value)
toast.success('标签创建成功')
}
router.push('/admin/tags')
} catch (error: any) {
console.error('Failed to submit tag form:', error)
toast.error(error.message || (isEdit.value ? '更新标签失败' : '创建标签失败'))
}
}
onMounted(() => {
fetchTagDetail()
})
</script>
<style scoped>
.tag-form-container {
width: 100%;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin-bottom: 1.5rem;
font-family: 'Inter', sans-serif;
}
.form-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 2rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-width: 600px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-size: 0.95rem;
font-weight: 500;
color: #d4b383;
margin-bottom: 0.5rem;
font-family: 'Inter', sans-serif;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.375rem;
color: rgba(255, 255, 255, 0.9);
font-size: 0.95rem;
font-family: 'Inter', sans-serif;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.1);
}
.form-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
textarea.form-input {
resize: vertical;
min-height: 100px;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
.btn-primary, .btn-secondary {
padding: 0.625rem 1.25rem;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.btn-primary {
background: rgba(212, 179, 131, 0.1);
border: 1px solid #d4b383;
color: #d4b383;
}
.btn-primary:hover {
background: rgba(212, 179, 131, 0.2);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(212, 179, 131, 0.15);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
transform: translateY(-2px);
}
</script>
<style scoped>
.tag-form-container {
width: 100%;
}
.form-card {
margin-top: 1rem;
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="tags-container">
<h1 class="page-title">标签管理</h1>
<!-- Action Buttons -->
<div class="action-bar">
<button class="btn-primary" @click="router.push('/admin/tags/create')">
<span class="btn-icon"></span>
新建标签
</button>
</div>
<!-- Tags Table -->
<div class="table-container">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>描述</th>
<th>创建时间</th>
<th>更新时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="tag in tags" :key="tag.id">
<td class="table-cell">{{ tag.id }}</td>
<td class="table-cell">{{ tag.name }}</td>
<td class="table-cell">{{ tag.description || '-' }}</td>
<td class="table-cell">{{ tag.createdAt }}</td>
<td class="table-cell">{{ tag.updatedAt }}</td>
<td class="table-cell actions">
<button class="btn-edit" @click="router.push(`/admin/tags/${tag.id}/edit`)" title="编辑">
</button>
<button class="btn-delete" @click="handleDeleteTag(tag.id)" title="删除">
🗑
</button>
</td>
</tr>
</tbody>
</table>
<!-- Empty State -->
<div v-if="tags.length === 0" class="empty-state">
<p>暂无标签数据</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { adminGetTags, deleteTag } from '../../services/api'
const router = useRouter()
// 标签数据
const tags = ref([])
// 获取标签列表
const fetchTags = async () => {
try {
const data = await adminGetTags()
tags.value = data
} catch (error) {
console.error('Failed to fetch tags:', error)
}
}
// 删除标签
const handleDeleteTag = async (id: number) => {
if (confirm('确定要删除这个标签吗?')) {
try {
await deleteTag(id)
fetchTags() // 重新获取标签列表
} catch (error) {
console.error('Failed to delete tag:', error)
}
}
}
onMounted(() => {
fetchTags()
})
</script>
<style scoped>
.tags-container {
width: 100%;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin-bottom: 1.5rem;
font-family: 'Inter', sans-serif;
}
.action-bar {
display: flex;
justify-content: flex-start;
margin-bottom: 1rem;
}
.btn-primary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: rgba(212, 179, 131, 0.1);
border: 1px solid #d4b383;
color: #d4b383;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.95rem;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.btn-primary:hover {
background: rgba(212, 179, 131, 0.2);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(212, 179, 131, 0.15);
}
.btn-icon {
font-size: 1rem;
}
.table-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-family: 'Inter', sans-serif;
}
.admin-table thead {
background: rgba(255, 255, 255, 0.08);
}
.admin-table th {
padding: 1rem;
text-align: left;
font-size: 0.875rem;
font-weight: 600;
color: #d4b383;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-table td {
padding: 1rem;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-table tr:last-child td {
border-bottom: none;
}
.admin-table tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.actions {
display: flex;
gap: 0.5rem;
}
.btn-edit, .btn-delete {
padding: 0.5rem;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.btn-edit {
background: rgba(59, 130, 246, 0.1);
color: rgba(59, 130, 246, 0.8);
}
.btn-edit:hover {
background: rgba(59, 130, 246, 0.2);
transform: translateY(-1px);
}
.btn-delete {
background: rgba(239, 68, 68, 0.1);
color: rgba(239, 68, 68, 0.8);
}
.btn-delete:hover {
background: rgba(239, 68, 68, 0.2);
transform: translateY(-1px);
}
.empty-state {
padding: 3rem;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 1rem;
}
</style>

View File

@@ -0,0 +1,364 @@
<template>
<div class="user-form-container">
<h1 class="page-title">{{ isEditing ? '编辑用户' : '新建用户' }}</h1>
<div class="form-container">
<form @submit.prevent="handleSubmit" class="user-form">
<!-- Username Field -->
<div class="form-group">
<label for="username">用户名</label>
<input
type="text"
id="username"
v-model="form.username"
placeholder="请输入用户名"
required
/>
<div class="error-message" v-if="errors.username">
{{ errors.username }}
</div>
</div>
<!-- Email Field -->
<div class="form-group">
<label for="email">邮箱</label>
<input
type="email"
id="email"
v-model="form.email"
placeholder="请输入邮箱"
required
/>
<div class="error-message" v-if="errors.email">
{{ errors.email }}
</div>
</div>
<!-- Role Field -->
<div class="form-group">
<label for="role">角色</label>
<CustomSelect
v-model="form.role"
:options="roleOptions"
placeholder="请选择角色"
/>
<div class="error-message" v-if="errors.role">
{{ errors.role }}
</div>
</div>
<!-- Status Field -->
<div class="form-group">
<label for="isActive">状态</label>
<CustomSelect
v-model="form.isActive"
:options="statusOptions"
placeholder="请选择状态"
/>
<div class="error-message" v-if="errors.isActive">
{{ errors.isActive }}
</div>
</div>
<!-- Submit Buttons -->
<div class="form-actions">
<button type="button" class="cancel-btn" @click="handleCancel">
取消
</button>
<button type="submit" class="submit-btn" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : (isEditing ? '更新用户' : '创建用户') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createUser, updateUser, fetchUser, User, API_BASE, getAuthHeaders } from '../../services/api'
import CustomSelect from '../../components/CustomSelect.vue'
const router = useRouter()
const route = useRoute()
const toast = useToast()
// Form state
const isSubmitting = ref(false)
const isEditing = computed(() => !!route.params.id)
const errors = reactive<Record<string, string>>({})
// Form data
const form = reactive({
username: '',
email: '',
role: 'viewer',
isActive: 1
})
// Select options
const roleOptions = [
{ value: 'admin', label: '管理员' },
{ value: 'editor', label: '编辑' },
{ value: 'viewer', label: '查看者' }
]
const statusOptions = [
{ value: 1, label: '激活' },
{ value: 0, label: '禁用' }
]
// Validation function
const validateForm = (): boolean => {
// Reset errors
Object.keys(errors).forEach(key => delete errors[key])
let isValid = true
// Validate username
if (!form.username.trim()) {
errors.username = '用户名不能为空'
isValid = false
}
// Validate email
if (!form.email.trim()) {
errors.email = '邮箱不能为空'
isValid = false
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
errors.email = '请输入有效的邮箱地址'
isValid = false
}
// Validate role
if (!form.role) {
errors.role = '请选择角色'
isValid = false
}
return isValid
}
// Submit handler
const handleSubmit = async () => {
if (!validateForm()) {
return
}
isSubmitting.value = true
try {
if (isEditing.value) {
// Update existing user
await updateUser(parseInt(route.params.id as string), form)
toast.success('用户更新成功')
} else {
// Create new user
await createUser(form)
toast.success('用户创建成功')
}
// Redirect to users list
router.push('/admin/users')
} catch (error: any) {
console.error('Error submitting form:', error)
toast.error(error.message || (isEditing.value ? '更新用户失败' : '创建用户失败'))
} finally {
isSubmitting.value = false
}
}
// Cancel handler
const handleCancel = () => {
router.push('/admin/users')
}
// Lifecycle
onMounted(async () => {
if (isEditing.value) {
try {
const userId = parseInt(route.params.id as string)
console.log(`Fetching user data for ID: ${userId}`)
// Direct fetch to debug
const response = await fetch(`${API_BASE}/admin/users/${userId}`, {
headers: getAuthHeaders()
})
console.log(`Response status: ${response.status}`)
// Check response headers
const contentType = response.headers.get('content-type')
console.log(`Response content-type: ${contentType}`)
// Read response as text first to debug
const responseText = await response.text()
console.log(`Response text: ${responseText}`)
// Then try to parse as JSON
if (!response.ok) {
// If response is not ok, still try to parse as JSON
let errorData
try {
errorData = JSON.parse(responseText)
throw new Error(errorData.error || '获取用户详情失败')
} catch (parseError) {
throw new Error(`获取用户详情失败,响应格式错误: ${parseError.message}`)
}
}
// Parse successful response
const user = JSON.parse(responseText)
console.log('Parsed user data:', user)
// Populate form with user data
form.username = user.username
form.email = user.email
form.role = user.role
form.isActive = user.isActive
} catch (error: any) {
console.error('Failed to fetch user data:', error)
console.error('Error stack:', error.stack)
toast.error('加载用户数据失败: ' + (error.message || '未知错误'))
}
}
})
</script>
<style scoped>
.user-form-container {
width: 100%;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin-bottom: 1.5rem;
font-family: 'Inter', sans-serif;
}
.form-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.user-form {
max-width: 600px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.95rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 0.5rem;
font-family: 'Inter', sans-serif;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
font-size: 0.95rem;
background: rgba(255, 255, 255, 0.05);
color: white;
font-family: 'Inter', sans-serif;
}
.form-group input::placeholder,
.form-group select::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
font-family: 'Inter', sans-serif;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.cancel-btn {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.cancel-btn:hover {
background: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.submit-btn {
padding: 0.75rem 1.5rem;
background-color: #d4b383;
color: #050505;
border: 1px solid #d4b383;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.submit-btn:hover:not(:disabled) {
background-color: transparent;
color: #d4b383;
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
transform: translateY(-1px);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive Design */
@media (max-width: 768px) {
.form-container {
padding: 1.5rem;
}
.form-actions {
flex-direction: column;
}
.cancel-btn,
.submit-btn {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,574 @@
<template>
<div class="users-container">
<div class="page-header">
<h1 class="page-title">用户管理</h1>
<button class="create-btn" @click="router.push('/admin/users/create')">
<span class="btn-icon">+</span>
<span class="btn-text">新建用户</span>
</button>
</div>
<!-- Search and Filter -->
<div class="search-filter">
<div class="search-box">
<input
type="text"
placeholder="搜索用户名或邮箱"
v-model="searchQuery"
@input="handleSearch"
/>
<button class="search-btn">🔍</button>
</div>
<div class="filter-options">
<CustomSelect
v-model="roleFilter"
:options="roleFilterOptions"
@update:modelValue="handleFilter"
style="width: 120px; margin-right: 10px;"
/>
<CustomSelect
v-model="statusFilter"
:options="statusFilterOptions"
@update:modelValue="handleFilter"
style="width: 120px;"
/>
</div>
</div>
<!-- Users Table -->
<div class="table-container">
<table class="users-table">
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>邮箱</th>
<th>角色</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in filteredUsers" :key="user.id">
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>
<span class="role-badge" :class="user.role">
{{ getUserRoleText(user.role) }}
</span>
</td>
<td>
<span class="status-badge" :class="user.isActive ? 'active' : 'inactive'">
{{ user.isActive ? '激活' : '禁用' }}
</span>
</td>
<td>{{ formatDate(user.createdAt) }}</td>
<td class="actions">
<button class="action-btn edit" @click="router.push(`/admin/users/${user.id}/edit`)" title="编辑">
</button>
<button class="action-btn delete" @click="handleDelete(user.id)" title="删除">
🗑
</button>
</td>
</tr>
</tbody>
</table>
<!-- Empty State -->
<div class="empty-state" v-if="filteredUsers.length === 0">
<div class="empty-icon">👥</div>
<h3>暂无用户数据</h3>
<p>点击右上角按钮创建新用户</p>
</div>
</div>
<!-- Pagination -->
<div class="pagination" v-if="filteredUsers.length > 0">
<button class="page-btn" @click="currentPage--" :disabled="currentPage === 1">
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button class="page-btn" @click="currentPage++" :disabled="currentPage === totalPages">
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { getUsers, deleteUser, User } from '../../services/api'
import CustomSelect from '../../components/CustomSelect.vue'
const router = useRouter()
const toast = useToast()
// State
const users = ref<User[]>([])
const searchQuery = ref('')
const roleFilter = ref('')
const statusFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const isLoading = ref(false)
// Select options
const roleFilterOptions = [
{ value: '', label: '全部角色' },
{ value: 'admin', label: '管理员' },
{ value: 'editor', label: '编辑' },
{ value: 'viewer', label: '查看者' }
]
const statusFilterOptions = [
{ value: '', label: '全部状态' },
{ value: '1', label: '激活' },
{ value: '0', label: '禁用' }
]
// Computed properties
const filteredUsers = computed(() => {
let result = users.value
// Apply search filter
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(user =>
user.username.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
)
}
// Apply role filter
if (roleFilter.value) {
result = result.filter(user => user.role === roleFilter.value)
}
// Apply status filter
if (statusFilter.value !== '') {
result = result.filter(user => user.isActive === parseInt(statusFilter.value))
}
return result
})
const totalPages = computed(() => {
return Math.ceil(filteredUsers.value.length / pageSize.value)
})
// Methods
const fetchUsers = async () => {
isLoading.value = true
try {
const data = await getUsers()
users.value = data
} catch (error) {
toast.error('获取用户列表失败')
console.error('Error fetching users:', error)
} finally {
isLoading.value = false
}
}
const handleSearch = () => {
// Debounce search if needed
currentPage.value = 1
}
const handleFilter = () => {
currentPage.value = 1
}
const handleDelete = async (id: number) => {
if (confirm('确定要删除这个用户吗?')) {
try {
await deleteUser(id)
toast.success('用户删除成功')
fetchUsers() // Refresh the list
} catch (error) {
toast.error('删除用户失败')
console.error('Error deleting user:', error)
}
}
}
const getUserRoleText = (role: string): string => {
const roleMap: Record<string, string> = {
'admin': '管理员',
'editor': '编辑',
'viewer': '查看者'
}
return roleMap[role] || role
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// Lifecycle
onMounted(() => {
fetchUsers()
})
</script>
<style scoped>
.users-container {
width: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin: 0;
font-family: 'Inter', sans-serif;
}
.create-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background-color: #d4b383;
color: #050505;
border: 1px solid #d4b383;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.create-btn:hover {
background-color: transparent;
color: #d4b383;
transform: translateY(-1px);
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.btn-icon {
font-size: 1.25rem;
}
/* Search and Filter */
.search-filter {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.search-box {
display: flex;
align-items: center;
gap: 0.5rem;
}
.search-box input {
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
width: 300px;
font-size: 0.95rem;
background: rgba(255, 255, 255, 0.05);
color: white;
font-family: 'Inter', sans-serif;
}
.search-box input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.search-box input:focus {
outline: none;
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
}
.search-btn {
padding: 0.75rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
color: rgba(255, 255, 255, 0.8);
}
.search-btn:hover {
background: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.filter-options {
display: flex;
gap: 1rem;
}
.filter-options select {
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
font-size: 0.95rem;
background: rgba(255, 255, 255, 0.05);
color: white;
cursor: pointer;
font-family: 'Inter', sans-serif;
}
.filter-options select:focus {
outline: none;
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
}
/* Table Styles */
.table-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
margin-bottom: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.users-table {
width: 100%;
border-collapse: collapse;
color: white;
}
.users-table th,
.users-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-family: 'Inter', sans-serif;
}
.users-table th {
background: rgba(255, 255, 255, 0.08);
font-weight: 600;
color: #d4b383;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.users-table tr:hover {
background: rgba(212, 179, 131, 0.05);
}
/* Badges */
.role-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid transparent;
}
.role-badge.admin {
background-color: rgba(59, 130, 246, 0.2);
color: #3b82f6;
border-color: rgba(59, 130, 246, 0.3);
}
.role-badge.editor {
background-color: rgba(16, 185, 129, 0.2);
color: #10b981;
border-color: rgba(16, 185, 129, 0.3);
}
.role-badge.viewer {
background-color: rgba(251, 191, 36, 0.2);
color: #f59e0b;
border-color: rgba(251, 191, 36, 0.3);
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
border: 1px solid transparent;
}
.status-badge.active {
background-color: rgba(16, 185, 129, 0.2);
color: #10b981;
border-color: rgba(16, 185, 129, 0.3);
}
.status-badge.inactive {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
/* Actions */
.actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.5rem;
border: 1px solid transparent;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn.edit {
background-color: rgba(212, 179, 131, 0.2);
color: #d4b383;
border-color: rgba(212, 179, 131, 0.3);
}
.action-btn.edit:hover {
background-color: rgba(212, 179, 131, 0.3);
border-color: #d4b383;
box-shadow: 0 0 10px rgba(212, 179, 131, 0.3);
}
.action-btn.delete {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.action-btn.delete:hover {
background-color: rgba(239, 68, 68, 0.3);
border-color: #ef4444;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
color: rgba(255, 255, 255, 0.6);
}
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
color: #d4b383;
}
.empty-state h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
color: white;
font-family: 'Inter', sans-serif;
}
.empty-state p {
margin: 0;
font-size: 0.95rem;
font-family: 'Inter', sans-serif;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
.page-btn {
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
color: rgba(255, 255, 255, 0.8);
font-family: 'Inter', sans-serif;
}
.page-btn:hover:not(:disabled) {
background: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.6);
font-family: 'Inter', sans-serif;
}
/* Responsive Design */
@media (max-width: 768px) {
.search-filter {
flex-direction: column;
align-items: stretch;
}
.search-box input {
width: 100%;
}
.filter-options {
flex-direction: column;
}
.users-table {
display: block;
overflow-x: auto;
}
.users-table th,
.users-table td {
white-space: nowrap;
}
}
</style>

View File

@@ -0,0 +1,465 @@
<template>
<div class="work-form-container">
<h1 class="page-title">{{ isEditing ? '编辑作品' : '新建作品' }}</h1>
<div class="form-container">
<form @submit.prevent="handleSubmit" class="work-form">
<!-- Title Field -->
<div class="form-group">
<label for="title">作品标题</label>
<input
type="text"
id="title"
v-model="form.title"
placeholder="请输入作品标题"
required
/>
<div class="error-message" v-if="errors.title">
{{ errors.title }}
</div>
</div>
<!-- Category Field -->
<div class="form-group">
<label for="category">分类</label>
<input
type="text"
id="category"
v-model="form.category"
placeholder="请输入作品分类"
required
/>
<div class="error-message" v-if="errors.category">
{{ errors.category }}
</div>
</div>
<!-- Year Field -->
<div class="form-group">
<label for="year">创作年份</label>
<input
type="text"
id="year"
v-model="form.year"
placeholder="请输入创作年份"
required
/>
<div class="error-message" v-if="errors.year">
{{ errors.year }}
</div>
</div>
<!-- Hero Image Field -->
<div class="form-group">
<label for="heroImg">作品主图 URL</label>
<input
type="url"
id="heroImg"
v-model="form.heroImg"
placeholder="请输入作品主图 URL"
required
/>
<div class="error-message" v-if="errors.heroImg">
{{ errors.heroImg }}
</div>
</div>
<!-- Description Field -->
<div class="form-group">
<label for="desc">作品描述</label>
<textarea
id="desc"
v-model="form.desc"
placeholder="请输入作品描述"
rows="5"
required
></textarea>
<div class="error-message" v-if="errors.desc">
{{ errors.desc }}
</div>
</div>
<!-- Tech Stack Field (Simplified for now) -->
<div class="form-group">
<label for="techStack">技术栈JSON格式</label>
<textarea
id="techStack"
v-model="techStackJson"
placeholder='请输入技术栈 JSON例如[{"category": "前端", "items": ["Vue 3", "TypeScript"]}]'
rows="3"
required
></textarea>
<div class="error-message" v-if="errors.techStack">
{{ errors.techStack }}
</div>
</div>
<!-- Gallery Field (Simplified for now) -->
<div class="form-group">
<label for="gallery">作品图库JSON格式</label>
<textarea
id="gallery"
v-model="galleryJson"
placeholder='请输入图库 JSON例如["image1.jpg", "image2.jpg"]'
rows="3"
required
></textarea>
<div class="error-message" v-if="errors.gallery">
{{ errors.gallery }}
</div>
</div>
<!-- Links Field (Simplified for now) -->
<div class="form-group">
<label for="links">链接JSON格式</label>
<textarea
id="links"
v-model="linksJson"
placeholder='请输入链接 JSON例如{"live": "https://example.com"}'
rows="3"
required
></textarea>
<div class="error-message" v-if="errors.links">
{{ errors.links }}
</div>
</div>
<!-- Submit Buttons -->
<div class="form-actions">
<button type="button" class="cancel-btn" @click="handleCancel">
取消
</button>
<button type="submit" class="submit-btn" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : (isEditing ? '更新作品' : '创建作品') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from '../../composables/useToast'
import { createWork, updateWork, fetchWork } from '../../services/api'
const router = useRouter()
const route = useRoute()
const toast = useToast()
// Form state
const isSubmitting = ref(false)
const isEditing = computed(() => !!route.params.id)
const errors = reactive<Record<string, string>>({})
// Form data
const form = reactive({
title: '',
category: '',
year: '',
heroImg: '',
desc: '',
techStack: [] as { category: string; items: string[] }[],
gallery: [] as string[],
links: { live: '' }
})
// JSON string representations for easy editing
const techStackJson = ref('[]')
const galleryJson = ref('[]')
const linksJson = ref('{"live": ""}')
// Watch JSON strings and update form data
watch(techStackJson, (newVal) => {
try {
form.techStack = JSON.parse(newVal)
delete errors.techStack
} catch (e) {
// Validation will catch this
}
})
watch(galleryJson, (newVal) => {
try {
form.gallery = JSON.parse(newVal)
delete errors.gallery
} catch (e) {
// Validation will catch this
}
})
watch(linksJson, (newVal) => {
try {
form.links = JSON.parse(newVal)
delete errors.links
} catch (e) {
// Validation will catch this
}
})
// Validation function
const validateForm = (): boolean => {
// Reset errors
Object.keys(errors).forEach(key => delete errors[key])
let isValid = true
// Validate title
if (!form.title.trim()) {
errors.title = '作品标题不能为空'
isValid = false
}
// Validate category
if (!form.category.trim()) {
errors.category = '作品分类不能为空'
isValid = false
}
// Validate year
if (!form.year.trim()) {
errors.year = '创作年份不能为空'
isValid = false
}
// Validate hero image
if (!form.heroImg.trim()) {
errors.heroImg = '作品主图 URL 不能为空'
isValid = false
}
// Validate description
if (!form.desc.trim()) {
errors.desc = '作品描述不能为空'
isValid = false
}
// Validate tech stack JSON
try {
JSON.parse(techStackJson.value)
} catch (e) {
errors.techStack = '技术栈 JSON 格式无效'
isValid = false
}
// Validate gallery JSON
try {
JSON.parse(galleryJson.value)
} catch (e) {
errors.gallery = '图库 JSON 格式无效'
isValid = false
}
// Validate links JSON
try {
JSON.parse(linksJson.value)
} catch (e) {
errors.links = '链接 JSON 格式无效'
isValid = false
}
return isValid
}
// Submit handler
const handleSubmit = async () => {
if (!validateForm()) {
return
}
isSubmitting.value = true
try {
if (isEditing.value) {
// Update existing work
await updateWork(route.params.id as string, form)
toast.success('作品更新成功')
} else {
// Create new work
await createWork(form)
toast.success('作品创建成功')
}
// Redirect to works list
router.push('/admin/works')
} catch (error: any) {
console.error('Error submitting form:', error)
toast.error(error.message || (isEditing.value ? '更新作品失败' : '创建作品失败'))
} finally {
isSubmitting.value = false
}
}
// Cancel handler
const handleCancel = () => {
router.push('/admin/works')
}
// Lifecycle
onMounted(async () => {
if (isEditing.value) {
try {
const workId = route.params.id as string
const work = await fetchWork(workId)
// Populate form with work data
form.title = work.title
form.category = work.category
form.year = work.year
form.heroImg = work.heroImg
form.desc = work.desc
form.techStack = work.techStack
form.gallery = work.gallery
form.links = work.links
// Update JSON string representations
techStackJson.value = JSON.stringify(work.techStack, null, 2)
galleryJson.value = JSON.stringify(work.gallery, null, 2)
linksJson.value = JSON.stringify(work.links, null, 2)
} catch (error: any) {
console.error('Failed to fetch work data:', error)
toast.error('加载作品数据失败: ' + (error.message || '未知错误'))
}
}
})
</script>
<style scoped>
.work-form-container {
width: 100%;
}
.page-title {
font-size: 1.75rem;
font-weight: 600;
color: #d4b383;
margin-bottom: 1.5rem;
font-family: 'Inter', sans-serif;
}
.form-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.work-form {
max-width: 800px;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.95rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 0.5rem;
font-family: 'Inter', sans-serif;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
font-size: 0.95rem;
background: rgba(255, 255, 255, 0.05);
color: white;
font-family: 'Inter', sans-serif;
resize: vertical;
}
.form-group input::placeholder,
.form-group textarea::placeholder,
.form-group select::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #d4b383;
box-shadow: 0 0 0 3px rgba(212, 179, 131, 0.2);
}
.error-message {
color: #ef4444;
font-size: 0.875rem;
margin-top: 0.25rem;
font-family: 'Inter', sans-serif;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.cancel-btn {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.cancel-btn:hover {
background: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.submit-btn {
padding: 0.75rem 1.5rem;
background-color: #d4b383;
color: #050505;
border: 1px solid #d4b383;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
font-family: 'Inter', sans-serif;
}
.submit-btn:hover:not(:disabled) {
background-color: transparent;
color: #d4b383;
transform: translateY(-1px);
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive Design */
@media (max-width: 768px) {
.form-container {
padding: 1.5rem;
}
.form-actions {
flex-direction: column;
}
.cancel-btn,
.submit-btn {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<div class="admin-works">
<h1 class="page-title">作品管理</h1>
<div class="toolbar">
<router-link to="/admin/works/create" class="btn btn-primary">
+ 新增作品
</router-link>
</div>
<div class="table-container">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>分类</th>
<th>年份</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="work in works" :key="work.id">
<td>{{ work.id }}</td>
<td>{{ work.title }}</td>
<td>{{ work.category }}</td>
<td>{{ work.year }}</td>
<td class="actions">
<router-link :to="`/admin/works/${work.id}/edit`" class="btn btn-sm btn-secondary">
编辑
</router-link>
<button @click="deleteWork(work.id)" class="btn btn-sm btn-danger">
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="works.length === 0" class="empty-state">
<p>暂无作品请点击上方按钮新增</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getAdminWorks, deleteWork as deleteWorkApi, Work } from '../../services/api'
import { useToast } from '../../composables/useToast'
const toast = useToast()
const works = ref<Work[]>([])
const fetchWorks = async () => {
try {
works.value = await getAdminWorks()
} catch (error) {
console.error('Error fetching works:', error)
toast.error('获取作品列表失败')
}
}
const deleteWork = async (id: string) => {
if (confirm('确定要删除这个作品吗?')) {
try {
await deleteWorkApi(id)
toast.success('作品删除成功')
fetchWorks()
} catch (error) {
console.error('Error deleting work:', error)
toast.error('删除作品失败')
}
}
}
onMounted(() => {
fetchWorks()
})
</script>
<style scoped>
.admin-works {
max-width: 1200px;
margin: 0 auto;
}
.page-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: #d4b383;
font-family: 'Inter', sans-serif;
}
.toolbar {
margin-bottom: 1rem;
display: flex;
justify-content: flex-end;
}
.table-container {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-table {
width: 100%;
border-collapse: collapse;
color: white;
}
.admin-table th,
.admin-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-family: 'Inter', sans-serif;
}
.admin-table th {
background: rgba(255, 255, 255, 0.08);
font-weight: 600;
color: #d4b383;
}
.admin-table tr:hover {
background: rgba(212, 179, 131, 0.05);
}
.actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: 'Inter', sans-serif;
border: 1px solid transparent;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.btn-primary {
background: rgba(212, 179, 131, 0.1);
color: #d4b383;
border-color: #d4b383;
}
.btn-primary:hover {
background-color: transparent;
box-shadow: 0 0 15px rgba(212, 179, 131, 0.3);
}
.btn-secondary {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.2);
}
.btn-secondary:hover {
background-color: rgba(212, 179, 131, 0.1);
border-color: #d4b383;
color: #d4b383;
}
.btn-danger {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.btn-danger:hover {
background-color: rgba(239, 68, 68, 0.3);
border-color: #ef4444;
box-shadow: 0 0 15px rgba(239, 68, 68, 0.3);
}
.empty-state {
text-align: center;
padding: 2rem;
color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.05);
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
font-family: 'Inter', sans-serif;
}
</style>

89
client/src/router.ts Normal file
View File

@@ -0,0 +1,89 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', name: 'home', component: () => import('./pages/Home.vue') },
{ path: '/blog', name: 'blog', component: () => import('./pages/Blog.vue') },
{ path: '/blog/:id', name: 'blog-detail', component: () => import('./pages/BlogDetail.vue') },
{ path: '/works', name: 'works', component: () => import('./pages/Works.vue') },
{ path: '/works/:id', name: 'work-detail', component: () => import('./pages/WorkDetail.vue') },
{ path: '/snippets', name: 'snippets', component: () => import('./pages/Snippets.vue') },
{ path: '/services', name: 'services', component: () => import('./pages/Services.vue') },
{ path: '/about', name: 'about', component: () => import('./pages/About.vue') },
// 登录路由
{ path: '/login', name: 'login', component: () => import('./pages/Login.vue') },
// 管理员路由 - 使用 AdminLayout 组件
{
path: '/admin',
name: 'admin',
component: () => import('./components/admin/AdminLayout.vue'),
meta: { requiresAuth: true },
children: [
// 仪表盘
{ path: '', redirect: '/admin/dashboard' },
{ path: 'dashboard', name: 'admin-dashboard', component: () => import('./pages/admin/Dashboard.vue') },
// 用户管理
{ path: 'users', name: 'admin-users', component: () => import('./pages/admin/Users.vue') },
{ path: 'users/create', name: 'admin-users-create', component: () => import('./pages/admin/UserForm.vue') },
{ path: 'users/:id/edit', name: 'admin-users-edit', component: () => import('./pages/admin/UserForm.vue') },
// 角色管理
{ path: 'roles', name: 'admin-roles', component: () => import('./pages/admin/Roles.vue') },
{ path: 'roles/create', name: 'admin-roles-create', component: () => import('./pages/admin/RoleForm.vue') },
{ path: 'roles/:id/edit', name: 'admin-roles-edit', component: () => import('./pages/admin/RoleForm.vue') },
// 文章管理
{ path: 'posts', name: 'admin-posts', component: () => import('./pages/admin/Posts.vue') },
{ path: 'posts/create', name: 'admin-posts-create', component: () => import('./pages/admin/PostForm.vue') },
{ path: 'posts/:id/edit', name: 'admin-posts-edit', component: () => import('./pages/admin/PostForm.vue') },
// 作品管理
{ path: 'works', name: 'admin-works', component: () => import('./pages/admin/Works.vue') },
{ path: 'works/create', name: 'admin-works-create', component: () => import('./pages/admin/WorkForm.vue') },
{ path: 'works/:id/edit', name: 'admin-works-edit', component: () => import('./pages/admin/WorkForm.vue') },
// 代码片段管理
{ path: 'snippets', name: 'admin-snippets', component: () => import('./pages/admin/Snippets.vue') },
{ path: 'snippets/create', name: 'admin-snippets-create', component: () => import('./pages/admin/SnippetForm.vue') },
{ path: 'snippets/:id/edit', name: 'admin-snippets-edit', component: () => import('./pages/admin/SnippetForm.vue') },
// 标签管理
{ path: 'tags', name: 'admin-tags', component: () => import('./pages/admin/Tags.vue') },
{ path: 'tags/create', name: 'admin-tags-create', component: () => import('./pages/admin/TagForm.vue') },
{ path: 'tags/:id/edit', name: 'admin-tags-edit', component: () => import('./pages/admin/TagForm.vue') },
// 系统配置
{ path: 'settings', name: 'admin-settings', component: () => import('./pages/admin/Settings.vue') },
// 操作日志
{ path: 'logs', name: 'admin-logs', component: () => import('./pages/admin/Logs.vue') }
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫,用于保护需要认证的路由
router.beforeEach((to, from, next) => {
// 检查路由是否需要认证
if (to.matched.some(record => record.meta.requiresAuth)) {
// 检查本地存储中是否有token
const token = localStorage.getItem('token')
if (!token) {
// 没有token重定向到登录页
next({ name: 'login' })
} else {
// 有token继续访问
next()
}
} else {
// 不需要认证的路由,直接访问
next()
}
})
export default router

856
client/src/services/api.ts Normal file
View File

@@ -0,0 +1,856 @@
export const API_BASE = 'http://localhost:8081/api'
// 通用请求头配置
export const getAuthHeaders = () => {
const token = localStorage.getItem('token')
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
}
}
// 认证相关类型
export interface User {
id: number
username: string
email: string
role: string
isActive: number
createdAt: string
updatedAt: string
}
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
token: string
user: User
expire: number
}
// 角色相关类型
export interface Role {
id: number
name: string
description: string
createdAt: string
updatedAt: string
}
// 作品相关类型
export interface Work {
id: string
title: string
category: string
year: string
heroImg: string
desc: string
techStack: { category: string; items: string[] }[]
gallery: string[]
links: { live: string }
next: string
}
// 文章相关类型
export interface Post {
id: string
title: string
category: string
date: string
excerpt?: string
content?: string
isPublished?: number
}
export interface PostHistory {
id: number
postId: string
version: number
title: string
category: string
excerpt?: string
content: string
isPublished: number
modifiedBy: number
modifiedAt: string
createdAt: string
}
// 代码片段相关类型
export interface Snippet {
id: string
title: string
code: string
type: string
description?: string
viewCount?: number
}
// 系统配置相关类型
export interface Setting {
id: number
keyName: string
value: string
description: string
createdAt: string
updatedAt: string
}
// 操作日志相关类型
export interface OperationLog {
id: number
userId: number
username: string
ip: string
path: string
method: string
params: string
status: number
duration: number
createdAt: string
}
// 分页响应类型
export interface PaginationResponse<T> {
list: T[]
total: number
page: number
size: number
}
// 登录API
export const login = async (credentials: LoginRequest): Promise<LoginResponse> => {
try {
const response = await fetch(`${API_BASE}/admin/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '登录失败')
}
return await response.json()
} catch (error) {
console.error('Login error:', error)
throw error
}
}
// 用户管理API
export const getUsers = async (): Promise<User[]> => {
try {
const response = await fetch(`${API_BASE}/admin/users`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取用户列表失败')
}
return await response.json()
} catch (error) {
console.error('Get users error:', error)
throw error
}
}
export const fetchUser = async (id: number): Promise<User> => {
try {
const response = await fetch(`${API_BASE}/admin/users/${id}`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取用户详情失败')
}
return await response.json()
} catch (error) {
console.error(`Error fetching user ${id}:`, error)
throw error
}
}
export const createUser = async (userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/users`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(userData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '创建用户失败')
}
} catch (error) {
console.error('Create user error:', error)
throw error
}
}
export const updateUser = async (id: number, userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/users/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(userData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '更新用户失败')
}
} catch (error) {
console.error('Update user error:', error)
throw error
}
}
export const deleteUser = async (id: number): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/users/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '删除用户失败')
}
} catch (error) {
console.error('Delete user error:', error)
throw error
}
}
// 角色管理API
export const getRoles = async (): Promise<Role[]> => {
try {
const response = await fetch(`${API_BASE}/admin/roles`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取角色列表失败')
}
return await response.json()
} catch (error) {
console.error('Get roles error:', error)
throw error
}
}
export const createRole = async (roleData: Omit<Role, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/roles`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(roleData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '创建角色失败')
}
} catch (error) {
console.error('Create role error:', error)
throw error
}
}
export const updateRole = async (id: number, roleData: Omit<Role, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/roles/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(roleData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '更新角色失败')
}
} catch (error) {
console.error('Update role error:', error)
throw error
}
}
export const deleteRole = async (id: number): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/roles/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '删除角色失败')
}
} catch (error) {
console.error('Delete role error:', error)
throw error
}
}
export const fetchRole = async (id: number): Promise<Role> => {
try {
const response = await fetch(`${API_BASE}/admin/roles/${id}`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取角色详情失败')
}
return await response.json()
} catch (error) {
console.error(`Error fetching role ${id}:`, error)
throw error
}
}
// 作品管理API
export const getAdminWorks = async (): Promise<Work[]> => {
try {
const response = await fetch(`${API_BASE}/admin/works`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取作品列表失败')
}
return await response.json()
} catch (error) {
console.error('Get admin works error:', error)
throw error
}
}
export const fetchWorks = async (): Promise<Work[]> => {
try {
const response = await fetch(`${API_BASE}/works`)
if (!response.ok) throw new Error('Failed to fetch works')
return await response.json()
} catch (error) {
console.error('Error fetching works:', error)
return []
}
}
export const fetchWork = async (id: string): Promise<Work> => {
try {
const response = await fetch(`${API_BASE}/works/${id}`)
if (!response.ok) throw new Error('Failed to fetch work')
return await response.json()
} catch (error) {
console.error(`Error fetching work ${id}:`, error)
return {
id: 'default',
title: '默认作品',
category: '默认分类',
year: '2024',
heroImg: '',
desc: '',
techStack: [],
gallery: [],
links: { live: '#' },
next: ''
}
}
}
export const createWork = async (workData: Omit<Work, 'id' | 'next'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/works`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(workData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '创建作品失败')
}
} catch (error) {
console.error('Create work error:', error)
throw error
}
}
export const updateWork = async (id: string, workData: Omit<Work, 'id' | 'next'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/works/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(workData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '更新作品失败')
}
} catch (error) {
console.error('Update work error:', error)
throw error
}
}
export const deleteWork = async (id: string): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/works/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '删除作品失败')
}
} catch (error) {
console.error('Delete work error:', error)
throw error
}
}
// 文章管理API
export const getAdminPosts = async (): Promise<Post[]> => {
try {
const response = await fetch(`${API_BASE}/admin/posts`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取文章列表失败')
}
return await response.json()
} catch (error) {
console.error('Get admin posts error:', error)
throw error
}
}
export const fetchPosts = async (): Promise<Post[]> => {
try {
const response = await fetch(`${API_BASE}/posts`)
if (!response.ok) throw new Error('Failed to fetch posts')
return await response.json()
} catch (error) {
console.error('Error fetching posts:', error)
return []
}
}
export const fetchPost = async (id: string): Promise<Post> => {
try {
const response = await fetch(`${API_BASE}/posts/${id}`)
if (!response.ok) throw new Error('Failed to fetch post')
return await response.json()
} catch (error) {
console.error(`Error fetching post ${id}:`, error)
return {
id: id,
title: '默认文章',
category: '默认分类',
date: '2024-01-01'
}
}
}
export const createPost = async (postData: Omit<Post, 'id'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/posts`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(postData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '创建文章失败')
}
} catch (error) {
console.error('Create post error:', error)
throw error
}
}
export const updatePost = async (id: string, postData: Omit<Post, 'id'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/posts/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(postData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '更新文章失败')
}
} catch (error) {
console.error('Update post error:', error)
throw error
}
}
export const deletePost = async (id: string): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/posts/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '删除文章失败')
}
} catch (error) {
console.error('Delete post error:', error)
throw error
}
}
// 文章历史记录API
export const getPostHistory = async (postId: string): Promise<PostHistory[]> => {
try {
const response = await fetch(`${API_BASE}/admin/posts/${postId}/history`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取文章历史失败')
}
return await response.json()
} catch (error) {
console.error('Get post history error:', error)
throw error
}
}
export const getPostHistoryByVersion = async (postId: string, version: number): Promise<PostHistory> => {
try {
const response = await fetch(`${API_BASE}/admin/posts/${postId}/history/${version}`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取指定版本文章历史失败')
}
return await response.json()
} catch (error) {
console.error('Get post history by version error:', error)
throw error
}
}
// 代码片段管理API
export const getAdminSnippets = async (): Promise<Snippet[]> => {
try {
const response = await fetch(`${API_BASE}/admin/snippets`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取代码片段列表失败')
}
return await response.json()
} catch (error) {
console.error('Get admin snippets error:', error)
throw error
}
}
export const fetchSnippets = async (): Promise<Snippet[]> => {
try {
const response = await fetch(`${API_BASE}/snippets`)
if (!response.ok) throw new Error('Failed to fetch snippets')
return await response.json()
} catch (error) {
console.error('Error fetching snippets:', error)
return []
}
}
export const fetchSnippet = async (id: string): Promise<Snippet> => {
try {
const response = await fetch(`${API_BASE}/snippets/${id}`)
if (!response.ok) throw new Error('Failed to fetch snippet')
return await response.json()
} catch (error) {
console.error(`Error fetching snippet ${id}:`, error)
return {
id: id,
title: '默认代码片段',
code: 'console.log("Hello World");',
type: 'js'
}
}
}
export const createSnippet = async (snippetData: Omit<Snippet, 'id' | 'viewCount'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/snippets`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(snippetData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '创建代码片段失败')
}
} catch (error) {
console.error('Create snippet error:', error)
throw error
}
}
export const updateSnippet = async (id: string, snippetData: Omit<Snippet, 'id' | 'viewCount'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/snippets/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(snippetData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '更新代码片段失败')
}
} catch (error) {
console.error('Update snippet error:', error)
throw error
}
}
export const deleteSnippet = async (id: string): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/snippets/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '删除代码片段失败')
}
} catch (error) {
console.error('Delete snippet error:', error)
throw error
}
}
// 系统配置API
export const getSettings = async (): Promise<Setting[]> => {
try {
const response = await fetch(`${API_BASE}/admin/settings`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取系统配置失败')
}
return await response.json()
} catch (error) {
console.error('Get settings error:', error)
throw error
}
}
export const createSetting = async (settingData: Omit<Setting, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/settings`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(settingData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '创建系统配置失败')
}
} catch (error) {
console.error('Create setting error:', error)
throw error
}
}
export const updateSetting = async (settingData: Omit<Setting, 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/settings`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(settingData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '更新系统配置失败')
}
} catch (error) {
console.error('Update setting error:', error)
throw error
}
}
export const deleteSetting = async (keyName: string): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/settings/${keyName}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '删除系统配置失败')
}
} catch (error) {
console.error('Delete setting error:', error)
throw error
}
}
// 操作日志API
export const getOperationLogs = async (page: number = 1, pageSize: number = 10): Promise<PaginationResponse<OperationLog>> => {
try {
const response = await fetch(`${API_BASE}/admin/operation-logs?page=${page}&pageSize=${pageSize}`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取操作日志失败')
}
return await response.json()
} catch (error) {
console.error('Get operation logs error:', error)
throw error
}
}
// 仪表盘数据类型
export interface DashboardStats {
users: number
posts: number
works: number
snippets: number
}
// 最近活动类型
export interface RecentActivity {
id: number
icon: string
text: string
time: string
}
// 仪表盘API
export const getDashboardStats = async (): Promise<DashboardStats> => {
try {
const response = await fetch(`${API_BASE}/admin/dashboard/stats`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取仪表盘统计数据失败')
}
return await response.json()
} catch (error) {
console.error('Get dashboard stats error:', error)
throw error
}
}
// 获取最近活动
export const getRecentActivities = async (): Promise<RecentActivity[]> => {
try {
const response = await fetch(`${API_BASE}/admin/dashboard/activities`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取最近活动失败')
}
return await response.json()
} catch (error) {
console.error('Get recent activities error:', error)
throw error
}
}
// 标签相关类型
export interface Tag {
id: number
name: string
description: string
createdAt: string
updatedAt: string
}
// 标签管理API
export const adminGetTags = async (): Promise<Tag[]> => {
try {
const response = await fetch(`${API_BASE}/admin/tags`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取标签列表失败')
}
return await response.json()
} catch (error) {
console.error('Get admin tags error:', error)
throw error
}
}
export const adminGetTag = async (id: number): Promise<Tag> => {
try {
const response = await fetch(`${API_BASE}/admin/tags/${id}`, {
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '获取标签详情失败')
}
return await response.json()
} catch (error) {
console.error('Get admin tag error:', error)
throw error
}
}
export const createTag = async (tagData: Omit<Tag, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/tags`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(tagData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '创建标签失败')
}
} catch (error) {
console.error('Create tag error:', error)
throw error
}
}
export const updateTag = async (id: number, tagData: Omit<Tag, 'id' | 'createdAt' | 'updatedAt'>): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/tags/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(tagData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '更新标签失败')
}
} catch (error) {
console.error('Update tag error:', error)
throw error
}
}
export const deleteTag = async (id: number): Promise<void> => {
try {
const response = await fetch(`${API_BASE}/admin/tags/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || '删除标签失败')
}
} catch (error) {
console.error('Delete tag error:', error)
throw error
}
}

114
client/src/style.css Normal file
View File

@@ -0,0 +1,114 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 3px; transition: all 0.3s ease; }
::-webkit-scrollbar-thumb:hover { background: #d4b383; box-shadow: 0 0 10px rgba(212, 179, 131, 0.4); }
::-webkit-scrollbar-corner { background: transparent; }
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.05); }
.custom-scrollbar:hover::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); }
/* Glass & Utils */
.glass-nav { 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); }
.art-card { background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.03); transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1); position: relative; overflow: hidden; }
.art-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.02) 100%); opacity: 0; transition: opacity 0.5s ease; }
.art-card:hover { transform: translateY(-5px); border-color: rgba(212, 179, 131, 0.3); box-shadow: 0 20px 40px -10px rgba(0,0,0,0.5); }
.art-card:hover::before { opacity: 1; }
.text-glow { text-shadow: 0 0 30px rgba(255,255,255,0.1); }
.link-underline { position: relative; }
.link-underline::after { content: ''; position: absolute; width: 0; height: 1px; bottom: -2px; left: 0; background-color: #d4b383; transition: width 0.3s ease; }
.link-underline:hover::after { width: 100%; }
/* Modal & Toast */
.modal-overlay { background: rgba(0, 0, 0, 0.9); backdrop-filter: blur(10px); }
.toast-container { position: fixed; bottom: 2rem; right: 2rem; z-index: 100; display: flex; flex-direction: column; gap: 0.5rem; pointer-events: none; }
.toast { background: rgba(20, 20, 23, 0.9); border: 1px solid rgba(255,255,255,0.1); backdrop-filter: blur(12px); padding: 1rem 1.5rem; border-radius: 0.5rem; color: white; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5); display: flex; align-items: center; gap: 0.75rem; transform: translateX(100%); transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); pointer-events: auto; min-width: 300px; }
.toast.show { transform: translateX(0); }
.toast-success { border-left: 3px solid #d4b383; }
.toast-error { border-left: 3px solid #ef4444; }
/* Form Error */
.input-error { border-bottom-color: #ef4444 !important; }
.error-msg { color: #ef4444; font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; margin-top: 0.4rem; display: block; position: absolute; bottom: -1.2rem; left: 0; opacity: 0; letter-spacing: 0.05em; animation: fadeIn 0.3s forwards; background: rgba(239, 68, 68, 0.1); padding: 0.1rem 0.3rem; border-radius: 0.2rem; }
@keyframes fadeIn { to { opacity: 1; } }
/* Code Block */
.mac-code-block { background: #1e1e1e; padding: 3rem 1.5rem 1.5rem 1.5rem; border-radius: 0.75rem; overflow-x: auto; margin-bottom: 0; border: 1px solid rgba(255,255,255,0.1); position: relative; box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5); }
.mac-code-block::before { content: ''; position: absolute; top: 1rem; left: 1.5rem; width: 0.75rem; height: 0.75rem; border-radius: 50%; background: #ff5f56; box-shadow: 1.25rem 0 0 #ffbd2e, 2.5rem 0 0 #27c93f; }
.code-keyword { color: #c678dd; } .code-func { color: #61afef; } .code-string { color: #98c379; } .code-comment { color: #5c6370; font-style: italic; }
/* Layout Specifics */
.work-detail-page { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #050505; z-index: 100; overflow-y: auto; }
.gallery-image { width: 100%; margin-bottom: 4rem; filter: grayscale(20%); transition: filter 0.5s; }
.gallery-image:hover { filter: grayscale(0%); }
.menu-open { transform: translateY(0); opacity: 1; pointer-events: auto; }
.menu-closed { transform: translateY(-20px); opacity: 0; pointer-events: none; }
/* Tilt & Service Styles */
.tilt-card { position: relative; transform-style: preserve-3d; transform: perspective(1000px); transition: transform 0.1s ease; }
.tilt-inner { background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.05); backdrop-filter: blur(10px); border-radius: 1rem; padding: 2rem; height: 100%; position: relative; overflow: hidden; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05); }
.tilt-inner::before { content: ""; position: absolute; height: 100%; width: 100%; top: 0; left: 0; background: radial-gradient(600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(212, 179, 131, 0.15), transparent 40%); z-index: 0; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
.tilt-card:hover .tilt-inner::before { opacity: 1; }
.process-accordion { display: flex; gap: 1rem; height: 400px; width: 100%; }
.process-step { position: relative; flex: 1; background: #121214; border: 1px solid rgba(255,255,255,0.1); border-radius: 1.5rem; overflow: hidden; transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1); cursor: default; display: flex; flex-direction: column; justify-content: flex-end; padding: 2rem; }
.process-step:hover { flex: 3; background: #1a1a1c; border-color: rgba(212, 179, 131, 0.4); }
.process-step::before { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.8) 100%); z-index: 1; }
.step-bg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0.3; transition: opacity 0.5s, transform 0.5s; z-index: 0; filter: grayscale(100%); }
.process-step:hover .step-bg { opacity: 0.6; transform: scale(1.05); filter: grayscale(0%); }
.step-content { position: relative; z-index: 10; transform: translateY(0); transition: transform 0.5s; }
.step-number { font-family: 'Playfair Display', serif; font-size: 4rem; color: rgba(255,255,255,0.1); line-height: 1; margin-bottom: 1rem; transition: color 0.3s; }
.process-step:hover .step-number { color: #d4b383; }
.step-desc { max-height: 0; opacity: 0; overflow: hidden; transition: all 0.5s ease; color: #888888; font-size: 0.9rem; line-height: 1.6; }
.process-step:hover .step-desc { max-height: 100px; opacity: 1; margin-top: 1rem; }
@media (max-width: 768px) { .process-accordion { flex-direction: column; height: auto; } .process-step { height: 120px; } .process-step:hover { flex: none; height: 200px; } }
/* Tech Stack Grid */
.tech-category-title { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; letter-spacing: 0.1em; color: #d4b383; text-transform: uppercase; margin-bottom: 0.75rem; padding-bottom: 0.25rem; border-bottom: 1px solid rgba(255, 255, 255, 0.1); }
.tech-grid { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.5rem; }
.tech-item { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); padding: 0.35rem 0.75rem; font-family: 'Inter', sans-serif; font-size: 0.8rem; color: #a1a1aa; transition: all 0.2s; cursor: default; }
.tech-item:hover { border-color: #d4b383; color: white; background: rgba(212, 179, 131, 0.1); }
/* Danmaku Styles */
.danmaku-item { background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(8px); border: 1px solid rgba(255, 255, 255, 0.1); padding: 0.75rem 1.25rem; border-radius: 9999px; display: inline-flex; align-items: center; gap: 0.75rem; white-space: nowrap; color: #d4d4d8; font-size: 0.875rem; transition: all 0.3s; }
.danmaku-item:hover { background: rgba(255, 255, 255, 0.1); border-color: #d4b383; transform: scale(1.05); color: white; z-index: 10; }
.danmaku-row { display: flex; gap: 2rem; width: max-content; }
.partner-logo { filter: grayscale(100%) opacity(0.5); transition: all 0.4s ease; }
.partner-logo:hover { filter: grayscale(0%) opacity(1); transform: scale(1.1); }
/* Animations */
.animate-reveal { opacity: 0; transform: translateY(30px); transition: all 1s cubic-bezier(0.16, 1, 0.3, 1); }
.animate-reveal.visible { opacity: 1; transform: translateY(0); }
.animate-slide-down { opacity: 0; transform: translateY(-30px); transition: all 1s cubic-bezier(0.16, 1, 0.3, 1); }
.animate-slide-down.visible { opacity: 1; transform: translateY(0); }
@keyframes marquee { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } }
@keyframes marquee-reverse { 0% { transform: translateX(-50%); } 100% { transform: translateX(0); } }
.animate-marquee { animation: marquee 30s linear infinite; }
.animate-marquee-reverse { animation: marquee-reverse 35s linear infinite; }
/* Base Styles */
body {
font-family: 'Inter', 'Noto Sans SC', sans-serif;
background-color: #050505;
color: #ececec;
overflow-x: hidden;
}
/* Selection */
::selection {
background-color: #d4b383;
color: black;
}
/* Scroll Smooth */
html {
scroll-behavior: smooth;
}

9
client/src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare interface Window {
lucide?: {
createIcons: () => void
}
showSnippet?: (title: string, code: string, preview?: string) => void
closeSnippet?: () => void
openInquiry?: () => void
closeInquiry?: () => void
}