feat: 增加前端

This commit is contained in:
ViperEkura 2026-04-12 16:49:38 +08:00
parent 72a3738388
commit 4fcad51b83
22 changed files with 2710 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="zh-CN">
<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>Luxx Dashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1387
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
dashboard/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.15.0",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.4"
}
}

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

@ -0,0 +1,162 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import Sidebar from './components/Sidebar.vue'
import ChatArea from './components/ChatArea.vue'
import { conversationsAPI, authAPI } from './services/api'
const router = useRouter()
const currentConversation = ref(null)
const conversations = ref([])
const isLoading = ref(false)
const sidebarOpen = ref(true)
const currentUser = ref(null)
const selectConversation = (conv) => {
currentConversation.value = conv
}
const createNewConversation = async () => {
try {
const response = await conversationsAPI.create({
title: '新对话',
model: 'deepseek-chat'
})
if (response.data.success) {
conversations.value.unshift(response.data.data)
currentConversation.value = response.data.data
}
} catch (error) {
console.error('Failed to create conversation:', error)
}
}
const deleteConversation = async (id) => {
try {
await conversationsAPI.delete(id)
conversations.value = conversations.value.filter(c => c.id !== id)
if (currentConversation.value?.id === id) {
currentConversation.value = null
}
} catch (error) {
console.error('Failed to delete conversation:', error)
}
}
const handleLogout = async () => {
try {
await authAPI.logout()
} catch (e) {
// Ignore
}
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
}
onMounted(async () => {
const token = localStorage.getItem('token')
console.log('Token from localStorage:', token ? token.substring(0, 30) + '...' : 'none')
if (!token) {
router.push('/login')
return
}
try {
// Verify token by calling debug endpoint
console.log('Making debug request with token...')
const debugResponse = await fetch('/api/auth/debug', {
headers: { 'Authorization': 'Bearer ' + token }
})
console.log('Debug response status:', debugResponse.status)
if (!debugResponse.ok) {
const errorText = await debugResponse.text()
console.error('Debug error:', errorText)
throw new Error('Invalid token')
}
const debugData = await debugResponse.json()
console.log('Debug data:', debugData)
const userStr = localStorage.getItem('user')
if (userStr) {
currentUser.value = JSON.parse(userStr)
}
const response = await conversationsAPI.list({ page: 1, page_size: 50 })
if (response.data.success) {
conversations.value = response.data.data.conversations
}
} catch (error) {
console.error('Failed to load conversations:', error)
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
}
})
</script>
<template>
<div class="app-container">
<Sidebar
:conversations="conversations"
:currentConversation="currentConversation"
:isOpen="sidebarOpen"
@select="selectConversation"
@create="createNewConversation"
@delete="deleteConversation"
/>
<ChatArea
:conversation="currentConversation"
@toggle-sidebar="sidebarOpen = !sidebarOpen"
/>
<div class="user-menu">
<span class="username">{{ currentUser?.username || '用户' }}</span>
<button class="btn-logout" @click="handleLogout">登出</button>
</div>
</div>
</template>
<style scoped>
.app-container {
display: flex;
height: 100vh;
width: 100vw;
overflow: hidden;
background-color: var(--bg-primary);
position: relative;
}
.user-menu {
position: absolute;
top: 16px;
right: 16px;
display: flex;
align-items: center;
gap: 12px;
z-index: 100;
}
.username {
font-size: 14px;
color: var(--text-secondary);
}
.btn-logout {
padding: 6px 12px;
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.btn-logout:hover {
color: var(--danger-color);
border-color: var(--danger-color);
}
</style>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,392 @@
<script setup>
import { ref, watch, nextTick, computed } from 'vue'
import { messagesAPI } from '../services/api'
const props = defineProps({
conversation: {
type: Object,
default: null
}
})
const emit = defineEmits(['toggle-sidebar'])
const messages = ref([])
const inputMessage = ref('')
const isLoading = ref(false)
const messagesContainer = ref(null)
const canSend = computed(() => {
return props.conversation && inputMessage.value.trim() && !isLoading.value
})
const loadMessages = async () => {
if (!props.conversation) {
messages.value = []
return
}
try {
const response = await messagesAPI.list(props.conversation.id)
if (response.data.success) {
messages.value = response.data.data.messages
scrollToBottom()
}
} catch (error) {
console.error('Failed to load messages:', error)
}
}
const sendMessage = async () => {
if (!canSend.value) return
const content = inputMessage.value.trim()
inputMessage.value = ''
isLoading.value = true
try {
const response = await messagesAPI.send({
conversation_id: props.conversation.id,
content: content
})
if (response.data.success) {
messages.value.push(response.data.data.user_message)
messages.value.push(response.data.data.assistant_message)
scrollToBottom()
}
} catch (error) {
console.error('Failed to send message:', error)
} finally {
isLoading.value = false
}
}
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
const handleKeydown = (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
sendMessage()
}
}
watch(() => props.conversation, () => {
loadMessages()
}, { immediate: true })
</script>
<template>
<div class="chat-area">
<!-- Header -->
<header class="chat-header">
<button class="btn-toggle-sidebar" @click="emit('toggle-sidebar')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<div class="conversation-title" v-if="conversation">
<span>{{ conversation.title || '新对话' }}</span>
</div>
<div class="conversation-title placeholder" v-else>
选择一个对话开始聊天
</div>
</header>
<!-- Messages -->
<div class="messages-container" ref="messagesContainer">
<!-- Welcome State -->
<div v-if="!conversation" class="welcome-state">
<div class="welcome-icon">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<h2>欢迎使用 Luxx</h2>
<p>选择一个对话或创建新对话开始聊天</p>
</div>
<!-- Message List -->
<div v-else class="messages-list">
<div v-for="msg in messages" :key="msg.id" class="message" :class="{ 'message-user': msg.role === 'user', 'message-assistant': msg.role === 'assistant' }">
<div class="message-avatar">
<svg v-if="msg.role === 'user'" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
<svg v-else width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"></path>
<path d="M12 6v6l4 2"></path>
</svg>
</div>
<div class="message-content">
<pre>{{ msg.content }}</pre>
</div>
</div>
<!-- Loading -->
<div v-if="isLoading" class="message message-assistant">
<div class="message-avatar">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"></path>
<path d="M12 6v6l4 2"></path>
</svg>
</div>
<div class="message-content">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
</div>
<!-- Input -->
<div class="input-container">
<form class="input-form" @submit.prevent="sendMessage">
<textarea
v-model="inputMessage"
placeholder="输入消息... (Shift+Enter 换行)"
:disabled="!conversation || isLoading"
@keydown="handleKeydown"
rows="1"
></textarea>
<button type="submit" class="btn-send" :disabled="!canSend">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</form>
</div>
</div>
</template>
<style scoped>
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-primary);
}
.chat-header {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.btn-toggle-sidebar {
padding: 8px;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary);
border-radius: 8px;
transition: background-color 0.2s;
}
.btn-toggle-sidebar:hover {
background-color: var(--bg-hover);
}
.conversation-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.conversation-title.placeholder {
color: var(--text-secondary);
font-weight: 400;
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.welcome-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
}
.welcome-icon {
margin-bottom: 16px;
color: var(--accent-color);
}
.welcome-state h2 {
margin: 0 0 8px;
font-size: 24px;
color: var(--text-primary);
}
.welcome-state p {
margin: 0;
font-size: 14px;
}
.messages-list {
display: flex;
flex-direction: column;
gap: 24px;
}
.message {
display: flex;
gap: 12px;
}
.message-content {
flex: 1;
}
.message-content pre {
margin: 0;
font-family: inherit;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
}
.message-user .message-content {
background-color: var(--bg-secondary);
padding: 12px 16px;
border-radius: 12px;
}
.message-assistant .message-content {
background-color: transparent;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.message-user .message-avatar {
background-color: var(--accent-light);
color: var(--accent-color);
}
.message-assistant .message-avatar {
background-color: var(--bg-secondary);
color: var(--text-secondary);
}
.typing-indicator {
display: flex;
gap: 4px;
}
.typing-indicator span {
width: 8px;
height: 8px;
background-color: var(--text-secondary);
border-radius: 50%;
animation: typing 1.4s infinite both;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.input-container {
padding: 16px 24px 24px;
}
.input-form {
display: flex;
align-items: flex-end;
gap: 12px;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 8px;
}
.input-form textarea {
flex: 1;
padding: 8px 12px;
background: transparent;
border: none;
resize: none;
font-family: inherit;
font-size: 14px;
color: var(--text-primary);
max-height: 120px;
outline: none;
}
.input-form textarea::placeholder {
color: var(--text-secondary);
}
.input-form textarea:disabled {
opacity: 0.6;
}
.btn-send {
padding: 10px;
background: var(--accent-color);
border: none;
border-radius: 8px;
cursor: pointer;
color: white;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.btn-send:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@ -0,0 +1,200 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
conversations: {
type: Array,
default: () => []
},
currentConversation: {
type: Object,
default: null
},
isOpen: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['select', 'create', 'delete'])
const formatDate = (dateStr) => {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
const selectConversation = (conv) => {
emit('select', conv)
}
const createNewConversation = () => {
emit('create')
}
const deleteConversation = (id, event) => {
event.stopPropagation()
if (confirm('确定要删除这个对话吗?')) {
emit('delete', id)
}
}
</script>
<template>
<aside class="sidebar" :class="{ collapsed: !isOpen }">
<div class="sidebar-header">
<h2 v-if="isOpen">对话列表</h2>
<button class="btn-new-chat" @click="createNewConversation" title="新建对话">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<div class="conversation-list" v-if="isOpen">
<div v-if="conversations.length === 0" class="empty-state">
暂无对话
</div>
<div
v-else
v-for="conv in conversations"
:key="conv.id"
class="conversation-item"
:class="{ active: currentConversation?.id === conv.id }"
@click="selectConversation(conv)"
>
<div class="conversation-info">
<span class="conversation-title">{{ conv.title || '新对话' }}</span>
<span class="conversation-date">{{ formatDate(conv.updated_at) }}</span>
</div>
<button class="btn-delete" @click="deleteConversation(conv.id, $event)" title="删除">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 280px;
height: 100%;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: width 0.3s ease;
}
.sidebar.collapsed {
width: 60px;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h2 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.btn-new-chat {
padding: 8px;
background: var(--accent-color);
border: none;
border-radius: 8px;
cursor: pointer;
color: white;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.btn-new-chat:hover {
background: var(--accent-hover);
}
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.empty-state {
text-align: center;
color: var(--text-secondary);
padding: 24px;
font-size: 14px;
}
.conversation-item {
padding: 12px;
border-radius: 8px;
cursor: pointer;
margin-bottom: 4px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.2s;
}
.conversation-item:hover {
background-color: var(--bg-hover);
}
.conversation-item.active {
background-color: var(--accent-light);
}
.conversation-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.conversation-title {
font-size: 14px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-date {
font-size: 12px;
color: var(--text-secondary);
}
.btn-delete {
padding: 4px;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary);
border-radius: 4px;
opacity: 0;
transition: all 0.2s;
}
.conversation-item:hover .btn-delete {
opacity: 1;
}
.btn-delete:hover {
background: var(--danger-light);
color: var(--danger-color);
}
</style>

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

@ -0,0 +1,34 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import './style.css'
import App from './App.vue'
import Layout from './views/Layout.vue'
import LoginView from './views/LoginView.vue'
// Create router
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: Layout,
children: [
{
path: '',
name: 'chat',
component: App
}
]
},
{
path: '/login',
name: 'login',
component: LoginView
}
]
})
// Mount app with router
const app = createApp(Layout)
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,57 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json'
}
})
// Add auth token to requests - use axios defaults
api.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
// Use direct assignment
api.defaults.headers.common['Authorization'] = 'Bearer ' + token
config.headers['Authorization'] = 'Bearer ' + token
}
return config
})
// Handle auth errors
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// Auth API
export const authAPI = {
login: (data) => api.post('/auth/login', data),
register: (data) => api.post('/auth/register', data),
me: () => api.get('/auth/me'),
logout: () => api.post('/auth/logout')
}
// Conversations API
export const conversationsAPI = {
list: (params) => api.get('/conversations/', { params }),
get: (id) => api.get(`/conversations/${id}`),
create: (data) => api.post('/conversations/', data),
update: (id, data) => api.put(`/conversations/${id}`, data),
delete: (id) => api.delete(`/conversations/${id}`)
}
// Messages API
export const messagesAPI = {
list: (conversationId) => api.get('/messages/', { params: { conversation_id: conversationId } }),
send: (data) => api.post('/messages/', data)
}
export default api

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

