Commit 37792b38 by pangchong

feat(api): 完善API请求适配及添加OpenAPI规范文档

- 添加VITE_PARTITION_NAME环境变量,支持动态配置分区名称
- 在vite.config.ts中新增本地开发代理配置,适配后端接口路径
- 优化api/index.ts中请求处理逻辑,支持FormData的Content-Type自动处理
- 新增postJson和deleteJson方法以支持JSON格式的POST和DELETE请求
- 统一接口返回码判断,增加code和success字段缺失时设为200的兼容处理
- 调整接口基础路径获取逻辑,区分管理端和普通接口
- 导出serviceManage用于管理端接口调用
- 制定并完善全局组件使用规范,规范表单及标签组件使用
- 完善XML编辑器节点操作偏好设置联动逻辑,支持自动展开节点插入行为
- 新增完整的OpenAPI JSON规范文件,覆盖翻译、XML处理、系统状态等多个接口
- OpenAPI文档包含翻译功能、批量处理、文件下载、标签管理等丰富的业务能力描述
parent 48fdf105
VITE_PARTITION_NAME = 'from_project_001'
\ No newline at end of file
......@@ -189,12 +189,15 @@
---
## 16. 全局选择框与输入框占位符规范
- **统一使用 `CommonSelect`**:在所有业务界面中,**禁止**直接使用 Naive UI 原生的 `n-select`**强制要求**统一使用项目封装好的全局组件 `CommonSelect`
## 16. 全局常用表单、选择与状态组件规范 (强制)
- **统一使用全局组件**:在所有业务界面中,对于选择框、多选框、数字输入框与状态标签,**禁止**直接使用 Naive UI 原生的组件,**强制要求**统一使用项目封装好的全局组件进行替代:
- **下拉选择框**:使用 **`CommonSelect`** 替代 `n-select`。对 `CommonSelect` 监听值变更回调时,**必须**绑定组件导出的 **`@change`** 事件,**禁止**使用 `@update:value`
- **数字输入框**:使用 **`CommonInputNumber`** 替代 `n-input-number`。若绑定业务数据需要强制保留 number 类型,应显式配置属性 **`:strict="true"`**
- **多选/单选框组**:使用 **`CommonCheckbox`** 替代 `n-checkbox-group` 及原生的 `v-for` 循环列表。对于需要使用单个 `n-checkbox` 作为独立布尔值开关的场景,可通过将绑定的响应式变量类型转为 `string[]`,并对接传入 `:options="[{ label: '文案', value: 'yes' }]"` 来完全对齐 `<CommonCheckbox>` 全局组件的使用。
- **状态标签**:使用 **`CommonTag`** 替代 `n-tag`。支持插槽、字典配置及 autoColor 自适应着色。
- **禁用冗余的 placeholder 占位符**
- 在编写 `CommonSelect``n-input` 组件时,对于普通属性、枚举选择或内容输入,**禁止**手动添加 `placeholder="请选择"``placeholder="选择"``placeholder="请输入"` 等冗余的占位字符。
- 只在具有明确指示性特指文案的业务场景下(如“请选择切换动画”等特有场景),方可添加具体的占位文本。
- **事件监听规范**:对 `CommonSelect` 组件监听值变更回调时,**必须**绑定组件导出的 **`@change`** 事件(如 `@change="onTagChange"`),**禁止**使用 `@update:value` 监听器,以保障数据联动更新的生命周期一致性。
---
......@@ -202,3 +205,12 @@
- **禁止直接在业务代码或常量中硬编码 XML 节点标签数组或映射**(如 `['PARA', 'PARAC', 'TITLE']` 等元素集合)。
- **强制要求统一收拢管理**:所有关于 DTD 定义的各类 XML 标签、节点分类(如文档型标签、列表条目标签、复杂容器标签、头部元数据标签等)必须统一在 [xmlTags.ts](file:///e:/refactor-Editor/Ifar-Xml-Editor/src/configs/xmlTags.ts) 文件中进行定义和导出。
- **使用规范**:在任何业务组件、逻辑 Hook 或常量模块中需要判断或使用节点标签集合时,必须先在 `xmlTags.ts` 中维护,随后在对应的代码文件中通过 `import` 引用,严禁私自在局部硬编码私有数组,以确保 DTD 分类规则的一致性与后期维护的便捷度。
---
## 18. XML 编辑器节点操作与偏好设置联动规范
- **偏好设置与行为控制**:节点操作(如新增节点、粘贴 XML 片段等)和表格操作(如新增行、新增列等)应当尊重用户的全局偏好设置。
- **插入节点自动展开 (autoExpandOnInsert) 规范**
- 如果开启了“插入节点自动展开”偏好设置,在树上新增/粘贴节点或在表格新增行列时,应当自动展开新增节点自身(以及包含的子节点结构),并展开对应的父节点。
- 如果关闭了该设置,在新增/粘贴节点或新增表格行列时,**必须跳过**对新增节点、其子节点以及其所有父/祖先节点的展开操作,保持原有的折叠状态。
- **实现要求**:为防止选中节点更新(`selectedNodeId`)时自动触发的树祖先节点级联展开动作,必须在选中变化前设置 store 的 `skipExpandOnSelect` 状态进行拦截限制,随后在 `nextTick` 中恢复,以实现无级联展开的静默插入。
......@@ -50,7 +50,14 @@ const createService = (baseURL: string) => {
if (isJson) {
json = (await response.json()) as ResponseData
if (json) {
if (json.code === 200 || json.code === '200' || json.code === 0 || json.code === '0' || json.success === true) {
if (
json.code === 200 ||
json.code === '200' ||
json.code === 0 ||
json.code === '0' ||
json.success === true ||
(json.code === undefined && json.success === undefined)
) {
json.code = 200
return json
}
......@@ -76,7 +83,14 @@ const createService = (baseURL: string) => {
}
if (json) {
if (json.code === 200 || json.code === '200' || json.code === 0 || json.code === '0' || json.success === true) {
if (
json.code === 200 ||
json.code === '200' ||
json.code === 0 ||
json.code === '0' ||
json.success === true ||
(json.code === undefined && json.success === undefined)
) {
json.code = 200
}
}
......@@ -176,7 +190,11 @@ const createService = (baseURL: string) => {
const isFormData = data instanceof FormData
const headers = { ...rest.headers }
if (!isFormData) {
if (isFormData) {
// FormData 不需要手动设置 Content-Type,浏览器会自动处理并加上 boundary
delete headers['Content-Type']
delete headers['content-type']
} else {
headers['Content-Type'] = 'application/x-www-form-urlencoded'
}
......@@ -203,13 +221,47 @@ const createService = (baseURL: string) => {
})
.send(true)
},
postJson<T = any>(url: string, data?: any, config?: RequestConfig) {
const { showLoading = false, msgField, ...rest } = config || {}
const headers = {
'Content-Type': 'application/json',
...rest.headers
}
return alovaInst
.Post<ResponseData<T>>(url, JSON.stringify(data), {
...rest,
headers,
meta: { ...rest.meta, showLoading, msgField }
})
.send(true)
},
put<T = any>(url: string, data?: any, config?: RequestConfig) {
const { showLoading = false, msgField, ...rest } = config || {}
return alovaInst.Put<ResponseData<T>>(url, data, { ...rest, meta: { ...rest.meta, showLoading, msgField } }).send(true)
const isFormData = data instanceof FormData
const headers = { ...rest.headers }
if (isFormData) {
delete headers['Content-Type']
delete headers['content-type']
}
return alovaInst.Put<ResponseData<T>>(url, data, { ...rest, headers, meta: { ...rest.meta, showLoading, msgField } }).send(true)
},
delete<T = any>(url: string, config?: RequestConfig) {
const { showLoading = false, msgField, ...rest } = config || {}
return alovaInst.Delete<ResponseData<T>>(url, { ...rest, meta: { ...rest.meta, showLoading, msgField } }).send(true)
return alovaInst.Delete<ResponseData<T>>(url, undefined, { ...rest, meta: { ...rest.meta, showLoading, msgField } }).send(true)
},
deleteJson<T = any>(url: string, data?: any, config?: RequestConfig) {
const { showLoading = false, msgField, ...rest } = config || {}
const headers = {
'Content-Type': 'application/json',
...rest.headers
}
return alovaInst
.Delete<ResponseData<T>>(url, JSON.stringify(data), {
...rest,
headers,
meta: { ...rest.meta, showLoading, msgField }
})
.send(true)
},
/** 下载文件 (支持流式下载及自动两步验证模式) */
async download(url: string, data?: any, fileName?: string, config?: RequestConfig) {
......@@ -229,7 +281,10 @@ const createService = (baseURL: string) => {
const isFormData = data instanceof FormData
const headers = { ...rest.headers }
if (!isFormData) {
if (isFormData) {
delete headers['Content-Type']
delete headers['content-type']
} else {
headers['Content-Type'] = 'application/x-www-form-urlencoded'
}
......@@ -339,15 +394,27 @@ const createService = (baseURL: string) => {
const getBaseURL = () => {
// 开发环境使用相对路径以便配合 vite.config.ts 的代理服务
if (import.meta.env.DEV) {
return '/api'
return '/translations'
}
// 生产/打包环境下,若配置了 VITE_APP_PROXY_URL,则将接口基准地址指向对应地址的 /api 路径
// 生产/打包环境下,若配置了 VITE_APP_PROXY_URL,则将接口基准地址指向对应地址的 /translations 路径
const proxyUrl = import.meta.env.VITE_APP_PROXY_URL
if (proxyUrl) {
const cleanUrl = proxyUrl.trim().replace(/\/$/, '')
return cleanUrl.endsWith('/api') ? cleanUrl : `${cleanUrl}/api`
return cleanUrl.endsWith('/translations') ? cleanUrl : `${cleanUrl}/translations`
}
return '/translations'
}
const getManageBaseURL = () => {
if (import.meta.env.DEV) {
return '/translationsManage'
}
const proxyUrl = import.meta.env.VITE_APP_MANAGE_PROXY_URL
if (proxyUrl) {
const cleanUrl = proxyUrl.trim().replace(/\/$/, '')
return cleanUrl.endsWith('/translationsManage') ? cleanUrl : `${cleanUrl}/translationsManage`
}
return '/api'
return '/translationsManage'
}
// 当前主要的域名服务
......@@ -355,6 +422,7 @@ export const service = createService(getBaseURL())
// 后续支持多个域名的示例(需要调用另一域名的接口时,直接导出并使用即可)
export const serviceDomain2 = createService('/apiDomain2')
export const serviceManage = createService(getManageBaseURL())
// 兼容老代码中直接引用 alovaInstance 的方式
export const alovaInstance = service.alova
......@@ -22,11 +22,11 @@ const props = defineProps({
/** 绑定的值 (v-model) */
value: {
type: [Array, String] as PropType<any[] | string | null | undefined>,
default: () => []
default: undefined
},
modelValue: {
type: [Array, String] as PropType<any[] | string | null | undefined>,
default: () => []
default: undefined
},
/** 静态选项 */
options: {
......
......@@ -235,6 +235,18 @@
</div>
<n-switch v-model:value="appStore.collapsed" />
</div>
<n-divider class="my-4" />
<div class="text-sm font-semibold mb-3" :style="{ color: themeVars.textColor2 }">编辑器设置</div>
<!-- 自动展开新增节点 -->
<div class="flex items-center justify-between py-3" :style="{ borderColor: themeVars.dividerColor }">
<div>
<div class="text-sm" :style="{ color: themeVars.textColor1 }">插入节点自动展开</div>
<div class="text-xs mt-0.5" :style="{ color: themeVars.textColor3 }">插入/粘贴节点及表格行列时,自动展开相关节点</div>
</div>
<n-switch v-model:value="appStore.autoExpandOnInsert" />
</div>
</div>
</n-tab-pane>
......@@ -395,6 +407,7 @@ const resetPrefs = () => {
appStore.fixedHeader = false
appStore.collapsed = false
appStore.transitionName = 'none'
appStore.autoExpandOnInsert = true
customColor.value = '#165DFF'
applyAndSave()
window.$message.success('已重置为默认设置')
......@@ -409,7 +422,8 @@ const copyPrefs = async () => {
isDark: appStore.isDark,
colorWeak: appStore.colorWeak,
grayMode: appStore.grayMode,
transitionName: appStore.transitionName
transitionName: appStore.transitionName,
autoExpandOnInsert: appStore.autoExpandOnInsert
},
null,
2
......@@ -439,6 +453,7 @@ const importPrefs = async () => {
if (typeof conf.colorWeak === 'boolean') appStore.colorWeak = conf.colorWeak
if (typeof conf.grayMode === 'boolean') appStore.grayMode = conf.grayMode
if (conf.transitionName) appStore.transitionName = conf.transitionName
if (typeof conf.autoExpandOnInsert === 'boolean') appStore.autoExpandOnInsert = conf.autoExpandOnInsert
applyAndSave()
window.$message.success('偏好设置已成功导入并应用')
......
......@@ -19,7 +19,8 @@ export const useAppStore = defineStore('app', {
loadingText: '加载中...',
transitionName: 'none',
settingsPinned: false,
settingsOpen: false
settingsOpen: false,
autoExpandOnInsert: true
}),
actions: {
toggleSidebar() {
......
......@@ -19,4 +19,5 @@ export interface AppState {
transitionName: string
settingsPinned: boolean
settingsOpen: boolean
autoExpandOnInsert: boolean // 新增/粘贴节点及表格行列时自动展开
}
......@@ -14,4 +14,6 @@ export interface EditorState {
redoStack: Snapshot[] // 保存 Snapshot 重做快照
lastUndoRedoTime: number // 回退重做动作的时间戳,用于触发视口重定位
editorZoom: number // 编辑器文字字号缩放比例 (80 - 200)
expandedKeys: string[] // 全局的节点展开状态
skipExpandOnSelect: boolean // 在选中时是否跳过祖先展开逻辑
}
export interface TranslateXmlOptions {
database_partition_name?: string | null
model_name?: string
search_direction?: 'en_to_zh' | 'zh_to_en'
target_tags?: string
suffix?: string
save_to_database?: boolean
use_parallel_method?: boolean
enable_nested_translation?: boolean
skip_tags?: string
}
export interface TranslateXmlResponse {
success: boolean
message: string
file_id: string
}
export interface TranslationStatusResponse {
status: 'pending' | 'processing' | 'completed' | 'failed'
progress: number
message?: string
error?: string
download_url?: string
}
import { type TranslateXmlOptions, type TranslateXmlResponse, type TranslationStatusResponse } from '../constants'
import { useEditorStore } from '@/store/editor'
import { serializeTreeToXml, parseXmlToTreeAsync } from '@/utils/xmlParser'
import { getBaseOrigin } from '@/utils/render'
export function useBatchTranslate() {
const editorStore = useEditorStore()
const showModal = ref(false)
const loading = ref(false)
const completed = ref(false)
const progress = ref(0)
const progressText = ref('正在准备翻译文档...')
const resultFileId = ref('')
const resultDownloadUrl = ref('')
const availableTags = ref<string[]>(['PARAC', 'TITLEC'])
const availableTagOptions = computed(() => {
return availableTags.value.map((tag) => ({ label: tag, value: tag }))
})
const form = ref({
model_name: 'glm-4-flash',
search_direction: 'en_to_zh',
target_tags: ['PARAC', 'TITLEC'],
suffix: '',
use_parallel_method: true,
save_to_database: false,
database_partition_name: import.meta.env.VITE_PARTITION_NAME
})
const modelOptions = [
{ label: 'GLM 4 Flash (速度快,经济)', value: 'glm-4-flash' },
{ label: 'DeepSeek Chat (高质量翻译)', value: 'deepseek-chat' },
{ label: 'GLM 4 Air (轻量高效)', value: 'glm-4-air' }
]
const directionOptions = [
{ label: '英译中 (EN -> ZH)', value: 'en_to_zh' },
{ label: '中译英 (ZH -> EN)', value: 'zh_to_en' }
]
const statusLabel = computed(() => {
if (loading.value) return '翻译中'
if (completed.value) return '已完成'
return '未开始'
})
const statusType = computed(() => {
if (loading.value) return 'info'
if (completed.value) return 'success'
return 'default'
})
const reset = () => {
loading.value = false
completed.value = false
progress.value = 0
progressText.value = '正在准备翻译文档...'
resultFileId.value = ''
resultDownloadUrl.value = ''
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
const open = async () => {
showModal.value = true
if (loading.value || completed.value) {
return
}
reset()
try {
const res = (await service.get('/xml/tags')) as any as { success: boolean; message: string; tags: string[] }
if (res && res.success && res.tags && res.tags.length > 0) {
availableTags.value = res.tags
// 自动勾选所有获取到的标签
form.value.target_tags = [...res.tags]
}
} catch (e) {
console.error('获取可翻译标签失败:', e)
}
}
let pollInterval: any = null
const startPolling = (fileId: string) => {
if (pollInterval) clearInterval(pollInterval)
pollInterval = setInterval(async () => {
try {
const statusRes = (await service.get(`/translation/status/${fileId}`)) as any as TranslationStatusResponse
if (statusRes) {
if (statusRes.status === 'completed') {
clearInterval(pollInterval)
pollInterval = null
progress.value = 100
progressText.value = '翻译完成!'
loading.value = false
completed.value = true
// 构造下载链接
let dlUrl = statusRes.download_url
if (dlUrl) {
if (dlUrl.startsWith('/') && !dlUrl.startsWith('/translations')) {
dlUrl = `/translations${dlUrl}`
}
dlUrl = `${getBaseOrigin()}${dlUrl}`
}
resultDownloadUrl.value = dlUrl || `${getBaseOrigin()}/translations/download/xml/${fileId}`
} else if (statusRes.status === 'failed') {
clearInterval(pollInterval)
pollInterval = null
loading.value = false
window.$message.error(`翻译失败: ${statusRes.error || statusRes.message || '服务器内部错误'}`)
} else {
// 更新进度
const currentProgress = statusRes.progress || 0
progress.value = Math.max(30, Math.min(95, Math.floor(currentProgress)))
progressText.value = statusRes.message || '翻译正在进行中...'
}
}
} catch (e) {
console.error('轮询状态异常:', e)
}
}, 1500)
}
const startTranslation = async () => {
if (!editorStore.xmlTree) {
window.$message.error('编辑器中无 XML 数据')
return
}
loading.value = true
progress.value = 5
progressText.value = '正在序列化当前 XML...'
try {
const xmlContent = serializeTreeToXml(editorStore.xmlTree, 0, true)
const blob = new Blob([xmlContent], { type: 'application/xml;charset=utf-8;' })
progress.value = 15
progressText.value = '正在上传文件至翻译服务器...'
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
if (res && res.success && res.file_id) {
resultFileId.value = res.file_id
progress.value = 30
progressText.value = '翻译任务已创建,正在排队等待处理...'
// 开始轮询翻译状态
startPolling(res.file_id)
} else {
throw new Error(res.message || '创建翻译任务失败')
}
} catch (err: any) {
loading.value = false
window.$message.error(`翻译出错: ${err.message || err}`)
}
}
const downloadResult = () => {
if (!resultFileId.value) return
const url = resultDownloadUrl.value || `${getBaseOrigin()}/translations/download/xml/${resultFileId.value}`
const link = document.createElement('a')
link.href = url
link.download = `translated_${Date.now()}.xml`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const importResult = async () => {
if (!resultFileId.value) return
window.$loading?.show('获取并解析翻译后的 XML 文件...')
try {
const url = resultDownloadUrl.value || `${getBaseOrigin()}/translations/download/xml/${resultFileId.value}`
const res = await fetch(url)
if (!res.ok) throw new Error('获取文件失败')
const xmlText = await res.text()
const tree = await parseXmlToTreeAsync(xmlText)
editorStore.setXmlTree(tree)
window.$message.success('翻译后的 XML 已成功导入编辑器!')
showModal.value = false
} catch (err: any) {
window.$message.error(`导入失败: ${err.message || err}`)
} finally {
window.$loading?.hide()
}
}
onBeforeUnmount(() => {
if (pollInterval) {
clearInterval(pollInterval)
}
})
return {
showModal,
loading,
completed,
progress,
progressText,
resultFileId,
resultDownloadUrl,
availableTags,
availableTagOptions,
form,
modelOptions,
directionOptions,
statusLabel,
statusType,
open,
reset,
startTranslation,
downloadResult,
importResult
}
}
<template>
<CommonModal
v-model="showModal"
title="批量 XML 翻译"
:width="600"
:show-confirm="false"
:show-cancel="false"
>
<template #header-extra>
<n-tag :type="statusType" size="small">{{ statusLabel }}</n-tag>
</template>
<div class="py-2">
<n-form v-if="!loading && !completed" label-placement="left" label-width="120" size="medium">
<n-form-item label="翻译模型">
<CommonSelect v-model:value="form.model_name" :options="modelOptions" />
</n-form-item>
<n-form-item label="翻译方向">
<CommonSelect v-model:value="form.search_direction" :options="directionOptions" />
</n-form-item>
<n-form-item label="目标翻译标签">
<div class="flex flex-col gap-2 w-full">
<CommonCheckbox v-model:value="form.target_tags" :options="availableTagOptions" />
<span class="text-xs text-color3">仅翻译所勾选的 XML 节点标签</span>
</div>
</n-form-item>
<n-form-item label="翻译标签后缀">
<n-input v-model:value="form.suffix" placeholder="默认无后缀,如填 'Z' 则 PARA 翻译为 PARAZ" />
</n-form-item>
<n-form-item label="并行处理模式">
<n-space align="center">
<n-switch v-model:value="form.use_parallel_method" />
<span class="text-xs text-color3">开启并行模式可显著提升大型文件翻译速度</span>
</n-space>
</n-form-item>
<n-form-item label="保存翻译至 Milvus">
<n-space align="center">
<n-switch v-model:value="form.save_to_database" />
<span class="text-xs text-color3">将翻译对照结果保存到 Milvus 数据库以供以后检索</span>
</n-space>
</n-form-item>
<n-form-item v-if="form.save_to_database" label="Milvus 分区名">
<n-input v-model:value="form.database_partition_name" placeholder="请输入数据库分区名称" />
</n-form-item>
</n-form>
<!-- 翻译进行中/进度条 -->
<div v-else-if="loading" class="flex flex-col items-center justify-center py-6 gap-4">
<div class="text-sm font-semibold text-color1">{{ progressText }}</div>
<div class="w-full px-8">
<n-progress
type="line"
:percentage="progress"
:indicator-placement="'inside'"
processing
status="info"
/>
</div>
</div>
<!-- 翻译完成/结果展示 -->
<div v-else-if="completed" class="flex flex-col items-center justify-center py-6 gap-4 text-center">
<n-icon size="48" class="text-success">
<checkmark-circle-outline />
</n-icon>
<div class="text-base font-bold text-success">XML 文档批量翻译完成!</div>
<div class="text-xs text-color3">所有指定标签的文本已翻译成功。你可以直接导入当前编辑器或下载文件。</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3 w-full">
<!-- 翻译中状态:允许隐入后台 -->
<template v-if="loading">
<CommonButton type="primary" secondary @click="showModal = false">
后台运行 (隐藏)
</CommonButton>
</template>
<!-- 翻译完成状态 -->
<template v-else-if="completed">
<CommonButton @click="reset">重新翻译</CommonButton>
<CommonButton type="warning" secondary @click="downloadResult">下载 XML 文件</CommonButton>
<CommonButton type="primary" @click="importResult">直接导入编辑器</CommonButton>
</template>
<!-- 未开始配置状态 -->
<template v-else>
<CommonButton @click="showModal = false">取消</CommonButton>
<CommonButton type="primary" @click="startTranslation">开始翻译</CommonButton>
</template>
</div>
</template>
</CommonModal>
</template>
<script setup lang="ts">
import { CheckmarkCircleOutline } from '@vicons/ionicons5'
import { useBatchTranslate } from './functionals'
const {
showModal,
loading,
completed,
progress,
progressText,
availableTags,
availableTagOptions,
form,
modelOptions,
directionOptions,
statusLabel,
statusType,
open,
reset,
startTranslation,
downloadResult,
importResult
} = useBatchTranslate()
defineExpose({
open,
loading,
progress,
progressText,
completed
})
</script>
<template>
<CommonModal v-model="show" title="插入表格" :width="380" @confirm="handleConfirm">
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<div class="flex flex-col gap-4 py-2">
<div class="grid grid-cols-2 gap-4">
<n-form-item label="行数" path="rows">
<CommonInputNumber
v-model:value="form.rows"
:min="1"
:max="50"
:step="1"
:strict="true"
:show-button="true"
class="w-full"
placeholder="请输入行数"
/>
</n-form-item>
<n-form-item label="列数" path="cols">
<CommonInputNumber
v-model:value="form.cols"
:min="1"
:max="20"
:step="1"
:strict="true"
:show-button="true"
class="w-full"
placeholder="请输入列数"
/>
</n-form-item>
</div>
<n-form-item label="单元格初始化段落节点" path="cellChildTags">
<div class="w-full bg-fill-2 p-3 rounded border border-divider">
<CommonCheckbox
v-model:value="form.cellChildTags"
:options="cellChildOptions"
:space-size="24"
/>
</div>
</n-form-item>
</div>
</n-form>
</CommonModal>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import type { FormInst, FormRules } from 'naive-ui'
import CommonCheckbox from '@/components/CommonCheckbox.vue'
const emit = defineEmits<{
confirm: [rows: number, cols: number, cellChildTags: string[]]
}>()
const cellChildOptions = [
{ label: 'PARAC (中文)', value: 'PARAC' },
{ label: 'PARA (英文)', value: 'PARA' }
]
const show = ref(false)
const formRef = ref<FormInst | null>(null)
const form = reactive({
rows: 3,
cols: 3,
cellChildTags: ['PARAC', 'PARA']
})
const rules: FormRules = {
rows: { type: 'number', required: true, message: '请指定行数', trigger: ['blur', 'change'] },
cols: { type: 'number', required: true, message: '请指定列数', trigger: ['blur', 'change'] }
}
const open = () => {
form.rows = 3
form.cols = 3
form.cellChildTags = ['PARAC', 'PARA']
show.value = true
}
const handleConfirm = async () => {
try {
await formRef.value?.validate()
emit('confirm', form.rows, form.cols, form.cellChildTags)
show.value = false
} catch (err) {
// validation failed
}
}
defineExpose({ open })
</script>
export interface ExtractOptions {
tag_pairs: Array<{ en_tag: string; cn_tag: string }>
save_excel?: boolean
import_to_db?: boolean
partition_name?: string
}
export interface ExtractResponse {
success: boolean
message: string
file_id: string
download_url?: string
total_count: number
import_count: number
preview_data: Array<{ text: string; translation: string }>
}
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()
const showModal = ref(false)
const loading = ref(false)
const completed = ref(false)
const totalCount = ref(0)
const importCount = ref(0)
const resultDownloadUrl = ref('')
const previewList = ref<any[]>([])
const form = ref({
save_excel: true,
import_to_db: false,
partition_name: import.meta.env.VITE_PARTITION_NAME,
tag_pairs: [
{ en_tag: 'PARA', cn_tag: 'PARAC' },
{ en_tag: 'TITLE', cn_tag: 'TITLEC' }
]
})
const previewColumns = [
{ title: '英文源文本 (text)', key: 'text', ellipsis: { tooltip: true } },
{ title: '中文翻译 (translation)', key: 'translation', ellipsis: { tooltip: true } }
]
const reset = () => {
loading.value = false
completed.value = false
totalCount.value = 0
importCount.value = 0
resultDownloadUrl.value = ''
previewList.value = []
}
const open = () => {
showModal.value = true
reset()
}
const addTagPair = () => {
form.value.tag_pairs.push({ en_tag: '', cn_tag: '' })
}
const removeTagPair = (index: number) => {
form.value.tag_pairs.splice(index, 1)
}
const startExtraction = async () => {
if (!editorStore.xmlTree) {
window.$message.error('编辑器中无 XML 数据')
return
}
// 过滤掉空的标签对
const validPairs = form.value.tag_pairs.filter((p) => p.en_tag.trim() && p.cn_tag.trim())
if (validPairs.length === 0) {
window.$message.warning('请配置至少一个有效的标签对')
return
}
loading.value = true
try {
const xmlContent = serializeTreeToXml(editorStore.xmlTree, 0, true)
const blob = new Blob([xmlContent], { type: 'application/xml;charset=utf-8;' })
const formData = new FormData()
formData.append('file', blob, 'document.xml')
formData.append('tag_pairs', JSON.stringify(validPairs))
if (form.value.save_excel !== undefined) {
formData.append('save_excel', String(form.value.save_excel))
}
if (form.value.import_to_db !== undefined) {
formData.append('import_to_db', String(form.value.import_to_db))
}
const partitionName = form.value.import_to_db ? form.value.partition_name : undefined
if (partitionName) {
formData.append('partition_name', partitionName)
}
const res = (await service.post('/xml/extract/process', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})) as any as ExtractResponse
if (res && res.success) {
totalCount.value = res.total_count || 0
importCount.value = res.import_count || 0
previewList.value = res.preview_data || []
// 构造下载 Excel 的链接
if (res.download_url) {
resultDownloadUrl.value = res.download_url
} else if (res.file_id) {
resultDownloadUrl.value = `${getBaseOrigin()}/translations/xml/extract/download/${res.file_id}/extracted_translations.xlsx`
}
loading.value = false
completed.value = true
window.$message.success('翻译对照对提取成功!')
} else {
throw new Error(res.message || '提取翻译对照失败')
}
} catch (err: any) {
loading.value = false
window.$message.error(`提取出错: ${err.message || err}`)
}
}
const downloadExcel = () => {
if (!resultDownloadUrl.value) return
const link = document.createElement('a')
link.href = resultDownloadUrl.value
link.download = `extracted_pairs_${Date.now()}.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
return {
showModal,
loading,
completed,
totalCount,
importCount,
resultDownloadUrl,
previewList,
form,
previewColumns,
open,
reset,
addTagPair,
removeTagPair,
startExtraction,
downloadExcel
}
}
<template>
<CommonModal
v-model="showModal"
title="提取 XML 翻译对照"
:width="700"
:loading="loading"
confirm-text="开始提取"
:show-confirm="!completed"
:show-cancel="!completed"
@confirm="startExtraction"
>
<div class="py-2">
<!-- 配置与对照标签对管理 -->
<div v-if="!loading && !completed" class="flex flex-col gap-4">
<n-form label-placement="left" label-width="120" size="medium">
<n-form-item label="生成 Excel 文件">
<n-space align="center">
<n-switch v-model:value="form.save_excel" />
<span class="text-xs text-color3">是否导出包含提取出来的中英文对照的 Excel</span>
</n-space>
</n-form-item>
<n-form-item label="导入至 Milvus">
<n-space align="center">
<n-switch v-model:value="form.import_to_db" />
<span class="text-xs text-color3">是否将提取得到的对照存入向量数据库中以备后用</span>
</n-space>
</n-form-item>
<n-form-item v-if="form.import_to_db" label="Milvus 分区名">
<n-input v-model:value="form.partition_name" placeholder="请输入数据库分区名称" />
</n-form-item>
</n-form>
<!-- 对照标签对配置 -->
<div class="border border-divider rounded-lg p-3">
<div class="flex justify-between items-center mb-3">
<span class="text-sm font-bold text-color1">配置提取标签对</span>
<CommonButton size="tiny" type="primary" secondary @click="addTagPair">添加标签对</CommonButton>
</div>
<div class="flex flex-col gap-2 max-h-[200px] overflow-y-auto pr-1">
<div
v-for="(pair, index) in form.tag_pairs"
:key="index"
class="flex items-center gap-2"
>
<n-input
v-model:value="pair.en_tag"
placeholder="源英文标签 (例: PARA)"
size="small"
class="flex-1"
/>
<n-icon class="text-color3">
<arrow-forward-outline />
</n-icon>
<n-input
v-model:value="pair.cn_tag"
placeholder="目标中文标签 (例: PARAC)"
size="small"
class="flex-1"
/>
<CommonButton
size="small"
type="error"
circle
quaternary
:disabled="form.tag_pairs.length <= 1"
@click="removeTagPair(index)"
>
<template #icon>
<trash-outline />
</template>
</CommonButton>
</div>
</div>
</div>
</div>
<!-- 运行中加载描述 -->
<div v-else-if="loading" class="flex flex-col items-center justify-center py-8 gap-4">
<div class="text-sm font-semibold text-color1">正在提取并处理翻译对照对,请稍候...</div>
</div>
<!-- 完成,展示提取到的预览数据和下载 -->
<div v-else-if="completed" class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<n-icon size="32" class="text-success">
<checkmark-circle-outline />
</n-icon>
<div>
<div class="text-base font-bold text-color1">对照对提取处理成功!</div>
<div class="text-xs text-color3">
共提取中英双语对:<span class="text-primary font-bold text-sm">{{ totalCount }}</span>
<span v-if="form.import_to_db" class="ml-2">
,导入数据库:<span class="text-success font-bold text-sm">{{ importCount }}</span>
</span>
</div>
</div>
</div>
<!-- 提取数据预览列表 -->
<div class="border border-divider rounded-lg overflow-hidden">
<div class="bg-fill-3 px-3 py-2 text-xs font-bold text-color2 border-b border-divider">
数据预览 (展示前 10 条)
</div>
<n-data-table
:columns="previewColumns"
:data="previewList"
:max-height="250"
size="small"
:bordered="false"
/>
</div>
</div>
</div>
<template #footer v-if="completed">
<div class="flex justify-end gap-3 w-full">
<CommonButton @click="reset">重新提取</CommonButton>
<CommonButton v-if="form.save_excel" type="primary" @click="downloadExcel">下载 Excel 对照表</CommonButton>
<CommonButton @click="showModal = false">关闭</CommonButton>
</div>
</template>
</CommonModal>
</template>
<script setup lang="ts">
import { CheckmarkCircleOutline, TrashOutline, ArrowForwardOutline } from '@vicons/ionicons5'
import { useExtractTranslate } from './functionals'
const {
showModal,
loading,
completed,
totalCount,
importCount,
form,
previewColumns,
previewList,
open,
reset,
addTagPair,
removeTagPair,
startExtraction,
downloadExcel
} = useExtractTranslate()
defineExpose({
open
})
</script>
export interface SearchRequest {
query_text: string
search_type?: 'full_text' | 'text_match'
search_direction?: 'en_to_zh' | 'zh_to_en'
partition_names?: string[] | null
limit?: number
}
export interface SearchResult {
id: number | string
text: string
translation: string
score: number
fuzzy_score: number | null
partition_name: string | null
}
export interface SearchResponse {
success: boolean
results: SearchResult[]
count: number
message?: string
}
......@@ -7,7 +7,7 @@ export const TOOLBAR_TITLE = 'XML 编辑工具栏'
export const GREEN_BUTTONS: any[] = [
// { label: '插入图片', tag: 'GRAPHIC', icon: ImageOutline },
// { label: '插入表格', tag: 'TABLE', icon: GridOutline },
{ label: '插入表格', tag: 'TABLE', icon: GridOutline }
// { label: '插入模板', tag: 'PRETOPIC', icon: DocumentTextOutline },
// { label: '插入签字点', tag: 'SIGNOFF', icon: CreateOutline }
]
import { useEditorStore } from '@/store/editor'
import { useEditorStore, createTableStructure } from '@/store/editor'
import { useAppStore } from '@/store/app/index'
import { canAddChild } from '@/utils/dtdManager'
/**
* EditorToolbar 组件级业务逻辑 Hook
......@@ -11,17 +12,62 @@ export function useEditorToolbar(emit: any) {
const insertBelow = ref(true)
const fileInputRef = ref<HTMLInputElement | null>(null)
const isUploading = ref(false)
const createTableModalRef = ref<any>(null)
const canUndo = computed(() => editorStore.undoStack.length > 0)
const canRedo = computed(() => editorStore.redoStack.length > 0)
const handleInsert = (tag: string) => {
editorStore.insertNode(tag, insertBelow.value)
const selected = editorStore.selectedNode
if (!selected) {
window.$message.warning('请先在树中选择一个目标节点!')
return
}
let parentTag = ''
let parentNode: XmlNode | null = null
if (insertBelow.value) {
parentNode = editorStore.selectedNodeParent
if (!parentNode) {
window.$message.warning('无法在根节点下方插入兄弟节点')
return
}
parentTag = parentNode.tagName
} else {
parentNode = selected
parentTag = selected.tagName
}
const existingCount = parentNode.children.filter((c) => c.tagName === tag).length
if (!canAddChild(parentTag, tag, existingCount)) {
window.$message.warning(`DTD 校验失败: 节点 <${parentTag}> 无法接受子元素 <${tag}>`)
return
}
if (tag === 'TABLE') {
createTableModalRef.value?.open()
} else {
editorStore.insertNode(tag, insertBelow.value)
}
}
const handleCreateTableConfirm = (rows: number, cols: number, cellChildTags: string[]) => {
const tableNode = createTableStructure(rows, cols, cellChildTags)
editorStore.insertNode('TABLE', insertBelow.value, tableNode)
}
const batchTranslateModalRef = ref<any>(null)
const extractTranslateModalRef = ref<any>(null)
const searchTranslateModalRef = ref<any>(null)
const handleTranslate = (type: 'batch' | 'extract' | 'search') => {
const labelMap = { batch: '批量翻译', extract: '提取翻译', search: '搜索翻译' }
window.$message.info(`已触发 ${labelMap[type]} 功能,自动匹配双语对照。`)
if (type === 'batch') {
batchTranslateModalRef.value?.open()
} else if (type === 'extract') {
extractTranslateModalRef.value?.open()
} else if (type === 'search') {
searchTranslateModalRef.value?.open()
}
}
const triggerUpload = () => {
......@@ -68,6 +114,11 @@ export function useEditorToolbar(emit: any) {
handleInsert,
handleTranslate,
triggerUpload,
createTableModalRef,
handleCreateTableConfirm,
batchTranslateModalRef,
extractTranslateModalRef,
searchTranslateModalRef,
handleFileUpload: () => {} // 保留空函数以防组件 template 尚未完全更新时报错
}
}
......@@ -50,11 +50,29 @@
<div class="flex items-center gap-1.5 flex-shrink-0 ml-auto">
<!-- 翻译辅助组 -->
<div class="flex items-center gap-1 bg-fill-3 px-1 py-0.5 rounded-md border border-divider flex-shrink-0">
<CommonButton size="small" quaternary class="util-btn" @click="handleTranslate('batch')">
<CommonButton
size="small"
quaternary
class="util-btn"
:type="batchTranslateModalRef?.completed ? 'success' : (batchTranslateModalRef?.loading ? 'primary' : 'default')"
@click="handleTranslate('batch')"
>
<template #icon>
<n-icon><language-outline /></n-icon>
<n-icon>
<sync-outline v-if="batchTranslateModalRef?.loading" class="animate-spin text-primary" />
<checkmark-circle-outline v-else-if="batchTranslateModalRef?.completed" class="text-success" />
<language-outline v-else />
</n-icon>
</template>
批量翻译
<span v-if="batchTranslateModalRef?.loading">
批量翻译 ({{ batchTranslateModalRef.progress }}%)
</span>
<span v-else-if="batchTranslateModalRef?.completed">
批量翻译 (已完成)
</span>
<span v-else>
批量翻译
</span>
</CommonButton>
<CommonButton size="small" quaternary class="util-btn" @click="handleTranslate('extract')">
<template #icon>
......@@ -145,6 +163,18 @@
<!-- 插入 XML 片段弹窗 -->
<InsertFragmentModal ref="insertFragmentModalRef" />
<!-- 插入表格弹窗 -->
<CreateTableModal ref="createTableModalRef" @confirm="handleCreateTableConfirm" />
<!-- 批量翻译弹窗 -->
<BatchTranslateModal ref="batchTranslateModalRef" />
<!-- 提取翻译对照弹窗 -->
<ExtractTranslateModal ref="extractTranslateModalRef" />
<!-- 搜索翻译数据库弹窗 -->
<SearchTranslateModal ref="searchTranslateModalRef" />
</div>
</template>
......@@ -160,20 +190,40 @@ import {
ArrowRedoOutline,
SunnyOutline,
MoonOutline,
CodeWorkingOutline
CodeWorkingOutline,
SyncOutline,
CheckmarkCircleOutline
} from '@vicons/ionicons5'
import { useEditorToolbar } from './functionals'
import { GREEN_BUTTONS } from './constants'
import SettingsDrawer from '@/layouts/components/SettingsDrawer.vue'
import InsertFragmentModal from './components/InsertFragmentModal/index.vue'
import CreateTableModal from './components/CreateTableModal/index.vue'
import BatchTranslateModal from './components/BatchTranslateModal/index.vue'
import ExtractTranslateModal from './components/ExtractTranslateModal/index.vue'
import SearchTranslateModal from './components/SearchTranslateModal/index.vue'
const emit = defineEmits(['save', 'validate', 'export', 'preview'])
const { editorStore, appStore, insertBelow, fileInputRef, isUploading, canUndo, canRedo, handleInsert, handleTranslate, triggerUpload } =
useEditorToolbar(emit)
const {
editorStore,
appStore,
insertBelow,
fileInputRef,
isUploading,
canUndo,
canRedo,
handleInsert,
handleTranslate,
triggerUpload,
createTableModalRef,
handleCreateTableConfirm,
batchTranslateModalRef,
extractTranslateModalRef,
searchTranslateModalRef
} = useEditorToolbar(emit)
const insertFragmentModalRef = ref<any>(null)
</script>
const insertFragmentModalRef = ref<any>(null)</script>
<style scoped>
/* 消除工具栏容器本身的 focus outline */
......
import type { FormInst } from 'naive-ui'
import { getElementAttributes, createDefaultAttributes, isTextOnlyElement, isMixedContentElement } from '@/utils/dtdManager'
import { nextTick } from 'vue'
import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app'
import type { XmlNode, DtdAttribute } from '@/types/xmlNode'
import type { AttrDef } from '../../../constants'
import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../../../functionals'
......@@ -336,10 +338,22 @@ export function useAddNodeModal(formRef: Ref<FormInst | null>) {
}
}
const appStore = useAppStore()
if (!appStore.autoExpandOnInsert) {
store.skipExpandOnSelect = true
}
store.rebuildNodeMap()
store.setSelectedNodeId(newId)
store.handleNodeInsertion(newNode)
window.$message?.success(`成功插入节点 <${form.tagName}>`)
addNodeVisible.value = false
if (!appStore.autoExpandOnInsert) {
nextTick(() => {
store.skipExpandOnSelect = false
})
}
}
}
} finally {
......
......@@ -50,3 +50,23 @@ export interface SafeNodeItem {
parentPath: string
checked: boolean
}
// 翻译请求接口
export interface TranslationRequest {
text: string
partition_names?: string[] | null
stream?: boolean
fuzzy_score_threshold?: number | null
search_direction?: 'en_to_zh' | 'zh_to_en'
save_to_database?: boolean
database_partition_name?: string | null
}
// 翻译响应接口
export interface TranslationResponse {
success: boolean
translation: string
is_direct_match: boolean
fuzzy_score: number | null
reference_count: number
}
......@@ -81,6 +81,25 @@
@click="handleSelect(item.id)"
@contextmenu.prevent="(e) => handleContextMenu(e, item)"
>
<!-- 局部翻译加载状态 -->
<Transition name="translate-loading">
<div
v-if="translatingNodeId === item.id"
class="translate-loading-mask"
>
<div class="translate-loading-inner">
<n-icon size="13" class="translate-loading-icon">
<SyncOutline />
</n-icon>
<span class="translate-loading-text">
正在智能翻译
<span class="translate-dots">
<span>.</span><span>.</span><span>.</span>
</span>
</span>
</div>
</div>
</Transition>
<!-- Checkbox (批量管理模式) -->
<n-checkbox
v-if="isBatchMode"
......@@ -178,9 +197,9 @@
</template>
<script setup lang="ts">
import { SearchOutline, ListOutline, TrashOutline, CloseOutline } from '@vicons/ionicons5'
import { SearchOutline, ListOutline, TrashOutline, CloseOutline, SyncOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor'
import { useNodeTree, checkRuleVisible, viewXmlVisible, addNodeVisible } from './functionals'
import { useNodeTree, checkRuleVisible, viewXmlVisible, addNodeVisible, translatingNodeId } from './functionals'
import CheckRuleModal from './components/CheckRuleModal/index.vue'
import AddNodeModal from './components/AddNodeModal/index.vue'
import ViewXmlModal from './components/ViewXmlModal/index.vue'
......@@ -325,4 +344,81 @@ const batchDeleteConfirmModalRef = ref<any>(null)
color: var(--primary-color) !important;
opacity: 0.8;
}
/* ── 智能翻译 局部加载遮罩 ── */
.translate-loading-mask {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
border-radius: 4px;
display: flex;
align-items: center;
overflow: hidden;
/* 从左向右渐变的主色条纹 */
background: linear-gradient(
90deg,
var(--primary-color) 0%,
color-mix(in srgb, var(--primary-color) 85%, transparent) 60%,
color-mix(in srgb, var(--primary-color) 50%, transparent) 100%
);
}
.translate-loading-inner {
display: flex;
align-items: center;
gap: 5px;
padding: 0 10px;
width: 100%;
}
.translate-loading-icon {
color: #fff;
flex-shrink: 0;
animation: translate-spin 0.8s linear infinite;
}
.translate-loading-text {
font-size: 12px;
font-weight: 600;
color: #fff;
letter-spacing: 0.02em;
white-space: nowrap;
display: flex;
align-items: baseline;
gap: 1px;
}
/* 三点跳动 */
.translate-dots span {
display: inline-block;
animation: translate-bounce 1.2s ease-in-out infinite;
font-weight: 900;
}
.translate-dots span:nth-child(1) { animation-delay: 0s; }
.translate-dots span:nth-child(2) { animation-delay: 0.2s; }
.translate-dots span:nth-child(3) { animation-delay: 0.4s; }
/* 入场 / 离场过渡 */
.translate-loading-enter-active,
.translate-loading-leave-active {
transition: opacity 0.18s ease, transform 0.18s ease;
}
.translate-loading-enter-from,
.translate-loading-leave-to {
opacity: 0;
transform: scaleX(0.9);
transform-origin: left center;
}
@keyframes translate-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes translate-bounce {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-3px); }
}
</style>
......@@ -11,4 +11,4 @@ export interface TableBatchModalProps {
}
/** 批量操作弹框确认事件类型 */
export type ConfirmEmit = (event: 'confirm', count: number, action: string) => void
export type ConfirmEmit = (event: 'confirm', count: number, action: string, cellChildTags: string[]) => void
......@@ -13,7 +13,8 @@ export function useTableBatchModal(emit: ConfirmEmit, formRef: Ref<FormInst | nu
const pendingAction = ref('')
const form = reactive({
count: 1 as number | null
count: 1 as number | null,
cellChildTags: [] as string[]
})
const rules = {
......@@ -39,6 +40,7 @@ export function useTableBatchModal(emit: ConfirmEmit, formRef: Ref<FormInst | nu
description.value = meta.description
isDelete.value = meta.isDelete
form.count = meta.count
form.cellChildTags = []
pendingAction.value = meta.action
show.value = true
}
......@@ -50,7 +52,7 @@ export function useTableBatchModal(emit: ConfirmEmit, formRef: Ref<FormInst | nu
return
}
if (form.count !== null) {
emit('confirm', form.count, pendingAction.value)
emit('confirm', form.count, pendingAction.value, form.cellChildTags)
show.value = false
}
}
......
<template>
<CommonModal v-model="show" :title="title" :width="360" @confirm="handleConfirm">
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
<div class="flex flex-col gap-3">
<span class="text-sm text-color2">{{ description }}</span>
<n-form-item path="count">
<div class="flex flex-col gap-4 py-2">
<n-form-item :label="description" path="count">
<CommonInputNumber
v-model:value="form.count"
:min="1"
......@@ -15,6 +14,17 @@
placeholder="请输入数量"
/>
</n-form-item>
<template v-if="!isDelete">
<n-form-item label="单元格初始化段落节点" path="cellChildTags">
<div class="w-full bg-fill-2 p-3 rounded border border-divider">
<CommonCheckbox
v-model:value="form.cellChildTags"
:options="cellChildOptions"
:space-size="24"
/>
</div>
</n-form-item>
</template>
</div>
</n-form>
</CommonModal>
......@@ -24,10 +34,17 @@
import type { FormInst } from 'naive-ui'
import { useTableBatchModal } from './functionals'
import CommonCheckbox from '@/components/CommonCheckbox.vue'
const emit = defineEmits<{
confirm: [count: number, action: string]
confirm: [count: number, action: string, cellChildTags: string[]]
}>()
const cellChildOptions = [
{ label: 'PARAC (中文)', value: 'PARAC' },
{ label: 'PARA (英文)', value: 'PARA' }
]
const formRef = ref<FormInst | null>(null)
const { show, title, description, isDelete, form, rules, open, handleConfirm } = useTableBatchModal(emit, formRef)
......
import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app'
import type { XmlNode } from '@/types/xmlNode'
import type { TableCellModel, TableRowModel, TableStructureModel, BatchModalRef, BatchActionMeta, CellParagraphModel } from '../constants'
import { ACTION_META } from '../constants'
......@@ -43,6 +44,7 @@ const setDeepText = (node: XmlNode, text: string): void => {
*/
export function useTableEditor(props: { node: XmlNode }) {
const store = useEditorStore()
const appStore = useAppStore()
const structure = ref<TableStructureModel>({
cols: 0,
......@@ -289,9 +291,10 @@ export function useTableEditor(props: { node: XmlNode }) {
}
}
const createDefaultEntry = (parentRowId: string): XmlNode => {
return {
id: crypto.randomUUID(),
const createDefaultEntry = (parentRowId: string, cellChildTags?: string[] | null): XmlNode => {
const entryId = crypto.randomUUID()
const entry: XmlNode = {
id: entryId,
tagName: 'ENTRY',
attributes: {},
children: [],
......@@ -299,6 +302,30 @@ export function useTableEditor(props: { node: XmlNode }) {
mixedContent: [],
parentId: parentRowId
}
if (cellChildTags && cellChildTags.length > 0) {
// 按照 PARAC -> PARA 的顺序插入,以确保 PARAC 始终在 PARA 节点的前面
const order = ['PARAC', 'PARA']
for (const tag of order) {
if (cellChildTags.includes(tag)) {
const childId = crypto.randomUUID()
const childNode: XmlNode = {
id: childId,
tagName: tag,
attributes: {},
children: [],
textContent: '',
mixedContent: [],
parentId: entryId
}
entry.children.push(childNode)
entry.mixedContent.push({
type: 'element',
nodeId: childId
})
}
}
}
return entry
}
const updateCellText = (node: XmlNode, cellId: string, text: string): void => {
......@@ -329,7 +356,7 @@ export function useTableEditor(props: { node: XmlNode }) {
store.rebuildNodeMap()
}
const addRow = (node: XmlNode, section: 'THEAD' | 'TBODY' = 'TBODY', activeRowId?: string, insertBelow = true): void => {
const addRow = (node: XmlNode, section: 'THEAD' | 'TBODY' = 'TBODY', activeRowId?: string, insertBelow = true, cellChildTags?: string[] | null): void => {
const tgroup = findTgroup(node)
if (!tgroup) return
......@@ -362,7 +389,7 @@ export function useTableEditor(props: { node: XmlNode }) {
}
for (let i = 0; i < cols; i++) {
newRow.children.push(createDefaultEntry(rowId))
newRow.children.push(createDefaultEntry(rowId, cellChildTags))
}
if (activeRowId) {
......@@ -376,6 +403,9 @@ export function useTableEditor(props: { node: XmlNode }) {
insertedRowIds.value.delete(rowId)
}, 5000)
if (appStore.autoExpandOnInsert) {
store.expandNodes([rowId, ...newRow.children.map((c) => c.id)])
}
store.rebuildNodeMap()
return
}
......@@ -388,6 +418,9 @@ export function useTableEditor(props: { node: XmlNode }) {
insertedRowIds.value.delete(rowId)
}, 5000)
if (appStore.autoExpandOnInsert) {
store.expandNodes([rowId, ...newRow.children.map((c) => c.id)])
}
store.rebuildNodeMap()
}
......@@ -415,7 +448,7 @@ export function useTableEditor(props: { node: XmlNode }) {
}
}
const addColumn = (node: XmlNode, activeColIdx?: number, insertRight = true): void => {
const addColumn = (node: XmlNode, activeColIdx?: number, insertRight = true, cellChildTags?: string[] | null): void => {
const tgroup = findTgroup(node)
if (!tgroup) return
......@@ -502,7 +535,7 @@ export function useTableEditor(props: { node: XmlNode }) {
const insertedColCellIds: string[] = []
const createDefaultColEntry = (parentRowId: string): XmlNode => {
const cellNode = createDefaultEntry(parentRowId)
const cellNode = createDefaultEntry(parentRowId, cellChildTags)
insertedColCellIds.push(cellNode.id)
return cellNode
}
......@@ -547,6 +580,9 @@ export function useTableEditor(props: { node: XmlNode }) {
insertedColCellIds.forEach((id) => insertedCellIds.value.delete(id))
}, 5000)
if (appStore.autoExpandOnInsert) {
store.expandNodes(insertedColCellIds)
}
store.rebuildNodeMap()
}
......@@ -1109,7 +1145,7 @@ export function useTableEditor(props: { node: XmlNode }) {
}
])
const handleRowAddSelect = (key: string) => {
const handleRowAddSelect = (key: string, cellChildTags?: string[] | null) => {
let section: 'THEAD' | 'TBODY' = 'TBODY'
let activeRowId: string | undefined = undefined
......@@ -1132,11 +1168,11 @@ export function useTableEditor(props: { node: XmlNode }) {
}
if (key === 'above') {
addRow(props.node, section, activeRowId, false)
addRow(props.node, section, activeRowId, false, cellChildTags)
} else if (key === 'below') {
addRow(props.node, section, activeRowId, true)
addRow(props.node, section, activeRowId, true, cellChildTags)
} else {
addRow(props.node, section)
addRow(props.node, section, undefined, true, cellChildTags)
}
}
......@@ -1157,7 +1193,7 @@ export function useTableEditor(props: { node: XmlNode }) {
}
])
const handleColAddSelect = (key: string) => {
const handleColAddSelect = (key: string, cellChildTags?: string[] | null) => {
let activeColIdx: number | undefined = undefined
if (selectedCellIds.value.length > 0) {
......@@ -1170,11 +1206,11 @@ export function useTableEditor(props: { node: XmlNode }) {
}
if (key === 'left') {
addColumn(props.node, activeColIdx, false)
addColumn(props.node, activeColIdx, false, cellChildTags)
} else if (key === 'right') {
addColumn(props.node, activeColIdx, true)
addColumn(props.node, activeColIdx, true, cellChildTags)
} else {
addColumn(props.node)
addColumn(props.node, undefined, true, cellChildTags)
}
}
......@@ -1470,8 +1506,8 @@ export function useTableEditor(props: { node: XmlNode }) {
export function useTableBatchActions(deps: {
batchModalRef: Ref<BatchModalRef | undefined>
handleContextMenuSelect: (key: string) => void
handleRowAddSelect: (pos: string) => void
handleColAddSelect: (pos: string) => void
handleRowAddSelect: (pos: string, cellChildTags?: string[] | null) => void
handleColAddSelect: (pos: string, cellChildTags?: string[] | null) => void
batchDeleteRows: (rowId: string, count: number) => void
batchDeleteColumns: (colIdx: number, count: number) => void
contextMenu: Ref<{ cellId: string; rowId: string; colIdx: number; show: boolean; x: number; y: number }>
......@@ -1497,7 +1533,7 @@ export function useTableBatchActions(deps: {
}
/** 确认执行批量操作(由 TableBatchModal 的 @confirm 事件触发) */
const executeBatchAction = (count: number, action: string) => {
const executeBatchAction = (count: number, action: string, cellChildTags: string[]) => {
const { cellId, rowId, colIdx } = deps.contextMenu.value
if (cellId && !deps.selectedCellIds.value.includes(cellId)) {
......@@ -1507,22 +1543,22 @@ export function useTableBatchActions(deps: {
switch (action) {
case 'row-above':
for (let i = 0; i < count; i++) deps.handleRowAddSelect('above')
for (let i = 0; i < count; i++) deps.handleRowAddSelect('above', cellChildTags)
break
case 'row-below':
for (let i = 0; i < count; i++) deps.handleRowAddSelect('below')
for (let i = 0; i < count; i++) deps.handleRowAddSelect('below', cellChildTags)
break
case 'row-append':
for (let i = 0; i < count; i++) deps.handleRowAddSelect('append')
for (let i = 0; i < count; i++) deps.handleRowAddSelect('append', cellChildTags)
break
case 'col-left':
for (let i = 0; i < count; i++) deps.handleColAddSelect('left')
for (let i = 0; i < count; i++) deps.handleColAddSelect('left', cellChildTags)
break
case 'col-right':
for (let i = 0; i < count; i++) deps.handleColAddSelect('right')
for (let i = 0; i < count; i++) deps.handleColAddSelect('right', cellChildTags)
break
case 'col-append':
for (let i = 0; i < count; i++) deps.handleColAddSelect('append')
for (let i = 0; i < count; i++) deps.handleColAddSelect('append', cellChildTags)
break
case 'row-delete':
if (rowId) deps.batchDeleteRows(rowId, count)
......
......@@ -41,7 +41,10 @@ import Splitter from './components/Splitter/index.vue'
const themeVars = useThemeVars()
const editorStore = useEditorStore()
const expandedKeys = ref<string[]>([])
const expandedKeys = computed({
get: () => editorStore.expandedKeys,
set: (val) => editorStore.setExpandedKeys(val)
})
// 界面拖动分割状态
const leftWidthPx = ref(560)
......
......@@ -49,7 +49,21 @@ export default defineConfig(({ mode }) => {
base: './', // 打包路径
server: {
port: 5555,
host: true
host: true,
proxy: {
'/translationsManage': {
target: 'https://app.anyremote.cn:8008',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/translationsManage/, '')
},
'/translations': {
target: 'https://app.anyremote.cn:8005',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/translations/, '')
}
}
}
}
})
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