Files
agent-frontend-web/docs/superpowers/plans/2026-07-16-shuzhi-ai-chat-frontend-implementation-plan.md
2026-08-07 13:56:29 +08:00

1136 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 数智 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 目录**
```bash
mkdir -p src/views/frontend/components
```
---
## Task 2: 创建 ConversationSidebar.vue(左侧会话列表)
**Files:**
- Create: `src/views/frontend/components/ConversationSidebar.vue`
- [ ] **Step 1: 创建组件文件**
```vue
<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: 创建组件文件**
```vue
<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: 创建组件文件**
```vue
<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: 创建组件文件**
```vue
<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: 创建组件文件**
```vue
<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: 创建组件文件**
```vue
<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: 创建组件文件**
```vue
<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: 创建主页面**
```vue
<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` 中添加:
```javascript
{
path: '/frontend',
component: Layout,
children: [
{
path: 'index',
component: () => import('@/views/frontend/index'),
name: 'Frontend',
meta: { title: '数智 AI' }
}
]
}
```
---
## Task 11: 测试验证
- [ ] **Step 1: 启动开发服务器验证**
```bash
npm run dev
```
访问 `/frontend/index` 验证:
1. 左侧会话列表可折叠/展开
2. 点击会话切换聊天窗口
3. 顶部栏智能体(默认选中)和文件按钮
4. 输入框 @ 触发文件选择弹层
5. 点击文件按钮展开右侧文件面板
6. 文件树可点击预览/编辑/保存
7. 布局自适应
---
## 自检清单
- [ ] Spec 覆盖:每个设计功能都有对应实现
- [ ] 占位符检查:无 TBD/TODO/待实现
- [ ] 类型一致性:组件 props/emit 命名一致
- [ ] 组件独立:每个组件职责单一