feat: 改为顶部导航栏
This commit is contained in:
parent
a65356113e
commit
c88b5686f3
|
|
@ -1,35 +1,26 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useAuth } from './utils/useAuth.js'
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
import AppHeader from './components/AppHeader.vue'
|
||||
|
||||
const { isLoggedIn } = useAuth()
|
||||
const sidebarCollapsed = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||
<AppSidebar v-if="isLoggedIn" :collapsed="sidebarCollapsed" @toggle="sidebarCollapsed = !sidebarCollapsed" />
|
||||
<main class="main-content" :class="{ 'no-sidebar': !isLoggedIn }">
|
||||
<div id="app">
|
||||
<AppHeader v-if="isLoggedIn" />
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#app { display: flex; min-height: 100vh; background: var(--bg); }
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 260px;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
transition: margin-left 0.3s;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.main-content.no-sidebar { margin-left: 0; }
|
||||
.sidebar-collapsed .main-content { margin-left: 70px; }
|
||||
@media (max-width: 768px) {
|
||||
.main-content { margin-left: 70px; }
|
||||
.sidebar-collapsed .main-content { margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<div class="brand">
|
||||
<div class="brand-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="brand-text">Luxx</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="header-nav">
|
||||
<router-link v-for="item in navItems" :key="item.path" :to="item.path" class="nav-link" active-class="active">
|
||||
<span class="nav-icon" v-html="item.icon"></span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="header-right">
|
||||
<button class="header-btn" @click="toggleTheme" :title="isDark ? '切换到浅色模式' : '切换到暗色模式'">
|
||||
<svg v-if="!isDark" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const isDark = ref(localStorage.getItem('theme') === 'dark')
|
||||
|
||||
const toggleTheme = () => {
|
||||
isDark.value = !isDark.value
|
||||
if (isDark.value) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
localStorage.setItem('theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
localStorage.setItem('theme', 'light')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (localStorage.getItem('theme') === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
isDark.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
path: '/',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>`
|
||||
},
|
||||
{
|
||||
path: '/conversations',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`
|
||||
},
|
||||
{
|
||||
path: '/tools',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-header {
|
||||
height: 56px;
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 1.5rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, var(--accent-primary), #6366f1);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-link.active {
|
||||
background: var(--accent-primary-light);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.header-btn:hover {
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.brand-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
<template>
|
||||
<aside class="sidebar" :class="{ collapsed }">
|
||||
<div class="sidebar-header">
|
||||
<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-text">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ collapsed: Boolean })
|
||||
defineEmits(['toggle'])
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '首页' },
|
||||
{ path: '/conversations', label: '会话' },
|
||||
{ path: '/tools', label: '工具' },
|
||||
{ path: '/settings', label: '设置' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
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: 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);
|
||||
min-height: 60px;
|
||||
}
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
justify-content: center;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.brand-text {
|
||||
font-size: 1.5rem; font-weight: 700;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.toggle-btn {
|
||||
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;
|
||||
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-text {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.collapsed .nav-text { display: none; }
|
||||
.collapsed .nav-item { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { width: 0 !important; overflow: hidden; }
|
||||
.sidebar-header { display: none; }
|
||||
.sidebar-nav { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -48,7 +48,6 @@
|
|||
|
||||
/* 滚动条颜色 */
|
||||
--scrollbar-thumb: rgba(0, 0, 0, 0.08);
|
||||
--scrollbar-thumb-sidebar: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* 遮罩背景 */
|
||||
--overlay-bg: rgba(0, 0, 0, 0.3);
|
||||
|
|
@ -120,7 +119,6 @@
|
|||
--danger-bg: rgba(248, 113, 113, 0.15);
|
||||
|
||||
--scrollbar-thumb: rgba(255, 255, 255, 0.1);
|
||||
--scrollbar-thumb-sidebar: rgba(255, 255, 255, 0.15);
|
||||
|
||||
--overlay-bg: rgba(0, 0, 0, 0.6);
|
||||
--avatar-gradient: linear-gradient(135deg, #3b82f6, #60a5fa);
|
||||
|
|
|
|||
|
|
@ -1,46 +1,103 @@
|
|||
<template>
|
||||
<div class="page-container conversations">
|
||||
<div class="page-header">
|
||||
<h1>会话管理</h1>
|
||||
<button @click="showModal = true" class="btn-primary">+ 新建会话</button>
|
||||
</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-card">暂无会话,点击上方按钮新建</div>
|
||||
|
||||
<div v-else class="table-container">
|
||||
<table class="conv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>模型</th>
|
||||
<th>修改时间</th>
|
||||
<th class="action-col">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in list" :key="c.id" @click="$router.push(`/conversations/${c.id}`)">
|
||||
<td class="title-col">
|
||||
<div class="conv-title">{{ c.title || c.first_message || '未命名会话' }}</div>
|
||||
</td>
|
||||
<td class="model-col">{{ c.model || '-' }}</td>
|
||||
<td class="time-col">{{ formatDate(c.updated_at) }}</td>
|
||||
<td class="action-col" @click.stop>
|
||||
<button @click="editTitle(c)" class="btn-edit">重命名</button>
|
||||
<button @click="deleteConv(c)" class="btn-delete">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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 class="conv-layout">
|
||||
<!-- 左侧会话列表 -->
|
||||
<aside class="conv-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<button @click="showModal = true" class="btn-new-conv">+ 新建会话</button>
|
||||
</div>
|
||||
<div v-if="loading" class="loading"><div class="spinner-small"></div>加载中...</div>
|
||||
<div v-else-if="error" class="error-msg">{{ error }} <button @click="fetchData">重试</button></div>
|
||||
<div v-else-if="!list.length" class="empty-sidebar">暂无会话</div>
|
||||
|
||||
<div v-else class="conv-list">
|
||||
<div
|
||||
v-for="c in list"
|
||||
:key="c.id"
|
||||
class="conv-item"
|
||||
:class="{ active: selectedId === c.id }"
|
||||
@click="selectConv(c)"
|
||||
>
|
||||
<div class="conv-item-header">
|
||||
<span class="conv-item-title">{{ c.title || c.first_message || '未命名会话' }}</span>
|
||||
<span class="conv-item-time">{{ formatDate(c.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="conv-item-meta">
|
||||
<span class="conv-item-model">{{ c.model || '-' }}</span>
|
||||
<div class="conv-item-actions" @click.stop>
|
||||
<button @click="editTitle(c)" class="btn-icon" title="重命名">✏️</button>
|
||||
<button @click="deleteConv(c)" class="btn-icon btn-delete-icon" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="sidebar-pagination">
|
||||
<button @click="page--; fetchData()" :disabled="page === 1" class="btn-page">‹</button>
|
||||
<span>{{ page }} / {{ totalPages }}</span>
|
||||
<button @click="page++; fetchData()" :disabled="page >= totalPages" class="btn-page">›</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧内容区 - 对话界面 -->
|
||||
<main class="conv-content">
|
||||
<div v-if="!selectedConv" class="empty-content">
|
||||
<div class="empty-icon">💬</div>
|
||||
<p>选择一个会话查看</p>
|
||||
</div>
|
||||
<div v-else class="chat-view-container">
|
||||
<div class="chat-messages" ref="messagesContainer">
|
||||
<div v-if="loadingMessages" class="loading-messages">
|
||||
<div class="spinner-small"></div>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div v-else-if="convMessages.length || streamingMessage">
|
||||
<!-- 历史消息 -->
|
||||
<div v-for="msg in convMessages" :key="msg.id" class="chat-message" :class="msg.role">
|
||||
<div class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
|
||||
<div class="message-content">
|
||||
<!-- 工具调用步骤显示(包含思考和文本内容) -->
|
||||
<ProcessBlock
|
||||
v-if="msg.process_steps && msg.process_steps.length"
|
||||
:process-steps="msg.process_steps"
|
||||
/>
|
||||
<!-- 或仅显示消息内容 -->
|
||||
<div v-else class="message-text" v-html="renderMsgContent(msg)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 流式消息 -->
|
||||
<div v-if="streamingMessage" class="chat-message assistant streaming">
|
||||
<div class="message-avatar">🤖</div>
|
||||
<div class="message-content">
|
||||
<ProcessBlock
|
||||
:process-steps="streamingMessage.process_steps"
|
||||
:streaming="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="chat-empty">
|
||||
<p>暂无消息,开始对话吧</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-area">
|
||||
<input
|
||||
v-model="newMessage"
|
||||
@keyup.enter="sendMessage"
|
||||
type="text"
|
||||
placeholder="输入消息..."
|
||||
class="chat-input"
|
||||
:disabled="sending"
|
||||
/>
|
||||
<button @click="sendMessage" class="btn-send" :disabled="sending || !newMessage.trim()">
|
||||
{{ sending ? '发送中...' : '发送' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 新建会话弹窗 -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal">
|
||||
<h2>新建会话</h2>
|
||||
|
|
@ -65,6 +122,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重命名弹窗 -->
|
||||
<div v-if="editConv" class="modal-overlay" @click.self="editConv = null">
|
||||
<div class="modal">
|
||||
<h2>重命名会话</h2>
|
||||
|
|
@ -79,9 +137,11 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { conversationsAPI, providersAPI } from '../utils/api.js'
|
||||
import { conversationsAPI, providersAPI, messagesAPI } from '../utils/api.js'
|
||||
import { renderMarkdown } from '../utils/markdown.js'
|
||||
import ProcessBlock from '../components/ProcessBlock.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const list = ref([])
|
||||
|
|
@ -94,6 +154,10 @@ const error = ref('')
|
|||
const showModal = ref(false)
|
||||
const creating = ref(false)
|
||||
const form = ref({ title: '', provider_id: null, model: '' })
|
||||
const selectedId = ref(null)
|
||||
const selectedConv = ref(null)
|
||||
const convMessages = ref([])
|
||||
const loadingMessages = ref(false)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
||||
|
||||
|
|
@ -113,6 +177,10 @@ const fetchData = async () => {
|
|||
if (convRes.status === 'fulfilled' && convRes.value.success) {
|
||||
list.value = convRes.value.data?.items || []
|
||||
total.value = convRes.value.data?.total || 0
|
||||
// 默认选中最后一个会话
|
||||
if (list.value.length > 0 && !selectedId.value) {
|
||||
selectConv(list.value[0])
|
||||
}
|
||||
}
|
||||
if (provRes.status === 'fulfilled' && provRes.value.success) {
|
||||
providers.value = provRes.value.data?.providers || []
|
||||
|
|
@ -121,6 +189,127 @@ const fetchData = async () => {
|
|||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const selectConv = async (c) => {
|
||||
selectedId.value = c.id
|
||||
selectedConv.value = c
|
||||
await fetchConvMessages(c.id)
|
||||
}
|
||||
|
||||
const fetchConvMessages = async (convId) => {
|
||||
loadingMessages.value = true
|
||||
convMessages.value = []
|
||||
try {
|
||||
const res = await messagesAPI.list(convId)
|
||||
if (res.success) {
|
||||
convMessages.value = res.data?.messages || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取消息失败:', e)
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const newMessage = ref('')
|
||||
const sending = ref(false)
|
||||
const messagesContainer = ref(null)
|
||||
const streamingMessage = ref(null)
|
||||
|
||||
watch(convMessages, () => {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
watch(() => streamingMessage.value?.process_steps?.length, () => {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 渲染消息内容(Markdown)
|
||||
const renderMsgContent = (msg) => {
|
||||
const content = msg.content || msg.text || ''
|
||||
if (!content) return '-'
|
||||
return renderMarkdown(content)
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.value.trim() || !selectedConv.value || sending.value) return
|
||||
|
||||
const content = newMessage.value.trim()
|
||||
newMessage.value = ''
|
||||
sending.value = true
|
||||
|
||||
// 添加用户消息到列表
|
||||
const userMsg = {
|
||||
id: 'temp-' + Date.now(),
|
||||
role: 'user',
|
||||
content: content,
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
convMessages.value.push(userMsg)
|
||||
|
||||
// 初始化流式消息
|
||||
streamingMessage.value = {
|
||||
id: Date.now() + 1,
|
||||
role: 'assistant',
|
||||
process_steps: [],
|
||||
content: '',
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
messagesAPI.sendStream({
|
||||
conversation_id: selectedConv.value.id,
|
||||
content: content
|
||||
}, {
|
||||
onProcessStep: (step) => {
|
||||
if (!streamingMessage.value) return
|
||||
// 按 id 更新或追加步骤
|
||||
const idx = streamingMessage.value.process_steps.findIndex(s => s.id === step.id)
|
||||
if (idx >= 0) {
|
||||
streamingMessage.value.process_steps[idx] = step
|
||||
} else {
|
||||
streamingMessage.value.process_steps.push(step)
|
||||
}
|
||||
},
|
||||
onDone: async (data) => {
|
||||
// 将流式消息添加到列表
|
||||
if (streamingMessage.value) {
|
||||
convMessages.value.push({
|
||||
...streamingMessage.value,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
streamingMessage.value = null
|
||||
}
|
||||
resolve()
|
||||
},
|
||||
onError: (error) => {
|
||||
if (streamingMessage.value) {
|
||||
streamingMessage.value.process_steps.push({
|
||||
id: 'error-' + Date.now(),
|
||||
index: streamingMessage.value.process_steps.length,
|
||||
type: 'error',
|
||||
content: error
|
||||
})
|
||||
}
|
||||
reject(new Error(error))
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createConv = async () => {
|
||||
creating.value = true
|
||||
try {
|
||||
|
|
@ -128,7 +317,13 @@ const createConv = async () => {
|
|||
if (res.success && res.data?.id) {
|
||||
showModal.value = false
|
||||
form.value = { title: '', provider_id: null, model: '' }
|
||||
router.push(`/conversations/${res.data.id}`)
|
||||
// 刷新列表
|
||||
await fetchData()
|
||||
// 选中新创建的会话
|
||||
const newConv = list.value.find(c => c.id === res.data.id)
|
||||
if (newConv) {
|
||||
selectConv(newConv)
|
||||
}
|
||||
}
|
||||
} catch (e) { alert('创建失败: ' + e.message) }
|
||||
finally { creating.value = false }
|
||||
|
|
@ -137,6 +332,10 @@ const createConv = async () => {
|
|||
const deleteConv = async (c) => {
|
||||
if (!confirm(`删除「${c.title || '未命名会话'}」?`)) return
|
||||
await conversationsAPI.delete(c.id)
|
||||
if (selectedId.value === c.id) {
|
||||
selectedId.value = null
|
||||
selectedConv.value = null
|
||||
}
|
||||
fetchData()
|
||||
}
|
||||
|
||||
|
|
@ -161,35 +360,477 @@ onMounted(fetchData)
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-container { background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }
|
||||
.conv-table { width: 100%; border-collapse: collapse; }
|
||||
.conv-table th { text-align: left; padding: 1rem; background: var(--bg-secondary); font-weight: 600; font-size: 0.85rem; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); }
|
||||
.conv-table td { padding: 1rem; border-bottom: 1px solid var(--border-light); vertical-align: middle; }
|
||||
.conv-table tr:last-child td { border-bottom: none; }
|
||||
.conv-table tr:hover td { background: var(--bg-secondary); cursor: pointer; }
|
||||
.title-col { max-width: 300px; }
|
||||
.conv-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
||||
.model-col { width: 150px; font-size: 0.85rem; color: var(--text-secondary); }
|
||||
.time-col { width: 150px; font-size: 0.85rem; color: var(--text-secondary); }
|
||||
.action-col { width: 140px; text-align: center; }
|
||||
.btn-edit, .btn-delete { padding: 0.3rem 0.5rem; background: transparent; border: 1px solid var(--border-light); border-radius: 4px; cursor: pointer; font-size: 0.75rem; margin: 0 0.15rem; }
|
||||
.btn-edit { color: var(--text-secondary); }
|
||||
.btn-edit:hover { color: var(--accent-primary); border-color: var(--accent-primary); }
|
||||
.btn-delete { color: #dc2626; }
|
||||
.btn-delete:hover { background: #dc2626; color: white; }
|
||||
.btn-primary { padding: 0.6rem 1.2rem; background: #2563eb; color: #ffffff; border: none; border-radius: 8px; cursor: pointer; font-size: 0.9rem; font-weight: 500; }
|
||||
.btn-primary:hover { background: #1d4ed8; }
|
||||
.btn-secondary { padding: 0.6rem 1.2rem; background: transparent; border: 1px solid var(--border-light); border-radius: 8px; cursor: pointer; font-size: 0.9rem; }
|
||||
.loading { text-align: center; padding: 4rem; }
|
||||
.empty-card { text-align: center; padding: 4rem; color: var(--text-secondary); background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; }
|
||||
.error-msg { text-align: center; padding: 2rem; color: var(--accent-primary); background: var(--accent-primary-light); border-radius: 12px; }
|
||||
.pagination { display: flex; justify-content: center; align-items: center; gap: 1rem; margin-top: 1.5rem; }
|
||||
.pagination button { padding: 0.5rem 1rem; background: var(--bg-primary); border: 1px solid var(--border-light); 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-primary); border-radius: 16px; padding: 2rem; width: 100%; max-width: 480px; }
|
||||
.modal h2 { margin: 0 0 1.5rem; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.5rem; }
|
||||
.spinner { width: 40px; height: 40px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
/* 布局 */
|
||||
.page-container {
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.conv-layout {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 80px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 左侧边栏 */
|
||||
.conv-sidebar {
|
||||
width: 20%;
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.btn-new-conv {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-new-conv:hover {
|
||||
background: var(--accent-primary-hover);
|
||||
}
|
||||
|
||||
.conv-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 会话列表项 */
|
||||
.conv-item {
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.conv-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.conv-item.active {
|
||||
background: var(--accent-primary-light);
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.conv-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.conv-item-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.conv-item-time {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conv-item-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.conv-item-model {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.conv-item-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.2rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-delete-icon {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* 侧边栏分页 */
|
||||
.sidebar-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-page {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-page:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 右侧内容区 */
|
||||
.conv-content {
|
||||
flex: 1;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-content p {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 聊天视图容器 */
|
||||
.chat-view-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.chat-message.streaming {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-message.streaming .message-avatar {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-message.user .message-text {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.loading-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.loading-messages .spinner-small {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Markdown 内容样式 */
|
||||
.message-text {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message-text :deep(pre) {
|
||||
background: var(--bg-code);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.message-text :deep(code) {
|
||||
background: var(--bg-code);
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.message-text :deep(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.message-text :deep(p) {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.message-text :deep(p:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.message-text :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-send:hover {
|
||||
background: var(--accent-primary-hover);
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn-primary {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* 加载和空状态 */
|
||||
.loading, .empty-sidebar {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: var(--danger-color);
|
||||
background: var(--danger-bg);
|
||||
border-radius: 8px;
|
||||
margin: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.spinner-small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--border-light);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 0.5rem;
|
||||
}
|
||||
|
||||
/* 模态框 */
|
||||
.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-primary);
|
||||
border-radius: 16px;
|
||||
padding: 1.25rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-input);
|
||||
box-sizing: border-box;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue