refactor: 优化样式

This commit is contained in:
ViperEkura 2026-04-12 20:45:25 +08:00
parent fffd3f36d8
commit fc0a499a9f
19 changed files with 158 additions and 429 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 489 B

View File

@ -21,6 +21,8 @@ const sidebarCollapsed = ref(false)
.main-content { flex: 1; margin-left: 260px; padding: 2rem; min-height: 100vh; transition: margin-left 0.3s; } .main-content { flex: 1; margin-left: 260px; padding: 2rem; min-height: 100vh; transition: margin-left 0.3s; }
.main-content.no-sidebar { margin-left: 0; } .main-content.no-sidebar { margin-left: 0; }
.sidebar-collapsed .main-content { margin-left: 70px; } .sidebar-collapsed .main-content { margin-left: 70px; }
@media (max-width: 1024px) { .main-content { margin-left: 70px; } } @media (max-width: 768px) {
@media (max-width: 768px) { .main-content { margin-left: 0; } } .main-content { margin-left: 70px; }
.sidebar-collapsed .main-content { margin-left: 0; }
}
</style> </style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

View File

@ -1,119 +1,125 @@
<template> <template>
<aside class="sidebar" :class="{ collapsed }"> <aside class="sidebar" :class="{ collapsed }">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="brand"> <button v-if="collapsed" @click="$emit('toggle')" class="expand-btn" title="展开侧栏">
<span class="brand-icon"></span>
<span class="brand-text">Luxx</span> </button>
</div> <template v-else>
<button @click="$emit('toggle')" class="toggle-btn">{{ collapsed ? '→' : '←' }}</button> <div class="brand">
<span class="brand-text"> Luxx</span>
</div>
<button @click="$emit('toggle')" class="toggle-btn" title="收起侧栏">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M10 12L6 8L10 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</template>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<router-link v-for="item in navItems" :key="item.path" :to="item.path" class="nav-item" active-class="active"> <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> <span class="nav-text">{{ item.label }}</span>
</router-link> </router-link>
</nav> </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> </aside>
</template> </template>
<script setup> <script setup>
import { useAuth } from '../composables/useAuth.js'
import { authAPI } from '../services/api.js'
import { useRouter } from 'vue-router'
defineProps({ collapsed: Boolean }) defineProps({ collapsed: Boolean })
defineEmits(['toggle']) defineEmits(['toggle'])
const router = useRouter()
const { user, logout } = useAuth()
const navItems = [ const navItems = [
{ path: '/', icon: '🏠', label: '首页' }, { path: '/', label: '首页' },
{ path: '/conversations', icon: '💬', label: '会话' }, { path: '/conversations', label: '会话' },
{ path: '/tools', icon: '🛠️', label: '工具' }, { path: '/tools', label: '工具' },
{ path: '/settings', icon: '⚙️', label: '设置' }, { path: '/settings', label: '设置' }
{ path: '/about', icon: '', label: '关于' }
] ]
const handleLogout = async () => {
await logout(authAPI)
router.push('/auth')
}
</script> </script>
<style scoped> <style scoped>
.sidebar { .sidebar {
width: 260px; height: 100vh; width: var(--sidebar-width, 260px);
background: var(--bg); border-right: 1px solid var(--border); height: 100vh;
display: flex; flex-direction: column; background: var(--bg);
position: fixed; left: 0; top: 0; z-index: 100; border-right: 1px solid var(--border);
transition: width 0.3s; display: flex;
flex-direction: column;
position: fixed;
left: 0;
top: 0;
z-index: 100;
transition: width 0.3s ease;
} }
.sidebar.collapsed { width: 70px; } .sidebar.collapsed { width: var(--sidebar-collapsed-width, 70px); }
.sidebar-header { .sidebar-header {
display: flex; align-items: center; justify-content: space-between; display: flex;
padding: 1.25rem; border-bottom: 1px solid var(--border); align-items: center;
justify-content: space-between;
padding: 1.25rem;
border-bottom: 1px solid var(--border);
min-height: 60px;
}
.sidebar.collapsed .sidebar-header {
justify-content: center;
} }
.brand { display: flex; align-items: center; gap: 0.75rem; } .brand { display: flex; align-items: center; gap: 0.75rem; }
.brand-icon { font-size: 1.75rem; }
.brand-text { .brand-text {
font-size: 1.5rem; font-weight: 700; font-size: 1.5rem; font-weight: 700;
background: linear-gradient(135deg, var(--accent) 0%, #8b5cf6 100%); color: var(--accent);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; white-space: nowrap;
} }
.collapsed .brand-text, .collapsed .toggle-btn { display: none; }
.toggle-btn { .toggle-btn {
background: var(--code-bg); border: none; border-radius: 6px; background: var(--code-bg);
padding: 0.5rem 0.75rem; cursor: pointer; border: none;
border-radius: 6px;
padding: 0.5rem;
cursor: pointer;
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
} }
.toggle-btn:hover { color: var(--accent); }
.expand-btn {
background: var(--accent);
border: none;
border-radius: 8px;
padding: 0.25rem 0.5rem;
cursor: pointer;
color: white;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
transition: transform 0.2s;
}
.expand-btn:hover { transform: scale(1.1); }
.sidebar-nav { flex: 1; padding: 1rem 0.75rem; overflow-y: auto; } .sidebar-nav { flex: 1; padding: 1rem 0.75rem; overflow-y: auto; }
.nav-item { .nav-item {
display: flex; align-items: center; gap: 0.875rem; display: flex;
padding: 0.875rem 1rem; border-radius: 10px; align-items: center;
text-decoration: none; color: var(--text); margin-bottom: 0.25rem; gap: 0.875rem;
padding: 0.875rem 1rem;
border-radius: 10px;
text-decoration: none;
color: var(--text);
margin-bottom: 0.25rem;
transition: all 0.2s; transition: all 0.2s;
} }
.nav-item:hover { background: var(--code-bg); color: var(--text-h); } .nav-item:hover { background: var(--code-bg); color: var(--text-h); }
.nav-item.active { background: var(--accent-bg); color: var(--accent); } .nav-item.active { background: var(--accent-bg); color: var(--accent); }
.nav-icon { font-size: 1.25rem; width: 24px; text-align: center; } .nav-text {
.nav-text { font-size: 0.95rem; font-weight: 500; white-space: nowrap; } font-size: 0.95rem;
font-weight: 500;
white-space: nowrap;
}
.collapsed .nav-text { display: none; } .collapsed .nav-text { display: none; }
.collapsed .nav-item { justify-content: center; padding: 0.875rem; } .collapsed .nav-item { display: none; }
.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) { @media (max-width: 768px) {
.sidebar { width: 70px; } .sidebar { width: 0 !important; overflow: hidden; }
.brand-text, .toggle-btn, .nav-text, .user-info { display: none !important; } .sidebar-header { display: none; }
.nav-item { justify-content: center; padding: 0.875rem; } .sidebar-nav { display: none; }
.user-button { justify-content: center; }
} }
</style> </style>

