refactor: 优化前端界面

This commit is contained in:
ViperEkura 2026-04-12 17:48:56 +08:00
parent d9e8a15c87
commit a5be5b1fdc
9 changed files with 564 additions and 3725 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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'

View File

@ -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
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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