@ -0,0 +1,85 @@
:root {
/* Light mode colors */
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-hover: #f1f3f5;
--text-primary: #212529;
--text-secondary: #6c757d;
--border-color: #dee2e6;
/* Accent colors */
--accent-color: #4f46e5;
--accent-hover: #4338ca;
--accent-light: #eef2ff;
/* Status colors */
--success-color: #10b981;
--danger-color: #ef4444;
--danger-light: #fee2e2;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #0f0f0f;
--bg-secondary: #1a1a1a;
--bg-hover: #262626;
--text-primary: #f5f5f5;
--text-secondary: #a3a3a3;
--border-color: #2e2e2e;
--accent-color: #6366f1;
--accent-hover: #818cf8;
--accent-light: #312e81;
--danger-light: #450a0a;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
margin: 0;
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: inherit;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100vw;
height: 100vh;
}
button {
font-family: inherit;
}
input, textarea {
font-family: inherit;
}
/* Scrollbar styles */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}

View File

@ -0,0 +1,15 @@
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
// Redirect to login if no token
const token = localStorage.getItem('token')
if (!token && router.currentRoute.value.path !== '/login') {
router.push('/login')
}
</script>
<template>
<router-view />
</template>

View File

@ -0,0 +1,254 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { authAPI } from '../services/api'
const router = useRouter()
const isLogin = ref(true)
const isLoading = ref(false)
const error = ref('')
const form = ref({
username: '',
password: '',
email: ''
})
const toggleMode = () => {
isLogin.value = !isLogin.value
error.value = ''
}
const submit = async () => {
if (!form.value.username || !form.value.password) {
error.value = '请填写用户名和密码'
return
}
isLoading.value = true
error.value = ''
try {
if (isLogin.value) {
const response = await authAPI.login({
username: form.value.username,
password: form.value.password
})
if (response.data.success) {
localStorage.setItem('token', response.data.data.access_token)
localStorage.setItem('user', JSON.stringify(response.data.data.user))
router.push('/')
} else {
error.value = response.data.message || '登录失败'
}
} else {
const response = await authAPI.register({
username: form.value.username,
password: form.value.password,
email: form.value.email || undefined
})
if (response.data.success) {
// Auto login after register
const loginResponse = await authAPI.login({
username: form.value.username,
password: form.value.password
})
if (loginResponse.data.success) {
localStorage.setItem('token', loginResponse.data.data.access_token)
localStorage.setItem('user', JSON.stringify(loginResponse.data.data.user))
router.push('/')
}
} else {
error.value = response.data.message || '注册失败'
}
}
} catch (err) {
error.value = err.response?.data?.message || '请求失败,请稍后重试'
} finally {
isLoading.value = false
}
}
</script>
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>Luxx</h1>
<p>{{ isLogin ? '登录' : '注册' }}</p>
</div>
<form @submit.prevent="submit" class="login-form">
<div class="form-group">
<input
v-model="form.username"
type="text"
placeholder="用户名"
:disabled="isLoading"
/>
</div>
<div class="form-group" v-if="!isLogin">
<input
v-model="form.email"
type="email"
placeholder="邮箱 (可选)"
:disabled="isLoading"
/>
</div>
<div class="form-group">
<input
v-model="form.password"
type="password"
placeholder="密码"
:disabled="isLoading"
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button type="submit" class="btn-submit" :disabled="isLoading">
{{ isLoading ? '处理中...' : (isLogin ? '登录' : '注册') }}
</button>
</form>
<div class="login-footer">
<span>{{ isLogin ? '没有账号?' : '已有账号?' }}</span>
<button @click="toggleMode" :disabled="isLoading">
{{ isLogin ? '立即注册' : '立即登录' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: var(--bg-primary);
padding: 20px;
}
.login-card {
width: 100%;
max-width: 360px;
padding: 32px;
background-color: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header h1 {
font-size: 28px;
font-weight: 700;
color: var(--accent-color);
margin: 0 0 8px;
}
.login-header p {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group input {
width: 100%;
padding: 12px 16px;
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: var(--bg-primary);
color: var(--text-primary);
outline: none;
transition: border-color 0.2s;
}
.form-group input:focus {
border-color: var(--accent-color);
}
.form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-group input::placeholder {
color: var(--text-secondary);
}
.error-message {
padding: 12px;
font-size: 14px;
color: var(--danger-color);
background-color: var(--danger-light);
border-radius: 8px;
text-align: center;
}
.btn-submit {
width: 100%;
padding: 12px;
font-size: 14px;
font-weight: 600;
color: white;
background-color: var(--accent-color);
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-submit:hover:not(:disabled) {
background-color: var(--accent-hover);
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 24px;
font-size: 14px;
color: var(--text-secondary);
}
.login-footer button {
padding: 0;
font-size: 14px;
color: var(--accent-color);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}
.login-footer button:hover {
color: var(--accent-hover);
}
</style>

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

@ -0,0 +1,26 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
base: './',
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name].js',
chunkFileNames: 'assets/[name].js',
assetFileNames: 'assets/[name].[ext]'
}
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
})

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