Files
agent-frontend-web/docs/superpowers/plans/2026-08-06-frontend-sidebar-user-area.md
2026-08-07 13:56:29 +08:00

18 KiB
Raw Permalink Blame History

Frontend 会话列表底部用户区域块 Implementation Plan

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:ConversationSidebar 底部新增用户区域块,包含用户头像+昵称、退出按钮、前往智能体广场按钮;侧边栏收起时仅显示小头像弹出菜单;点击头像昵称打开「个人资料」修改弹窗(可改头像、昵称)。

Architecture: 新增两个独立的 Vue 3 组件 UserArea.vueUserInfoDialog.vueConversationSidebar.vue 引入并放置底部。两个组件用 defineExpose / v-model + emit 通信,遵循项目现有的 Composition API + Element Plus 风格。

Tech Stack: Vue 3 + Vite + Element Plus + Pinia + vue-cropper(已有依赖)+ vue-router

Note: 项目无测试框架,所有验证通过手动 yarn dev 在浏览器操作完成。


File Structure

Create

  • src/views/frontend/components/UserArea.vue — 底部区域块组件(展示 + 退出 + 跳转 + 弹出头像菜单)
  • src/views/frontend/components/UserInfoDialog.vue — 个人资料修改弹窗(头像裁剪 + 昵称)

Modify

  • src/views/frontend/components/ConversationSidebar.vue — 引入 UserArea注意:放在 <transition> + sidebar-content 之外——因为 sidebar-contentv-if="!collapsed" 会让 user area 在收起态被隐藏;放到 .conversation-sidebar 直接子节点让 UserArea 自己处理收起/展开 UI),调整 sidebar-content 为 flex 列布局让会话列表占中间、UserArea 固定在底部

Task 1: 创建 UserArea.vue 组件骨架(展开态)

Files:

  • Create: src/views/frontend/components/UserArea.vue

  • Step 1: 创建文件,写入以下完整内容

<template>
  <div v-if="!collapsed" class="user-area">
    <div class="user-area-divider"></div>

    <div class="user-info-row" @click="openInfoDialog">
      <el-avatar :size="36" :src="user.avatar" />
      <div class="user-name-wrap">
        <span class="user-name">{{ user.nickName || user.name || '未登录' }}</span>
      </div>
      <el-tooltip content="退出登录" placement="top" :show-after="300">
        <el-button class="logout-btn" link @click.stop="handleLogout">
          <el-icon><SwitchButton /></el-icon>
        </el-button>
      </el-tooltip>
    </div>

    <el-button class="marketplace-btn" @click="goToMarketplace">
      <el-icon><Promotion /></el-icon>
      <span>前往智能体广场</span>
    </el-button>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { SwitchButton, Promotion } from '@element-plus/icons-vue'
import useUserStore from '@/store/modules/user'
import UserInfoDialog from './UserInfoDialog.vue'

const props = defineProps({
  collapsed: {
    type: Boolean,
    default: false
  }
})

const router = useRouter()
const userStore = useUserStore()

const user = computed(() => ({
  avatar: userStore.avatar,
  nickName: userStore.nickName,
  name: userStore.name
}))

const infoDialogVisible = ref(false)

function openInfoDialog() {
  infoDialogVisible.value = true
}

function handleSaved(updated) {
  ElMessage.success('修改成功')
}

async function handleLogout() {
  try {
    await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
      confirmButtonText: '退出',
      cancelButtonText: '取消',
      type: 'warning'
    })
  } catch {
    return
  }
  try {
    await userStore.logOut()
    router.push('/login')
  } catch (error) {
    console.error('退出失败:', error)
    ElMessage.error('退出失败,请稍后重试')
  }
}

function goToMarketplace() {
  router.push('/frontend/marketplace')
}

defineExpose({
  openInfoDialog
})
</script>

<style scoped lang="scss">
.user-area {
  display: flex;
  flex-direction: column;
  padding: 12px;
  background: #fff;
  flex-shrink: 0;
}

.user-area-divider {
  height: 1px;
  background: #e4e7ed;
  margin: -12px -12px 12px -12px;
}

.user-info-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 8px;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.2s;

  &:hover {
    background: #f5f7fa;
  }
}

.user-name-wrap {
  flex: 1;
  min-width: 0;
}

.user-name {
  display: block;
  font-size: 14px;
  color: #303133;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.logout-btn {
  padding: 4px 8px;
  margin-left: 0 !important;
  color: #909399;

  &:hover {
    color: #f56c6c;
    background: rgba(245, 108, 108, 0.1);
  }

  .el-icon {
    font-size: 16px;
  }
}

