Files
FiDA_Front/src/views/home/components/Input.vue
2026-02-24 13:53:01 +08:00

964 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="assist-input-wrapper flex flex-col" :class="{ agent: isAgentMode }">
<div class="scroll-content flex-col">
<!-- 图片预览区域 -->
<div v-if="uploadedImages.length > 0" class="image-preview-list flex wrap">
<div
v-for="(image, index) in uploadedImages"
:key="index"
class="image-preview-item"
>
<img :src="image.url" :alt="image.name" class="preview-image" />
<div class="image-remove-btn" @click="removeImage(index)">
<SvgIcon name="delete" size="16" />
</div>
</div>
</div>
<!-- 编辑区域 -->
<div
ref="editorRef"
class="editor"
contenteditable="true"
:placeholder="$t('Input.placeholder')"
@input="handleEditorInput"
@paste="handleEditorPaste"
@keypress="handleKeyPress"
></div>
</div>
<div class="operate flex align-center space-between">
<div class="left flex align-center">
<div class="attach flex flex-center" @click="triggerFileUpload">
<img src="@/assets/icons/attach.svg" alt="" />
</div>
<input
ref="fileInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleFileChange"
/>
<el-select
v-if="!isAgentMode"
v-model="typeValue"
:placeholder="$t('Input.typePlaceholder')"
>
<el-option
v-for="item in typeOptions"
class="input-option"
:key="item.value"
:label="$t(item.label)"
:value="item.value"
/>
</el-select>
<el-select
v-if="!isAgentMode"
v-model="areaValue"
:placeholder="$t('Input.areaPlaceholder')"
>
<el-option
v-for="item in areaOptions"
class="input-option"
:key="item.value"
:label="$t(item.label)"
:value="item.value"
/>
</el-select>
<div v-if="!isAgentMode" class="fida-style-select-wrapper">
<el-select
v-model="styleValue"
:placeholder="$t('Input.stylePlaceholder')"
@focus="openStylePopup"
/>
<el-popover
v-model:visible="stylePopupVisible"
placement="top"
:width="342"
:show-arrow="false"
trigger="click"
popper-class="fida-style-select-popover"
>
<template #reference>
<div class="fida-style-select-trigger"></div>
</template>
<div class="fida-style-popover-content flex flex-col">
<div class="fida-style-popover-header">
{{ $t('Input.chooseStyle') }}
</div>
<div class="fida-style-popover-grid">
<div
v-for="item in styleOptions"
:key="item.value"
class="fida-style-popover-item"
:class="{ 'is-selected': tempSelectedValue === item.value }"
@click="selectStyle(item.value)"
>
<span class="fida-option-label">{{ $t(item.label) }}</span>
<img
v-show="tempSelectedValue === item.value"
src="@/assets/images/checked.png"
class="checked-item-icon"
/>
</div>
</div>
<div class="fida-style-popover-footer flex flex-center">
<button class="fida-confirm-btn" @click="confirmStyle">
{{ $t('Input.confirm') }}
</button>
</div>
</div>
</el-popover>
</div>
<el-popover
v-model:visible="settingPopupVisible"
placement="top"
:width="342"
:show-arrow="false"
trigger="click"
popper-class="fida-setting-popover"
>
<template #reference>
<img src="@/assets/images/setting.png" class="setting-icon" />
</template>
<div class="fida-setting-popover-content flex flex-col">
<div class="fida-setting-popover-header">{{ $t('Input.styleTitle') }}</div>
<div class="fida-setting-slider-list">
<div
v-for="item in settingOptions"
:key="item.label"
class="fida-setting-slider-item"
>
<div class="fida-slider-label">{{ $t(item.label) }}</div>
<div class="fida-slider-row flex align-center">
<el-slider
class="setting-popover-slider"
v-model="item.value"
:show-tooltip="false"
/>
<span class="fida-slider-value">{{ item.value }}%</span>
</div>
</div>
</div>
</div>
</el-popover>
</div>
<div class="right">
<div
class="create-btn flex flex-center"
v-if="!isAgentMode"
@click="handleCreateProject"
>
<img src="@/assets/images/shining.png" class="shining-icon" alt="" />
<span class="create-btn-text">{{ $t('Input.createProject') }}</span>
</div>
<img
v-else
src="@/assets/images/sender.png"
class="sender-icon"
@click="handleSendAgent"
/>
</div>
</div>
<div v-if="!isAgentMode" class="report-btn flex flex-center" @click="toogltReportTag">
<SvgIcon class="light-icon" name="light" size="16" />
<span>{{ $t('Input.trendingReport') }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick, onMounted } from 'vue'
import { areaList } from '@/utils/area'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useAgentStore } from '@/stores/agent'
import lightIcon from '@/assets/images/light-icon.png'
import closeIcon from '@/assets/images/close-icon.png'
// import Tag from './Tag.vue'
const router = useRouter()
const agentStore = useAgentStore()
const props = withDefaults(
defineProps<{
isAgentMode?: boolean
}>(),
{
isAgentMode: false
}
)
const emits = defineEmits(['send'])
const { t } = useI18n()
// 图片上传相关
const fileInputRef = ref<HTMLInputElement | null>(null)
const uploadedImages = ref<Array<{ url: string; name: string }>>([])
// 触发文件上传
const triggerFileUpload = () => {
fileInputRef.value?.click()
}
// 处理文件选择
const handleFileChange = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files) {
Array.from(input.files).forEach((file) => {
// 只处理图片文件
if (file.type.startsWith('image/')) {
const reader = new FileReader()
reader.onload = (e) => {
uploadedImages.value.push({
url: e.target?.result as string,
name: file.name
})
}
reader.readAsDataURL(file)
}
})
}
// 清空input的value允许重复选择同一文件
input.value = ''
}
// 移除图片
const removeImage = (index: number) => {
uploadedImages.value.splice(index, 1)
}
const styleKeys: string[] = [
'Coastal',
'Verdant',
'Traditional',
'CenturyChrome',
'ModernRevival',
'Tuscan2000s',
'Bauhaus',
'Constructivism',
'NordicNoir'
]
const editorRef = ref<HTMLDivElement | null>(null)
const inputValue = ref<string>('')
const reportTags = ref([])
const addReportTag = () => {
// create container matching static structure: <div class="editor-tag report-btn flex-center" contenteditable="false">...
const tag = document.createElement('div')
tag.className = 'editor-tag report-btn flex-center'
tag.contentEditable = 'false'
const imgLeft = document.createElement('img')
imgLeft.className = 'light-icon'
imgLeft.src = lightIcon as unknown as string
const textSpan = document.createElement('span')
textSpan.innerText = t('Input.trendingReport')
const imgClose = document.createElement('img')
imgClose.className = 'close-icon'
imgClose.src = closeIcon as unknown as string
imgClose.addEventListener('click', (ev) => {
ev.stopPropagation()
// remove tag when close clicked
tag.remove()
const idx = reportTags.value.indexOf(tag)
if (idx > -1) reportTags.value.splice(idx, 1)
})
// assemble
tag.appendChild(imgLeft)
tag.appendChild(textSpan)
tag.appendChild(imgClose)
// Insert tag at the current cursor position
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
range.insertNode(tag)
// Insert a zero-width space text node after the tag so the caret can be placed there
const zwsp = document.createTextNode('\u200B')
if (tag.parentNode) tag.parentNode.insertBefore(zwsp, tag.nextSibling)
// Create a new collapsed range positioned inside the zwsp (after the tag)
const newRange = document.createRange()
newRange.setStart(zwsp, 1)
newRange.collapse(true)
selection.removeAllRanges()
selection.addRange(newRange)
// ensure editor has focus
editorRef.value && (editorRef.value as HTMLElement).focus()
} else if (editorRef.value) {
// If no selection, append directly to editor and place caret after
editorRef.value.appendChild(tag)
const zwsp = document.createTextNode('\u200B')
editorRef.value.appendChild(zwsp)
const sel = window.getSelection()
if (sel) {
const r = document.createRange()
r.setStart(zwsp, 1)
r.collapse(true)
sel.removeAllRanges()
sel.addRange(r)
}
editorRef.value && (editorRef.value as HTMLElement).focus()
}
reportTags.value.push(tag)
}
const toogltReportTag = () => {
// 清理掉已被删除的标签引用(从 DOM 中移除的元素)
reportTags.value = reportTags.value.filter((tag) => tag.parentNode !== null)
if (reportTags.value.length > 0) {
// 移除所有标签及其关联的零宽空格
reportTags.value.forEach((tag) => {
if (
tag.nextSibling &&
tag.nextSibling.nodeType === Node.TEXT_NODE &&
tag.nextSibling.textContent === '\u200B'
) {
tag.nextSibling.remove()
}
tag.remove()
})
reportTags.value = []
} else {
// 添加标签
addReportTag()
}
}
const handleEditorInput = () => {
if (!editorRef.value) return
// 提取纯文本排除插入的report标签
let text = ''
const walker = document.createTreeWalker(editorRef.value, NodeFilter.SHOW_TEXT, null)
let node: Node | null
while ((node = walker.nextNode())) {
text += node.textContent
}
// 移除末尾的空格
text = text.replace(/\s+$/, '')
inputValue.value = text
// 自动调整高度
autoResizeEditor()
}
const handleEditorPaste = (e: ClipboardEvent) => {
e.preventDefault()
const text = e.clipboardData?.getData('text/plain') || ''
document.execCommand('insertText', false, text)
}
const autoResizeEditor = () => {
const editor = editorRef.value
if (editor) {
if (props.isAgentMode) {
// editor.style.height = '6rem'
// editor.style.overflowY = 'auto'
return
} else {
// editor.style.height = 'auto'
// const maxHeight =
// 20 * parseFloat(getComputedStyle(document.documentElement).fontSize || '16')
// editor.style.height = Math.min(editor.scrollHeight, maxHeight) + 'px'
}
}
}
const handleKeyPress = (e) => {
// 检测回车
if (e.key === 'Enter') {
e.preventDefault()
handleSendAgent()
return
}
if (e.key === 'Backspace') {
const selection = window.getSelection()
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
if (range.collapsed) {
let nodeToDelete = null
const startContainer = range.startContainer
const startOffset = range.startOffset
if (startContainer.nodeType === Node.TEXT_NODE) {
// Cursor at the end of a text node, check next sibling
if (startOffset === startContainer.length) {
nodeToDelete = startContainer.nextSibling
}
} else if (startContainer.nodeType === Node.ELEMENT_NODE) {
// Cursor positioned between child nodes
nodeToDelete = startContainer.childNodes[startOffset]
}
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'))
) {
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)
}
return
}
}
}
}
}
const handleSendAgent = () => {
if (!inputValue.value.trim()) return
emits('send', { text: inputValue.value.trim(), images: uploadedImages.value })
// 发送后清空输入框
if (editorRef.value) {
editorRef.value.innerHTML = ''
}
inputValue.value = ''
}
// 监听 inputValue 外部变化
watch(inputValue, () => {
nextTick(() => {
autoResizeEditor()
})
})
// 初始化编辑器高度
onMounted(() => {
autoResizeEditor()
})
const typeValue = ref<string>('')
const areaValue = ref<string>('')
const styleValue = ref<string>('')
const tempSelectedValue = ref<string>('')
const stylePopupVisible = ref(false)
const settingPopupVisible = ref(false)
const settingOptions = ref([
{ label: 'Input.settingOptions.creativity', value: 50 },
{ label: 'Input.settingOptions.diversity', value: 75 },
{ label: 'Input.settingOptions.relevance', value: 60 }
])
const openStylePopup = () => {
// 打开弹窗时初始化临时选中值为当前选中值
tempSelectedValue.value = styleValue.value
stylePopupVisible.value = true
}
const selectStyle = (value: string) => {
tempSelectedValue.value = value
}
const confirmStyle = () => {
// 点击确认后才真正赋值
styleValue.value = tempSelectedValue.value
stylePopupVisible.value = false
}
const confirmSetting = () => {
settingPopupVisible.value = false
}
const typeOptions = ref<any[]>([
{
label: 'Input.types.sofa',
value: 'Sofa'
},
{
label: 'Input.types.desk',
value: 'Desk'
},
{
label: 'Input.types.chair',
value: 'Chair'
}
])
const areaOptions = ref<any[]>(areaList)
const styleOptions = ref<any[]>(
styleKeys.map((key) => ({
label: `Input.styles.${key}`,
value: key
}))
)
const handleCreateProject = () => {
// 这里可以添加创建项目的逻辑
const params = {
type: typeValue.value,
area: areaValue.value,
style: styleValue.value
}
// 保存初始数据到 store
agentStore.setInitialProjectData({
text: inputValue.value.trim(),
images: uploadedImages.value,
type: typeValue.value,
area: areaValue.value,
style: styleValue.value
})
console.log('Create project with:', params)
router.push('/home/agent', { query: params })
}
</script>
<style lang="less" scoped>
.assist-input-wrapper {
min-height: 23.5rem;
max-height: 43.5rem;
width: 106.3rem;
border-radius: 2.8rem;
background-color: #fff;
border: 0.1rem solid #00000005;
box-shadow: 0px 5px 14px 0px #0000001a;
margin: 0 auto;
padding: 0;
position: relative;
.report-btn {
position: absolute;
bottom: -7.4rem;
height: 4.4rem;
border-radius: 2.2rem;
width: 20rem;
background-color: #fff;
border: 1px solid #f6f4ef;
column-gap: 1.2rem;
cursor: pointer;
.c-svg {
width: 1.5rem;
height: 1.9rem;
}
}
.scroll-content {
display: flex;
flex: 1;
overflow-y: auto;
padding: 3.4rem 1.7rem 1.7rem;
}
.editor {
width: 100%;
flex: 1;
border: none;
outline: none;
padding: 0 1.4rem 1.4rem;
font-size: 2rem;
font-family: 'InterRegular';
font-weight: 400;
color: #000000;
overflow-y: auto;
overflow-x: hidden;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
// 占位符
&:empty::before {
content: attr(placeholder);
color: #999;
pointer-events: none;
}
}
// 图片预览区域样式
.image-preview-list {
padding: 0 1.4rem 1rem;
column-gap: 1rem;
max-height: 15rem;
overflow-y: auto;
flex-shrink: 0;
.image-preview-item {
position: relative;
width: 8.6rem;
height: 8.6rem;
border-radius: 1.5rem;
overflow: hidden;
flex-shrink: 0;
border: 0.1rem solid #cdcdcd;
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0.8rem;
}
.image-remove-btn {
position: absolute;
top: 0.2rem;
right: 0.2rem;
width: 1.6rem;
height: 1.6rem;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover .image-remove-btn {
opacity: 1;
}
}
}
.operate {
flex-shrink: 0;
margin-top: auto;
padding: 0 1.7rem 1.7rem;
.left {
column-gap: 2rem;
}
.attach {
width: 4rem;
height: 4rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
cursor: pointer;
}
.el-select {
width: 13.9rem;
height: 4rem;
:deep(.el-select__wrapper) {
border-radius: 0.8rem;
height: 100%;
box-shadow: none;
border: 0.1rem solid rgba(0, 0, 0, 0.1);
font-family: 'GeneralMedium';
font-weight: 500;
font-size: 1.4rem;
.el-select__placeholder {
color: #000;
}
.el-select__icon {
color: #000;
}
}
}
.fida-style-select-wrapper {
position: relative;
width: 13.9rem;
height: 4rem;
}
.fida-style-select-trigger {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
cursor: pointer;
}
.setting-icon {
width: 2.4rem;
height: 2.4rem;
cursor: pointer;
}
.create-btn {
background-color: #ff7a51;
height: 4rem;
width: 13rem;
color: #fff;
border-radius: 4.2rem;
font-family: 'Mazzard';
font-weight: 600;
font-size: 1.28rem;
cursor: pointer;
.shining-icon {
width: 1.4rem;
height: 1.4rem;
}
}
}
}
.input-option {
// padding: 0 1rem;
margin: 0 0.6rem;
padding: 0 0.8rem 0 1rem;
color: #0d0d0d;
font-weight: 510;
font-size: 1.3rem;
height: 3rem;
line-height: 3rem;
&.el-select-dropdown__item.is-hovering {
background-color: rgba(13, 13, 13, 0.02);
// border-radius: 0.6rem;
}
}
.agent {
padding: 1.2rem;
box-shadow: none;
border-radius: 1.5rem;
border: 0.1rem solid #0000001a;
.scroll-content {
padding: 0;
flex: 1;
overflow: auto;
.editor {
font-family: 'Regular';
font-weight: 400;
font-size: 1.22rem;
min-height: initial;
max-height: initial;
padding: 0;
height: 100%;
}
}
.operate {
padding: 1.2rem 0 0;
margin: 0;
.right {
display: flex;
align-items: center;
.sender-icon {
width: 3.2rem;
height: 3.2rem;
cursor: pointer;
}
}
}
}
</style>
<style lang="less">
.fida-style-select-popover {
width: 34.2rem !important;
padding: 0 !important;
border-radius: 0.6rem !important;
box-shadow: 0px 5px 20px 0px rgba(0, 0, 0, 0.15) !important;
background-color: #fff !important;
border: none !important;
}
.fida-style-popover-content {
padding: 2rem 2.4rem 2.4rem;
}
.fida-style-popover-header {
font-family: 'GeneralMedium';
font-weight: 500;
font-size: 1.6rem;
color: #000;
margin-bottom: 2rem;
padding: 2rem 2.4rem !important;
// padding: 1.8rem 2rem 1.5rem;
// border-bottom: 0.1rem solid #f0f0f0;
}
.fida-style-popover-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
height: 28.5rem;
overflow-y: auto;
// display: grid;
// grid-template-columns: repeat(3, 1fr);
// gap: 1rem;
}
.fida-style-popover-item {
height: 9.1rem;
width: 9.1rem;
background-color: #a6a6a6;
border-radius: 0.8rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
.checked-item-icon {
position: absolute;
bottom: 0;
right: 0;
transform: translate(50%, 50%);
width: 2.4rem;
height: 2.4rem;
}
}
.fida-style-popover-item:hover {
background-color: #e8e8e8;
}
.fida-style-popover-item.is-selected {
background-color: #e3f2fd;
.fida-option-label {
color: #000;
}
// border-color: #2196f3;
}
.fida-style-popover-item .fida-option-label {
font-family: 'GeneralMedium';
font-weight: 500;
font-size: 1.2rem;
color: #fff;
text-align: center;
padding: 0.5rem;
}
.fida-style-popover-footer {
// border-top: 0.1rem solid #f0f0f0;
padding: 2.4rem 0 !important;
margin-top: 2.4rem;
.fida-confirm-btn {
margin: 0 auto;
width: 15.7rem;
height: 3.4rem;
line-height: 3.4rem;
background-color: #ff7a51;
color: #fff;
border: none;
border-radius: 3.8rem;
font-family: 'GeneralMedium';
font-weight: 500;
font-size: 1.4rem;
cursor: pointer;
}
}
.fida-setting-popover {
padding: 0 !important;
border-radius: 0.6rem !important;
background-color: #fff !important;
border: none !important;
width: 25.6rem;
height: 23.9rem;
box-shadow: 0px 11px 20px 0px #0000001a;
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;
color: #000;
margin-bottom: 2rem !important;
}
.fida-setting-popover-content {
padding: 1.6rem 1.4rem 2.2rem !important;
}
.fida-setting-slider-list {
display: flex;
flex-direction: column;
row-gap: 1rem;
}
.fida-setting-slider-item {
.fida-slider-label {
font-weight: 400;
font-size: 1.2rem;
color: #000;
margin-bottom: 1rem;
}
.fida-slider-row {
column-gap: 2.6rem;
.el-slider {
flex: 1;
}
.fida-slider-value {
font-weight: 400;
font-size: 1.4rem;
color: #000;
}
}
}
.setting-popover-slider {
--el-slider-height: 0.4rem;
height: fit-content;
.el-slider__runway {
height: var(--el-slider-height);
background-color: #e8e8e8;
border-radius: 0.2rem;
}
.el-slider__bar {
height: var(--el-slider-height);
background-color: #000;
border-radius: 0.2rem;
}
.el-slider__button-wrapper {
width: fit-content;
height: fit-content;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
}
.el-slider__button {
width: 1rem;
height: 1rem;
background-color: #000;
border-radius: 50%;
border: none;
}
.el-slider__stop {
display: none;
}
}
/* 动态添加的编辑器标签样式 */
.assist-input-wrapper .editor .editor-tag {
width: 21.8rem;
height: 4.4rem;
display: inline-flex;
border: 0.11rem solid #0000001a;
font-family: 'GeneralMedium';
font-weight: 500;
font-size: 1.8rem;
column-gap: 0;
margin: 0 0.5rem;
vertical-align: middle;
border-radius: 2.2rem;
span {
margin: 0 0.7rem 0 1.2rem;
}
.light-icon {
width: 1.5rem;
height: 1.9rem;
flex-shrink: 0;
}
.close-icon {
width: 1rem;
height: 1rem;
cursor: pointer;
flex-shrink: 0;
}
}
</style>