1136 lines
24 KiB
Markdown
1136 lines
24 KiB
Markdown
# 数智 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 命名一致
|
||
- [ ] 组件独立:每个组件职责单一
|