.marketplace-btn {
  margin-top: 10px;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  background: #ecf5ff;
  color: #409eff;
  border: 1px solid #d9ecff;

  &:hover {
    background: #409eff;
    color: #fff;
    border-color: #409eff;
  }

  .el-icon {
    font-size: 14px;
  }
}
</style>

Task 2: 创建 UserInfoDialog.vue 组件

Files:

  • Create: src/views/frontend/components/UserInfoDialog.vue

  • Step 1: 创建文件,写入以下完整内容

<template>
  <el-dialog
    title="个人资料"
    :model-value="modelValue"
    @update:model-value="$emit('update:modelValue', $event)"
    width="500px"
    :close-on-click-modal="false"
    @open="handleOpen"
  >
    <el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
      <el-form-item label="头像" prop="avatar">
        <div class="avatar-uploader" @click="triggerFileInput">
          <el-avatar :size="64" :src="previewAvatar || form.avatar" />
          <div class="avatar-mask">
            <el-icon><Camera /></el-icon>
            <span>更换头像</span>
          </div>
        </div>
        <input
          ref="fileInputRef"
          type="file"
          accept="image/*"
          style="display: none"
          @change="handleFileChange"
        />
      </el-form-item>

      <el-form-item label="昵称" prop="nickName">
        <el-input v-model="form.nickName" maxlength="30" show-word-limit placeholder="请输入昵称" />
      </el-form-item>
    </el-form>

    <template #footer>
      <el-button @click="handleCancel">取消</el-button>
      <el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
    </template>

    <el-dialog
      title="裁剪头像"
      v-model="cropperVisible"
      width="600px"
      :close-on-click-modal="false"
      append-to-body
      @opened="onCropperOpened"
    >
      <div class="cropper-container">
        <vue-cropper
          ref="cropperRef"
          :img="cropperImg"
          :autoCrop="true"
          :autoCropWidth="180"
          :autoCropHeight="180"
          :fixedBox="true"
          :outputType="'png'"
          v-if="cropperVisible"
        />
      </div>
      <template #footer>
        <el-button @click="cropperVisible = false">取消</el-button>
        <el-button type="primary" @click="confirmCrop">确认裁剪</el-button>
      </template>
    </el-dialog>
  </el-dialog>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { Camera } from '@element-plus/icons-vue'
import { VueCropper } from 'vue-cropper'
import { uploadAvatar, updateUserProfile } from '@/api/system/user'
import useUserStore from '@/store/modules/user'

const props = defineProps({
  modelValue: {
    type: Boolean,
    default: false
  },
  user: {
    type: Object,
    default: () => ({ avatar: '', nickName: '', name: '' })
  }
})

const emit = defineEmits(['update:modelValue', 'saved'])

const userStore = useUserStore()

const formRef = ref()
const fileInputRef = ref()
const cropperRef = ref()

const form = reactive({
  nickName: '',
  avatar: ''
})

const rules = {
  nickName: [{ required: true, message: '昵称不能为空', trigger: 'blur' }]
}

const previewAvatar = ref('')
const cropperImg = ref('')
const cropperVisible = ref(false)
const saving = ref(false)

function handleOpen() {
  form.nickName = props.user.nickName || props.user.name || ''
  form.avatar = props.user.avatar || ''
  previewAvatar.value = ''
  cropperVisible.value = false
}

function triggerFileInput() {
  fileInputRef.value?.click()
}

function handleFileChange(e) {
  const file = e.target.files?.[0]
  if (!file) return
  if (!file.type.startsWith('image/')) {
    ElMessage.error('请选择图片文件')
    return
  }
  const reader = new FileReader()
  reader.onload = () => {
    cropperImg.value = reader.result
    cropperVisible.value = true
  }
  reader.readAsDataURL(file)
  e.target.value = ''
}

function onCropperOpened() {
  // cropper 已挂载,可触发自动裁剪
}

function confirmCrop() {
  cropperRef.value?.getCropData((data) => {
    previewAvatar.value = data
    form.avatar = data
    cropperVisible.value = false
  })
}

