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

703 lines
18 KiB
Markdown
Raw 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.
# 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.vue``UserInfoDialog.vue``ConversationSidebar.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-content``v-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: 创建文件,写入以下完整内容**
```vue
<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: 创建文件,写入以下完整内容**
```vue
<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):
```js
import { ChatLineRound, DArrowLeft, DArrowRight, Refresh, Plus, Delete } from '@element-plus/icons-vue'
```
在下一行 import 区域找到 `formatTimeAgo``ElMessage``ElMessageBox` 的导入,并在其后加入:
```js
import UserArea from './UserArea.vue'
```
- [ ] **Step 2: 修改 template,把 UserArea 放在 `.conversation-sidebar` 内、`sidebar-content` 外**
找到 template 中:
```html
<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>
```
替换为:
```html
<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;`
```scss
.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 { ... }`,把它从:
```scss
.sidebar-content {
height: 100%;
overflow: hidden;
```
改为:
```scss
.sidebar-content {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
```
并在 `.conversation-item { ... }` 之后(任何空白处)新增:
```scss
.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 末尾:
```html
</el-button>
</div>
</template>
```
`</div>` 改为 `</div>` + v-else 分支 + UserInfoDialog,整体变为:
```html
</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()` 之前的位置,新增:
```js
async function handleCollapsedCommand(cmd) {
if (cmd === 'profile') {
openInfoDialog()
} else if (cmd === 'marketplace') {
goToMarketplace()
} else if (cmd === 'logout') {
await handleLogout()
}
}
```
- [ ] **Step 3: 在 `<style scoped>` 末尾添加收起态样式**
```scss
.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**
```bash
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
- [x] Spec coverage: 三块功能(头像昵称、退出、智能体广场)+ 收起态 + 修改弹窗 全部覆盖
- [x] Placeholder scan: 无 TBD/TODO/模糊描述
- [x] Type consistency: `openInfoDialog` / `infoDialogVisible` / `user` 在两个组件间命名一致
- [x] Vue 3 风格: `<script setup>` + Composition API
- [x] Element Plus 风格: el-dialog / el-dropdown / el-message-box 与项目其他组件一致