feat: 增加前端

This commit is contained in:
ViperEkura 2026-04-12 17:19:03 +08:00
parent 72a3738388
commit 43e42094ec
31 changed files with 2943 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

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

@ -0,0 +1,114 @@
<script setup>
//
</script>
<template>
<div id="app">
<nav class="navbar">
<div class="navbar-brand">Luxx Dashboard</div>
<div class="navbar-links">
<router-link to="/">首页</router-link>
<router-link to="/about">关于</router-link>
<router-link to="/conversations">会话</router-link>
<router-link to="/tools">工具</router-link>
<router-link to="/auth">登录</router-link>
</div>
</nav>
<div class="container">
<router-view />
</div>
</div>
</template>
<style>
#app {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background-color: #2c3e50;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.navbar-brand {
font-size: 1.5rem;
font-weight: bold;
}
.navbar-links {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.navbar-links a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.3s;
}
.navbar-links a:hover {
background-color: #34495e;
}
.container {
padding: 2rem;
flex: 1;
max-width: 1200px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
/* 响应式设计 */
@media (max-width: 768px) {
.navbar {
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.navbar-links {
width: 100%;
justify-content: center;
}
.navbar-links a {
margin: 0;
padding: 0.5rem;
font-size: 0.9rem;
}
.container {
padding: 1rem;
}
}
@media (max-width: 480px) {
.navbar-brand {
font-size: 1.2rem;
}
.navbar-links {
flex-direction: column;
align-items: center;
}
.navbar-links a {
width: 100%;
text-align: center;
}
}
</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,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>

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,38 @@
import axios from 'axios'
// 创建 axios 实例
const api = axios.create({
baseURL: '/api',
timeout: 10000,
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) {
// 未授权,跳转到登录页
window.location.href = '/auth'
}
return Promise.reject(error.response?.data || error.message)
}
)
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'

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

@ -0,0 +1,296 @@
: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: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
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,172 @@
<template>
<div class="auth">
<h1>用户认证</h1>
<div class="tabs">
<button @click="activeTab = 'login'" :class="{ active: activeTab === 'login' }">登录</button>
<button @click="activeTab = 'register'" :class="{ active: activeTab === 'register' }">注册</button>
</div>
<div v-if="activeTab === 'login'" class="form-container">
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="login-username">用户名</label>
<input v-model="loginForm.username" id="login-username" type="text" required />
</div>
<div class="form-group">
<label for="login-password">密码</label>
<input v-model="loginForm.password" id="login-password" type="password" required />
</div>
<button type="submit" :disabled="loading">登录</button>
<p v-if="loginError" class="error">{{ loginError }}</p>
</form>
</div>
<div v-else class="form-container">
<form @submit.prevent="handleRegister">
<div class="form-group">
<label for="register-username">用户名</label>
<input v-model="registerForm.username" id="register-username" type="text" required />
</div>
<div class="form-group">
<label for="register-email">邮箱</label>
<input v-model="registerForm.email" id="register-email" type="email" required />
</div>
<div class="form-group">
<label for="register-password">密码</label>
<input v-model="registerForm.password" id="register-password" type="password" required />
</div>
<button type="submit" :disabled="loading">注册</button>
<p v-if="registerError" class="error">{{ registerError }}</p>
</form>
</div>
<div v-if="authSuccess" class="success">
认证成功正在跳转...
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const activeTab = ref('login')
const loading = ref(false)
const loginError = ref('')
const registerError = ref('')
const authSuccess = ref(false)
const loginForm = ref({
username: '',
password: ''
})
const registerForm = ref({
username: '',
email: '',
password: ''
})
// API
const handleLogin = async () => {
loading.value = true
loginError.value = ''
// TODO: API
await new Promise(resolve => setTimeout(resolve, 800))
if (loginForm.value.username && loginForm.value.password) {
authSuccess.value = true
// token
} else {
loginError.value = '用户名或密码错误'
}
loading.value = false
}
// API
const handleRegister = async () => {
loading.value = true
registerError.value = ''
// TODO: API
await new Promise(resolve => setTimeout(resolve, 800))
if (registerForm.value.username && registerForm.value.email && registerForm.value.password) {
authSuccess.value = true
//
} else {
registerError.value = '注册失败,请检查输入'
}
loading.value = false
}
</script>
<style scoped>
.auth {
padding: 2rem;
max-width: 500px;
margin: 0 auto;
}
.tabs {
display: flex;
margin-bottom: 2rem;
border-bottom: 1px solid #ddd;
}
.tabs button {
padding: 0.75rem 1.5rem;
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
}
.tabs button.active {
border-bottom: 3px solid #2c3e50;
font-weight: bold;
}
.form-container {
margin-top: 1rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
button[type="submit"] {
padding: 0.75rem 1.5rem;
background-color: #2c3e50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button[type="submit"]:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.error {
color: #e74c3c;
margin-top: 1rem;
}
.success {
color: #27ae60;
margin-top: 1rem;
}
</style>

View File

@ -0,0 +1,101 @@
<template>
<div class="conversations">
<h1>会话管理</h1>
<p>列出所有会话支持分页和筛选</p>
<div v-if="loading">加载中...</div>
<div v-else>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>标题</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="conv in conversations" :key="conv.id">
<td>{{ conv.id }}</td>
<td>{{ conv.title }}</td>
<td>{{ conv.created_at }}</td>
<td>
<button @click="openDetail(conv.id)">查看</button>
</td>
</tr>
</tbody>
</table>
<div class="pagination">
<button @click="prevPage" :disabled="page === 1">上一页</button>
<span> {{ page }} </span>
<button @click="nextPage">下一页</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(true)
const conversations = ref([])
const page = ref(1)
const pageSize = 20
//
const fetchConversations = async () => {
loading.value = true
// TODO: API
await new Promise(resolve => setTimeout(resolve, 500))
conversations.value = [
{ id: 1, title: '测试会话1', created_at: '2026-01-01 10:00' },
{ id: 2, title: '测试会话2', created_at: '2026-01-02 11:00' },
{ id: 3, title: '测试会话3', created_at: '2026-01-03 12:00' },
]
loading.value = false
}
const prevPage = () => {
if (page.value > 1) {
page.value--
fetchConversations()
}
}
const nextPage = () => {
page.value++
fetchConversations()
}
const openDetail = (id) => {
alert(`打开会话 ${id}`)
}
onMounted(() => {
fetchConversations()
})
</script>
<style scoped>
.conversations {
padding: 2rem;
}
.table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.table th, .table td {
border: 1px solid #ddd;
padding: 0.75rem;
text-align: left;
}
.pagination {
margin-top: 1rem;
display: flex;
gap: 1rem;
align-items: center;
}
</style>

View File

@ -0,0 +1,214 @@
<template>
<div class="home">
<div class="hero">
<h1>Luxx 管理仪表板</h1>
<p class="subtitle">高效管理您的会话工具和用户认证</p>
</div>
<div class="features">
<div class="card">
<div class="card-icon">💬</div>
<h3>会话管理</h3>
<p>查看搜索和过滤所有会话记录支持分页和详细查看</p>
<router-link to="/conversations" class="card-link">前往管理</router-link>
</div>
<div class="card">
<div class="card-icon">🛠</div>
<h3>工具管理</h3>
<p>启用或禁用系统内置工具监控工具运行状态</p>
<router-link to="/tools" class="card-link">前往管理</router-link>
</div>
<div class="card">
<div class="card-icon">🔐</div>
<h3>用户认证</h3>
<p>管理员和用户的登录注册及权限管理</p>
<router-link to="/auth" class="card-link">前往管理</router-link>
</div>
</div>
<div class="quick-stats">
<h2>快速概览</h2>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">12</div>
<div class="stat-label">活跃会话</div>
</div>
<div class="stat-item">
<div class="stat-value">5</div>
<div class="stat-label">启用工具</div>
</div>
<div class="stat-item">
<div class="stat-value">3</div>
<div class="stat-label">在线用户</div>
</div>
<div class="stat-item">
<div class="stat-value">98%</div>
<div class="stat-label">系统健康度</div>
</div>
</div>
</div>
<div class="footer-note">
<p>需要帮助请查看 <router-link to="/about">关于页面</router-link> API </p>
</div>
</div>
</template>
<script setup>
//
</script>
<style scoped>
.home {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.hero {
text-align: center;
margin-bottom: 3rem;
padding: 2rem;
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
color: white;
border-radius: 16px;
}
.hero h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 1.2rem;
opacity: 0.9;
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 2rem;
margin-bottom: 3rem;
}
.card {
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: transform 0.3s, box-shadow 0.3s;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.card-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.card h3 {
font-size: 1.5rem;
margin-bottom: 0.75rem;
}
.card p {
color: #555;
line-height: 1.5;
flex-grow: 1;
margin-bottom: 1.5rem;
}
.card-link {
display: inline-block;
padding: 0.75rem 1.5rem;
background-color: #2c3e50;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
transition: background-color 0.3s;
}
.card-link:hover {
background-color: #34495e;
}
.quick-stats {
margin-bottom: 3rem;
}
.quick-stats h2 {
font-size: 1.8rem;
margin-bottom: 1.5rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
}
.stat-item {
background: #f8f9fa;
border-radius: 10px;
padding: 1.5rem;
text-align: center;
border: 1px solid #e9ecef;
}
.stat-value {
font-size: 2.5rem;
font-weight: bold;
color: #2c3e50;
}
.stat-label {
color: #6c757d;
font-size: 0.95rem;
}
.footer-note {
text-align: center;
padding: 2rem;
background-color: #f1f8ff;
border-radius: 12px;
color: #495057;
}
.footer-note a {
color: #2575fc;
text-decoration: none;
font-weight: 500;
}
/* 响应式 */
@media (max-width: 768px) {
.hero h1 {
font-size: 2rem;
}
.features {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.home {
padding: 1rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,106 @@
<template>
<div class="tools">
<h1>工具管理</h1>
<p>系统内置工具列表支持启用/禁用</p>
<div v-if="loading">加载中...</div>
<div v-else>
<table class="table">
<thead>
<tr>
<th>名称</th>
<th>描述</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="tool in tools" :key="tool.name">
<td>{{ tool.name }}</td>
<td>{{ tool.description }}</td>
<td>
<span :class="['badge', tool.enabled ? 'enabled' : 'disabled']">
{{ tool.enabled ? '已启用' : '已禁用' }}
</span>
</td>
<td>
<button @click="toggleTool(tool)" class="btn-toggle">
{{ tool.enabled ? '禁用' : '启用' }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(true)
const tools = ref([])
//
const fetchTools = async () => {
loading.value = true
// TODO: API
await new Promise(resolve => setTimeout(resolve, 500))
tools.value = [
{ name: 'code', description: '代码执行工具', enabled: true },
{ name: 'crawler', description: '网页爬虫工具', enabled: true },
{ name: 'data', description: '数据分析工具', enabled: false },
]
loading.value = false
}
const toggleTool = (tool) => {
tool.enabled = !tool.enabled
// TODO: API
}
onMounted(() => {
fetchTools()
})
</script>
<style scoped>
.tools {
padding: 2rem;
}
.table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.table th, .table td {
border: 1px solid #ddd;
padding: 0.75rem;
text-align: left;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.badge.enabled {
background-color: #d4edda;
color: #155724;
}
.badge.disabled {
background-color: #f8d7da;
color: #721c24;
}
.btn-toggle {
padding: 0.25rem 0.75rem;
border-radius: 4px;
border: 1px solid #ccc;
background-color: #f8f9fa;
cursor: pointer;
}
</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