修(chore): 前端 - 调整侧边栏布局、移除当前会话显示与图标主题支持

This commit is contained in:
2025-08-25 20:51:06 +08:00
parent 3c8d2dab7e
commit a5865dd559
3 changed files with 170 additions and 36 deletions

View File

@@ -1,52 +1,28 @@
<template> <template>
<div class="layout"> <div class="layout">
<aside class="sidebar"> <Sidebar :collapsed="!showSidebar && !mobileOpen" :mobileOpen="mobileOpen" @toggle="toggleSidebar" @new="handleNewConv" />
<div class="top"> <!-- mobile overlay when sidebar is open on small screens -->
<h1 class="logo">AI Chat</h1> <div v-if="mobileOpen" class="overlay" @click="closeMobileSidebar"></div>
<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>
<main class="main"> <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 /> <RouterView />
</main> </main>
<!-- global left-top toggle when sidebar is hidden is rendered inside the topbar only -->
</div> </div>
</template> </template>
<script setup lang="ts"> <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 { useModelStore, type ModelInfo } from './stores/modelStore';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useThemeStore } from './stores/themeStore'; import { useThemeStore } from './stores/themeStore';
import { useConversationStore } from './stores/conversationStore'; import { useConversationStore } from './stores/conversationStore';
import ConversationList from './components/ConversationList.vue'; import ConversationList from './components/ConversationList.vue';
import Sidebar from './components/Sidebar.vue';
import { useAuthStore } from './stores/authStore'; import { useAuthStore } from './stores/authStore';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -59,11 +35,42 @@ const { currentModel } = storeToRefs(modelStore);
const models = ref(modelStore.supportedModels); const models = ref(modelStore.supportedModels);
const themeLabel = computed(() => themeStore.theme === 'dark' ? '切换为浅色' : '切换为深色'); const themeLabel = computed(() => themeStore.theme === 'dark' ? '切换为浅色' : '切换为深色');
const menuOpen = ref(false); 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 userEmail = computed(() => authStore.user?.email || '');
const userInitial = computed(() => authStore.user ? (authStore.user.userName || authStore.user.email)[0] : '访'); const userInitial = computed(() => authStore.user ? (authStore.user.userName || authStore.user.email)[0] : '访');
const showUserMenu = ref(false); 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 logout() { authStore.logout(); showUserMenu.value = false; router.push('/'); }
function toggleTheme() { themeStore.toggle(); } function toggleTheme() { themeStore.toggle(); }
@@ -149,10 +156,37 @@ button:hover { filter: brightness(1.05); }
button:active { filter: brightness(.95); } button:active { filter: brightness(.95); }
.main { flex: 1; display: flex; flex-direction: column; } .main { flex: 1; display: flex; flex-direction: column; }
.main { overflow:hidden; } .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 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 { 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-name { font-weight:600; }
.user-menu .user-id { font-size:12px; opacity:.7; } .user-menu .user-id { font-size:12px; opacity:.7; }
.user-menu .user-actions { display:flex; gap:8px; margin-top:8px; } .user-menu .user-actions { display:flex; gap:8px; margin-top:8px; }
.user-menu button { padding:6px 8px; border-radius:6px; } .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> </style>

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

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="chat-view"> <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"> <div class="messages" ref="messagesEl">
<template v-for="m in messages" :key="m.id"> <template v-for="m in messages" :key="m.id">
<div v-if="m.role==='system'" class="msg system"> <div v-if="m.role==='system'" class="msg system">