From 01b7aaee963317f2bfeb6bc68094e7eee76f3b0c Mon Sep 17 00:00:00 2001
From: zjk <852185829@qq.com>
Date: 星期三, 01 七月 2026 18:15:44 +0800
Subject: [PATCH] Merge branch 'deploy-2.0' into feat/deploy-2.0

---
 src/views/system/appVersion/appVersionManage/index.vue  |   71 +++++++++++++++++------
 src/components/custom/Editor/AiRichTextEditor/index.vue |   99 ++++++++++++++++++++++----------
 2 files changed, 120 insertions(+), 50 deletions(-)

diff --git a/src/components/custom/Editor/AiRichTextEditor/index.vue b/src/components/custom/Editor/AiRichTextEditor/index.vue
index 58fa883..6996065 100644
--- a/src/components/custom/Editor/AiRichTextEditor/index.vue
+++ b/src/components/custom/Editor/AiRichTextEditor/index.vue
@@ -284,7 +284,11 @@
 
   const container = document.createElement('div')
   container.innerHTML = html
-  return (container.textContent || '').length
+  // 必须严格使用 textContent,绝对不能用 innerText。
+  // 因为 innerText 会根据 CSS 样式额外计算出换行和空格,导致总长度和底层 TreeWalker 遍历的节点长度不一致!
+  // 这种不一致会导致长文本截断算法失效。
+  const text = container.textContent || ''
+  return text.replace(/[\r\n\u200B-\u200D\uFEFF]/g, '').length
 }
 
 function truncateHtmlByTextLength(html: string, maxLength: number) {
@@ -301,17 +305,28 @@
   let currentNode = walker.nextNode()
   while (currentNode) {
     const nodeText = currentNode.nodeValue || ''
-    const nodeLength = nodeText.length
+    // 计算长度时,剔除不可见字符,避免长度计算偏大导致错误截断
+    const actualText = nodeText.replace(/[\r\n\u200B-\u200D\uFEFF]/g, '')
+    const nodeLength = actualText.length
 
-    if (currentLength + nodeLength > maxLength) {
-      const allowedLength = Math.max(0, maxLength - currentLength)
-      if (allowedLength > 0) {
-        currentNode.nodeValue = nodeText.substring(0, allowedLength)
-        currentLength += allowedLength
+    if (currentLength >= maxLength) {
+      nodesToRemove.push(currentNode)
+    }
+    else if (currentLength + nodeLength > maxLength) {
+      const allowedLength = maxLength - currentLength
+      // 为了确保保留原有的换行格式,我们在原字符串上进行子串截取
+      // 这里采用更精确的方式:逐字符遍历原字符串,直到有效字符达到 allowedLength
+      let sliceIndex = 0
+      let validCharCount = 0
+      while (sliceIndex < nodeText.length && validCharCount < allowedLength) {
+        const char = nodeText[sliceIndex]
+        if (!/[\r\n\u200B-\u200D\uFEFF]/.test(char)) {
+          validCharCount++
+        }
+        sliceIndex++
       }
-      else {
-        nodesToRemove.push(currentNode)
-      }
+      currentNode.nodeValue = nodeText.substring(0, sliceIndex)
+      currentLength += allowedLength
     }
     else {
       currentLength += nodeLength
@@ -321,14 +336,25 @@
   }
 
   nodesToRemove.forEach((node) => {
-    let target: Node | null = node
+    const parent = node.parentNode
+    if (!parent)
+      return
+
+    parent.removeChild(node)
+
+    let target: Node | null = parent
     while (target && target !== container && !target.textContent?.trim()) {
-      const parent: Node | null = target.parentNode
-      if (parent)
-        parent.removeChild(target)
-      target = parent
+      const ancestor = target.parentNode
+      if (ancestor)
+        ancestor.removeChild(target)
+      target = ancestor
     }
   })
+
+  // 如果容器内没有文本内容了,直接返回空字符串,避免残留空标签
+  if (!container.textContent?.replace(/[\r\n\u200B-\u200D\uFEFF]/g, '').trim()) {
+    return ''
+  }
 
   return container.innerHTML
 }
@@ -379,6 +405,10 @@
   if (!selection || selection.rangeCount === 0)
     return 0
 
+  // 当从外部复制富文本时,不应该算作编辑器内被选中的文本
+  if (editorRef.value && !editorRef.value.contains(selection.anchorNode))
+    return 0
+
   const range = selection.getRangeAt(0)
   const commonAncestor = range.commonAncestorContainer
   if (!editableRoot.value.contains(commonAncestor))
@@ -387,20 +417,17 @@
   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()
+  const maxLength = getEffectiveMaxLength()
+  if (maxLength === undefined)
+    return false
+
+  const selectedTextLength = getSelectedTextLength()
+  const currentLength = getTextLengthByHtml(getEditor()?.getHtml() || '')
+  return currentLength - selectedTextLength + incomingLength > maxLength
 }
 
 function handleBeforeInput(event: Event) {
@@ -411,7 +438,8 @@
   if (!inputEvent.inputType?.startsWith('insert'))
     return
 
-  if (inputEvent.inputType === 'insertFromPaste')
+  // 如果是粘贴操作,交由 onChange 进行柔性截断
+  if (inputEvent.inputType === 'insertFromPaste' || inputEvent.inputType === 'insertFromDrop')
     return
 
   const incomingLength = inputEvent.data?.length ?? 0
@@ -549,17 +577,24 @@
 
       const currentHtml = currentEditor.getHtml()
       const maxLength = getEffectiveMaxLength()
-      const textLength = currentEditor.getText().length
+      // 这里必须用纯文本长度判断,否则带格式的文本长度会被低估导致无法截断
+      const textLength = getTextLengthByHtml(currentHtml)
 
       if (maxLength !== undefined && textLength > maxLength) {
-        isTruncating.value = true
+        // 计算出截断后的合法 HTML
         const truncatedHtml = truncateHtmlByTextLength(currentHtml, maxLength)
-        currentEditor.setContent(truncatedHtml)
-        currentEditor.focusEnd()
         
+        // 【核心修复】必须使用 setTimeout 异步更新内容。
+        // 因为 ProseMirror 在处理粘贴事务时,如果同步调用 setContent 替换整个文档,会被底层引擎直接忽略!
         setTimeout(() => {
-          isTruncating.value = false
-        }, 10)
+          isTruncating.value = true
+          currentEditor.setContent(truncatedHtml)
+          currentEditor.focusEnd()
+          
+          setTimeout(() => {
+            isTruncating.value = false
+          }, 50)
+        }, 0)
 
         hasMaxLengthOverflow.value = true
         syncModelValue(truncatedHtml)
diff --git a/src/views/system/appVersion/appVersionManage/index.vue b/src/views/system/appVersion/appVersionManage/index.vue
index b26454d..1a2648e 100644
--- a/src/views/system/appVersion/appVersionManage/index.vue
+++ b/src/views/system/appVersion/appVersionManage/index.vue
@@ -245,9 +245,30 @@
   })
 }
 
