feat: 报告标签添加后显示新的Placeholder
This commit is contained in:
172
src/views/home/components/Input.md
Normal file
172
src/views/home/components/Input.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Input 组件文档
|
||||
|
||||
## 组件概述
|
||||
|
||||
`Input.vue` 是一个输入框组件,用于家具设计项目的创建和AI对话场景。主要提供文本输入、图片上传、选项选择和项目创建等功能。
|
||||
|
||||
## 组件位置
|
||||
|
||||
`src/views/home/components/Input.vue`
|
||||
|
||||
## Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `isAgentMode` | `boolean` | `false` | 是否为Agent模式(简化界面) |
|
||||
| `generating` | `boolean` | `false` | 是否正在生成内容 |
|
||||
|
||||
## Events
|
||||
|
||||
| 事件名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| `send` | `{ text: string, images: string[], tempImages: Array }` | 发送消息/创建项目 |
|
||||
| `pause` | - | 暂停生成 |
|
||||
|
||||
## 组件模式
|
||||
|
||||
### 普通模式 (isAgentMode = false)
|
||||
|
||||
完整的输入界面,包含以下功能:
|
||||
- 图片上传附件按钮
|
||||
- 类型选择下拉框 (Sofa/Desk/Chair)
|
||||
- 区域选择下拉框
|
||||
- 风格选择弹窗 (15种风格)
|
||||
- 设置弹窗 (3个滑块参数)
|
||||
- 创建项目按钮
|
||||
|
||||
### Agent模式 (isAgentMode = true)
|
||||
|
||||
简化界面,仅包含:
|
||||
- 文本输入区域
|
||||
- 发送/暂停按钮
|
||||
|
||||
## 功能详解
|
||||
|
||||
### 1. 图片上传
|
||||
|
||||
- 支持通过附件按钮或拖拽上传图片
|
||||
- 图片预览区域显示已上传图片
|
||||
- 可删除单个图片
|
||||
- 图片通过 `uploadImage` API 上传
|
||||
|
||||
### 2. 富文本编辑
|
||||
|
||||
使用 `contenteditable` div 实现:
|
||||
- 支持粘贴纯文本
|
||||
- 支持回车发送/创建
|
||||
- 支持退格键删除标签
|
||||
|
||||
### 3. 热点报告标签
|
||||
|
||||
点击底部按钮添加特殊标签:
|
||||
- 标签包含图标和文本
|
||||
- 可通过关闭按钮删除
|
||||
- 插入到编辑器光标位置
|
||||
|
||||
### 4. 风格选择弹窗
|
||||
|
||||
15种家具风格可选:
|
||||
```
|
||||
Venetian Modern, Coastal, Maximalism, Memphis, Verdant,
|
||||
Century Chrome, Modern Revival, Transitional, Tuscan 2000's,
|
||||
Kitsch-core, Bauhaus, Constructivism, Nordic Noir, Dopamine, Squiggle
|
||||
```
|
||||
|
||||
### 5. 设置弹窗
|
||||
|
||||
3个滑块参数配置 (默认值50%):
|
||||
- 对应翻译路径: `Input.settingOptions.first/second/third`
|
||||
|
||||
## 关键方法
|
||||
|
||||
| 方法名 | 作用域 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `triggerFileUpload` | 公开 | 触发文件选择 |
|
||||
| `handleFileChange` | 私有 | 处理文件选择变化 |
|
||||
| `removeImage` | 私有 | 删除指定索引的图片 |
|
||||
| `handleEditorInput` | 私有 | 处理编辑器输入 |
|
||||
| `handleEditorPaste` | 私有 | 处理粘贴事件 |
|
||||
| `handleKeyDown` | 私有 | 处理键盘事件 |
|
||||
| `handleSendAgent` | 私有 | Agent模式发送 |
|
||||
| `handleCreateProject` | 私有 | 创建项目 |
|
||||
| `addReportTag` | 公开 | 添加热点报告标签 |
|
||||
| `toogltReportTag` | 私有 | 切换热点报告标签 |
|
||||
| `selectStyle` | 私有 | 选择风格 |
|
||||
| `confirmStyle` | 私有 | 确认风格选择 |
|
||||
| `confirmSetting` | 私有 | 确认设置 |
|
||||
|
||||
## 公开API (defineExpose)
|
||||
|
||||
```typescript
|
||||
defineExpose({
|
||||
addReportTag: (text?: string) => void
|
||||
})
|
||||
```
|
||||
|
||||
## 数据结构
|
||||
|
||||
### 上传图片格式
|
||||
```typescript
|
||||
interface UploadedImage {
|
||||
url: string // 预览URL (base64)
|
||||
name: string // 文件名
|
||||
path: string // 服务器路径
|
||||
}
|
||||
```
|
||||
|
||||
### 发送Payload格式
|
||||
```typescript
|
||||
interface SendPayload {
|
||||
text: string // 输入文本
|
||||
images: string[] // 图片服务器路径
|
||||
tempImages: UploadedImage[] // 预览图片数据
|
||||
}
|
||||
```
|
||||
|
||||
### 创建项目参数
|
||||
```typescript
|
||||
interface ProjectParams {
|
||||
type: string // 家具类型
|
||||
area: string // 区域
|
||||
style: string // 风格
|
||||
temperature: number // 温度参数 (固定0.7)
|
||||
}
|
||||
```
|
||||
|
||||
## 依赖项
|
||||
|
||||
- **Vue API**: `computed`, `ref`, `watch`, `nextTick`, `onMounted`
|
||||
- **Store**: `useAgentStore`, `useProjectStore`
|
||||
- **Utils**: `areaList` (区域列表), `getStyleImage` (风格图片)
|
||||
- **API**: `createProject`, `uploadImage`
|
||||
- **UI库**: Element Plus (`el-select`, `el-popover`, `el-slider`)
|
||||
- **国际化**: `vue-i18n`
|
||||
|
||||
## 样式说明
|
||||
|
||||
- 使用 `<style lang="less">` scoped 样式
|
||||
- 全局样式部分使用非 scoped (`.fida-` 前缀类名)
|
||||
- 支持 hover 动画效果
|
||||
- Agent 模式下样式有所简化
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 编辑器使用 `contenteditable`,需手动处理输入事件
|
||||
2. 图片上传后使用 FileReader 读取本地预览
|
||||
3. 标签使用 DOM 操作动态插入
|
||||
4. 项目创建后会路由跳转至 `/home/agent/{projectId}`
|
||||
5. 组件会自动聚焦编辑器
|
||||
|
||||
## 国际化键名参考
|
||||
|
||||
- `Input.placeholder` - 输入框占位符
|
||||
- `Input.typePlaceholder` - 类型选择占位符
|
||||
- `Input.areaPlaceholder` - 区域选择占位符
|
||||
- `Input.stylePlaceholder` - 风格选择占位符
|
||||
- `Input.chooseStyle` - 选择风格标题
|
||||
- `Input.confirm` - 确认按钮
|
||||
- `Input.styleTitle` - 设置弹窗标题
|
||||
- `Input.createProject` - 创建项目按钮
|
||||
- `Input.trendingReport` - 热点报告按钮文本
|
||||
- `Input.types.sofa/desk/chair` - 家具类型选项
|
||||
- `Input.settingOptions.first/second/third` - 设置选项
|
||||
@@ -16,15 +16,38 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- 编辑区域 -->
|
||||
<div
|
||||
ref="editorRef"
|
||||
class="editor"
|
||||
contenteditable="true"
|
||||
:placeholder="$t('Input.placeholder')"
|
||||
@input="handleEditorInput"
|
||||
@paste="handleEditorPaste"
|
||||
@keydown="handleKeyDown"
|
||||
></div>
|
||||
<div class="editor-wrapper">
|
||||
<!-- 静态占位符 - 当编辑器为空时显示 -->
|
||||
<div
|
||||
v-if="showPlaceholder"
|
||||
class="editor-placeholder"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
padding: '0 1.4rem 1.4rem',
|
||||
fontSize: '2rem',
|
||||
fontFamily: 'InterRegular',
|
||||
fontWeight: 400,
|
||||
color: '#999',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
width: 'calc(100% - 2.8rem)',
|
||||
boxSizing: 'border-box'
|
||||
}"
|
||||
>
|
||||
{{ $t('Input.placeholder') }}
|
||||
</div>
|
||||
<div
|
||||
ref="editorRef"
|
||||
class="editor"
|
||||
contenteditable="true"
|
||||
@input="handleEditorInput"
|
||||
@paste="handleEditorPaste"
|
||||
@keydown="handleKeyDown"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operate flex align-center space-between">
|
||||
<div class="left flex align-center">
|
||||
@@ -281,14 +304,49 @@
|
||||
|
||||
const editorRef = ref<HTMLDivElement | null>(null)
|
||||
const inputValue = ref<string>('')
|
||||
|
||||
const reportTags = ref([])
|
||||
const customPlaceholder = ref<HTMLElement | null>(null)
|
||||
|
||||
// 控制占位符显示
|
||||
const showPlaceholder = computed(() => {
|
||||
if (!editorRef.value) return true
|
||||
if (inputValue.value || reportTags.value.length > 0) return false
|
||||
|
||||
const editor = editorRef.value
|
||||
const textContent = editor.textContent?.replace(/[\s\u200B\n]/g, '').trim() || '' // 移除空格、零宽空格、换行
|
||||
const isEmptyHTML = editor.innerHTML === '' || editor.innerHTML === '<br>' // 处理浏览器 <br> 插入
|
||||
const hasMeaningfulChildren = Array.from(editor.children).some(
|
||||
(child) => child.tagName !== 'BR' && !child.classList.contains('editor-tag') // 排除 <br> 和 tags(如果 tags 算非空)
|
||||
)
|
||||
|
||||
// 如果纯文本为空、无有意义子元素、且不是仅剩 <br>,则显示 placeholder
|
||||
return textContent === '' && !hasMeaningfulChildren && isEmptyHTML
|
||||
})
|
||||
|
||||
// 打字机效果显示placeholder
|
||||
let typewriterTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const typeWriterEffect = (element: HTMLElement, text: string, index: number = 0) => {
|
||||
if (index < text.length) {
|
||||
element.innerText += text.charAt(index)
|
||||
typewriterTimeout = setTimeout(() => {
|
||||
typeWriterEffect(element, text, index + 1)
|
||||
}, 30) // 每个字符间隔30ms
|
||||
}
|
||||
}
|
||||
|
||||
// 停止打字机效果
|
||||
const stopTypewriter = () => {
|
||||
if (typewriterTimeout) {
|
||||
clearTimeout(typewriterTimeout)
|
||||
typewriterTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
// 导出给父组件调用的方法
|
||||
const addReportTag = (text?: string) => {
|
||||
// 使用传入的文本,如果没有传入则使用默认的翻译文本
|
||||
const tagText = text || t('Input.trendingReport')
|
||||
|
||||
// create container matching static structure: <div class="editor-tag report-btn flex-center" contenteditable="false">...
|
||||
const tag = document.createElement('div')
|
||||
tag.contentEditable = 'false'
|
||||
const imgLeft = document.createElement('img')
|
||||
@@ -313,10 +371,15 @@
|
||||
|
||||
imgClose.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
stopTypewriter() // 关闭标签时停止打字机效果
|
||||
// remove tag when close clicked
|
||||
tag.remove()
|
||||
const idx = reportTags.value.indexOf(tag)
|
||||
if (idx > -1) reportTags.value.splice(idx, 1)
|
||||
if (customPlaceholder.value) {
|
||||
customPlaceholder.value.remove()
|
||||
customPlaceholder.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// assemble
|
||||
@@ -361,9 +424,41 @@
|
||||
}
|
||||
|
||||
reportTags.value.push(tag)
|
||||
const placeholderSpan = document.createElement('span')
|
||||
placeholderSpan.className = 'custom-placeholder'
|
||||
// 初始为空字符串,稍后通过打字机效果填充
|
||||
placeholderSpan.innerText = ''
|
||||
if (tag.parentNode) {
|
||||
tag.parentNode.insertBefore(placeholderSpan, tag.nextSibling)
|
||||
const zwsp = document.createTextNode('\u200B')
|
||||
tag.parentNode.insertBefore(zwsp, placeholderSpan.nextSibling)
|
||||
const newRange = document.createRange()
|
||||
newRange.setStart(placeholderSpan, 0)
|
||||
newRange.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(newRange)
|
||||
selection.addRange(newRange)
|
||||
}
|
||||
customPlaceholder.value = placeholderSpan
|
||||
|
||||
// 打字机效果显示placeholder文本
|
||||
const placeholderText =
|
||||
'Generate a furniture trending report for 2025, including popular styles and design directions.'
|
||||
typeWriterEffect(placeholderSpan, placeholderText)
|
||||
|
||||
const removePlaceholderOnInput = () => {
|
||||
stopTypewriter() // 用户输入时停止打字机效果
|
||||
if (placeholderSpan.parentNode) {
|
||||
placeholderSpan.remove()
|
||||
customPlaceholder.value = null
|
||||
}
|
||||
editorRef.value?.removeEventListener('input', removePlaceholderOnInput)
|
||||
}
|
||||
editorRef.value?.addEventListener('input', removePlaceholderOnInput)
|
||||
}
|
||||
|
||||
const toogltReportTag = () => {
|
||||
stopTypewriter() // 移除标签时停止打字机效果
|
||||
// 清理掉已被删除的标签引用(从 DOM 中移除的元素)
|
||||
reportTags.value = reportTags.value.filter((tag) => tag.parentNode !== null)
|
||||
|
||||
@@ -380,12 +475,38 @@
|
||||
tag.remove()
|
||||
})
|
||||
reportTags.value = []
|
||||
if (customPlaceholder.value) {
|
||||
customPlaceholder.value.remove()
|
||||
customPlaceholder.value = null
|
||||
}
|
||||
} else {
|
||||
// 添加标签
|
||||
addReportTag()
|
||||
}
|
||||
}
|
||||
|
||||
// 清理编辑器中的零宽字符
|
||||
const cleanupEditor = () => {
|
||||
if (!editorRef.value) return
|
||||
|
||||
const editor = editorRef.value
|
||||
|
||||
// 移除所有零宽字符
|
||||
if (editor.textContent) {
|
||||
const cleanedText = editor.textContent.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
||||
|
||||
// 检查是否真的为空
|
||||
if (!cleanedText.trim() && editor.children.length === 0) {
|
||||
// 完全清空
|
||||
editor.innerHTML = ''
|
||||
editor.textContent = ''
|
||||
reportTags.value = []
|
||||
customPlaceholder.value = null
|
||||
stopTypewriter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditorInput = () => {
|
||||
if (!editorRef.value) return
|
||||
|
||||
@@ -395,13 +516,29 @@
|
||||
|
||||
let node: Node | null
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.parentElement?.classList.contains('custom-placeholder')) continue
|
||||
if (node.parentElement?.classList.contains('editor-tag')) continue
|
||||
text += node.textContent
|
||||
}
|
||||
|
||||
// 移除末尾的空格
|
||||
text = text.replace(/\s+$/, '')
|
||||
// 移除末尾的空格和零宽空格
|
||||
text = text.replace(/[\s\u200B]+$/, '')
|
||||
inputValue.value = text
|
||||
|
||||
// 检查编辑器是否真正为空(没有可见子元素且没有文本)
|
||||
const editor = editorRef.value
|
||||
const hasChildElements = editor.children.length > 0
|
||||
const hasTextContent = editor.textContent?.replace(/[\s\u200B]/g, '').trim().length > 0
|
||||
|
||||
// 如果编辑器完全为空,清空它以显示占位符
|
||||
// 同时也要清空 reportTags 和 customPlaceholder
|
||||
if (!hasChildElements && !hasTextContent) {
|
||||
editor.innerHTML = ''
|
||||
reportTags.value = []
|
||||
customPlaceholder.value = null
|
||||
stopTypewriter()
|
||||
}
|
||||
|
||||
// 自动调整高度
|
||||
autoResizeEditor()
|
||||
}
|
||||
@@ -444,7 +581,7 @@
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0)
|
||||
if (range.collapsed) {
|
||||
let nodeToDelete = null
|
||||
let nodeToDelete: Node | null = null
|
||||
const startContainer = range.startContainer
|
||||
const startOffset = range.startOffset
|
||||
|
||||
@@ -458,22 +595,59 @@
|
||||
nodeToDelete = startContainer.childNodes[startOffset]
|
||||
}
|
||||
|
||||
// 检查 nodeToDelete 是否存在以及是否为元素节点
|
||||
if (
|
||||
nodeToDelete &&
|
||||
nodeToDelete.nodeType === Node.ELEMENT_NODE &&
|
||||
(nodeToDelete as Element).classList &&
|
||||
((nodeToDelete as Element).classList.contains('editor-tag') ||
|
||||
(nodeToDelete as Element).classList.contains('report-tag'))
|
||||
(nodeToDelete as Element).classList
|
||||
) {
|
||||
e.preventDefault()
|
||||
;(nodeToDelete as Element).remove()
|
||||
// Optional: remove from reportTags if tracking
|
||||
const index = reportTags.value.indexOf(nodeToDelete)
|
||||
if (index > -1) {
|
||||
reportTags.value.splice(index, 1)
|
||||
const element = nodeToDelete as Element
|
||||
const isEditorTag = element.classList.contains('editor-tag')
|
||||
const isReportTag = element.classList.contains('report-tag')
|
||||
const isCustomPlaceholder = element.classList.contains('custom-placeholder')
|
||||
|
||||
if (isEditorTag || isReportTag || isCustomPlaceholder) {
|
||||
e.preventDefault()
|
||||
element.remove()
|
||||
|
||||
// 如果删除的是 customPlaceholder,停止打字机效果
|
||||
if (isCustomPlaceholder) {
|
||||
stopTypewriter()
|
||||
customPlaceholder.value = null
|
||||
}
|
||||
|
||||
// 从 reportTags 中移除
|
||||
const index = reportTags.value.indexOf(nodeToDelete as Element)
|
||||
if (index > -1) {
|
||||
reportTags.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 删除标签后清理零宽字符并检查是否为空
|
||||
nextTick(() => {
|
||||
cleanupEditor()
|
||||
})
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
nextTick(() => {
|
||||
if (editorRef.value) {
|
||||
const text = editorRef.value.textContent || ''
|
||||
const cleanedText = text.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
||||
|
||||
if (
|
||||
editorRef.value.innerHTML === '<br>' ||
|
||||
!cleanedText.trim()
|
||||
) {
|
||||
editorRef.value.innerHTML = ''
|
||||
// 检查是否完全为空,需要显示占位符
|
||||
if (editorRef.value.children.length === 0) {
|
||||
reportTags.value = []
|
||||
customPlaceholder.value = null
|
||||
stopTypewriter()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -510,7 +684,9 @@
|
||||
|
||||
// 初始化编辑器高度
|
||||
onMounted(() => {
|
||||
autoResizeEditor()
|
||||
nextTick(() => {
|
||||
autoResizeEditor()
|
||||
})
|
||||
})
|
||||
|
||||
const typeValue = ref<string>('')
|
||||
@@ -644,11 +820,26 @@
|
||||
}
|
||||
.scroll-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 3.4rem 1.7rem 1.7rem;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor-placeholder {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
@@ -868,6 +1059,11 @@
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
.custom-placeholder {
|
||||
color: #999; // 灰色,像 placeholder
|
||||
margin-left: 0.5rem; // 与标签间距
|
||||
pointer-events: auto; // 允许交互
|
||||
}
|
||||
.fida-style-select-popover {
|
||||
width: 34.2rem !important;
|
||||
padding: 0 !important;
|
||||
|
||||
Reference in New Issue
Block a user