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