+let androidAbortController: AbortController | null = null
+let iosAbortController: AbortController | null = null
+
+function resetFormAndUploads() {
+  if (androidAbortController) {
+    androidAbortController.abort()
+    androidAbortController = null
+  }
+  if (iosAbortController) {
+    iosAbortController.abort()
+    iosAbortController = null
+  }
+  androidUploading.value = false
+  iosUploading.value = false
+  resetForm()
+}
+
 function handleCancel() {
   showForm.value = false
-  resetForm()
+  resetFormAndUploads()
+}
+
+function handleClose() {
+  resetFormAndUploads()
 }
 
 function beforeUploadApk(data: { file: UploadFileInfo, fileList: UploadFileInfo[] }) {
@@ -259,6 +280,11 @@
 }
 
 async function customRequestAndroid(options: any) {
+  if (androidAbortController) {
+    androidAbortController.abort()
+  }
+  androidAbortController = new AbortController()
+
   androidUploading.value = true
   logAppVersionUpload('android upload triggered', {
     fileName: options?.file?.name,
@@ -272,7 +298,10 @@
   }
   try {
     // 安卓包改为前端直传:先换取上传地址,再直接上传到 OSS
-    const res = await customDirectUploadRequest(options as UploadCustomRequestOptions)
+    const res = await customDirectUploadRequest({
+      ...options,
+      signal: androidAbortController.signal,
+    } as UploadCustomRequestOptions)
     if (!res) {
       logAppVersionUpload('android upload result empty')
       formModel.value.androidFiles = []
@@ -297,6 +326,11 @@
 }
 
 async function customRequestIos(options: any) {
+  if (iosAbortController) {
+    iosAbortController.abort()
+  }
+  iosAbortController = new AbortController()
+
   iosUploading.value = true
   logAppVersionUpload('ios upload triggered', {
     fileName: options?.file?.name,
@@ -310,7 +344,10 @@
   }
   try {
     // iOS 包改为前端直传:上传成功后将 fileId 存到文件项 id 字段
-    const res = await customDirectUploadRequest(options as UploadCustomRequestOptions)
+    const res = await customDirectUploadRequest({
+      ...options,
+      signal: iosAbortController.signal,
+    } as UploadCustomRequestOptions)
     if (!res) {
       logAppVersionUpload('ios upload result empty')
       formModel.value.iosFiles = []
@@ -392,21 +429,19 @@
       </div>
 
       <n-modal
-        :show="showForm"
-        :mask-closable="false"
-        preset="card"
-        :title="formMode === 'add' ? '新增版本号' : '查看版本号'"
-        style="width: 600px"
-        :segmented="{
-          content: true,
-          action: true,
-        }"
-        :content-style="{
-          padding: '16px 24px 0px 24px',
-        }"
-        @close="handleCancel"
-      >
-        <n-form
+          v-model:show="showForm"
+          :mask-closable="false"
+          preset="card"
+          :title="formMode === 'add' ? '新增版本号' : '查看版本号'"
+          style="width: 600px"
+          :segmented="{ content: true, action: true }"
+          :content-style="{
+            padding: '16px 24px 0px 24px',
+          }"
+          :on-after-leave="handleClose"
+          :on-update:show="(val) => { if (!val) handleCancel() }"
+        >
+          <n-form
           ref="formRef"
           :model="formModel"
           :rules="rules"

--
Gitblit v1.7.1