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);">
@@ -37,28 +37,28 @@ :value="opt.key" />
-
+
图片
{ if (!formData[item.key]) formData[item.key] = { image: [], video: [] }; formData[item.key].image = val.map((item: any) => item.url).filter(Boolean); }" />
-
+
视频
{ if (!formData[item.key]) formData[item.key] = { image: [], video: [] }; formData[item.key].video = val.map((item: any) => item.url).filter(Boolean); }" />
- +
@@ -90,15 +90,10 @@ v-model="formData.translations[primaryLocaleKey][item.key]" type="textarea" :rows="4" :placeholder="`请输入${item.name}`" /> + }"> +
+ +
@@ -132,16 +132,11 @@
- + }"> +
+ +
@@ -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) } } ],