24 KiB
数智 AI 聊天前端页面实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在 src/views/frontend 目录下实现完整的数智 AI 聊天页面,包含左侧会话列表、聊天窗口、输入区(含@引用文件)、右侧文件列表面板。
Architecture: 采用 Vue 3 Composition API + Element Plus + SCSS,主页面 index.vue 整合各子组件,各组件职责单一,通过 props/emit 通信。
Tech Stack: Vue 3, Element Plus, SCSS
文件结构
src/views/frontend/
├── index.vue # 主页面
└── components/
├── ConversationSidebar.vue # 左侧会话列表
├── ChatHeader.vue # 顶部栏
├── ChatWindow.vue # 聊天消息区
├── InputArea.vue # 输入区域(含@引用)
├── FilePanel.vue # 右侧文件列表面板
├── FileSelectModal.vue # @文件选择弹层
└── QuotedFiles.vue # 已引用文件区域
Task 1: 创建目录结构
- Step 1: 创建 components 目录
mkdir -p src/views/frontend/components
Task 2: 创建 ConversationSidebar.vue(左侧会话列表)
Files:
-
Create:
src/views/frontend/components/ConversationSidebar.vue -
Step 1: 创建组件文件
<template>
<div class="conversation-sidebar" :class="{ collapsed }">
<div class="sidebar-toggle" @click="toggleSidebar">
<el-icon v-if="collapsed"><DArrowRight /></el-icon>
<el-icon v-else><DArrowLeft /></el-icon>
</div>
<div class="sidebar-content" v-show="!collapsed">
<div class="sidebar-header">
<span>会话列表</span>
</div>
<el-scrollbar height="calc(100vh - 280px)">
<div
v-for="conv in conversationList"
:key="conv.id"
class="conversation-item"
:class="{ active: currentConversation?.id === conv.id }"
@click="selectConversation(conv)"
>
<el-icon><Chat /></el-icon>
<span class="conv-name">{{ conv.name }}</span>
</div>
</el-scrollbar>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Chat, DArrowLeft, DArrowRight } from '@element-plus/icons-vue'
const collapsed = ref(false)
const conversationList = ref([
{ id: 1, name: '通用会话 1' },
{ id: 2, name: '通用会话 2' },
{ id: 3, name: '通用会话 3' }
])
const currentConversation = ref(null)
const emit = defineEmits(['select'])
function toggleSidebar() {
collapsed.value = !collapsed.value
}
function selectConversation(conv) {
currentConversation.value = conv
emit('select', conv)
}
onMounted(() => {
// 默认选中第一个
if (conversationList.value.length > 0) {
selectConversation(conversationList.value[0])
}
})
</script>
<style scoped lang="scss">
.conversation-sidebar {
width: 240px;
background: #fafafa;
border-right: 1px solid #e4e7ed;
transition: width 0.3s;
position: relative;
&.collapsed {
width: 40px;
}
.sidebar-toggle {
position: absolute;
right: -12px;
top: 50%;
transform: translateY(-50%);
width: 24px;
height: 24px;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
&:hover {
background: #ecf5ff;
}
}
.sidebar-content {
height: 100%;
.sidebar-header {
padding: 16px;
font-weight: 600;
border-bottom: 1px solid #e4e7ed;
}
.conversation-item {
padding: 12px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s;
border-bottom: 1px solid #f0f0f0;
&:hover {
background: #ecf5ff;
}
&.active {
background: #409eff;
color: #fff;
}
.conv-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
</style>
Task 3: 创建 ChatHeader.vue(顶部栏)
Files:
-
Create:
src/views/frontend/components/ChatHeader.vue -
Step 1: 创建组件文件
<template>
<div class="chat-header">
<div class="header-left">
<span class="conversation-name">{{ conversationName }}</span>
</div>
<div class="header-right">
<el-button
:type="activeTab === 'agent' ? 'primary' : 'default'"
@click="handleTabClick('agent')"
>
智能体
</el-button>
<el-button
:type="activeTab === 'file' ? 'primary' : 'default'"
@click="handleTabClick('file')"
>
文件
</el-button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
conversationName: {
type: String,
default: '请选择会话'
}
})
const activeTab = ref('agent')
const emit = defineEmits(['tab-change'])
function handleTabClick(tab) {
activeTab.value = tab
emit('tab-change', tab)
}
</script>
<style scoped lang="scss">
.chat-header {
padding: 16px 24px;
border-bottom: 1px solid #e4e7ed;
display: flex;
justify-content: space-between;
align-items: center;
background: #fff;
.header-left {
.conversation-name {
font-size: 16px;
font-weight: 600;
}
}
.header-right {
display: flex;
gap: 8px;
}
}
</style>
Task 4: 创建 ChatWindow.vue(聊天消息区)
Files:
-
Create:
src/views/frontend/components/ChatWindow.vue -
Step 1: 创建组件文件
<template>
<div class="chat-window">
<el-scrollbar ref="scrollRef" class="chat-messages">
<div class="messages-wrapper">
<!-- 欢迎消息 -->
<div v-if="messages.length === 0" class="welcome-message">
<el-empty description="开始聊天吧">
<template #image>
<el-icon :size="60"><ChatDotRound /></el-icon>
</template>
</el-empty>
</div>
<!-- 消息列表 -->
<div
v-for="(msg, index) in messages"
:key="index"
class="message-item"
:class="msg.role"
>
<div class="message-avatar">
<el-icon v-if="msg.role === 'user'" :size="24"><User /></el-icon>
<el-icon v-else :size="24"><MagicStick /></el-icon>
</div>
<div class="message-content">
<div class="message-text" v-html="formatMessage(msg.content)"></div>
<div class="message-time">{{ msg.time }}</div>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="message-item assistant">
<div class="message-avatar">
<el-icon :size="24"><MagicStick /></el-icon>
</div>
<div class="message-content">
<div class="message-text">
<span class="loading-dots">
<span></span><span></span><span></span>
</span>
正在思考...
</div>
</div>
</div>
</div>
</el-scrollbar>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { ChatDotRound, User, MagicStick } from '@element-plus/icons-vue'
const props = defineProps({
messages: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
}
})
const scrollRef = ref()
function formatMessage(content) {
if (!content) return ''
return content.replace(/\n/g, '<br/>')
}
function scrollToBottom() {
nextTick(() => {
if (scrollRef.value) {
scrollRef.value.setScrollTop(9999999)
}
})
}
watch(() => props.messages.length, () => {
scrollToBottom()
})
watch(() => props.loading, (val) => {
if (!val) {
scrollToBottom()
}
})
defineExpose({ scrollToBottom })
</script>
<style scoped lang="scss">
.chat-window {
flex: 1;
padding: 20px;
overflow: hidden;
display: flex;
flex-direction: column;
.chat-messages {
flex: 1;
overflow: hidden;
}
.messages-wrapper {
max-width: 800px;
margin: 0 auto;
height: 100%;
}
.welcome-message {
text-align: center;
padding-top: 100px;
color: #909399;
}
.message-item {
display: flex;
gap: 12px;
margin-bottom: 20px;
&.user {
flex-direction: row-reverse;
.message-content {
align-items: flex-end;
}
.message-text {
background: #409eff;
color: #fff;
}
}
&.assistant {
.message-text {
background: #f4f4f5;
color: #303133;
}
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #f4f4f5;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.message-content {
display: flex;
flex-direction: column;
max-width: 70%;
.message-text {
padding: 12px 16px;
border-radius: 8px;
line-height: 1.6;
word-break: break-word;
}
.message-time {
font-size: 12px;
color: #c0c4cc;
margin-top: 4px;
}
}
}
.loading-dots {
display: inline-flex;
gap: 4px;
margin-right: 8px;
span {
width: 6px;
height: 6px;
background: #909399;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
&:nth-child(1) { animation-delay: -0.32s; }
&:nth-child(2) { animation-delay: -0.16s; }
}
}
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
}
</style>
Task 5: 创建 QuotedFiles.vue(已引用文件区域)
Files:
-
Create:
src/views/frontend/components/QuotedFiles.vue -
Step 1: 创建组件文件
<template>
<div class="quoted-files" v-if="files.length > 0">
<div class="quoted-files-header">
<span>已引用文件</span>
</div>
<div class="quoted-files-list">
<el-tag
v-for="(file, index) in files"
:key="index"
closable
@close="handleRemove(index)"
>
<el-icon><Document /></el-icon>
{{ file.name }}
</el-tag>
</div>
</div>
</template>
<script setup>
import { Document } from '@element-plus/icons-vue'
const props = defineProps({
files: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['remove'])
function handleRemove(index) {
emit('remove', index)
}
</script>
<style scoped lang="scss">
.quoted-files {
padding: 8px 24px;
border-top: 1px solid #e4e7ed;
background: #fafafa;
.quoted-files-header {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.quoted-files-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
.el-tag {
display: flex;
align-items: center;
gap: 4px;
}
}
}
</style>
Task 6: 创建 FileSelectModal.vue(@文件选择弹层)
Files:
-
Create:
src/views/frontend/components/FileSelectModal.vue -
Step 1: 创建组件文件
<template>
<el-dialog
v-model="visible"
title="选择引用文件"
width="400px"
@close="handleClose"
>
<div class="file-list">
<el-scrollbar height="300px">
<div
v-for="file in fileList"
:key="file.name"
class="file-item"
@click="handleSelect(file)"
>
<el-icon><Document /></el-icon>
<span>{{ file.name }}</span>
</div>
</el-scrollbar>
</div>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Document } from '@element-plus/icons-vue'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'select'])
const visible = ref(false)
// 模拟文件列表(仅文件,无文件夹)
const fileList = ref([
{ id: 1, name: '项目说明文档.docx' },
{ id: 2, name: '需求规格说明书.pdf' },
{ id: 3, name: '接口文档.md' },
{ id: 4, name: '代码规范.txt' },
{ id: 5, name: '配置文件.yaml' }
])
watch(() => props.modelValue, (val) => {
visible.value = val
})
function handleClose() {
emit('update:modelValue', false)
}
function handleSelect(file) {
emit('select', file)
handleClose()
}
</script>
<style scoped lang="scss">
.file-list {
.file-item {
padding: 10px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #ecf5ff;
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>
Task 7: 创建 InputArea.vue(输入区域)
Files:
-
Create:
src/views/frontend/components/InputArea.vue -
Step 1: 创建组件文件
<template>
<div class="input-area">
<div class="input-container">
<div class="input-toolbar">
<el-button
class="at-btn"
@click="showFileSelect = true"
title="引用文件"
>
@
</el-button>
</div>
<el-input
v-model="inputText"
type="textarea"
:rows="3"
placeholder="请输入问题,按 Enter 发送,Shift + Enter 换行"
resize="none"
@keydown.enter.exact.prevent="handleSend"
@keydown.enter.shift.exact="handleNewLine"
@input="handleInput"
/>
<div class="input-actions">
<el-button @click="handleUpload">
<el-icon><Upload /></el-icon>
上传
</el-button>
<el-button type="primary" @click="handleSend" :disabled="!inputText.trim()">
<el-icon><Promotion /></el-icon>
发送
</el-button>
</div>
</div>
<FileSelectModal v-model="showFileSelect" @select="handleFileSelect" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Upload, Promotion } from '@element-plus/icons-vue'
import FileSelectModal from './FileSelectModal.vue'
const inputText = ref('')
const showFileSelect = ref(false)
const quotedFiles = ref([])
const emit = defineEmits(['send', 'upload', 'quote-add', 'quote-remove'])
function handleSend() {
if (!inputText.value.trim()) return
emit('send', {
text: inputText.value.trim(),
quotedFiles: [...quotedFiles.value]
})
inputText.value = ''
quotedFiles.value = []
}
function handleNewLine(e) {
// Shift + Enter 换行默认行为
}
function handleUpload() {
emit('upload')
}
function handleFileSelect(file) {
quotedFiles.value.push(file)
emit('quote-add', file)
}
function handleQuoteRemove(index) {
quotedFiles.value.splice(index, 1)
emit('quote-remove', index)
}
function handleInput(value) {
// 检测输入 @ 字符
if (value.endsWith('@')) {
showFileSelect.value = true
}
}
defineExpose({
quotedFiles
})
</script>
<style scoped lang="scss">
.input-area {
padding: 16px 24px;
background: #fff;
border-top: 1px solid #e4e7ed;
.input-container {
max-width: 800px;
margin: 0 auto;
}
.input-toolbar {
margin-bottom: 8px;
.at-btn {
padding: 4px 8px;
font-weight: bold;
}
}
.input-actions {
margin-top: 12px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
}
</style>
Task 8: 创建 FilePanel.vue(右侧文件列表面板)
Files:
-
Create:
src/views/frontend/components/FilePanel.vue -
Step 1: 创建组件文件
<template>
<div class="file-panel" :class="{ collapsed: !visible }">
<div class="panel-header">
<span>本地文件</span>
<el-button link @click="emit('close')">
<el-icon><Close /></el-icon>
</el-button>
</div>
<div class="panel-content" v-show="visible">
<div class="file-tree-container">
<el-scrollbar height="calc(100vh - 320px)">
<el-tree
:data="fileTree"
:props="treeProps"
@node-click="handleNodeClick"
node-key="path"
default-expand-all
>
<template #default="{ node, data }">
<span class="tree-node">
<el-icon v-if="data.type === 'folder'"><Folder /></el-icon>
<el-icon v-else><Document /></el-icon>
<span>{{ node.label }}</span>
</span>
</template>
</el-tree>
</el-scrollbar>
</div>
<!-- 文件预览/编辑区 -->
<div class="file-preview" v-if="selectedFile">
<div class="preview-header">
<span>{{ selectedFile.name }}</span>
<el-button type="primary" size="small" @click="handleSave">保存</el-button>
</div>
<el-input
v-model="fileContent"
type="textarea"
:rows="10"
placeholder="文件内容"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Close, Folder, Document } from '@element-plus/icons-vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close'])
const treeProps = {
children: 'children',
label: 'name'
}
// 模拟文件树数据
const fileTree = ref([
{
name: 'src',
type: 'folder',
path: '/src',
children: [
{ name: 'index.js', type: 'file', path: '/src/index.js', content: '// index.js\nconsole.log("Hello World");' },
{ name: 'App.vue', type: 'file', path: '/src/App.vue', content: '<template>\n <div id="app">\n <h1>Hello Vue</h1>\n </div>\n</template>' },
{ name: 'main.js', type: 'file', path: '/src/main.js', content: '// main.js\nimport Vue from "vue";\nimport App from "./App.vue";' }
]
},
{
name: 'docs',
type: 'folder',
path: '/docs',
children: [
{ name: 'README.md', type: 'file', path: '/docs/README.md', content: '# 项目文档\n\n这是一个示例项目。' },
{ name: 'API.md', type: 'file', path: '/docs/API.md', content: '# API 文档\n\n## 接口列表' }
]
},
{ name: 'package.json', type: 'file', path: '/package.json', content: '{\n "name": "demo",\n "version": "1.0.0"\n}' },
{ name: 'README.md', type: 'file', path: '/README.md', content: '# 项目说明\n\n这是一个基于 Vue3 的项目。' }
])
const selectedFile = ref(null)
const fileContent = ref('')
function handleNodeClick(data) {
if (data.type === 'file') {
selectedFile.value = data
fileContent.value = data.content || ''
}
}
function handleSave() {
if (selectedFile.value) {
selectedFile.value.content = fileContent.value
ElMessage.success('文件保存成功')
}
}
</script>
<style scoped lang="scss">
.file-panel {
width: 300px;
border-left: 1px solid #e4e7ed;
background: #fff;
display: flex;
flex-direction: column;
transition: width 0.3s;
&.collapsed {
width: 0;
border-left: none;
overflow: hidden;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid #e4e7ed;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.panel-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.file-tree-container {
flex: 1;
overflow: hidden;
border-bottom: 1px solid #e4e7ed;
}
.file-preview {
padding: 12px;
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
}
}
}
.tree-node {
display: flex;
align-items: center;
gap: 4px;
}
}
</style>
Task 9: 创建 index.vue(主页面整合)
Files:
-
Create:
src/views/frontend/index.vue -
Step 1: 创建主页面
<template>
<div class="frontend-container">
<!-- 左侧会话列表 -->
<ConversationSidebar @select="handleConversationSelect" />
<!-- 右侧主内容区 -->
<div class="main-content">
<!-- 顶部栏 -->
<ChatHeader
:conversation-name="currentConversation?.name || '请选择会话'"
@tab-change="handleTabChange"
/>
<!-- 聊天区域 + 右侧文件面板 -->
<div class="content-wrapper">
<div class="chat-area">
<!-- 聊天消息区 -->
<ChatWindow
ref="chatWindowRef"
:messages="messages"
:loading="loading"
/>
<!-- 已引用文件 -->
<QuotedFiles
:files="quotedFiles"
@remove="handleQuoteRemove"
/>
<!-- 输入区 -->
<InputArea
ref="inputAreaRef"
@send="handleSend"
@upload="handleUpload"
/>
</div>
<!-- 右侧文件面板 -->
<FilePanel
:visible="filePanelVisible"
@close="filePanelVisible = false"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ConversationSidebar from './components/ConversationSidebar.vue'
import ChatHeader from './components/ChatHeader.vue'
import ChatWindow from './components/ChatWindow.vue'
import InputArea from './components/InputArea.vue'
import FilePanel from './components/FilePanel.vue'
import QuotedFiles from './components/QuotedFiles.vue'
const chatWindowRef = ref()
const inputAreaRef = ref()
const currentConversation = ref(null)
const messages = ref([])
const quotedFiles = ref([])
const loading = ref(false)
const filePanelVisible = ref(false)
function handleConversationSelect(conv) {
currentConversation.value = conv
messages.value = []
}
function handleTabChange(tab) {
if (tab === 'file') {
filePanelVisible.value = !filePanelVisible.value
}
}
function handleSend({ text, quotedFiles: files }) {
if (!text.trim()) return
// 添加用户消息
messages.value.push({
role: 'user',
content: text,
time: new Date().toLocaleTimeString()
})
// 记录引用的文件
quotedFiles.value = files || []
// 模拟 AI 回复
loading.value = true
setTimeout(() => {
loading.value = false
messages.value.push({
role: 'assistant',
content: '这是一条模拟回复。您发送的消息是:' + text,
time: new Date().toLocaleTimeString()
})
}, 1500)
}
function handleUpload() {
ElMessage.info('上传功能待实现')
}
function handleQuoteRemove(index) {
quotedFiles.value.splice(index, 1)
}
</script>
<style scoped lang="scss">
.frontend-container {
display: flex;
height: calc(100vh - 140px);
background: #fff;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-wrapper {
flex: 1;
display: flex;
overflow: hidden;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
Task 10: 配置路由
Files:
-
Modify:
src/router/index.js -
Step 1: 添加前端路由
在 dynamicRoutes 中添加:
{
path: '/frontend',
component: Layout,
children: [
{
path: 'index',
component: () => import('@/views/frontend/index'),
name: 'Frontend',
meta: { title: '数智 AI' }
}
]
}
Task 11: 测试验证
- Step 1: 启动开发服务器验证
npm run dev
访问 /frontend/index 验证:
- 左侧会话列表可折叠/展开
- 点击会话切换聊天窗口
- 顶部栏智能体(默认选中)和文件按钮
- 输入框 @ 触发文件选择弹层
- 点击文件按钮展开右侧文件面板
- 文件树可点击预览/编辑/保存
- 布局自适应
自检清单
- Spec 覆盖:每个设计功能都有对应实现
- 占位符检查:无 TBD/TODO/待实现
- 类型一致性:组件 props/emit 命名一致
- 组件独立:每个组件职责单一