feat: 标签插入
This commit is contained in:
@@ -1,172 +0,0 @@
|
||||
# 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` - 设置选项
|
||||
@@ -53,7 +53,10 @@
|
||||
<span>Upload files</span>
|
||||
</div>
|
||||
<div class="gap"></div>
|
||||
<div class="report flex align-center" @click="toogltReportTag()">
|
||||
<div
|
||||
class="report flex align-center"
|
||||
@click="toogltReportTag()"
|
||||
>
|
||||
<SvgIcon color="#5A5A5A" name="light" size="11" />
|
||||
<span>Trending report</span>
|
||||
</div>
|
||||
@@ -232,6 +235,9 @@
|
||||
import { useAgentStore, useProjectStore } from '@/stores'
|
||||
import lightIcon from '@/assets/images/light-icon.png'
|
||||
import closeIcon from '@/assets/images/close-icon.png'
|
||||
import TypeIcon from '@/assets/icons/TypeIcon.svg'
|
||||
import RegionIcon from '@/assets/icons/RegionIcon.svg'
|
||||
import StyleIcon from '@/assets/icons/StyleIcon.svg'
|
||||
import restoreIcon from '@/assets/images/restore.png'
|
||||
import restoreCloseIcon from '@/assets/images/tag-close.png'
|
||||
import { createProject } from '@/api/agent'
|
||||
@@ -335,9 +341,14 @@
|
||||
'Squiggle'
|
||||
]
|
||||
|
||||
type OptionTagKind = 'type' | 'area' | 'style'
|
||||
|
||||
const optionTagOrder: OptionTagKind[] = ['type', 'area', 'style']
|
||||
|
||||
const editorRef = ref<HTMLDivElement | null>(null)
|
||||
const inputValue = ref<string>('')
|
||||
const reportTags = ref<HTMLElement[]>([])
|
||||
const optionTags = ref<Partial<Record<OptionTagKind, HTMLElement>>>({})
|
||||
const reportPromptText = ref<Text | null>(null)
|
||||
let reportTypewriterTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -393,10 +404,161 @@
|
||||
reportPromptText.value = null
|
||||
}
|
||||
|
||||
const removeNodeWithNextSpacer = (node: Node) => {
|
||||
const nextNode = node.nextSibling
|
||||
if (nextNode && nextNode.nodeType === Node.TEXT_NODE && nextNode.textContent === '\u200B') {
|
||||
nextNode.remove()
|
||||
}
|
||||
node.parentNode?.removeChild(node)
|
||||
}
|
||||
|
||||
const hasOptionTags = () => {
|
||||
return Object.values(optionTags.value).some((tag) => tag?.parentNode)
|
||||
}
|
||||
|
||||
const getOptionTagValue = (kind: OptionTagKind) => {
|
||||
if (kind === 'type') return typeValue.value
|
||||
if (kind === 'area') return areaValue.value
|
||||
return styleValue.value
|
||||
}
|
||||
|
||||
const getOptionTagIcon = (kind: OptionTagKind) => {
|
||||
if (kind === 'type') return TypeIcon
|
||||
if (kind === 'area') return RegionIcon
|
||||
return StyleIcon
|
||||
}
|
||||
|
||||
const getTranslatedOptionLabel = (
|
||||
options: Array<{ label: string; value: string }>,
|
||||
value: string
|
||||
) => {
|
||||
const option = options.find((item) => item.value === value)
|
||||
return option ? t(option.label) : value
|
||||
}
|
||||
|
||||
const getOptionTagLabel = (kind: OptionTagKind) => {
|
||||
const value = getOptionTagValue(kind)
|
||||
if (kind === 'type') return getTranslatedOptionLabel(typeOptions.value, value)
|
||||
if (kind === 'area') return getTranslatedOptionLabel(areaOptions.value, value)
|
||||
return styleOptions.value.find((item) => item.value === value)?.label || value
|
||||
}
|
||||
|
||||
const removeOptionTag = (kind: OptionTagKind) => {
|
||||
const tag = optionTags.value[kind]
|
||||
if (tag?.parentNode) {
|
||||
removeNodeWithNextSpacer(tag)
|
||||
}
|
||||
delete optionTags.value[kind]
|
||||
}
|
||||
|
||||
const removeAllOptionTags = () => {
|
||||
optionTagOrder.forEach(removeOptionTag)
|
||||
optionTags.value = {}
|
||||
}
|
||||
|
||||
const clearOptionTagValue = (kind: OptionTagKind) => {
|
||||
if (kind === 'type') {
|
||||
typeValue.value = ''
|
||||
} else if (kind === 'area') {
|
||||
areaValue.value = ''
|
||||
} else {
|
||||
styleValue.value = ''
|
||||
tempSelectedValue.value = ''
|
||||
}
|
||||
|
||||
removeOptionTag(kind)
|
||||
handleEditorInput()
|
||||
}
|
||||
|
||||
const getOptionTagInsertBefore = () => {
|
||||
const editor = editorRef.value
|
||||
if (!editor) return null
|
||||
|
||||
for (const child of Array.from(editor.childNodes)) {
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
const childKind = (child as HTMLElement).dataset.optionTagKind as OptionTagKind
|
||||
if (childKind) continue
|
||||
}
|
||||
return child
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const createOptionTag = (kind: OptionTagKind, label: string) => {
|
||||
const tag = document.createElement('div')
|
||||
tag.contentEditable = 'false'
|
||||
tag.className = 'editor-tag option-tag flex-center'
|
||||
tag.dataset.optionTagKind = kind
|
||||
|
||||
const icon = document.createElement('img')
|
||||
icon.className = 'option-tag-icon'
|
||||
icon.src = getOptionTagIcon(kind) as unknown as string
|
||||
|
||||
const textSpan = document.createElement('span')
|
||||
textSpan.className = 'option-tag-text'
|
||||
textSpan.innerText = label
|
||||
|
||||
const close = document.createElement('img')
|
||||
close.className = 'close-icon option-close'
|
||||
close.src = closeIcon as unknown as string
|
||||
close.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
clearOptionTagValue(kind)
|
||||
})
|
||||
|
||||
tag.appendChild(icon)
|
||||
tag.appendChild(textSpan)
|
||||
tag.appendChild(close)
|
||||
|
||||
return tag
|
||||
}
|
||||
|
||||
const syncOptionTag = (kind: OptionTagKind) => {
|
||||
const value = getOptionTagValue(kind)
|
||||
if (!value) {
|
||||
removeOptionTag(kind)
|
||||
handleEditorInput()
|
||||
return
|
||||
}
|
||||
|
||||
const label = getOptionTagLabel(kind)
|
||||
const existingTag = optionTags.value[kind]
|
||||
|
||||
if (existingTag?.parentNode) {
|
||||
const text = existingTag.querySelector('.option-tag-text')
|
||||
if (text) text.textContent = label
|
||||
handleEditorInput()
|
||||
return
|
||||
}
|
||||
|
||||
const editor = editorRef.value
|
||||
if (!editor) return
|
||||
|
||||
const tag = createOptionTag(kind, label)
|
||||
const referenceNode = getOptionTagInsertBefore()
|
||||
if (referenceNode) {
|
||||
editor.insertBefore(tag, referenceNode)
|
||||
} else {
|
||||
editor.appendChild(tag)
|
||||
}
|
||||
|
||||
if (tag.parentNode) {
|
||||
tag.parentNode.insertBefore(document.createTextNode('\u200B'), tag.nextSibling)
|
||||
}
|
||||
|
||||
optionTags.value[kind] = tag
|
||||
handleEditorInput()
|
||||
}
|
||||
|
||||
const syncAllOptionTags = () => {
|
||||
optionTagOrder.forEach(syncOptionTag)
|
||||
}
|
||||
|
||||
// 控制占位符显示
|
||||
const showPlaceholder = computed(() => {
|
||||
if (!editorRef.value) return true
|
||||
if (inputValue.value || reportTags.value.length > 0) return false
|
||||
if (inputValue.value || reportTags.value.length > 0 || hasOptionTags()) return false
|
||||
|
||||
const editor = editorRef.value
|
||||
const textContent = editor.textContent?.replace(/[\s\u200B\n]/g, '').trim() || '' // 移除空格、零宽空格、换行
|
||||
@@ -539,6 +701,7 @@
|
||||
editor.innerHTML = ''
|
||||
editor.textContent = ''
|
||||
reportTags.value = []
|
||||
removeAllOptionTags()
|
||||
stopReportTypewriter()
|
||||
reportPromptText.value = null
|
||||
}
|
||||
@@ -573,6 +736,7 @@
|
||||
if (!hasChildElements && !hasTextContent) {
|
||||
editor.innerHTML = ''
|
||||
reportTags.value = []
|
||||
removeAllOptionTags()
|
||||
stopReportTypewriter()
|
||||
reportPromptText.value = null
|
||||
}
|
||||
@@ -642,10 +806,24 @@
|
||||
const element = nodeToDelete as Element
|
||||
const isEditorTag = element.classList.contains('editor-tag')
|
||||
const isReportTag = element.classList.contains('report-tag')
|
||||
const optionTagKind = (element as HTMLElement).dataset
|
||||
.optionTagKind as OptionTagKind
|
||||
|
||||
if (optionTagKind) {
|
||||
e.preventDefault()
|
||||
clearOptionTagValue(optionTagKind)
|
||||
nextTick(() => {
|
||||
cleanupEditor()
|
||||
handleEditorInput()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditorTag || isReportTag) {
|
||||
e.preventDefault()
|
||||
removeReportPromptText()
|
||||
if (reportTags.value.includes(element as HTMLElement)) {
|
||||
removeReportPromptText()
|
||||
}
|
||||
element.remove()
|
||||
|
||||
// 从 reportTags 中移除
|
||||
@@ -772,6 +950,30 @@
|
||||
}))
|
||||
)
|
||||
|
||||
watch(typeValue, () => {
|
||||
nextTick(() => {
|
||||
syncOptionTag('type')
|
||||
})
|
||||
})
|
||||
|
||||
watch(areaValue, () => {
|
||||
nextTick(() => {
|
||||
syncOptionTag('area')
|
||||
})
|
||||
})
|
||||
|
||||
watch(styleValue, () => {
|
||||
nextTick(() => {
|
||||
syncOptionTag('style')
|
||||
})
|
||||
})
|
||||
|
||||
watch(locale, () => {
|
||||
nextTick(() => {
|
||||
syncAllOptionTags()
|
||||
})
|
||||
})
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!inputValue.value.trim()) {
|
||||
return
|
||||
@@ -822,12 +1024,17 @@
|
||||
if (editorRef.value) {
|
||||
editorRef.value.innerHTML = ''
|
||||
}
|
||||
optionTags.value = {}
|
||||
nextTick(() => {
|
||||
syncAllOptionTags()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
MyEvent.add('quote', handleQuote)
|
||||
MyEvent.add('projectChange', handleInitInput)
|
||||
nextTick(() => {
|
||||
syncAllOptionTags()
|
||||
autoResizeEditor()
|
||||
})
|
||||
})
|
||||
@@ -1360,6 +1567,37 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&.option-tag {
|
||||
width: auto;
|
||||
max-width: 24rem;
|
||||
height: 3.4rem;
|
||||
padding: 0 1rem;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
column-gap: 0.8rem;
|
||||
color: #0d0d0d;
|
||||
|
||||
.option-tag-icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-tag-text {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.option-close {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin: 0 0.7rem 0 1.2rem;
|
||||
&.restore-text {
|
||||
|
||||
Reference in New Issue
Block a user