feat: 增加前端
This commit is contained in:
parent
72a3738388
commit
4fcad51b83
|
|
@ -10,4 +10,7 @@
|
|||
!.gitignore
|
||||
|
||||
!luxx/**/*.py
|
||||
!docs/**/*.md
|
||||
!asserts/**/*.md
|
||||
|
||||
# Dashboard
|
||||
!dashboard/**/*
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue