feat(settings): 实现主题切换和显示模式功能

- 新增主题切换功能,支持多种主题颜色配置
- 实现显示模式切换,包括系统、深色和浅色模式
- 添加动画效果开关功能
- 优化设置面板组件,集中管理所有设置项
- 重构部分代码以支持新功能,包括 store、样式和组件
This commit is contained in:
Lexcubia 2025-04-22 16:36:54 +08:00
parent 9d4d52260f
commit f65dd67087
24 changed files with 2607 additions and 892 deletions

View File

@ -5,11 +5,10 @@
## 技术栈
- Vue 3 - 渐进式 JavaScript 框架
- Vite - 下一代前端构建工具
- Vue Router - 官方路由管理器
- Pinia - Vue 的官方状态管理库
- TypeScript - JavaScript 的超集
- Tailwind CSS - 实用优先的 CSS 框架
- Element Plus - 基于 Vue 3 的组件库
- Vite - 下一代前端构建工具
- Sass - CSS 预处理器
## 功能特性

BIN
dist.zip Normal file

Binary file not shown.

View File

@ -48,10 +48,19 @@
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"sass": "^1.86.3",
"@vitejs/plugin-vue": "^5.0.4",
"@types/node": "^20.11.19",
"@vue/compiler-sfc": "^3.4.21",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"postcss-import": "^16.0.1",
"postcss-preset-env": "^9.3.0",
"sass": "^1.71.1",
"sass-embedded": "^1.86.3",
"vite": "^6.2.0",
"vite-plugin-mkcert": "^1.17.8"
"typescript": "^5.3.3",
"vite": "^5.1.4",
"vite-plugin-mkcert": "^1.17.8",
"vite-plugin-vue-devtools": "^7.0.19",
"vue-tsc": "^1.8.27"
}
}

12
postcss.config.js Normal file
View File

@ -0,0 +1,12 @@
export default {
plugins: {
'postcss-import': {},
'postcss-preset-env': {
stage: 3,
features: {
'nesting-rules': true
}
},
autoprefixer: {}
}
}

View File

