Commit 22485358 by pangchong

refactor(api): 优化接口请求处理与表单提交方式

- 移除旧的错误信息智能识别和处理逻辑,精简响应码拦截代码
- 新增 postForm 方法支持自动处理 multipart/form-data,优化文件上传逻辑
- 删除下载文件相关的复杂实现,简化请求发送流程
- 在批量翻译功能中添加取消翻译支持,完善用户交互
- 优化编辑器选中节点逻辑,确保滚动时正确设置选中状态
- 调整编辑器工具栏分隔线的样式,提升界面一致性
- 修改搜索翻译模块的警示卡样式,增强视觉效果
- 移除多个无用的 utils 导入,精简编辑器相关代码
- 修正设置面板确认清空并退出弹窗调用,使用标准对象参数
- 新增 Splitter 组件的类型定义,调整相关实现导入方式
parent a05473b7
......@@ -93,77 +93,6 @@ const createService = (baseURL: string) => {
}
}
// 此处可根据后台全局的格式状态码做拦截或直接返回
if (json && json.code !== 200) {
// 1. 定义识别“技术性垃圾 ID”的逻辑
const isTechnicalId = (s: any) =>
!s ||
typeof s !== 'string' ||
s.trim() === '' ||
s.startsWith('ERRMSG.') || // 过滤标识符
s.startsWith('#{') || // 过滤占位符
/^[A-Z0-9_.]+$/.test(s) || // 过滤全大写/点号连接的代码名
(json && s === String(json.code)) // 过滤纯状态码
// 2. 初始回退信息
let errorMsg = json.msg || '请求发生错误'
// 如果在 RequestConfig 中指定了字段名,则以此为主 (最高优先级)
const customField = method.meta?.msgField
if (customField && json.data && typeof json.data === 'object' && json.data[customField]) {
errorMsg = json.data[customField]
} else {
// 3. 构建候选字段池 (按质量优先级排序)
const { default: i18n } = await import('@/locales')
const isZh = String(i18n.global.locale.value || i18n.global.locale)
.toLowerCase()
.includes('zh')
// 收集所有候选,优先处理 data 内部字段
const dataObj = json.data && typeof json.data === 'object' ? json.data : {}
const candidates = [dataObj.message, dataObj.remark, dataObj.msg, dataObj.i18nText, dataObj.msgData]
// 注入外层候选字段
candidates.push(json.msg)
candidates.push(json.msgData)
// 4. 智能筛选逻辑
for (let cand of candidates) {
if (!cand) continue
// 处理数组类型的错误信息(常见于 msgData)
if (Array.isArray(cand)) {
cand = cand.length > 0 ? cand[0] : null
}
if (!cand) continue
if (!isTechnicalId(cand)) {
// 如果是中文环境,进一步检查是否有中文字符,提升准确度
if (isZh && /[\u4e00-\u9fa5]/.test(cand!)) {
errorMsg = cand!
break
}
// 如果是非中文环境或未匹配到中文,但这个候选词不是技术 ID,我们也选它
if (!isZh || !errorMsg || isTechnicalId(errorMsg)) {
errorMsg = cand!
// 如果在非中文环境下找到了 i18nText,直接结束
if (!isZh && cand === json.data?.i18nText) break
}
}
}
}
// 【核心修复】将智能识别出的信息回写到响应对象中,确保业务层获取到的也是处理后的文案
json.msg = errorMsg
if (json.code === 100 || json.code === '100') {
window.$message.error(errorMsg)
window.location.hash = '/login'
throw new Error('未登录')
}
window.$message.error(errorMsg)
// 视业务约定是否抛出异常来中断后续 promise 处理
// throw new Error(errorMsg)
}
return json!
},
onError: (err, method) => {
......@@ -261,128 +190,42 @@ const createService = (baseURL: string) => {
})
.send(true)
},
/** 下载文件 (支持流式下载及自动两步验证模式) */
async download(url: string, data?: any, fileName?: string, config?: RequestConfig) {
const { showLoading = true, ...rest } = config || {}
const getProcessedBody = (dataObj: any) => {
if (dataObj instanceof FormData) return dataObj
const searchParams = new URLSearchParams()
if (dataObj && typeof dataObj === 'object') {
Object.keys(dataObj).forEach((key) => {
const val = dataObj[key]
searchParams.append(key, val === null || val === undefined ? '' : val)
/** 提交 multipart/form-data 数据,不设置 Content-Type,由浏览器自动携带 boundary
* - 普通值: `{ key: value }`
* - 带文件名的 Blob: `{ key: [blob, 'filename.ext'] }`
*/
postForm<T = any>(url: string, data?: FormData | Record<string, any>, config?: RequestConfig) {
const { showLoading = false, msgField, ...rest } = config || {}
let formData: FormData
if (data instanceof FormData) {
formData = data
} else {
formData = new FormData()
if (data) {
Object.entries(data).forEach(([key, val]) => {
if (val === null || val === undefined) return
// 支持 [Blob, filename] 元组格式,用于带文件名的文件上传
if (Array.isArray(val) && val.length === 2 && val[0] instanceof Blob) {
formData.append(key, val[0], val[1])
} else if (val instanceof Blob) {
formData.append(key, val)
} else {
formData.append(key, String(val))
}
})
}
return searchParams.toString()
}
const isFormData = data instanceof FormData
// 不设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
const headers = { ...rest.headers }
if (isFormData) {
delete headers['Content-Type']
delete headers['content-type']
} else {
headers['Content-Type'] = 'application/x-www-form-urlencoded'
}
try {
// 第一步:发送请求(可能是预检查 JSON,也可能是直接文件流)
const method = alovaInst.Post(url, getProcessedBody(data), {
...rest,
headers,
meta: { ...rest.meta, showLoading, isDownload: true }
})
const result = await method.send()
// 情况 A:返回的是 Response 对象(即已识别出的二进制流)
if (result instanceof Response) {
const blob = await result.blob()
this._triggerDownload(blob, fileName, result)
return true
}
// 情况 B:返回的是 JSON 对象(预检查成功)
const resData = result as any
if (resData && typeof resData === 'object' && (resData.code === 200 || resData.code === '200')) {
// 如果已经是带 down=Y 的请求返回了 JSON(可能是某些特殊接口),则不再重试
if (data?.down === 'Y') return true
// 自动执行第二步:携带 down=Y 获取正式文件流
const retryData = isFormData ? data : { ...data, down: 'Y' }
if (isFormData) {
;(retryData as FormData).append('down', 'Y')
}
const downloadMethod = alovaInst.Post(url, getProcessedBody(retryData), {
return alovaInst
.Post<ResponseData<T>>(url, formData, {
...rest,
headers,
meta: { ...rest.meta, showLoading: false, isDownload: true }
meta: { ...rest.meta, showLoading, msgField }
})
const dlRes = await downloadMethod.send()
if (dlRes instanceof Response) {
const blob = await dlRes.blob()
this._triggerDownload(blob, fileName, dlRes)
return true
}
}
return false
} catch (err) {
console.error('Download error:', err)
return false
}
},
/** 内部辅助:触发浏览器下载 */
_triggerDownload(blob: Blob, fileName?: string, response?: Response) {
let parsedFileName = fileName
if (!parsedFileName && response) {
const disposition = response.headers.get('content-disposition') || response.headers.get('Content-Disposition')
if (disposition) {
// 优先匹配 filename* (可能包含 UTF-8 编码格式)
const filenameStarRegex = /filename\*=utf-8''([^;\n]*)/i
const starMatches = filenameStarRegex.exec(disposition)
if (starMatches && starMatches[1]) {
try {
parsedFileName = decodeURIComponent(starMatches[1])
} catch (e) {
// Ignore
}
} else {
// 兜底匹配普通 filename
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
const matches = filenameRegex.exec(disposition)
if (matches && matches[1]) {
parsedFileName = matches[1].replace(/['"]/g, '')
try {
parsedFileName = decodeURIComponent(parsedFileName)
} catch (e) {
// Ignore
}
// 核心修复:如果文件名是 ISO-8859-1 编码的 UTF-8 字节串(常见于 Java 后端导出),则进行转换
if (parsedFileName && !/[^\x00-\xff]/.test(parsedFileName)) {
try {
const bytes = new Uint8Array(parsedFileName.split('').map((c) => c.charCodeAt(0)))
parsedFileName = new TextDecoder('utf-8').decode(bytes)
} catch (e) {
// Ignore
}
}
}
}
}
}
const downloadUrl = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = downloadUrl
a.download = parsedFileName || `export_${new Date().getTime()}.xlsx`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(downloadUrl)
document.body.removeChild(a)
.send(true)
},
// 对外暴露 alova 实例,以便使用 useRequest 或者其他 hook
alova: alovaInst
......
......@@ -466,7 +466,10 @@ const importPrefs = async () => {
const clearAndLogout = async () => {
try {
await window.$dialog.warning('确认清空并退出', '确定要清空所有本地缓存并退出登录吗?此操作不可逆。')
await window.$dialog.warning({
title: '确认清空并退出',
content: '确定要清空所有本地缓存并退出登录吗?此操作不可逆。'
})
localStorage.clear()
router.push('/login')
window.$message.success('已清空并退出')
......
......@@ -10,7 +10,6 @@ import { setupNaiveDefaults } from '@/plugins/naive-ui-defaults'
// 设置 Naive UI 组件默认属性
setupNaiveDefaults()
import { setupNaiveDiscreteApi } from '@/utils/naive'
const app = createApp(App)
......
import type { Ref } from 'vue'
import type { XmlNode } from '@/types/xmlNode'
import type { EditorState } from './types'
import { createDefaultAttributes, canAddChild } from '@/utils/dtdManager'
import { parseXmlToTree } from '@/utils/xmlParser'
import { useAppStore } from '@/store/app'
import { CHINESE_FIRST_PARA_TAGS } from '@/configs/xmlTags'
......
......@@ -24,7 +24,6 @@
<script setup lang="ts">
import { SettingsOutline } from '@vicons/ionicons5'
import type { XmlNode } from '@/types/xmlNode'
import { getElementAttributes } from '@/utils/dtdManager'
import { useAttributeEditor } from './functionals'
const props = defineProps<{
......
import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
import { serializeTreeToXml } from '@/utils/xmlParser'
import { getElementRule, isMixedContentElement } from '@/utils/dtdManager'
import type { EditAreaContextMenuProps } from '../constants'
// 导入共享弹窗状态
......
......@@ -311,6 +311,9 @@ export function useEditorPanel() {
return
}
el.scrollIntoView({ behavior: 'auto', block: 'center' })
if (foundNodeId && foundNodeId !== editorStore.selectedNodeId) {
editorStore.setSelectedNodeId(foundNodeId)
}
return
}
......@@ -344,6 +347,9 @@ export function useEditorPanel() {
}
if (targetEl) {
targetEl.scrollIntoView({ behavior: 'auto', block: 'center' })
if (subFoundId && subFoundId !== editorStore.selectedNodeId) {
editorStore.setSelectedNodeId(subFoundId)
}
}
}, 100)
})
......
......@@ -73,6 +73,22 @@ export function useBatchTranslate() {
}
}
const cancelTranslation = () => {
if (!loading.value) return
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
loading.value = false
completed.value = false
progress.value = 0
resultFileId.value = ''
resultDownloadUrl.value = ''
progressText.value = '正在准备翻译文档...'
addLog('翻译已取消,返回配置页面')
window.$message.info('已取消翻译')
}
const open = async () => {
showModal.value = true
if (loading.value || completed.value) {
......@@ -80,7 +96,7 @@ export function useBatchTranslate() {
}
reset()
try {
const res = (await service.get('/xml/tags')) as any as { success: boolean; message: string; tags: string[] }
const res = await service.get('/xml/tags')
if (res && res.success && res.tags && res.tags.length > 0) {
availableTags.value = res.tags
// 自动勾选所有获取到的标签
......@@ -98,7 +114,7 @@ export function useBatchTranslate() {
pollInterval = setInterval(async () => {
try {
const statusRes = (await service.get(`/translation/status/${fileId}`)) as any as TranslationStatusResponse
const statusRes = await service.get(`/translation/status/${fileId}`)
if (statusRes) {
if (statusRes.status === 'completed') {
clearInterval(pollInterval)
......@@ -159,28 +175,19 @@ export function useBatchTranslate() {
addLog(`文档序列化完成,大小: ${Math.round(blob.size / 1024)} KB。准备上传...`)
addLog(`请求配置: 模型 = ${form.value.model_name}, 目标标签 = [${form.value.target_tags.join(', ')}]`)
const formData = new FormData()
formData.append('files', blob, 'document.xml')
if (form.value.model_name) {
formData.append('model_name', form.value.model_name)
}
formData.append('search_direction', form.value.search_direction)
formData.append('target_tags', form.value.target_tags.join(','))
if (form.value.suffix) {
formData.append('suffix', form.value.suffix)
}
formData.append('save_to_database', String(form.value.save_to_database))
formData.append('use_parallel_method', String(form.value.use_parallel_method))
const dbPartition = form.value.save_to_database ? form.value.database_partition_name : undefined
if (dbPartition) {
formData.append('database_partition_name', dbPartition)
}
const res = (await service.post('/translate/xml', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})) as any as TranslateXmlResponse
const res = await service.postForm('/translate/xml', {
query_partition_names: import.meta.env.VITE_PARTITION_NAME,
files: [blob, 'document.xml'],
...(form.value.model_name ? { model_name: form.value.model_name } : {}),
search_direction: form.value.search_direction,
target_tags: form.value.target_tags.join(','),
...(form.value.suffix ? { suffix: form.value.suffix } : {}),
save_to_database: form.value.save_to_database,
use_parallel_method: form.value.use_parallel_method,
...(form.value.save_to_database && form.value.database_partition_name
? { database_partition_name: form.value.database_partition_name }
: {})
})
if (res && res.success && res.file_id) {
resultFileId.value = res.file_id
......@@ -259,6 +266,7 @@ export function useBatchTranslate() {
open,
reset,
startTranslation,
cancelTranslation,
downloadResult,
importResult,
logs
......
......@@ -78,8 +78,9 @@
<template #footer>
<div class="flex justify-end gap-3 w-full">
<!-- 翻译中状态:允许隐入后台 -->
<!-- 翻译中状态:允许隐入后台或取消 -->
<template v-if="loading">
<CommonButton type="error" secondary @click="cancelTranslation">取消翻译</CommonButton>
<CommonButton type="primary" secondary @click="showModal = false">后台运行 (隐藏)</CommonButton>
</template>
<!-- 翻译完成状态 -->
......@@ -118,6 +119,7 @@ const {
open,
reset,
startTranslation,
cancelTranslation,
downloadResult,
importResult,
logs
......
import { type ExtractResponse } from '../constants'
import { useEditorStore } from '@/store/editor'
import { serializeTreeToXml } from '@/utils/xmlParser'
import { getBaseOrigin } from '@/utils/render'
export function useExtractTranslate() {
const editorStore = useEditorStore()
......
import { type SearchResult, type SearchResponse, type SearchRequest } from '../constants'
import { serviceManage } from '@/api'
import dayjs from 'dayjs'
export function useSearchTranslate() {
......
......@@ -160,7 +160,7 @@
<div class="flex flex-col gap-4">
<!-- 警示卡片:说明删除操作 -->
<div
class="bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg p-4 text-xs text-amber-800 dark:text-amber-300 flex items-start gap-2"
class="bg-[color-mix(in_srgb,var(--warning-color)_10%,transparent)] border border-[color-mix(in_srgb,var(--warning-color)_25%,transparent)] rounded-lg p-4 text-xs text-warning flex items-start gap-2"
>
<n-icon size="18" class="mt-0.5"><alert-circle-outline /></n-icon>
<div class="flex-1 flex flex-col gap-1">
......
import { useEditorStore, createTableStructure } from '@/store/editor'
import { useAppStore } from '@/store/app/index'
import { canAddChild } from '@/utils/dtdManager'
import type { XmlNode } from '@/types/xmlNode'
/**
......
......@@ -25,7 +25,7 @@
</div>
</div>
<div class="w-[1px] h-4 bg-divider mx-1 flex-shrink-0"></div>
<div class="w-px h-5 flex-shrink-0 mx-2" style="background: #d0d0d0"></div>
<!-- 组 2:插入元素按钮组 -->
<div class="flex items-center gap-0.5 flex-shrink-0">
......@@ -51,9 +51,8 @@
</button>
</div>
<div class="w-[1px] h-4 bg-divider mx-1 flex-shrink-0"></div>
<div class="w-px h-5 flex-shrink-0 mx-3" style="background: #d0d0d0"></div>
<!-- 组 3:翻译辅助组 -->
<div class="flex items-center gap-0.5 flex-shrink-0">
<button
type="button"
......@@ -94,7 +93,7 @@
</button>
</div>
<div class="w-[1px] h-4 bg-divider mx-1 flex-shrink-0"></div>
<div class="w-px h-5 flex-shrink-0 mx-3" style="background: #d0d0d0"></div>
<!-- 组 4:文件管理与导入导出 -->
<div class="flex items-center gap-0.5 flex-shrink-0">
......@@ -136,7 +135,7 @@
</button>
</div>
<div class="w-[1px] h-4 bg-divider mx-1 flex-shrink-0"></div>
<div class="w-px h-5 flex-shrink-0 mx-2" style="background: #d0d0d0"></div>
<!-- 组 5:历史操作 (撤销 / 重做) -->
<div class="flex items-center gap-0.5 flex-shrink-0">
......
import type { FormInst } from 'naive-ui'
import { getElementAttributes, createDefaultAttributes, isTextOnlyElement, isMixedContentElement } from '@/utils/dtdManager'
import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app'
......
import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
import type { BlockedNodeDetail, SafeNodeItem } from '../../../constants'
import { canDeleteChild } from '@/utils/dtdManager'
export function useBatchDeleteConfirmModal(emit: (event: 'confirm') => void) {
const editorStore = useEditorStore()
......
......@@ -19,8 +19,6 @@ import {
import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app'
import type { XmlNode } from '@/types/xmlNode'
import { serializeTreeToXml } from '@/utils/xmlParser'
import { isMixedContentElement, isTextOnlyElement, sortChildrenByDtd } from '@/utils/dtdManager'
import type { CheckRuleData, InsertMode, FlatNode, TranslationResponse } from '../constants'
import { DOCUMENT_LIKE_TAGS } from '../constants'
import { STRUCTURAL_TAGS, WARNING_LIKE_TAGS, NOTE_AND_REF_TAGS, COLORED_TAGS } from '@/configs/xmlTags'
......
export interface SplitterProps {
width: number
collapsed: boolean
collapseThreshold?: number
defaultWidth?: number
maxRatio?: number
}
export interface SplitterProps {
width: number
collapsed: boolean
collapseThreshold?: number
defaultWidth?: number
maxRatio?: number
}
import { type SplitterProps } from '../constants'
export function useSplitter(
props: SplitterProps,
......
import { useEditorStore } from '@/store/editor'
import { loadDtdSchema, getElementRule } from '@/utils/dtdManager'
import { parseXmlToTreeAsync, serializeTreeToXml } from '@/utils/xmlParser'
import type { XmlNode } from '@/types/xmlNode'
import { DEFAULT_FILE_NAME } from '../constants'
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment