This commit is contained in:
lzp
2026-03-09 13:44:34 +08:00
4 changed files with 499 additions and 50 deletions

View File

@@ -1,4 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="8" fill="white"/>
<path d="M5.03431 5.03431C5.34673 4.7219 5.85327 4.7219 6.16569 5.03431L8 6.86863L9.83432 5.03431C10.1467 4.7219 10.6533 4.7219 10.9657 5.03431C11.2781 5.34673 11.2781 5.85327 10.9657 6.16569L9.13137 8L10.9657 9.83432C11.2781 10.1467 11.2781 10.6533 10.9657 10.9657C10.6533 11.2781 10.1467 11.2781 9.83432 10.9657L8 9.13137L6.16569 10.9657C5.85327 11.2781 5.34673 11.2781 5.03431 10.9657C4.7219 10.6533 4.7219 10.1467 5.03431 9.83432L6.86863 8L5.03431 6.16569C4.7219 5.85327 4.7219 5.34673 5.03431 5.03431Z" fill="#CDCDCD"/>
<path d="M8 1.6C4.46538 1.6 1.6 4.46538 1.6 8C1.6 11.5346 4.46538 14.4 8 14.4C11.5346 14.4 14.4 11.5346 14.4 8C14.4 4.46538 11.5346 1.6 8 1.6ZM0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8Z" fill="#CDCDCD"/>
</svg>

Before

Width:  |  Height:  |  Size: 896 B

After

Width:  |  Height:  |  Size: 939 B

View File

@@ -0,0 +1,72 @@
<!-- ImagePreview.vue -->
<template>
<div v-if="modelValue" class="image-preview-modal" @click="closePreview">
<div class="image-preview-container" @click.stop>
<img :src="url" alt="Preview Image" class="preview-image" />
<button class="close-btn" @click="closePreview">×</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
url: string
modelValue: boolean
}>()
const emits = defineEmits(['update:modelValue'])
const closePreview = () => {
emits('update:modelValue', false)
}
</script>
<style lang="less" scoped>
.image-preview-modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.image-preview-container {
position: relative;
max-width: 90vw;
max-height: 90vh;
display: flex;
justify-content: center;
align-items: center;
}
.preview-image {
max-width: 100%;
max-height: 90vh;
border-radius: 0.8rem;
display: block;
}
.close-btn {
position: absolute;
top: -2rem;
right: -2rem;
background: white;
border: none;
font-size: 3rem;
cursor: pointer;
color: #606266;
width: 4rem;
height: 4rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View 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` - 设置选项

View File

@@ -8,6 +8,7 @@
v-for="(image, index) in uploadedImages"
:key="index"
class="image-preview-item"
@click="previewImage(image.url)"
>
<img :src="image.url" :alt="image.name" class="preview-image" />
<div class="image-remove-btn" @click="removeImage(index)">
@@ -16,15 +17,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">
@@ -174,11 +198,16 @@
</div>
</div>
<div v-if="!isAgentMode" class="report-btn flex flex-center" @click="toogltReportTag">
<div
v-if="!isAgentMode"
class="report-btn flex space-between align-center"
@click="toogltReportTag"
>
<SvgIcon class="light-icon" name="light" size="16" />
<span>{{ $t('Input.trendingReport') }}</span>
</div>
</div>
<Preview v-model="showPreview" :url="previewUrl" />
</template>
<script setup lang="ts">
@@ -195,7 +224,7 @@
import { getStyleImage } from './style'
import { uploadImage } from '@/api/upload'
import MyEvent from '@/utils/myEvent'
// import Tag from './Tag.vue'
import Preview from '@/components/Preview/Preview.vue'
const router = useRouter()
const agentStore = useAgentStore()
@@ -281,14 +310,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 +377,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 +430,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 +481,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 +522,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 +587,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 +601,56 @@
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 +687,9 @@
// 初始化编辑器高度
onMounted(() => {
autoResizeEditor()
nextTick(() => {
autoResizeEditor()
})
})
const typeValue = ref<string>('')
@@ -596,6 +775,13 @@
uploadedImages.value = []
}
const showPreview = ref(false)
const previewUrl = ref('')
const previewImage = (url: string) => {
showPreview.value = true
previewUrl.value = url
}
// 暴露方法给父组件
defineExpose({
addReportTag
@@ -608,15 +794,16 @@
bottom: -7.4rem;
height: 4.4rem;
border-radius: 2.2rem;
width: 20rem;
width: 19.7rem;
padding: 0 2rem;
font-size: 1.8rem;
background-color: #fff;
border: 1px solid #f6f4ef;
column-gap: 1.2rem;
border: 1.1px solid #f6f4ef1a;
cursor: pointer;
.c-svg {
width: 1.5rem;
height: 1.9rem;
height: 2rem;
}
}
.assist-input-wrapper {
@@ -644,24 +831,39 @@
}
.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;
border: none;
outline: none;
padding: 0 1.4rem 1.4rem;
font-size: 2rem;
font-size: 1.8rem;
font-family: 'InterRegular';
font-weight: 400;
color: #000000;
overflow-y: auto;
overflow-x: hidden;
line-height: 1.5;
line-height: 2.8rem;
white-space: pre-wrap;
word-wrap: break-word;
&::-webkit-scrollbar {
@@ -703,14 +905,15 @@
.preview-image {
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
border-radius: 0.8rem;
cursor: pointer;
}
.image-remove-btn {
position: absolute;
top: 0.2rem;
right: 0.2rem;
top: 0.6rem;
right: 0.6rem;
width: 1.6rem;
height: 1.6rem;
color: #fff;
@@ -720,12 +923,12 @@
justify-content: center;
font-size: 1.2rem;
cursor: pointer;
opacity: 0;
display: none;
transition: opacity 0.2s ease;
}
&:hover .image-remove-btn {
opacity: 1;
display: block;
}
}
}
@@ -868,6 +1071,11 @@
</style>
<style lang="less">
.custom-placeholder {
color: #999;
margin-left: 0.5rem;
pointer-events: none;
}
.fida-style-select-popover {
width: 34.2rem !important;
padding: 0 !important;
@@ -975,10 +1183,6 @@
border-radius: 0.6rem;
}
// .fida-setting-popover-content {
// padding: 2rem 2.4rem 2.4rem;
// }
.fida-setting-popover-header {
font-weight: 400;
font-size: 1.4rem;
@@ -1049,12 +1253,12 @@
/* 动态添加的编辑器标签样式 */
.assist-input-wrapper .editor .editor-tag {
width: 21.8rem;
height: 4.4rem;
width: 15.6rem;
height: 3.1rem;
display: inline-flex;
border: 0.11rem solid #bfbfbf;
border: 1px solid #0000001a;
font-weight: 500;
font-size: 1.8rem;
font-size: 1.3rem;
column-gap: 0;
margin: 0 0.5rem;
vertical-align: middle;
@@ -1088,14 +1292,14 @@
}
.light-icon {
width: 1.5rem;
height: 1.9rem;
width: 0.9rem;
height: 1.2rem;
flex-shrink: 0;
}
.close-icon {
width: 1rem;
height: 1rem;
width: 0.9rem;
height: 0.9rem;
cursor: pointer;
flex-shrink: 0;
&.restore {