feat: 增加前端
This commit is contained in:
parent
72a3738388
commit
4fcad51b83
|
|
@ -10,4 +10,7 @@
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
|
||||||
!luxx/**/*.py
|
!luxx/**/*.py
|
||||||
!docs/**/*.md
|
!asserts/**/*.md
|
||||||
|
|
||||||
|
# Dashboard
|
||||||
|
!dashboard/**/*
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
# 配置文件
|
# 配置文件
|
||||||
app:
|
app:
|
||||||
secret_key: ${APP_SECRET_KEY}
|
secret_key: ${APP_SECRET_KEY}
|
||||||
debug: true
|
debug: false
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8000
|
port: 8000
|
||||||
|
|
||||||
database:
|
database:
|
||||||
type: sqlite
|
type: sqlite
|
||||||
url: sqlite:///./chat.db
|
url: sqlite:///../chat.db
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
provider: deepseek
|
provider: deepseek
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="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>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 |
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -11,7 +11,30 @@ from luxx.routes import api_router
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan manager"""
|
"""Application lifespan manager"""
|
||||||
|
# Import all models to ensure they are registered with Base
|
||||||
|
from luxx import models # noqa
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
# Create default test user if not exists
|
||||||
|
from luxx.database import SessionLocal
|
||||||
|
from luxx.models import User
|
||||||
|
from luxx.utils.helpers import hash_password
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
default_user = db.query(User).filter(User.username == "admin").first()
|
||||||
|
if not default_user:
|
||||||
|
default_user = User(
|
||||||
|
username="admin",
|
||||||
|
password_hash=hash_password("admin123"),
|
||||||
|
role="admin"
|
||||||
|
)
|
||||||
|
db.add(default_user)
|
||||||
|
db.commit()
|
||||||
|
print("Default admin user created: admin / admin123")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
from luxx.tools.builtin import crawler, code, data
|
from luxx.tools.builtin import crawler, code, data
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,9 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from sqlalchemy import String, Integer, Boolean, Float, Text, DateTime, ForeignKey
|
from sqlalchemy import String, Integer, Boolean, Float, Text, DateTime, ForeignKey
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, DeclarativeBase
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from luxx.database import Base
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Project(Base):
|
class Project(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Authentication routes"""
|
"""Authentication routes"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from fastapi import APIRouter, Depends, status
|
from fastapi import APIRouter, Depends, status, HTTPException
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -56,13 +56,13 @@ def get_current_user(
|
||||||
"""Get current user"""
|
"""Get current user"""
|
||||||
payload = decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
raise status.HTTP_401_UNAUTHORIZED
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise status.HTTP_401_UNAUTHORIZED
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
user = db.query(User).filter(User.id == int(user_id)).first()
|
user = db.query(User).filter(User.id == int(user_id)).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise status.HTTP_401_UNAUTHORIZED
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue