refactor: 优化样式

This commit is contained in:
ViperEkura 2026-04-12 21:14:58 +08:00
parent fffd3f36d8
commit 9a078c24b6
20 changed files with 401 additions and 459 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.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; } }
@media (max-width: 768px) {
.main-content { margin-left: 70px; }
.sidebar-collapsed .main-content { margin-left: 0; }
}
</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>
<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>
<button v-if="collapsed" @click="$emit('toggle')" class="expand-btn" title="展开侧栏">
</button>
<template v-else>
<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>
<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: '/settings', icon: '⚙️', label: '设置' },
{ path: '/about', icon: '', label: '关于' }
{ path: '/', label: '首页' },
{ path: '/conversations', label: '会话' },
{ path: '/tools', label: '工具' },
{ path: '/settings', 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;
width: var(--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 ease;
}
.sidebar.collapsed { width: 70px; }
.sidebar.collapsed { width: var(--sidebar-collapsed-width, 70px); }
.sidebar-header {
display: flex; align-items: center; justify-content: space-between;
padding: 1.25rem; border-bottom: 1px solid var(--border);
display: flex;
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-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;
color: var(--accent);
white-space: nowrap;
}
.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;
background: var(--code-bg);
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; }
.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;
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; }
.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; }
.collapsed .nav-item { display: none; }
@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; }
@media (max-width: 768px) {
.sidebar { width: 0 !important; overflow: hidden; }
.sidebar-header { display: none; }
.sidebar-nav { display: none; }
}
</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>
<div class="empty-state">
<div class="empty-icon">{{ icon }}</div>
<div class="empty-icon"></div>
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<button v-if="actionText" @click="$emit('action')" class="btn-action">
@ -11,10 +11,6 @@
<script setup>
defineProps({
icon: {
type: String,
default: '📭'
},
title: {
type: String,
default: '暂无数据'
@ -45,7 +41,10 @@ defineEmits(['action'])
}
.empty-icon {
font-size: 4rem;
width: 64px;
height: 64px;
background: var(--accent);
border-radius: 16px;
margin-bottom: 1rem;
}
@ -74,7 +73,7 @@ defineEmits(['action'])
}
.btn-action:hover {
background: var(--accent-border);
opacity: 0.9;
transform: translateY(-2px);
}
</style>

View File

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

View File

@ -4,9 +4,9 @@
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--accent: #2563eb;
--accent-bg: rgba(37, 99, 235, 0.1);
--accent-border: rgba(37, 99, 235, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
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;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--accent: #60a5fa;
--accent-bg: rgba(96, 165, 250, 0.15);
--accent-border: rgba(96, 165, 250, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
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>
</div>
<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-text">{{ msg.content }}</div>
<div class="message-time">{{ formatTime(msg.created_at) }}</div>
</div>
</div>
<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-text">{{ streamContent }}<span class="cursor"></span></div>
</div>

View File

@ -15,7 +15,7 @@
<h3>{{ c.title || '未命名会话' }}</h3>
<p>{{ formatDate(c.created_at) }} {{ c.model || '默认模型' }}</p>
</div>
<button @click.stop="deleteConv(c)" class="btn-delete">🗑</button>
<button @click.stop="deleteConv(c)" class="btn-delete">删除</button>
</div>
</div>
@ -150,7 +150,8 @@ onMounted(fetchData)
.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; }
.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 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; }

View File

@ -4,30 +4,19 @@
<h1>欢迎使用 Luxx</h1>
<p class="subtitle">智能会话管理与工具平台</p>
<div class="hero-actions">
<router-link to="/conversations" class="btn-primary">💬 开始会话</router-link>
<router-link to="/tools" class="btn-secondary">🛠 查看工具</router-link>
<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 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>核心功能</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 class="footer-note">正在运行 <strong>Luxx</strong> 智能会话系统</div>
</div>
</template>
@ -36,12 +25,6 @@ import { ref, onMounted } from 'vue'
import { conversationsAPI, toolsAPI } from '../services/api.js'
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 {
@ -61,14 +44,14 @@ onMounted(async () => {
<style scoped>
.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; }
.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-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-label { color: var(--text); font-size: 0.9rem; }
.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; }
.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); }

View File

