修(chore): 前端 - 调整侧边栏布局、移除当前会话显示与图标主题支持
This commit is contained in:
@@ -1,52 +1,28 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="top">
|
||||
<h1 class="logo">AI Chat</h1>
|
||||
<nav>
|
||||
<!-- chat icon removed per request -->
|
||||
</nav>
|
||||
</div>
|
||||
<ConversationList @new="handleNewConv" />
|
||||
<div class="sidebar-bottom">
|
||||
<div class="avatar-area left-align" style="position:relative;">
|
||||
<button class="avatar-btn" @click="authStore.isAuthed ? (showUserMenu = !showUserMenu) : goLogin()">
|
||||
<img v-if="authStore.user" :src="avatarUrlForName(authStore.user.userName || authStore.user.email)" alt="avatar" />
|
||||
<div v-else class="avatar-fallback">{{ userInitial }}</div>
|
||||
</button>
|
||||
<div v-if="showUserMenu" class="user-menu">
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ authStore.user?.userName || '访客' }}</div>
|
||||
<div class="user-id" v-if="authStore.user">ID: {{ authStore.user.userId }}</div>
|
||||
<div class="user-status">{{ authStore.isAuthed ? '已登录' : '未登录' }}</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<button v-if="authStore.isAuthed" @click="logout">登出</button>
|
||||
<button v-else @click="goLogin">登录</button>
|
||||
<button @click="goSettings">设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-icons">
|
||||
<button class="icon-btn" title="设置" @click="goSettings">⚙️</button>
|
||||
<button class="icon-btn" title="关于" @click="goAbout">ℹ️</button>
|
||||
<button class="theme-toggle" @click="toggleTheme" :title="themeLabel">{{ themeStore.theme === 'dark' ? '🌙' : '☀️' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<Sidebar :collapsed="!showSidebar && !mobileOpen" :mobileOpen="mobileOpen" @toggle="toggleSidebar" @new="handleNewConv" />
|
||||
<!-- mobile overlay when sidebar is open on small screens -->
|
||||
<div v-if="mobileOpen" class="overlay" @click="closeMobileSidebar"></div>
|
||||
<main class="main">
|
||||
<div class="topbar">
|
||||
<!-- Show a single global toggle only when the sidebar is fully hidden (desktop collapsed or mobile closed) -->
|
||||
<button v-if="!showSidebar && !mobileOpen" class="global-toggle" @click="toggleSidebar" aria-label="打开侧边栏">☰</button>
|
||||
<!-- when sidebar is visible (desktop expanded or mobile open) the Sidebar component shows its own close icon; no duplicate icon here -->
|
||||
</div>
|
||||
<RouterView />
|
||||
</main>
|
||||
<!-- global left-top toggle when sidebar is hidden is rendered inside the topbar only -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { ref, watch, computed, onMounted } from 'vue';
|
||||
import { useModelStore, type ModelInfo } from './stores/modelStore';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useThemeStore } from './stores/themeStore';
|
||||
import { useConversationStore } from './stores/conversationStore';
|
||||
import ConversationList from './components/ConversationList.vue';
|
||||
import Sidebar from './components/Sidebar.vue';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
@@ -59,11 +35,42 @@ const { currentModel } = storeToRefs(modelStore);
|
||||
const models = ref(modelStore.supportedModels);
|
||||
const themeLabel = computed(() => themeStore.theme === 'dark' ? '切换为浅色' : '切换为深色');
|
||||
const menuOpen = ref(false);
|
||||
const showSidebar = ref(true);
|
||||
const mobileOpen = ref(false);
|
||||
|
||||
function toggleSidebar() {
|
||||
// on small screens open mobile drawer, on desktop just toggle collapse
|
||||
if (window.innerWidth <= 768) {
|
||||
mobileOpen.value = !mobileOpen.value;
|
||||
} else {
|
||||
showSidebar.value = !showSidebar.value;
|
||||
}
|
||||
}
|
||||
|
||||
function closeMobileSidebar() { mobileOpen.value = false; }
|
||||
|
||||
onMounted(() => {
|
||||
// auto-collapse on small screens
|
||||
if (window.innerWidth <= 768) showSidebar.value = false;
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth > 768) {
|
||||
// leaving mobile: close mobile drawer
|
||||
mobileOpen.value = false;
|
||||
} else {
|
||||
// entering mobile: if sidebar was open on desktop, convert it to mobile drawer
|
||||
if (showSidebar.value) {
|
||||
mobileOpen.value = true;
|
||||
showSidebar.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const userEmail = computed(() => authStore.user?.email || '');
|
||||
const userInitial = computed(() => authStore.user ? (authStore.user.userName || authStore.user.email)[0] : '访');
|
||||
const showUserMenu = ref(false);
|
||||
|
||||
/* make sure the global toggle sits above content when sidebar is hidden */
|
||||
function logout() { authStore.logout(); showUserMenu.value = false; router.push('/'); }
|
||||
|
||||
function toggleTheme() { themeStore.toggle(); }
|
||||
@@ -149,10 +156,37 @@ button:hover { filter: brightness(1.05); }
|
||||
button:active { filter: brightness(.95); }
|
||||
.main { flex: 1; display: flex; flex-direction: column; }
|
||||
.main { overflow:hidden; }
|
||||
/* responsive sidebar */
|
||||
.hamburger { display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; margin-right:8px; border-radius:8px; background:var(--input-bg); border:1px solid var(--input-border); cursor:pointer; }
|
||||
.sidebar { width:240px; transition: width .18s ease, transform .18s ease, opacity .18s ease; position:relative; z-index:30; flex: 0 0 240px; }
|
||||
.sidebar-collapsed { width:0; flex: 0 0 0; padding:0; margin:0; overflow:hidden; opacity:0; }
|
||||
.mobile-open { transform: translateX(0); position:fixed; left:0; top:0; bottom:0; background:var(--sidebar-bg); box-shadow:2px 0 12px rgba(0,0,0,0.2); }
|
||||
.overlay { position:fixed; inset:0; background:rgba(0,0,0,0.45); z-index:20; }
|
||||
|
||||
/* small screens: hide sidebar by default and allow sliding in */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { position:fixed; left:-320px; top:0; bottom:0; height:100vh; width:280px; transform:translateX(-100%); flex: 0 0 280px; }
|
||||
.sidebar.mobile-open { transform: translateX(0); }
|
||||
.main { padding: 12px; }
|
||||
.logo { font-size:18px; }
|
||||
/* prevent global-toggle from overlapping page content on small screens */
|
||||
.main { padding-top:56px; }
|
||||
.topbar { position:relative; }
|
||||
|
||||
/* icon theme variables (can be toggled via theme store) */
|
||||
:root { --icon-bg: var(--input-bg); --icon-fg: var(--text-color); --icon-border: var(--input-border); }
|
||||
}
|
||||
|
||||
/* Ensure main takes remaining space when sidebar collapsed */
|
||||
.layout { align-items: stretch; }
|
||||
.main { flex: 1 1 auto; min-width:0; }
|
||||
.topbar { display:flex; align-items:center; gap:8px; padding:8px 12px; border-bottom:1px solid var(--border-color); }
|
||||
/* user menu positioning */
|
||||
.user-menu { position:absolute; bottom:56px; left:0; background:var(--sidebar-bg); border:1px solid var(--input-border); padding:8px; border-radius:8px; min-width:160px; box-shadow:0 6px 18px rgba(0,0,0,0.12); }
|
||||
.user-menu .user-name { font-weight:600; }
|
||||
.user-menu .user-id { font-size:12px; opacity:.7; }
|
||||
.user-menu .user-actions { display:flex; gap:8px; margin-top:8px; }
|
||||
.user-menu button { padding:6px 8px; border-radius:6px; }
|
||||
/* global left-top toggle when sidebar is hidden */
|
||||
.global-toggle { position:fixed; left:12px; top:12px; z-index:40; width:40px; height:40px; border-radius:8px; background:var(--input-bg); border:1px solid var(--input-border); display:inline-flex; align-items:center; justify-content:center; cursor:pointer; }
|
||||
</style>
|
||||
|
100
frontend/src/components/Sidebar.vue
Normal file
100
frontend/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<aside :class="['sidebar', { 'sidebar-collapsed': collapsed, 'mobile-open': mobileOpen }]">
|
||||
<div class="top">
|
||||
<div class="top-left">
|
||||
<h1 class="logo">AI Chat</h1>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<button class="hamburger" @click="$emit('toggle')" aria-label="切换侧边栏">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConversationList @new="$emit('new')" />
|
||||
|
||||
<div class="sidebar-bottom">
|
||||
<div class="avatar-area" style="position:relative;">
|
||||
<button class="avatar-btn" @click="authStore.isAuthed ? (showUserMenu = !showUserMenu) : goLogin()">
|
||||
<img v-if="authStore.user" :src="avatarUrlForName(authStore.user.userName || authStore.user.email)" alt="avatar" />
|
||||
<div v-else class="avatar-fallback">{{ userInitial }}</div>
|
||||
</button>
|
||||
|
||||
<div v-if="showUserMenu" class="user-menu">
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ authStore.user?.userName || '访客' }}</div>
|
||||
<div class="user-id" v-if="authStore.user">ID: {{ authStore.user.userId }}</div>
|
||||
<div class="user-status">{{ authStore.isAuthed ? '已登录' : '未登录' }}</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<button v-if="authStore.isAuthed" @click="logout">登出</button>
|
||||
<button v-else @click="goLogin">登录</button>
|
||||
<button @click="goSettings">设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-icons">
|
||||
<button class="icon-btn" title="设置" @click="goSettings">⚙️</button>
|
||||
<button class="icon-btn" title="关于" @click="goAbout">ℹ️</button>
|
||||
<button class="theme-toggle" @click="toggleTheme" :title="themeLabel">{{ themeStore.theme === 'dark' ? '🌙' : '☀️' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import ConversationList from '@/components/ConversationList.vue';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { useThemeStore } from '@/stores/themeStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const { collapsed, mobileOpen } = defineProps<{ collapsed: boolean; mobileOpen: boolean }>();
|
||||
const emit = defineEmits(['toggle', 'new']);
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const themeStore = useThemeStore();
|
||||
const router = useRouter();
|
||||
const showUserMenu = ref(false);
|
||||
|
||||
const userInitial = computed(() => authStore.user ? (authStore.user.userName || authStore.user.email)[0] : '访');
|
||||
const themeLabel = computed(() => themeStore.theme === 'dark' ? '切换为浅色' : '切换为深色');
|
||||
|
||||
function logout() { authStore.logout(); showUserMenu.value = false; router.push('/'); }
|
||||
function toggleTheme() { themeStore.toggle(); }
|
||||
function goSettings() { router.push('/settings'); }
|
||||
function goAbout() { router.push('/about'); }
|
||||
function goLogin() { router.push('/login'); }
|
||||
|
||||
function avatarUrlForName(name: string) {
|
||||
const initials = (name || '访客').trim().split(/\s+/).slice(0,2).map(s => s[0]).join('').toUpperCase();
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
const hue = Math.abs(hash) % 360;
|
||||
const bg = `hsl(${hue} 60% 45%)`;
|
||||
const fg = '#fff';
|
||||
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80'><rect width='100%' height='100%' fill='${bg}' rx='16'/><text x='50%' y='52%' dominant-baseline='middle' text-anchor='middle' font-family='system-ui,Segoe UI,Roboto,Helvetica,Arial' font-size='32' fill='${fg}'>${initials}</text></svg>`;
|
||||
return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar { width:240px; padding:16px; background:var(--sidebar-bg); display:flex; flex-direction:column; gap:16px; box-shadow:2px 0 6px var(--shadow-color); transition: width .18s ease, transform .18s ease, opacity .18s ease; flex: 0 0 240px; position:relative; }
|
||||
.icon-btn, .hamburger, .theme-toggle, .avatar-btn { color: var(--icon-fg); background: var(--icon-bg); border-color: var(--icon-border); }
|
||||
.top { display:flex; align-items:center; gap:8px; }
|
||||
.top-left { display:flex; align-items:center; }
|
||||
.top-right { margin-left:auto; display:flex; align-items:center; }
|
||||
.logo { margin:0; }
|
||||
.hamburger { display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; margin-right:8px; border-radius:8px; background:var(--input-bg); border:1px solid var(--input-border); cursor:pointer; }
|
||||
.sidebar-collapsed { width:0 !important; padding:0 !important; overflow:hidden; opacity:0; pointer-events:none; }
|
||||
.sidebar.mobile-open { transform: translateX(0); position:fixed; left:0; top:0; bottom:0; z-index:30; }
|
||||
.sidebar-bottom { margin-top:auto; display:flex; justify-content:space-between; align-items:center; }
|
||||
.avatar-area { display:flex; gap:8px; align-items:center; }
|
||||
.avatar-btn { width:40px; height:40px; border-radius:50%; overflow:hidden; border:none; display:inline-flex; align-items:center; justify-content:center; background:var(--button-bg); color:#fff; }
|
||||
.user-menu { position:absolute; bottom:56px; left:0; background:var(--sidebar-bg); border:1px solid var(--input-border); padding:8px; border-radius:8px; min-width:160px; box-shadow:0 6px 18px rgba(0,0,0,0.12); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { position:fixed; left:-100vw; top:0; bottom:0; height:100vh; width:80vw; transform:translateX(-100%); }
|
||||
.sidebar.mobile-open { transform:translateX(0); left:0; }
|
||||
}
|
||||
.icon-btn, .hamburger, .theme-toggle, .avatar-btn { /* ensure consistent sizing */ border:1px solid var(--icon-border); }
|
||||
</style>
|
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="chat-view">
|
||||
<div class="conv-meta" v-if="conversationId">当前会话: {{ conversationId }}</div>
|
||||
<!-- conv-meta removed as per UI: hide current conversation id -->
|
||||
<div class="messages" ref="messagesEl">
|
||||
<template v-for="m in messages" :key="m.id">
|
||||
<div v-if="m.role==='system'" class="msg system">
|
||||
|
Reference in New Issue
Block a user