feat: 增加前端

This commit is contained in:
ViperEkura 2026-04-12 17:31:52 +08:00
parent 72a3738388
commit 3ddc8c52ad
38 changed files with 6977 additions and 11 deletions

5
.gitignore vendored
View File

@ -10,4 +10,7 @@
!.gitignore
!luxx/**/*.py
!docs/**/*.md
!asserts/**/*.md
# Dashboard
!dashboard/**/*

View File

@ -1,13 +1,13 @@
# 配置文件
app:
secret_key: ${APP_SECRET_KEY}
debug: true
debug: false
host: 0.0.0.0
port: 8000
database:
type: sqlite
url: sqlite:///./chat.db
url: sqlite:///../chat.db
llm:
provider: deepseek

24
dashboard/.gitignore vendored Normal file
View File

@ -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?

5
dashboard/README.md Normal file
View File

@ -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).

13
dashboard/index.html Normal file
View File

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

1523
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
dashboard/package.json Normal file
View File

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

View File

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

594
dashboard/src/App.vue Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
}

View File

@ -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'
}

View File

@ -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
}
}
}

9
dashboard/src/index.js Normal file
View File

@ -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'

10
dashboard/src/main.js Normal file
View File

@ -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')

View File

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

View File

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

View File

@ -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 }
}
}
}
})

View File

@ -0,0 +1,8 @@
import { createPinia } from 'pinia'
const pinia = createPinia()
export default pinia
// 方便导入 store
export * from './auth'

294
dashboard/src/style.css Normal file
View File

@ -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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

28
dashboard/vite.config.js Normal file
View File

@ -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]'
}
}
}
})

View File

@ -11,7 +11,30 @@ from luxx.routes import api_router
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager"""
# Import all models to ensure they are registered with Base
from luxx import models # noqa
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
yield

View File

@ -2,11 +2,9 @@
from datetime import datetime
from typing import Optional, List
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
class Base(DeclarativeBase):
pass
from luxx.database import Base
class Project(Base):

View File

@ -1,6 +1,6 @@
"""Authentication routes"""
from datetime import timedelta
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, status, HTTPException
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from pydantic import BaseModel
@ -56,13 +56,13 @@ def get_current_user(
"""Get current user"""
payload = decode_access_token(token)
if not payload:
raise status.HTTP_401_UNAUTHORIZED
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user_id = payload.get("sub")
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()
if not user:
raise status.HTTP_401_UNAUTHORIZED
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user