@ -1,10 +1,42 @@
<template>
<div class="settings">
<h1>LLM Provider 设置</h1>
<p class="subtitle">配置您的 AI 模型提供商</p>
<h1>设置</h1>
<div class="actions">
<button @click="showModal = true" class="btn-primary">+ 添加 Provider</button>
<div class="section">
<div class="section-header">
<h2>用户信息</h2>
</div>
<div v-if="loadingUser" class="loading-small"><div class="spinner-small"></div>加载中...</div>
<div v-else class="user-info-card">
<div class="info-item"><span class="label">用户名</span><span class="value">{{ userForm.username || '-' }}</span></div>
<div class="info-item"><span class="label">邮箱</span><span class="value">{{ userForm.email || '-' }}</span></div>
<div class="card-actions">
<button @click="openUserModal" class="btn-primary">编辑</button>
<button @click="handleLogout" class="btn-danger">退出登录</button>
</div>
</div>
</div>
<!-- 用户信息模态框 -->
<div v-if="showUserModal" class="modal-overlay" @click.self="closeUserModal">
<div class="modal">
<h2>编辑用户信息</h2>
<div class="form-group"><label>用户名</label><input v-model="userFormEdit.username" placeholder="输入用户名" /></div>
<div class="form-group"><label>邮箱</label><input v-model="userFormEdit.email" type="email" placeholder="输入邮箱" /></div>
<div class="form-group"><label>新密码 <span class="optional">(留空不修改)</span></label><input v-model="userFormEdit.password" type="password" placeholder="输入新密码" /></div>
<div v-if="userFormError" class="error">{{ userFormError }}</div>
<div class="modal-actions">
<button @click="closeUserModal" class="btn-secondary">取消</button>
<button @click="updateUser" :disabled="savingUser" class="btn-primary">{{ savingUser ? '保存中...' : '保存' }}</button>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>LLM Provider</h2>
<button @click="showModal = true" class="btn-primary">+ 添加</button>
</div>
</div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
@ -16,7 +48,7 @@
<div class="card-header">
<div class="card-title">
<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>
<label class="switch">
<input type="checkbox" :checked="p.enabled" @change="toggleEnabled(p)" />
@ -25,48 +57,56 @@
</div>
<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> {{ p.base_url }}</div>
<div class="info-row"><span class="label">模型:</span> {{ p.default_model }}</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">模型:</span><span class="value">{{ p.default_model }}</span></div>
</div>
<div class="card-actions">
<button @click="testProvider(p)" :disabled="testing === p.id">🔗 测试</button>
<button @click="editProvider(p)"> 编辑</button>
<button @click="deleteProvider(p)" class="btn-danger">🗑 删除</button>
<button @click="editProvider(p)">编辑</button>
<button @click="testProvider(p)" :disabled="testing === p.id">测试</button>
<button @click="deleteProvider(p)" class="btn-danger">删除</button>
</div>
</div>
</div>
<!-- 测试结果弹窗 -->
<div v-if="testResult !== null" class="modal-overlay" @click.self="testResult = null">
<div class="modal result-modal" :class="{ success: testResult.success, error: !testResult.success }">
<div class="result-icon">
<span v-if="testResult.success">&#10003;</span>
<span v-else>&#10007;</span>
</div>
<h2>{{ testResult.success ? '连接成功' : '连接失败' }}</h2>
<pre v-if="testResult.json" class="result-json">{{ testResult.json }}</pre>
<div v-else class="result-message">{{ testResult.message }}</div>
<button @click="testResult = null" class="btn-primary">确定</button>
</div>
</div>
<!-- 添加/编辑模态框 -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<h2>{{ editing ? '编辑 Provider' : '添加 Provider' }}</h2>
<div class="form-group"><label>名称</label><input v-model="form.name" placeholder="如: 我的 DeepSeek" /></div>
<div class="form-group">
<label>类型</label>
<select v-model="form.provider_type">
<option value="openai">OpenAI 兼容</option>
<option value="deepseek">DeepSeek</option>
<option value="glm">智谱 GLM</option>
<option value="anthropic">Anthropic</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-group"><label>Base URL</label><input v-model="form.base_url" placeholder="https://api.deepseek.com/v1" /></div>
<div class="form-group"><label>API Key</label><input v-model="form.api_key" type="password" placeholder="sk-..." /></div>
<div class="form-group"><label>默认模型</label><input v-model="form.default_model" placeholder="gpt-4 / deepseek-chat" /></div>
<div class="form-group"><label>API Key</label><input v-model="form.api_key" type="text" :placeholder="editing ? '留空则保持原密码' : 'sk-...'" /></div>
<div class="form-group">
<label class="radio-card" :class="{ active: form.is_default }">
<input v-model="form.is_default" type="checkbox" />
<div class="radio-content">
<span class="radio-icon"></span>
<div class="radio-text">
<span class="radio-title">设为默认 Provider</span>
<span class="radio-desc">新会话将默认使用此 Provider</span>
</div>
<label>模型名称 </label>
<input v-model="form.default_model" placeholder="gpt-4 / deepseek-chat" required />
</div>
<div class="form-group">
<label class="switch-card" :class="{ active: form.is_default }">
<div class="switch-content">
<span class="switch-title">设为默认 Provider</span>
<span class="switch-desc">新会话将默认使用此 Provider</span>
</div>
<label class="switch">
<input v-model="form.is_default" type="checkbox" />
<span class="slider"></span>
</label>
</label>
</div>
@ -84,6 +124,67 @@
<script setup>
import { ref, onMounted } from 'vue'
import { providersAPI } from '../services/api.js'
import { useAuth } from '../composables/useAuth.js'
import { authAPI } from '../services/api.js'
import { useRouter } from 'vue-router'
const router = useRouter()
const { user, logout } = useAuth()
const handleLogout = async () => {
if (!confirm('确定要退出登录吗?')) return
await logout(authAPI)
router.push('/auth')
}
const userForm = ref({ username: '', email: '', password: '' })
const userFormEdit = ref({ username: '', email: '', password: '' })
const showUserModal = ref(false)
const savingUser = ref(false)
const userFormError = ref('')
const loadingUser = ref(false)
const fetchUserInfo = async () => {
loadingUser.value = true
try {
const res = await authAPI.getMe()
if (res.success && res.data) {
userForm.value.username = res.data.username || ''
userForm.value.email = res.data.email || ''
}
} catch (e) {
console.error('获取用户信息失败:', e)
} finally {
loadingUser.value = false
}
}
const openUserModal = () => {
userFormEdit.value = { ...userForm.value, password: '' }
userFormError.value = ''
showUserModal.value = true
}
const closeUserModal = () => {
showUserModal.value = false
userFormError.value = ''
}
const updateUser = async () => {
savingUser.value = true
userFormError.value = ''
try {
// TODO: Call API to update user info
alert('用户信息已保存')
userForm.value.username = userFormEdit.value.username
userForm.value.email = userFormEdit.value.email
closeUserModal()
} catch (e) {
userFormError.value = e.message || '保存失败'
} finally {
savingUser.value = false
}
}
const providers = ref([])
const loading = ref(true)
@ -92,6 +193,7 @@ const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const testing = ref(null)
const testResult = ref(null)
const formError = ref('')
const form = ref({
@ -112,21 +214,31 @@ const fetchProviders = async () => {
const closeModal = () => {
showModal.value = false
editing.value = null
form.value = { name: '', provider_type: 'openai', base_url: '', api_key: '', default_model: '', is_default: false }
form.value = { name: '', base_url: '', api_key: '', default_model: '', is_default: false }
formError.value = ''
}
const editProvider = (p) => {
const editProvider = async (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
try {
const res = await providersAPI.get(p.id)
if (res.success && res.data) {
form.value = {
name: res.data.name,
base_url: res.data.base_url,
api_key: res.data.api_key || '',
default_model: res.data.default_model,
is_default: res.data.is_default
}
}
} catch (e) {
console.error('获取Provider详情失败:', e)
}
showModal.value = true
}
const saveProvider = async () => {
if (!form.value.name || !form.value.base_url || !form.value.api_key) {
if (!form.value.base_url || !form.value.api_key || !form.value.default_model) {
formError.value = '请填写所有必填项'
return
}
@ -147,11 +259,27 @@ const saveProvider = async () => {
const testProvider = async (p) => {
testing.value = p.id
testResult.value = null
try {
const res = await providersAPI.test(p.id)
alert(res.data?.message || (res.data?.success ? '连接成功' : '连接失败'))
} catch (e) { alert('测试失败: ' + e.message) }
finally { testing.value = null }
const isSuccess = res.data?.success === true
testResult.value = {
success: isSuccess,
message: res.data?.message || (isSuccess ? '连接成功' : '连接失败'),
status: 200,
json: JSON.stringify(res.data || res, null, 2)
}
} catch (e) {
const errorData = e.response?.data || { error: e.message }
testResult.value = {
success: false,
status: e.status || e.response?.status || '未知',
message: e.message || e.response?.data?.detail || e.response?.message || '连接失败',
json: JSON.stringify(errorData, null, 2)
}
} finally {
testing.value = null
}
}
const deleteProvider = async (p) => {
@ -171,43 +299,78 @@ const toggleEnabled = async (p) => {
} catch (e) { alert('更新失败: ' + e.message) }
}
onMounted(fetchProviders)
onMounted(() => {
fetchUserInfo()
fetchProviders()
})
</script>
<style scoped>
.settings { padding: 0; }
.settings h1 { font-size: 2rem; margin: 0 0 0.5rem; color: var(--text-h); }
.settings h1 { font-size: 2rem; margin: 0 0 2rem; color: var(--text-h); }
.section { margin-bottom: 2rem; }
.section h2 { font-size: 1.25rem; margin: 0; color: var(--text-h); }
.user-info-card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; margin-top: 1rem; }
.user-info-card .info-item { display: flex; gap: 1rem; margin-bottom: 0.75rem; }
.user-info-card .info-item:last-of-type { margin-bottom: 1rem; }
.user-info-card .label { color: var(--text); min-width: 60px; }
.user-info-card .value { color: var(--text-h); font-weight: 500; }
.user-info-card .card-actions { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.optional { color: var(--text); font-weight: normal; font-size: 0.85rem; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.section-header h2 { margin: 0; }
.section-header .btn-primary { min-width: 80px; text-align: center; }
.subtitle { color: var(--text); margin: 0 0 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-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); }
.error { background: #fef2f2; border-radius: 12px; color: #dc2626; }
.error { background: var(--accent-bg); border-radius: 12px; color: var(--accent); }
.loading-small { padding: 2rem; text-align: center; 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; margin: 0 auto 0.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-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1rem; }
.card-title { display: flex; align-items: center; gap: 0.5rem; }
.card-title h3 { margin: 0; color: var(--text-h); }
.badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.75rem; background: var(--code-bg); color: var(--text); }
.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: center; margin-bottom: 1rem; }
.card-title { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.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); flex-shrink: 0; }
.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; }
.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%; }
input:checked + .slider { background-color: #16a34a; }
input:checked + .slider:before { transform: translateX(22px); }
.card-info { margin-bottom: 1rem; }
.info-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9rem; }
.label { color: var(--text); min-width: 50px; }
.card-info { margin-bottom: 1rem; flex: 1; }
.info-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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 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; min-width: 60px; text-align: center; }
.card-actions button.btn-primary { background: var(--accent); color: white; border: none; }
.card-actions button.btn-danger { background: var(--accent-bg); color: var(--accent); border: 1px solid var(--accent-border); }
.card-actions button.btn-danger:hover { background: var(--accent); color: white; }
.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; }
.result-modal { text-align: center; }
.result-modal .result-icon { width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; font-size: 2rem; }
.result-modal.success .result-icon { background: #dcfce7; color: #16a34a; }
.result-modal.error .result-icon { background: var(--accent-bg); color: var(--accent); }
.result-modal h2 { margin: 0 0 0.5rem; color: var(--text-h); }
.result-modal .result-status { color: var(--accent); font-size: 0.9rem; margin-bottom: 0.5rem; font-weight: 500; }
.result-modal .result-message { color: var(--text); margin-bottom: 1.5rem; }
.result-modal .result-json { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; margin: 1rem 0; max-height: 200px; overflow: auto; font-size: 0.85rem; text-align: left; white-space: pre-wrap; word-break: break-all; color: var(--text-h); }
.result-modal .btn-primary { width: 100%; }
.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; color: var(--text-h); }
.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; }
.form-group.model-group { background: var(--code-bg); padding: 1rem; border-radius: 12px; border: 2px solid var(--accent-border); }
.form-group.model-group label { color: var(--accent); margin-bottom: 0.75rem; }
.form-group.model-group input { border-color: var(--accent); }
.required { color: #ef4444; }
.form-group.checkbox label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
.radio-card { display: flex; align-items: center; padding: 1rem; border: 2px solid var(--border); border-radius: 12px; cursor: pointer; transition: all 0.2s; }
.radio-card:hover { border-color: var(--accent); }
@ -218,6 +381,17 @@ input:checked + .slider:before { transform: translateX(22px); }
.radio-text { display: flex; flex-direction: column; }
.radio-title { font-weight: 600; color: var(--text-h); }
.radio-desc { font-size: 0.85rem; color: var(--text); }
.switch-card { display: flex; align-items: center; justify-content: space-between; }
.switch-card input { display: none; }
.switch-content { display: flex; flex-direction: column; gap: 0.25rem; }
.switch-title { font-weight: 600; color: var(--text-h); }
.switch-desc { font-size: 0.85rem; color: var(--text); }
.switch-card .switch { position: relative; display: inline-block; width: 48px; height: 26px; flex-shrink: 0; }
.switch-card .switch input { opacity: 0; width: 0; height: 0; }
.switch-card .slider { position: absolute; cursor: pointer; inset: 0; background-color: #ccc; transition: 0.3s; border-radius: 26px; }
.switch-card .slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
.switch-card input:checked + .slider { background-color: var(--accent); }
.switch-card input:checked + .slider:before { transform: translateX(22px); }
.modal-actions { display: flex; justify-content: flex-end; gap: 1rem; margin-top: 1.5rem; }
.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); } }

