This commit is contained in:
2026-08-07 13:56:29 +08:00
commit 152d5273a5
306 changed files with 38651 additions and 0 deletions
@@ -0,0 +1,703 @@
# 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 与项目其他组件一致