tangning 18 часов назад
Родитель
Сommit
c172dc521a

+ 3 - 1
package.json

@@ -12,6 +12,7 @@
   "dependencies": {
     "@ant-design/icons-vue": "^7.0.1",
     "@types/three": "^0.169.0",
+    "@vueup/vue-quill": "^1.2.0",
     "ant-design-vue": "4.1.0",
     "axios": "^0.27.2",
     "coordtransform": "^2.1.2",
@@ -20,6 +21,7 @@
     "konva": "^9.3.18",
     "less": "^4.1.3",
     "mitt": "^3.0.0",
+    "quill": "^2.0.3",
     "simaqcore": "^1.2.0",
     "swiper": "^11.1.15",
     "three": "^0.169.0",
@@ -39,4 +41,4 @@
     "vite": "^3.0.0",
     "vue-tsc": "^0.38.4"
   }
-}
+}

+ 5 - 0
src/assets/style/global.less

@@ -47,4 +47,9 @@ button, input, select, textarea {
 
 .cesium-widget-credits {
   display: none !important;
+}
+.small-button{
+  width: 80px;
+  font-size: 12px;
+  height: 28px;
 }

+ 766 - 0
src/components/bill-ui/components/input/richInput copy.vue

@@ -0,0 +1,766 @@
+<template>
+    <div
+        class="input textarea"
+        :class="{ suffix: showBar, disabled, right, 'has-toolbar': enableToolbar }"
+        ref="textRef"
+    >
+        <div
+            :contenteditable="canEdit"
+            class="ui-text input-div"
+            @input="inputHandler"
+            :placeholder="props.placeholder"
+            @click="emit('click')"
+            @focus="focusHandler"
+            @blur="blurHandler"
+            @paste="pasteHandler"
+            @compositionstart="compositionstartHandler"
+            @compositionend="compositionendHandler"
+            @mouseup="selectionUpdate"
+            @keyup="selectionUpdate"
+            ref="inputRef"
+            v-bind="other"
+        />
+        <span class="replace"></span>
+        <span v-if="showBar" class="retouch">
+            <div v-if="enableToolbar" class="toolbar" @mousedown.prevent>
+                <div class="toolbar-item dropdown font-size" :class="{ open: showFontSizeMenu }" @mousedown.prevent="toggleFontSizeMenu">
+                    <span class="dropdown-value">{{ currentFontSize }}</span>
+                    <span class="dropdown-arrow">▾</span>
+                    <div v-if="showFontSizeMenu" class="dropdown-menu" @mousedown.prevent>
+                        <div
+                            v-for="size in fontSizeOptions"
+                            :key="size"
+                            class="dropdown-option"
+                            :class="{ active: size === currentFontSize }"
+                            @mousedown.prevent="applyFontSize(size)"
+                        >
+                            {{ size }}
+                        </div>
+                    </div>
+                </div>
+
+                <button
+                    class="toolbar-item btn"
+                    type="button"
+                    :class="{ active: isBold }"
+                    :disabled="!canEdit"
+                    @mousedown.prevent="toggleBold"
+                >
+                    B
+                </button>
+
+                <button
+                    class="toolbar-item btn"
+                    type="button"
+                    :disabled="!canEdit"
+                    @mousedown.prevent="triggerTextColor"
+                >
+                    <span class="a-letter" :style="{ '--a-color': textColor }">A</span>
+                </button>
+                <input ref="textColorInputRef" class="color-input" type="color" :value="textColor" @input="applyTextColor" />
+
+                <button
+                    class="toolbar-item btn"
+                    type="button"
+                    :disabled="!canEdit"
+                    @mousedown.prevent="triggerHighlightColor"
+                >
+                    <span class="hl-icon" :style="{ '--hl-color': highlightColor }"></span>
+                </button>
+                <input ref="highlightColorInputRef" class="color-input" type="color" :value="highlightColor" @input="applyHighlightColor" />
+
+                <div class="toolbar-item dropdown align-menu" :class="{ open: showAlignMenu }" @mousedown.prevent="toggleAlignMenu">
+                    <span class="align-icon">{{ alignLabel }}</span>
+                    <span class="dropdown-arrow">▾</span>
+                    <div v-if="showAlignMenu" class="dropdown-menu" @mousedown.prevent>
+                        <div class="dropdown-option" :class="{ active: align === 'left' }" @mousedown.prevent="applyAlign('left')">左对齐</div>
+                        <div class="dropdown-option" :class="{ active: align === 'center' }" @mousedown.prevent="applyAlign('center')">居中</div>
+                        <div class="dropdown-option" :class="{ active: align === 'right' }" @mousedown.prevent="applyAlign('right')">右对齐</div>
+                    </div>
+                </div>
+
+                <div class="toolbar-item link">
+                    <button class="btn link-btn-trigger" type="button" :disabled="!canEdit" @mousedown.prevent="openLinkInput">
+                        链接
+                    </button>
+                    <div v-if="showLinkInput" class="link-pop" @mousedown.prevent>
+                        <input class="link-input" v-model="linkValue" placeholder="请输入链接" />
+                        <button class="link-btn" type="button" @mousedown.prevent="confirmLink">确认</button>
+                    </div>
+                </div>
+            </div>
+            <slot v-else name="icon" />
+            <span v-if="props.maxlength" class="len">
+                <span>{{ length }}</span> / {{ maxlength }}
+            </span>
+        </span>
+    </div>
+</template>
+
+<script setup>
+import { richtextPropsDesc } from './state'
+import { computed, nextTick, onMounted, onUnmounted, ref, useSlots, watchEffect } from 'vue'
+const props = defineProps({
+    ...richtextPropsDesc,
+})
+
+const emit = defineEmits(['update:modelValue', 'focus', 'blur', 'click', 'updatePos'])
+const slots = useSlots()
+const textRef = ref(null)
+const inputRef = ref(null)
+const length = ref(0)
+const textColor = ref('#ffffff')
+const highlightColor = ref('#ffe58f')
+const textColorInputRef = ref(null)
+const highlightColorInputRef = ref(null)
+const showFontSizeMenu = ref(false)
+const showAlignMenu = ref(false)
+const showLinkInput = ref(false)
+const linkValue = ref('')
+const savedRange = ref(null)
+const isBold = ref(false)
+const align = ref('left')
+const currentFontSize = ref(12)
+
+const enableToolbar = computed(() => !!props.rich)
+const showBar = computed(() => enableToolbar.value || !!props.maxlength || !!slots.icon)
+const canEdit = computed(() => !props.disabled && !props.readonly)
+
+const fontSizeOptions = computed(() => Array.from({ length: 37 }, (_, idx) => idx + 12))
+const alignLabel = computed(() => (align.value === 'center' ? '居中' : align.value === 'right' ? '右' : '左'))
+
+const getTextLen = (text = '') => text.replace(/[\u200B\uFEFF]/g, '').length
+const normalizeUrl = raw => {
+    const val = String(raw || '').trim()
+    if (!val) return ''
+    const low = val.toLowerCase()
+    if (low.startsWith('javascript:') || low.startsWith('data:')) return ''
+    if (/^(https?:\/\/|mailto:|tel:)/i.test(val)) return val
+    return 'https://' + val
+}
+
+const syncFromDom = () => {
+    if (!inputRef.value) return
+    length.value = getTextLen(inputRef.value.textContent || '')
+    emit('update:modelValue', inputRef.value.innerHTML)
+}
+
+const saveSelection = () => {
+    const sel = window.getSelection?.()
+    if (!sel || sel.rangeCount === 0) return
+    const range = sel.getRangeAt(0)
+    if (!inputRef.value || !inputRef.value.contains(range.commonAncestorContainer)) return
+    savedRange.value = range.cloneRange()
+}
+
+const restoreSelection = () => {
+    if (!savedRange.value || !window.getSelection || !inputRef.value) return false
+    const range = savedRange.value
+    if (range?.startContainer?.isConnected === false || range?.endContainer?.isConnected === false) {
+        savedRange.value = null
+        return false
+    }
+    if (!inputRef.value.contains(range.commonAncestorContainer)) return false
+    const sel = window.getSelection()
+    if (!sel) return false
+    try {
+        sel.removeAllRanges()
+        sel.addRange(range)
+        return true
+    } catch (e) {
+        savedRange.value = null
+        return false
+    }
+}
+
+const getAlignFromRange = range => {
+    if (!inputRef.value || !window.getComputedStyle) return null
+    let node = range?.startContainer
+    if (!node) return null
+    if (node.nodeType === Node.TEXT_NODE) node = node.parentElement
+    if (!(node instanceof Element)) return null
+
+    let cur = node
+    while (cur && cur !== inputRef.value) {
+        const style = window.getComputedStyle(cur)
+        const display = style.display
+        if (display === 'block' || display === 'list-item') {
+            const v = style.textAlign
+            if (v === 'center') return 'center'
+            if (v === 'right' || v === 'end') return 'right'
+            if (v === 'left' || v === 'start' || v === 'justify') return 'left'
+        }
+        cur = cur.parentElement
+    }
+
+    const v = window.getComputedStyle(inputRef.value).textAlign
+    if (v === 'center') return 'center'
+    if (v === 'right' || v === 'end') return 'right'
+    if (v === 'left' || v === 'start' || v === 'justify') return 'left'
+    return null
+}
+
+const selectionUpdate = () => {
+    const sel = window.getSelection?.()
+    if (!sel || sel.rangeCount === 0) return
+    const range = sel.getRangeAt(0)
+    if (!inputRef.value || !inputRef.value.contains(range.commonAncestorContainer)) return
+    savedRange.value = range.cloneRange()
+    if (!document.queryCommandState) return
+    try {
+        isBold.value = document.queryCommandState('bold')
+        if (document.queryCommandState('justifyCenter')) align.value = 'center'
+        else if (document.queryCommandState('justifyRight')) align.value = 'right'
+        else if (document.queryCommandState('justifyLeft')) align.value = 'left'
+    } catch (e) {}
+    const nextAlign = getAlignFromRange(range)
+    if (nextAlign) align.value = nextAlign
+}
+
+const focusEditor = () => {
+    if (!inputRef.value) return
+    inputRef.value.focus()
+}
+
+const ensureEditorSelection = () => {
+    if (!inputRef.value) return false
+    focusEditor()
+    const sel = window.getSelection?.()
+    if (!sel) return false
+    if (sel.rangeCount > 0) {
+        const range = sel.getRangeAt(0)
+        if (inputRef.value.contains(range.commonAncestorContainer)) {
+            savedRange.value = range.cloneRange()
+            return true
+        }
+    }
+    if (restoreSelection()) return true
+    const range = document.createRange()
+    range.selectNodeContents(inputRef.value)
+    range.collapse(false)
+    try {
+        sel.removeAllRanges()
+        sel.addRange(range)
+        savedRange.value = range.cloneRange()
+        return true
+    } catch (e) {
+        return false
+    }
+}
+
+const wrapSelectionWithStyle = style => {
+    if (!canEdit.value) return
+    ensureEditorSelection()
+    const sel = window.getSelection?.()
+    if (!sel || sel.rangeCount === 0) return
+    const range = sel.getRangeAt(0)
+    if (!inputRef.value || !inputRef.value.contains(range.commonAncestorContainer)) return
+
+    const span = document.createElement('span')
+    Object.keys(style).forEach(key => {
+        span.style[key] = style[key]
+    })
+
+    if (range.collapsed) {
+        span.appendChild(document.createTextNode('\u200B'))
+        range.insertNode(span)
+        const nextRange = document.createRange()
+        nextRange.setStart(span.firstChild, 1)
+        nextRange.collapse(true)
+        sel.removeAllRanges()
+        sel.addRange(nextRange)
+        savedRange.value = nextRange.cloneRange()
+    } else {
+        const content = range.extractContents()
+        span.appendChild(content)
+        range.insertNode(span)
+        const nextRange = document.createRange()
+        nextRange.selectNodeContents(span)
+        nextRange.collapse(false)
+        sel.removeAllRanges()
+        sel.addRange(nextRange)
+        savedRange.value = nextRange.cloneRange()
+    }
+
+    syncFromDom()
+    selectionUpdate()
+}
+
+const toggleFontSizeMenu = () => {
+    if (!enableToolbar.value) return
+    saveSelection()
+    showFontSizeMenu.value = !showFontSizeMenu.value
+    showAlignMenu.value = false
+    showLinkInput.value = false
+}
+const applyFontSize = size => {
+    currentFontSize.value = Number(size)
+    showFontSizeMenu.value = false
+    wrapSelectionWithStyle({ fontSize: currentFontSize.value + 'px' })
+}
+
+const toggleBold = () => {
+    if (!canEdit.value) return
+    ensureEditorSelection()
+    document.execCommand?.('bold')
+    nextTick(() => {
+        syncFromDom()
+        selectionUpdate()
+    })
+}
+
+const triggerTextColor = () => {
+    if (!canEdit.value) return
+    saveSelection()
+    textColorInputRef.value?.click?.()
+}
+const applyTextColor = ev => {
+    if (!canEdit.value) return
+    const val = ev?.target?.value
+    if (!val) return
+    textColor.value = val
+    ensureEditorSelection()
+    document.execCommand?.('styleWithCSS', false, true)
+    document.execCommand?.('foreColor', false, val)
+    nextTick(() => {
+        syncFromDom()
+        selectionUpdate()
+    })
+}
+
+const triggerHighlightColor = () => {
+    if (!canEdit.value) return
+    saveSelection()
+    highlightColorInputRef.value?.click?.()
+}
+const applyHighlightColor = ev => {
+    if (!canEdit.value) return
+    const val = ev?.target?.value
+    if (!val) return
+    highlightColor.value = val
+    ensureEditorSelection()
+    document.execCommand?.('styleWithCSS', false, true)
+    document.execCommand?.('hiliteColor', false, val) || document.execCommand?.('backColor', false, val)
+    nextTick(() => {
+        syncFromDom()
+        selectionUpdate()
+    })
+}
+
+const toggleAlignMenu = () => {
+    if (!enableToolbar.value) return
+    saveSelection()
+    showAlignMenu.value = !showAlignMenu.value
+    showFontSizeMenu.value = false
+    showLinkInput.value = false
+}
+const applyAlign = mode => {
+    if (!canEdit.value) return
+    align.value = mode
+    showAlignMenu.value = false
+    ensureEditorSelection()
+    const cmd = mode === 'center' ? 'justifyCenter' : mode === 'right' ? 'justifyRight' : 'justifyLeft'
+    document.execCommand?.(cmd)
+    nextTick(() => {
+        syncFromDom()
+        selectionUpdate()
+    })
+}
+
+const openLinkInput = () => {
+    if (!canEdit.value) return
+    saveSelection()
+    showLinkInput.value = true
+    showFontSizeMenu.value = false
+    showAlignMenu.value = false
+    nextTick(() => {
+        const el = textRef.value?.querySelector?.('.link-input')
+        el?.focus?.()
+    })
+}
+const confirmLink = () => {
+    if (!canEdit.value) return
+    const url = normalizeUrl(linkValue.value)
+    showLinkInput.value = false
+    linkValue.value = ''
+    if (!url) return
+    ensureEditorSelection()
+    const sel = window.getSelection?.()
+    if (!sel || sel.rangeCount === 0) return
+    const range = sel.getRangeAt(0)
+    if (!inputRef.value || !inputRef.value.contains(range.commonAncestorContainer)) return
+
+    if (range.collapsed) {
+        const a = document.createElement('a')
+        a.href = url
+        a.target = '_blank'
+        a.rel = 'noopener noreferrer'
+        a.textContent = url
+        range.insertNode(a)
+        const nextRange = document.createRange()
+        nextRange.setStartAfter(a)
+        nextRange.collapse(true)
+        sel.removeAllRanges()
+        sel.addRange(nextRange)
+        savedRange.value = nextRange.cloneRange()
+        syncFromDom()
+        selectionUpdate()
+        return
+    }
+
+    try {
+        document.execCommand?.('createLink', false, url)
+        inputRef.value.querySelectorAll('a').forEach(a => {
+            a.target = '_blank'
+            a.rel = 'noopener noreferrer'
+        })
+        nextTick(() => {
+            syncFromDom()
+            selectionUpdate()
+        })
+    } catch (e) {}
+}
+
+const updateContent = html => {
+    if (!inputRef.value) return
+    inputRef.value.innerHTML = html || ''
+    length.value = getTextLen(inputRef.value.textContent || '')
+}
+
+watchEffect(() => {
+    if (inputRef.value && props.modelValue !== inputRef.value.innerHTML) {
+        updateContent(props.modelValue)
+    }
+})
+
+let inComposition = false
+const compositionstartHandler = () => {
+    inComposition = true
+}
+const compositionendHandler = ev => {
+    inComposition = false
+    inputHandler(ev)
+}
+
+const inputHandler = ev => {
+    if (inComposition) return
+    const nextLen = getTextLen(ev.target.textContent || '')
+    if (!props.maxlength || nextLen <= Number(props.maxlength)) {
+        length.value = nextLen
+        emit('update:modelValue', ev.target.innerHTML || '')
+    } else {
+        nextTick(() => {
+            if (ev.target.innerHTML !== props.modelValue.toString()) {
+                updateContent(props.modelValue.toString())
+                inputFocus()
+            }
+        })
+    }
+    nextTick(selectionUpdate)
+}
+//获取当前光标位置
+const getCursortPosition = function (element = inputRef.value) {
+    var caretOffset = 0
+    var doc = element.ownerDocument || element.document
+    var win = doc.defaultView || doc.parentWindow
+    var sel
+    if (typeof win.getSelection != 'undefined') {
+        //谷歌、火狐
+        sel = win.getSelection()
+        if (sel.rangeCount > 0) {
+            //选中的区域
+            var range = win.getSelection().getRangeAt(0)
+            var preCaretRange = range.cloneRange() //克隆一个选中区域
+            preCaretRange.selectNodeContents(element) //设置选中区域的节点内容为当前节点
+            preCaretRange.setEnd(range.endContainer, range.endOffset) //重置选中区域的结束位置
+            caretOffset = preCaretRange.toString().length
+        }
+    } else if ((sel = doc.selection) && sel.type != 'Control') {
+        //IE
+        var textRange = sel.createRange()
+        var preCaretTextRange = doc.body.createTextRange()
+        preCaretTextRange.moveToElementText(element)
+        preCaretTextRange.setEndPoint('EndToEnd', textRange)
+        caretOffset = preCaretTextRange.text.length
+    }
+    return caretOffset
+}
+
+let interval
+const focusHandler = ev => {
+    clearInterval(interval)
+    interval = setInterval(() => {
+        emit('updatePos', getCursortPosition())
+    }, 100)
+    emit('focus')
+    nextTick(selectionUpdate)
+}
+const blurHandler = () => {
+    clearInterval(interval)
+    emit('blur')
+}
+
+const inputFocus = () => {
+    inputRef.value.focus()
+    const range = window.getSelection()
+    range.selectAllChildren(inputRef.value)
+    range.collapseToEnd()
+}
+
+const getPasteText = text => {
+    if (!props.maxlength) {
+        return text
+    }
+
+    const $el = document.createElement('div')
+    $el.innerHTML = text
+    const allowLen = Number(props.maxlength) - length.value
+    const rawText = $el.textContent || ''
+    if (getTextLen(rawText) > allowLen) {
+        return rawText.replace(/[\u200B\uFEFF]/g, '').substring(0, allowLen)
+    } else {
+        return text
+    }
+}
+
+const pasteHandler = event => {
+    event.preventDefault()
+    var text
+    var clp = (event.originalEvent || event).clipboardData
+    // 兼容针对于opera ie等浏览器
+    if (clp === undefined || clp === null) {
+        text = window.clipboardData.getData('text') || ''
+        if (text !== '') {
+            if (window.getSelection) {
+                // 针对于ie11 10 9 safari
+                var newNode = document.createElement('span')
+                newNode.innerHTML = getPasteText(text)
+                window.getSelection().getRangeAt(0).insertNode(newNode)
+            } else {
+                document.selection.createRange().pasteHTML(text)
+            }
+        }
+    } else {
+        // 兼容chorme或hotfire
+        text = clp.getData('text/plain') || ''
+        if (text !== '') {
+            document.execCommand('insertText', false, getPasteText(text))
+        }
+    }
+}
+
+const onDocMousedown = ev => {
+    const root = textRef.value
+    if (!root) return
+    const target = ev.target
+    if (showFontSizeMenu.value) {
+        const el = root.querySelector('.font-size')
+        if (!el || !el.contains(target)) showFontSizeMenu.value = false
+    }
+    if (showAlignMenu.value) {
+        const el = root.querySelector('.align-menu')
+        if (!el || !el.contains(target)) showAlignMenu.value = false
+    }
+    if (showLinkInput.value) {
+        const pop = root.querySelector('.link-pop')
+        const btn = root.querySelector('.link-btn-trigger')
+        if ((!pop || !pop.contains(target)) && (!btn || !btn.contains(target))) showLinkInput.value = false
+    }
+
+    if (!root.contains(target)) {
+        savedRange.value = null
+        window.getSelection?.()?.removeAllRanges?.()
+    }
+}
+
+onMounted(() => {
+    document.addEventListener('mousedown', onDocMousedown)
+})
+onUnmounted(() => {
+    document.removeEventListener('mousedown', onDocMousedown)
+})
+
+defineExpose({
+    root: textRef,
+    input: inputRef,
+    getCursortPosition: getCursortPosition,
+})
+</script>
+
+<style scoped>
+.toolbar {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    min-width: 0;
+}
+
+.btn {
+    border: none;
+    background: transparent;
+    color: rgba(255, 255, 255, 0.8);
+    padding: 0 6px;
+    height: 22px;
+    line-height: 22px;
+    border-radius: 4px;
+    cursor: pointer;
+    font-weight: 600;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    user-select: none;
+}
+
+.btn:disabled {
+    opacity: 0.4;
+    cursor: not-allowed;
+}
+
+.btn.active {
+    color: var(--colors-primary-base);
+    background-color: rgba(var(--colors-primary-fill), 0.18);
+}
+
+.dropdown {
+    position: relative;
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    padding: 0 6px;
+    height: 22px;
+    border-radius: 4px;
+    cursor: pointer;
+    color: rgba(255, 255, 255, 0.8);
+    background: rgba(255, 255, 255, 0.06);
+    border: 1px solid rgba(255, 255, 255, 0.08);
+    user-select: none;
+}
+
+.dropdown-value {
+    font-size: 12px;
+    width: 24px;
+    text-align: center;
+}
+
+.dropdown-arrow {
+    font-size: 10px;
+    opacity: 0.7;
+}
+
+.dropdown-menu {
+    position: absolute;
+    bottom: calc(100% + 6px);
+    left: 0;
+    min-width: 92px;
+    max-height: 220px;
+    overflow: auto;
+    background: rgba(27, 27, 28, 0.95);
+    border: 1px solid rgba(255, 255, 255, 0.1);
+    border-radius: 6px;
+    padding: 6px 0;
+    z-index: 2;
+    backdrop-filter: blur(6px);
+}
+
+.dropdown-option {
+    height: 28px;
+    line-height: 28px;
+    padding: 0 10px;
+    font-size: 12px;
+    color: rgba(255, 255, 255, 0.85);
+    cursor: pointer;
+}
+
+.dropdown-option:hover {
+    background: rgba(255, 255, 255, 0.06);
+}
+
+.dropdown-option.active {
+    color: var(--colors-primary-base);
+}
+
+.a-letter {
+    position: relative;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 14px;
+    height: 14px;
+    color: rgba(255, 255, 255, 0.95);
+}
+.a-letter::after {
+    content: '';
+    position: absolute;
+    left: 1px;
+    right: 1px;
+    bottom: -3px;
+    height: 2px;
+    border-radius: 2px;
+    background: var(--a-color, #ffffff);
+}
+
+.hl-icon {
+    width: 14px;
+    height: 14px;
+    border-radius: 3px;
+    background: var(--hl-color, #ffe58f);
+    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18) inset;
+}
+
+.color-input {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    opacity: 0;
+    pointer-events: none;
+}
+
+.link {
+    position: relative;
+    display: inline-flex;
+    align-items: center;
+}
+
+.link-pop {
+    position: absolute;
+    bottom: calc(100% + 6px);
+    left: 0;
+    padding: 10px;
+    width: 320px;
+    height: 40px;
+    display: flex;
+    gap: 8px;
+    background: rgba(27, 27, 28, 0.95);
+    border: 1px solid rgba(255, 255, 255, 0.1);
+    border-radius: 6px;
+    z-index: 3;
+    backdrop-filter: blur(6px);
+}
+
+.link-input {
+    flex: 1;
+    height: 28px;
+    border-radius: 4px;
+    border: 1px solid rgba(255, 255, 255, 0.12);
+    background: rgba(255, 255, 255, 0.06);
+    outline: none;
+    color: rgba(255, 255, 255, 0.9);
+    padding: 0 8px;
+    font-size: 12px;
+}
+
+.link-btn {
+    height: 28px;
+    padding: 0 10px;
+    border-radius: 4px;
+    border: 1px solid rgba(255, 255, 255, 0.12);
+    background: rgba(var(--colors-primary-fill), 0.2);
+    color: rgba(255, 255, 255, 0.9);
+    cursor: pointer;
+    font-size: 12px;
+}
+
+.has-toolbar > .retouch {
+    justify-content: space-between !important;
+}
+</style>

+ 44 - 760
src/components/bill-ui/components/input/richInput.vue

@@ -1,766 +1,50 @@
 <template>
-    <div
-        class="input textarea"
-        :class="{ suffix: showBar, disabled, right, 'has-toolbar': enableToolbar }"
-        ref="textRef"
-    >
-        <div
-            :contenteditable="canEdit"
-            class="ui-text input-div"
-            @input="inputHandler"
-            :placeholder="props.placeholder"
-            @click="emit('click')"
-            @focus="focusHandler"
-            @blur="blurHandler"
-            @paste="pasteHandler"
-            @compositionstart="compositionstartHandler"
-            @compositionend="compositionendHandler"
-            @mouseup="selectionUpdate"
-            @keyup="selectionUpdate"
-            ref="inputRef"
-            v-bind="other"
-        />
-        <span class="replace"></span>
-        <span v-if="showBar" class="retouch">
-            <div v-if="enableToolbar" class="toolbar" @mousedown.prevent>
-                <div class="toolbar-item dropdown font-size" :class="{ open: showFontSizeMenu }" @mousedown.prevent="toggleFontSizeMenu">
-                    <span class="dropdown-value">{{ currentFontSize }}</span>
-                    <span class="dropdown-arrow">▾</span>
-                    <div v-if="showFontSizeMenu" class="dropdown-menu" @mousedown.prevent>
-                        <div
-                            v-for="size in fontSizeOptions"
-                            :key="size"
-                            class="dropdown-option"
-                            :class="{ active: size === currentFontSize }"
-                            @mousedown.prevent="applyFontSize(size)"
-                        >
-                            {{ size }}
-                        </div>
-                    </div>
-                </div>
-
-                <button
-                    class="toolbar-item btn"
-                    type="button"
-                    :class="{ active: isBold }"
-                    :disabled="!canEdit"
-                    @mousedown.prevent="toggleBold"
-                >
-                    B
-                </button>
-
-                <button
-                    class="toolbar-item btn"
-                    type="button"
-                    :disabled="!canEdit"
-                    @mousedown.prevent="triggerTextColor"
-                >
-                    <span class="a-letter" :style="{ '--a-color': textColor }">A</span>
-                </button>
-                <input ref="textColorInputRef" class="color-input" type="color" :value="textColor" @input="applyTextColor" />
-
-                <button
-                    class="toolbar-item btn"
-                    type="button"
-                    :disabled="!canEdit"
-                    @mousedown.prevent="triggerHighlightColor"
-                >
-                    <span class="hl-icon" :style="{ '--hl-color': highlightColor }"></span>
-                </button>
-                <input ref="highlightColorInputRef" class="color-input" type="color" :value="highlightColor" @input="applyHighlightColor" />
-
-                <div class="toolbar-item dropdown align-menu" :class="{ open: showAlignMenu }" @mousedown.prevent="toggleAlignMenu">
-                    <span class="align-icon">{{ alignLabel }}</span>
-                    <span class="dropdown-arrow">▾</span>
-                    <div v-if="showAlignMenu" class="dropdown-menu" @mousedown.prevent>
-                        <div class="dropdown-option" :class="{ active: align === 'left' }" @mousedown.prevent="applyAlign('left')">左对齐</div>
-                        <div class="dropdown-option" :class="{ active: align === 'center' }" @mousedown.prevent="applyAlign('center')">居中</div>
-                        <div class="dropdown-option" :class="{ active: align === 'right' }" @mousedown.prevent="applyAlign('right')">右对齐</div>
-                    </div>
-                </div>
-
-                <div class="toolbar-item link">
-                    <button class="btn link-btn-trigger" type="button" :disabled="!canEdit" @mousedown.prevent="openLinkInput">
-                        链接
-                    </button>
-                    <div v-if="showLinkInput" class="link-pop" @mousedown.prevent>
-                        <input class="link-input" v-model="linkValue" placeholder="请输入链接" />
-                        <button class="link-btn" type="button" @mousedown.prevent="confirmLink">确认</button>
-                    </div>
-                </div>
-            </div>
-            <slot v-else name="icon" />
-            <span v-if="props.maxlength" class="len">
-                <span>{{ length }}</span> / {{ maxlength }}
-            </span>
-        </span>
-    </div>
+    <QuillEditor content-type='html' v-model:content="content" :options='editorOption' />
 </template>
-
-<script setup>
-import { richtextPropsDesc } from './state'
-import { computed, nextTick, onMounted, onUnmounted, ref, useSlots, watchEffect } from 'vue'
+<script setup lang="ts">
+import { reactive, ref, watchEffect } from 'vue';
+import { QuillEditor } from '@vueup/vue-quill';
+import '@vueup/vue-quill/dist/vue-quill.snow.css';
+ 
 const props = defineProps({
-    ...richtextPropsDesc,
-})
-
-const emit = defineEmits(['update:modelValue', 'focus', 'blur', 'click', 'updatePos'])
-const slots = useSlots()
-const textRef = ref(null)
-const inputRef = ref(null)
-const length = ref(0)
-const textColor = ref('#ffffff')
-const highlightColor = ref('#ffe58f')
-const textColorInputRef = ref(null)
-const highlightColorInputRef = ref(null)
-const showFontSizeMenu = ref(false)
-const showAlignMenu = ref(false)
-const showLinkInput = ref(false)
-const linkValue = ref('')
-const savedRange = ref(null)
-const isBold = ref(false)
-const align = ref('left')
-const currentFontSize = ref(12)
-
-const enableToolbar = computed(() => !!props.rich)
-const showBar = computed(() => enableToolbar.value || !!props.maxlength || !!slots.icon)
-const canEdit = computed(() => !props.disabled && !props.readonly)
-
-const fontSizeOptions = computed(() => Array.from({ length: 37 }, (_, idx) => idx + 12))
-const alignLabel = computed(() => (align.value === 'center' ? '居中' : align.value === 'right' ? '右' : '左'))
-
-const getTextLen = (text = '') => text.replace(/[\u200B\uFEFF]/g, '').length
-const normalizeUrl = raw => {
-    const val = String(raw || '').trim()
-    if (!val) return ''
-    const low = val.toLowerCase()
-    if (low.startsWith('javascript:') || low.startsWith('data:')) return ''
-    if (/^(https?:\/\/|mailto:|tel:)/i.test(val)) return val
-    return 'https://' + val
-}
-
-const syncFromDom = () => {
-    if (!inputRef.value) return
-    length.value = getTextLen(inputRef.value.textContent || '')
-    emit('update:modelValue', inputRef.value.innerHTML)
-}
-
-const saveSelection = () => {
-    const sel = window.getSelection?.()
-    if (!sel || sel.rangeCount === 0) return
-    const range = sel.getRangeAt(0)
-    if (!inputRef.value || !inputRef.value.contains(range.commonAncestorContainer)) return
-    savedRange.value = range.cloneRange()
-}
-
-const restoreSelection = () => {
-    if (!savedRange.value || !window.getSelection || !inputRef.value) return false
-    const range = savedRange.value
-    if (range?.startContainer?.isConnected === false || range?.endContainer?.isConnected === false) {
-        savedRange.value = null
-        return false
-    }
-    if (!inputRef.value.contains(range.commonAncestorContainer)) return false
-    const sel = window.getSelection()
-    if (!sel) return false
-    try {
-        sel.removeAllRanges()
-        sel.addRange(range)
-        return true
-    } catch (e) {
-        savedRange.value = null
-        return false
-    }
-}
-
-const getAlignFromRange = range => {
-    if (!inputRef.value || !window.getComputedStyle) return null
-    let node = range?.startContainer
-    if (!node) return null
-    if (node.nodeType === Node.TEXT_NODE) node = node.parentElement
-    if (!(node instanceof Element)) return null
-
-    let cur = node
-    while (cur && cur !== inputRef.value) {
-        const style = window.getComputedStyle(cur)
-        const display = style.display
-        if (display === 'block' || display === 'list-item') {
-            const v = style.textAlign
-            if (v === 'center') return 'center'
-            if (v === 'right' || v === 'end') return 'right'
-            if (v === 'left' || v === 'start' || v === 'justify') return 'left'
-        }
-        cur = cur.parentElement
-    }
-
-    const v = window.getComputedStyle(inputRef.value).textAlign
-    if (v === 'center') return 'center'
-    if (v === 'right' || v === 'end') return 'right'
-    if (v === 'left' || v === 'start' || v === 'justify') return 'left'
-    return null
-}
-
-const selectionUpdate = () => {
-    const sel = window.getSelection?.()
-    if (!sel || sel.rangeCount === 0) return
-    const range = sel.getRangeAt(0)
-    if (!inputRef.value || !inputRef.value.contains(range.commonAncestorContainer)) return
-    savedRange.value = range.cloneRange()
-    if (!document.queryCommandState) return
-    try {
-        isBold.value = document.queryCommandState('bold')
-        if (document.queryCommandState('justifyCenter')) align.value = 'center'
-        else if (document.queryCommandState('justifyRight')) align.value = 'right'
-        else if (document.queryCommandState('justifyLeft')) align.value = 'left'
-    } catch (e) {}
-    const nextAlign = getAlignFromRange(range)
-    if (nextAlign) align.value = nextAlign
-}
-
-const focusEditor = () => {
-    if (!inputRef.value) return
-    inputRef.value.focus()
-}
-
-const ensureEditorSelection = () => {
-    if (!inputRef.value) return false
-    focusEditor()
-    const sel = window.getSelection?.()
-    if (!sel) return false
-    if (sel.rangeCount > 0) {
-        const range = sel.getRangeAt(0)
-        if (inputRef.value.contains(range.commonAncestorContainer)) {
-            savedRange.value = range.cloneRange()
-            return true
-        }
-    }
-    if (restoreSelection()) return true
-    const range = document.createRange()
-    range.selectNodeContents(inputRef.value)
-    range.collapse(false)
-    try {
-        sel.removeAllRanges()
-        sel.addRange(range)
-        savedRange.value = range.cloneRange()
-        return true
-    } catch (e) {
-        return false
-    }
-}
-
-const wrapSelectionWithStyle = style => {
-    if (!canEdit.value) return
-    ensureEditorSelection()
-    const sel = window.getSelection?.()
-    if (!sel || sel.rangeCount === 0) return
-    const range = sel.getRangeAt(0)
-    if (!inputRef.value || !inputRef.value.contains(range.commonAncestorContainer)) return
-
-    const span = document.createElement('span')
-    Object.keys(style).forEach(key => {
-        span.style[key] = style[key]
-    })
-
-    if (range.collapsed) {
-        span.appendChild(document.createTextNode('\u200B'))
-        range.insertNode(span)
-        const nextRange = document.createRange()
-        nextRange.setStart(span.firstChild, 1)
-        nextRange.collapse(true)
-        sel.removeAllRanges()
-        sel.addRange(nextRange)
-        savedRange.value = nextRange.cloneRange()
-    } else {
-        const content = range.extractContents()
-        span.appendChild(content)
-        range.insertNode(span)
-        const nextRange = document.createRange()
-        nextRange.selectNodeContents(span)
-        nextRange.collapse(false)
-        sel.removeAllRanges()
-        sel.addRange(nextRange)
-        savedRange.value = nextRange.cloneRange()
-    }
-
-    syncFromDom()
-    selectionUpdate()
-}
-
-const toggleFontSizeMenu = () => {
-    if (!enableToolbar.value) return
-    saveSelection()
-    showFontSizeMenu.value = !showFontSizeMenu.value
-    showAlignMenu.value = false
-    showLinkInput.value = false
-}
-const applyFontSize = size => {
-    currentFontSize.value = Number(size)
-    showFontSizeMenu.value = false
-    wrapSelectionWithStyle({ fontSize: currentFontSize.value + 'px' })
-}
-
-const toggleBold = () => {
-    if (!canEdit.value) return
-    ensureEditorSelection()
-    document.execCommand?.('bold')
-    nextTick(() => {
-        syncFromDom()
-        selectionUpdate()
-    })
-}
-
-const triggerTextColor = () => {
-    if (!canEdit.value) return
-    saveSelection()
-    textColorInputRef.value?.click?.()
-}
-const applyTextColor = ev => {
-    if (!canEdit.value) return
-    const val = ev?.target?.value
-    if (!val) return
-    textColor.value = val
-    ensureEditorSelection()
-    document.execCommand?.('styleWithCSS', false, true)
-    document.execCommand?.('foreColor', false, val)
-    nextTick(() => {
-        syncFromDom()
-        selectionUpdate()
-    })
-}
-
-const triggerHighlightColor = () => {
-    if (!canEdit.value) return
-    saveSelection()
-    highlightColorInputRef.value?.click?.()
-}
-const applyHighlightColor = ev => {
-    if (!canEdit.value) return
-    const val = ev?.target?.value
-    if (!val) return
-    highlightColor.value = val
-    ensureEditorSelection()
-    document.execCommand?.('styleWithCSS', false, true)
-    document.execCommand?.('hiliteColor', false, val) || document.execCommand?.('backColor', false, val)
-    nextTick(() => {
-        syncFromDom()
-        selectionUpdate()
-    })
-}
-
-const toggleAlignMenu = () => {
-    if (!enableToolbar.value) return
-    saveSelection()
-    showAlignMenu.value = !showAlignMenu.value
-    showFontSizeMenu.value = false
-    showLinkInput.value = false
-}
-const applyAlign = mode => {
-    if (!canEdit.value) return
-    align.value = mode
-    showAlignMenu.value = false
-    ensureEditorSelection()
-    const cmd = mode === 'center' ? 'justifyCenter' : mode === 'right' ? 'justifyRight' : 'justifyLeft'
-    document.execCommand?.(cmd)
-    nextTick(() => {
-        syncFromDom()
-        selectionUpdate()
-    })
-}
-
-const openLinkInput = () => {
-    if (!canEdit.value) return
-    saveSelection()
-    showLinkInput.value = true
-    showFontSizeMenu.value = false
-    showAlignMenu.value = false
-    nextTick(() => {
-        const el = textRef.value?.querySelector?.('.link-input')
-        el?.focus?.()
-    })
-}
-const confirmLink = () => {
-    if (!canEdit.value) return
-    const url = normalizeUrl(linkValue.value)
-    showLinkInput.value = false
-    linkValue.value = ''
-    if (!url) return
-    ensureEditorSelection()
-    const sel = window.getSelection?.()
-    if (!sel || sel.rangeCount === 0) return
-    const range = sel.getRangeAt(0)
-    if (!inputRef.value || !inputRef.value.contains(range.commonAncestorContainer)) return
-
-    if (range.collapsed) {
-        const a = document.createElement('a')
-        a.href = url
-        a.target = '_blank'
-        a.rel = 'noopener noreferrer'
-        a.textContent = url
-        range.insertNode(a)
-        const nextRange = document.createRange()
-        nextRange.setStartAfter(a)
-        nextRange.collapse(true)
-        sel.removeAllRanges()
-        sel.addRange(nextRange)
-        savedRange.value = nextRange.cloneRange()
-        syncFromDom()
-        selectionUpdate()
-        return
-    }
-
-    try {
-        document.execCommand?.('createLink', false, url)
-        inputRef.value.querySelectorAll('a').forEach(a => {
-            a.target = '_blank'
-            a.rel = 'noopener noreferrer'
-        })
-        nextTick(() => {
-            syncFromDom()
-            selectionUpdate()
-        })
-    } catch (e) {}
-}
-
-const updateContent = html => {
-    if (!inputRef.value) return
-    inputRef.value.innerHTML = html || ''
-    length.value = getTextLen(inputRef.value.textContent || '')
-}
-
+    // 默认值
+    value: {
+        type: String,
+        default: '',
+    },
+});
+ 
+const emit = defineEmits(['update:value']);
+ 
+const content = ref(props.value);
+const editorOption = reactive({
+    modules: {
+        toolbar: [  // 工具栏配置
+            [{ 'color': [] },'bold', 'italic', 'underline', 'strike'],  // 粗体、斜体、下划线、删除线
+            [{ 'header': 1 }, { 'header': 2 }],  // 标题1和标题2
+            [{ 'list': 'ordered' }, { 'list': 'bullet' }],  // 有序列表和无序列表
+            [{ 'script': 'sub' }, { 'script': 'super' }],  // 上标和下标
+            [{ 'indent': '-1' }, { 'indent': '+1' }],  // 缩进
+            [{ 'direction': 'rtl' }],  // 文字方向
+            // [{ 'size': ['small', false, 'large', 'huge'] }],  // 字号
+            [{ 'size': [1, 2, 3, 4, 5, 6, false] }],  // 标题等级
+            [{ 'color': [] }, { 'background': [] }],  // 字体颜色和背景色
+            [{ 'font': [] }],  // 字体
+            [{ 'align': [] }],  // 对齐方式
+            ['clean']  // 清除格式
+        ]
+    },
+    placeholder: '请输入内容...',
+    theme: 'snow'
+},
+);
+// 内容有变化,就更新内容,将值返回给父组件
 watchEffect(() => {
-    if (inputRef.value && props.modelValue !== inputRef.value.innerHTML) {
-        updateContent(props.modelValue)
-    }
-})
-
-let inComposition = false
-const compositionstartHandler = () => {
-    inComposition = true
-}
-const compositionendHandler = ev => {
-    inComposition = false
-    inputHandler(ev)
-}
-
-const inputHandler = ev => {
-    if (inComposition) return
-    const nextLen = getTextLen(ev.target.textContent || '')
-    if (!props.maxlength || nextLen <= Number(props.maxlength)) {
-        length.value = nextLen
-        emit('update:modelValue', ev.target.innerHTML || '')
-    } else {
-        nextTick(() => {
-            if (ev.target.innerHTML !== props.modelValue.toString()) {
-                updateContent(props.modelValue.toString())
-                inputFocus()
-            }
-        })
-    }
-    nextTick(selectionUpdate)
-}
-//获取当前光标位置
-const getCursortPosition = function (element = inputRef.value) {
-    var caretOffset = 0
-    var doc = element.ownerDocument || element.document
-    var win = doc.defaultView || doc.parentWindow
-    var sel
-    if (typeof win.getSelection != 'undefined') {
-        //谷歌、火狐
-        sel = win.getSelection()
-        if (sel.rangeCount > 0) {
-            //选中的区域
-            var range = win.getSelection().getRangeAt(0)
-            var preCaretRange = range.cloneRange() //克隆一个选中区域
-            preCaretRange.selectNodeContents(element) //设置选中区域的节点内容为当前节点
-            preCaretRange.setEnd(range.endContainer, range.endOffset) //重置选中区域的结束位置
-            caretOffset = preCaretRange.toString().length
-        }
-    } else if ((sel = doc.selection) && sel.type != 'Control') {
-        //IE
-        var textRange = sel.createRange()
-        var preCaretTextRange = doc.body.createTextRange()
-        preCaretTextRange.moveToElementText(element)
-        preCaretTextRange.setEndPoint('EndToEnd', textRange)
-        caretOffset = preCaretTextRange.text.length
-    }
-    return caretOffset
-}
-
-let interval
-const focusHandler = ev => {
-    clearInterval(interval)
-    interval = setInterval(() => {
-        emit('updatePos', getCursortPosition())
-    }, 100)
-    emit('focus')
-    nextTick(selectionUpdate)
-}
-const blurHandler = () => {
-    clearInterval(interval)
-    emit('blur')
-}
-
-const inputFocus = () => {
-    inputRef.value.focus()
-    const range = window.getSelection()
-    range.selectAllChildren(inputRef.value)
-    range.collapseToEnd()
-}
-
-const getPasteText = text => {
-    if (!props.maxlength) {
-        return text
-    }
-
-    const $el = document.createElement('div')
-    $el.innerHTML = text
-    const allowLen = Number(props.maxlength) - length.value
-    const rawText = $el.textContent || ''
-    if (getTextLen(rawText) > allowLen) {
-        return rawText.replace(/[\u200B\uFEFF]/g, '').substring(0, allowLen)
-    } else {
-        return text
-    }
-}
-
-const pasteHandler = event => {
-    event.preventDefault()
-    var text
-    var clp = (event.originalEvent || event).clipboardData
-    // 兼容针对于opera ie等浏览器
-    if (clp === undefined || clp === null) {
-        text = window.clipboardData.getData('text') || ''
-        if (text !== '') {
-            if (window.getSelection) {
-                // 针对于ie11 10 9 safari
-                var newNode = document.createElement('span')
-                newNode.innerHTML = getPasteText(text)
-                window.getSelection().getRangeAt(0).insertNode(newNode)
-            } else {
-                document.selection.createRange().pasteHTML(text)
-            }
-        }
-    } else {
-        // 兼容chorme或hotfire
-        text = clp.getData('text/plain') || ''
-        if (text !== '') {
-            document.execCommand('insertText', false, getPasteText(text))
-        }
-    }
-}
-
-const onDocMousedown = ev => {
-    const root = textRef.value
-    if (!root) return
-    const target = ev.target
-    if (showFontSizeMenu.value) {
-        const el = root.querySelector('.font-size')
-        if (!el || !el.contains(target)) showFontSizeMenu.value = false
-    }
-    if (showAlignMenu.value) {
-        const el = root.querySelector('.align-menu')
-        if (!el || !el.contains(target)) showAlignMenu.value = false
-    }
-    if (showLinkInput.value) {
-        const pop = root.querySelector('.link-pop')
-        const btn = root.querySelector('.link-btn-trigger')
-        if ((!pop || !pop.contains(target)) && (!btn || !btn.contains(target))) showLinkInput.value = false
-    }
-
-    if (!root.contains(target)) {
-        savedRange.value = null
-        window.getSelection?.()?.removeAllRanges?.()
-    }
-}
-
-onMounted(() => {
-    document.addEventListener('mousedown', onDocMousedown)
-})
-onUnmounted(() => {
-    document.removeEventListener('mousedown', onDocMousedown)
-})
-
-defineExpose({
-    root: textRef,
-    input: inputRef,
-    getCursortPosition: getCursortPosition,
-})
+    emit('update:value', content.value);
+});
 </script>
-
-<style scoped>
-.toolbar {
-    display: flex;
-    align-items: center;
-    gap: 8px;
-    min-width: 0;
-}
-
-.btn {
-    border: none;
-    background: transparent;
-    color: rgba(255, 255, 255, 0.8);
-    padding: 0 6px;
-    height: 22px;
-    line-height: 22px;
-    border-radius: 4px;
-    cursor: pointer;
-    font-weight: 600;
-    display: inline-flex;
-    align-items: center;
-    justify-content: center;
-    user-select: none;
-}
-
-.btn:disabled {
-    opacity: 0.4;
-    cursor: not-allowed;
-}
-
-.btn.active {
-    color: var(--colors-primary-base);
-    background-color: rgba(var(--colors-primary-fill), 0.18);
-}
-
-.dropdown {
-    position: relative;
-    display: inline-flex;
-    align-items: center;
-    gap: 6px;
-    padding: 0 6px;
-    height: 22px;
-    border-radius: 4px;
-    cursor: pointer;
-    color: rgba(255, 255, 255, 0.8);
-    background: rgba(255, 255, 255, 0.06);
-    border: 1px solid rgba(255, 255, 255, 0.08);
-    user-select: none;
-}
-
-.dropdown-value {
-    font-size: 12px;
-    width: 24px;
-    text-align: center;
-}
-
-.dropdown-arrow {
-    font-size: 10px;
-    opacity: 0.7;
-}
-
-.dropdown-menu {
-    position: absolute;
-    bottom: calc(100% + 6px);
-    left: 0;
-    min-width: 92px;
-    max-height: 220px;
-    overflow: auto;
-    background: rgba(27, 27, 28, 0.95);
-    border: 1px solid rgba(255, 255, 255, 0.1);
-    border-radius: 6px;
-    padding: 6px 0;
-    z-index: 2;
-    backdrop-filter: blur(6px);
-}
-
-.dropdown-option {
-    height: 28px;
-    line-height: 28px;
-    padding: 0 10px;
-    font-size: 12px;
-    color: rgba(255, 255, 255, 0.85);
-    cursor: pointer;
-}
-
-.dropdown-option:hover {
-    background: rgba(255, 255, 255, 0.06);
-}
-
-.dropdown-option.active {
-    color: var(--colors-primary-base);
-}
-
-.a-letter {
-    position: relative;
-    display: inline-flex;
-    align-items: center;
-    justify-content: center;
-    width: 14px;
-    height: 14px;
-    color: rgba(255, 255, 255, 0.95);
-}
-.a-letter::after {
-    content: '';
-    position: absolute;
-    left: 1px;
-    right: 1px;
-    bottom: -3px;
-    height: 2px;
-    border-radius: 2px;
-    background: var(--a-color, #ffffff);
-}
-
-.hl-icon {
-    width: 14px;
-    height: 14px;
-    border-radius: 3px;
-    background: var(--hl-color, #ffe58f);
-    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18) inset;
-}
-
-.color-input {
-    position: absolute;
-    width: 1px;
-    height: 1px;
-    opacity: 0;
-    pointer-events: none;
-}
-
-.link {
-    position: relative;
-    display: inline-flex;
-    align-items: center;
-}
-
-.link-pop {
-    position: absolute;
-    bottom: calc(100% + 6px);
-    left: 0;
-    padding: 10px;
-    width: 320px;
-    height: 40px;
-    display: flex;
-    gap: 8px;
-    background: rgba(27, 27, 28, 0.95);
-    border: 1px solid rgba(255, 255, 255, 0.1);
-    border-radius: 6px;
-    z-index: 3;
-    backdrop-filter: blur(6px);
-}
-
-.link-input {
-    flex: 1;
-    height: 28px;
-    border-radius: 4px;
-    border: 1px solid rgba(255, 255, 255, 0.12);
-    background: rgba(255, 255, 255, 0.06);
-    outline: none;
-    color: rgba(255, 255, 255, 0.9);
-    padding: 0 8px;
-    font-size: 12px;
-}
-
-.link-btn {
-    height: 28px;
-    padding: 0 10px;
-    border-radius: 4px;
-    border: 1px solid rgba(255, 255, 255, 0.12);
-    background: rgba(var(--colors-primary-fill), 0.2);
-    color: rgba(255, 255, 255, 0.9);
-    cursor: pointer;
-    font-size: 12px;
-}
-
-.has-toolbar > .retouch {
-    justify-content: space-between !important;
+<style lang="scss" scoped>
+.ql-editor.ql-blank::before{
+  color: #fff;
 }
-</style>
+</style>

+ 2 - 2
src/components/bill-ui/expose-common.js

@@ -6,7 +6,7 @@
  * @Description: 通用组件
  */
 import { setup } from './utils/componentHelper'
-import Dialog, { Window, Toast, Alert, DialogContent } from './components/dialog'
+import Dialog, { Window, Toast, Alert, DialogContent, Confirm } from './components/dialog'
 import Loading from './components/loading'
 import Message from './components/message'
 import Tree from './components/tree'
@@ -52,7 +52,7 @@ const components = setup(
     More
 )
 
-export { More, DialogContent, Cropper, Message, Loading, Dialog, Tree, Button, Group, GroupOption, Input, Icon, MenuItem, Floating, Gate, GateContent, Slide, Audio, Bubble, Guide, Tip }
+export { More, DialogContent, Cropper, Message, Loading, Dialog, Confirm, Tree, Button, Group, GroupOption, Input, Icon, MenuItem, Floating, Gate, GateContent, Slide, Audio, Bubble, Guide, Tip }
 
 export default function install(app) {
     components.forEach(component => component.install(app))

+ 1 - 0
src/main.ts

@@ -10,6 +10,7 @@ import { currentLayout, RoutesName } from "./router";
 import * as URL from "@/api/constant";
 import VueKonva from "vue-konva";
 // import 'ant-design-vue/dist/reset.css';
+import '@vueup/vue-quill/dist/vue-quill.snow.css';
 import "@/assets/style/global.less";
 import { isFirefoxBelow } from "./utils";
 

+ 1 - 0
src/views/guide/guide/sign.vue

@@ -73,6 +73,7 @@ const emit = defineEmits<{
 const menus = [
   { label: "重命名", value: "editTitle" },
   { label: "编辑", value: "edit" },
+  { label: "下载", value: "download" },
   { label: "删除", value: "delete" },
 ];
 const actions = {

+ 4 - 0
src/views/setting/index.vue

@@ -37,6 +37,7 @@
         <selectBack :value="[setting?.back, setting?.mapId]" @update:value="changeBack" />
       </ui-group-option>
     </ui-group>
+
   </RightFillPano>
 </template>
 
@@ -60,6 +61,8 @@ import selectBack from "./select-back.vue";
 import { Input } from "ant-design-vue";
 import { updateCaseInfo } from "@/api";
 import Message from "bill/components/message/message.vue";
+import { sysTiles } from "@/store";
+import { DialogContent } from "bill/index";
 
 const updateGPS = (val: any) => {
   console.log(val);
@@ -93,6 +96,7 @@ let isFirst = true;
 const changeBack = (mapData?: [string | null, number | null], title?: string) => {
   const back = mapData ? mapData[0] : setting.value?.back;
   const mapId = mapData ? mapData[1] : setting.value?.mapId;
+  console.log('changeBack', mapId);
 
   title = typeof title === "string" ? title : caseProject.value?.fusionTitle;
 

+ 57 - 18
src/views/setting/select-back.vue

@@ -31,7 +31,7 @@
                     color:
                       value[1] === item.value ? 'var(--colors-primary-base) ' : '#fff',
                   }"
-                  >{{ item.label }}</span
+                  >{{ item.label }}{{ value }}</span
                 >
               </MenuItem>
             </Menu>
@@ -48,23 +48,37 @@
       />
     </template>
     <!-- 上传入口 -->
-      <!-- <ui-input
-        class="img-input"
-        type="file"
-        width="88px"
-        height="88px"
-        preview
-        :accept="'.png'"
-        :disable="true"
-        :multiple="false"
-        :maxSize="audioSize"
-      >
-        <template v-slot:replace>
-          <p class="audio-tip">
-            <ui-icon type="add" />
-          </p>
+    <div class="file img-input" @click="handleFile" style="height: 88px;text-align: center;padding-top: 33px">
+        <p class="audio-tip">
+          <ui-icon type="add" />
+        </p>
+    </div>
+    <Confirm v-if="showFile" >
+        <template v-slot:content>
+          <div class="fileContent" style="width: 360px">
+            <div class="file">
+              <span>全景图</span>
+              <ui-input
+                  class="imgqjt-input"
+                  type="file"
+                  width="88px"
+                  height="28px"
+                  preview
+                  :accept="'.png'"
+                  :multiple="false"
+                  @update:modelValue="updateFile"
+                  :maxSize="audioSize"
+                >
+                  <template v-slot:replace>
+                    <ui-button class="small-button" type="submit" >选择全景图</ui-button>
+                  </template>
+              </ui-input>
+              <span></span>
+            </div>
+            <div class="tips">支持JPG/JPEG格式,2:1宽高比的全景图,建议分辨率≥4096×2048,全景图大小不超过20MB。</div>
+          </div>
         </template>
-    </ui-input> -->
+    </Confirm>
   </div>
 </template>
 
@@ -74,6 +88,7 @@ import { computed, ref } from "vue";
 import BackItem from "./back-item.vue";
 import { fetchMapTiles } from "@/api/map-tile";
 import { sysTiles } from "@/store";
+import { Confirm } from "bill/index";
 
 const props = defineProps<{
   value: [string | null | undefined, number | null | undefined];
@@ -81,7 +96,8 @@ const props = defineProps<{
 defineEmits<{
   (e: "update:value", value: [string | null, number | null]): void;
 }>();
-
+const file = ref<File | null>(null);
+const showFile = ref(false)
 const backs = computed(() => [
   { label: "无", type: "icon", image: "icon-without", value: "none" },
   {
@@ -118,6 +134,7 @@ const backs = computed(() => [
 ]);
 
 const activeParent = computed(() => {
+  console.log(backs.value, 'activeParent', props.value);
   for (const back of backs.value) {
     if (back.value === props.value[0]) {
       return back.value;
@@ -130,6 +147,12 @@ const activeParent = computed(() => {
     }
   }
 });
+const updateFile = async ({ file, preview }: { file: File; preview: string }) => {
+  console.log(file, preview, 'updateFile');
+}
+ const handleFile = () => {
+    showFile.value = true;
+ }
 </script>
 <style lang="scss" scoped>
 .back-layout {
@@ -170,4 +193,20 @@ const activeParent = computed(() => {
     inset: 0px;
   }
 }
+.fileContent{
+  font-weight: 400;
+  font-size: 14px;
+  color: rgba(255,255,255,0.7);
+  pointer-events:all;
+  * {
+    user-select: auto !important;
+  }
+  .tips{
+    margin-top: 20px;
+  }
+  .imgqjt-input{
+    margin: 0 20px;
+    position: relative;
+  }
+}
 </style>

+ 34 - 3
src/views/tagging/hot/edit.vue

@@ -41,7 +41,7 @@
         </ui-input>
       </template>
 
-      <ui-input
+      <!-- <ui-input
         class="input"
         width="100%"
         height="158px"
@@ -50,7 +50,9 @@
         :rich="true"
         v-model="tagging.desc"
         :maxlength="200"
-      />
+      /> -->
+      <editorInput :placeholder="defStyleType.id === type ? '描述:' : '特征描述:'" v-model="tagging.desc" />
+
       <template v-if="defStyleType.id !== type">
         <ui-input
           class="input preplace"
@@ -189,6 +191,8 @@
 import StyleTypeSelect from "./style-float-select.vue";
 import StylesManage from "./styles.vue";
 import Images from "./images.vue";
+import editorInput from "./editorInput.vue";
+// import { QuillEditor } from '@vueup/vue-quill'
 
 import { computed, ref, watchEffect } from "vue";
 import { Dialog, Message } from "bill/index";
@@ -222,7 +226,34 @@ const tqStatusOptions = [
 ];
 
 const props = defineProps<EditProps>();
-
+// var toolbarOptions = [
+//   ['bold', 'italic', 'underline'],        // toggled buttons
+//   ['code-block'],
+
+//   // [{ 'header': 1 }, { 'header': 2 }],               // custom button values
+//   [{ 'list': 'ordered'}, { 'list': 'bullet' }],
+//   // [{ 'script': 'sub'}, { 'script': 'super' }],      // superscript/subscript
+//   [{ 'indent': '-1'}, { 'indent': '+1' }],          // outdent/indent
+//   [{ 'direction': 'rtl' }],                         // text direction
+
+//   [{ 'size': ["12", "14", "16", "20", "24", false] }],  // custom dropdown
+//   // [{ 'header': [1, 2, 3, 4, 5, 6, 7, 8 , 9, 10] }],
+
+//   [{ 'color': [] }, { 'background': [] }],          // dropdown with defaults from theme
+//   [{ 'font': fonts }],
+//   [{ 'align': [] }],
+
+//   ['clean']                                         // remove formatting button
+// ];
+// const globalOptions = {
+//   debug: 'info',
+//   modules: {
+//     toolbar: toolbarOptions
+//   },
+//   placeholder: 'Compose an epic...',
+//   // readOnly: true,
+//   theme: 'bubble'
+// }
 const tqMethodOptions = [
   { label: "未送检", value: "未送检" },
   { label: "鉴定委托", value: "鉴定委托" },

+ 210 - 0
src/views/tagging/hot/editorInput.vue

@@ -0,0 +1,210 @@
+<template>
+    <div  class="myEditor">
+        <span class="tips len"><span class="value">{{ currentLength }}</span>/{{ maxLength }}</span>
+        <QuillEditor @textChange="handleTextChange" content-type='html' ref="quillRef" :maxlength="200" v-model:content="content" :options='editorOption' />
+    </div>
+</template>
+<script setup lang="ts">
+import { reactive, ref, watchEffect, onMounted, computed } from 'vue';
+import { QuillEditor } from '@vueup/vue-quill';
+import '@vueup/vue-quill/dist/vue-quill.snow.css';
+import Quill from 'quill'
+
+const maxLength = ref(200)
+const currentLength = ref(0)
+const quillRef = ref(null) // 编辑器 ref
+const props = defineProps({
+    // 默认值
+    value: {
+        type: String,
+        default: '',
+    },
+    placeholder: {
+        type: String,
+        default: '',
+    },
+});
+ 
+const emit = defineEmits(['update:value']);
+// 自定义 Quill 的字体大小格式化器
+const fontSizeOptions = [
+  '8px', '10px', '12px', '14px', '16px', '18px', 
+  '20px', '24px',
+]
+const Size = Quill.import('attributors/style/size')
+Size.whitelist = fontSizeOptions // 设置允许的字体大小值
+Quill.register(Size, true)
+ var fonts = [
+  "Microsoft-YaHei",
+  "YouYuan",
+  "SimSun",
+  "SimHei",
+  "KaiTi",
+  "FangSong",
+  "Arial",
+  "Times-New-Roman",
+  "sans-serif",
+];
+const content = ref(props.value);
+const editorOption = reactive({
+    modules: {
+        toolbar: [  // 工具栏配置
+            [{ 'color': [], label: '字体颜色' }, { 'background': [], label: '背景颜色' }, 'bold'],  // 粗体、斜体、下划线、删除线 'italic', 'underline'
+            // [{ 'size': fontSizeOptions, label: '字体' }], // 自定义字号
+            // [{ 'header': 1 }, { 'header': 2 }],  // 标题1和标题2
+            // [{ 'list': 'ordered' }, { 'list': 'bullet' }],  // 有序列表和无序列表
+            // [{ 'script': 'sub' }, { 'script': 'super' }],  // 上标和下标
+            [{ 'indent': '-1' }, { 'indent': '+1' }],  // 缩进
+            // [{ 'direction': 'rtl' }],  // 文字方向
+            [{ 'size': ['small', false, 'large', 'huge'] }],  // 字号
+            // [{ 'size': [1, 2, 3, 4, 5, 6, false] }],  // 标题等级
+            // [{ 'background': [] }],  // 字体颜色和背景色
+            // [{ 'font': fonts }],  // 字体
+            [{ 'align': [] }],  // 对齐方式
+            // ['clean']  // 清除格式
+        ]
+    },
+    placeholder: props.placeholder,
+    theme: 'snow'
+},
+);
+// 监听文本变化并限制长度
+const handleTextChange = () => {
+  const quillInstance = quillRef.value?.getQuill()
+  if (!quillInstance) return
+
+  // 获取纯文本内容(带换行,不影响长度统计)
+  const text = quillInstance.getText()
+  // 计算长度(去除首尾空白,也可根据需求直接用 text.length)
+  const textLength = text.trim().length
+
+  // 更新当前字数
+  currentLength.value = textLength
+  console.log(textLength, text, 'handleTextChange')
+
+  // 超过最大长度时截断
+  if (textLength > maxLength.value) {
+    // 保存当前光标位置
+    const selection = quillInstance.getSelection()
+    
+    // 截断文本(保留前 maxLength 个字符)
+    // 先禁用文本变化监听,避免循环触发
+    quillInstance.off('text-change', handleTextChange)
+    
+    // 截断逻辑:获取前maxLength个字符并设置
+    const truncatedText = text.substring(0, maxLength.value)
+    quillInstance.setText(truncatedText)
+    
+    // 恢复光标位置到末尾
+    quillInstance.setSelection(maxLength.value, 0)
+    
+    // 重新开启监听
+    quillInstance.on('text-change', handleTextChange)
+    
+    // 同步更新双向绑定的值
+    editorContent.value = quillInstance.root.innerHTML
+  }
+}
+onMounted(() => {
+});
+// 内容有变化,就更新内容,将值返回给父组件
+watchEffect(() => {
+    emit('update:value', content.value);
+});
+</script>
+<style lang="scss">
+.ql-editor.ql-blank::before{
+  color: #fff;
+}
+.myEditor{
+    position: relative;
+    border-radius: 4px;
+    border: 1px solid rgba(255, 255, 255, 0.2);
+    .tips{
+        position: absolute;
+        right: 10px;
+        top: 10px;
+        font-size: 12px;
+        .value{
+            color: #00C8AF;
+        }
+    }
+// .ql-editor{
+//     background-color: #fff !important;
+// }
+// .ql-editor.ql-blank::before{
+//   color: #fff;
+// }
+.ql-snow .ql-picker-label::before{
+    color: #fff;
+}
+.ql-formats{
+    color: #444;
+}
+.ql-toolbar.ql-snow .ql-formats{
+    margin-right: 10px !important;
+}
+/* 美化工具栏下拉菜单 */
+.ql-snow .ql-picker.ql-size {
+  width: 74px !important;
+}
+/* 自定义font-size */
+.ql-snow .ql-picker.ql-size .ql-picker-label::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item::before {
+//   content: "14px";
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="10px"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="10px"]::before {
+  content: "10px";
+  font-size: 10px;
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="12px"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="12px"]::before {
+  content: "12px";
+  font-size: 12px;
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="14px"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="14px"]::before {
+  content: "14px";
+  font-size: 14px;
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before {
+  content: "16px";
+  font-size: 16px;
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="18px"]::before {
+  content: "18px";
+  font-size: 18px;
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="20px"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="20px"]::before {
+  content: "20px";
+  font-size: 20px;
+}
+.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="24px"]::before,
+.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="24px"]::before {
+  content: "24px";
+  font-size: 24px;
+}
+.ql-toolbar.ql-snow{
+    background: #444;
+}
+.ql-snow{
+    border: none;
+}
+.ql-editor {
+    border: none;
+    height: 130px;
+}
+.ql-snow .ql-stroke{
+    filter: brightness(0) invert(1) opacity(0.8);
+}
+.ql-active{
+    &::before{
+        color: #444 !important;
+    }
+}
+}
+</style>

Разница между файлами не показана из-за своего большого размера
+ 1510 - 0
yarn.lock