View File

@ -3,8 +3,8 @@
<h1>工具管理</h1>
<div class="stats">
<div class="stat">🛠 {{ list.length }} 个工具</div>
<button @click="fetchData" :disabled="loading" class="btn-refresh">🔄 刷新</button>
<div class="stat">{{ list.length }} 个工具</div>
<button @click="fetchData" :disabled="loading" class="btn-refresh">刷新</button>
</div>
<div v-if="loading" class="loading"><div class="spinner"></div>加载中...</div>
@ -12,12 +12,11 @@
<div v-else class="grid">
<div v-for="tool in list" :key="tool.name" class="card">
<div class="card-header">
<span class="icon">{{ getIcon(tool.name) }}</span>
<span :class="['badge', tool.enabled ? 'enabled' : 'disabled']">{{ tool.enabled ? '已启用' : '已禁用' }}</span>
</div>
<h3>{{ tool.name }}</h3>
<p>{{ tool.description || '暂无描述' }}</p>
<button @click="showDetail(tool)" class="btn-info"> 查看详情</button>
<button @click="showDetail(tool)" class="btn-info">查看详情</button>
</div>
</div>
@ -66,14 +65,6 @@ const fetchData = async () => {
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 }
onMounted(fetchData)
@ -84,19 +75,18 @@ onMounted(fetchData)
.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; }
.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); }
.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; }
.card { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; }
.card-header { display: flex; justify-content: space-between; margin-bottom: 1rem; }
.icon { font-size: 2.5rem; }
.badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.8rem; font-weight: 500; }
.badge.enabled { background: #d4edda; color: #155724; }
.badge.disabled { background: #f8d7da; color: #721c24; }
.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-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; }
.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: center; margin-bottom: 1rem; }
.badge { padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.8rem; font-weight: 500; flex-shrink: 0; }
.badge.enabled { background: var(--accent-bg); color: var(--accent); }
.badge.disabled { background: var(--code-bg); color: var(--text); }
.card h3 { margin: 0 0 0.75rem; font-size: 1.25rem; color: var(--text-h); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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; }
.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; }
.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 h2 { margin: 0 0 1rem; color: var(--text-h); }

