feat: 标签插入

This commit is contained in:
2026-04-30 13:57:41 +08:00
parent b55a5ba896
commit c3d26bdb49
5 changed files with 261 additions and 175 deletions

View File

@@ -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 {