View File

@ -1,50 +0,0 @@
<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

@ -1,6 +1,6 @@
<template> <template>
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon">{{ icon }}</div> <div class="empty-icon"></div>
<h3>{{ title }}</h3> <h3>{{ title }}</h3>
<p>{{ description }}</p> <p>{{ description }}</p>
<button v-if="actionText" @click="$emit('action')" class="btn-action"> <button v-if="actionText" @click="$emit('action')" class="btn-action">
@ -11,10 +11,6 @@
<script setup> <script setup>
defineProps({ defineProps({
icon: {
type: String,
default: '📭'
},
title: { title: {
type: String, type: String,
default: '暂无数据' default: '暂无数据'
@ -45,7 +41,10 @@ defineEmits(['action'])
} }
.empty-icon { .empty-icon {
font-size: 4rem; width: 64px;
height: 64px;
background: var(--accent);
border-radius: 16px;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -74,7 +73,7 @@ defineEmits(['action'])
} }
.btn-action:hover { .btn-action:hover {
background: var(--accent-border); opacity: 0.9;
transform: translateY(-2px); transform: translateY(-2px);
} }
</style> </style>

View File

@ -1,12 +1,12 @@
<template> <template>
<div class="error-message" :class="typeClass"> <div class="error-message" :class="typeClass">
<div class="error-icon">{{ icon }}</div> <div class="error-icon"></div>
<div class="error-content"> <div class="error-content">
<h4 v-if="title">{{ title }}</h4> <h4 v-if="title">{{ title }}</h4>
<p>{{ message }}</p> <p>{{ message }}</p>
</div> </div>
<button v-if="showRetry" @click="$emit('retry')" class="btn-retry"> <button v-if="showRetry" @click="$emit('retry')" class="btn-retry">
🔄 重试 重试
</button> </button>
</div> </div>
</template> </template>
@ -37,14 +37,6 @@ const props = defineProps({
defineEmits(['retry']) defineEmits(['retry'])
const typeClass = computed(() => `type-${props.type}`) const typeClass = computed(() => `type-${props.type}`)
const icon = computed(() => {
switch (props.type) {
case 'warning': return '⚠️'
case 'info': return ''
default: return '❌'
}
})
</script> </script>
<style scoped> <style scoped>
@ -59,9 +51,9 @@ const icon = computed(() => {
} }
.type-error { .type-error {
background: #fef2f2; background: var(--accent-bg);
border: 1px solid #fecaca; border: 1px solid var(--accent-border);
color: #dc2626; color: var(--accent);
} }
.type-warning { .type-warning {
@ -77,7 +69,11 @@ const icon = computed(() => {
} }
.error-icon { .error-icon {
font-size: 3rem; width: 48px;
height: 48px;
background: currentColor;
opacity: 0.2;
border-radius: 50%;
} }
.error-content h4 { .error-content h4 {

View File

@ -1,93 +0,0 @@
<script setup>
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button class="counter" @click="count++">Count is {{ count }}</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>

View File

@ -1,70 +0,0 @@
<template>
<div class="loading-spinner" :class="sizeClass">
<div class="spinner"></div>
<p v-if="text" class="loading-text">{{ text }}</p>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large'].includes(value)
},
text: {
type: String,
default: ''
}
})
const sizeClass = computed(() => `size-${props.size}`)
</script>
<style scoped>
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.spinner {
border-radius: 50%;
border-style: solid;
border-color: var(--border);
border-top-color: var(--accent);
animation: spin 1s linear infinite;
}
.size-small .spinner {
width: 24px;
height: 24px;
border-width: 3px;
}
.size-medium .spinner {
width: 48px;
height: 48px;
border-width: 4px;
}
.size-large .spinner {
width: 64px;
height: 64px;
border-width: 5px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin: 1rem 0 0 0;
color: var(--text);
font-size: 0.95rem;
}
</style>

View File

@ -1,9 +1,3 @@
// 导出 composables
export * from './composables/useApi.js'
export * from './composables/useFormatters.js'
export * from './composables/useUtils.js'
// 导出组件 // 导出组件
export { default as LoadingSpinner } from './components/LoadingSpinner.vue'
export { default as ErrorMessage } from './components/ErrorMessage.vue' export { default as ErrorMessage } from './components/ErrorMessage.vue'
export { default as EmptyState } from './components/EmptyState.vue' export { default as EmptyState } from './components/EmptyState.vue'

View File

@ -4,9 +4,9 @@
--bg: #fff; --bg: #fff;
--border: #e5e4e7; --border: #e5e4e7;
--code-bg: #f4f3ec; --code-bg: #f4f3ec;
--accent: #aa3bff; --accent: #2563eb;
--accent-bg: rgba(170, 59, 255, 0.1); --accent-bg: rgba(37, 99, 235, 0.1);
--accent-border: rgba(170, 59, 255, 0.5); --accent-border: rgba(37, 99, 235, 0.5);
--social-bg: rgba(244, 243, 236, 0.5); --social-bg: rgba(244, 243, 236, 0.5);
--shadow: --shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
@ -37,9 +37,9 @@
--bg: #16171d; --bg: #16171d;
--border: #2e303a; --border: #2e303a;
--code-bg: #1f2028; --code-bg: #1f2028;
--accent: #c084fc; --accent: #60a5fa;
--accent-bg: rgba(192, 132, 252, 0.15); --accent-bg: rgba(96, 165, 250, 0.15);
--accent-border: rgba(192, 132, 252, 0.5); --accent-border: rgba(96, 165, 250, 0.5);
--social-bg: rgba(47, 48, 58, 0.5); --social-bg: rgba(47, 48, 58, 0.5);
--shadow: --shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;

View File

@ -1,19 +0,0 @@
<template>
<div class="about">
<h1>关于此项目</h1>
<p>这是 Luxx 项目的管理仪表板前端</p>
<p>技术栈Vue 3 + Vue Router + Pinia + Vite</p>
<router-link to="/">返回首页</router-link>
</div>
</template>
<script setup>
//
</script>
<style scoped>
.about {
padding: 2rem;
text-align: center;
}
</style>

View File

@ -7,14 +7,14 @@
<p>开始对话吧</p> <p>开始对话吧</p>
</div> </div>
<div v-for="msg in messages" :key="msg.id" :class="['message', msg.role]"> <div v-for="msg in messages" :key="msg.id" :class="['message', msg.role]">
<div class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div> <div class="message-avatar">{{ msg.role === 'user' ? 'U' : 'A' }}</div>
<div class="message-content"> <div class="message-content">
<div class="message-text">{{ msg.content }}</div> <div class="message-text">{{ msg.content }}</div>
<div class="message-time">{{ formatTime(msg.created_at) }}</div> <div class="message-time">{{ formatTime(msg.created_at) }}</div>
</div> </div>
</div> </div>
<div v-if="streaming" class="message assistant streaming"> <div v-if="streaming" class="message assistant streaming">
<div class="message-avatar">🤖</div> <div class="message-avatar">A</div>
<div class="message-content"> <div class="message-content">
<div class="message-text">{{ streamContent }}<span class="cursor"></span></div> <div class="message-text">{{ streamContent }}<span class="cursor"></span></div>
</div> </div>

View File

@ -15,7 +15,7 @@
<h3>{{ c.title || '未命名会话' }}</h3> <h3>{{ c.title || '未命名会话' }}</h3>
<p>{{ formatDate(c.created_at) }} {{ c.model || '默认模型' }}</p> <p>{{ formatDate(c.created_at) }} {{ c.model || '默认模型' }}</p>
</div> </div>
<button @click.stop="deleteConv(c)" class="btn-delete">🗑</button> <button @click.stop="deleteConv(c)" class="btn-delete">删除</button>
</div> </div>
</div> </div>
@ -150,7 +150,8 @@ onMounted(fetchData)
.card:hover { border-color: var(--accent); transform: translateY(-2px); } .card:hover { border-color: var(--accent); transform: translateY(-2px); }
.card h3 { margin: 0 0 0.5rem; color: var(--text-h); } .card h3 { margin: 0 0 0.5rem; color: var(--text-h); }
.card p { margin: 0; color: var(--text); font-size: 0.875rem; } .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; } .btn-delete { background: var(--accent-bg); color: var(--accent); border: 1px solid var(--accent-border); font-size: 0.875rem; cursor: pointer; padding: 0.25rem 0.75rem; border-radius: 6px; transition: all 0.2s; }
.btn-delete:hover { background: var(--accent); color: white; }
.pagination { display: flex; justify-content: center; gap: 1rem; margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); } .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 { 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; } .pagination button:disabled { opacity: 0.5; cursor: not-allowed; }

View File

@ -4,30 +4,19 @@
<h1>欢迎使用 Luxx</h1> <h1>欢迎使用 Luxx</h1>
<p class="subtitle">智能会话管理与工具平台</p> <p class="subtitle">智能会话管理与工具平台</p>
<div class="hero-actions"> <div class="hero-actions">
<router-link to="/conversations" class="btn-primary">💬 开始会话</router-link> <router-link to="/conversations" class="btn-primary">开始会话</router-link>
<router-link to="/tools" class="btn-secondary">🛠 查看工具</router-link> <router-link to="/tools" class="btn-secondary">查看工具</router-link>
</div> </div>
</div> </div>
<div class="stats-grid"> <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.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.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.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 class="stat-card"><div><div class="stat-value">{{ stats.models }}</div><div class="stat-label">支持模型</div></div></div>
</div> </div>
<div class="features"> <div class="footer-note">正在运行 <strong>Luxx</strong> 智能会话系统</div>
<h2>核心功能</h2>
<div class="features-grid">
<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="footer-note">🚀 正在运行 <strong>Luxx</strong> 智能会话系统</div>
</div> </div>
</template> </template>
@ -36,12 +25,6 @@ import { ref, onMounted } from 'vue'
import { conversationsAPI, toolsAPI } from '../services/api.js' import { conversationsAPI, toolsAPI } from '../services/api.js'
const stats = ref({ conversations: 0, tools: 0, messages: 0, models: 1 }) 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 () => { onMounted(async () => {
try { try {
@ -61,14 +44,14 @@ onMounted(async () => {
<style scoped> <style scoped>
.home { padding: 0; max-width: 1200px; margin: 0 auto; } .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 { background: linear-gradient(135deg, #2563eb 0%, #3b82f6 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; } .hero h1 { font-size: 3rem; margin: 0 0 1rem; color: white; }
.subtitle { font-size: 1.3rem; opacity: 0.9; margin: 0 0 2rem; } .subtitle { font-size: 1.3rem; opacity: 0.9; margin: 0 0 2rem; }
.hero-actions { display: flex; justify-content: center; gap: 1rem; } .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-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; } .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; } .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-card { display: flex; align-items: center; gap: 1rem; padding: 1.5rem; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; }
.stat-value { font-size: 2.5rem; font-weight: bold; color: var(--text-h); } .stat-value { font-size: 2.5rem; font-weight: bold; color: var(--text-h); }
.stat-label { color: var(--text); font-size: 0.9rem; } .stat-label { color: var(--text); font-size: 0.9rem; }
.features { margin-bottom: 3rem; } .features { margin-bottom: 3rem; }
@ -76,7 +59,6 @@ onMounted(async () => {
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; } .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 { 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-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 h3 { font-size: 1.3rem; margin: 0 0 0.75rem; color: var(--text-h); }
.feature-card p { color: var(--text); margin: 0; } .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 { background: var(--code-bg); border-radius: 16px; padding: 2rem; text-align: center; color: var(--text); }

View File

@ -16,7 +16,7 @@
<div class="card-header"> <div class="card-header">
<div class="card-title"> <div class="card-title">
<h3>{{ p.name }}</h3> <h3>{{ p.name }}</h3>
<span v-if="p.is_default" class="badge default"> 默认</span> <span v-if="p.is_default" class="badge default">默认</span>
</div> </div>
<label class="switch"> <label class="switch">
<input type="checkbox" :checked="p.enabled" @change="toggleEnabled(p)" /> <input type="checkbox" :checked="p.enabled" @change="toggleEnabled(p)" />
@ -25,15 +25,13 @@
</div> </div>
<div class="card-info"> <div class="card-info">
<div class="info-row"><span class="label">类型:</span> {{ p.provider_type }}</div> <div class="info-row"><span class="label">API:</span><span class="value">{{ p.base_url }}</span></div>
<div class="info-row"><span class="label">API:</span> {{ p.base_url }}</div> <div class="info-row"><span class="label">模型:</span><span class="value">{{ p.default_model }}</span></div>
<div class="info-row"><span class="label">模型:</span> {{ p.default_model }}</div>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button @click="testProvider(p)" :disabled="testing === p.id">🔗 测试</button> <button @click="testProvider(p)" :disabled="testing === p.id">测试</button>
<button @click="editProvider(p)"> 编辑</button> <button @click="deleteProvider(p)" class="btn-danger">删除</button>
<button @click="deleteProvider(p)" class="btn-danger">🗑 删除</button>
</div> </div>
</div> </div>
</div> </div>
@ -61,7 +59,7 @@
<label class="radio-card" :class="{ active: form.is_default }"> <label class="radio-card" :class="{ active: form.is_default }">
<input v-model="form.is_default" type="checkbox" /> <input v-model="form.is_default" type="checkbox" />
<div class="radio-content"> <div class="radio-content">
<span class="radio-icon"></span> <span class="radio-icon"></span>
<div class="radio-text"> <div class="radio-text">
<span class="radio-title">设为默认 Provider</span> <span class="radio-title">设为默认 Provider</span>
<span class="radio-desc">新会话将默认使用此 Provider</span> <span class="radio-desc">新会话将默认使用此 Provider</span>
@ -116,15 +114,6 @@ const closeModal = () => {
formError.value = '' formError.value = ''
} }
const editProvider = (p) => {
editing.value = p.id
form.value = {
name: p.name, provider_type: p.provider_type, base_url: p.base_url,
api_key: p.api_key, default_model: p.default_model, is_default: p.is_default
}
showModal.value = true
}
const saveProvider = async () => { const saveProvider = async () => {
if (!form.value.name || !form.value.base_url || !form.value.api_key) { if (!form.value.name || !form.value.base_url || !form.value.api_key) {
formError.value = '请填写所有必填项' formError.value = '请填写所有必填项'
@ -181,25 +170,27 @@ onMounted(fetchProviders)
.actions { margin-bottom: 2rem; } .actions { margin-bottom: 2rem; }
.btn-primary { padding: 0.75rem 1.5rem; background: var(--accent); color: white; border: none; border-radius: 8px; cursor: pointer; } .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; } .btn-secondary { padding: 0.75rem 1.5rem; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; }
.btn-danger { background: #dc2626; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; } .btn-danger { background: var(--accent-bg); color: var(--accent); border: 1px solid var(--accent-border); padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; transition: all 0.2s; }
.btn-danger:hover { background: var(--accent); color: white; }
.loading, .empty, .error { text-align: center; padding: 4rem; color: var(--text); } .loading, .empty, .error { text-align: center; padding: 4rem; color: var(--text); }
.error { background: #fef2f2; border-radius: 12px; color: #dc2626; } .error { background: var(--accent-bg); border-radius: 12px; color: var(--accent); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; }
.card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; } .card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; }
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; } .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.card-title { display: flex; align-items: center; gap: 0.5rem; } .card-title { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.card-title h3 { margin: 0; color: var(--text-h); } .card-title h3 { margin: 0; color: var(--text-h); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.75rem; background: var(--code-bg); color: var(--text); } .badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.75rem; background: var(--code-bg); color: var(--text); flex-shrink: 0; }
.badge.default { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; } .badge.default { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; }
.switch { position: relative; display: inline-block; width: 48px; height: 26px; } .switch { position: relative; display: inline-block; width: 48px; height: 26px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; } .switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; inset: 0; background-color: #ccc; transition: 0.3s; border-radius: 26px; } .slider { position: absolute; cursor: pointer; inset: 0; background-color: #ccc; transition: 0.3s; border-radius: 26px; }
.slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; } .slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
input:checked + .slider { background-color: #16a34a; } input:checked + .slider { background-color: #16a34a; }
input:checked + .slider:before { transform: translateX(22px); } input:checked + .slider:before { transform: translateX(22px); }
.card-info { margin-bottom: 1rem; } .card-info { margin-bottom: 1rem; flex: 1; }
.info-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9rem; } .info-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.label { color: var(--text); min-width: 50px; } .info-row .label { color: var(--text); min-width: 50px; flex-shrink: 0; }
.info-row .value { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } .card-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.card-actions button { padding: 0.5rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; } .card-actions button { padding: 0.5rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; }
.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-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; }

View File

@ -3,8 +3,8 @@
<h1>工具管理</h1> <h1>工具管理</h1>
<div class="stats"> <div class="stats">
<div class="stat">🛠 {{ list.length }} 个工具</div> <div class="stat">{{ list.length }} 个工具</div>
<button @click="fetchData" :disabled="loading" class="btn-refresh">🔄 刷新</button> <button @click="fetchData" :disabled="loading" class="btn-refresh">刷新</button>
</div> </div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div> <div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
@ -12,12 +12,11 @@
<div v-else class="grid"> <div v-else class="grid">
<div v-for="tool in list" :key="tool.name" class="card"> <div v-for="tool in list" :key="tool.name" class="card">
<div class="card-header"> <div class="card-header">
<span class="icon">{{ getIcon(tool.name) }}</span>
<span :class="['badge', tool.enabled ? 'enabled' : 'disabled']">{{ tool.enabled ? '已启用' : '已禁用' }}</span> <span :class="['badge', tool.enabled ? 'enabled' : 'disabled']">{{ tool.enabled ? '已启用' : '已禁用' }}</span>
</div> </div>
<h3>{{ tool.name }}</h3> <h3>{{ tool.name }}</h3>
<p>{{ tool.description || '暂无描述' }}</p> <p>{{ tool.description || '暂无描述' }}</p>
<button @click="showDetail(tool)" class="btn-info"> 查看详情</button> <button @click="showDetail(tool)" class="btn-info">查看详情</button>
</div> </div>
</div> </div>
@ -66,14 +65,6 @@ const fetchData = async () => {
finally { loading.value = false } finally { loading.value = false }
} }
const getIcon = (name) => {
const n = name?.toLowerCase() || ''
if (n.includes('code') || n.includes('python')) return '💻'
if (n.includes('web') || n.includes('search') || n.includes('crawl')) return '🌐'
if (n.includes('data')) return '📊'
return '🛠️'
}
const showDetail = (tool) => { detail.value = tool } const showDetail = (tool) => { detail.value = tool }
onMounted(fetchData) onMounted(fetchData)
@ -84,19 +75,18 @@ onMounted(fetchData)
.tools h1 { font-size: 2rem; margin: 0 0 1.5rem; color: var(--text-h); } .tools h1 { font-size: 2rem; margin: 0 0 1.5rem; color: var(--text-h); }
.stats { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; } .stats { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
.stat { font-size: 1.1rem; color: var(--text); } .stat { font-size: 1.1rem; color: var(--text); }
.btn-refresh { padding: 0.5rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; } .btn-refresh { padding: 0.5rem 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; color: var(--accent); }
.loading, .error-msg { text-align: center; padding: 4rem; color: var(--text); } .loading, .error-msg { text-align: center; padding: 4rem; color: var(--text); }
.error-msg { background: #fef2f2; border-radius: 12px; } .error-msg { background: var(--accent-bg); border-radius: 12px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; }
.card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; } .card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; display: flex; flex-direction: column; }
.card-header { display: flex; justify-content: space-between; margin-bottom: 1rem; } .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.icon { font-size: 2.5rem; } .badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.8rem; font-weight: 500; flex-shrink: 0; }
.badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.8rem; font-weight: 500; } .badge.enabled { background: var(--accent-bg); color: var(--accent); }
.badge.enabled { background: #d4edda; color: #155724; } .badge.disabled { background: var(--code-bg); color: var(--text); }
.badge.disabled { background: #f8d7da; color: #721c24; } .card h3 { margin: 0 0 0.75rem; font-size: 1.25rem; color: var(--text-h); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card h3 { margin: 0 0 0.75rem; font-size: 1.25rem; color: var(--text-h); } .card p { color: var(--text); font-size: 0.95rem; margin: 0 0 1rem; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; flex: 1; }
.card p { color: var(--text); font-size: 0.95rem; margin: 0 0 1rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .btn-info { background: transparent; border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 1rem; cursor: pointer; color: var(--accent); align-self: flex-start; }
.btn-info { background: transparent; border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 1rem; cursor: pointer; }
.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-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: 600px; max-height: 80vh; overflow-y: auto; } .modal { background: var(--bg); border-radius: 16px; padding: 2rem; width: 100%; max-width: 600px; max-height: 80vh; overflow-y: auto; }
.modal h2 { margin: 0 0 1rem; color: var(--text-h); } .modal h2 { margin: 0 0 1rem; color: var(--text-h); }