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

24 KiB
Raw Blame History

数智 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 验证:

  1. 左侧会话列表可折叠/展开
  2. 点击会话切换聊天窗口
  3. 顶部栏智能体(默认选中)和文件按钮
  4. 输入框 @ 触发文件选择弹层
  5. 点击文件按钮展开右侧文件面板
  6. 文件树可点击预览/编辑/保存
  7. 布局自适应

自检清单

  • Spec 覆盖:每个设计功能都有对应实现
  • 占位符检查:无 TBD/TODO/待实现
  • 类型一致性:组件 props/emit 命名一致
  • 组件独立:每个组件职责单一