feat: 增加前端
This commit is contained in:
parent
72a3738388
commit
3ddc8c52ad
|
|
@ -10,4 +10,7 @@
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
|
||||||
!luxx/**/*.py
|
!luxx/**/*.py
|
||||||
!docs/**/*.md
|
!asserts/**/*.md
|
||||||
|
|
||||||
|
# Dashboard
|
||||||
|
!dashboard/**/*
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# 配置文件
|
# 配置文件
|
||||||
app:
|
app:
|
||||||
secret_key: ${APP_SECRET_KEY}
|
secret_key: ${APP_SECRET_KEY}
|
||||||
debug: true
|
debug: false
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8000
|
port: 8000
|
||||||
|
|
||||||
database:
|
database:
|
||||||
type: sqlite
|
type: sqlite
|
||||||
url: sqlite:///./chat.db
|
url: sqlite:///../chat.db
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
provider: deepseek
|
provider: deepseek
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.15.0",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.32",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
|
"vite": "^8.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -0,0 +1,594 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { authAPI } from './services/api.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const user = ref(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const showUserMenu = ref(false)
|
||||||
|
|
||||||
|
// 检查是否已登录
|
||||||
|
const isLoggedIn = computed(() => {
|
||||||
|
return !!localStorage.getItem('access_token')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
const fetchUserInfo = async () => {
|
||||||
|
if (!isLoggedIn.value) {
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authAPI.getMe()
|
||||||
|
if (response.success) {
|
||||||
|
user.value = response.data
|
||||||
|
localStorage.setItem('user', JSON.stringify(response.data))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取用户信息失败:', err)
|
||||||
|
// 如果获取失败,清除登录状态
|
||||||
|
logout()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登出
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await authAPI.logout()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('登出失败:', err)
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
user.value = null
|
||||||
|
showUserMenu.value = false
|
||||||
|
router.push('/auth')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换用户菜单
|
||||||
|
const toggleUserMenu = () => {
|
||||||
|
showUserMenu.value = !showUserMenu.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭用户菜单
|
||||||
|
const closeUserMenu = () => {
|
||||||
|
showUserMenu.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导航高亮
|
||||||
|
const isActive = (path) => {
|
||||||
|
return route.path === path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听路由变化时关闭菜单
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
showUserMenu.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchUserInfo()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<!-- 导航栏 -->
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="navbar-container">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<router-link to="/" class="brand-link">
|
||||||
|
<span class="brand-icon">✨</span>
|
||||||
|
<span class="brand-text">Luxx</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-links">
|
||||||
|
<router-link
|
||||||
|
to="/"
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: isActive('/') }"
|
||||||
|
>
|
||||||
|
<span class="nav-icon">🏠</span>
|
||||||
|
<span class="nav-text">首页</span>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
to="/conversations"
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: isActive('/conversations') }"
|
||||||
|
>
|
||||||
|
<span class="nav-icon">💬</span>
|
||||||
|
<span class="nav-text">会话</span>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
to="/tools"
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: isActive('/tools') }"
|
||||||
|
>
|
||||||
|
<span class="nav-icon">🛠️</span>
|
||||||
|
<span class="nav-text">工具</span>
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<router-link
|
||||||
|
to="/about"
|
||||||
|
class="nav-link"
|
||||||
|
:class="{ active: isActive('/about') }"
|
||||||
|
>
|
||||||
|
<span class="nav-icon">ℹ️</span>
|
||||||
|
<span class="nav-text">关于</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-actions">
|
||||||
|
<!-- 用户菜单 -->
|
||||||
|
<div v-if="loading" class="user-loading">
|
||||||
|
<div class="spinner-nav"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="isLoggedIn && user" class="user-menu-container">
|
||||||
|
<button @click="toggleUserMenu" class="user-button">
|
||||||
|
<div class="user-avatar">
|
||||||
|
{{ user.username?.charAt(0).toUpperCase() || 'U' }}
|
||||||
|
</div>
|
||||||
|
<span class="user-name">{{ user.username }}</span>
|
||||||
|
<span class="dropdown-arrow" :class="{ open: showUserMenu }">▼</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="showUserMenu" class="user-dropdown" @click.stop>
|
||||||
|
<div class="dropdown-header">
|
||||||
|
<div class="dropdown-avatar">
|
||||||
|
{{ user.username?.charAt(0).toUpperCase() || 'U' }}
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-user-info">
|
||||||
|
<div class="dropdown-username">{{ user.username }}</div>
|
||||||
|
<div class="dropdown-email">{{ user.email || '未设置邮箱' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
|
||||||
|
<button @click="closeUserMenu" class="dropdown-item">
|
||||||
|
<span>👤</span> 个人设置
|
||||||
|
</button>
|
||||||
|
<button @click="closeUserMenu" class="dropdown-item">
|
||||||
|
<span>🔑</span> 修改密码
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
|
||||||
|
<button @click="logout" class="dropdown-item dropdown-item-danger">
|
||||||
|
<span>🚪</span> 退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<router-link v-else to="/auth" class="btn-login">
|
||||||
|
<span>🔐</span> 登录
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 页脚 -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="footer-content">
|
||||||
|
<p>© 2026 Luxx. Built with ❤️</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- 点击外部关闭菜单 -->
|
||||||
|
<div
|
||||||
|
v-if="showUserMenu"
|
||||||
|
class="overlay"
|
||||||
|
@click="closeUserMenu"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#app {
|
||||||
|
font-family: var(--sans);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导航栏 */
|
||||||
|
.navbar {
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品牌 */
|
||||||
|
.navbar-brand {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-h);
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导航链接 */
|
||||||
|
.navbar-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background: var(--code-bg);
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background: var(--accent-bg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-text {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户操作区 */
|
||||||
|
.navbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-loading {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-nav {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户菜单 */
|
||||||
|
.user-menu-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-button:hover {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-arrow {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-arrow.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下拉菜单 */
|
||||||
|
.user-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.5rem);
|
||||||
|
right: 0;
|
||||||
|
width: 280px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 40px var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: dropdownFade 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropdownFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-user-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-username {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-email {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: var(--accent-bg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item-danger:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 登录按钮 */
|
||||||
|
.btn-login {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover {
|
||||||
|
background: var(--accent-border);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主内容区 */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1400px) {
|
||||||
|
.main-content {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页脚 */
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 遮罩层 */
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.navbar-container {
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.navbar-container {
|
||||||
|
height: 60px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
|
width: calc(100vw - 2rem);
|
||||||
|
right: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.navbar-links {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
|
|
@ -0,0 +1,80 @@
|
||||||
|
<template>
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">{{ icon }}</div>
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
<p>{{ description }}</p>
|
||||||
|
<button v-if="actionText" @click="$emit('action')" class="btn-action">
|
||||||
|
{{ actionText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: '📭'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '暂无数据'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
actionText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['action'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
color: var(--text);
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action:hover {
|
||||||
|
background: var(--accent-border);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
<template>
|
||||||
|
<div class="error-message" :class="typeClass">
|
||||||
|
<div class="error-icon">{{ icon }}</div>
|
||||||
|
<div class="error-content">
|
||||||
|
<h4 v-if="title">{{ title }}</h4>
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
<button v-if="showRetry" @click="$emit('retry')" class="btn-retry">
|
||||||
|
🔄 重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
message: {
|
||||||
|
type: String,
|
||||||
|
default: '发生了一些错误'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'error',
|
||||||
|
validator: (value) => ['error', 'warning', 'info'].includes(value)
|
||||||
|
},
|
||||||
|
showRetry: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['retry'])
|
||||||
|
|
||||||
|
const typeClass = computed(() => `type-${props.type}`)
|
||||||
|
|
||||||
|
const icon = computed(() => {
|
||||||
|
switch (props.type) {
|
||||||
|
case 'warning': return '⚠️'
|
||||||
|
case 'info': return 'ℹ️'
|
||||||
|
default: return '❌'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-warning {
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-info {
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-retry {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-retry:hover {
|
||||||
|
background: currentColor;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import viteLogo from '../assets/vite.svg'
|
||||||
|
import heroImg from '../assets/hero.png'
|
||||||
|
import vueLogo from '../assets/vue.svg'
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="center">
|
||||||
|
<div class="hero">
|
||||||
|
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||||
|
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||||
|
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>Get started</h1>
|
||||||
|
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||||
|
</div>
|
||||||
|
<button class="counter" @click="count++">Count is {{ count }}</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ticks"></div>
|
||||||
|
|
||||||
|
<section id="next-steps">
|
||||||
|
<div id="docs">
|
||||||
|
<svg class="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#documentation-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Documentation</h2>
|
||||||
|
<p>Your questions, answered</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://vite.dev/" target="_blank">
|
||||||
|
<img class="logo" :src="viteLogo" alt="" />
|
||||||
|
Explore Vite
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://vuejs.org/" target="_blank">
|
||||||
|
<img class="button-icon" :src="vueLogo" alt="" />
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="social">
|
||||||
|
<svg class="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#social-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Connect with us</h2>
|
||||||
|
<p>Join the Vite community</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#github-icon"></use>
|
||||||
|
</svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://chat.vite.dev/" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#discord-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Discord
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://x.com/vite_js" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#x-icon"></use>
|
||||||
|
</svg>
|
||||||
|
X.com
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#bluesky-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Bluesky
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ticks"></div>
|
||||||
|
<section id="spacer"></section>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<template>
|
||||||
|
<div class="loading-spinner" :class="sizeClass">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p v-if="text" class="loading-text">{{ text }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'medium',
|
||||||
|
validator: (value) => ['small', 'medium', 'large'].includes(value)
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const sizeClass = computed(() => `size-${props.size}`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-spinner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border-radius: 50%;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-small .spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-medium .spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-large .spinner {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin: 1rem 0 0 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* API 请求组合式函数
|
||||||
|
* 提供统一的错误处理和加载状态管理
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export function useApi() {
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行 API 请求
|
||||||
|
* @param {Function} apiFn - API 函数
|
||||||
|
* @param {Object} options - 配置选项
|
||||||
|
* @returns {Promise<Object>} 响应数据
|
||||||
|
*/
|
||||||
|
const request = async (apiFn, options = {}) => {
|
||||||
|
const { showLoading = true, errorMessage = '请求失败' } = options
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFn()
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
return response.data
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || errorMessage)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message || errorMessage
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置状态
|
||||||
|
*/
|
||||||
|
const reset = () => {
|
||||||
|
loading.value = false
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
request,
|
||||||
|
reset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页组合式函数
|
||||||
|
*/
|
||||||
|
export function usePagination(initialPage = 1, initialPageSize = 20) {
|
||||||
|
const page = ref(initialPage)
|
||||||
|
const pageSize = ref(initialPageSize)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const totalPages = () => Math.ceil(total.value / pageSize.value)
|
||||||
|
|
||||||
|
const nextPage = () => {
|
||||||
|
if (page.value < totalPages()) {
|
||||||
|
page.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevPage = () => {
|
||||||
|
if (page.value > 1) {
|
||||||
|
page.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToPage = (p) => {
|
||||||
|
if (p >= 1 && p <= totalPages()) {
|
||||||
|
page.value = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPagination = () => {
|
||||||
|
page.value = initialPage
|
||||||
|
total.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
nextPage,
|
||||||
|
prevPage,
|
||||||
|
goToPage,
|
||||||
|
resetPagination
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表单组合式函数
|
||||||
|
*/
|
||||||
|
export function useForm(initialData = {}) {
|
||||||
|
const formData = ref({ ...initialData })
|
||||||
|
const errors = ref({})
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const updateField = (field, value) => {
|
||||||
|
formData.value[field] = value
|
||||||
|
// 清除字段错误
|
||||||
|
if (errors.value[field]) {
|
||||||
|
delete errors.value[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setFieldError = (field, message) => {
|
||||||
|
errors.value[field] = message
|
||||||
|
}
|
||||||
|
|
||||||
|
const setErrors = (errorObj) => {
|
||||||
|
errors.value = errorObj
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearErrors = () => {
|
||||||
|
errors.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetForm = (newData = {}) => {
|
||||||
|
formData.value = { ...initialData, ...newData }
|
||||||
|
errors.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validate = (rules) => {
|
||||||
|
const newErrors = {}
|
||||||
|
|
||||||
|
for (const [field, rule] of Object.entries(rules)) {
|
||||||
|
const value = formData.value[field]
|
||||||
|
|
||||||
|
if (rule.required && !value) {
|
||||||
|
newErrors[field] = rule.message || `${field}不能为空`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.minLength && value && value.length < rule.minLength) {
|
||||||
|
newErrors[field] = rule.message || `${field}长度不能少于${rule.minLength}位`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.maxLength && value && value.length > rule.maxLength) {
|
||||||
|
newErrors[field] = rule.message || `${field}长度不能超过${rule.maxLength}位`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.pattern && value && !rule.pattern.test(value)) {
|
||||||
|
newErrors[field] = rule.message || `${field}格式不正确`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
return Object.keys(newErrors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
formData,
|
||||||
|
errors,
|
||||||
|
submitting,
|
||||||
|
updateField,
|
||||||
|
setFieldError,
|
||||||
|
setErrors,
|
||||||
|
clearErrors,
|
||||||
|
resetForm,
|
||||||
|
validate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* 格式化工具函数
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化日期
|
||||||
|
* @param {string|Date} date - 日期
|
||||||
|
* @param {string} format - 格式 ('short', 'long', 'relative')
|
||||||
|
*/
|
||||||
|
export function formatDate(date, format = 'short') {
|
||||||
|
if (!date) return '未知时间'
|
||||||
|
|
||||||
|
const d = new Date(date)
|
||||||
|
if (isNaN(d.getTime())) return '无效日期'
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - d
|
||||||
|
|
||||||
|
// 相对时间
|
||||||
|
if (format === 'relative') {
|
||||||
|
if (diff < 60000) return '刚刚'
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
|
||||||
|
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 短格式
|
||||||
|
if (format === 'short') {
|
||||||
|
const year = d.getFullYear()
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(d.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
|
||||||
|
if (year === now.getFullYear()) {
|
||||||
|
return `${month}-${day} ${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 长格式
|
||||||
|
const year = d.getFullYear()
|
||||||
|
const month = d.getMonth() + 1
|
||||||
|
const day = d.getDate()
|
||||||
|
const hours = String(d.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}年${month}月${day}日 ${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化数字
|
||||||
|
* @param {number} num - 数字
|
||||||
|
* @param {Object} options - 配置
|
||||||
|
*/
|
||||||
|
export function formatNumber(num, options = {}) {
|
||||||
|
const {
|
||||||
|
decimals = 0,
|
||||||
|
thousands = true,
|
||||||
|
suffix = ''
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (typeof num !== 'number') return num
|
||||||
|
|
||||||
|
const fixed = num.toFixed(decimals)
|
||||||
|
|
||||||
|
if (thousands) {
|
||||||
|
const parts = fixed.split('.')
|
||||||
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
|
return parts.join('.') + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixed + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截断文本
|
||||||
|
* @param {string} text - 文本
|
||||||
|
* @param {number} maxLength - 最大长度
|
||||||
|
* @param {string} suffix - 后缀
|
||||||
|
*/
|
||||||
|
export function truncate(text, maxLength = 50, suffix = '...') {
|
||||||
|
if (!text) return ''
|
||||||
|
if (text.length <= maxLength) return text
|
||||||
|
return text.slice(0, maxLength - suffix.length) + suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化文件大小
|
||||||
|
* @param {number} bytes - 字节数
|
||||||
|
*/
|
||||||
|
export function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首字母大写
|
||||||
|
* @param {string} str - 字符串
|
||||||
|
*/
|
||||||
|
export function capitalize(str) {
|
||||||
|
if (!str) return ''
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化令牌数
|
||||||
|
* @param {number} tokens - 令牌数
|
||||||
|
*/
|
||||||
|
export function formatTokens(tokens) {
|
||||||
|
if (tokens < 1000) return tokens.toString()
|
||||||
|
if (tokens < 1000000) return (tokens / 1000).toFixed(1) + 'K'
|
||||||
|
return (tokens / 1000000).toFixed(1) + 'M'
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
/**
|
||||||
|
* 防抖函数
|
||||||
|
* @param {Function} func - 要执行的函数
|
||||||
|
* @param {number} wait - 等待时间(毫秒)
|
||||||
|
*/
|
||||||
|
export function debounce(func, wait = 300) {
|
||||||
|
let timeout
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
func(...args)
|
||||||
|
}
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(later, wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节流函数
|
||||||
|
* @param {Function} func - 要执行的函数
|
||||||
|
* @param {number} limit - 时间限制(毫秒)
|
||||||
|
*/
|
||||||
|
export function throttle(func, limit = 300) {
|
||||||
|
let inThrottle
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func(...args)
|
||||||
|
inThrottle = true
|
||||||
|
setTimeout(() => inThrottle = false, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深拷贝
|
||||||
|
* @param {any} obj - 要拷贝的对象
|
||||||
|
*/
|
||||||
|
export function deepClone(obj) {
|
||||||
|
if (obj === null || typeof obj !== 'object') return obj
|
||||||
|
if (obj instanceof Date) return new Date(obj)
|
||||||
|
if (obj instanceof Array) return obj.map(item => deepClone(item))
|
||||||
|
if (obj instanceof Object) {
|
||||||
|
const copy = {}
|
||||||
|
for (const key in obj) {
|
||||||
|
if (obj.hasOwnProperty(key)) {
|
||||||
|
copy[key] = deepClone(obj[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随机 ID
|
||||||
|
* @param {number} length - 长度
|
||||||
|
*/
|
||||||
|
export function generateId(length = 8) {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
|
let result = ''
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地存储工具
|
||||||
|
*/
|
||||||
|
export const storage = {
|
||||||
|
get(key, defaultValue = null) {
|
||||||
|
try {
|
||||||
|
const item = localStorage.getItem(key)
|
||||||
|
return item ? JSON.parse(item) : defaultValue
|
||||||
|
} catch {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
set(key, value) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(key) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
try {
|
||||||
|
localStorage.clear()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测设备类型
|
||||||
|
*/
|
||||||
|
export function getDeviceType() {
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) {
|
||||||
|
return 'tablet'
|
||||||
|
}
|
||||||
|
if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) {
|
||||||
|
return 'mobile'
|
||||||
|
}
|
||||||
|
return 'desktop'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制到剪贴板
|
||||||
|
* @param {string} text - 要复制的文本
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
// 降级方案
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.opacity = '0'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
try {
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
// 导出 composables
|
||||||
|
export * from './composables/useApi.js'
|
||||||
|
export * from './composables/useFormatters.js'
|
||||||
|
export * from './composables/useUtils.js'
|
||||||
|
|
||||||
|
// 导出组件
|
||||||
|
export { default as LoadingSpinner } from './components/LoadingSpinner.vue'
|
||||||
|
export { default as ErrorMessage } from './components/ErrorMessage.vue'
|
||||||
|
export { default as EmptyState } from './components/EmptyState.vue'
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import './style.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import pinia from './stores'
|
||||||
|
|
||||||
|
createApp(App)
|
||||||
|
.use(router)
|
||||||
|
.use(pinia)
|
||||||
|
.mount('#app')
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: () => import('../views/HomeView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/about',
|
||||||
|
name: 'About',
|
||||||
|
component: () => import('../views/AboutView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/conversations',
|
||||||
|
name: 'Conversations',
|
||||||
|
component: () => import('../views/ConversationsView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/tools',
|
||||||
|
name: 'Tools',
|
||||||
|
component: () => import('../views/ToolsView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/auth',
|
||||||
|
name: 'Auth',
|
||||||
|
component: () => import('../views/AuthView.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器:添加认证 token
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截器:处理通用错误
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response.data,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
window.location.href = '/auth'
|
||||||
|
}
|
||||||
|
return Promise.reject(error.response?.data || error.message)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============ 认证接口 ============
|
||||||
|
|
||||||
|
export const authAPI = {
|
||||||
|
// 用户登录
|
||||||
|
login: (data) => api.post('/auth/login', data),
|
||||||
|
|
||||||
|
// 用户注册
|
||||||
|
register: (data) => api.post('/auth/register', data),
|
||||||
|
|
||||||
|
// 用户登出
|
||||||
|
logout: () => api.post('/auth/logout'),
|
||||||
|
|
||||||
|
// 获取当前用户信息
|
||||||
|
getMe: () => api.get('/auth/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 会话接口 ============
|
||||||
|
|
||||||
|
export const conversationsAPI = {
|
||||||
|
// 获取会话列表
|
||||||
|
list: (params) => api.get('/conversations/', { params }),
|
||||||
|
|
||||||
|
// 创建会话
|
||||||
|
create: (data) => api.post('/conversations/', data),
|
||||||
|
|
||||||
|
// 获取会话详情
|
||||||
|
get: (id) => api.get(`/conversations/${id}`),
|
||||||
|
|
||||||
|
// 更新会话
|
||||||
|
update: (id, data) => api.put(`/conversations/${id}`, data),
|
||||||
|
|
||||||
|
// 删除会话
|
||||||
|
delete: (id) => api.delete(`/conversations/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 消息接口 ============
|
||||||
|
|
||||||
|
export const messagesAPI = {
|
||||||
|
// 获取消息列表
|
||||||
|
list: (conversationId, params) => api.get(`/messages/${conversationId}`, { params }),
|
||||||
|
|
||||||
|
// 发送消息(非流式)
|
||||||
|
send: (data) => api.post('/messages/', data),
|
||||||
|
|
||||||
|
// 发送消息(流式)- 返回 EventSource 或使用 fetch
|
||||||
|
sendStream: (data) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
return fetch('/api/messages/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除消息
|
||||||
|
delete: (id) => api.delete(`/messages/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 工具接口 ============
|
||||||
|
|
||||||
|
export const toolsAPI = {
|
||||||
|
// 获取工具列表
|
||||||
|
list: (params) => api.get('/tools/', { params }),
|
||||||
|
|
||||||
|
// 获取工具详情
|
||||||
|
get: (name) => api.get(`/tools/${name}`),
|
||||||
|
|
||||||
|
// 执行工具
|
||||||
|
execute: (name, data) => api.post(`/tools/${name}/execute`, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认导出
|
||||||
|
export default api
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import api from '../services/api'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', {
|
||||||
|
state: () => ({
|
||||||
|
user: null,
|
||||||
|
token: localStorage.getItem('access_token') || null,
|
||||||
|
isAuthenticated: !!localStorage.getItem('access_token')
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async login(credentials) {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/auth/login', credentials)
|
||||||
|
this.token = response.data.access_token
|
||||||
|
this.user = response.data.user
|
||||||
|
this.isAuthenticated = true
|
||||||
|
localStorage.setItem('access_token', this.token)
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(userData) {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/auth/register', userData)
|
||||||
|
// 注册后自动登录
|
||||||
|
return this.login({ username: userData.username, password: userData.password })
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
try {
|
||||||
|
await api.post('/auth/logout')
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略错误
|
||||||
|
}
|
||||||
|
this.token = null
|
||||||
|
this.user = null
|
||||||
|
this.isAuthenticated = false
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchUser() {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/auth/me')
|
||||||
|
this.user = response.data
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
this.token = null
|
||||||
|
this.isAuthenticated = false
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
export default pinia
|
||||||
|
|
||||||
|
// 方便导入 store
|
||||||
|
export * from './auth'
|
||||||
|
|
@ -0,0 +1,294 @@
|
||||||
|
:root {
|
||||||
|
--text: #6b6375;
|
||||||
|
--text-h: #08060d;
|
||||||
|
--bg: #fff;
|
||||||
|
--border: #e5e4e7;
|
||||||
|
--code-bg: #f4f3ec;
|
||||||
|
--accent: #aa3bff;
|
||||||
|
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||||
|
--accent-border: rgba(170, 59, 255, 0.5);
|
||||||
|
--social-bg: rgba(244, 243, 236, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||||
|
|
||||||
|
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--mono: ui-monospace, Consolas, monospace;
|
||||||
|
|
||||||
|
font: 18px/145% var(--sans);
|
||||||
|
letter-spacing: 0.18px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--text: #9ca3af;
|
||||||
|
--text-h: #f3f4f6;
|
||||||
|
--bg: #16171d;
|
||||||
|
--border: #2e303a;
|
||||||
|
--code-bg: #1f2028;
|
||||||
|
--accent: #c084fc;
|
||||||
|
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||||
|
--accent-border: rgba(192, 132, 252, 0.5);
|
||||||
|
--social-bg: rgba(47, 48, 58, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#social .button-icon {
|
||||||
|
filter: invert(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-family: var(--heading);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 56px;
|
||||||
|
letter-spacing: -1.68px;
|
||||||
|
margin: 32px 0;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 118%;
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
.counter {
|
||||||
|
font-family: var(--mono);
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 135%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<template>
|
||||||
|
<div class="about">
|
||||||
|
<h1>关于此项目</h1>
|
||||||
|
<p>这是 Luxx 项目的管理仪表板前端。</p>
|
||||||
|
<p>技术栈:Vue 3 + Vue Router + Pinia + Vite</p>
|
||||||
|
<router-link to="/">返回首页</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 可以在此添加组件逻辑
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.about {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,703 @@
|
||||||
|
<template>
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-container">
|
||||||
|
<!-- 左侧品牌区 -->
|
||||||
|
<div class="auth-branding">
|
||||||
|
<div class="branding-content">
|
||||||
|
<div class="brand-logo">✨</div>
|
||||||
|
<h1>欢迎回来</h1>
|
||||||
|
<p>登录到 Luxx 平台,开始智能对话之旅</p>
|
||||||
|
<div class="features-list">
|
||||||
|
<div class="feature-item">
|
||||||
|
<span class="feature-icon">💬</span>
|
||||||
|
<span>智能会话管理</span>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<span class="feature-icon">🛠️</span>
|
||||||
|
<span>丰富的工具生态</span>
|
||||||
|
</div>
|
||||||
|
<div class="feature-item">
|
||||||
|
<span class="feature-icon">🔒</span>
|
||||||
|
<span>安全可靠的认证</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧表单区 -->
|
||||||
|
<div class="auth-form-section">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'login'"
|
||||||
|
:class="{ active: activeTab === 'login' }"
|
||||||
|
class="tab-btn"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'register'"
|
||||||
|
:class="{ active: activeTab === 'register' }"
|
||||||
|
class="tab-btn"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录表单 -->
|
||||||
|
<div v-if="activeTab === 'login'" class="form-container">
|
||||||
|
<h2>登录账户</h2>
|
||||||
|
<p class="form-subtitle">请输入您的账户信息</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="login-username">
|
||||||
|
<span class="label-icon">👤</span>
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="loginForm.username"
|
||||||
|
id="login-username"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
required
|
||||||
|
class="form-input"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="login-password">
|
||||||
|
<span class="label-icon">🔑</span>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<div class="password-input-wrapper">
|
||||||
|
<input
|
||||||
|
v-model="loginForm.password"
|
||||||
|
id="login-password"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
required
|
||||||
|
class="form-input"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
class="password-toggle"
|
||||||
|
>
|
||||||
|
{{ showPassword ? '👁️' : '👁️🗨️' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loginError" class="error-message">
|
||||||
|
<span class="error-icon">⚠️</span>
|
||||||
|
{{ loginError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="btn-submit"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="spinner-small"></span>
|
||||||
|
<span v-else>登录</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<p>还没有账户?<button @click="activeTab = 'register'" class="link-btn">立即注册</button></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 注册表单 -->
|
||||||
|
<div v-else class="form-container">
|
||||||
|
<h2>创建账户</h2>
|
||||||
|
<p class="form-subtitle">填写以下信息完成注册</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleRegister">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="register-username">
|
||||||
|
<span class="label-icon">👤</span>
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="registerForm.username"
|
||||||
|
id="register-username"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入用户名(3-20位)"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
maxlength="20"
|
||||||
|
class="form-input"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="register-email">
|
||||||
|
<span class="label-icon">📧</span>
|
||||||
|
邮箱
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="registerForm.email"
|
||||||
|
id="register-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="请输入邮箱地址"
|
||||||
|
required
|
||||||
|
class="form-input"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="register-password">
|
||||||
|
<span class="label-icon">🔒</span>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<div class="password-input-wrapper">
|
||||||
|
<input
|
||||||
|
v-model="registerForm.password"
|
||||||
|
id="register-password"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
placeholder="请输入密码(至少6位)"
|
||||||
|
required
|
||||||
|
minlength="6"
|
||||||
|
class="form-input"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
class="password-toggle"
|
||||||
|
>
|
||||||
|
{{ showPassword ? '👁️' : '👁️🗨️' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="register-confirm-password">
|
||||||
|
<span class="label-icon">🔐</span>
|
||||||
|
确认密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="confirmPassword"
|
||||||
|
id="register-confirm-password"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
required
|
||||||
|
class="form-input"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="registerError" class="error-message">
|
||||||
|
<span class="error-icon">⚠️</span>
|
||||||
|
{{ registerError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="btn-submit"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="spinner-small"></span>
|
||||||
|
<span v-else>注册</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<p>已有账户?<button @click="activeTab = 'login'" class="link-btn">立即登录</button></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 成功提示 -->
|
||||||
|
<div v-if="authSuccess" class="success-overlay">
|
||||||
|
<div class="success-content">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h3>登录成功!</h3>
|
||||||
|
<p>正在跳转...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { authAPI } from '../services/api.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const activeTab = ref('login')
|
||||||
|
const loading = ref(false)
|
||||||
|
const loginError = ref('')
|
||||||
|
const registerError = ref('')
|
||||||
|
const authSuccess = ref(false)
|
||||||
|
const showPassword = ref(false)
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
|
||||||
|
const loginForm = ref({
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const registerForm = ref({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 登录处理
|
||||||
|
const handleLogin = async () => {
|
||||||
|
loading.value = true
|
||||||
|
loginError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authAPI.login({
|
||||||
|
username: loginForm.value.username,
|
||||||
|
password: loginForm.value.password
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success && response.data?.access_token) {
|
||||||
|
// 保存 token 和用户信息
|
||||||
|
localStorage.setItem('access_token', response.data.access_token)
|
||||||
|
localStorage.setItem('user', JSON.stringify(response.data.user))
|
||||||
|
|
||||||
|
authSuccess.value = true
|
||||||
|
|
||||||
|
// 延迟跳转,让用户看到成功提示
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/')
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '登录失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('登录失败:', err)
|
||||||
|
loginError.value = err.message || '用户名或密码错误'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册处理
|
||||||
|
const handleRegister = async () => {
|
||||||
|
loading.value = true
|
||||||
|
registerError.value = ''
|
||||||
|
|
||||||
|
// 验证密码确认
|
||||||
|
if (registerForm.value.password !== confirmPassword.value) {
|
||||||
|
registerError.value = '两次输入的密码不一致'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authAPI.register({
|
||||||
|
username: registerForm.value.username,
|
||||||
|
email: registerForm.value.email,
|
||||||
|
password: registerForm.value.password
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// 注册成功后自动登录
|
||||||
|
authSuccess.value = true
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
// 清空表单并切换到登录
|
||||||
|
registerForm.value = {
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
}
|
||||||
|
confirmPassword.value = ''
|
||||||
|
authSuccess.value = false
|
||||||
|
activeTab.value = 'login'
|
||||||
|
loginForm.value.username = registerForm.value.username
|
||||||
|
}, 1500)
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '注册失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('注册失败:', err)
|
||||||
|
registerError.value = err.message || '注册失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-page {
|
||||||
|
min-height: calc(100vh - 70px - 80px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
max-width: 1000px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧品牌区 */
|
||||||
|
.auth-branding {
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
|
||||||
|
padding: 3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-branding h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-branding p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin: 0 0 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧表单区 */
|
||||||
|
.auth-form-section {
|
||||||
|
padding: 3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签页 */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-h);
|
||||||
|
box-shadow: 0 2px 8px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单容器 */
|
||||||
|
.form-container h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-subtitle {
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单组 */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: var(--text);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 密码输入框 */
|
||||||
|
.password-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-wrapper .form-input {
|
||||||
|
padding-right: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误消息 */
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 提交按钮 */
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:hover:not(:disabled) {
|
||||||
|
background: var(--accent-border);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 20px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单页脚 */
|
||||||
|
.form-footer {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-footer p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-btn:hover {
|
||||||
|
color: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 成功覆盖层 */
|
||||||
|
.success-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
animation: scaleIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from { transform: scale(0); }
|
||||||
|
to { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-content h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-content p {
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.auth-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-branding {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-section {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.auth-page {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form-section {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,900 @@
|
||||||
|
<template>
|
||||||
|
<div class="conversations">
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h1>会话管理</h1>
|
||||||
|
<p class="subtitle">查看和管理所有会话记录</p>
|
||||||
|
</div>
|
||||||
|
<button @click="createNewConversation" class="btn-primary">
|
||||||
|
<span class="icon">+</span> 新建会话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选和搜索 -->
|
||||||
|
<div class="filters">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索会话..."
|
||||||
|
class="search-input"
|
||||||
|
@input="debouncedSearch"
|
||||||
|
/>
|
||||||
|
<select v-model="selectedModel" class="model-select" @change="fetchConversations">
|
||||||
|
<option value="">全部模型</option>
|
||||||
|
<option value="glm-5">GLM-5</option>
|
||||||
|
<option value="glm-4">GLM-4</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="loading-container">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>正在加载会话列表...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<div v-else-if="error" class="error-container">
|
||||||
|
<div class="error-icon">⚠️</div>
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button @click="fetchConversations" class="btn-secondary">重试</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else-if="conversations.length === 0" class="empty-container">
|
||||||
|
<div class="empty-icon">💬</div>
|
||||||
|
<h3>暂无会话</h3>
|
||||||
|
<p>创建您的第一个会话开始对话</p>
|
||||||
|
<button @click="createNewConversation" class="btn-primary">新建会话</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 会话列表 -->
|
||||||
|
<div v-else class="conversation-list">
|
||||||
|
<div
|
||||||
|
v-for="conv in conversations"
|
||||||
|
:key="conv.id"
|
||||||
|
class="conversation-card"
|
||||||
|
@click="openDetail(conv.id)"
|
||||||
|
>
|
||||||
|
<div class="conversation-info">
|
||||||
|
<h3 class="conversation-title">{{ conv.title || '未命名会话' }}</h3>
|
||||||
|
<div class="conversation-meta">
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="meta-icon">📅</span>
|
||||||
|
{{ formatDate(conv.created_at) }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="meta-icon">🤖</span>
|
||||||
|
{{ conv.model || '默认模型' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="conv.message_count" class="meta-item">
|
||||||
|
<span class="meta-icon">💬</span>
|
||||||
|
{{ conv.message_count }} 条消息
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-actions" @click.stop>
|
||||||
|
<button @click="editConversation(conv)" class="btn-icon" title="编辑">
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button @click="confirmDelete(conv)" class="btn-icon btn-danger" title="删除">
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div v-if="totalPages > 1 && !loading" class="pagination">
|
||||||
|
<button
|
||||||
|
@click="prevPage"
|
||||||
|
:disabled="page === 1"
|
||||||
|
class="btn-pagination"
|
||||||
|
>
|
||||||
|
← 上一页
|
||||||
|
</button>
|
||||||
|
<div class="pagination-info">
|
||||||
|
<span>第 {{ page }} / {{ totalPages }} 页</span>
|
||||||
|
<span class="divider">|</span>
|
||||||
|
<span>共 {{ total }} 条</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="nextPage"
|
||||||
|
:disabled="page >= totalPages"
|
||||||
|
class="btn-pagination"
|
||||||
|
>
|
||||||
|
下一页 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建/编辑会话模态框 -->
|
||||||
|
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{{ editingConversation ? '编辑会话' : '新建会话' }}</h2>
|
||||||
|
<button @click="closeModal" class="btn-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>会话标题</label>
|
||||||
|
<input
|
||||||
|
v-model="formData.title"
|
||||||
|
type="text"
|
||||||
|
placeholder="输入会话标题"
|
||||||
|
class="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>模型选择</label>
|
||||||
|
<select v-model="formData.model" class="form-select">
|
||||||
|
<option value="glm-5">GLM-5</option>
|
||||||
|
<option value="glm-4">GLM-4</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>系统提示词 (可选)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.system_prompt"
|
||||||
|
placeholder="设置系统提示词"
|
||||||
|
class="form-textarea"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>温度参数</label>
|
||||||
|
<input
|
||||||
|
v-model.number="formData.temperature"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="0.1"
|
||||||
|
class="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>最大令牌数</label>
|
||||||
|
<input
|
||||||
|
v-model.number="formData.max_tokens"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65536"
|
||||||
|
class="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
v-model="formData.thinking_enabled"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
启用思考模式
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button @click="closeModal" class="btn-secondary">取消</button>
|
||||||
|
<button @click="submitForm" class="btn-primary">
|
||||||
|
{{ editingConversation ? '保存' : '创建' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 删除确认模态框 -->
|
||||||
|
<div v-if="showDeleteModal" class="modal-overlay" @click.self="closeDeleteModal">
|
||||||
|
<div class="modal modal-small">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>确认删除</h2>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>确定要删除会话「{{ deletingConversation?.title || '未命名会话' }}」吗?</p>
|
||||||
|
<p class="warning-text">此操作不可撤销</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button @click="closeDeleteModal" class="btn-secondary">取消</button>
|
||||||
|
<button @click="deleteConversation" class="btn-danger" :disabled="deleting">
|
||||||
|
{{ deleting ? '删除中...' : '确认删除' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { conversationsAPI } from '../services/api.js'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(null)
|
||||||
|
const conversations = ref([])
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const total = ref(0)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedModel = ref('')
|
||||||
|
|
||||||
|
// 模态框状态
|
||||||
|
const showModal = ref(false)
|
||||||
|
const editingConversation = ref(null)
|
||||||
|
const formData = ref({
|
||||||
|
title: '',
|
||||||
|
model: 'glm-5',
|
||||||
|
system_prompt: '',
|
||||||
|
temperature: 1.0,
|
||||||
|
max_tokens: 65536,
|
||||||
|
thinking_enabled: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 删除模态框状态
|
||||||
|
const showDeleteModal = ref(false)
|
||||||
|
const deletingConversation = ref(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
|
||||||
|
|
||||||
|
// 获取会话列表
|
||||||
|
const fetchConversations = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery.value) {
|
||||||
|
params.search = searchQuery.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedModel.value) {
|
||||||
|
params.model = selectedModel.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await conversationsAPI.list(params)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
conversations.value = response.data.items || []
|
||||||
|
total.value = response.data.total || 0
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '获取会话列表失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取会话列表失败:', err)
|
||||||
|
error.value = err.message || '网络错误,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防抖搜索
|
||||||
|
let searchTimeout = null
|
||||||
|
const debouncedSearch = () => {
|
||||||
|
if (searchTimeout) clearTimeout(searchTimeout)
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
page.value = 1
|
||||||
|
fetchConversations()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const prevPage = () => {
|
||||||
|
if (page.value > 1) {
|
||||||
|
page.value--
|
||||||
|
fetchConversations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPage = () => {
|
||||||
|
if (page.value < totalPages.value) {
|
||||||
|
page.value++
|
||||||
|
fetchConversations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建会话
|
||||||
|
const createNewConversation = () => {
|
||||||
|
editingConversation.value = null
|
||||||
|
formData.value = {
|
||||||
|
title: '',
|
||||||
|
model: 'glm-5',
|
||||||
|
system_prompt: '',
|
||||||
|
temperature: 1.0,
|
||||||
|
max_tokens: 65536,
|
||||||
|
thinking_enabled: false
|
||||||
|
}
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑会话
|
||||||
|
const editConversation = (conv) => {
|
||||||
|
editingConversation.value = conv
|
||||||
|
formData.value = {
|
||||||
|
title: conv.title || '',
|
||||||
|
model: conv.model || 'glm-5',
|
||||||
|
system_prompt: conv.system_prompt || '',
|
||||||
|
temperature: conv.temperature || 1.0,
|
||||||
|
max_tokens: conv.max_tokens || 65536,
|
||||||
|
thinking_enabled: conv.thinking_enabled || false
|
||||||
|
}
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭模态框
|
||||||
|
const closeModal = () => {
|
||||||
|
showModal.value = false
|
||||||
|
editingConversation.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const submitForm = async () => {
|
||||||
|
try {
|
||||||
|
if (editingConversation.value) {
|
||||||
|
// 更新会话
|
||||||
|
const response = await conversationsAPI.update(
|
||||||
|
editingConversation.value.id,
|
||||||
|
formData.value
|
||||||
|
)
|
||||||
|
if (response.success) {
|
||||||
|
closeModal()
|
||||||
|
fetchConversations()
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '更新失败')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 创建会话
|
||||||
|
const response = await conversationsAPI.create(formData.value)
|
||||||
|
if (response.success) {
|
||||||
|
closeModal()
|
||||||
|
// 如果创建成功,可以跳转到新会话或刷新列表
|
||||||
|
fetchConversations()
|
||||||
|
// 或者跳转到新创建的会话详情
|
||||||
|
if (response.data?.id) {
|
||||||
|
openDetail(response.data.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '创建失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('提交失败:', err)
|
||||||
|
alert(err.message || '操作失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开详情
|
||||||
|
const openDetail = (id) => {
|
||||||
|
// 可以导航到详情页或打开详情模态框
|
||||||
|
console.log('打开会话:', id)
|
||||||
|
// 这里可以导航到会话详情页
|
||||||
|
// router.push(`/conversations/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认删除
|
||||||
|
const confirmDelete = (conv) => {
|
||||||
|
deletingConversation.value = conv
|
||||||
|
showDeleteModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDeleteModal = () => {
|
||||||
|
showDeleteModal.value = false
|
||||||
|
deletingConversation.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行删除
|
||||||
|
const deleteConversation = async () => {
|
||||||
|
if (!deletingConversation.value) return
|
||||||
|
|
||||||
|
deleting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await conversationsAPI.delete(deletingConversation.value.id)
|
||||||
|
if (response.success) {
|
||||||
|
closeDeleteModal()
|
||||||
|
fetchConversations()
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除失败:', err)
|
||||||
|
alert(err.message || '删除失败,请重试')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '未知时间'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - date
|
||||||
|
|
||||||
|
// 一天内显示相对时间
|
||||||
|
if (diff < 86400000) {
|
||||||
|
if (diff < 3600000) {
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
return `${minutes} 分钟前`
|
||||||
|
}
|
||||||
|
const hours = Math.floor(diff / 3600000)
|
||||||
|
return `${hours} 小时前`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超过一天显示具体日期
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
|
||||||
|
if (year === now.getFullYear()) {
|
||||||
|
return `${month}-${day} ${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchConversations()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.conversations {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-select {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态 */
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误状态 */
|
||||||
|
.error-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-container h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-container p {
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 会话列表 */
|
||||||
|
.conversation-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 4px 12px var(--shadow);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮样式 */
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-border);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分页 */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pagination {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pagination:hover:not(:disabled) {
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pagination:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模态框 */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-small {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body p {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-text {
|
||||||
|
color: #dc2626 !important;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单 */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-select,
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-select:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-actions {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,830 @@
|
||||||
|
<template>
|
||||||
|
<div class="home">
|
||||||
|
<!-- Hero 区域 -->
|
||||||
|
<div class="hero">
|
||||||
|
<div class="hero-content">
|
||||||
|
<h1>欢迎使用 Luxx</h1>
|
||||||
|
<p class="subtitle">智能会话管理与工具平台</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<router-link to="/conversations" class="btn-hero-primary">
|
||||||
|
<span>💬</span> 开始会话
|
||||||
|
</router-link>
|
||||||
|
<router-link to="/tools" class="btn-hero-secondary">
|
||||||
|
<span>🛠️</span> 查看工具
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 功能特性 -->
|
||||||
|
<div class="features">
|
||||||
|
<h2 class="section-title">核心功能</h2>
|
||||||
|
<div class="features-grid">
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">💬</div>
|
||||||
|
<h3>智能会话</h3>
|
||||||
|
<p>支持多模型对话,灵活配置参数,实时流式响应</p>
|
||||||
|
<router-link to="/conversations" class="feature-link">管理会话 →</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🛠️</div>
|
||||||
|
<h3>工具生态</h3>
|
||||||
|
<p>内置多种工具,支持代码执行、网页爬虫、数据分析</p>
|
||||||
|
<router-link to="/tools" class="feature-link">探索工具 →</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">🔐</div>
|
||||||
|
<h3>安全认证</h3>
|
||||||
|
<p>完整的用户认证系统,支持JWT Token授权</p>
|
||||||
|
<router-link to="/auth" class="feature-link">安全登录 →</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">📊</div>
|
||||||
|
<h3>数据分析</h3>
|
||||||
|
<p>强大的数据处理能力,实时监控系统状态</p>
|
||||||
|
<router-link to="/about" class="feature-link">了解更多 →</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计数据 -->
|
||||||
|
<div class="stats-section">
|
||||||
|
<h2 class="section-title">系统概览</h2>
|
||||||
|
<div v-if="!isLoggedIn" class="login-prompt">
|
||||||
|
<p>🔐 登录后查看更多统计信息</p>
|
||||||
|
<router-link to="/auth" class="btn-login-prompt">立即登录</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="loadingStats" class="loading-inline">
|
||||||
|
<div class="spinner-small"></div>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="statsError" class="stats-error">
|
||||||
|
<p>⚠️ 无法加载统计数据</p>
|
||||||
|
<button @click="fetchStats" class="btn-text">重试</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">💬</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stats.conversations || 0 }}</div>
|
||||||
|
<div class="stat-label">会话总数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">🛠️</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stats.tools || 0 }}</div>
|
||||||
|
<div class="stat-label">可用工具</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">📝</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stats.messages || 0 }}</div>
|
||||||
|
<div class="stat-label">消息总数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">🤖</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ stats.models || 1 }}</div>
|
||||||
|
<div class="stat-label">支持模型</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快捷操作 -->
|
||||||
|
<div class="quick-actions">
|
||||||
|
<h2 class="section-title">快捷操作</h2>
|
||||||
|
<div class="actions-grid">
|
||||||
|
<button @click="createConversation" class="action-card">
|
||||||
|
<div class="action-icon">➕</div>
|
||||||
|
<div class="action-content">
|
||||||
|
<h4>新建会话</h4>
|
||||||
|
<p>开始新的智能对话</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button @click="navigateToTools" class="action-card">
|
||||||
|
<div class="action-icon">🔍</div>
|
||||||
|
<div class="action-content">
|
||||||
|
<h4>浏览工具</h4>
|
||||||
|
<p>查看所有可用工具</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button @click="navigateToConversations" class="action-card">
|
||||||
|
<div class="action-icon">📋</div>
|
||||||
|
<div class="action-content">
|
||||||
|
<h4>会话历史</h4>
|
||||||
|
<p>查看历史会话记录</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button @click="checkStatus" class="action-card">
|
||||||
|
<div class="action-icon">📊</div>
|
||||||
|
<div class="action-content">
|
||||||
|
<h4>系统状态</h4>
|
||||||
|
<p>监控服务健康状态</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近活动 -->
|
||||||
|
<div class="recent-activity">
|
||||||
|
<h2 class="section-title">最近会话</h2>
|
||||||
|
<div v-if="!isLoggedIn" class="login-prompt">
|
||||||
|
<p>🔐 登录后查看最近会话</p>
|
||||||
|
<router-link to="/auth" class="btn-login-prompt">立即登录</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="loadingRecent" class="loading-inline">
|
||||||
|
<div class="spinner-small"></div>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="recentConversations.length === 0" class="empty-recent">
|
||||||
|
<p>暂无最近会话</p>
|
||||||
|
<button @click="createConversation" class="btn-hero-primary">创建第一个会话</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="recent-list">
|
||||||
|
<div
|
||||||
|
v-for="conv in recentConversations"
|
||||||
|
:key="conv.id"
|
||||||
|
class="recent-item"
|
||||||
|
@click="openConversation(conv.id)"
|
||||||
|
>
|
||||||
|
<div class="recent-icon">💬</div>
|
||||||
|
<div class="recent-info">
|
||||||
|
<h4>{{ conv.title || '未命名会话' }}</h4>
|
||||||
|
<p>{{ formatDate(conv.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="recent-arrow">→</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 页脚提示 -->
|
||||||
|
<div class="footer-note">
|
||||||
|
<div class="footer-content">
|
||||||
|
<p>🚀 正在运行 <strong>Luxx</strong> 智能会话系统</p>
|
||||||
|
<p class="footer-links">
|
||||||
|
<router-link to="/about">关于</router-link>
|
||||||
|
<span class="divider">•</span>
|
||||||
|
<a href="/api/docs" target="_blank">API 文档</a>
|
||||||
|
<span class="divider">•</span>
|
||||||
|
<router-link to="/auth">{{ isLoggedIn ? '个人中心' : '登录' }}</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { conversationsAPI, toolsAPI } from '../services/api.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const loadingStats = ref(false)
|
||||||
|
const loadingRecent = ref(false)
|
||||||
|
const statsError = ref(false)
|
||||||
|
const stats = ref({
|
||||||
|
conversations: 0,
|
||||||
|
tools: 0,
|
||||||
|
messages: 0,
|
||||||
|
models: 1
|
||||||
|
})
|
||||||
|
const recentConversations = ref([])
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => {
|
||||||
|
return !!localStorage.getItem('access_token')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取统计数据
|
||||||
|
const fetchStats = async () => {
|
||||||
|
if (!isLoggedIn.value) return
|
||||||
|
|
||||||
|
loadingStats.value = true
|
||||||
|
statsError.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 并行获取多个统计数据
|
||||||
|
const [convsRes, toolsRes] = await Promise.allSettled([
|
||||||
|
conversationsAPI.list({ page: 1, page_size: 1 }),
|
||||||
|
toolsAPI.list()
|
||||||
|
])
|
||||||
|
|
||||||
|
// 处理会话统计
|
||||||
|
if (convsRes.status === 'fulfilled' && convsRes.value.success) {
|
||||||
|
stats.value.conversations = convsRes.value.data?.total || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理工具统计
|
||||||
|
if (toolsRes.status === 'fulfilled' && toolsRes.value.success) {
|
||||||
|
const toolsData = toolsRes.value.data?.tools || toolsRes.value.data || []
|
||||||
|
stats.value.tools = Array.isArray(toolsData) ? toolsData.length : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 估算消息总数(根据会话数和平均消息数)
|
||||||
|
if (stats.value.conversations > 0) {
|
||||||
|
stats.value.messages = stats.value.conversations * 5 // 假设平均每个会话5条消息
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取统计数据失败:', err)
|
||||||
|
statsError.value = true
|
||||||
|
} finally {
|
||||||
|
loadingStats.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最近会话
|
||||||
|
const fetchRecentConversations = async () => {
|
||||||
|
if (!isLoggedIn.value) return
|
||||||
|
|
||||||
|
loadingRecent.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await conversationsAPI.list({ page: 1, page_size: 5 })
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
recentConversations.value = response.data?.items || []
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取最近会话失败:', err)
|
||||||
|
} finally {
|
||||||
|
loadingRecent.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新会话
|
||||||
|
const createConversation = async () => {
|
||||||
|
if (!isLoggedIn.value) {
|
||||||
|
router.push('/auth')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await conversationsAPI.create({
|
||||||
|
title: '',
|
||||||
|
model: 'glm-5'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success && response.data?.id) {
|
||||||
|
router.push(`/conversations/${response.data.id}`)
|
||||||
|
} else {
|
||||||
|
router.push('/conversations')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('创建会话失败:', err)
|
||||||
|
router.push('/conversations')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导航到工具页面
|
||||||
|
const navigateToTools = () => {
|
||||||
|
router.push('/tools')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导航到会话列表
|
||||||
|
const navigateToConversations = () => {
|
||||||
|
router.push('/conversations')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开会话
|
||||||
|
const openConversation = (id) => {
|
||||||
|
router.push(`/conversations/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查系统状态
|
||||||
|
const checkStatus = () => {
|
||||||
|
fetchStats()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '未知时间'
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - date
|
||||||
|
|
||||||
|
if (diff < 86400000) {
|
||||||
|
if (diff < 3600000) {
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
return `${minutes} 分钟前`
|
||||||
|
}
|
||||||
|
const hours = Math.floor(diff / 3600000)
|
||||||
|
return `${hours} 小时前`
|
||||||
|
}
|
||||||
|
|
||||||
|
const month = date.getMonth() + 1
|
||||||
|
const day = date.getDate()
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
|
||||||
|
if (date.getFullYear() === now.getFullYear()) {
|
||||||
|
return `${month}月${day}日 ${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
return `${date.getFullYear()}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchStats()
|
||||||
|
fetchRecentConversations()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home {
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero 区域 */
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||||
|
animation: pulse 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 0.5; }
|
||||||
|
50% { transform: scale(1.1); opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: white;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin: 0 0 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hero-primary,
|
||||||
|
.btn-hero-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hero-primary {
|
||||||
|
background: white;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hero-primary:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hero-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hero-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 区域标题 */
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 功能特性 */
|
||||||
|
.features {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 10px 30px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card p {
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-link:hover {
|
||||||
|
color: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计数据 */
|
||||||
|
.stats-section {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-prompt {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-prompt p {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login-prompt {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login-prompt:hover {
|
||||||
|
background: var(--accent-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 5px 20px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-h);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #dc2626;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 快捷操作 */
|
||||||
|
.quick-actions {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover .action-icon {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card:hover .action-icon {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-content h4 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-content p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 最近会话 */
|
||||||
|
.recent-activity {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-recent {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-recent p {
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-item:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateX(5px);
|
||||||
|
box-shadow: 0 5px 15px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-info h4 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-info p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-arrow {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-item:hover .recent-arrow {
|
||||||
|
transform: translateX(5px);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页脚 */
|
||||||
|
.footer-note {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content p {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content strong {
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a:hover {
|
||||||
|
color: var(--accent-border);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links .divider {
|
||||||
|
color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero {
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-hero-primary,
|
||||||
|
.btn-hero-secondary {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid,
|
||||||
|
.stats-grid,
|
||||||
|
.actions-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.home {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,937 @@
|
||||||
|
<template>
|
||||||
|
<div class="tools">
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h1>工具管理</h1>
|
||||||
|
<p class="subtitle">管理系统内置工具,启用或禁用各项功能</p>
|
||||||
|
</div>
|
||||||
|
<button @click="refreshTools" class="btn-secondary" :disabled="loading">
|
||||||
|
<span class="icon">🔄</span> 刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 统计概览 -->
|
||||||
|
<div class="stats-overview">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">🛠️</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ totalTools }}</div>
|
||||||
|
<div class="stat-label">工具总数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon enabled">✓</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ enabledTools }}</div>
|
||||||
|
<div class="stat-label">已启用</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon disabled">✗</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">{{ totalTools - enabledTools }}</div>
|
||||||
|
<div class="stat-label">已禁用</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分类筛选 -->
|
||||||
|
<div class="category-tabs">
|
||||||
|
<button
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="cat.key"
|
||||||
|
:class="['tab', { active: selectedCategory === cat.key }]"
|
||||||
|
@click="selectedCategory = cat.key; fetchTools()"
|
||||||
|
>
|
||||||
|
<span class="tab-icon">{{ cat.icon }}</span>
|
||||||
|
{{ cat.label }}
|
||||||
|
<span class="tab-count">{{ getCategoryCount(cat.key) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="loading-container">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>正在加载工具列表...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<div v-else-if="error" class="error-container">
|
||||||
|
<div class="error-icon">⚠️</div>
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
<button @click="fetchTools" class="btn-secondary">重试</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else-if="filteredTools.length === 0" class="empty-container">
|
||||||
|
<div class="empty-icon">🔧</div>
|
||||||
|
<h3>暂无工具</h3>
|
||||||
|
<p>该分类下没有可用工具</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工具列表 -->
|
||||||
|
<div v-else class="tools-grid">
|
||||||
|
<div
|
||||||
|
v-for="tool in filteredTools"
|
||||||
|
:key="tool.name"
|
||||||
|
class="tool-card"
|
||||||
|
:class="{ disabled: !tool.enabled }"
|
||||||
|
>
|
||||||
|
<div class="tool-header">
|
||||||
|
<div class="tool-icon">{{ getToolIcon(tool.name) }}</div>
|
||||||
|
<div class="tool-badge" :class="tool.enabled ? 'badge-enabled' : 'badge-disabled'">
|
||||||
|
{{ tool.enabled ? '已启用' : '已禁用' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="tool-name">{{ tool.name }}</h3>
|
||||||
|
<p class="tool-description">{{ tool.description || '暂无描述' }}</p>
|
||||||
|
|
||||||
|
<div v-if="tool.parameters" class="tool-params">
|
||||||
|
<span class="params-label">参数:</span>
|
||||||
|
<code>{{ Object.keys(tool.parameters).join(', ') }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-footer">
|
||||||
|
<button
|
||||||
|
@click="toggleTool(tool)"
|
||||||
|
class="btn-toggle"
|
||||||
|
:class="tool.enabled ? 'btn-disable' : 'btn-enable'"
|
||||||
|
>
|
||||||
|
{{ tool.enabled ? '禁用' : '启用' }}
|
||||||
|
</button>
|
||||||
|
<button @click="viewToolDetail(tool)" class="btn-icon" title="查看详情">
|
||||||
|
ℹ️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 工具详情模态框 -->
|
||||||
|
<div v-if="showDetailModal" class="modal-overlay" @click.self="closeDetailModal">
|
||||||
|
<div class="modal modal-large">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{{ selectedTool?.name }}</h2>
|
||||||
|
<button @click="closeDetailModal" class="btn-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4>基本信息</h4>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">名称</span>
|
||||||
|
<span class="detail-value">{{ selectedTool?.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">状态</span>
|
||||||
|
<span :class="['status-badge', selectedTool?.enabled ? 'enabled' : 'disabled']">
|
||||||
|
{{ selectedTool?.enabled ? '已启用' : '已禁用' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">分类</span>
|
||||||
|
<span class="detail-value">{{ getToolCategory(selectedTool?.name) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">版本</span>
|
||||||
|
<span class="detail-value">{{ selectedTool?.version || '1.0.0' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h4>描述</h4>
|
||||||
|
<p class="description-text">{{ selectedTool?.description || '暂无描述' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedTool?.parameters" class="detail-section">
|
||||||
|
<h4>参数说明</h4>
|
||||||
|
<table class="params-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>参数名</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>必填</th>
|
||||||
|
<th>描述</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(param, key) in selectedTool.parameters" :key="key">
|
||||||
|
<td><code>{{ key }}</code></td>
|
||||||
|
<td><span class="type-badge">{{ param.type || 'string' }}</span></td>
|
||||||
|
<td>{{ param.required ? '是' : '否' }}</td>
|
||||||
|
<td>{{ param.description || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedTool?.examples" class="detail-section">
|
||||||
|
<h4>使用示例</h4>
|
||||||
|
<div class="example-box">
|
||||||
|
<pre><code>{{ selectedTool.examples }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button @click="closeDetailModal" class="btn-secondary">关闭</button>
|
||||||
|
<button
|
||||||
|
@click="toggleTool(selectedTool)"
|
||||||
|
class="btn-primary"
|
||||||
|
:class="selectedTool?.enabled ? 'btn-disable' : 'btn-enable'"
|
||||||
|
>
|
||||||
|
{{ selectedTool?.enabled ? '禁用工具' : '启用工具' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { toolsAPI } from '../services/api.js'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(null)
|
||||||
|
const tools = ref([])
|
||||||
|
const selectedCategory = ref('all')
|
||||||
|
const showDetailModal = ref(false)
|
||||||
|
const selectedTool = ref(null)
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ key: 'all', label: '全部', icon: '📦' },
|
||||||
|
{ key: 'code', label: '代码', icon: '💻' },
|
||||||
|
{ key: 'crawler', label: '爬虫', icon: '🕷️' },
|
||||||
|
{ key: 'data', label: '数据', icon: '📊' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const totalTools = computed(() => tools.value.length)
|
||||||
|
const enabledTools = computed(() => tools.value.filter(t => t.enabled).length)
|
||||||
|
|
||||||
|
const filteredTools = computed(() => {
|
||||||
|
if (selectedCategory.value === 'all') {
|
||||||
|
return tools.value
|
||||||
|
}
|
||||||
|
return tools.value.filter(tool => {
|
||||||
|
const category = getToolCategory(tool.name)
|
||||||
|
return category === selectedCategory.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const getCategoryCount = (category) => {
|
||||||
|
if (category === 'all') return tools.value.length
|
||||||
|
return tools.value.filter(tool => getToolCategory(tool.name) === category).length
|
||||||
|
}
|
||||||
|
|
||||||
|
const getToolCategory = (name) => {
|
||||||
|
if (!name) return 'other'
|
||||||
|
const lowerName = name.toLowerCase()
|
||||||
|
if (lowerName.includes('code') || lowerName.includes('python') || lowerName.includes('javascript')) {
|
||||||
|
return 'code'
|
||||||
|
}
|
||||||
|
if (lowerName.includes('crawl') || lowerName.includes('web') || lowerName.includes('fetch')) {
|
||||||
|
return 'crawler'
|
||||||
|
}
|
||||||
|
if (lowerName.includes('data') || lowerName.includes('analys')) {
|
||||||
|
return 'data'
|
||||||
|
}
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getToolIcon = (name) => {
|
||||||
|
if (!name) return '🔧'
|
||||||
|
const lowerName = name.toLowerCase()
|
||||||
|
if (lowerName.includes('code')) return '💻'
|
||||||
|
if (lowerName.includes('crawl')) return '🕷️'
|
||||||
|
if (lowerName.includes('data')) return '📊'
|
||||||
|
if (lowerName.includes('weather')) return '🌤️'
|
||||||
|
if (lowerName.includes('search')) return '🔍'
|
||||||
|
return '🛠️'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取工具列表
|
||||||
|
const fetchTools = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {}
|
||||||
|
if (selectedCategory.value !== 'all') {
|
||||||
|
params.category = selectedCategory.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await toolsAPI.list(params)
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// 处理返回的数据
|
||||||
|
const toolsData = response.data.tools || response.data || []
|
||||||
|
|
||||||
|
// 如果返回的是分类格式,转换为数组
|
||||||
|
if (response.data.categorized) {
|
||||||
|
const categorized = response.data.categorized
|
||||||
|
const allTools = []
|
||||||
|
Object.keys(categorized).forEach(cat => {
|
||||||
|
if (Array.isArray(categorized[cat])) {
|
||||||
|
allTools.push(...categorized[cat].map(tool => ({
|
||||||
|
...tool,
|
||||||
|
category: cat
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
tools.value = allTools
|
||||||
|
} else {
|
||||||
|
tools.value = Array.isArray(toolsData) ? toolsData : []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '获取工具列表失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取工具列表失败:', err)
|
||||||
|
error.value = err.message || '网络错误,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换工具状态
|
||||||
|
const toggleTool = async (tool) => {
|
||||||
|
if (!tool) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await toolsAPI.execute(tool.name, {
|
||||||
|
action: tool.enabled ? 'disable' : 'enable'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// 更新本地状态
|
||||||
|
tool.enabled = !tool.enabled
|
||||||
|
// 如果详情模态框打开,更新详情
|
||||||
|
if (selectedTool.value?.name === tool.name) {
|
||||||
|
selectedTool.value.enabled = tool.enabled
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message || '操作失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('切换工具状态失败:', err)
|
||||||
|
alert(err.message || '操作失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看工具详情
|
||||||
|
const viewToolDetail = async (tool) => {
|
||||||
|
selectedTool.value = { ...tool }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await toolsAPI.get(tool.name)
|
||||||
|
if (response.success) {
|
||||||
|
selectedTool.value = { ...selectedTool.value, ...response.data }
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取工具详情失败:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
showDetailModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDetailModal = () => {
|
||||||
|
showDetailModal.value = false
|
||||||
|
selectedTool.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshTools = () => {
|
||||||
|
fetchTools()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchTools()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tools {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计概览 */
|
||||||
|
.stats-overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon.enabled {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon.disabled {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-h);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分类标签 */
|
||||||
|
.category-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-count {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:not(.active) .tab-count {
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态 */
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 错误状态 */
|
||||||
|
.error-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
background: #fef2f2;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.empty-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-container h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具网格 */
|
||||||
|
.tools-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 4px 12px var(--shadow);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card.disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-enabled {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-disabled {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-description {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-params {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-label {
|
||||||
|
color: var(--text);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-params code {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮样式 */
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-border);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-enable {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-enable:hover {
|
||||||
|
background: #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-disable {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-disable:hover {
|
||||||
|
background: #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模态框 */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-large {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详情部分 */
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section h4 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.enabled {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.disabled {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-text {
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 参数表格 */
|
||||||
|
.params-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-table th,
|
||||||
|
.params-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-table th {
|
||||||
|
background: var(--code-bg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-table td {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.params-table code {
|
||||||
|
background: var(--code-bg);
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
background: var(--accent-bg);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-box {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-box pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-box code {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-overview {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tabs {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
rewrite: (path) => path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: '../luxx/static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'assets/[name].[hash].js',
|
||||||
|
chunkFileNames: 'assets/[name].[hash].js',
|
||||||
|
assetFileNames: 'assets/[name].[hash].[ext]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -11,7 +11,30 @@ from luxx.routes import api_router
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan manager"""
|
"""Application lifespan manager"""
|
||||||
|
# Import all models to ensure they are registered with Base
|
||||||
|
from luxx import models # noqa
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
# Create default test user if not exists
|
||||||
|
from luxx.database import SessionLocal
|
||||||
|
from luxx.models import User
|
||||||
|
from luxx.utils.helpers import hash_password
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
default_user = db.query(User).filter(User.username == "admin").first()
|
||||||
|
if not default_user:
|
||||||
|
default_user = User(
|
||||||
|
username="admin",
|
||||||
|
password_hash=hash_password("admin123"),
|
||||||
|
role="admin"
|
||||||
|
)
|
||||||
|
db.add(default_user)
|
||||||
|
db.commit()
|
||||||
|
print("Default admin user created: admin / admin123")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
from luxx.tools.builtin import crawler, code, data
|
from luxx.tools.builtin import crawler, code, data
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,9 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from sqlalchemy import String, Integer, Boolean, Float, Text, DateTime, ForeignKey
|
from sqlalchemy import String, Integer, Boolean, Float, Text, DateTime, ForeignKey
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, DeclarativeBase
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from luxx.database import Base
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Project(Base):
|
class Project(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Authentication routes"""
|
"""Authentication routes"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from fastapi import APIRouter, Depends, status
|
from fastapi import APIRouter, Depends, status, HTTPException
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -56,13 +56,13 @@ def get_current_user(
|
||||||
"""Get current user"""
|
"""Get current user"""
|
||||||
payload = decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
raise status.HTTP_401_UNAUTHORIZED
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise status.HTTP_401_UNAUTHORIZED
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
user = db.query(User).filter(User.id == int(user_id)).first()
|
user = db.query(User).filter(User.id == int(user_id)).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise status.HTTP_401_UNAUTHORIZED
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue