feat(layout): 添加基础布局组件和侧边栏功能- 新增 BasicLayout.vue 基础布局组件,包含侧边栏和主内容区域- 实现侧边栏展开/收缩功能,支持通过 props 控制显示状态- 添加侧边栏切换按钮,支持点击切换显示状态- 新增 SideBar.vue 组件,包含历史对话列表和新对话按钮

- 实现历史对话项的展示和操作菜单(重命名、删除)
- 添加 AI 机器人 logo 和新对话图标 SVG 文件
- 引入 ant-design-vue 图标组件库,支持菜单操作图标
- 更新 IndexPage.vue 使用新的布局组件重构页面结构
- 移除原有聊天界面相关逻辑,简化首页展示内容- 添加底部提示文字"内容由 AI 生成,请仔细甄别"
This commit is contained in:
2025-11-06 14:02:22 +08:00
parent c3d2f680c0
commit 6afe9b25e9
10 changed files with 237 additions and 222 deletions

5
components.d.ts vendored
View File

@@ -12,8 +12,13 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AButton: typeof import('ant-design-vue/es')['Button']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SideBar: typeof import('./src/components/SideBar.vue')['default']
StreamMarkdownRender: typeof import('./src/components/StreamMarkdownRender.vue')['default']
SvgIcon: typeof import('./src/components/SvgIcon.vue')['default']
}

View File

@@ -16,6 +16,7 @@
"format": "prettier --write src/"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@tailwindcss/vite": "^4.1.15",
"ant-design-vue": "~4.2.6",
"highlight.js": "^11.11.1",

3
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@ant-design/icons-vue':
specifier: ^7.0.1
version: 7.0.1(vue@3.5.22(typescript@5.9.3))
'@tailwindcss/vite':
specifier: ^4.1.15
version: 4.1.15(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2))

View File

@@ -0,0 +1,6 @@
<svg t="1755328337279" fill="currentColor" viewBox="0 0 1024 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg" p-id="35341" width="200" height="200">
<path
d="M585.152 85.312C827.52 85.312 1024 276.352 1024 512s-196.48 426.688-438.848 426.688H438.848C196.48 938.688 0 747.648 0 512s196.48-426.688 438.848-426.688h146.304z m103.936 244.48H334.912C231.424 329.792 147.52 411.392 147.52 512c0 99.648 82.24 180.608 184.32 182.208h357.248c103.488 0 187.392-81.6 187.392-182.208s-83.84-182.208-187.392-182.208zM347.392 418.688c37.184 0 67.328 29.312 67.328 65.536v56.64c0 36.224-30.08 65.6-67.328 65.6-37.184 0-67.328-29.376-67.328-65.6v-56.64c0-36.224 30.08-65.536 67.328-65.536z m415.424 9.088a35.648 35.648 0 0 1-11.136 49.6l-0.512 0.32-52.928 32.448 51.008 27.264a35.712 35.712 0 0 1 14.976 48.64l-0.256 0.512a37.696 37.696 0 0 1-49.92 14.592l-0.576-0.32L607.168 544a35.712 35.712 0 0 1-2.624-61.888l0.704-0.448 106.304-65.216a37.76 37.76 0 0 1 51.264 11.328z"
fill="#165DFF" p-id="35342"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,6 @@
<svg t="1755332748552" fill="currentColor" viewBox="0 0 1109 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg" p-id="37243" width="200" height="200">
<path
d="M1018.154667 82.56C1080.32 145.066667 1088 202.581333 1088 449.493333c0 246.698667-7.68 304.512-69.845333 366.933334-60.16 60.586667-115.968 69.802667-342.784 70.357333h-22.442667c-47.36 0-105.301333 18.346667-169.216 51.797333-149.973333 78.208-171.946667 86.784-214.442667 58.026667-32.213333-21.76-40.106667-40.96-35.84-117.674667-63.402667-8.874667-107.392-27.392-142.250666-62.464C31.018667 756.053333 21.888 699.989333 21.333333 472.149333v-22.613333c0-246.698667 7.68-304.512 69.845334-366.933333C151.338667 21.973333 207.146667 12.8 433.92 12.202667h218.965333c245.376 0 303.018667 7.808 365.226667 70.314666z m-344.746667 13.909333h-237.482667c-200.106667 0.469333-247.722667 7.936-285.013333 45.482667-37.418667 37.589333-44.928 85.76-45.354667 286.933333v41.258667c0.469333 201.386667 7.936 249.386667 45.312 287.018667 19.456 19.498667 45.824 31.104 89.002667 37.632l0.64-4.949334 2.090667-22.997333a42.112 42.112 0 0 1 32.213333-37.205333l6.570667-1.024h6.912a42.112 42.112 0 0 1 38.144 45.738666l-2.090667 23.04c-6.613333 72.618667-7.850667 108.458667-8.021333 122.282667v4.693333l0.085333 2.474667c-0.256-0.128 32.768-13.056 128.341333-62.933333 74.965333-39.125333 144.853333-61.354667 208.170667-61.354667 217.472 0 267.008-6.741333 305.493333-45.482667 38.613333-38.826667 45.354667-88.96 45.354667-307.541333 0-218.837333-6.656-268.8-45.312-307.626667-37.333333-37.461333-85.077333-44.970667-285.056-45.44zM554.666667 259.285333a42.112 42.112 0 0 1 42.112 42.069334l-0.085334 105.984 105.344 0.085333a42.112 42.112 0 0 1 41.557334 35.285333l0.554666 6.826667a42.112 42.112 0 0 1-42.112 42.069333h-105.344l0.085334 106.069334a42.069333 42.069333 0 0 1-35.328 41.514666l-6.784 0.64a42.112 42.112 0 0 1-42.112-42.112l-0.085334-106.154666-105.173333 0.042666a42.112 42.112 0 0 1-41.557333-35.285333l-0.554667-6.826667a42.112 42.112 0 0 1 42.112-42.069333h105.216l0.042667-106.197333a42.069333 42.069333 0 0 1 35.328-41.557334l6.784-0.426666z"
p-id="37244"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
<svg t="1755325907291" fill="currentColor" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="28009" width="200" height="200"><path d="M638.763 970.667h-256c-231.766 0-330.667-98.902-330.667-330.667V384c0-231.765 98.901-330.667 330.667-330.667h256c231.765 0 330.666 98.902 330.666 330.667v256c0 231.765-98.901 330.667-330.666 330.667z m-256-853.334c-196.864 0-266.667 69.803-266.667 266.667v256c0 196.864 69.803 266.667 266.667 266.667h256c196.864 0 266.666-69.803 266.666-266.667V384c0-196.864-69.76-266.667-266.666-266.667z" p-id="28010"></path><path d="M606.763 938.667V85.333a32.213 32.213 0 0 1 32-32 32.213 32.213 0 0 1 32 32v853.334a32 32 0 0 1-64 0z m-289.28-294.742a32.17 32.17 0 0 1 0-45.226l86.613-86.614-86.613-86.698a32 32 0 0 1 45.184-45.227l109.226 109.227a32.17 32.17 0 0 1 0 45.226L362.667 643.925a30.293 30.293 0 0 1-22.614 9.387 31.659 31.659 0 0 1-22.57-9.387z" p-id="28011"></path></svg>