async function handleSave() {
  try {
    await formRef.value.validate()
  } catch {
    return
  }
  saving.value = true
  try {
    if (previewAvatar.value) {
      const blob = dataURLtoBlob(previewAvatar.value)
      const formData = new FormData()
      formData.append('avatarfile', blob, 'avatar.png')
      const res = await uploadAvatar(formData)
      const rawUrl = res.imgUrl || res.data?.imgUrl || ''
      if (rawUrl) {
        // 后端返回的 imgUrl 是相对路径,需要拼接 baseApi
        const fullUrl = rawUrl.startsWith('http') ? rawUrl : (import.meta.env.VITE_APP_BASE_API + rawUrl)
        userStore.avatar = fullUrl
      }
    }
    await updateUserProfile({ nickName: form.nickName })
    userStore.nickName = form.nickName
    emit('saved', { avatar: userStore.avatar, nickName: form.nickName })
    emit('update:modelValue', false)
    ElMessage.success('保存成功')
  } catch (error) {
    console.error('保存失败:', error)
    ElMessage.error('保存失败,请稍后重试')
  } finally {
    saving.value = false
  }
}

function handleCancel() {
  emit('update:modelValue', false)
}

function dataURLtoBlob(dataURL) {
  const parts = dataURL.split(',')
  const mime = parts[0].match(/:(.*?);/)?.[1] || 'image/png'
  const binary = atob(parts[1])
  const array = new Uint8Array(binary.length)
  for (let i = 0; i < binary.length; i++) {
    array[i] = binary.charCodeAt(i)
  }
  return new Blob([array], { type: mime })
}
</script>

<style scoped lang="scss">
.avatar-uploader {
  position: relative;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  overflow: hidden;
  cursor: pointer;
  border: 1px solid #e4e7ed;

  &:hover .avatar-mask {
    opacity: 1;
  }
}

.avatar-mask {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  color: #fff;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: 12px;
  gap: 2px;
  opacity: 0;
  transition: opacity 0.2s;
}

.cropper-container {
  height: 350px;
}
</style>

Task 3: 改造 ConversationSidebar 集成 UserArea(展开态)

Files:

  • Modify: src/views/frontend/components/ConversationSidebar.vue(在 <script setup> 顶部 import + 在 template 引入 UserArea + 调整 sidebar-content 布局)

  • Step 1: 修改 script setup 顶部 import 区块,在 ChatLineRound 同一行 import 后追加 UserArea

找到(line 67):

import { ChatLineRound, DArrowLeft, DArrowRight, Refresh, Plus, Delete } from '@element-plus/icons-vue'

在下一行 import 区域找到 formatTimeAgoElMessageElMessageBox 的导入,并在其后加入:

import UserArea from './UserArea.vue'
  • Step 2: 修改 template,把 UserArea 放在 .conversation-sidebar 内、sidebar-content

找到 template 中:

<div class="conversation-sidebar" :class="{ collapsed }">
  <div class="sidebar-toggle" @click="toggleSidebar">
    ...
  </div>

  <transition name="sidebar-fade">
    <div v-if="!collapsed" class="sidebar-content">
      <div class="sidebar-header">
        ...
      </div>
      <el-scrollbar height="calc(100vh - 160px)">
        ...
      </el-scrollbar>
    </div>
  </transition>
</div>

替换为:

<div class="conversation-sidebar" :class="{ collapsed }">
  <div class="sidebar-toggle" @click="toggleSidebar">
    ...
  </div>

  <transition name="sidebar-fade">
    <div v-if="!collapsed" class="sidebar-content">
      <div class="sidebar-header">
        ...
      </div>
      <el-scrollbar class="sidebar-scrollbar">
        ...
      </el-scrollbar>
    </div>
  </transition>

  <UserArea :collapsed="collapsed" />
</div>

关键改动:

  1. <el-scrollbar> 的内联 height 移除,改用 .sidebar-scrollbar flex:1 样式
  2. UserArea 放在 </transition> 后,与 sidebar-content 同级——这样收起态 UserArea 仍可见
  • Step 3: 修改 .conversation-sidebar.sidebar-content 与新增 .sidebar-scrollbar 样式

找到 style scoped 内 .conversation-sidebar { ... },在 &.collapsed { width: 28px } 之后追加 display: flex; flex-direction: column;

.conversation-sidebar {
  width: 240px;
  border-right: 1px solid #e4e7ed;
  transition: width 0.3s;
  position: relative;
  display: flex;
  flex-direction: column;

  &.collapsed {
    width: 28px;
  }
  ...
}

找到 style scoped 内 .sidebar-content { ... },把它从:

