refactor: 优化前端界面
This commit is contained in:
parent
d9e8a15c87
commit
a5be5b1fdc
|
|
@ -1,469 +1,26 @@
|
|||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { authAPI } from './services/api.js'
|
||||
import { ref } from 'vue'
|
||||
import { useAuth } from './composables/useAuth.js'
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const user = ref(null)
|
||||
const loading = ref(true)
|
||||
const showUserMenu = ref(false)
|
||||
const { isLoggedIn } = useAuth()
|
||||
const sidebarCollapsed = ref(false)
|
||||
const isLoggedIn = ref(false)
|
||||
|
||||
// 初始化检查登录状态
|
||||
const checkAuth = () => {
|
||||
isLoggedIn.value = !!localStorage.getItem('access_token')
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const fetchUserInfo = async () => {
|
||||
checkAuth()
|
||||
if (!isLoggedIn.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authAPI.getMe()
|
||||
if (response.success) {
|
||||
user.value = response.data
|
||||
localStorage.setItem('user', JSON.stringify(response.data))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取用户信息失败:', err)
|
||||
logout()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 登出
|
||||
const logout = async () => {
|
||||
try {
|
||||
await authAPI.logout()
|
||||
} catch (err) {
|
||||
console.error('登出失败:', err)
|
||||
} finally {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('user')
|
||||
user.value = null
|
||||
showUserMenu.value = false
|
||||
isLoggedIn.value = false
|
||||
router.push('/auth')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换侧边栏
|
||||
const toggleSidebar = () => {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
// 导航高亮
|
||||
const isActive = (path) => {
|
||||
return route.path === path
|
||||
}
|
||||
|
||||
// 监听路由变化时关闭菜单
|
||||
watch(() => route.path, () => {
|
||||
showUserMenu.value = false
|
||||
})
|
||||
|
||||
// 监听登录状态变化
|
||||
onMounted(() => {
|
||||
fetchUserInfo()
|
||||
|
||||
// 监听 auth-change 事件
|
||||
window.addEventListener('auth-change', () => {
|
||||
checkAuth()
|
||||
if (isLoggedIn.value) {
|
||||
fetchUserInfo()
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar" v-if="isLoggedIn">
|
||||
<div class="sidebar-header">
|
||||
<div class="brand">
|
||||
<span class="brand-icon">✨</span>
|
||||
<span class="brand-text">Luxx</span>
|
||||
</div>
|
||||
<button @click="toggleSidebar" class="toggle-btn">
|
||||
{{ sidebarCollapsed ? '→' : '←' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<router-link to="/" class="nav-item" :class="{ active: isActive('/') }">
|
||||
<span class="nav-icon">🏠</span>
|
||||
<span class="nav-text">首页</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/conversations" class="nav-item" :class="{ active: isActive('/conversations') }">
|
||||
<span class="nav-icon">💬</span>
|
||||
<span class="nav-text">会话</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/tools" class="nav-item" :class="{ active: isActive('/tools') }">
|
||||
<span class="nav-icon">🛠️</span>
|
||||
<span class="nav-text">工具</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/about" class="nav-item" :class="{ active: isActive('/about') }">
|
||||
<span class="nav-icon">ℹ️</span>
|
||||
<span class="nav-text">关于</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-menu-container">
|
||||
<button @click="showUserMenu = !showUserMenu" class="user-button">
|
||||
<div class="user-avatar">
|
||||
{{ user?.username?.charAt(0).toUpperCase() || 'U' }}
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ user?.username }}</span>
|
||||
<span class="user-role">{{ user?.role || '用户' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="showUserMenu" class="user-dropdown">
|
||||
<button @click="logout" class="dropdown-item">
|
||||
<span>🚪</span> 退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<AppSidebar v-if="isLoggedIn" :collapsed="sidebarCollapsed" @toggle="sidebarCollapsed = !sidebarCollapsed" />
|
||||
<main class="main-content" :class="{ 'no-sidebar': !isLoggedIn }">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- 点击外部关闭菜单 -->
|
||||
<div
|
||||
v-if="showUserMenu"
|
||||
class="overlay"
|
||||
@click="showUserMenu = false"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* 侧边栏 */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: var(--bg);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
/* 侧边栏头部 */
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .brand-text,
|
||||
.sidebar-collapsed .toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: var(--code-bg);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
/* 导航 */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 1rem 0.75rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.25rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--code-bg);
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.25rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .nav-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .nav-item {
|
||||
justify-content: center;
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
/* 侧边栏底部 */
|
||||
.sidebar-footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.user-menu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.user-button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-h);
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.sidebar-collapsed .user-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .user-button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 下拉菜单 */
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 -5px 20px var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 260px;
|
||||
padding: 2rem;
|
||||
min-height: 100vh;
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
.main-content.no-sidebar {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .main-content {
|
||||
margin-left: 70px;
|
||||
}
|
||||
|
||||
/* 遮罩层 */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.brand-text,
|
||||
.toggle-btn,
|
||||
.nav-text,
|
||||
.user-info {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.user-button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.brand-text,
|
||||
.toggle-btn,
|
||||
.nav-text,
|
||||
.user-info {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: flex-start;
|
||||
padding: 0.875rem 1rem;
|
||||
}
|
||||
|
||||
.user-button {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
#app { display: flex; min-height: 100vh; background: var(--bg); }
|
||||
.main-content { flex: 1; margin-left: 260px; padding: 2rem; min-height: 100vh; transition: margin-left 0.3s; }
|
||||
.main-content.no-sidebar { margin-left: 0; }
|
||||
.sidebar-collapsed .main-content { margin-left: 70px; }
|
||||
@media (max-width: 1024px) { .main-content { margin-left: 70px; } }
|
||||
@media (max-width: 768px) { .main-content { margin-left: 0; } }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<aside class="sidebar" :class="{ collapsed }">
|
||||
<div class="sidebar-header">
|
||||
<div class="brand">
|
||||
<span class="brand-icon">✨</span>
|
||||
<span class="brand-text">Luxx</span>
|
||||
</div>
|
||||
<button @click="$emit('toggle')" class="toggle-btn">{{ collapsed ? '→' : '←' }}</button>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<router-link v-for="item in navItems" :key="item.path" :to="item.path" class="nav-item" active-class="active">
|
||||
<span class="nav-icon">{{ item.icon }}</span>
|
||||
<span class="nav-text">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button @click="handleLogout" class="user-button">
|
||||
<div class="user-avatar">{{ user?.username?.charAt(0)?.toUpperCase() || 'U' }}</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ user?.username }}</span>
|
||||
<span class="user-role">{{ user?.role || '用户' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuth } from '../composables/useAuth.js'
|
||||
import { authAPI } from '../services/api.js'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
defineProps({ collapsed: Boolean })
|
||||
defineEmits(['toggle'])
|
||||
|
||||
const router = useRouter()
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', icon: '🏠', label: '首页' },
|
||||
{ path: '/conversations', icon: '💬', label: '会话' },
|
||||
{ path: '/tools', icon: '🛠️', label: '工具' },
|
||||
{ path: '/about', icon: 'ℹ️', label: '关于' }
|
||||
]
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout(authAPI)
|
||||
router.push('/auth')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 260px; height: 100vh;
|
||||
background: var(--bg); border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
position: fixed; left: 0; top: 0; z-index: 100;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.sidebar.collapsed { width: 70px; }
|
||||
.sidebar-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 1.25rem; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.brand-icon { font-size: 1.75rem; }
|
||||
.brand-text {
|
||||
font-size: 1.5rem; font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.collapsed .brand-text, .collapsed .toggle-btn { display: none; }
|
||||
.toggle-btn {
|
||||
background: var(--code-bg); border: none; border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem; cursor: pointer;
|
||||
}
|
||||
.sidebar-nav { flex: 1; padding: 1rem 0.75rem; overflow-y: auto; }
|
||||
.nav-item {
|
||||
display: flex; align-items: center; gap: 0.875rem;
|
||||
padding: 0.875rem 1rem; border-radius: 10px;
|
||||
text-decoration: none; color: var(--text); margin-bottom: 0.25rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.nav-item:hover { background: var(--code-bg); color: var(--text-h); }
|
||||
.nav-item.active { background: var(--accent-bg); color: var(--accent); }
|
||||
.nav-icon { font-size: 1.25rem; width: 24px; text-align: center; }
|
||||
.nav-text { font-size: 0.95rem; font-weight: 500; white-space: nowrap; }
|
||||
.collapsed .nav-text { display: none; }
|
||||
.collapsed .nav-item { justify-content: center; padding: 0.875rem; }
|
||||
.sidebar-footer { padding: 1rem; border-top: 1px solid var(--border); }
|
||||
.user-button {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
width: 100%; padding: 0.75rem; background: var(--code-bg);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.user-button:hover { border-color: var(--accent); }
|
||||
.user-avatar {
|
||||
width: 40px; height: 40px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
|
||||
color: white; display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 600; flex-shrink: 0;
|
||||
}
|
||||
.user-info { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.user-name { font-weight: 600; color: var(--text-h); font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.user-role { font-size: 0.8rem; color: var(--text); }
|
||||
.collapsed .user-info { display: none; }
|
||||
.collapsed .user-button { justify-content: center; }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar { width: 70px; }
|
||||
.brand-text, .toggle-btn, .nav-text, .user-info { display: none !important; }
|
||||
.nav-item { justify-content: center; padding: 0.875rem; }
|
||||
.user-button { justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<button
|
||||
:class="['base-btn', `btn-${type}`, { 'btn-block': block, 'btn-loading': loading }]"
|
||||
:disabled="disabled || loading"
|
||||
@click="$emit('click', $event)"
|
||||
>
|
||||
<span v-if="loading" class="spinner"></span>
|
||||
<slot v-else />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
type: { type: String, default: 'primary' },
|
||||
disabled: Boolean,
|
||||
loading: Boolean,
|
||||
block: Boolean
|
||||
})
|
||||
defineEmits(['click'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: white; }
|
||||
.btn-secondary { background: var(--bg); color: var(--text); border: 1px solid var(--border); }
|
||||
.btn-danger { background: #dc2626; color: white; }
|
||||
.btn-block { width: 100%; }
|
||||
.base-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.base-btn:not(:disabled):hover { transform: translateY(-2px); }
|
||||
.spinner {
|
||||
width: 20px; height: 20px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// 导出所有组合式函数
|
||||
export { useAuth } from './useAuth.js'
|
||||
export { useApi, usePagination, useForm } from './useApi.js'
|
||||
export { formatDate, formatNumber, truncate } from './useFormatters.js'
|
||||
export { debounce, throttle, storage, copyToClipboard } from './useUtils.js'
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* 认证状态管理组合式函数
|
||||
*/
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useAuth() {
|
||||
const isLoggedIn = ref(false)
|
||||
const user = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
// 检查登录状态
|
||||
const checkAuth = () => {
|
||||
isLoggedIn.value = !!localStorage.getItem('access_token')
|
||||
if (isLoggedIn.value) {
|
||||
const userData = localStorage.getItem('user')
|
||||
if (userData) {
|
||||
try {
|
||||
user.value = JSON.parse(userData)
|
||||
} catch {
|
||||
user.value = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
user.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
const login = (token, userData) => {
|
||||
localStorage.setItem('access_token', token)
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
isLoggedIn.value = true
|
||||
user.value = userData
|
||||
window.dispatchEvent(new CustomEvent('auth-change'))
|
||||
}
|
||||
|
||||
// 登出
|
||||
const logout = async (api) => {
|
||||
try {
|
||||
if (api) await api.logout()
|
||||
} catch (e) {
|
||||
console.error('登出失败:', e)
|
||||
}
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('user')
|
||||
isLoggedIn.value = false
|
||||
user.value = null
|
||||
window.dispatchEvent(new CustomEvent('auth-change'))
|
||||
}
|
||||
|
||||
// 获取 Token
|
||||
const getToken = () => localStorage.getItem('access_token')
|
||||
|
||||
// 监听认证变化
|
||||
const handleAuthChange = () => {
|
||||
checkAuth()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkAuth()
|
||||
window.addEventListener('auth-change', handleAuthChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth-change', handleAuthChange)
|
||||
})
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
user,
|
||||
loading,
|
||||
checkAuth,
|
||||
login,
|
||||
logout,
|
||||
getToken
|
||||
}
|
||||
}
|
||||
|
|
@ -1,335 +1,94 @@
|
|||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-container">
|
||||
<!-- 左侧品牌区 -->
|
||||
<div class="auth-branding">
|
||||
<div class="branding-content">
|
||||
<div class="brand-logo">✨</div>
|
||||
<h1>欢迎回来</h1>
|
||||
<p>登录到 Luxx 平台,开始智能对话之旅</p>
|
||||
<div class="features-list">
|
||||
<div class="feature-item">
|
||||
<span class="feature-icon">💬</span>
|
||||
<span>智能会话管理</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<span class="feature-icon">🛠️</span>
|
||||
<span>丰富的工具生态</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<span class="feature-icon">🔒</span>
|
||||
<span>安全可靠的认证</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧表单区 -->
|
||||
<div class="auth-form-section">
|
||||
<div class="auth-card">
|
||||
<div class="tabs">
|
||||
<button
|
||||
@click="activeTab = 'login'"
|
||||
:class="{ active: activeTab === 'login' }"
|
||||
class="tab-btn"
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'register'"
|
||||
:class="{ active: activeTab === 'register' }"
|
||||
class="tab-btn"
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
<button @click="mode = 'login'" :class="{ active: mode === 'login' }">登录</button>
|
||||
<button @click="mode = 'register'" :class="{ active: mode === 'register' }">注册</button>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<div v-if="activeTab === 'login'" class="form-container">
|
||||
<h2>登录账户</h2>
|
||||
<p class="form-subtitle">请输入您的账户信息</p>
|
||||
|
||||
<form @submit.prevent="handleLogin">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="form-group">
|
||||
<label for="login-username">
|
||||
<span class="label-icon">👤</span>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
v-model="loginForm.username"
|
||||
id="login-username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
class="form-input"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<label>用户名</label>
|
||||
<input v-model="form.username" type="text" placeholder="请输入用户名" required />
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'register'" class="form-group">
|
||||
<label>邮箱</label>
|
||||
<input v-model="form.email" type="email" placeholder="请输入邮箱" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="login-password">
|
||||
<span class="label-icon">🔑</span>
|
||||
密码
|
||||
</label>
|
||||
<div class="password-input-wrapper">
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
id="login-password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
class="form-input"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="password-toggle"
|
||||
>
|
||||
{{ showPassword ? '👁️' : '👁️🗨️' }}
|
||||
</button>
|
||||
</div>
|
||||
<label>密码</label>
|
||||
<input v-model="form.password" type="password" placeholder="请输入密码" required />
|
||||
</div>
|
||||
|
||||
<div v-if="loginError" class="error-message">
|
||||
<span class="error-icon">⚠️</span>
|
||||
{{ loginError }}
|
||||
<div v-if="mode === 'register'" class="form-group">
|
||||
<label>确认密码</label>
|
||||
<input v-model="confirmPassword" type="password" placeholder="请再次输入密码" required />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="btn-submit"
|
||||
>
|
||||
<span v-if="loading" class="spinner-small"></span>
|
||||
<span v-else>登录</span>
|
||||
<div v-if="error" class="error-msg">{{ error }}</div>
|
||||
|
||||
<button type="submit" :disabled="loading" class="btn-submit">
|
||||
<span v-if="loading" class="spinner"></span>
|
||||
<span v-else>{{ mode === 'login' ? '登录' : '注册' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="form-footer">
|
||||
<p>还没有账户?<button @click="activeTab = 'register'" class="link-btn">立即注册</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<div v-else class="form-container">
|
||||
<h2>创建账户</h2>
|
||||
<p class="form-subtitle">填写以下信息完成注册</p>
|
||||
|
||||
<form @submit.prevent="handleRegister">
|
||||
<div class="form-group">
|
||||
<label for="register-username">
|
||||
<span class="label-icon">👤</span>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
v-model="registerForm.username"
|
||||
id="register-username"
|
||||
type="text"
|
||||
placeholder="请输入用户名(3-20位)"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="20"
|
||||
class="form-input"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="register-email">
|
||||
<span class="label-icon">📧</span>
|
||||
邮箱
|
||||
</label>
|
||||
<input
|
||||
v-model="registerForm.email"
|
||||
id="register-email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱地址"
|
||||
required
|
||||
class="form-input"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="register-password">
|
||||
<span class="label-icon">🔒</span>
|
||||
密码
|
||||
</label>
|
||||
<div class="password-input-wrapper">
|
||||
<input
|
||||
v-model="registerForm.password"
|
||||
id="register-password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="请输入密码(至少6位)"
|
||||
required
|
||||
minlength="6"
|
||||
class="form-input"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="password-toggle"
|
||||
>
|
||||
{{ showPassword ? '👁️' : '👁️🗨️' }}
|
||||
<p class="switch-mode">
|
||||
{{ mode === 'login' ? '没有账户?' : '已有账户?' }}
|
||||
<button @click="mode = mode === 'login' ? 'register' : 'login'">
|
||||
{{ mode === 'login' ? '立即注册' : '立即登录' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="register-confirm-password">
|
||||
<span class="label-icon">🔐</span>
|
||||
确认密码
|
||||
</label>
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
id="register-confirm-password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="请再次输入密码"
|
||||
required
|
||||
class="form-input"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="registerError" class="error-message">
|
||||
<span class="error-icon">⚠️</span>
|
||||
{{ registerError }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="btn-submit"
|
||||
>
|
||||
<span v-if="loading" class="spinner-small"></span>
|
||||
<span v-else>注册</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="form-footer">
|
||||
<p>已有账户?<button @click="activeTab = 'login'" class="link-btn">立即登录</button></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功提示 -->
|
||||
<div v-if="authSuccess" class="success-overlay">
|
||||
<div class="success-content">
|
||||
<div class="success-icon">✓</div>
|
||||
<h3>登录成功!</h3>
|
||||
<p>正在跳转...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authAPI } from '../services/api.js'
|
||||
import { useAuth } from '../composables/useAuth.js'
|
||||
|
||||
const router = useRouter()
|
||||
const { login } = useAuth()
|
||||
|
||||
const activeTab = ref('login')
|
||||
const mode = ref('login')
|
||||
const loading = ref(false)
|
||||
const loginError = ref('')
|
||||
const registerError = ref('')
|
||||
const authSuccess = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const error = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const form = reactive({ username: '', email: '', password: '' })
|
||||
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const registerForm = ref({
|
||||
username: '',
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
// 登录处理
|
||||
const handleLogin = async () => {
|
||||
const handleSubmit = async () => {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
loginError.value = ''
|
||||
|
||||
try {
|
||||
const response = await authAPI.login({
|
||||
username: loginForm.value.username,
|
||||
password: loginForm.value.password
|
||||
})
|
||||
|
||||
if (response.success && response.data?.access_token) {
|
||||
// 保存 token 和用户信息
|
||||
localStorage.setItem('access_token', response.data.access_token)
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user))
|
||||
|
||||
// 触发登录成功事件
|
||||
window.dispatchEvent(new CustomEvent('auth-change'))
|
||||
|
||||
authSuccess.value = true
|
||||
|
||||
// 延迟跳转,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
router.push('/')
|
||||
}, 1000)
|
||||
} else {
|
||||
throw new Error(response.message || '登录失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('登录失败:', err)
|
||||
loginError.value = err.message || '用户名或密码错误'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 注册处理
|
||||
const handleRegister = async () => {
|
||||
loading.value = true
|
||||
registerError.value = ''
|
||||
|
||||
// 验证密码确认
|
||||
if (registerForm.value.password !== confirmPassword.value) {
|
||||
registerError.value = '两次输入的密码不一致'
|
||||
loading.value = false
|
||||
return
|
||||
if (mode.value === 'register' && form.password !== confirmPassword.value) {
|
||||
throw new Error('两次密码不一致')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authAPI.register({
|
||||
username: registerForm.value.username,
|
||||
email: registerForm.value.email,
|
||||
password: registerForm.value.password
|
||||
})
|
||||
const api = mode.value === 'login' ? authAPI.login : authAPI.register
|
||||
const response = await api(mode.value === 'login'
|
||||
? { username: form.username, password: form.password }
|
||||
: form
|
||||
)
|
||||
|
||||
if (response.success) {
|
||||
// 注册成功后自动登录
|
||||
authSuccess.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
// 清空表单并切换到登录
|
||||
registerForm.value = {
|
||||
username: '',
|
||||
email: '',
|
||||
password: ''
|
||||
}
|
||||
confirmPassword.value = ''
|
||||
authSuccess.value = false
|
||||
activeTab.value = 'login'
|
||||
loginForm.value.username = registerForm.value.username
|
||||
}, 1500)
|
||||
if (mode.value === 'login' && response.data?.access_token) {
|
||||
login(response.data.access_token, response.data.user)
|
||||
router.push('/')
|
||||
} else {
|
||||
throw new Error(response.message || '注册失败')
|
||||
mode.value = 'login'
|
||||
form.username = form.password = form.email = ''
|
||||
confirmPassword.value = ''
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || '操作失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('注册失败:', err)
|
||||
registerError.value = err.message || '注册失败,请稍后重试'
|
||||
error.value = err.message || '操作失败,请重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
|
@ -337,370 +96,21 @@ const handleRegister = async () => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: calc(100vh - 70px - 80px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px var(--shadow);
|
||||
}
|
||||
|
||||
/* 左侧品牌区 */
|
||||
.auth-branding {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
|
||||
padding: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.branding-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-branding h1 {
|
||||
font-size: 2.5rem;
|
||||
margin: 0 0 1rem 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.auth-branding p {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
|
||||
.features-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* 右侧表单区 */
|
||||
.auth-form-section {
|
||||
padding: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 标签页 */
|
||||
.tabs {
|
||||
display: flex;
|
||||
margin-bottom: 2rem;
|
||||
background: var(--code-bg);
|
||||
border-radius: 12px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--bg);
|
||||
color: var(--text-h);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
|
||||
/* 表单容器 */
|
||||
.form-container h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.form-subtitle {
|
||||
color: var(--text);
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* 表单组 */
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.label-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-bg);
|
||||
}
|
||||
|
||||
.form-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--text);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 密码输入框 */
|
||||
.password-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-input-wrapper .form-input {
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 错误消息 */
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
color: #dc2626;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 提交按钮 */
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
background: var(--accent-border);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px var(--shadow);
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 表单页脚 */
|
||||
.form-footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
color: var(--accent-border);
|
||||
}
|
||||
|
||||
/* 成功覆盖层 */
|
||||
.success-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 16px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.success-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
margin: 0 auto 1rem;
|
||||
animation: scaleIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { transform: scale(0); }
|
||||
to { transform: scale(1); }
|
||||
}
|
||||
|
||||
.success-content h3 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.success-content p {
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.auth-container {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.auth-branding {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.branding-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-form-section {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.auth-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.auth-form-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.auth-page { min-height: calc(100vh - 70px); display: flex; align-items: center; justify-content: center; padding: 2rem; }
|
||||
.auth-card { width: 100%; max-width: 400px; }
|
||||
.tabs { display: flex; margin-bottom: 2rem; background: var(--code-bg); border-radius: 12px; padding: 0.25rem; }
|
||||
.tabs button { flex: 1; padding: 0.75rem; background: transparent; border: none; border-radius: 8px; font-size: 1rem; font-weight: 500; color: var(--text); cursor: pointer; transition: all 0.2s; }
|
||||
.tabs button.active { background: var(--bg); color: var(--text-h); box-shadow: 0 2px 8px var(--shadow); }
|
||||
.form-group { margin-bottom: 1.25rem; }
|
||||
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-h); }
|
||||
.form-group input { width: 100%; padding: 0.875rem 1rem; border: 1px solid var(--border); border-radius: 10px; font-size: 1rem; background: var(--bg); color: var(--text); box-sizing: border-box; }
|
||||
.form-group input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
|
||||
.error-msg { padding: 0.75rem 1rem; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; color: #dc2626; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.btn-submit { width: 100%; padding: 1rem; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; }
|
||||
.btn-submit:hover:not(:disabled) { background: var(--accent-border); }
|
||||
.btn-submit:disabled { opacity: 0.7; cursor: not-allowed; }
|
||||
.spinner { width: 20px; height: 20px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.switch-mode { text-align: center; color: var(--text); margin-top: 1.5rem; }
|
||||
.switch-mode button { background: none; border: none; color: var(--accent); cursor: pointer; font-weight: 500; text-decoration: underline; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,201 +1,38 @@
|
|||
<template>
|
||||
<div class="conversations">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>会话管理</h1>
|
||||
<p class="subtitle">查看和管理所有会话记录</p>
|
||||
</div>
|
||||
<button @click="createNewConversation" class="btn-primary">
|
||||
<span class="icon">+</span> 新建会话
|
||||
</button>
|
||||
<button @click="showModal = true" class="btn-primary">+ 新建会话</button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选和搜索 -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索会话..."
|
||||
class="search-input"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="selectedModel" class="model-select" @change="fetchConversations">
|
||||
<option value="">全部模型</option>
|
||||
<option value="glm-5">GLM-5</option>
|
||||
<option value="glm-4">GLM-4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
|
||||
<div v-else-if="error" class="error-msg">{{ error }} <button @click="fetchData">重试</button></div>
|
||||
<div v-else-if="!list.length" class="empty">暂无会话</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<div class="spinner"></div>
|
||||
<p>正在加载会话列表...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-container">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<p>{{ error }}</p>
|
||||
<button @click="fetchConversations" class="btn-secondary">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="conversations.length === 0" class="empty-container">
|
||||
<div class="empty-icon">💬</div>
|
||||
<h3>暂无会话</h3>
|
||||
<p>创建您的第一个会话开始对话</p>
|
||||
<button @click="createNewConversation" class="btn-primary">新建会话</button>
|
||||
</div>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<div v-else class="conversation-list">
|
||||
<div
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="conversation-card"
|
||||
@click="openDetail(conv.id)"
|
||||
>
|
||||
<div class="conversation-info">
|
||||
<h3 class="conversation-title">{{ conv.title || '未命名会话' }}</h3>
|
||||
<div class="conversation-meta">
|
||||
<span class="meta-item">
|
||||
<span class="meta-icon">📅</span>
|
||||
{{ formatDate(conv.created_at) }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-icon">🤖</span>
|
||||
{{ conv.model || '默认模型' }}
|
||||
</span>
|
||||
<span v-if="conv.message_count" class="meta-item">
|
||||
<span class="meta-icon">💬</span>
|
||||
{{ conv.message_count }} 条消息
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conversation-actions" @click.stop>
|
||||
<button @click="editConversation(conv)" class="btn-icon" title="编辑">
|
||||
✏️
|
||||
</button>
|
||||
<button @click="confirmDelete(conv)" class="btn-icon btn-danger" title="删除">
|
||||
🗑️
|
||||
</button>
|
||||
<div v-else class="list">
|
||||
<div v-for="c in list" :key="c.id" class="card" @click="$router.push(`/conversations/${c.id}`)">
|
||||
<div class="card-info">
|
||||
<h3>{{ c.title || '未命名会话' }}</h3>
|
||||
<p>{{ formatDate(c.created_at) }} • {{ c.model || '默认模型' }}</p>
|
||||
</div>
|
||||
<button @click.stop="deleteConv(c)" class="btn-delete">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="totalPages > 1 && !loading" class="pagination">
|
||||
<button
|
||||
@click="prevPage"
|
||||
:disabled="page === 1"
|
||||
class="btn-pagination"
|
||||
>
|
||||
← 上一页
|
||||
</button>
|
||||
<div class="pagination-info">
|
||||
<span>第 {{ page }} / {{ totalPages }} 页</span>
|
||||
<span class="divider">|</span>
|
||||
<span>共 {{ total }} 条</span>
|
||||
</div>
|
||||
<button
|
||||
@click="nextPage"
|
||||
:disabled="page >= totalPages"
|
||||
class="btn-pagination"
|
||||
>
|
||||
下一页 →
|
||||
</button>
|
||||
<div v-if="totalPages > 1" class="pagination">
|
||||
<button @click="page--; fetchData()" :disabled="page === 1">← 上一页</button>
|
||||
<span>{{ page }} / {{ totalPages }}</span>
|
||||
<button @click="page++; fetchData()" :disabled="page >= totalPages">下一页 →</button>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑会话模态框 -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ editingConversation ? '编辑会话' : '新建会话' }}</h2>
|
||||
<button @click="closeModal" class="btn-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>会话标题</label>
|
||||
<input
|
||||
v-model="formData.title"
|
||||
type="text"
|
||||
placeholder="输入会话标题"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>模型选择</label>
|
||||
<select v-model="formData.model" class="form-select">
|
||||
<option value="glm-5">GLM-5</option>
|
||||
<option value="glm-4">GLM-4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>系统提示词 (可选)</label>
|
||||
<textarea
|
||||
v-model="formData.system_prompt"
|
||||
placeholder="设置系统提示词"
|
||||
class="form-textarea"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>温度参数</label>
|
||||
<input
|
||||
v-model.number="formData.temperature"
|
||||
type="number"
|
||||
min="0"
|
||||
max="2"
|
||||
step="0.1"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>最大令牌数</label>
|
||||
<input
|
||||
v-model.number="formData.max_tokens"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65536"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
v-model="formData.thinking_enabled"
|
||||
type="checkbox"
|
||||
/>
|
||||
启用思考模式
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button @click="closeModal" class="btn-secondary">取消</button>
|
||||
<button @click="submitForm" class="btn-primary">
|
||||
{{ editingConversation ? '保存' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="closeDeleteModal">
|
||||
<div class="modal modal-small">
|
||||
<div class="modal-header">
|
||||
<h2>确认删除</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>确定要删除会话「{{ deletingConversation?.title || '未命名会话' }}」吗?</p>
|
||||
<p class="warning-text">此操作不可撤销</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button @click="closeDeleteModal" class="btn-secondary">取消</button>
|
||||
<button @click="deleteConversation" class="btn-danger" :disabled="deleting">
|
||||
{{ deleting ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
<h2>新建会话</h2>
|
||||
<div class="form-group"><label>标题</label><input v-model="form.title" placeholder="会话标题" /></div>
|
||||
<div class="form-group"><label>模型</label><select v-model="form.model"><option value="glm-5">GLM-5</option><option value="glm-4">GLM-4</option></select></div>
|
||||
<div class="modal-actions">
|
||||
<button @click="showModal = false" class="btn-secondary">取消</button>
|
||||
<button @click="createConv" :disabled="creating" class="btn-primary">{{ creating ? '创建中...' : '创建' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -204,697 +41,92 @@
|
|||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { conversationsAPI } from '../services/api.js'
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const conversations = ref([])
|
||||
const router = useRouter()
|
||||
const list = ref([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const searchQuery = ref('')
|
||||
const selectedModel = ref('')
|
||||
|
||||
// 模态框状态
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const showModal = ref(false)
|
||||
const editingConversation = ref(null)
|
||||
const formData = ref({
|
||||
title: '',
|
||||
model: 'glm-5',
|
||||
system_prompt: '',
|
||||
temperature: 1.0,
|
||||
max_tokens: 65536,
|
||||
thinking_enabled: false
|
||||
})
|
||||
const creating = ref(false)
|
||||
const form = ref({ title: '', model: 'glm-5' })
|
||||
|
||||
// 删除模态框状态
|
||||
const showDeleteModal = ref(false)
|
||||
const deletingConversation = ref(null)
|
||||
const deleting = ref(false)
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
|
||||
|
||||
// 获取会话列表
|
||||
const fetchConversations = async () => {
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
error.value = ''
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
page_size: pageSize.value
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
params.search = searchQuery.value
|
||||
}
|
||||
|
||||
if (selectedModel.value) {
|
||||
params.model = selectedModel.value
|
||||
}
|
||||
|
||||
const response = await conversationsAPI.list(params)
|
||||
|
||||
if (response.success) {
|
||||
conversations.value = response.data.items || []
|
||||
total.value = response.data.total || 0
|
||||
} else {
|
||||
throw new Error(response.message || '获取会话列表失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取会话列表失败:', err)
|
||||
error.value = err.message || '网络错误,请稍后重试'
|
||||
const res = await conversationsAPI.list({ page: page.value, page_size: pageSize })
|
||||
if (res.success) {
|
||||
list.value = res.data.items || []
|
||||
total.value = res.data.total || 0
|
||||
} else throw new Error(res.message)
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 防抖搜索
|
||||
let searchTimeout = null
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
fetchConversations()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 分页
|
||||
const prevPage = () => {
|
||||
if (page.value > 1) {
|
||||
page.value--
|
||||
fetchConversations()
|
||||
}
|
||||
}
|
||||
|
||||
const nextPage = () => {
|
||||
if (page.value < totalPages.value) {
|
||||
page.value++
|
||||
fetchConversations()
|
||||
}
|
||||
}
|
||||
|
||||
// 新建会话
|
||||
const createNewConversation = () => {
|
||||
editingConversation.value = null
|
||||
formData.value = {
|
||||
title: '',
|
||||
model: 'glm-5',
|
||||
system_prompt: '',
|
||||
temperature: 1.0,
|
||||
max_tokens: 65536,
|
||||
thinking_enabled: false
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
// 编辑会话
|
||||
const editConversation = (conv) => {
|
||||
editingConversation.value = conv
|
||||
formData.value = {
|
||||
title: conv.title || '',
|
||||
model: conv.model || 'glm-5',
|
||||
system_prompt: conv.system_prompt || '',
|
||||
temperature: conv.temperature || 1.0,
|
||||
max_tokens: conv.max_tokens || 65536,
|
||||
thinking_enabled: conv.thinking_enabled || false
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
const createConv = async () => {
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await conversationsAPI.create(form.value)
|
||||
if (res.success && res.data?.id) {
|
||||
showModal.value = false
|
||||
editingConversation.value = null
|
||||
form.value = { title: '', model: 'glm-5' }
|
||||
router.push(`/conversations/${res.data.id}`)
|
||||
}
|
||||
} catch (e) { alert(e.message) }
|
||||
finally { creating.value = false }
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const submitForm = async () => {
|
||||
try {
|
||||
if (editingConversation.value) {
|
||||
// 更新会话
|
||||
const response = await conversationsAPI.update(
|
||||
editingConversation.value.id,
|
||||
formData.value
|
||||
)
|
||||
if (response.success) {
|
||||
closeModal()
|
||||
fetchConversations()
|
||||
} else {
|
||||
throw new Error(response.message || '更新失败')
|
||||
}
|
||||
} else {
|
||||
// 创建会话
|
||||
const response = await conversationsAPI.create(formData.value)
|
||||
if (response.success) {
|
||||
closeModal()
|
||||
// 如果创建成功,可以跳转到新会话或刷新列表
|
||||
fetchConversations()
|
||||
// 或者跳转到新创建的会话详情
|
||||
if (response.data?.id) {
|
||||
openDetail(response.data.id)
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || '创建失败')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('提交失败:', err)
|
||||
alert(err.message || '操作失败,请重试')
|
||||
}
|
||||
const deleteConv = async (c) => {
|
||||
if (!confirm(`删除「${c.title || '未命名会话'}」?`)) return
|
||||
await conversationsAPI.delete(c.id)
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 打开详情
|
||||
const openDetail = (id) => {
|
||||
// 可以导航到详情页或打开详情模态框
|
||||
console.log('打开会话:', id)
|
||||
// 这里可以导航到会话详情页
|
||||
// router.push(`/conversations/${id}`)
|
||||
const formatDate = (d) => {
|
||||
if (!d) return ''
|
||||
const diff = Date.now() - new Date(d)
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
|
||||
return new Date(d).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = (conv) => {
|
||||
deletingConversation.value = conv
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
showDeleteModal.value = false
|
||||
deletingConversation.value = null
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const deleteConversation = async () => {
|
||||
if (!deletingConversation.value) return
|
||||
|
||||
deleting.value = true
|
||||
|
||||
try {
|
||||
const response = await conversationsAPI.delete(deletingConversation.value.id)
|
||||
if (response.success) {
|
||||
closeDeleteModal()
|
||||
fetchConversations()
|
||||
} else {
|
||||
throw new Error(response.message || '删除失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('删除失败:', err)
|
||||
alert(err.message || '删除失败,请重试')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '未知时间'
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
// 一天内显示相对时间
|
||||
if (diff < 86400000) {
|
||||
if (diff < 3600000) {
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
return `${minutes} 分钟前`
|
||||
}
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
return `${hours} 小时前`
|
||||
}
|
||||
|
||||
// 超过一天显示具体日期
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
|
||||
if (year === now.getFullYear()) {
|
||||
return `${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConversations()
|
||||
})
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.conversations {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-bg);
|
||||
}
|
||||
|
||||
.model-select {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 4px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: #fef2f2;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-container {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--accent-bg);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-container h3 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.empty-container p {
|
||||
color: var(--text);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* 会话列表 */
|
||||
.conversation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.conversation-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.conversation-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 4px 12px var(--shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.conversation-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--text-h);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conversation-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.conversation-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-border);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-pagination {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-pagination:hover:not(:disabled) {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-pagination:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
/* 模态框 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-small {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-body p {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #dc2626 !important;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-bg);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.conversation-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.conversation-actions {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
.conversations { padding: 0; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
|
||||
.header h1 { font-size: 2rem; margin: 0; color: var(--text-h); }
|
||||
.btn-primary { padding: 0.75rem 1.5rem; background: var(--accent); color: white; border: none; border-radius: 8px; cursor: pointer; }
|
||||
.btn-secondary { padding: 0.75rem 1.5rem; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; }
|
||||
.loading, .empty, .error-msg { text-align: center; padding: 4rem; color: var(--text); }
|
||||
.error-msg { background: #fef2f2; border-radius: 12px; }
|
||||
.list { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.card { display: flex; justify-content: space-between; align-items: center; padding: 1.25rem; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; cursor: pointer; transition: all 0.2s; }
|
||||
.card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||
.card h3 { margin: 0 0 0.5rem; color: var(--text-h); }
|
||||
.card p { margin: 0; color: var(--text); font-size: 0.875rem; }
|
||||
.btn-delete { background: transparent; border: none; font-size: 1.2rem; cursor: pointer; padding: 0.5rem; }
|
||||
.pagination { display: flex; justify-content: center; gap: 1rem; margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); }
|
||||
.pagination button { padding: 0.5rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
|
||||
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.modal { background: var(--bg); border-radius: 16px; padding: 2rem; width: 100%; max-width: 500px; }
|
||||
.modal h2 { margin: 0 0 1.5rem; color: var(--text-h); }
|
||||
.form-group { margin-bottom: 1.25rem; }
|
||||
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
|
||||
.form-group input, .form-group select { width: 100%; padding: 0.75rem; border: 1px solid var(--border); border-radius: 8px; font-size: 1rem; background: var(--bg); box-sizing: border-box; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 1rem; }
|
||||
.spinner { width: 48px; height: 48px; border: 4px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,830 +1,85 @@
|
|||
<template>
|
||||
<div class="home">
|
||||
<!-- Hero 区域 -->
|
||||
<div class="hero">
|
||||
<div class="hero-content">
|
||||
<h1>欢迎使用 Luxx</h1>
|
||||
<p class="subtitle">智能会话管理与工具平台</p>
|
||||
<div class="hero-actions">
|
||||
<router-link to="/conversations" class="btn-hero-primary">
|
||||
<span>💬</span> 开始会话
|
||||
</router-link>
|
||||
<router-link to="/tools" class="btn-hero-secondary">
|
||||
<span>🛠️</span> 查看工具
|
||||
</router-link>
|
||||
</div>
|
||||
<router-link to="/conversations" class="btn-primary">💬 开始会话</router-link>
|
||||
<router-link to="/tools" class="btn-secondary">🛠️ 查看工具</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 功能特性 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">💬<div><div class="stat-value">{{ stats.conversations }}</div><div class="stat-label">会话总数</div></div></div>
|
||||
<div class="stat-card">🛠️<div><div class="stat-value">{{ stats.tools }}</div><div class="stat-label">可用工具</div></div></div>
|
||||
<div class="stat-card">📝<div><div class="stat-value">{{ stats.messages }}</div><div class="stat-label">消息总数</div></div></div>
|
||||
<div class="stat-card">🤖<div><div class="stat-value">{{ stats.models }}</div><div class="stat-label">支持模型</div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<h2 class="section-title">核心功能</h2>
|
||||
<h2>核心功能</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">💬</div>
|
||||
<h3>智能会话</h3>
|
||||
<p>支持多模型对话,灵活配置参数,实时流式响应</p>
|
||||
<router-link to="/conversations" class="feature-link">管理会话 →</router-link>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛠️</div>
|
||||
<h3>工具生态</h3>
|
||||
<p>内置多种工具,支持代码执行、网页爬虫、数据分析</p>
|
||||
<router-link to="/tools" class="feature-link">探索工具 →</router-link>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔐</div>
|
||||
<h3>安全认证</h3>
|
||||
<p>完整的用户认证系统,支持JWT Token授权</p>
|
||||
<router-link to="/auth" class="feature-link">安全登录 →</router-link>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h3>数据分析</h3>
|
||||
<p>强大的数据处理能力,实时监控系统状态</p>
|
||||
<router-link to="/about" class="feature-link">了解更多 →</router-link>
|
||||
<div class="feature-card" v-for="f in features" :key="f.title">
|
||||
<div class="feature-icon">{{ f.icon }}</div>
|
||||
<h3>{{ f.title }}</h3>
|
||||
<p>{{ f.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计数据 -->
|
||||
<div class="stats-section">
|
||||
<h2 class="section-title">系统概览</h2>
|
||||
<div v-if="!isLoggedIn" class="login-prompt">
|
||||
<p>🔐 登录后查看更多统计信息</p>
|
||||
<router-link to="/auth" class="btn-login-prompt">立即登录</router-link>
|
||||
</div>
|
||||
<div v-else-if="loadingStats" class="loading-inline">
|
||||
<div class="spinner-small"></div>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else-if="statsError" class="stats-error">
|
||||
<p>⚠️ 无法加载统计数据</p>
|
||||
<button @click="fetchStats" class="btn-text">重试</button>
|
||||
</div>
|
||||
<div v-else class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">💬</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.conversations || 0 }}</div>
|
||||
<div class="stat-label">会话总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🛠️</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.tools || 0 }}</div>
|
||||
<div class="stat-label">可用工具</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📝</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.messages || 0 }}</div>
|
||||
<div class="stat-label">消息总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🤖</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.models || 1 }}</div>
|
||||
<div class="stat-label">支持模型</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<div class="quick-actions">
|
||||
<h2 class="section-title">快捷操作</h2>
|
||||
<div class="actions-grid">
|
||||
<button @click="createConversation" class="action-card">
|
||||
<div class="action-icon">➕</div>
|
||||
<div class="action-content">
|
||||
<h4>新建会话</h4>
|
||||
<p>开始新的智能对话</p>
|
||||
</div>
|
||||
</button>
|
||||
<button @click="navigateToTools" class="action-card">
|
||||
<div class="action-icon">🔍</div>
|
||||
<div class="action-content">
|
||||
<h4>浏览工具</h4>
|
||||
<p>查看所有可用工具</p>
|
||||
</div>
|
||||
</button>
|
||||
<button @click="navigateToConversations" class="action-card">
|
||||
<div class="action-icon">📋</div>
|
||||
<div class="action-content">
|
||||
<h4>会话历史</h4>
|
||||
<p>查看历史会话记录</p>
|
||||
</div>
|
||||
</button>
|
||||
<button @click="checkStatus" class="action-card">
|
||||
<div class="action-icon">📊</div>
|
||||
<div class="action-content">
|
||||
<h4>系统状态</h4>
|
||||
<p>监控服务健康状态</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近活动 -->
|
||||
<div class="recent-activity">
|
||||
<h2 class="section-title">最近会话</h2>
|
||||
<div v-if="!isLoggedIn" class="login-prompt">
|
||||
<p>🔐 登录后查看最近会话</p>
|
||||
<router-link to="/auth" class="btn-login-prompt">立即登录</router-link>
|
||||
</div>
|
||||
<div v-else-if="loadingRecent" class="loading-inline">
|
||||
<div class="spinner-small"></div>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else-if="recentConversations.length === 0" class="empty-recent">
|
||||
<p>暂无最近会话</p>
|
||||
<button @click="createConversation" class="btn-hero-primary">创建第一个会话</button>
|
||||
</div>
|
||||
<div v-else class="recent-list">
|
||||
<div
|
||||
v-for="conv in recentConversations"
|
||||
:key="conv.id"
|
||||
class="recent-item"
|
||||
@click="openConversation(conv.id)"
|
||||
>
|
||||
<div class="recent-icon">💬</div>
|
||||
<div class="recent-info">
|
||||
<h4>{{ conv.title || '未命名会话' }}</h4>
|
||||
<p>{{ formatDate(conv.created_at) }}</p>
|
||||
</div>
|
||||
<div class="recent-arrow">→</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 页脚提示 -->
|
||||
<div class="footer-note">
|
||||
<div class="footer-content">
|
||||
<p>🚀 正在运行 <strong>Luxx</strong> 智能会话系统</p>
|
||||
<p class="footer-links">
|
||||
<router-link to="/about">关于</router-link>
|
||||
<span class="divider">•</span>
|
||||
<a href="/api/docs" target="_blank">API 文档</a>
|
||||
<span class="divider">•</span>
|
||||
<router-link to="/auth">{{ isLoggedIn ? '个人中心' : '登录' }}</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-note">🚀 正在运行 <strong>Luxx</strong> 智能会话系统</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { conversationsAPI, toolsAPI } from '../services/api.js'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loadingStats = ref(false)
|
||||
const loadingRecent = ref(false)
|
||||
const statsError = ref(false)
|
||||
const stats = ref({
|
||||
conversations: 0,
|
||||
tools: 0,
|
||||
messages: 0,
|
||||
models: 1
|
||||
})
|
||||
const recentConversations = ref([])
|
||||
|
||||
const isLoggedIn = computed(() => {
|
||||
return !!localStorage.getItem('access_token')
|
||||
})
|
||||
|
||||
// 获取统计数据
|
||||
const fetchStats = async () => {
|
||||
if (!isLoggedIn.value) return
|
||||
|
||||
loadingStats.value = true
|
||||
statsError.value = false
|
||||
const stats = ref({ conversations: 0, tools: 0, messages: 0, models: 1 })
|
||||
const features = [
|
||||
{ icon: '💬', title: '智能会话', desc: '支持多模型对话,灵活配置参数' },
|
||||
{ icon: '🛠️', title: '工具生态', desc: '内置多种工具,扩展系统能力' },
|
||||
{ icon: '🔐', title: '安全认证', desc: 'JWT Token 授权,安全可靠' },
|
||||
{ icon: '📊', title: '数据分析', desc: '强大的数据处理能力' }
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 并行获取多个统计数据
|
||||
const [convsRes, toolsRes] = await Promise.allSettled([
|
||||
const [convs, tools] = await Promise.allSettled([
|
||||
conversationsAPI.list({ page: 1, page_size: 1 }),
|
||||
toolsAPI.list()
|
||||
])
|
||||
|
||||
// 处理会话统计
|
||||
if (convsRes.status === 'fulfilled' && convsRes.value.success) {
|
||||
stats.value.conversations = convsRes.value.data?.total || 0
|
||||
if (convs.status === 'fulfilled' && convs.value.success) stats.value.conversations = convs.value.data?.total || 0
|
||||
if (tools.status === 'fulfilled' && tools.value.success) {
|
||||
const t = tools.value.data?.tools || tools.value.data || []
|
||||
stats.value.tools = Array.isArray(t) ? t.length : 0
|
||||
}
|
||||
|
||||
// 处理工具统计
|
||||
if (toolsRes.status === 'fulfilled' && toolsRes.value.success) {
|
||||
const toolsData = toolsRes.value.data?.tools || toolsRes.value.data || []
|
||||
stats.value.tools = Array.isArray(toolsData) ? toolsData.length : 0
|
||||
}
|
||||
|
||||
// 估算消息总数(根据会话数和平均消息数)
|
||||
if (stats.value.conversations > 0) {
|
||||
stats.value.messages = stats.value.conversations * 5 // 假设平均每个会话5条消息
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('获取统计数据失败:', err)
|
||||
statsError.value = true
|
||||
} finally {
|
||||
loadingStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取最近会话
|
||||
const fetchRecentConversations = async () => {
|
||||
if (!isLoggedIn.value) return
|
||||
|
||||
loadingRecent.value = true
|
||||
|
||||
try {
|
||||
const response = await conversationsAPI.list({ page: 1, page_size: 5 })
|
||||
|
||||
if (response.success) {
|
||||
recentConversations.value = response.data?.items || []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取最近会话失败:', err)
|
||||
} finally {
|
||||
loadingRecent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新会话
|
||||
const createConversation = async () => {
|
||||
if (!isLoggedIn.value) {
|
||||
router.push('/auth')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await conversationsAPI.create({
|
||||
title: '',
|
||||
model: 'glm-5'
|
||||
})
|
||||
|
||||
if (response.success && response.data?.id) {
|
||||
router.push(`/conversations/${response.data.id}`)
|
||||
} else {
|
||||
router.push('/conversations')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建会话失败:', err)
|
||||
router.push('/conversations')
|
||||
}
|
||||
}
|
||||
|
||||
// 导航到工具页面
|
||||
const navigateToTools = () => {
|
||||
router.push('/tools')
|
||||
}
|
||||
|
||||
// 导航到会话列表
|
||||
const navigateToConversations = () => {
|
||||
router.push('/conversations')
|
||||
}
|
||||
|
||||
// 打开会话
|
||||
const openConversation = (id) => {
|
||||
router.push(`/conversations/${id}`)
|
||||
}
|
||||
|
||||
// 检查系统状态
|
||||
const checkStatus = () => {
|
||||
fetchStats()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '未知时间'
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 86400000) {
|
||||
if (diff < 3600000) {
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
return `${minutes} 分钟前`
|
||||
}
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
return `${hours} 小时前`
|
||||
}
|
||||
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${month}月${day}日 ${hours}:${minutes}`
|
||||
}
|
||||
return `${date.getFullYear()}-${month}-${day}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
fetchRecentConversations()
|
||||
stats.value.messages = stats.value.conversations * 5
|
||||
} catch (e) { console.error(e) }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Hero 区域 */
|
||||
.hero {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%);
|
||||
border-radius: 20px;
|
||||
padding: 3rem 2rem;
|
||||
margin-bottom: 3rem;
|
||||
color: white;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
animation: pulse 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.5; }
|
||||
50% { transform: scale(1.1); opacity: 0.3; }
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 3rem;
|
||||
margin: 0 0 1rem 0;
|
||||
color: white;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.3rem;
|
||||
opacity: 0.9;
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-hero-primary,
|
||||
.btn-hero-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 12px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-hero-primary {
|
||||
background: white;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-hero-primary:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-hero-secondary {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-hero-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
/* 区域标题 */
|
||||
.section-title {
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
/* 功能特性 */
|
||||
.features {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px var(--shadow);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.3rem;
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.feature-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.feature-link:hover {
|
||||
color: var(--accent-border);
|
||||
}
|
||||
|
||||
/* 统计数据 */
|
||||
.stats-section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.login-prompt {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: var(--code-bg);
|
||||
border-radius: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.login-prompt p {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.btn-login-prompt {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-login-prompt:hover {
|
||||
background: var(--accent-border);
|
||||
}
|
||||
|
||||
.loading-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 5px 20px var(--shadow);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-bg);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-h);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stats-error {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* 快捷操作 */
|
||||
.quick-actions {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 2rem;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--code-bg);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.action-card:hover .action-icon {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.action-card:hover .action-icon {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.action-content h4 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.action-content p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 最近会话 */
|
||||
.recent-activity {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.empty-recent {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
background: var(--accent-bg);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-recent p {
|
||||
color: var(--text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.recent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.recent-item:hover {
|
||||
border-color: var(--accent);
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 5px 15px var(--shadow);
|
||||
}
|
||||
|
||||
.recent-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.recent-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recent-info h4 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.recent-info p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.recent-arrow {
|
||||
color: var(--text);
|
||||
font-size: 1.2rem;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.recent-item:hover .recent-arrow {
|
||||
transform: translateX(5px);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 页脚 */
|
||||
.footer-note {
|
||||
background: var(--code-bg);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-content p {
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.footer-content strong {
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: var(--accent-border);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-links .divider {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.hero {
|
||||
padding: 2rem 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-hero-primary,
|
||||
.btn-hero-secondary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.features-grid,
|
||||
.stats-grid,
|
||||
.actions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.home {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
.home { padding: 0; max-width: 1200px; margin: 0 auto; }
|
||||
.hero { background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%); border-radius: 20px; padding: 3rem 2rem; margin-bottom: 3rem; color: white; text-align: center; }
|
||||
.hero h1 { font-size: 3rem; margin: 0 0 1rem; color: white; }
|
||||
.subtitle { font-size: 1.3rem; opacity: 0.9; margin: 0 0 2rem; }
|
||||
.hero-actions { display: flex; justify-content: center; gap: 1rem; }
|
||||
.btn-primary { padding: 1rem 2rem; background: white; color: var(--accent); border-radius: 12px; text-decoration: none; font-weight: 500; }
|
||||
.btn-secondary { padding: 1rem 2rem; background: rgba(255,255,255,0.2); color: white; border: 2px solid rgba(255,255,255,0.3); border-radius: 12px; text-decoration: none; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem; margin-bottom: 3rem; }
|
||||
.stat-card { display: flex; align-items: center; gap: 1rem; padding: 1.5rem; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; font-size: 2rem; }
|
||||
.stat-value { font-size: 2.5rem; font-weight: bold; color: var(--text-h); }
|
||||
.stat-label { color: var(--text); font-size: 0.9rem; }
|
||||
.features { margin-bottom: 3rem; }
|
||||
.features h2 { font-size: 1.8rem; margin: 0 0 1.5rem; color: var(--text-h); }
|
||||
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; }
|
||||
.feature-card { background: var(--bg); border: 1px solid var(--border); border-radius: 16px; padding: 2rem; transition: all 0.3s; }
|
||||
.feature-card:hover { border-color: var(--accent); transform: translateY(-5px); }
|
||||
.feature-icon { font-size: 3rem; margin-bottom: 1rem; }
|
||||
.feature-card h3 { font-size: 1.3rem; margin: 0 0 0.75rem; color: var(--text-h); }
|
||||
.feature-card p { color: var(--text); margin: 0; }
|
||||
.footer-note { background: var(--code-bg); border-radius: 16px; padding: 2rem; text-align: center; color: var(--text); }
|
||||
.footer-note strong { color: var(--text-h); }
|
||||
@media (max-width: 768px) { .hero h1 { font-size: 2rem; } .hero-actions { flex-direction: column; } .btn-primary, .btn-secondary { width: 100%; justify-content: center; } }
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue