From 8b4832ca459f67bd0091242f5279fa33686a50ba Mon Sep 17 00:00:00 2001
From: zhangjianjun
Date: Wed, 25 Mar 2026 14:51:25 +0800
Subject: [PATCH] ud
---
components/In18FormDialog.vue | 158 ++++++++++++++++-----
components/JsonFormDialog.vue | 67 ++++++---
components/UploadInput.vue | 260 ++++++++++++++++++++++++++++++++++
views/config/list.tsx | 1 +
views/document/list.tsx | 18 ++-
views/news/list.tsx | 5 +-
6 files changed, 454 insertions(+), 55 deletions(-)
create mode 100644 components/UploadInput.vue
diff --git a/components/In18FormDialog.vue b/components/In18FormDialog.vue
index 56550d7..4802b78 100644
--- a/components/In18FormDialog.vue
+++ b/components/In18FormDialog.vue
@@ -20,7 +20,7 @@
+ label-position="left" v-loading="detailLoading" style="border-top: 1px solid var(--el-border-color);">
@@ -174,7 +174,7 @@ import { reactive, computed, watch, ref, nextTick } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { Close, Promotion, FullScreen, CopyDocument } from '@element-plus/icons-vue'
-import UploadInput from "lib/components/UploadInput.vue";
+import UploadInput from "./UploadInput.vue";
import WangEditor from "lib/components/WangEditor.vue";
type FormItemType =
| 'input'
@@ -206,7 +206,7 @@ type LocaleItem = {
const props = withDefaults(
defineProps<{
- fullscreen: boolean
+ fullscreen?: boolean
show: boolean
id: string | number
title?: string
@@ -239,7 +239,7 @@ const normalFields = computed(() => props.form.filter((f) => !f.shouldTranslate)
const translateFields = computed(() => props.form.filter((f) => f.shouldTranslate))
function isUploadMediaType(type: string): boolean {
- return type === 'upload:images,video' || type === 'upload:image,video'
+ return type === 'upload:images,video' || type === 'upload:image,video' || type === 'upload:image' || type === 'upload:video'
}
function getSelectItems(item: FormItem): { key: string; name: string }[] {
@@ -250,6 +250,68 @@ function parseTranslateResult(res: any): string {
return res?.data?.text ?? res?.data ?? res?.result ?? (typeof res === 'string' ? res : '')
}
+function extractMediaFromHtml(html: string): { textWithPlaceholders: string; mediaParts: string[] } {
+ if (!html || typeof html !== 'string') {
+ return { textWithPlaceholders: html || '', mediaParts: [] }
+ }
+ if (!html.includes('<')) {
+ return { textWithPlaceholders: html, mediaParts: [] }
+ }
+ const div = document.createElement('div')
+ div.innerHTML = html
+ const mediaParts: string[] = []
+ const mediaElements = Array.from(div.querySelectorAll('img, video, iframe, audio'))
+ mediaElements.forEach((el, index) => {
+ mediaParts.push((el as HTMLElement).outerHTML)
+ // 使用类 HTML 占位符,翻译 API 通常会原样保留
+ const placeholder = document.createTextNode(``)
+ el.parentNode?.replaceChild(placeholder, el)
+ })
+ return {
+ textWithPlaceholders: div.innerHTML,
+ mediaParts
+ }
+}
+
+// 方案二:占位符不发给翻译 API。用于 split 时保留分隔符
+const PLACEHOLDER_SPLIT_REGEX = /(<x-media\s+id\s*=\s*["']?\d+["']?\s*\/>)/gi
+
+function reassembleHtmlWithMedia(translatedText: string, mediaParts: string[]): string {
+ if (!mediaParts.length) return translatedText
+ let result = translatedText
+ mediaParts.forEach((media, index) => {
+ // 只匹配我们产生的占位符(未经过 API,格式确定)
+ const placeholderRegex = new RegExp(
+ `<x-media\\s+id\\s*=\\s*["']?${index}["']?\\s*\\/>`,
+ 'gi'
+ )
+ result = result.replace(placeholderRegex, media)
+ })
+ return result
+}
+
+/** 方案二:按占位符拆分,只翻译文本段落,占位符不发给 API */
+async function translateRichtextBySegments(
+ textWithPlaceholders: string,
+ mediaParts: string[],
+ fromKey: string,
+ toKey: string
+): Promise {
+ const segments = textWithPlaceholders.split(PLACEHOLDER_SPLIT_REGEX)
+ const placeholderTest = /^<x-media\s+id\s*=\s*["']?\d+["']?\s*\/>$/i
+ const translatedSegments: string[] = []
+ for (const seg of segments) {
+ if (seg === '' || placeholderTest.test(seg)) {
+ translatedSegments.push(seg)
+ } else {
+ const res = await props.translateApi({ text: seg, from: fromKey, to: toKey })
+ translatedSegments.push(parseTranslateResult(res) || seg)
+ }
+ }
+ const concatenated = translatedSegments.join('')
+ return reassembleHtmlWithMedia(concatenated, mediaParts)
+}
+
const formRef = ref()
const formRules = computed(() => {
@@ -391,12 +453,17 @@ async function handleTranslate() {
const fromKey = primaryLocale.value.key
const toKey = activeSecondaryLocale.value.key
- const texts: { key: string; text: string }[] = []
+ const texts: { key: string; text: string; type: FormItemType; mediaParts?: string[] }[] = []
translateFields.value.forEach((item) => {
const val = formData.translations?.[fromKey]?.[item.key]
- if (val != null && String(val).trim()) {
- texts.push({ key: item.key, text: String(val).trim() })
+ if (val != null && String(val).trim() && !val.startsWith('http')) {
+ if (item.type === 'richtext') {
+ const { textWithPlaceholders, mediaParts } = extractMediaFromHtml(String(val).trim())
+ texts.push({ key: item.key, text: textWithPlaceholders, type: item.type, mediaParts })
+ } else {
+ texts.push({ key: item.key, text: String(val).trim(), type: item.type })
+ }
}
})
@@ -405,9 +472,15 @@ async function handleTranslate() {
translating.value = true
try {
const results = await Promise.allSettled(
- texts.map(async ({ key, text }) => {
- const res = await props.translateApi({ text, from: fromKey, to: toKey })
- return { key, translated: parseTranslateResult(res) }
+ texts.map(async ({ key, text, type, mediaParts }) => {
+ let translated: string
+ if (type === 'richtext' && mediaParts && mediaParts.length > 0) {
+ translated = await translateRichtextBySegments(text, mediaParts, fromKey, toKey)
+ } else {
+ const res = await props.translateApi({ text, from: fromKey, to: toKey })
+ translated = parseTranslateResult(res)
+ }
+ return { key, translated }
})
)
let failCount = 0
@@ -438,10 +511,25 @@ async function handleTranslateField(itemKey: string) {
const toKey = activeSecondaryLocale.value.key
const text = formData.translations?.[fromKey]?.[itemKey]
if (text == null || !String(text).trim()) return
+ const item = translateFields.value.find((f) => f.key === itemKey)
+ const isRichtext = item?.type === 'richtext'
+ let textToTranslate = String(text).trim()
+ let mediaParts: string[] = []
+ if (isRichtext) {
+ const extracted = extractMediaFromHtml(textToTranslate)
+ textToTranslate = extracted.textWithPlaceholders
+ mediaParts = extracted.mediaParts
+ }
translatingFieldKey.value = itemKey
try {
- const res = await props.translateApi({ text: String(text).trim(), from: fromKey, to: toKey })
- if (formData.translations[toKey]) formData.translations[toKey][itemKey] = parseTranslateResult(res)
+ let translated: string
+ if (isRichtext && mediaParts.length > 0) {
+ translated = await translateRichtextBySegments(textToTranslate, mediaParts, fromKey, toKey)
+ } else {
+ const res = await props.translateApi({ text: textToTranslate, from: fromKey, to: toKey })
+ translated = parseTranslateResult(res)
+ }
+ if (formData.translations[toKey]) formData.translations[toKey][itemKey] = translated
} catch (e) {
console.error('Translate failed:', e)
ElMessage.error('翻译失败')
diff --git a/components/JsonFormDialog.vue b/components/JsonFormDialog.vue
index dab6536..e7a0b4a 100644
--- a/components/JsonFormDialog.vue
+++ b/components/JsonFormDialog.vue
@@ -73,8 +73,8 @@ import {
ElCollapse, ElCollapseItem, ElIcon, ElMessage
} from 'element-plus'
import { translateApi } from 'src/api/common'
-import UploadInput from 'lib/components/UploadInput.vue'
-
+import UploadInput from './UploadInput.vue'
+import WangEditor from 'lib/components/WangEditor.vue'
const props = withDefaults(defineProps<{
show: boolean
title?: string
@@ -298,19 +298,23 @@ const NodeEditor: Component = defineComponent({
}
if (typeof value === 'string') {
+ // 含任意 HTML 闭合标签(如
、)时用富文本编辑器
+ const hasHtmlClosingTag = /<\/[a-zA-Z][\w:-]*\s*>/i.test(value)
+
// const isLong = value.length > 50
- const needUpload = value.startsWith('http')
- const isImage = needUpload && value.includes('.jpg') || value.includes('.png') || value.includes('.jpeg') || value.includes('.gif') || value.includes('.bmp') || value.includes('.webp')
- const isVideo = needUpload && value.includes('.mp4') || value.includes('.avi') || value.includes('.mov') || value.includes('.wmv') || value.includes('.flv') || value.includes('.mkv')
- const isFile = needUpload && value.includes('.pdf') || value.includes('.doc') || value.includes('.docx') || value.includes('.xls') || value.includes('.xlsx') || value.includes('.ppt') || value.includes('.pptx')
+ // const needUpload = value.startsWith('http')
+ const isImage = value.includes('.jpg') || value.includes('.png') || value.includes('.jpeg') || value.includes('.gif') || value.includes('.bmp') || value.includes('.webp')
+ const isVideo = value.includes('.mp4') || value.includes('.avi') || value.includes('.mov') || value.includes('.wmv') || value.includes('.flv') || value.includes('.mkv')
+ const isFile = value.includes('.pdf') || value.includes('.doc') || value.includes('.docx') || value.includes('.xls') || value.includes('.xlsx') || value.includes('.ppt') || value.includes('.pptx')
let uploadNode = null;
- if (needUpload && (isFile || isImage || isVideo)) {
+ if ((isFile || isImage || isVideo)) {
uploadNode = h(UploadInput, {
value: [{url: value}],
type: isImage ? 'image' : isVideo ? 'video' : 'file',
uploadFile: props.uploadFile,
+ hideUploadButton: true,
transport: `#jfd-${key}`,
onUpdateValue: (v: any) => {
if(v.length > 0) {
@@ -325,8 +329,19 @@ const NodeEditor: Component = defineComponent({
'onUpdate:modelValue': (v: string) => { p.parent[key] = v },
})
+ const wangEditor = h(WangEditor, {
+ content: value,
+ showSaveButton: false,
+ transport: `#jfd-${key}`,
+ 'onUpdate:content': (v: any) => {
+ if(v.length > 0) {
+ p.parent[key] = v || ''
+ }
+ },
+ })
+
const formItemNode = [
- uploadNode || inputNode,
+ uploadNode || (hasHtmlClosingTag ? wangEditor : inputNode),
]
@@ -367,9 +382,26 @@ const NodeEditor: Component = defineComponent({
const zhStr = Array.isArray(zhArr) && typeof zhArr[i] === 'string' ? zhArr[i] : undefined
const showBtn = p.lang === 'EN' && zhStr && hasChinese(zhStr)
-
+ const isImage = value[i].includes('.jpg') || value[i].includes('.png') || value[i].includes('.jpeg') || value[i].includes('.gif') || value[i].includes('.bmp') || value[i].includes('.webp')
+ const isVideo = value[i].includes('.mp4') || value[i].includes('.avi') || value[i].includes('.mov') || value[i].includes('.wmv') || value[i].includes('.flv') || value[i].includes('.mkv')
+ const isFile = value[i].includes('.pdf') || value[i].includes('.doc') || value[i].includes('.docx') || value[i].includes('.xls') || value[i].includes('.xlsx') || value[i].includes('.ppt') || value[i].includes('.pptx')
+ let uploadNode = null;
+ if ((isFile || isImage || isVideo)) {
+ uploadNode = h(UploadInput, {
+ value: [{url: value[i], name: value[i]}],
+ type: isImage ? 'image' : isVideo ? 'video' : 'file',
+ uploadFile: props.uploadFile,
+ hideUploadButton: true,
+ transport: `#jfd-${key}`,
+ onUpdateValue: (v: any) => {
+ if(v.length > 0) {
+ value[i] = v[0].url || ''
+ }
+ },
+ })
+ }
return h('div', { class: 'jfd-string-item', key: i }, [
- h(ElInput, {
+ uploadNode || h(ElInput, {
modelValue: value[i],
'onUpdate:modelValue': (v: string) => { value[i] = v },
}),
@@ -392,7 +424,7 @@ const NodeEditor: Component = defineComponent({
h(ElButton, {
size: 'small', type: 'danger', link: true,
onClick: () => value.splice(i, 1),
- }, () => h(ElIcon, { size: 14 }, () => h(Delete)))
+ }, () => h(ElIcon, { size: 14 }, () => h(Delete))),
])
})
])
@@ -424,12 +456,12 @@ const NodeEditor: Component = defineComponent({
const zhItem = Array.isArray(zhArr) ? zhArr[i] : undefined
return h(NodeEditor, { parent: item, fieldKey: k, fieldMap: fm, depth: depth + 1, key: k, lang: p.lang, zhParent: zhItem })
}),
- h('div', { class: 'jfd-item-actions' },
- h(ElButton, {
- size: 'small', type: 'danger',
- onClick: () => value.splice(i, 1),
- }, () => [h(ElIcon, { size: 14 }, () => h(Delete)), ' 删除此项'])
- )
+ // h('div', { class: 'jfd-item-actions' },
+ // h(ElButton, {
+ // size: 'small', type: 'danger',
+ // onClick: () => value.splice(i, 1),
+ // }, () => [h(ElIcon, { size: 14 }, () => h(Delete)), ' 删除此项'])
+ // )
])
})
)
@@ -572,4 +604,5 @@ const NodeEditor: Component = defineComponent({
.jfd-obj-array .el-collapse-item__header {
font-size: 13px;
}
+
diff --git a/components/UploadInput.vue b/components/UploadInput.vue
new file mode 100644
index 0000000..53d2e70
--- /dev/null
+++ b/components/UploadInput.vue
@@ -0,0 +1,260 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/views/config/list.tsx b/views/config/list.tsx
index 6a8f972..e7b7324 100644
--- a/views/config/list.tsx
+++ b/views/config/list.tsx
@@ -55,6 +55,7 @@ const data: TableData = {
(self: any) => {
return self.bean && {
+ console.log('-------')
return await self.api?.upload(file)
}}
show={self.bean.showJsonFormDialog}
diff --git a/views/document/list.tsx b/views/document/list.tsx
index e938f84..9138c27 100644
--- a/views/document/list.tsx
+++ b/views/document/list.tsx
@@ -28,12 +28,23 @@ const data: TableData = {
onSubmit={async (data) => {
const params = {
...data,
- id: data.id ? String(data.id) : '',
category_id: data.category_id ? String(data.category_id) : '',
+ id: data.id ? String(data.id) : '',
}
+
+ params.translations.EN.path = params.path
+ params.translations.ZH.path = params.path
+ delete params.path
+
+ params.translations.ZH.cover = params.cover.image[0] || ''
+ params.translations.EN.cover = params.cover.image[0] || ''
+ delete params.cover
+
+
if(data.id) {
await self.api?.updateData(params)
} else {
+ delete params.id
await self.api?.addData(params)
}
self.bean.showEditorDialog = false;
@@ -51,6 +62,9 @@ const data: TableData = {
if(fieldKey === 'name') {
data[langKey]['file_name'] = langData[fieldKey]
}
+ if(fieldKey === 'cover') {
+ data[langKey]['cover'] = {image: data[langKey]['cover'] ? [{url: data[langKey]['cover']}] : []}
+ }
if (!field) return;
data[langKey][fieldKey] = langData[fieldKey];
})
@@ -173,7 +187,7 @@ const data: TableData = {
{
key: 'cover',
name: '封面',
- type: 'input',
+ width: '80px',
image: true,
},
{
diff --git a/views/news/list.tsx b/views/news/list.tsx
index 331ae7a..9e75cf4 100644
--- a/views/news/list.tsx
+++ b/views/news/list.tsx
@@ -26,6 +26,10 @@ const data: TableData = {
...data,
id: data.id ? String(data.id) : undefined,
category_id: data.category_id ? String(data.category_id) : undefined,
+ cover_resource: {
+ image: data.cover_resource.image.map((i: any) => (i.url || i)),
+ video: data.cover_resource.video.map((i: any) => (i.url || i)),
+ }
}
if(data.id) {
await self.api?.updateData(params)
@@ -132,7 +136,6 @@ const data: TableData = {
[self.bean.newsTypes] = results.map(
(res) => toOptions(res?.data?.items ?? [])
)
- console.log('newsTypes', self.bean.newsTypes)
}
}
],