After

Width:  |  Height:  |  Size: 937 B

View File

@@ -0,0 +1 @@
<svg t="1755324148406" fill="currentColor" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="26171" width="200" height="200"><path d="M768 0H256C115.2 0 0 115.2 0 256v512c0 140.8 115.2 256 256 256h512c140.8 0 256-115.2 256-256V256c0-140.8-115.2-256-256-256zM119.466667 904.533333c-38.4-38.4-55.466667-81.066667-55.466667-136.533333V256c0-51.2 17.066667-98.133333 55.466667-136.533333 38.4-38.4 85.333333-55.466667 136.533333-55.466667h21.333333v896H256c-51.2 0-98.133333-17.066667-136.533333-55.466667zM960 768c0 51.2-17.066667 98.133333-55.466667 136.533333-38.4 38.4-81.066667 55.466667-136.533333 55.466667H341.333333v-896h426.666667c51.2 0 98.133333 17.066667 136.533333 55.466667 38.4 38.4 55.466667 81.066667 55.466667 136.533333v512z" p-id="26172"></path><path d="M699.733333 618.666667l-106.666666-102.4 106.666666-110.933334c12.8-12.8 12.8-34.133333 0-46.933333-12.8-12.8-34.133333-12.8-46.933333 0l-128 132.266667c-12.8 12.8-12.8 34.133333 0 46.933333l128 123.733333c12.8 12.8 34.133333 12.8 46.933333 0 12.8-8.533333 12.8-29.866667 0-42.666666z" p-id="26173"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

141
src/components/SideBar.vue Normal file
View File

