This commit is contained in:
parent
72fb06a4cf
commit
8b4832ca45
|
|
@ -20,7 +20,7 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-form :key="forceUpdateKey" v-if="show" ref="formRef" :model="formData" :rules="formRules"
|
<el-form :key="forceUpdateKey" v-if="show" ref="formRef" :model="formData" :rules="formRules"
|
||||||
label-position="left" v-loading="detailLoading">
|
label-position="left" v-loading="detailLoading" style="border-top: 1px solid var(--el-border-color);">
|
||||||
|
|
||||||
<div v-if="normalFields.length" class="in18-form-top">
|
<div v-if="normalFields.length" class="in18-form-top">
|
||||||
<el-form-item v-for="item in normalFields" :key="item.key" :prop="item.key">
|
<el-form-item v-for="item in normalFields" :key="item.key" :prop="item.key">
|
||||||
|
|
@ -37,28 +37,28 @@
|
||||||
:value="opt.key" />
|
:value="opt.key" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<div v-else-if="isUploadMediaType(item.type)" style="width:100%;">
|
<div v-else-if="isUploadMediaType(item.type)" style="width:100%;">
|
||||||
<div class="upload-group">
|
<div class="upload-group" v-if="item.type.includes('image')">
|
||||||
<div class="upload-label">图片</div>
|
<div class="upload-label">图片</div>
|
||||||
<UploadInput :value="formData[item.key].image"
|
<UploadInput :value="formData[item.key].image"
|
||||||
:type="item.type.includes('images') ? 'images' : 'image'" :uploadFile="uploadFun"
|
:type="item.type.includes('images') ? 'images' : 'image'" :uploadFile="uploadFun"
|
||||||
@updateValue="(val: any) => {
|
hideUploadButton :onUpdateValue="(val: any) => {
|
||||||
if (!formData[item.key]) formData[item.key] = { image: [], video: [] };
|
if (!formData[item.key]) formData[item.key] = { image: [], video: [] };
|
||||||
formData[item.key].image = val.map((item: any) => item.url).filter(Boolean);
|
formData[item.key].image = val.map((item: any) => item.url).filter(Boolean);
|
||||||
}" />
|
}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-group">
|
<div class="upload-group" v-if="item.type.includes('video')">
|
||||||
<div class="upload-label">视频</div>
|
<div class="upload-label">视频</div>
|
||||||
<UploadInput :value="formData[item.key]?.video" type="video" :uploadFile="uploadFun"
|
<UploadInput :value="formData[item.key]?.video" type="video" :uploadFile="uploadFun"
|
||||||
@updateValue="(val: any) => {
|
:onUpdateValue="(val: any) => {
|
||||||
if (!formData[item.key]) formData[item.key] = { image: [], video: [] };
|
if (!formData[item.key]) formData[item.key] = { image: [], video: [] };
|
||||||
formData[item.key].video = val.map((item: any) => item.url).filter(Boolean);
|
formData[item.key].video = val.map((item: any) => item.url).filter(Boolean);
|
||||||
}" />
|
}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="item.type === 'upload:file'" style="width:100%;" class="upload-file">
|
<div v-else-if="item.type === 'upload:file'" style="width:100%;" class="upload-file">
|
||||||
<UploadInput :value="formData[item.key]" type="file" :accept="item.accept || '*'"
|
<UploadInput :value="formData[item.key] && [{ url: formData[item.key], name: formData[item.key] }]" type="file" :accept="item.accept || '*'"
|
||||||
:uploadFile="uploadFun"
|
:uploadFile="uploadFun" hideUploadButton
|
||||||
@updateValue="(val: any) => { formData[item.key] = val[0]?.url || '' }" />
|
:onUpdateValue="(val: any) => { formData[item.key] = val[0]?.url || '' }" />
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -90,15 +90,10 @@
|
||||||
v-model="formData.translations[primaryLocaleKey][item.key]" type="textarea" :rows="4"
|
v-model="formData.translations[primaryLocaleKey][item.key]" type="textarea" :rows="4"
|
||||||
:placeholder="`请输入${item.name}`" />
|
:placeholder="`请输入${item.name}`" />
|
||||||
<WangEditor v-else-if="item.type === 'richtext'"
|
<WangEditor v-else-if="item.type === 'richtext'"
|
||||||
:content="formData.translations[primaryLocaleKey][item.key]"
|
:content="formData.translations[primaryLocaleKey][item.key]" :maxHeight="300"
|
||||||
:maxHeight="300"
|
style="z-index: 9999" :key="item.key" :upload-file="uploadFun" @update:content="(val: string) => {
|
||||||
style="z-index: 9999"
|
|
||||||
:key="item.key"
|
|
||||||
:upload-file="uploadFun"
|
|
||||||
@update:content="(val: string) => {
|
|
||||||
formData.translations[primaryLocaleKey][item.key] = val;
|
formData.translations[primaryLocaleKey][item.key] = val;
|
||||||
}"
|
}">
|
||||||
>
|
|
||||||
</WangEditor>
|
</WangEditor>
|
||||||
<el-select v-else-if="item.type === 'select'"
|
<el-select v-else-if="item.type === 'select'"
|
||||||
v-model="formData.translations[primaryLocaleKey][item.key]" :placeholder="`请选择${item.name}`"
|
v-model="formData.translations[primaryLocaleKey][item.key]" :placeholder="`请选择${item.name}`"
|
||||||
|
|
@ -106,6 +101,11 @@
|
||||||
<el-option v-for="opt in getSelectItems(item)" :key="opt.key" :label="opt.name"
|
<el-option v-for="opt in getSelectItems(item)" :key="opt.key" :label="opt.name"
|
||||||
:value="opt.key" />
|
:value="opt.key" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<div v-else-if="item.type === 'upload:file'" style="width:100%;" class="upload-file">
|
||||||
|
<UploadInput :value="formData.translations[primaryLocaleKey][item.key]" type="file"
|
||||||
|
:accept="item.accept || '*'" :uploadFile="uploadFun" hideUploadButton
|
||||||
|
:onUpdateValue="(val: any) => { formData.translations[primaryLocaleKey][item.key] = val[0]?.url || '' }" />
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<div class="in18-form-center">
|
<div class="in18-form-center">
|
||||||
|
|
@ -132,16 +132,11 @@
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="item.type === 'richtext'" class="field-with-translate">
|
<div v-else-if="item.type === 'richtext'" class="field-with-translate">
|
||||||
<WangEditor
|
<WangEditor :content="formData.translations[activeSecondaryLocale?.key || ''][item.key]"
|
||||||
:content="formData.translations[activeSecondaryLocale?.key || ''][item.key]"
|
:maxHeight="300" style="z-index: 9999" :key="item.key" :upload-file="uploadFun"
|
||||||
:maxHeight="300"
|
|
||||||
style="z-index: 9999"
|
|
||||||
:key="item.key"
|
|
||||||
:upload-file="uploadFun"
|
|
||||||
@update:content="(val: string) => {
|
@update:content="(val: string) => {
|
||||||
formData.translations[activeSecondaryLocale?.key || ''][item.key] = val;
|
formData.translations[activeSecondaryLocale?.key || ''][item.key] = val;
|
||||||
}"
|
}">
|
||||||
>
|
|
||||||
</WangEditor>
|
</WangEditor>
|
||||||
<el-button link size="small" class="translate-icon"
|
<el-button link size="small" class="translate-icon"
|
||||||
:loading="translatingFieldKey === item.key" @click="handleTranslateField(item.key)"
|
:loading="translatingFieldKey === item.key" @click="handleTranslateField(item.key)"
|
||||||
|
|
@ -157,6 +152,11 @@
|
||||||
<el-option v-for="opt in getSelectItems(item)" :key="opt.key" :label="opt.name"
|
<el-option v-for="opt in getSelectItems(item)" :key="opt.key" :label="opt.name"
|
||||||
:value="opt.key" />
|
:value="opt.key" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<div v-else-if="item.type === 'upload:file'" style="width:100%;" class="upload-file">
|
||||||
|
<UploadInput :value="formData.translations[activeSecondaryLocale?.key || ''][item.key]" type="file"
|
||||||
|
:accept="item.accept || '*'" :uploadFile="uploadFun" hideUploadButton
|
||||||
|
:onUpdateValue="(val: any) => { formData.translations[activeSecondaryLocale?.key || ''][item.key] = val[0]?.url || '' }" />
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -174,7 +174,7 @@ import { reactive, computed, watch, ref, nextTick } from 'vue'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Close, Promotion, FullScreen, CopyDocument } from '@element-plus/icons-vue'
|
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";
|
import WangEditor from "lib/components/WangEditor.vue";
|
||||||
type FormItemType =
|
type FormItemType =
|
||||||
| 'input'
|
| 'input'
|
||||||
|
|
@ -206,7 +206,7 @@ type LocaleItem = {
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
fullscreen: boolean
|
fullscreen?: boolean
|
||||||
show: boolean
|
show: boolean
|
||||||
id: string | number
|
id: string | number
|
||||||
title?: string
|
title?: string
|
||||||
|
|
@ -239,7 +239,7 @@ const normalFields = computed(() => props.form.filter((f) => !f.shouldTranslate)
|
||||||
const translateFields = computed(() => props.form.filter((f) => f.shouldTranslate))
|
const translateFields = computed(() => props.form.filter((f) => f.shouldTranslate))
|
||||||
|
|
||||||
function isUploadMediaType(type: string): boolean {
|
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 }[] {
|
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 : '')
|
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(`<x-media id="${index}"/>`)
|
||||||
|
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<string> {
|
||||||
|
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<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
|
|
||||||
const formRules = computed<FormRules>(() => {
|
const formRules = computed<FormRules>(() => {
|
||||||
|
|
@ -391,12 +453,17 @@ async function handleTranslate() {
|
||||||
|
|
||||||
const fromKey = primaryLocale.value.key
|
const fromKey = primaryLocale.value.key
|
||||||
const toKey = activeSecondaryLocale.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) => {
|
translateFields.value.forEach((item) => {
|
||||||
const val = formData.translations?.[fromKey]?.[item.key]
|
const val = formData.translations?.[fromKey]?.[item.key]
|
||||||
if (val != null && String(val).trim()) {
|
if (val != null && String(val).trim() && !val.startsWith('http')) {
|
||||||
texts.push({ key: item.key, text: String(val).trim() })
|
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
|
translating.value = true
|
||||||
try {
|
try {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
texts.map(async ({ key, text }) => {
|
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 })
|
const res = await props.translateApi({ text, from: fromKey, to: toKey })
|
||||||
return { key, translated: parseTranslateResult(res) }
|
translated = parseTranslateResult(res)
|
||||||
|
}
|
||||||
|
return { key, translated }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
let failCount = 0
|
let failCount = 0
|
||||||
|
|
@ -438,10 +511,25 @@ async function handleTranslateField(itemKey: string) {
|
||||||
const toKey = activeSecondaryLocale.value.key
|
const toKey = activeSecondaryLocale.value.key
|
||||||
const text = formData.translations?.[fromKey]?.[itemKey]
|
const text = formData.translations?.[fromKey]?.[itemKey]
|
||||||
if (text == null || !String(text).trim()) return
|
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
|
translatingFieldKey.value = itemKey
|
||||||
try {
|
try {
|
||||||
const res = await props.translateApi({ text: String(text).trim(), from: fromKey, to: toKey })
|
let translated: string
|
||||||
if (formData.translations[toKey]) formData.translations[toKey][itemKey] = parseTranslateResult(res)
|
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) {
|
} catch (e) {
|
||||||
console.error('Translate failed:', e)
|
console.error('Translate failed:', e)
|
||||||
ElMessage.error('翻译失败')
|
ElMessage.error('翻译失败')
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,8 @@ import {
|
||||||
ElCollapse, ElCollapseItem, ElIcon, ElMessage
|
ElCollapse, ElCollapseItem, ElIcon, ElMessage
|
||||||
} from 'element-plus'
|
} from 'element-plus'
|
||||||
import { translateApi } from 'src/api/common'
|
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<{
|
const props = withDefaults(defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
title?: string
|
title?: string
|
||||||
|
|
@ -298,19 +298,23 @@ const NodeEditor: Component = defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
|
// 含任意 HTML 闭合标签(如 </p>、</div>)时用富文本编辑器
|
||||||
|
const hasHtmlClosingTag = /<\/[a-zA-Z][\w:-]*\s*>/i.test(value)
|
||||||
|
|
||||||
// const isLong = value.length > 50
|
// const isLong = value.length > 50
|
||||||
|
|
||||||
const needUpload = value.startsWith('http')
|
// 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 isImage = 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 isVideo = 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 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;
|
let uploadNode = null;
|
||||||
if (needUpload && (isFile || isImage || isVideo)) {
|
if ((isFile || isImage || isVideo)) {
|
||||||
uploadNode = h(UploadInput, {
|
uploadNode = h(UploadInput, {
|
||||||
value: [{url: value}],
|
value: [{url: value}],
|
||||||
type: isImage ? 'image' : isVideo ? 'video' : 'file',
|
type: isImage ? 'image' : isVideo ? 'video' : 'file',
|
||||||
uploadFile: props.uploadFile,
|
uploadFile: props.uploadFile,
|
||||||
|
hideUploadButton: true,
|
||||||
transport: `#jfd-${key}`,
|
transport: `#jfd-${key}`,
|
||||||
onUpdateValue: (v: any) => {
|
onUpdateValue: (v: any) => {
|
||||||
if(v.length > 0) {
|
if(v.length > 0) {
|
||||||
|
|
@ -325,8 +329,19 @@ const NodeEditor: Component = defineComponent({
|
||||||
'onUpdate:modelValue': (v: string) => { p.parent[key] = v },
|
'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 = [
|
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 zhStr = Array.isArray(zhArr) && typeof zhArr[i] === 'string' ? zhArr[i] : undefined
|
||||||
const showBtn = p.lang === 'EN' && zhStr && hasChinese(zhStr)
|
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 }, [
|
return h('div', { class: 'jfd-string-item', key: i }, [
|
||||||
h(ElInput, {
|
uploadNode || h(ElInput, {
|
||||||
modelValue: value[i],
|
modelValue: value[i],
|
||||||
'onUpdate:modelValue': (v: string) => { value[i] = v },
|
'onUpdate:modelValue': (v: string) => { value[i] = v },
|
||||||
}),
|
}),
|
||||||
|
|
@ -392,7 +424,7 @@ const NodeEditor: Component = defineComponent({
|
||||||
h(ElButton, {
|
h(ElButton, {
|
||||||
size: 'small', type: 'danger', link: true,
|
size: 'small', type: 'danger', link: true,
|
||||||
onClick: () => value.splice(i, 1),
|
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
|
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 })
|
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('div', { class: 'jfd-item-actions' },
|
||||||
h(ElButton, {
|
// h(ElButton, {
|
||||||
size: 'small', type: 'danger',
|
// size: 'small', type: 'danger',
|
||||||
onClick: () => value.splice(i, 1),
|
// onClick: () => value.splice(i, 1),
|
||||||
}, () => [h(ElIcon, { size: 14 }, () => h(Delete)), ' 删除此项'])
|
// }, () => [h(ElIcon, { size: 14 }, () => h(Delete)), ' 删除此项'])
|
||||||
)
|
// )
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
@ -572,4 +604,5 @@ const NodeEditor: Component = defineComponent({
|
||||||
.jfd-obj-array .el-collapse-item__header {
|
.jfd-obj-array .el-collapse-item__header {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
<template>
|
||||||
|
<div v-loading="isUploading" element-loading-text="上传中..." class="upload-loading-wrapper">
|
||||||
|
<el-upload ref="uploadRef" v-model:file-list="fileList" action="*" :on-preview="handlePictureCardPreview"
|
||||||
|
v-bind="options" :http-request="handleHttpRequest" :on-change="handleChange"
|
||||||
|
:class="isMaxCount ? 'max-count' : ''" style="width: 100%">
|
||||||
|
<el-icon v-show="['image', 'images'].includes(type)">
|
||||||
|
<Plus />
|
||||||
|
</el-icon>
|
||||||
|
<div v-show="['file', 'files', 'video', 'videos'].includes(type)">
|
||||||
|
<el-icon class="el-icon--upload">
|
||||||
|
<upload-filled />
|
||||||
|
</el-icon>
|
||||||
|
<div class="el-upload__text">拖拽到这里或者 <em>点击上传</em></div>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
<el-button v-show="showUploadButton" type="primary" @click="handleUpload">上传</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
ref,
|
||||||
|
createVNode,
|
||||||
|
render,
|
||||||
|
} from "vue";
|
||||||
|
import { Plus } from "@element-plus/icons-vue";
|
||||||
|
import type { UploadFile, UploadFiles, UploadUserFile } from "element-plus";
|
||||||
|
import { ElImageViewer } from "element-plus";
|
||||||
|
import utils from "lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: {
|
||||||
|
data?: FormData;
|
||||||
|
uid?: string;
|
||||||
|
url: string;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
}[];
|
||||||
|
uploadFile: (file: FormData) => Promise<any>;
|
||||||
|
type?: "image" | "images" | "file" | "files" | "video" | "videos";
|
||||||
|
transport?: string;
|
||||||
|
accept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: "image",
|
||||||
|
transport: "",
|
||||||
|
accept: "*",
|
||||||
|
value: [] as any,
|
||||||
|
uploadFile: () => Promise.resolve({ url: "" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const showUploadButton = computed(() => {
|
||||||
|
return options.value.autoUpload === false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["updateValue"])
|
||||||
|
|
||||||
|
const fileList = ref<UploadUserFile[]>([...props.value] as any);
|
||||||
|
const uploadRef = ref<any>(null);
|
||||||
|
const options = computed(() => {
|
||||||
|
switch (props.type) {
|
||||||
|
case "image":
|
||||||
|
return {
|
||||||
|
limit: 1,
|
||||||
|
listType: "picture-card" as const, // 明确指定为字面量类型
|
||||||
|
accept: "image/*",
|
||||||
|
};
|
||||||
|
case "images":
|
||||||
|
return {
|
||||||
|
limit: 9,
|
||||||
|
listType: "picture-card" as const, // 明确指定为字面量类型
|
||||||
|
accept: "image/*",
|
||||||
|
multiple: true,
|
||||||
|
autoUpload: false,
|
||||||
|
};
|
||||||
|
case "video":
|
||||||
|
return {
|
||||||
|
limit: 1,
|
||||||
|
listType: "text" as const, // 明确指定为字面量类型
|
||||||
|
accept: "video/*",
|
||||||
|
};
|
||||||
|
case "videos":
|
||||||
|
return {
|
||||||
|
limit: 9,
|
||||||
|
listType: "text" as const, // 明确指定为字面量类型
|
||||||
|
accept: "video/*",
|
||||||
|
multiple: true,
|
||||||
|
autoUpload: false,
|
||||||
|
};
|
||||||
|
case "file":
|
||||||
|
return {
|
||||||
|
limit: 1,
|
||||||
|
accept: props.accept,
|
||||||
|
drag: true,
|
||||||
|
listType: "text" as const, // 为文件类型添加合适的 listType
|
||||||
|
};
|
||||||
|
case "files":
|
||||||
|
return {
|
||||||
|
limit: 10,
|
||||||
|
accept: props.accept,
|
||||||
|
drag: true,
|
||||||
|
listType: "text" as const, // 为文件类型添加合适的 listType
|
||||||
|
multiple: true,
|
||||||
|
autoUpload: false,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
limit: 1,
|
||||||
|
listType: "picture-card" as const,
|
||||||
|
accept: props.accept,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMaxCount = computed(() => {
|
||||||
|
return fileList.value.length >= options.value.limit;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isUploading = ref(false);
|
||||||
|
|
||||||
|
|
||||||
|
interface UploadRequestOptions {
|
||||||
|
file: File & { uid?: number; raw?: File, url?: string };
|
||||||
|
filename: string;
|
||||||
|
onSuccess: (res: any) => void;
|
||||||
|
onError: (err: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHttpRequest = async (options: UploadRequestOptions) => {
|
||||||
|
isUploading.value = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
const rawFile = options.file?.raw ?? options.file;
|
||||||
|
formData.append(options.filename || "file", rawFile, rawFile.name);
|
||||||
|
try {
|
||||||
|
const res = await props.uploadFile(formData);
|
||||||
|
const url = res?.data?.url ?? res?.url;
|
||||||
|
if (url) {
|
||||||
|
options.file.url = url;
|
||||||
|
}
|
||||||
|
options.onSuccess(res);
|
||||||
|
isUploading.value = false;
|
||||||
|
} catch (err) {
|
||||||
|
console.log('err', err)
|
||||||
|
options.onError(err as any);
|
||||||
|
isUploading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (uploadFile: UploadFile) => {
|
||||||
|
// props.onFileChange(uploadFile);
|
||||||
|
// emit("onUpdateValue", fileList.value.filter((f: any) => f.uid !== uploadFile.uid));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (file: any, list: any) => {
|
||||||
|
const updateList = fileList.value
|
||||||
|
.filter((f: any) => f.status === 'success')
|
||||||
|
.map((f: any) => {
|
||||||
|
return {
|
||||||
|
url: f.raw?.url,
|
||||||
|
uid: f.raw?.uid,
|
||||||
|
name: f.raw?.name,
|
||||||
|
id: f.raw?.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
emit("updateValue", updateList);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const handlePictureCardPreview = (file: any) => {
|
||||||
|
const imageUrl = file.url;
|
||||||
|
if (["file", "files"].includes(props.type)) {
|
||||||
|
// 下载文件
|
||||||
|
|
||||||
|
utils.downloadfile(imageUrl, imageUrl, "image/png");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果是视频,打开标签页
|
||||||
|
if (["video", "videos"].includes(props.type)) {
|
||||||
|
window.open(imageUrl, "_blank");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!imageUrl) return;
|
||||||
|
|
||||||
|
// 创建一个用于挂载 el-image-viewer 的容器
|
||||||
|
const container = document.createElement("div");
|
||||||
|
const parentNode =
|
||||||
|
document.querySelector(props.transport || "body") || document.body;
|
||||||
|
parentNode.appendChild(container);
|
||||||
|
// 创建并挂载 el-image-viewer 节点
|
||||||
|
const vnode = createVNode(ElImageViewer as any, {
|
||||||
|
urlList: [imageUrl],
|
||||||
|
onClose: () => {
|
||||||
|
render(null, container);
|
||||||
|
parentNode.removeChild(container);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(vnode, container);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
uploadRef.value.submit();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.upload-loading-wrapper {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-upload--picture-card) {
|
||||||
|
--el-upload-picture-card-size: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-upload-list--picture-card) {
|
||||||
|
--el-upload-list-picture-card-size: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-icon--close-tip) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置上传中进度条的大小
|
||||||
|
:deep(.el-upload-list__item.is-uploading .el-progress) {
|
||||||
|
.el-progress-circle {
|
||||||
|
transform: scale(0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-count {
|
||||||
|
:deep(.el-upload--picture-card) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-upload) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-upload.el-upload--text) {
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100px;
|
||||||
|
|
||||||
|
&>div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.el-icon.el-icon--upload {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
@ -55,6 +55,7 @@ const data: TableData = {
|
||||||
(self: any) => {
|
(self: any) => {
|
||||||
return self.bean && <JsonFormDialog
|
return self.bean && <JsonFormDialog
|
||||||
uploadFile={async (file: FormData) => {
|
uploadFile={async (file: FormData) => {
|
||||||
|
console.log('-------')
|
||||||
return await self.api?.upload(file)
|
return await self.api?.upload(file)
|
||||||
}}
|
}}
|
||||||
show={self.bean.showJsonFormDialog}
|
show={self.bean.showJsonFormDialog}
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,23 @@ const data: TableData = {
|
||||||
onSubmit={async (data) => {
|
onSubmit={async (data) => {
|
||||||
const params = {
|
const params = {
|
||||||
...data,
|
...data,
|
||||||
id: data.id ? String(data.id) : '',
|
|
||||||
category_id: data.category_id ? String(data.category_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) {
|
if(data.id) {
|
||||||
await self.api?.updateData(params)
|
await self.api?.updateData(params)
|
||||||
} else {
|
} else {
|
||||||
|
delete params.id
|
||||||
await self.api?.addData(params)
|
await self.api?.addData(params)
|
||||||
}
|
}
|
||||||
self.bean.showEditorDialog = false;
|
self.bean.showEditorDialog = false;
|
||||||
|
|
@ -51,6 +62,9 @@ const data: TableData = {
|
||||||
if(fieldKey === 'name') {
|
if(fieldKey === 'name') {
|
||||||
data[langKey]['file_name'] = langData[fieldKey]
|
data[langKey]['file_name'] = langData[fieldKey]
|
||||||
}
|
}
|
||||||
|
if(fieldKey === 'cover') {
|
||||||
|
data[langKey]['cover'] = {image: data[langKey]['cover'] ? [{url: data[langKey]['cover']}] : []}
|
||||||
|
}
|
||||||
if (!field) return;
|
if (!field) return;
|
||||||
data[langKey][fieldKey] = langData[fieldKey];
|
data[langKey][fieldKey] = langData[fieldKey];
|
||||||
})
|
})
|
||||||
|
|
@ -173,7 +187,7 @@ const data: TableData = {
|
||||||
{
|
{
|
||||||
key: 'cover',
|
key: 'cover',
|
||||||
name: '封面',
|
name: '封面',
|
||||||
type: 'input',
|
width: '80px',
|
||||||
image: true,
|
image: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ const data: TableData = {
|
||||||
...data,
|
...data,
|
||||||
id: data.id ? String(data.id) : undefined,
|
id: data.id ? String(data.id) : undefined,
|
||||||
category_id: data.category_id ? String(data.category_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) {
|
if(data.id) {
|
||||||
await self.api?.updateData(params)
|
await self.api?.updateData(params)
|
||||||
|
|
@ -132,7 +136,6 @@ const data: TableData = {
|
||||||
[self.bean.newsTypes] = results.map(
|
[self.bean.newsTypes] = results.map(
|
||||||
(res) => toOptions(res?.data?.items ?? [])
|
(res) => toOptions(res?.data?.items ?? [])
|
||||||
)
|
)
|
||||||
console.log('newsTypes', self.bean.newsTypes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue