董国庆
2026-07-01 5e329a7136c4d73ce657aad5281a45def1e60bbf
Merge branch 'deploy-2.0' into feat/deploy-2.0
3个文件已修改
282 ■■■■■ 已修改文件
src/components/custom/Editor/AiRichTextEditor/index.vue 263 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/contentCenter/agreementVersion.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/userContent/contentCenter/components/AgreementVersionModal.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/custom/Editor/AiRichTextEditor/index.vue
@@ -13,6 +13,7 @@
  disabled: false,
  readOnly: false,
  value: '',
  maxLength: 20000,
  placeholder: '请输入内容...',
  height: '360px',
  toolbarKeys: () => [
@@ -65,15 +66,19 @@
const model = defineModel<string>()
const editorRef = ref<HTMLElement | null>(null)
const editableRoot = ref<HTMLElement | null>(null)
const guardObserver = ref<MutationObserver | null>(null)
const editor = ref<AiEditorInstance | null>(null)
const lastHtml = ref('')
const currentTextCount = ref(0)
const hasMaxLengthOverflow = ref(false)
const isUnmounted = ref(false)
const isApplyingExternalContent = ref(false)
const isTruncating = ref(false)
let applyContentVersion = 0
const showCount = computed(() => typeof props.maxLength === 'number' && props.maxLength > 0)
const isCountOverflow = computed(() => showCount.value && currentTextCount.value > (props.maxLength || 0))
const isCountOverflow = computed(() => showCount.value && hasMaxLengthOverflow.value)
const commonUploader: Uploader = async (file) => {
  const res = await uploadSingleFile(file)
@@ -283,61 +288,46 @@
}
function truncateHtmlByTextLength(html: string, maxLength: number) {
  if (!html || maxLength < 0 || typeof document === 'undefined')
  if (!html || typeof document === 'undefined')
    return html || ''
  const container = document.createElement('div')
  container.innerHTML = html
  let remaining = maxLength
  const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT)
  let currentLength = 0
  const nodesToRemove: Node[] = []
  const trimNode = (node: Node) => {
    if (node.nodeType === Node.TEXT_NODE) {
      const textNode = node as Text
      const text = textNode.nodeValue || ''
      if (!text)
        return false
      if (remaining <= 0)
        return false
      if (text.length <= remaining) {
        remaining -= text.length
        return true
  let currentNode = walker.nextNode()
  while (currentNode) {
    const nodeText = currentNode.nodeValue || ''
    const nodeLength = nodeText.length
    if (currentLength + nodeLength > maxLength) {
      const allowedLength = Math.max(0, maxLength - currentLength)
      if (allowedLength > 0) {
        currentNode.nodeValue = nodeText.substring(0, allowedLength)
        currentLength += allowedLength
      }
      textNode.nodeValue = text.slice(0, remaining)
      remaining = 0
      return true
      else {
        nodesToRemove.push(currentNode)
      }
    }
    else {
      currentLength += nodeLength
    }
    if (node.nodeType !== Node.ELEMENT_NODE)
      return true
    const element = node as Element
    const childNodes = Array.from(element.childNodes)
    if (!childNodes.length)
      return true
    for (const child of childNodes) {
      if (remaining <= 0) {
        child.remove()
        continue
      }
      if (!trimNode(child))
        child.remove()
    }
    return true
    currentNode = walker.nextNode()
  }
  Array.from(container.childNodes).forEach((child) => {
    if (remaining <= 0) {
      child.remove()
      return
  nodesToRemove.forEach((node) => {
    let target: Node | null = node
    while (target && target !== container && !target.textContent?.trim()) {
      const parent: Node | null = target.parentNode
      if (parent)
        parent.removeChild(target)
      target = parent
    }
    if (!trimNode(child))
      child.remove()
  })
  return container.innerHTML
@@ -353,6 +343,149 @@
    : undefined
}
function syncOverflowStateByLength(length: number) {
  const maxLength = getEffectiveMaxLength()
  if (maxLength === undefined) {
    hasMaxLengthOverflow.value = false
    return
  }
  if (length > maxLength) {
    hasMaxLengthOverflow.value = true
    return
  }
  if (length < maxLength)
    hasMaxLengthOverflow.value = false
}
function getPreparedContentState(html: string) {
  const formattedHtml = formatRichTextContent(html)
  const maxLength = getEffectiveMaxLength()
  if (maxLength === undefined)
    return { html: formattedHtml, overflow: false }
  return {
    html: formattedHtml,
    overflow: getTextLengthByHtml(formattedHtml) > maxLength,
  }
}
function getSelectedTextLength() {
  if (typeof window === 'undefined' || !editableRoot.value)
    return 0
  const selection = window.getSelection()
  if (!selection || selection.rangeCount === 0)
    return 0
  const range = selection.getRangeAt(0)
  const commonAncestor = range.commonAncestorContainer
  if (!editableRoot.value.contains(commonAncestor))
    return 0
  return range.toString().length
}
function getRemainingInsertLength() {
  const maxLength = getEffectiveMaxLength()
  if (maxLength === undefined)
    return Number.POSITIVE_INFINITY
  const selectedTextLength = getSelectedTextLength()
  return Math.max(0, maxLength - (currentTextCount.value - selectedTextLength))
}
function shouldBlockInsert(incomingLength: number) {
  if (incomingLength <= 0)
    return false
  return incomingLength > getRemainingInsertLength()
}
function handleBeforeInput(event: Event) {
  if (props.disabled || props.readOnly)
    return
  const inputEvent = event as InputEvent
  if (!inputEvent.inputType?.startsWith('insert'))
    return
  if (inputEvent.inputType === 'insertFromPaste')
    return
  const incomingLength = inputEvent.data?.length ?? 0
  if (!shouldBlockInsert(incomingLength))
    return
  inputEvent.preventDefault()
  hasMaxLengthOverflow.value = true
}
function handleKeydown(event: Event) {
  if (props.disabled || props.readOnly)
    return
  const keyboardEvent = event as KeyboardEvent
  if (keyboardEvent.isComposing || keyboardEvent.ctrlKey || keyboardEvent.metaKey || keyboardEvent.altKey)
    return
  if (keyboardEvent.key.length !== 1)
    return
  if (!shouldBlockInsert(keyboardEvent.key.length))
    return
  keyboardEvent.preventDefault()
  hasMaxLengthOverflow.value = true
}
function disconnectGuardObserver() {
  guardObserver.value?.disconnect()
  guardObserver.value = null
}
function bindMaxLengthGuards() {
  if (typeof document === 'undefined' || !editorRef.value)
    return
  const root = editorRef.value.querySelector('.aie-content, [contenteditable="true"]') as HTMLElement | null
  if (!root || editableRoot.value === root)
    return
  unbindMaxLengthGuards()
  editableRoot.value = root
  editableRoot.value.addEventListener('beforeinput', handleBeforeInput, true)
  editableRoot.value.addEventListener('keydown', handleKeydown, true)
  disconnectGuardObserver()
}
function unbindMaxLengthGuards() {
  if (!editableRoot.value)
    return
  editableRoot.value.removeEventListener('beforeinput', handleBeforeInput, true)
  editableRoot.value.removeEventListener('keydown', handleKeydown, true)
  editableRoot.value = null
}
function ensureMaxLengthGuards() {
  if (typeof document === 'undefined' || !editorRef.value)
    return
  bindMaxLengthGuards()
  if (editableRoot.value || guardObserver.value)
    return
  guardObserver.value = new MutationObserver(() => {
    bindMaxLengthGuards()
  })
  guardObserver.value.observe(editorRef.value, {
    childList: true,
    subtree: true,
  })
}
function applyFormattedValue(formattedHtml: string) {
  syncTextCount(formattedHtml)
  if (formattedHtml === lastHtml.value)
@@ -366,7 +499,10 @@
}
function syncModelValue(html: string) {
  const formattedHtml = formatRichTextContent(html)
  const { html: formattedHtml, overflow } = getPreparedContentState(html)
  syncOverflowStateByLength(getTextLengthByHtml(formattedHtml))
  if (overflow)
    hasMaxLengthOverflow.value = true
  if (isApplyingExternalContent.value) {
    lastHtml.value = formattedHtml
    syncTextCount(formattedHtml)
@@ -376,6 +512,8 @@
}
function destroyEditor() {
  unbindMaxLengthGuards()
  disconnectGuardObserver()
  editor.value?.destroy()
  editor.value = null
@@ -391,7 +529,8 @@
  if (!editorRef.value || editor.value || isUnmounted.value)
    return
  const initialContent = formatRichTextContent(content || '')
  const { html: initialContent, overflow } = getPreparedContentState(content || '')
  hasMaxLengthOverflow.value = overflow
  const instance = new AiEditor({
    element: editorRef.value,
@@ -406,19 +545,28 @@
    video: createUploadConfig(),
    attachment: createUploadConfig(),
    onChange: (currentEditor) => {
      if (isTruncating.value) return
      const currentHtml = currentEditor.getHtml()
      const maxLength = getEffectiveMaxLength()
      const textLength = currentEditor.getText().length
      if (maxLength !== undefined && textLength > maxLength) {
        const truncatedHtml = formatRichTextContent(truncateHtmlByTextLength(currentHtml, maxLength))
        applyFormattedValue(truncatedHtml)
        isApplyingExternalContent.value = true
        isTruncating.value = true
        const truncatedHtml = truncateHtmlByTextLength(currentHtml, maxLength)
        currentEditor.setContent(truncatedHtml)
        requestAnimationFrame(() => {
          isApplyingExternalContent.value = false
        })
        currentEditor.focusEnd()
        setTimeout(() => {
          isTruncating.value = false
        }, 10)
        hasMaxLengthOverflow.value = true
        syncModelValue(truncatedHtml)
        return
      }
      syncOverflowStateByLength(textLength)
      syncModelValue(currentHtml)
    },
    onFocus: () => {
@@ -435,15 +583,19 @@
  editor.value = instance
  lastHtml.value = initialContent
  syncTextCount(initialContent)
  void nextTick(() => {
    ensureMaxLengthGuards()
  })
}
async function rebuildEditor(content: string, focus = false) {
  if (isUnmounted.value)
    return
  const formattedContent = formatRichTextContent(content || '')
  const { html: formattedContent, overflow } = getPreparedContentState(content || '')
  if (formattedContent === lastHtml.value && editor.value) {
    lastHtml.value = formattedContent
    hasMaxLengthOverflow.value = overflow
    syncTextCount(formattedContent)
    return
  }
@@ -476,6 +628,7 @@
  if (!isUnmounted.value && editorRef.value)
    initEditor('')
  lastHtml.value = ''
  hasMaxLengthOverflow.value = false
  syncTextCount('')
  syncModelValue('')
}
@@ -541,7 +694,9 @@
watch(
  () => props.maxLength,
  () => {
    syncTextCount(getIncomingContent() || lastHtml.value)
    const html = editor.value?.getHtml() || getIncomingContent() || lastHtml.value
    syncTextCount(html)
    syncOverflowStateByLength(getTextLengthByHtml(html))
  },
)
src/views/userContent/contentCenter/agreementVersion.vue
@@ -691,13 +691,13 @@
<style scoped lang="scss">
.content {
  position: relative;
  height: calc(100vh - 88px);
  height: calc(100vh - 88px - 28px);
  width: 100%;
  background: #f5f7fa;
}
.page-body {
  height: calc(100vh - 88px);
  height: calc(100vh - 88px - 28px);
  overflow-y: auto;
  padding: 12px;
  scrollbar-width: none;
src/views/userContent/contentCenter/components/AgreementVersionModal.vue
@@ -49,8 +49,8 @@
      validator: () => {
        if (!plainTextLength.value)
          return new Error('请输入协议内容')
        if (plainTextLength.value > 5000)
          return new Error('协议内容最多输入5000个字符')
        // if (plainTextLength.value > 20000)
        //   return new Error('协议内容最多输入20000个字符')
        return true
      },
      trigger: ['blur', 'change', 'input'],
@@ -72,7 +72,14 @@
async function handleSave() {
  if (props.readonly)
    return
  await formRef.value?.validate()
  try {
    await formRef.value?.validate()
  } catch (errors) {
    // 表单校验不通过,直接拦截
    return
  }
  emit('save', {
    id: formModel.id,
    version: formModel.version,
@@ -113,7 +120,7 @@
          <div class="editor-font-tip">
            推荐字号:大标题(21)小标题(15)正文(14)
          </div>
          <AiRichTextEditor v-model="formModel.content" :read-only="readonly" height="360px" :maxLength="5000" />
          <AiRichTextEditor v-model="formModel.content" :read-only="readonly" height="360px" />
        </div>
      </NFormItem>
    </NForm>