@ -1,40 +1,58 @@
<script setup>
import { RouterView } from "vue-router";
import { onMounted } from 'vue'
onMounted(() => {
import { useSettingsStore } from '@/store/settings';
import { setTheme } from '@/styles/theme';
})
onMounted(() => {
//1%1vh
let vh = window.innerHeight * 0.01
// vh
document.documentElement.style.setProperty('--vh', `${vh}px`)
const settingsStore = useSettingsStore();
// resize 1vh
window.addEventListener('resize', () => {
//
let vh = window.innerHeight * 0.01
console.log(vh);
document.documentElement.style.setProperty('--vh', `${vh}px`)
})
})
onMounted(() => {
// store
setTheme(settingsStore.theme);
});
</script>
<template>
<router-view></router-view>
</template>
<style scoped>
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
<style lang="scss">
html, body, #app {
height: 100%;
margin: 0;
padding: 0;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '\5FAE\8F6F\96C5\9ED1', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--el-bg-color);
color: var(--el-text-color-primary);
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 6px; /* 滚动条宽度 */
height: 6px; /* 滚动条高度,主要用于水平滚动条 */
}
::-webkit-scrollbar-track {
background: transparent; /* 滚动条轨道背景 */
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: rgba(144, 147, 153, 0.3); /* 滚动条滑块颜色 */
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(144, 147, 153, 0.5); /* 悬停时滑块颜色 */
}
//
body.no-animations {
* {
transition: none !important;
animation: none !important;
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
}
</style>

View File

@ -1,43 +0,0 @@
<script setup>
import { ref } from 'vue'
defineProps({
msg: String,
})
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@ -1,90 +0,0 @@
<template>
<el-dropdown
trigger="click"
@command="handleCommand"
class="mode-setting"
>
<el-button
class="setting-btn"
:icon="Setting"
text
bg
@click.stop
/>
<template #dropdown>
<el-dropdown-menu>
<slot>
<!-- 默认选项 -->
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>
编辑设置
</el-dropdown-item>
<el-dropdown-item command="reset">
<el-icon><RefreshRight /></el-icon>
重置设置
</el-dropdown-item>
</slot>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup>
import { Setting, Edit, RefreshRight } from '@element-plus/icons-vue'
const emit = defineEmits(['command'])
const handleCommand = (command) => {
emit('command', command)
}
</script>
<style lang="scss" scoped>
.mode-setting {
.setting-btn {
height: 24px;
width: 24px;
padding: 0;
color: inherit;
opacity: 0;
transform: translateX(10px);
transition: all 0.3s ease;
border: none;
background: transparent !important;
&:hover {
background-color: transparent !important;
}
&:focus {
outline: none;
box-shadow: none;
}
:deep(.el-icon) {
font-size: 16px;
}
}
:deep(.el-dropdown-menu__item) {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
padding: 8px 16px;
.el-icon {
margin-right: 4px;
}
}
}
//
.mode-item.active {
.setting-btn {
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
}
}
</style>

View File

@ -0,0 +1,243 @@
<template>
<div class="settings-popover">
<h4>整体外观</h4>
<el-radio-group
v-model="currentDisplayMode"
@change="changeDisplayMode"
size="small"
class="display-mode-group"
>
<el-radio-button label="system">
<span class="icon system"></span> 系统默认
</el-radio-button>
<el-radio-button label="light">
<span class="icon light"></span> 浅色
</el-radio-button>
<el-radio-button label="dark">
<span class="icon dark"></span> 深色
</el-radio-button>
</el-radio-group>
<el-divider />
<h4>主题</h4>
<div class="theme-grid">
<div
v-for="(theme, name) in filteredThemes"
:key="name"
class="theme-option"
:class="{ 'active': currentTheme === name }"
@click="changeTheme(name)"
>
<div class="theme-preview" :style="{ backgroundColor: theme.primary }"></div>
<span class="theme-label">{{ getThemeLabel(name) }}</span>
</div>
<!-- 可以添加 '选择一种颜色...' 的选项 -->
</div>
<el-divider />
<h4>界面设置</h4>
<div class="setting-item">
<span>启用动画效果</span>
<el-switch
v-model="animationsEnabled"
@change="toggleAnimationsSetting"
/>
</div>
<!-- 可以添加更多设置项 -->
<!--
<el-divider />
<h4>其他设置</h4>
<div class="setting-item">
<span>收起侧边栏</span>
<el-switch v-model="sidebarCollapsed" @change="toggleSidebarSetting" />
</div>
-->
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useSettingsStore } from '@/store/settings';
// Import themes object and omit 'dark' theme from selection
import { themes as availableThemes } from '@/styles/theme';
import {
ElButton, ElDropdown, ElDropdownMenu, ElDropdownItem,
ElDivider, ElSwitch, ElIcon, ElRadioGroup, ElRadioButton
} from 'element-plus';
import { ArrowDown } from '@element-plus/icons-vue'; // Keep if needed elsewhere, remove if not
const settingsStore = useSettingsStore();
// --- Display Mode ---
const currentDisplayMode = ref(settingsStore.displayMode);
const changeDisplayMode = (newMode) => {
settingsStore.updateDisplayMode(newMode);
// currentDisplayMode will update reactively from the store if using computed,
// but direct update here ensures immediate visual feedback if needed
currentDisplayMode.value = newMode;
};
// --- Theme Settings ---
const currentTheme = ref(settingsStore.theme);
// Filter out the 'dark' theme as it's handled by displayMode
const filteredThemes = computed(() => {
const { dark, ...rest } = availableThemes;
return rest;
});
const themeLabels = {
default: '默认',
// dark: '', // No longer selectable as a base theme color palette
fresh: '清新',
warm: '暖色',
business: '商务',
// Add labels for other themes from your image if they exist in styles/theme.js
// e.g., cool: '', pink: '', bubblegum: '', sunny: '', etc.
// Make sure the keys match the keys in your `themes` object in theme.js
};
const getThemeLabel = (themeName) => {
return themeLabels[themeName] || themeName;
};
const changeTheme = (newTheme) => {
settingsStore.updateTheme(newTheme); // Use the action
currentTheme.value = newTheme; // Update local ref for immediate feedback
};
// --- Animation Settings ---
const animationsEnabled = ref(settingsStore.animationsEnabled);
const toggleAnimationsSetting = (value) => {
settingsStore.toggleAnimations(value); // Use the action
animationsEnabled.value = value; // Update local ref
};
// Watch for external store changes (e.g., reset button) if necessary
// settingsStore.$subscribe((mutation, state) => {
// currentDisplayMode.value = state.displayMode;
// currentTheme.value = state.theme;
// animationsEnabled.value = state.animationsEnabled;
// });
</script>
<style lang="scss" scoped>
.settings-popover {
padding: 15px;
min-width: 240px; // Increased width for better layout
h4 {
margin-top: 0;
margin-bottom: 12px;
font-size: 14px;
font-weight: 600; // Slightly bolder heading
color: var(--el-text-color-primary);
}
.display-mode-group {
display: flex; // Use flex for equal spacing
width: 100%;
margin-bottom: 5px; // Add some space below
.el-radio-button {
flex: 1; // Make buttons fill the space equally
:deep(.el-radio-button__inner) {
width: 100%;
display: flex;
align-items: center;
justify-content: center; // Center content
border-radius: 4px !important; // Slightly round corners
border: 1px solid var(--el-border-color-lighter); // Use variable for border
box-shadow: none; // Remove default shadow
padding: 8px 5px; // Adjust padding
font-size: 12px; // Smaller font size
.icon {
display: inline-block;
width: 14px;
height: 14px;
margin-right: 5px;
border-radius: 2px;
border: 1px solid var(--el-border-color);
&.system { background: linear-gradient(135deg, #fff 50%, #222 50%); border-color: #888; }
&.light { background-color: #fff; }
&.dark { background-color: #222; border-color: #555; }
}
}
// Style for selected button
&.is-active :deep(.el-radio-button__inner) {
background-color: var(--el-color-primary-light-9);
border-color: var(--el-color-primary);
color: var(--el-color-primary);
box-shadow: none;
}
}
// Remove spacing between buttons created by inline-block nature
.el-radio-button + .el-radio-button {
margin-left: 0;
}
}
.theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); // Responsive grid
gap: 10px; // Gap between items
margin-bottom: 5px;
}
.theme-option {
cursor: pointer;
border: 2px solid transparent;
padding: 4px;
border-radius: 6px;
text-align: center;
transition: border-color 0.2s ease-in-out;
&:hover {
border-color: var(--el-border-color-hover);
}
&.active {
border-color: var(--el-color-primary);
}
.theme-preview {
width: 100%;
height: 30px; // Adjust height as needed
border-radius: 4px;
margin-bottom: 5px;
border: 1px solid var(--el-border-color-lighter); // Add subtle border
}
.theme-label {
font-size: 12px;
color: var(--el-text-color-regular);
display: block; // Ensure label is on new line
}
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px; // Consistent spacing
font-size: 13px;
color: var(--el-text-color-regular);
}
.el-divider {
margin: 18px 0; // Increase divider margin
}
}
</style>

View File

@ -0,0 +1,56 @@
<template>
<el-dropdown trigger="click" @command="handleCommand">
<el-button type="primary">
主题切换
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="(theme, name) in themes" :key="name" :command="name">
<div class="theme-item">
<span class="theme-name">{{ name }}</span>
<div class="theme-colors">
<span v-for="(color, type) in theme" :key="type"
class="color-dot"
:style="{ backgroundColor: color }">
</span>
</div>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup>
import { ArrowDown } from '@element-plus/icons-vue'
import { themes, setTheme } from '@/styles/theme'
const handleCommand = (themeName) => {
setTheme(themeName)
}
</script>
<style scoped>
.theme-item {
display: flex;
align-items: center;
gap: 8px;
}
.theme-name {
text-transform: capitalize;
}
.theme-colors {
display: flex;
gap: 4px;
}
.color-dot {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
</style>

View File

@ -1,21 +1,39 @@
import { createApp } from "vue";
import "./style.css";
import "@/styles/style.css";
// 使用自定义主题
import '@/styles/element-variables.scss'
import App from "./App.vue";
import {router} from "@/router/index";
import { createPinia } from "pinia";
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { useSettingsStore, applyDisplayMode } from '@/store/settings'; // Import store and helper
import { setTheme } from '@/styles/theme'; // Import setTheme
// 引入 Element Plus
import ElementPlus from 'element-plus'
// 使用自定义主题
import './styles/element-variables.scss'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate)
const app = createApp(App);
app.use(pinia);
app.use(pinia); // Pinia is now available
// Access the store *after* app.use(pinia)
// The store state will be hydrated from localStorage here by the persistence plugin
const settingsStore = useSettingsStore();
// Apply persisted/initial settings
applyDisplayMode(settingsStore.displayMode);
setTheme(settingsStore.theme);
// Apply initial animation setting
if (!settingsStore.animationsEnabled) {
document.body.classList.add('no-animations');
} else {
// Ensure the class is not present if animations are enabled initially
document.body.classList.remove('no-animations');
}
app.use(router);
app.use(ElementPlus, {
locale: zhCn,

View File

@ -18,18 +18,12 @@ const routes = [
},
{
path: "/chat",
component: () => import("@/layouts/MainLayout.vue"),
children: [
{
path: "",
name: "chat",
meta: {
icon: ChatRound
},
component: () => import("@/views/chat/index.vue"),
},
],
},
];
export const router = createRouter({

View File

@ -1,34 +1,40 @@
import { defineStore } from 'pinia'
import { setTheme, themes } from '@/styles/theme'
// Helper function to apply display mode
export function applyDisplayMode(mode) {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
let darkModeEnabled = false;
if (mode === 'dark') {
darkModeEnabled = true;
} else if (mode === 'system') {
darkModeEnabled = prefersDark;
} // else mode === 'light', darkModeEnabled remains false
if (darkModeEnabled) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
export const useSettingsStore = defineStore('settings', {
state: () => ({
// 侧边栏状态
sidebarCollapsed: false,
// 界面配置
interface: {
// 是否显示时间戳
showTimestamp: true,
// 消息气泡最大宽度(px)
messageMaxWidth: 800,
// 字体大小(px)
fontSize: 14
},
// 主题配置
theme: {
// 主色调
primary: '#07c160',
// 背景色
background: '#f5f5f5',
// 文字颜色
textColor: '#333333'
}
// 显示模式: 'light', 'dark', 'system'
displayMode: 'system', // Default to system preference
// 主题配置 (e.g., 'default', 'fresh', 'warm')
theme: 'default',
// 动画效果开关
animationsEnabled: true,
}),
persist: {
key: 'settings',
paths: ['sidebarCollapsed']
// Persist displayMode and theme
paths: ['sidebarCollapsed', 'displayMode', 'theme', 'animationsEnabled']
},
actions: {
@ -37,25 +43,54 @@ export const useSettingsStore = defineStore('settings', {
this.sidebarCollapsed = !this.sidebarCollapsed
},
// 更新界面配置
updateInterface(config) {
this.interface = {
...this.interface,
...config
// 切换动画状态
toggleAnimations(enabled) {
if (typeof enabled === 'boolean') {
this.animationsEnabled = enabled;
} else {
this.animationsEnabled = !this.animationsEnabled;
}
// Apply animation class to body
if (this.animationsEnabled) {
document.body.classList.remove('no-animations');
} else {
document.body.classList.add('no-animations');
}
},
// 更新主题配置
updateTheme(config) {
this.theme = {
...this.theme,
...config
// 更新显示模式
updateDisplayMode(newMode) {
if (['light', 'dark', 'system'].includes(newMode)) {
this.displayMode = newMode;
applyDisplayMode(newMode); // Apply the change immediately
// Optional: Re-apply theme colors if they differ significantly in dark mode
// setTheme(this.theme); // Uncomment if needed
} else {
console.warn(`Invalid display mode: ${newMode}`);
}
},
// 更新主题颜色配置
updateTheme(newTheme) {
if (themes.hasOwnProperty(newTheme)) { // Check if the theme exists
this.theme = newTheme;
setTheme(newTheme); // Apply the theme colors
} else {
console.warn(`Invalid theme: ${newTheme}`);
}
},
// 重置所有设置
resetSettings() {
this.$reset()
// Re-apply settings after reset
setTheme(this.theme);
applyDisplayMode(this.displayMode);
this.toggleAnimations(this.animationsEnabled); // Re-apply animation class
}
}
})
// --- Helper Function to apply animation class ---
// Moved applying logic into the toggleAnimations action directly
// export function applyAnimationSetting(enabled) { ... }

View File

@ -1,3 +1,22 @@
/* 基础样式 */
:root {
--primary-color: #409eff;
--success-color: #67c23a;
--warning-color: #e6a23c;
--danger-color: #f56c6c;
--info-color: #909399;
}
/* 全局样式 */
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
@ -58,10 +77,6 @@ button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
/* max-width: 100vh; */
height: 100%;

38
src/styles/theme.js Normal file
View File

@ -0,0 +1,38 @@
// 使用 import.meta.glob 动态导入 themes 目录下的所有 .js 文件
// { eager: true } 表示同步导入,立即获取模块内容
const themeModules = import.meta.glob('./themes/*.js', { eager: true });
// 构建 themes 对象
export const themes = {};
for (const path in themeModules) {
// 从路径中提取主题名称 (e.g., './themes/default.js' -> 'default')
const themeName = path.replace('./themes/', '').replace('.js', '');
// 获取模块的默认导出 (即主题配置对象)
if (themeModules[path].default) {
themes[themeName] = themeModules[path].default;
}
}
// 主题切换函数
export function setTheme(themeName) {
const theme = themes[themeName] || themes.default // Fallback to default
if (!theme) {
console.error(`Theme "${themeName}" not found.`);
return;
}
const root = document.documentElement
// 设置主题色变量
Object.keys(theme).forEach(key => {
root.style.setProperty(`--el-color-${key}`, theme[key])
})
// 设置主题类名
// 查找 body 上是否已有 theme- 开头的类
const currentThemeClass = Array.from(document.body.classList).find(cls => cls.startsWith('theme-'));
if (currentThemeClass) {
document.body.classList.remove(currentThemeClass);
}
document.body.classList.add(`theme-${themeName}`);
}

View File

@ -0,0 +1,7 @@
export default {
primary: '#2C3E50',
success: '#27AE60',
warning: '#F39C12',
danger: '#E74C3C',
info: '#3498DB'
};

View File

@ -0,0 +1,7 @@
export default {
primary: '#5CACEE',
success: '#85CE61',
warning: '#E6A23C',
danger: '#F78989',
info: '#A6A9AD'
};

View File

@ -0,0 +1,7 @@
export default {
primary: '#409EFF',
success: '#67C23A',
warning: '#E6A23C',
danger: '#F56C6C',
info: '#909399'
};

View File

@ -0,0 +1,7 @@
export default {
primary: '#36D1DC',
success: '#5CB85C',
warning: '#F0AD4E',
danger: '#D9534F',
info: '#5BC0DE'
};

View File

@ -0,0 +1,7 @@
export default {
primary: '#FF6B6B',
success: '#4ECDC4',
warning: '#FFE66D',
danger: '#FF6B6B',
info: '#45B7D1'
};

View File

@ -3,7 +3,6 @@
<div class="header-content">
<div class="title">
<el-button
v-if="isMobile || settingsStore.sidebarCollapsed"
class="desktop-sidebar-toggle"
:icon="Expand"
@click="settingsStore.toggleSidebar"
@ -128,7 +127,7 @@ onUnmounted(() => {
.chat-header {
height: 60px;
border-bottom: 1px solid #e5e5e5;
background: #fff;
background: #FFF;
padding: 0 20px;
flex-shrink: 0;

View File

@ -709,7 +709,6 @@ onUnmounted(() => {
padding: 16px;
background: #f5f5f5;
border-top: 1px solid #e5e5e5;
z-index: 101;
:deep(.el-textarea) {
flex: 1;

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="mode-selector-container">
<div
class="mode-selector-backdrop"
:class="{ visible: !settingsStore.sidebarCollapsed }"
@ -8,57 +8,40 @@
></div>
<div class="mode-selector" :class="{ 'collapsed': settingsStore.sidebarCollapsed }">
<div class="mode-header">
<div class="mode-title" v-if="!settingsStore.sidebarCollapsed">
<img
src="/lgogo.svg"
alt="Logo"
class="lgoo-icon"
/>
</div>
<div class="collapsed-logo" v-else>
<img
src="/logo.svg"
alt="Collapsed Logo"
class="lgoo-icon-small"
/>
</div>
<div v-if="!settingsStore.sidebarCollapsed" class="header-actions">
<!-- <HistoryButton v-if="!settingsStore.sidebarCollapsed" :icon-only="true" /> -->
<el-button
class="collapse-btn"
:icon="Fold"
text
bg
@click="settingsStore.toggleSidebar"
/>
<div class="mode-header-title" v-if="!settingsStore.sidebarCollapsed">
<img src="/lgogo.svg" alt="Logo" class="mode-header-icon" />
</div>
</div>
<div class="mode-list">
<div
v-for="mode in chatModes"
:key="mode.id"
:class="['mode-item', { active: chatStore.currentMode === mode.id }]"
@click="selectMode(mode.id)"
<el-menu
:default-active="chatStore.currentMode"
class="mode-menu"
:collapse="settingsStore.sidebarCollapsed"
@select="selectMode"
>
<span class="emoji-icon">{{ mode.icon }}</span>
<span v-if="!settingsStore.sidebarCollapsed" class="mode-name">{{ mode.name }}</span>
<ModeSetting v-if="!settingsStore.sidebarCollapsed" @command="(cmd) => handleModeSettings(cmd, mode.id)">
<el-dropdown-item command="edit">
<el-icon><EditPen /></el-icon>
编辑设置
</el-dropdown-item>
<el-dropdown-item command="reset">
<el-icon><RefreshRight /></el-icon>
重置设置
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>
删除场景
</el-dropdown-item>
</ModeSetting>
<el-menu-item v-for="mode in chatModes" :key="mode.id" :index="mode.id" class="mode-item">
<el-icon><component :is="mode.icon" /></el-icon>
<span class="mode-name">{{ mode.name }}</span>
</el-menu-item>
</el-menu>
<!-- Settings Button Popover -->
<el-popover
placement="top-end"
trigger="click"
popper-class="settings-popover-popper"
>
<template #reference>
<div class="sidebar-footer">
<div class="settings-button" title="设置">
<el-icon><Setting /></el-icon>
<span class="button-text">设置</span>
</div>
</div>
</template>
<!-- Popover Content -->
<SettingsPanel />
</el-popover>
</div>
</div>
</template>
@ -67,10 +50,9 @@
import { useChatStore } from '@/store/chat'
import { useSettingsStore } from '@/store/settings'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { Delete, EditPen, RefreshRight, Expand, Fold } from '@element-plus/icons-vue'
import { ElDropdown, ElDropdownMenu, ElDropdownItem, ElButton, ElMessageBox } from 'element-plus'
import ModeSetting from '@/components/chat/ModeSetting.vue'
import HistoryButton from '@/components/chat/HistoryButton.vue'
import { ChatDotRound, ChatLineRound, ChatRound, Service, Setting } from '@element-plus/icons-vue'
import { ElMenu, ElMenuItem, ElIcon, ElPopover } from 'element-plus'
import SettingsPanel from '@/components/settings/SettingsPanel.vue'
const chatStore = useChatStore()
const settingsStore = useSettingsStore()
@ -79,7 +61,9 @@ const chatModes = computed(() => {
return Object.keys(chatStore.chatModes).map(key => ({
id: key,
name: chatStore.chatModes[key].name,
icon: chatStore.chatModes[key].icon
icon: key === 'general' ? ChatRound :
key === 'assistant' ? Service :
key === 'chat' ? ChatDotRound : ChatLineRound
}))
})
@ -88,58 +72,10 @@ const currentModeHistory = computed(() => {
return chatStore.conversations.filter(chat => chat.mode === chatStore.currentMode)
})
//
const formatChatTitle = (chat) => {
// 使使
if (chat.messages && chat.messages.length > 0) {
const firstMessage = chat.messages[0].question
return firstMessage.length > 20 ? firstMessage.substring(0, 20) + '...' : firstMessage
}
return `会话 ${chat.conversationId.substring(0, 8)}`
}
const selectMode = (modeId) => {
chatStore.setCurrentMode(modeId)
}
const handleHistorySelect = (conversationId) => {
chatStore.switchConversation(conversationId)
}
const handleDelete = async (conversationId) => {
try {
await ElMessageBox.confirm(
'确定要删除这个会话吗?删除后无法恢复。',
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
chatStore.deleteConversation(conversationId)
} catch {
//
}
}
const handleModeSettings = (command, modeId) => {
switch (command) {
case 'edit':
// TODO:
console.log('编辑设置:', modeId)
break
case 'reset':
// TODO:
console.log('重置设置:', modeId)
break
case 'delete':
// TODO:
console.log('删除场景:', modeId)
break
}
}
const isMobile = ref(false)
const checkMobile = () => {
@ -157,341 +93,275 @@ onUnmounted(() => {
</script>
<style lang="scss" scoped>
$primary-color: #4CAF50;
$text-color: #333;
$text-color-dark: #fff;
$background-color: #F7F7F7;
$background-color-dark: #1e1e1e;
$border-color: #e0e0e0;
$border-color-dark: #333;
$hover-color: #f5f5f5;
$hover-color-dark: #2d2d2d;
.mode-selector-container {
height: 100vh;
position: relative;
}
.mode-selector {
width: 240px;
min-width: 240px;
background: $background-color;
border-right: 1px solid $border-color;
height: 100%;
padding: 0;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
background-color: var(--el-bg-color);
border-right: 1px solid var(--el-border-color-light);
position: relative;
z-index: 1000;
z-index: 10;
display: flex;
flex-direction: column;
body:not(.no-animations) & {
transition: width 0.3s ease-in-out;
}
&.collapsed {
width: 64px;
min-width: 64px;
.mode-header {
justify-content: center;
padding: 0;
}
.mode-item {
padding: 15px 0;
justify-content: center;
.mode-name {
display: none;
}
.emoji-icon {
font-size: 24px;
width: 32px;
}
}
.header-actions {
width: 100%;
justify-content: center;
}
}
.mode-header {
padding: 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
border-bottom: 1px solid #e5e5e5;
.header-actions {
padding: 0 16px;
display: flex;
align-items: center;
gap: 8px;
&-title {
display: flex;
align-items: center;
.mode-header-icon {
height: 32px;
will-change: filter;
body:not(.no-animations) & {
transition: filter 300ms;
}
.mode-title {
color: $text-color;
font-size: 16px;
font-weight: 500;
margin: 0;
white-space: nowrap;
&:hover {
filter: drop-shadow(0 0 0.75em rgba(var(--el-color-primary-rgb), 0.6));
}
}
}
}
.mode-menu {
background-color: transparent;
border: none;
padding: 8px;
flex-grow: 1;
overflow-y: auto;
:deep(.el-menu-item) {
height: 44px;
line-height: 44px;
color: var(--el-text-color-regular);
border-radius: 8px;
margin-bottom: 4px;
padding: 0 12px !important;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
justify-content: center;
.lgoo-icon {
vertical-align: middle;
transition: all 0.3s ease;
height: 36px;
width: auto;
}
.el-icon {
margin-right: 12px;
font-size: 18px;
width: 18px;
}
.collapsed-logo {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
&.is-active {
background-image: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%);
color: white;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
.lgoo-icon-small {
height: 36px;
width: auto;
}
}
.history-btn {
font-size: 14px;
height: 32px;
padding: 4px 12px;
color: $text-color;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 4px;
&:hover {
color: #07c160;
background: rgba(7, 193, 96, 0.1);
}
:deep(.el-icon) {
font-size: 16px;
}
}
.collapse-btn {
font-size: 14px;
height: 32px;
width: 32px;
padding: 0;
color: $text-color;
transition: all 0.3s ease;
border: none !important;
background: transparent !important;
&:hover {
color: $primary-color;
background: transparent !important;
}
&:focus {
outline: none;
box-shadow: none;
}
:deep(.el-icon) {
font-size: 16px;
}
}
}
.mode-list {
padding: 0 0 20px 0;
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 10px;
}
.mode-item {
padding: 15px 20px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: all 0.3s ease;
color: $text-color;
position: relative;
&:hover {
background: $hover-color;
:deep(.setting-btn) {
opacity: 1;
transform: translateX(0);
}
}
&.active {
background: $primary-color;
.el-icon {
color: white;
}
}
.emoji-icon {
font-size: 20px;
width: 24px;
height: 24px;
text-align: center;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&:not(.is-active):hover {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.mode-name {
font-size: 14px;
flex: 1;
margin-left: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.mode-setting) {
margin-left: auto;
}
}
}
:deep(.el-dropdown-menu__item) {
font-size: 13px;
padding: 8px 16px;
line-height: 1.4;
max-width: 300px;
overflow-x: hidden;
&:hover .delete-btn {
opacity: 1;
transform: translateX(0);
body:not(.no-animations) & {
transition: opacity 0.3s ease-in-out;
}
}
}
}
:deep(.el-dropdown-menu) {
min-width: 200px;
max-width: 300px;
}
.history-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
position: relative;
min-width: 0;
}
.history-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 24px;
min-width: 0;
}
.delete-btn {
.mode-setting-btn {
position: absolute;
right: -8px;
opacity: 0;
transform: translateX(10px);
transition: all 0.3s ease;
height: 24px;
width: 24px;
padding: 0;
color: #f56c6c;
flex-shrink: 0;
right: 8px;
top: 50%;
transform: translateY(-50%);
color: var(--el-text-color-secondary);
&:hover {
color: #f56c6c;
background-color: rgba(245, 108, 108, 0.1);
}
:deep(.el-icon) {
font-size: 14px;
color: var(--el-color-primary);
}
}
/* 移动端样式 */
@media screen and (max-width: 768px) {
.mode-selector {
position: fixed;
left: 0;
top: 0;
bottom: 0;
transform: translateX(-100%);
z-index: 1000;
&.collapsed {
transform: translateX(-100%);
width: 64px;
min-width: 64px;
.collapsed-logo {
padding: 0 5px;
}
}
&:not(.collapsed) {
transform: translateX(0);
}
}
.mode-selector-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
background-color: var(--el-overlay-color-lighter);
z-index: 9;
display: none;
&.visible {
display: block;
}
}
.mode-selector {
position: fixed;
left: 0;
top: 0;
&.collapsed {
transform: translateX(-100%);
}
}
}
.mode-selector.collapsed {
.mode-menu {
padding: 8px 12px;
}
:deep(.el-menu-item) {
width: 40px;
height: 40px;
padding: 0 !important;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 4px auto;
.el-icon {
margin-right: 0;
}
.mode-name {
display: none;
}
}
.settings-button {
width: 40px;
height: 40px;
.mode-name,
.button-text {
opacity: 0;
}
}
}
.sidebar-footer {
padding: 8px;
margin-top: auto;
}
.settings-button {
display: flex;
align-items: center;
height: 44px;
line-height: 44px;
color: var(--el-text-color-regular);
border-radius: 8px;
padding: 0 12px !important;
cursor: pointer;
overflow: hidden;
body:not(.no-animations) & {
transition: background-color 0.3s, color 0.3s;
}
.el-icon {
margin-right: 12px;
font-size: 18px;
width: 18px;
color: var(--el-text-color-regular);
body:not(.no-animations) & {
transition: color 0.3s;
}
}
.button-text {
font-size: 14px;
margin-left: 12px;
white-space: nowrap;
opacity: 1;
visibility: visible;
transform: translateX(0);
body:not(.no-animations) & {
transition: opacity 0.3s ease-in-out;
}
}
&:hover {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
.el-icon {
color: var(--el-color-primary);
}
}
}
// @media (prefers-color-scheme: dark) {
// .mode-selector {
// background: $background-color-dark;
// border-right-color: $border-color-dark;
.mode-selector.collapsed {
.sidebar-footer {
padding: 8px 12px;
.settings-button {
width: 40px;
height: 40px;
}
}
// .mode-title {
// color: $text-color-dark;
// }
.mode-menu {
padding: 8px 12px;
}
// .mode-item {
// color: $text-color-dark;
:deep(.el-menu-item) {
width: 40px;
height: 40px;
padding: 0 !important;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 4px auto;
// &:hover {
// background: $hover-color-dark;
// }
// }
.el-icon {
margin-right: 0;
}
// .history-btn {
// color: $text-color-dark;
.mode-name {
display: none;
}
}
// &:hover {
// color: $primary-color;
// background: rgba($primary-color, 0.15);
// }
// }
// }
// .delete-btn {
// &:hover {
// background-color: rgba(245, 108, 108, 0.15);
// }
// }
// }
.settings-button {
.mode-name,
.button-text {
opacity: 0;
}
}
}
</style>
<style lang="scss">
.settings-popover-popper {
padding: 0 !important;
min-width: 240px !important;
width: auto !important;
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="chat">
<div class="chat-page">
<div class="chat-container">
<ChatModeSelector @mode-changed="handleModeChange" />
<div class="chat-wrapper">
@ -27,7 +27,7 @@ const handleModeChange = (mode) => {
<style>
<style lang="scss" scoped>
* {
margin: 0;
padding: 0;
@ -35,8 +35,8 @@ const handleModeChange = (mode) => {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
}
.chat {
height: 100%;
.chat-page {
height: 100vh;
margin: 0;
padding: 0;
background-color: #f5f5f5;

2046
yarn.lock

File diff suppressed because it is too large Load Diff