.sidebar-content {
  height: 100%;
  overflow: hidden;

改为:

.sidebar-content {
  height: 100%;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

并在 .conversation-item { ... } 之后(任何空白处)新增:

.sidebar-scrollbar {
  flex: 1;
  min-height: 0;
}

并删除 <el-scrollbar height="calc(100vh - 160px)"> 里使用的内联高度(已经在 step 2 改为 class)。


Task 4: 添加 UserArea 收起态(仅头像 + dropdown 菜单)

Files:

  • Modify: src/views/frontend/components/UserArea.vue

  • Step 1: 在 <template></div> 闭合标签(即展开态分支)之后追加收起态分支与 UserInfoDialog

找到 template 末尾:

    </el-button>
  </div>
</template>

</div> 改为 </div> + v-else 分支 + UserInfoDialog,整体变为:

    </el-button>
  </div>
  <div v-else class="user-area-collapsed">
    <el-dropdown trigger="click" @command="handleCollapsedCommand">
      <el-avatar :size="24" :src="user.avatar" class="collapsed-avatar" />
      <template #dropdown>
        <el-dropdown-menu>
          <el-dropdown-item command="profile">个人资料</el-dropdown-item>
          <el-dropdown-item command="marketplace">前往智能体广场</el-dropdown-item>
          <el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
        </el-dropdown-menu>
      </template>
    </el-dropdown>
  </div>

  <UserInfoDialog v-model="infoDialogVisible" :user="user" @saved="handleSaved" />
</template>
  • Step 2: 在 <script setup> 内新增 handleCollapsedCommand 方法

找到 function goToMarketplace() 之前的位置,新增:

async function handleCollapsedCommand(cmd) {
  if (cmd === 'profile') {
    openInfoDialog()
  } else if (cmd === 'marketplace') {
    goToMarketplace()
  } else if (cmd === 'logout') {
    await handleLogout()
  }
}
  • Step 3: 在 <style scoped> 末尾添加收起态样式
.user-area-collapsed {
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 10px 0;
  border-top: 1px solid #e4e7ed;
  flex-shrink: 0;
  background: #fff;
}

.collapsed-avatar {
  cursor: pointer;
}

Task 5: 浏览器手动验证

  • Step 1: 启动 dev server
cd "D:/数科智联/项目/agent-frontend-web"
yarn dev

Expected: Vite dev server 启动成功,浏览器访问 http://localhost(端口按 .env 配置)后能进入 /login

  • Step 2: 登录并进入 /frontend/index,验证展开态 UI

Expected:

  • 左侧 sidebar 底部出现用户头像 + 昵称 + 退出按钮(图标)+「前往智能体广场」按钮

  • 头像昵称与登录用户一致

  • 整个 user-area 上方有一根细分割线

  • Step 3: 点击头像,验证弹窗

Expected:

  • 弹出「个人资料」弹窗

  • 显示当前头像、当前昵称

  • 输入框聚焦正常

  • Step 4: 修改昵称,保存,验证

Expected:

  • 输入新昵称

  • 点击保存

  • 提示「保存成功」

  • 弹窗关闭

  • sidebar 底部昵称已更新

  • 刷新页面后仍保留新昵称

  • Step 5: 修改头像,保存,验证

Expected:

  • 点击头像 → 弹出文件选择器

  • 选择图片 → 弹出裁剪对话框

  • 点击确认裁剪 → 回到资料弹窗,预览头像已更新

  • 点击保存 → 提示成功 → sidebar 头像更新

  • Step 6: 点击退出按钮,验证二次确认 + 跳转

Expected:

  • 点击退出图标 → 弹出「确定要退出登录吗?」确认框

  • 点击取消 → 不退出

  • 再点击退出 → 点击「退出」 → 跳转到 /login

  • Step 7: 点击「前往智能体广场」,验证跳转

Expected: 跳转到 /frontend/marketplace 智能体广场页面。

  • Step 8: 收起侧边栏(点击侧边栏右侧圆形箭头按钮),验证收起态

Expected:

  • sidebar 缩窄到约 28px

  • 底部只剩一个小圆形头像

  • 点击小头像 → 弹出 dropdown:「个人资料 / 前往智能体广场 / 退出登录」

  • 点击「个人资料」→ 弹窗正常打开

  • 点击「前往智能体广场」→ 正常跳转

  • 点击「退出登录」→ 二次确认后正常退出

  • Step 9: 展开侧边栏,验证收起态切换正常

Expected: 侧边栏重新展开,user area 回到完整三块布局,无样式错乱。


Self-Review Checklist

  • Spec coverage: 三块功能(头像昵称、退出、智能体广场)+ 收起态 + 修改弹窗 全部覆盖
  • Placeholder scan: 无 TBD/TODO/模糊描述
  • Type consistency: openInfoDialog / infoDialogVisible / user 在两个组件间命名一致
  • Vue 3 风格: <script setup> + Composition API
  • Element Plus 风格: el-dialog / el-dropdown / el-message-box 与项目其他组件一致