View File

@ -133,6 +133,9 @@ def update_provider(
# Update fields
update_data = update.dict(exclude_unset=True)
# Keep existing API key if the new one is empty
if update_data.get('api_key') == '':
update_data.pop('api_key')
for key, value in update_data.items():
setattr(provider, key, value)
@ -207,11 +210,38 @@ def test_provider(
"max_tokens": 10
}
)
return response.status_code == 200
return {
"status_code": response.status_code,
"success": response.status_code == 200,
"response_body": response.text[:500] if response.text else None
}
success = asyncio.run(test())
return success_response(data={"success": success, "message": "连接成功" if success else "连接失败"})
result = asyncio.run(test())
if result["success"]:
return success_response(data={
"success": True,
"message": "连接成功",
"status_code": result["status_code"]
})
else:
return success_response(data={
"success": False,
"message": f"HTTP {result['status_code']}",
"status_code": result["status_code"],
"response_body": result["response_body"]
})
except httpx.HTTPStatusError as e:
return success_response(data={
"success": False,
"message": f"HTTP {e.response.status_code}: {e.response.text[:200] if e.response.text else 'Unknown error'}",
"status_code": e.response.status_code,
"response_body": e.response.text[:500] if e.response.text else None
})
except Exception as e:
return success_response(data={"success": False, "message": f"连接失败: {str(e)}"})
return success_response(data={
"success": False,
"message": f"连接失败: {str(e)}",
"error_type": type(e).__name__
})
finally:
db.close()