1
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
# 数智 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:** 创建独立的「数智 AI」聊天页面,实现流式对话功能
|
||||
|
||||
**Architecture:** 单文件 Vue 组件实现,组合式 API + ElementPlus 组件,左侧边栏 + 中间消息区 + 底部输入栏的三栏布局
|
||||
|
||||
**Tech Stack:** Vue3 Composition API, ElementPlus, SCSS
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 创建 frontend 目录结构
|
||||
|
||||
**Files:**
|
||||
- 创建: `src/views/frontend/` (目录)
|
||||
|
||||
- [ ] **Step 1: 创建目录**
|
||||
|
||||
命令: `mkdir -p src/views/frontend`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 创建主页面组件 index.vue
|
||||
|
||||
**Files:**
|
||||
- 创建: `src/views/frontend/index.vue`
|
||||
|
||||
- [ ] **Step 1: 编写页面基础模板结构**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="shuzhi-ai-container">
|
||||
<!-- 左侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<!-- LOGO 栏 -->
|
||||
<div class="logo-bar">
|
||||
<span class="logo-text">数智 AI</span>
|
||||
<el-button link @click="handleComingSoon">
|
||||
<el-icon><Fold /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<!-- Tab 切换 -->
|
||||
<div class="tab-switch">
|
||||
<el-button size="small" @click="handleComingSoon">项目</el-button>
|
||||
<el-button size="small" @click="handleComingSoon">通用</el-button>
|
||||
</div>
|
||||
<!-- 树形列表 -->
|
||||
<div class="tree-section">
|
||||
<div class="tree-node tree-node-level1">
|
||||
<span class="node-label">数智AI Demo</span>
|
||||
<el-button link size="small" @click="handleComingSoon">×</el-button>
|
||||
<el-button link size="small" @click="handleComingSoon">+</el-button>
|
||||
</div>
|
||||
<div class="tree-node tree-node-level2" @click="handleComingSoon">
|
||||
<span class="node-label">欢迎使用 数智AI</span>
|
||||
<span class="node-hint">57 分钟前</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 中间主内容区 -->
|
||||
<main class="main-content">
|
||||
<!-- 面包屑和功能导航 -->
|
||||
<div class="content-header">
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item>general</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>智能体</el-breadcrumb-item>
|
||||
<el-breadcrumb-item>欢迎使用 数智AI</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<div class="nav-buttons">
|
||||
<el-button v-for="btn in navButtons" :key="btn" @click="handleComingSoon">{{ btn }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息区域 -->
|
||||
<el-scrollbar ref="scrollRef" class="messages-container">
|
||||
<div class="messages-wrapper">
|
||||
<template v-for="(msg, index) in messages" :key="index">
|
||||
<div class="message-divider" v-if="index > 0"></div>
|
||||
<!-- 用户消息 -->
|
||||
<div v-if="msg.role === 'user'" class="message-item user">
|
||||
<div class="message-bubble">
|
||||
<span class="message-tag">已处理 1m</span>
|
||||
<div class="message-content">{{ msg.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- AI 消息 -->
|
||||
<div v-else class="message-item assistant">
|
||||
<div class="message-bubble">
|
||||
<div class="message-content" v-html="formatMessage(msg.content)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<!-- 底部输入栏 -->
|
||||
<div class="input-bar">
|
||||
<div class="input-controls">
|
||||
<el-button size="small" @click="handleComingSoon">智能体</el-button>
|
||||
<el-select size="small" placeholder="选择" @click="handleComingSoon" style="width: 100px">
|
||||
<el-option label="选项1" value="1" />
|
||||
</el-select>
|
||||
<el-button size="small" @click="handleComingSoon">@</el-button>
|
||||
<el-button size="small" @click="handleComingSoon">权限</el-button>
|
||||
</div>
|
||||
<div class="input-wrapper">
|
||||
<el-input
|
||||
v-model="inputText"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="告诉 数智AI 你想完成什么..."
|
||||
resize="none"
|
||||
@keydown.enter.exact.prevent="handleSend"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="send-btn"
|
||||
:disabled="!inputText.trim() || loading"
|
||||
@click="handleSend"
|
||||
:loading="loading"
|
||||
>
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 编写 script setup 部分**
|
||||
|
||||
```javascript
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Fold } from '@element-plus/icons-vue'
|
||||
|
||||
const inputText = ref('')
|
||||
const messages = ref([])
|
||||
const loading = ref(false)
|
||||
const scrollRef = ref()
|
||||
|
||||
const navButtons = ['智能体', '文件', '技能', '路由', '记忆', '常驻']
|
||||
|
||||
function handleComingSoon() {
|
||||
ElMessage.info('敬请期待')
|
||||
}
|
||||
|
||||
function formatMessage(content) {
|
||||
if (!content) return ''
|
||||
// 高亮代码关键词
|
||||
return content
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br/>')
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!inputText.value.trim() || loading.value) return
|
||||
|
||||
const userContent = inputText.value.trim()
|
||||
inputText.value = ''
|
||||
|
||||
// 添加用户消息
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
content: userContent
|
||||
})
|
||||
|
||||
scrollToBottom()
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await fetch('http://192.168.5.125:18000/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer 5c49dd2a721e0f408ea49298b6c13ddf17267eb0b64e3da7019ee581f597408e',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'qwen3.6-27b-nvfp4',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `/no_think\n ${userContent}`
|
||||
}
|
||||
],
|
||||
max_tokens: 200,
|
||||
temperature: 0.7
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败: ${response.status}`)
|
||||
}
|
||||
|
||||
// 处理流式响应
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let aiContent = ''
|
||||
|
||||
// 添加 AI 消息占位
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: ''
|
||||
})
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value)
|
||||
aiContent += chunk
|
||||
|
||||
// 更新最后一条 AI 消息
|
||||
const lastMsg = messages.value[messages.value.length - 1]
|
||||
lastMsg.content = aiContent
|
||||
scrollToBottom()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('问答失败:' + error.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
if (scrollRef.value) {
|
||||
scrollRef.value.setScrollTop(9999999)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 编写样式部分**
|
||||
|
||||
```scss
|
||||
<style scoped lang="scss">
|
||||
.shuzhi-ai-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: #F8F9FA;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #E5E7EB;
|
||||
}
|
||||
|
||||
.logo-bar {
|
||||
height: 56px;
|
||||
background: #0F172A;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.logo-text {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-switch {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
}
|
||||
|
||||
.tree-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #E5E7EB;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-node-level1 {
|
||||
justify-content: space-between;
|
||||
|
||||
.node-label {
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
}
|
||||
|
||||
.tree-node-level2 {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-left: 16px;
|
||||
padding: 6px 8px;
|
||||
|
||||
.node-label {
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.node-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.messages-wrapper {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.message-divider {
|
||||
height: 1px;
|
||||
background: #E5E7EB;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
|
||||
&.user {
|
||||
justify-content: flex-end;
|
||||
|
||||
.message-bubble {
|
||||
background: #F1F3F5;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.message-tag {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
color: #222;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
&.assistant {
|
||||
justify-content: flex-start;
|
||||
|
||||
.message-bubble {
|
||||
padding: 0;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
color: #222;
|
||||
line-height: 1.8;
|
||||
|
||||
:deep(code) {
|
||||
background: #EEEEEE;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #E5E7EB;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
|
||||
.el-textarea {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
height: 60px;
|
||||
width: 80px;
|
||||
background: #F1F3F5;
|
||||
border: none;
|
||||
color: #444;
|
||||
border-radius: 8px;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: #E5E7EB;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #F8F9FA;
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 添加路由配置
|
||||
|
||||
**Files:**
|
||||
- 修改: `src/router/index.js` (在 dynamicRoutes 数组末尾添加)
|
||||
|
||||
- [ ] **Step 1: 添加路由配置**
|
||||
|
||||
在 `dynamicRoutes` 数组末尾添加:
|
||||
|
||||
```javascript
|
||||
{
|
||||
path: '/frontend',
|
||||
component: Layout,
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
component: () => import('@/views/frontend/index'),
|
||||
name: 'Frontend',
|
||||
meta: { title: '数智 AI' }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 验证实现
|
||||
|
||||
- [ ] **Step 1: 检查文件是否创建成功**
|
||||
|
||||
命令: `ls -la src/views/frontend/`
|
||||
|
||||
- [ ] **Step 2: 验证路由配置语法**
|
||||
|
||||
检查 `src/router/index.js` 中是否有新增的 `/frontend` 路由
|
||||
|
||||
---
|
||||
|
||||
## 自检清单
|
||||
|
||||
- [ ] Spec 覆盖:所有需求点都有对应实现
|
||||
- [ ] 占位符扫描:无 TBD、TODO 等占位符
|
||||
- [ ] 类型一致性:所有方法名、变量名一致
|
||||
|
||||
---
|
||||
|
||||
**Plan complete and saved to `docs/superpowers/plans/2026-07-15-shuzhi-ai-chat-plan.md`**
|
||||
|
||||
Two execution options:
|
||||
|
||||
**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
||||
|
||||
**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
||||
|
||||
**Which approach?**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,270 @@
|
||||
# Hide Layout Chrome for `common` Role 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:** Conditionally hide sidebar, navbar, tags-view, and the floating Settings button — and stretch `<app-main />` to fill the viewport — when the logged-in user's role list contains `"common"`.
|
||||
|
||||
**Architecture:** Add a single `isCommonUser` computed to `src/layout/index.vue` that derives from the existing Pinia `useUserStore`. Use that computed as a `v-if` gate on every chrome element and as a class binding on `.main-container`. Add one scoped SCSS rule for the fullscreen layout. No new files, no store/router/permission changes.
|
||||
|
||||
**Tech Stack:** Vue 3 (Composition API, `<script setup>`), Pinia, Vue Router, Element Plus, SCSS, Vuex-pattern Vue project (this is the `vue-element-plus-admin` template family).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-04-hide-chrome-for-common-role-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
Only one file changes:
|
||||
|
||||
| File | Change | Responsibility |
|
||||
|---|---|---|
|
||||
| `src/layout/index.vue` | Edit (script + template + style) | Add `isCommonUser` computed, gate chrome with `v-if`, add `fullscreen` class binding, add `fullscreen` SCSS rule |
|
||||
|
||||
No new files. No test files (the project has no frontend test infrastructure — see spec § Testing; verification is manual).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Apply all `src/layout/index.vue` edits
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/layout/index.vue:1-115` (entire file)
|
||||
|
||||
This task is one edit cycle but split into named sub-steps so the implementer can verify the diff top-to-bottom. Use the Edit tool with exact string matching against the current file.
|
||||
|
||||
### Step 1.1 — Add `useUserStore` import
|
||||
|
||||
In `<script setup>`, the current line is:
|
||||
|
||||
```js
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
const theme = computed(() => settingsStore.theme)
|
||||
```
|
||||
|
||||
It must become:
|
||||
|
||||
```js
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
import useUserStore from '@/store/modules/user'
|
||||
|
||||
const isCommonUser = computed(() =>
|
||||
useUserStore().roles?.includes('common') ?? false
|
||||
)
|
||||
```
|
||||
|
||||
Edit (exact old → new):
|
||||
|
||||
```js
|
||||
old:
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
new:
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
import useUserStore from '@/store/modules/user'
|
||||
|
||||
const isCommonUser = computed(() =>
|
||||
useUserStore().roles?.includes('common') ?? false
|
||||
)
|
||||
```
|
||||
|
||||
### Step 1.2 — Gate the four chrome elements + add `fullscreen` class
|
||||
|
||||
Current template body (lines 1-14):
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme, '--current-color-light': theme + '1a', '--current-color-dark-bg': theme + '33' }">
|
||||
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
|
||||
<sidebar v-if="!sidebar.hide" class="sidebar-container" />
|
||||
<div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
|
||||
<div :class="{ 'fixed-header': fixedHeader }">
|
||||
<navbar @setLayout="setLayout" />
|
||||
<tags-view v-if="needTagsView" />
|
||||
</div>
|
||||
<app-main />
|
||||
<settings ref="settingRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Must become:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme, '--current-color-light': theme + '1a', '--current-color-dark-bg': theme + '33' }">
|
||||
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
|
||||
<sidebar v-if="!sidebar.hide && !isCommonUser" class="sidebar-container" />
|
||||
<div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide, fullscreen: isCommonUser }" class="main-container">
|
||||
<div :class="{ 'fixed-header': fixedHeader }">
|
||||
<navbar v-if="!isCommonUser" @setLayout="setLayout" />
|
||||
<tags-view v-if="needTagsView && !isCommonUser" />
|
||||
</div>
|
||||
<app-main />
|
||||
<settings v-if="!isCommonUser" ref="settingRef" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
Five edits inside the template — do them one at a time with Edit if needed, or use Edit `replace_all=false` on each line above (each old/new pair is unique enough).
|
||||
|
||||
Edit 1 — sidebar line:
|
||||
```diff
|
||||
- <sidebar v-if="!sidebar.hide" class="sidebar-container" />
|
||||
+ <sidebar v-if="!sidebar.hide && !isCommonUser" class="sidebar-container" />
|
||||
```
|
||||
|
||||
Edit 2 — main-container opening line:
|
||||
```diff
|
||||
- <div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
|
||||
+ <div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide, fullscreen: isCommonUser }" class="main-container">
|
||||
```
|
||||
|
||||
Edit 3 — navbar line:
|
||||
```diff
|
||||
- <navbar @setLayout="setLayout" />
|
||||
+ <navbar v-if="!isCommonUser" @setLayout="setLayout" />
|
||||
```
|
||||
|
||||
Edit 4 — tags-view line:
|
||||
```diff
|
||||
- <tags-view v-if="needTagsView" />
|
||||
+ <tags-view v-if="needTagsView && !isCommonUser" />
|
||||
```
|
||||
|
||||
Edit 5 — settings line:
|
||||
```diff
|
||||
- <settings ref="settingRef" />
|
||||
+ <settings v-if="!isCommonUser" ref="settingRef" />
|
||||
```
|
||||
|
||||
### Step 1.3 — Add `.fullscreen` SCSS rule
|
||||
|
||||
Inside the existing `<style lang="scss" scoped>` block, append after the `.mobile .fixed-header` rule at line ~115:
|
||||
|
||||
```scss
|
||||
.main-container.fullscreen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
```
|
||||
|
||||
Use Edit with surrounding context to make the match unique — for example, anchor on `.mobile .fixed-header` block ending with `width: 100%;` followed by `}`.
|
||||
|
||||
### Step 1.4 — Self-check the diff
|
||||
|
||||
Re-read the whole file (`src/layout/index.vue`) and confirm:
|
||||
|
||||
- `useUserStore` import is present and `isCommonUser` computed is declared in `<script setup>`.
|
||||
- `<sidebar>`, `<navbar>`, `<tags-view>`, `<settings>` all carry `v-if` gating on `!isCommonUser`.
|
||||
- The `<div class="main-container">` has `fullscreen: isCommonUser` in its class binding.
|
||||
- `.main-container.fullscreen` SCSS rule is inside the scoped `<style>` block.
|
||||
|
||||
- [ ] **Step 1 completed**
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Manual verification
|
||||
|
||||
The project has no automated test suite (`package.json` scripts only include `dev` and `build`). Verify by running the dev server and exercising both role paths.
|
||||
|
||||
### Step 2.1 — Start the dev server
|
||||
|
||||
Run (from `D:/数科智联/agent-web/agent-web`):
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Expected: Vite/Element-Plus dev server starts; URL is logged (typically `http://localhost:5173` or similar). Leave running.
|
||||
|
||||
### Step 2.2 — Verify admin role (no `common` in roles)
|
||||
|
||||
1. Open the dev URL in a browser.
|
||||
2. Log in as a user whose `roles` array does **not** include `"common"` (e.g. admin / editor test accounts).
|
||||
3. Open browser DevTools → Vue DevTools (or just inspect the DOM). Confirm all four are present in `#app`:
|
||||
- `<aside>` containing the left sidebar.
|
||||
- Top `<header>` / navbar.
|
||||
- Tags-view row beneath the navbar.
|
||||
- `<app-main />` to the right.
|
||||
4. Confirm the floating Settings button is visible at the bottom-right.
|
||||
5. Click a couple of navigation links; tags-view and sidebar behavior should be unchanged from prior baseline.
|
||||
|
||||
Expected: identical to pre-change behavior. If anything looks off (extra padding, missing element), stop and re-read the file diff.
|
||||
|
||||
### Step 2.3 — Verify `common` role (chromeless)
|
||||
|
||||
1. Log out, then log in as a user whose `roles` array contains `"common"` (e.g. `['common']` or `['common', 'something-else']`).
|
||||
2. Reload the page if needed.
|
||||
3. Confirm visually:
|
||||
- No left sidebar.
|
||||
- No top navbar.
|
||||
- No tags-view row.
|
||||
- No Settings floating button.
|
||||
- `<app-main />` fills the entire viewport edge-to-edge (no sidebar gutter, no top whitespace from where navbar was).
|
||||
4. Resize the window narrower to test responsive: `.fullscreen` should remain full screen at all widths.
|
||||
5. Inside `<app-main />`, exercise the page's own controls (links, buttons, forms) to confirm interaction still works.
|
||||
|
||||
Expected: clean chromeless experience. If you see a gap or scrollbar that wasn't there before, the SCSS rule did not apply — check that the class binding in the template is correct and that `<style scoped>` did not strip the selector (use `:deep` if necessary, though for a scoped class on a top-level element this should not be needed).
|
||||
|
||||
### Step 2.4 — Verify mixed role `['admin', 'common']`
|
||||
|
||||
Repeat Step 2.3 with a user whose roles list is `['admin', 'common']`. Because `Array.prototype.includes` semantics treat any inclusion as "match", chrome should be hidden. If you see chrome here, the `isCommonUser` computed is wrong.
|
||||
|
||||
### Step 2.5 — Stop the dev server
|
||||
|
||||
When verification is complete, stop the dev server (Ctrl-C in the terminal where it is running).
|
||||
|
||||
- [ ] **Step 2 completed**
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Commit
|
||||
|
||||
### Step 3.1 — Stage and review
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git diff src/layout/index.vue
|
||||
```
|
||||
|
||||
Expected: only `src/layout/index.vue` is modified. No stray changes from your edits.
|
||||
|
||||
### Step 3.2 — Commit
|
||||
|
||||
```bash
|
||||
git add src/layout/index.vue
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(layout): common 角色隐藏侧栏/顶栏/tagsview 并全屏渲染 app-main
|
||||
|
||||
根据 useUserStore().roles 是否包含 'common' 隐藏 sidebar、navbar、
|
||||
tags-view、Settings 浮动按钮,并让 main-container 在该模式下全屏铺满。
|
||||
仅修改 src/layout/index.vue。
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Expected: one new commit on top of `b193fa1`. Working tree should be clean (modulo any pre-existing changes unrelated to this task — those are not part of this plan).
|
||||
|
||||
- [ ] **Step 3 completed**
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- [x] **Spec coverage:**
|
||||
- Goal (hide chrome + fullscreen app-main for `common` role) → Task 1
|
||||
- Trigger (`roles.includes('common')`) → Step 1.1
|
||||
- All four chrome elements gated → Steps 1.2 edits 1, 3, 4, 5
|
||||
- Fullscreen class binding on `main-container` → Step 1.2 edit 2
|
||||
- Fullscreen SCSS rule → Step 1.3
|
||||
- Manual verification scenarios (admin / common / mixed / page interaction) → Tasks 2.2, 2.3, 2.4
|
||||
- Settings also hidden (confirmed in brainstorming) → Step 1.2 edit 5
|
||||
- [x] **Placeholder scan:** No TBD/TODO. Each edit has exact code. Manual verification steps have concrete URLs and accounts to use (caller supplies).
|
||||
- [x] **Type consistency:** All references use `isCommonUser`. No alternate naming introduced. `useUserStore` matches existing imports elsewhere (`@/store/modules/user`).
|
||||
- [x] **Commit hygiene:** One focused commit. Message starts with `feat(layout):`. Co-authored-by line present.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 与项目其他组件一致
|
||||
Reference in New Issue
Block a user