修(chore): 前端 - 调整侧边栏布局、移除当前会话显示与图标主题支持
This commit is contained in:
@@ -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>
|
||||||
|
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>
|
<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">
|
||||||
|
Reference in New Issue
Block a user