From 6098993bb39263091d9cc546dcc66c832a04aea7 Mon Sep 17 00:00:00 2001 From: zhangyahui Date: Thu, 30 Apr 2026 14:18:19 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=8B=86=E5=88=86input=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/home/components/Input.vue | 1689 ++--------------- .../components/input/AgentOperatePopover.vue | 99 + .../home/components/input/InputEditor.vue | 231 +++ .../input/InputImagePreviewList.vue | 89 + .../home/components/input/InputToolbar.vue | 270 +++ .../components/input/ReportShortcutButton.vue | 46 + .../home/components/input/SettingPopover.vue | 160 ++ .../home/components/input/StyleSelect.vue | 222 +++ src/views/home/components/input/options.ts | 48 + src/views/home/components/input/types.ts | 19 + .../home/components/input/useInputEditor.ts | 527 +++++ .../home/components/input/useInputImages.ts | 78 + 12 files changed, 1958 insertions(+), 1520 deletions(-) create mode 100644 src/views/home/components/input/AgentOperatePopover.vue create mode 100644 src/views/home/components/input/InputEditor.vue create mode 100644 src/views/home/components/input/InputImagePreviewList.vue create mode 100644 src/views/home/components/input/InputToolbar.vue create mode 100644 src/views/home/components/input/ReportShortcutButton.vue create mode 100644 src/views/home/components/input/SettingPopover.vue create mode 100644 src/views/home/components/input/StyleSelect.vue create mode 100644 src/views/home/components/input/options.ts create mode 100644 src/views/home/components/input/types.ts create mode 100644 src/views/home/components/input/useInputEditor.ts create mode 100644 src/views/home/components/input/useInputImages.ts diff --git a/src/views/home/components/Input.vue b/src/views/home/components/Input.vue index 6a55191..3848b7f 100644 --- a/src/views/home/components/Input.vue +++ b/src/views/home/components/Input.vue @@ -2,249 +2,79 @@
-
-
- -
- -
-
-
-
-
- {{ $t('Input.placeholder') }} -
-
-
+ +
-
-
-
- - - - -
-
- -
- - - - - - - -
- - - -
-
- {{ $t('Input.chooseStyle') }} -
-
-
- - {{ - item.label - }} - -
-
- -
-
-
- - -
-
- {{ $t('Input.styleTitle') }} -
-
-
-
{{ $t(item.label) }}
-
- - {{ item.value }}% -
-
-
-
-
-
-
-
- - {{ $t('Input.createProject') }} -
- -
- -
-
-
-
+
-
- - {{ $t('Input.trendingReport') }} -
+ :is-cn="isCn" + :label="$t('Input.trendingReport')" + @click="toggleReportTag()" + />
- - + + diff --git a/src/views/home/components/input/InputEditor.vue b/src/views/home/components/input/InputEditor.vue new file mode 100644 index 0000000..6cfc18a --- /dev/null +++ b/src/views/home/components/input/InputEditor.vue @@ -0,0 +1,231 @@ + + + + + + + diff --git a/src/views/home/components/input/InputImagePreviewList.vue b/src/views/home/components/input/InputImagePreviewList.vue new file mode 100644 index 0000000..25b535a --- /dev/null +++ b/src/views/home/components/input/InputImagePreviewList.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/src/views/home/components/input/InputToolbar.vue b/src/views/home/components/input/InputToolbar.vue new file mode 100644 index 0000000..b68caa6 --- /dev/null +++ b/src/views/home/components/input/InputToolbar.vue @@ -0,0 +1,270 @@ + + + + + + + diff --git a/src/views/home/components/input/ReportShortcutButton.vue b/src/views/home/components/input/ReportShortcutButton.vue new file mode 100644 index 0000000..21154bb --- /dev/null +++ b/src/views/home/components/input/ReportShortcutButton.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/src/views/home/components/input/SettingPopover.vue b/src/views/home/components/input/SettingPopover.vue new file mode 100644 index 0000000..c687ac7 --- /dev/null +++ b/src/views/home/components/input/SettingPopover.vue @@ -0,0 +1,160 @@ + + + + + + + diff --git a/src/views/home/components/input/StyleSelect.vue b/src/views/home/components/input/StyleSelect.vue new file mode 100644 index 0000000..ff253a8 --- /dev/null +++ b/src/views/home/components/input/StyleSelect.vue @@ -0,0 +1,222 @@ + + + + + + + diff --git a/src/views/home/components/input/options.ts b/src/views/home/components/input/options.ts new file mode 100644 index 0000000..9fa9fcd --- /dev/null +++ b/src/views/home/components/input/options.ts @@ -0,0 +1,48 @@ +import type { OptionItem, SettingOption } from './types' + +export const styleKeys: string[] = [ + 'Venetian Modern', + 'Coastal', + 'Maximalism', + 'Memphis', + 'Verdant', + 'Century Chrome', + 'Modern Revival', + 'Transitional', + "Tuscan 2000's", + 'Kitsch-core', + 'Bauhaus', + 'Constructivism', + 'Nordic Noir', + 'Dopamine', + 'Squiggle' +] + +export const optionTagOrder = ['type', 'area', 'style'] as const + +export const createTypeOptions = (): OptionItem[] => [ + { + label: 'Input.types.sofa', + value: 'Sofa' + }, + { + label: 'Input.types.desk', + value: 'Desk' + }, + { + label: 'Input.types.chair', + value: 'Chair' + } +] + +export const createStyleOptions = (): OptionItem[] => + styleKeys.map((key) => ({ + label: key, + value: key + })) + +export const createSettingOptions = (): SettingOption[] => [ + { label: 'Input.settingOptions.first', value: 50 }, + { label: 'Input.settingOptions.second', value: 50 }, + { label: 'Input.settingOptions.third', value: 50 } +] diff --git a/src/views/home/components/input/types.ts b/src/views/home/components/input/types.ts new file mode 100644 index 0000000..b00c77e --- /dev/null +++ b/src/views/home/components/input/types.ts @@ -0,0 +1,19 @@ +export interface OptionItem { + label: string + value: string +} + +export interface SettingOption { + label: string + value: number +} + +export interface UploadedImage { + url: string + name: string + path?: any +} + +export type PreviewImage = UploadedImage | string + +export type OptionTagKind = 'type' | 'area' | 'style' diff --git a/src/views/home/components/input/useInputEditor.ts b/src/views/home/components/input/useInputEditor.ts new file mode 100644 index 0000000..af888cc --- /dev/null +++ b/src/views/home/components/input/useInputEditor.ts @@ -0,0 +1,527 @@ +import { computed, nextTick, ref } from 'vue' +import type { ComputedRef, Ref } from 'vue' +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 { optionTagOrder } from './options' +import type { OptionItem, OptionTagKind } from './types' + +interface UseInputEditorOptions { + isAgentMode: Ref | ComputedRef + t: (key: string) => string + typeValue: Ref + areaValue: Ref + styleValue: Ref + typeOptions: Ref + areaOptions: Ref + styleOptions: Ref + onSubmit: () => void | Promise +} + +export function useInputEditor(options: UseInputEditorOptions) { + const editorRef = ref(null) + const inputValue = ref('') + const reportTags = ref([]) + const optionTags = ref>>({}) + const reportPromptText = ref(null) + let reportTypewriterTimeout: ReturnType | null = null + + const setEditorElement = (element: HTMLDivElement | null) => { + editorRef.value = element + } + + const focusEditor = () => { + editorRef.value?.focus() + } + + const stopReportTypewriter = () => { + if (reportTypewriterTimeout) { + clearTimeout(reportTypewriterTimeout) + reportTypewriterTimeout = null + } + } + + const moveCaretToTextEnd = (textNode: Text) => { + const selection = window.getSelection() + if (!selection || selection.anchorNode !== textNode) return + + const range = document.createRange() + range.setStart(textNode, textNode.data.length) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } + + const typeReportPromptText = (textNode: Text, text: string, index = 0) => { + if (reportPromptText.value !== textNode) return + + if (index >= text.length) { + reportTypewriterTimeout = null + return + } + + textNode.textContent = `${textNode.textContent || ''}${text.charAt(index)}` + handleEditorInput() + moveCaretToTextEnd(textNode) + + reportTypewriterTimeout = setTimeout(() => { + typeReportPromptText(textNode, text, index + 1) + }, 30) + } + + const removeReportPromptText = () => { + stopReportTypewriter() + const promptText = reportPromptText.value + if (promptText?.parentNode) { + const nextNode = promptText.nextSibling + if ( + nextNode && + nextNode.nodeType === Node.TEXT_NODE && + nextNode.textContent === '\u200B' + ) { + nextNode.remove() + } + promptText.parentNode.removeChild(promptText) + } + 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 options.typeValue.value + if (kind === 'area') return options.areaValue.value + return options.styleValue.value + } + + const getOptionTagIcon = (kind: OptionTagKind) => { + if (kind === 'type') return TypeIcon + if (kind === 'area') return RegionIcon + return StyleIcon + } + + const getTranslatedOptionLabel = (items: OptionItem[], value: string) => { + const option = items.find((item) => item.value === value) + return option ? options.t(option.label) : value + } + + const getOptionTagLabel = (kind: OptionTagKind) => { + const value = getOptionTagValue(kind) + if (kind === 'type') return getTranslatedOptionLabel(options.typeOptions.value, value) + if (kind === 'area') return getTranslatedOptionLabel(options.areaOptions.value, value) + return options.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') { + options.typeValue.value = '' + } else if (kind === 'area') { + options.areaValue.value = '' + } else { + options.styleValue.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 || hasOptionTags()) return false + + const editor = editorRef.value + const textContent = editor.textContent?.replace(/[\s\u200B\n]/g, '').trim() || '' + const isEmptyHTML = editor.innerHTML === '' || editor.innerHTML === '
' + const hasMeaningfulChildren = Array.from(editor.children).some( + (child) => child.tagName !== 'BR' && !child.classList.contains('editor-tag') + ) + + return textContent === '' && !hasMeaningfulChildren && isEmptyHTML + }) + + const addReportTag = (text?: string) => { + const tagText = text || options.t('Input.trendingReport') + + const tag = document.createElement('div') + tag.contentEditable = 'false' + const imgLeft = document.createElement('img') + const imgClose = document.createElement('img') + const textSpan = document.createElement('span') + imgClose.className = 'close-icon' + if (text) { + tag.className = 'editor-tag restore flex-center' + imgLeft.className = 'restore-icon' + imgLeft.src = restoreIcon as unknown as string + imgClose.src = restoreCloseIcon as unknown as string + imgClose.className = 'close-icon restore' + textSpan.className = 'restore-text' + } else { + tag.className = 'editor-tag report-btn flex-center' + imgLeft.className = 'light-icon' + imgLeft.src = lightIcon as unknown as string + imgClose.src = closeIcon as unknown as string + } + + textSpan.innerText = tagText + + imgClose.addEventListener('click', (ev) => { + ev.stopPropagation() + removeReportPromptText() + tag.remove() + const idx = reportTags.value.indexOf(tag) + if (idx > -1) reportTags.value.splice(idx, 1) + handleEditorInput() + }) + + tag.appendChild(imgLeft) + tag.appendChild(textSpan) + tag.appendChild(imgClose) + + const selection = window.getSelection() + const isInEditor = + editorRef.value && selection && editorRef.value.contains(selection.anchorNode) + + if (isInEditor && selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0) + range.insertNode(tag) + focusEditor() + } else if (editorRef.value) { + editorRef.value.appendChild(tag) + focusEditor() + } + + reportTags.value.push(tag) + const promptText = document.createTextNode('') + if (tag.parentNode) { + tag.parentNode.insertBefore(promptText, tag.nextSibling) + const zwsp = document.createTextNode('\u200B') + tag.parentNode.insertBefore(zwsp, promptText.nextSibling) + const newRange = document.createRange() + newRange.setStart(promptText, promptText.data.length) + newRange.collapse(true) + const currentSelection = window.getSelection() + currentSelection?.removeAllRanges() + currentSelection?.addRange(newRange) + } + reportPromptText.value = promptText + typeReportPromptText(promptText, options.t('Input.reportPlaceholder')) + } + + const toggleReportTag = (clear = false) => { + const shouldClear = clear === true + reportTags.value = reportTags.value.filter((tag) => tag.parentNode !== null) + + if (shouldClear) { + removeReportPromptText() + reportTags.value.forEach((tag) => { + tag.remove() + }) + reportTags.value = [] + return + } + + if (reportTags.value.length > 0) { + removeReportPromptText() + reportTags.value.forEach((tag) => { + if ( + tag.nextSibling && + tag.nextSibling.nodeType === Node.TEXT_NODE && + tag.nextSibling.textContent === '\u200B' + ) { + tag.nextSibling.remove() + } + tag.remove() + }) + reportTags.value = [] + handleEditorInput() + } 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 = [] + removeAllOptionTags() + stopReportTypewriter() + reportPromptText.value = null + } + } + } + + const autoResizeEditor = () => { + if (options.isAgentMode.value) return + } + + const handleEditorInput = () => { + if (!editorRef.value) return + + let text = '' + const walker = document.createTreeWalker(editorRef.value, NodeFilter.SHOW_TEXT, null) + + let node: Node | null + while ((node = walker.nextNode())) { + if (node.parentElement?.closest('.editor-tag')) continue + text += node.textContent + } + + 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 + + if (!hasChildElements && !hasTextContent) { + editor.innerHTML = '' + reportTags.value = [] + removeAllOptionTags() + stopReportTypewriter() + reportPromptText.value = null + } + + autoResizeEditor() + } + + const handleEditorPaste = (e: ClipboardEvent) => { + e.preventDefault() + const text = e.clipboardData?.getData('text/plain') || '' + document.execCommand('insertText', false, text) + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + options.onSubmit() + return + } + if (e.key !== 'Backspace') return + + const selection = window.getSelection() + if (!selection || selection.rangeCount <= 0) return + + const range = selection.getRangeAt(0) + if (!range.collapsed) return + + let nodeToDelete: Node | null = null + const startContainer = range.startContainer + const startOffset = range.startOffset + + if (startContainer.nodeType === Node.TEXT_NODE) { + const textNode = startContainer as Text + if (startOffset === textNode.length) { + nodeToDelete = textNode.nextSibling + } + } else if (startContainer.nodeType === Node.ELEMENT_NODE) { + nodeToDelete = startContainer.childNodes[startOffset] + } + + if ( + nodeToDelete && + nodeToDelete.nodeType === Node.ELEMENT_NODE && + (nodeToDelete as Element).classList + ) { + 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() + if (reportTags.value.includes(element as HTMLElement)) { + removeReportPromptText() + } + element.remove() + + const index = reportTags.value.indexOf(nodeToDelete as HTMLElement) + if (index > -1) { + reportTags.value.splice(index, 1) + } + + nextTick(() => { + cleanupEditor() + handleEditorInput() + }) + return + } + } + + nextTick(() => { + if (editorRef.value) { + const text = editorRef.value.textContent || '' + const cleanedText = text.replace(/[\u200B-\u200D\uFEFF]/g, '') + + if (editorRef.value.innerHTML === '
' || !cleanedText.trim()) { + editorRef.value.innerHTML = '' + if (editorRef.value.children.length === 0) { + reportTags.value = [] + reportPromptText.value = null + } + } + handleEditorInput() + } + }) + } + + const clearEditorText = () => { + if (editorRef.value) { + editorRef.value.innerHTML = '' + } + inputValue.value = '' + } + + const resetOptionTags = () => { + optionTags.value = {} + } + + return { + editorRef, + inputValue, + reportTags, + showPlaceholder, + setEditorElement, + focusEditor, + stopReportTypewriter, + addReportTag, + toggleReportTag, + handleEditorInput, + handleEditorPaste, + handleKeyDown, + autoResizeEditor, + syncOptionTag, + syncAllOptionTags, + clearEditorText, + resetOptionTags + } +} diff --git a/src/views/home/components/input/useInputImages.ts b/src/views/home/components/input/useInputImages.ts new file mode 100644 index 0000000..58abbe7 --- /dev/null +++ b/src/views/home/components/input/useInputImages.ts @@ -0,0 +1,78 @@ +import { nextTick, ref } from 'vue' +import { uploadImage } from '@/api/upload' +import type { PreviewImage, UploadedImage } from './types' + +export function useInputImages(focusEditor: () => void) { + const uploadedImages = ref([]) + const quoteList = ref([]) + const showPreview = ref(false) + const previewUrl = ref('') + + 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 formData = new FormData() + formData.append('file', file) + + uploadImage(formData).then((res) => { + const reader = new FileReader() + reader.onload = (e) => { + uploadedImages.value.push({ + url: e.target?.result as string, + name: file.name, + path: res + }) + } + reader.readAsDataURL(file) + }) + } + }) + } + + nextTick(focusEditor) + input.value = '' + } + + const removeImage = (index: number, item: PreviewImage) => { + if (typeof item === 'string') { + const quoteIndex = quoteList.value.indexOf(item) + if (quoteIndex > -1) { + quoteList.value.splice(quoteIndex, 1) + } + return + } + + uploadedImages.value.splice(index, 1) + } + + const previewImage = (url: string) => { + showPreview.value = true + previewUrl.value = url + } + + const handleQuote = (url: string) => { + const hasQuoted = quoteList.value.includes(url) + if (hasQuoted) return + quoteList.value[0] = url + } + + const clearImages = () => { + uploadedImages.value = [] + quoteList.value = [] + } + + return { + uploadedImages, + quoteList, + showPreview, + previewUrl, + handleFileChange, + removeImage, + previewImage, + handleQuote, + clearImages + } +}