@@ -0,0 +1,141 @@
<template>
<!-- 左边栏 -->
<div
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
class="w-64 bg-[#f9fbff] border-r border-gray-200 fixed left-0 top-0 h-full transition-transform duration-300 ease-in-out z-10 overflow-y-auto"
>
<!-- 侧边栏内容区域 -->
<div class="p-0 h-full flex flex-col">
<!-- Logo 与应用名称 -->
<div @click="jumpToIndexPage" class="flex items-center justify-center p-4 cursor-pointer">
<SvgIcon name="ai-robot-logo" customCss="w-8 h-8 text-gray-700 mr-3" />
<span class="text-2xl font-bold font-sans tracking-wide text-gray-800">小维AI机器人</span>
</div>
<!-- 开启新对话按钮 -->
<button
@click="jumpToIndexPage"
class="mx-auto mb-[34px] my-2 px-6 py-2 text-white rounded-xl transition-colors new-chat-btn w-fit cursor-pointer"
>
<SvgIcon name="new-chat" customCss="w-6 h-6 mr-1.5 inline text-[#4d6bfe]" />
开启新对话
</button>
<!-- 历史对话区域 -->
<div class="my-4 px-2 overflow-y-auto overflow-x-hidden flex-1">
<div class="space-y-1">
<div class="text-xs px-3 py-1 text-gray-500">历史对话</div>
<div
v-for="(historyChat, index) in historyChats"
:key="index"
class="relative px-3 py-1 rounded-xl hover:bg-[rgb(239,246,255)] cursor-pointer transition-colors flex items-center justify-between"
@mouseenter="showButton = historyChat.uuid"
@mouseleave="showButton = null"
>
<a-tooltip placement="top">
<!-- Tooltip 提示文字 -->
<template #title>
<span>{{ historyChat.summary }}</span>
</template>
<p class="text-[14px] text-gray-800 overflow-hidden whitespace-nowrap">
{{ historyChat.summary }}
</p>
</a-tooltip>
<!-- 下拉菜单 -->
<a-dropdown>
<template #overlay>
<a-menu>
<a-menu-item key="rename">
<EditOutlined />
重命名
</a-menu-item>
<a-menu-item key="delete" danger>
<DeleteOutlined />
删除
</a-menu-item>
</a-menu>
</template>
<!-- 右边菜单按钮 -->
<button
class="z-10 rounded-lg outline-none justify-center items-center bg-white w-6 h-6 flex absolute right-2 top-1/2 transform -translate-y-1/2 transition-all duration-300 hover:bg-gray-50"
:style="{ opacity: showButton === historyChat.uuid ? 1 : 0 }"
>
<EllipsisOutlined class="w-4 h-4 text-gray-500" />
</button>
</a-dropdown>
</div>
</div>
</div>
</div>
</div>
<!-- 侧边栏切换按钮 -->
<a-tooltip placement="bottom">
<!-- Tooltip 提示文字 -->
<template #title>
<span>{{ sidebarOpen ? '收缩边栏' : '打开边栏' }}</span>
</template>
<button
:class="sidebarOpen ? 'left-64' : 'left-0'"
@click="toggleSidebar"
class="fixed top-4 z-20 bg-white border border-gray-200 rounded-r-lg p-2 transition-all duration-300"
>
<!-- 图标 -->
<SvgIcon
:name="sidebarOpen ? 'sidebar-open' : 'sidebar-close'"
:customCss="sidebarOpen ? 'w-6 h-6 text-gray-400' : 'w-7 h-7 text-gray-400'"
/>
</button>
</a-tooltip>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { EditOutlined, EllipsisOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import { useRouter } from 'vue-router'
const router = useRouter()
// 跳转到首页
const jumpToIndexPage = () => {
router.push('/')
}
// 定义 props, 对外部暴露配置项
defineProps<{
sidebarOpen: boolean
}>()
// 定义emits
const emit = defineEmits(['toggle-sidebar'])
// 切换侧边栏显示/隐藏
const toggleSidebar = () => {
emit('toggle-sidebar')
}
// 历史对话
const historyChats = ref([
{ uuid: '9640a419-4b0c-45dd-b16d-1980df2424c4', summary: '新对话1' },
{ uuid: '7c2af48e-dce2-4822-aef6-c7a3c1949805', summary: '新对话2' },
{ uuid: '152496bc-2776-422d-ac96-5dbfc903bc1d', summary: '新对话3' },
])
// 当前显示右侧栏按钮的聊天 ID
const showButton = ref<string | null>(null)
</script>
<style scoped>
.overflow-y-auto {
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 自定义滚动条颜色 */
}
.new-chat-btn {
background-color: rgb(219 234 254);
color: #4d6bfe;
}
.new-chat-btn:hover {
background-color: #c6dcf8;
}
</style>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import SideBar from '@/components/SideBar.vue'
import { ref } from 'vue'
// 左边栏状态true 表示默认展开
const sidebarOpen = ref(true)
// 定义props
withDefaults(defineProps<{
showFooterText?: boolean
}>(), {
showFooterText: true // 设置默认值
})
// 切换侧边栏显示/隐藏
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
</script>
<template>
<div class="h-screen flex overflow-hidden overflow-x-hidden">
<!-- 左边栏 -->
<SideBar :sidebarOpen="sidebarOpen" @toggle-sidebar="toggleSidebar" />
<!-- 主内容区域 -->
<div
:class="sidebarOpen ? 'ml-64' : 'ml-0'"
class="flex flex-col flex-1 transition-all duration-300"
>
<!-- 插槽 -->
<slot name="main-content"></slot>
</div>
<!-- 吸附底部的提示文字 -->
<div
v-if="showFooterText"
:class="sidebarOpen ? 'ml-64' : 'ml-0'"
class="fixed bottom-0 left-0 right-0 flex items-center justify-center text-xs text-gray-400 transition-all duration-300 py-2"
>
内容由 AI 生成请仔细甄别
</div>
</div>
</template>

View File

@@ -1,233 +1,42 @@
<template>
<div class="h-screen flex flex-col overflow-y-auto" ref="chatContainer">
<!-- 聊天记录区域 -->
<div class="flex-1 max-w-3xl mx-auto pb-24 pt-4 px-4">
<!-- 遍历聊天记录 -->
<template v-for="(chat, index) in chatList" :key="index">
<!-- 用户提问消息靠右 -->
<div v-if="chat.role === 'user'" class="flex justify-end mb-4">
<div class="quesiton-container">
<p>{{ chat.content }}</p>
<Layout>
<!-- 主内容区域 -->
<template #main-content>
<div class="flex items-center justify-center flex-1">
<div class="max-w-3xl w-full">
<div class="text-center mb-10">
<div class="flex items-center justify-center mb-3">
<SvgIcon name="ai-robot-logo" customCss="w-10 h-10 text-gray-700 mr-3" />
<h2 class="text-2xl text-gray-800">我是小维 AI 机器人很高兴见到你</h2>
</div>
<p class="text-gray-500">我可以帮你写代码写作各种创意内容请把你的任务交给我吧~</p>
</div>
</div>
<!-- 大模型回复消息靠左 -->
<div v-else class="flex mb-4">
<!-- 头像 -->
<div class="shrink-0 mr-3">
<div
class="w-8 h-8 rounded-full flex items-center justify-center border border-gray-200"
<div class="bg-gray-100 rounded-3xl px-4 py-3 mx-4 border border-gray-200 flex flex-col">
<textarea
v-model="message"
placeholder="给小维AI 机器人发送消息"
class="bg-transparent border-none outline-none w-full text-sm resize-none min-h-6"
rows="2"
ref="textareaRef"
>
<SvgIcon name="deepseek-logo" customCss="w-5 h-5"></SvgIcon>
</textarea>
<!-- 发送按钮 -->
<div class="flex justify-end mt-3">
<button
class="flex items-center justify-center bg-[#4d6bfe] rounded-full w-8 h-8 border border-[#4d6bfe] hover:bg-[#3b5bef] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<SvgIcon name="up-arrow" customCss="w-5 h-5 text-white"></SvgIcon>
</button>
</div>
</div>
<!-- 回复的内容 -->
<div class="p-1 mb-2 max-w-[90%]">
<StreamMarkdownRender :content="chat.content" />
</div>
</div>
</template>
</div>
<!-- 提问输入框 -->
<div class="sticky max-w-3xl mx-auto bg-white bottom-0 left-0 w-full">
<div class="bg-gray-100 rounded-3xl px-4 py-3 mx-4 border border-gray-200 flex flex-col">
<textarea
v-model="message"
placeholder="给小维 AI 机器人发送消息"
class="bg-transparent border-none outline-none w-full text-sm resize-none min-h-6"
rows="2"
@input="autoResize"
ref="textareaRef"
@keydown.enter.prevent="sendMessage"
>
</textarea>
<!-- 发送按钮 -->
<div class="flex justify-end">
<button
@click="sendMessage"
:disabled="!message.trim()"
class="flex items-center justify-center bg-[#4d6bfe] rounded-full w-8 h-8 border border-[#4d6bfe] hover:bg-[#3b5bef] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<SvgIcon name="up-arrow" customCss="w-5 h-5 text-white"></SvgIcon>
</button>
</div>
</div>
<!-- 提示文字 -->
<div class="flex items-center justify-center text-xs text-gray-400 mt-2">
内容由 AI 生成请仔细甄别
</div>
</div>
</div>
</template>
</Layout>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, nextTick } from 'vue'
import StreamMarkdownRender from '@/components/StreamMarkdownRender.vue'
interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
// 输入的消息
const message = ref<string>('')
// 聊天容器引用
const chatContainer = ref<HTMLElement | null>(null)
// textarea引用
const textareaRef = ref<HTMLTextAreaElement | null>(null)
// 当前 SSE 连接实例
let eventSource: EventSource | null = null
// 聊天记录 (给个默认的问候语)
const chatList = ref<ChatMessage[]>([
{
role: 'assistant',
content:
'我是小维智能 AI 助手!✨ 我可以帮你解答各种问题,无论是学习、工作,还是日常生活中的小困惑,都可以找我聊聊。有什么我可以帮你的吗?😊',
},
])
// 发送消息
const sendMessage = async (): Promise<void> => {
// 校验发送的消息不能为空
if (!message.value.trim()) return
// 将用户发送的消息添加到 chatList 聊天列表中
const userMessage = message.value.trim()
chatList.value.push({ role: 'user', content: userMessage })
// 点击发送按钮后,清空输入框
message.value = ''
// 将输入框的高度重置
if (textareaRef.value) {
textareaRef.value.style.height = 'auto'
}
// 发送新的消息前关闭旧的 SSE 连接
closeSSE()
// 添加一个占位的回复消息
chatList.value.push({ role: 'assistant', content: '' })
const updateAssistantMessage = (content: string) => {
const lastIndex = chatList.value.length - 1
if (lastIndex < 0) return
const lastMessage = chatList.value[lastIndex]
if (lastMessage?.role !== 'assistant') return
lastMessage.content = content
}
try {
// 建立 SSE 连接
eventSource = new EventSource(
`http://localhost:8080/v6/ai/generateStream?message=${encodeURIComponent(userMessage)}`,
)
// 响应的回答
let responseText = ''
// 处理消息事件
eventSource.onmessage = (event) => {
console.log('接收到数据: ', event.data)
if (event.data) {
// 若响应数据不为空
// 持续追加流式回答
const response = JSON.parse(event.data)
responseText += response.v
// 更新最后一条消息
updateAssistantMessage(responseText)
// 滚动到底部
scrollToBottom()
}
}
// 处理错误
eventSource.onerror = (error) => {
// 通常 SSE 在完成传输后会触发一次 error 事件,这是正常的
if (error.eventPhase === EventSource.CLOSED) {
console.log('SSE正常关闭')
} else {
// 提示用户 “请求出错”
updateAssistantMessage('抱歉,请求出错了,请稍后重试。')
// 滚动到底部
scrollToBottom()
}
// 关闭 SSE
closeSSE()
}
} catch (error) {
console.error('发送消息错误: ', error)
// 提示用户 “请求出错”
updateAssistantMessage('抱歉,请求出错了,请稍后重试。')
closeSSE()
// 滚动到底部
scrollToBottom()
}
}
// 关闭 SSE 连接
const closeSSE = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
// 滚动到底部
const scrollToBottom = async () => {
await nextTick() // 等待 Vue.js 完成 DOM 更新
if (chatContainer.value) {
// 若容器存在
// 将容器的滚动条位置设置到最底部
const container = chatContainer.value
container.scrollTop = container.scrollHeight
}
}
// 组件卸载时自动关闭连接
onBeforeUnmount(() => {
closeSSE()
})
// 自动调整文本域高度
const autoResize = () => {
const textarea = textareaRef.value
if (textarea) {
// 重置高度以获取正确的滚动高度
textarea.style.height = 'auto'
// 计算新高度,但最大不超过 300px
const newHeight = Math.min(textarea.scrollHeight, 300)
textarea.style.height = newHeight + 'px'
// 如果内容超出 300px则启用滚动
textarea.style.overflowY = textarea.scrollHeight > 300 ? 'auto' : 'hidden'
}
}
import Layout from '@/layouts/BasicLayout.vue'
import SvgIcon from '@/components/SvgIcon.vue'
</script>
<style scoped>
.quesiton-container {
font-size: 16px;
line-height: 28px;
color: #262626;
padding: calc((44px - 28px) / 2) 20px;
box-sizing: border-box;
white-space: pre-wrap;
word-break: break-word;
background-color: #eff6ff;
border-radius: 14px;
max-width: calc(100% - 48px);
position: relative;
}
/* 聊天内容区域样式 */
.overflow-y-auto {
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 自定义滚动条颜色 */
}
</style>