save
This commit is contained in:
parent
7acef4c569
commit
3ba5da9b98
17
api/app.ts
17
api/app.ts
|
|
@ -1,17 +1,4 @@
|
||||||
import Request from "lib/utils/requests";
|
import { uploadApi } from "./common";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
upload(data: any) {
|
upload: uploadApi.upload
|
||||||
return Request({
|
|
||||||
url: "/admin/cmp_upload",
|
|
||||||
method: "post",
|
|
||||||
data,
|
|
||||||
params: {
|
|
||||||
expire: 24 * 30 * 12 * 10,
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "multipart/form-data",
|
|
||||||
},
|
|
||||||
}).then(res => ({ data: {url: res.data} }))
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,8 @@ export const uploadApi = {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "multipart/form-data",
|
"Content-Type": "multipart/form-data",
|
||||||
},
|
},
|
||||||
|
}).then(res => {
|
||||||
|
return {data: {url: res.data}}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Request from "lib/utils/requests";
|
import Request from "lib/utils/requests";
|
||||||
|
import { uploadApi } from "../common";
|
||||||
export default {
|
export default {
|
||||||
getDataList(params: any) {
|
getDataList(params: any) {
|
||||||
return Request({
|
return Request({
|
||||||
|
|
@ -28,5 +29,6 @@ export default {
|
||||||
data: params
|
data: params
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
upload: uploadApi.upload,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-form v-if="show" ref="formRef" :model="formData" :rules="formRules" label-position="left"
|
<el-form :key="forceUpdateKey" v-if="show" ref="formRef" :model="formData" :rules="formRules"
|
||||||
v-loading="detailLoading">
|
label-position="left" v-loading="detailLoading">
|
||||||
|
|
||||||
<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">
|
||||||
|
|
@ -39,26 +39,26 @@
|
||||||
<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">
|
||||||
<div class="upload-label">图片</div>
|
<div class="upload-label">图片</div>
|
||||||
<UploadInput :model-value="(formData[item.key]?.image || []).join(',')"
|
<UploadInput :value="formData[item.key].image"
|
||||||
:type="item.type.includes('images') ? 'images' : 'image'"
|
:type="item.type.includes('images') ? 'images' : 'image'" :uploadFile="uploadFun"
|
||||||
:uploadFile="uploadFun" @update:modelValue="(val: string) => {
|
@updateValue="(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 ? val.split(',').filter(Boolean) : [];
|
formData[item.key].image = val.map((item: any) => item.url).filter(Boolean);
|
||||||
}" />
|
}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-group">
|
<div class="upload-group">
|
||||||
<div class="upload-label">视频</div>
|
<div class="upload-label">视频</div>
|
||||||
<UploadInput :model-value="(formData[item.key]?.video || []).join(',')" type="video"
|
<UploadInput :value="formData[item.key]?.video" type="video" :uploadFile="uploadFun"
|
||||||
:uploadFile="uploadFun" @update:modelValue="(val: string) => {
|
@updateValue="(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 ? val.split(',').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 :model-value="formData[item.key]" type="file" :accept="item.accept || '*'"
|
<UploadInput :value="formData[item.key]" type="file" :accept="item.accept || '*'"
|
||||||
:uploadFile="uploadFun"
|
:uploadFile="uploadFun"
|
||||||
@update:modelValue="(val: string) => { formData[item.key] = val }" />
|
@updateValue="(val: any) => { formData[item.key] = val[0]?.url || '' }" />
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -87,11 +87,22 @@
|
||||||
v-model="formData.translations[primaryLocaleKey][item.key]"
|
v-model="formData.translations[primaryLocaleKey][item.key]"
|
||||||
:placeholder="`请输入${item.name}`" />
|
:placeholder="`请输入${item.name}`" />
|
||||||
<el-input v-else-if="item.type === 'textarea'"
|
<el-input v-else-if="item.type === 'textarea'"
|
||||||
v-model="formData.translations[primaryLocaleKey][item.key]" type="textarea"
|
v-model="formData.translations[primaryLocaleKey][item.key]" type="textarea" :rows="4"
|
||||||
:rows="4" :placeholder="`请输入${item.name}`" />
|
:placeholder="`请输入${item.name}`" />
|
||||||
|
<WangEditor v-else-if="item.type === 'richtext'"
|
||||||
|
:content="formData.translations[primaryLocaleKey][item.key]"
|
||||||
|
:maxHeight="300"
|
||||||
|
style="z-index: 9999"
|
||||||
|
:key="item.key"
|
||||||
|
:upload-file="uploadFun"
|
||||||
|
@update:content="(val: string) => {
|
||||||
|
formData.translations[primaryLocaleKey][item.key] = val;
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
</WangEditor>
|
||||||
<el-select v-else-if="item.type === 'select'"
|
<el-select v-else-if="item.type === 'select'"
|
||||||
v-model="formData.translations[primaryLocaleKey][item.key]"
|
v-model="formData.translations[primaryLocaleKey][item.key]" :placeholder="`请选择${item.name}`"
|
||||||
:placeholder="`请选择${item.name}`" clearable style="width: 100%">
|
clearable style="width: 100%">
|
||||||
<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>
|
||||||
|
|
@ -109,11 +120,29 @@
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</template>
|
</template>
|
||||||
<div v-if="item.type === 'input' || item.type === 'textarea'" class="field-with-translate">
|
<div v-if="item.type === 'input' || item.type === 'textarea'" class="field-with-translate">
|
||||||
<el-input
|
<el-input v-model="formData.translations[activeSecondaryLocale?.key || ''][item.key]"
|
||||||
v-model="formData.translations[activeSecondaryLocale?.key || ''][item.key]"
|
|
||||||
:type="item.type === 'textarea' ? 'textarea' : undefined"
|
:type="item.type === 'textarea' ? 'textarea' : undefined"
|
||||||
:rows="item.type === 'textarea' ? 4 : undefined"
|
:rows="item.type === 'textarea' ? 4 : undefined" :placeholder="`请输入${item.name}`" />
|
||||||
:placeholder="`请输入${item.name}`" />
|
<el-button link size="small" class="translate-icon"
|
||||||
|
:loading="translatingFieldKey === item.key" @click="handleTranslateField(item.key)"
|
||||||
|
title="翻译该字段">
|
||||||
|
<el-icon color="#409eff">
|
||||||
|
<Promotion />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="item.type === 'richtext'" class="field-with-translate">
|
||||||
|
<WangEditor
|
||||||
|
:content="formData.translations[activeSecondaryLocale?.key || ''][item.key]"
|
||||||
|
:maxHeight="300"
|
||||||
|
style="z-index: 9999"
|
||||||
|
:key="item.key"
|
||||||
|
:upload-file="uploadFun"
|
||||||
|
@update:content="(val: string) => {
|
||||||
|
formData.translations[activeSecondaryLocale?.key || ''][item.key] = val;
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
</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)"
|
||||||
title="翻译该字段">
|
title="翻译该字段">
|
||||||
|
|
@ -146,11 +175,12 @@ 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 "lib/components/UploadInput.vue";
|
||||||
|
import WangEditor from "lib/components/WangEditor.vue";
|
||||||
type FormItemType =
|
type FormItemType =
|
||||||
| 'input'
|
| 'input'
|
||||||
| 'select'
|
| 'select'
|
||||||
| 'textarea'
|
| 'textarea'
|
||||||
|
| 'richtext'
|
||||||
| 'upload:images,video'
|
| 'upload:images,video'
|
||||||
| 'upload:image,video'
|
| 'upload:image,video'
|
||||||
| 'upload:file'
|
| 'upload:file'
|
||||||
|
|
@ -176,6 +206,7 @@ type LocaleItem = {
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
fullscreen: boolean
|
||||||
show: boolean
|
show: boolean
|
||||||
id: string | number
|
id: string | number
|
||||||
title?: string
|
title?: string
|
||||||
|
|
@ -186,6 +217,7 @@ const props = withDefaults(
|
||||||
uploadFun?: (formData: FormData) => Promise<any>
|
uploadFun?: (formData: FormData) => Promise<any>
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
|
fullscreen: false,
|
||||||
show: false,
|
show: false,
|
||||||
id: '',
|
id: '',
|
||||||
title: '详情',
|
title: '详情',
|
||||||
|
|
@ -245,9 +277,14 @@ const formData = reactive<{
|
||||||
|
|
||||||
const translating = ref(false)
|
const translating = ref(false)
|
||||||
const translatingFieldKey = ref<string>('')
|
const translatingFieldKey = ref<string>('')
|
||||||
const isFullscreen = ref(false)
|
const isFullscreen = ref(props.fullscreen || false)
|
||||||
const detailLoading = ref(false)
|
const detailLoading = ref(false)
|
||||||
|
|
||||||
|
const forceUpdateKey = ref(0)
|
||||||
|
function forceUpdate() {
|
||||||
|
forceUpdateKey.value++
|
||||||
|
}
|
||||||
|
|
||||||
function toggleFullscreen() {
|
function toggleFullscreen() {
|
||||||
isFullscreen.value = !isFullscreen.value
|
isFullscreen.value = !isFullscreen.value
|
||||||
}
|
}
|
||||||
|
|
@ -309,6 +346,7 @@ watch(
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await props.detailApi(Number(props.id))
|
const data = await props.detailApi(Number(props.id))
|
||||||
|
console.log('data', data)
|
||||||
if (data) {
|
if (data) {
|
||||||
const pKey = primaryLocaleKey.value
|
const pKey = primaryLocaleKey.value
|
||||||
Object.keys(data).forEach((localeKey) => {
|
Object.keys(data).forEach((localeKey) => {
|
||||||
|
|
@ -330,7 +368,9 @@ watch(
|
||||||
console.error('Failed to load detail:', e)
|
console.error('Failed to load detail:', e)
|
||||||
ElMessage.error('加载详情失败')
|
ElMessage.error('加载详情失败')
|
||||||
} finally {
|
} finally {
|
||||||
detailLoading.value = false
|
detailLoading.value = false;
|
||||||
|
// 强制刷新
|
||||||
|
forceUpdate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,23 @@
|
||||||
|
<!--
|
||||||
|
JsonFormDialog - JSON 多语言配置编辑弹窗
|
||||||
|
|
||||||
|
功能:
|
||||||
|
- 将 JSON 对象按语言 key(如 ZH、EN)拆分为 tab 页签,递归渲染为可编辑表单
|
||||||
|
- 支持 string / number / boolean / object / array(含对象数组、字符串数组)类型自动识别与编辑
|
||||||
|
- EN tab 下支持"一键翻译":将 ZH 内容复制到 EN 并批量调用翻译接口(仅翻译含汉字的字段)
|
||||||
|
- EN tab 下每个文本框支持单字段翻译按钮(当对应 ZH 值含中文时显示)
|
||||||
|
- 翻译请求分批执行(BATCH_SIZE / BATCH_DELAY),防止接口过载
|
||||||
|
|
||||||
|
Props:
|
||||||
|
- show: boolean 控制弹窗显示
|
||||||
|
- title: string 弹窗标题,默认 '配置编辑'
|
||||||
|
- data: Record<string, any> JSON 数据,顶层 key 作为语言 tab
|
||||||
|
- fieldMap: Record<string, string> 字段名映射,用于显示中文 label
|
||||||
|
|
||||||
|
Events:
|
||||||
|
- close 关闭弹窗
|
||||||
|
- submit(data) 保存,返回编辑后的完整 JSON 数据
|
||||||
|
-->
|
||||||
<template>
|
<template>
|
||||||
<el-dialog :model-value="show" width="900" :fullscreen="isFullscreen" :close-on-click-modal="false" @close="close"
|
<el-dialog :model-value="show" width="900" :fullscreen="isFullscreen" :close-on-click-modal="false" @close="close"
|
||||||
:show-close="false">
|
:show-close="false">
|
||||||
|
|
@ -21,12 +41,15 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-tabs v-if="langKeys.length" v-model="activeTab" type="border-card">
|
<el-tabs v-if="langKeys.length" v-model="activeTab" type="border-card"
|
||||||
|
v-loading="translating" element-loading-text="正在翻译中..."
|
||||||
|
element-loading-background="rgba(255,255,255,0.8)">
|
||||||
<el-tab-pane v-for="lang in langKeys" :key="lang" :label="lang" :name="lang">
|
<el-tab-pane v-for="lang in langKeys" :key="lang" :label="lang" :name="lang">
|
||||||
<el-scrollbar max-height="60vh">
|
<el-scrollbar max-height="60vh">
|
||||||
<div class="jfd-content">
|
<div class="jfd-content">
|
||||||
<NodeEditor v-for="sKey in getSectionKeys(lang)" :key="sKey" :parent="formData[lang]"
|
<NodeEditor v-for="sKey in getSectionKeys(lang)" :key="sKey" :parent="formData[lang]"
|
||||||
:field-key="sKey" :field-map="fieldMap" :depth="0" />
|
:field-key="sKey" :field-map="fieldMap" :depth="0" :lang="lang"
|
||||||
|
:zh-parent="lang === 'EN' ? formData['ZH'] : undefined" />
|
||||||
</div>
|
</div>
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|
@ -34,6 +57,8 @@
|
||||||
<el-empty v-else description="无数据" />
|
<el-empty v-else description="无数据" />
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
<el-button v-if="activeTab === 'EN'" type="warning" :loading="translating"
|
||||||
|
@click="handleTranslateAll">一键翻译</el-button>
|
||||||
<el-button @click="close">取消</el-button>
|
<el-button @click="close">取消</el-button>
|
||||||
<el-button type="primary" @click="submit">保存</el-button>
|
<el-button type="primary" @click="submit">保存</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -42,22 +67,26 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, defineComponent, h, type PropType, type VNode, type Component } from 'vue'
|
import { ref, computed, watch, defineComponent, h, type PropType, type VNode, type Component } from 'vue'
|
||||||
import { Close, FullScreen, CopyDocument, Plus, Delete } from '@element-plus/icons-vue'
|
import { Close, FullScreen, CopyDocument, Plus, Delete, Promotion } from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
ElFormItem, ElInput, ElInputNumber, ElSwitch, ElButton,
|
ElFormItem, ElInput, ElInputNumber, ElSwitch, ElButton,
|
||||||
ElCollapse, ElCollapseItem, ElIcon
|
ElCollapse, ElCollapseItem, ElIcon, ElMessage
|
||||||
} from 'element-plus'
|
} from 'element-plus'
|
||||||
|
import { translateApi } from 'src/api/common'
|
||||||
|
import UploadInput from 'lib/components/UploadInput.vue'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
title?: string
|
title?: string
|
||||||
data: Record<string, any>
|
data: Record<string, any>
|
||||||
fieldMap?: Record<string, string>
|
fieldMap?: Record<string, string>
|
||||||
|
uploadFile: (file: FormData) => Promise<any>
|
||||||
}>(), {
|
}>(), {
|
||||||
show: false,
|
show: false,
|
||||||
title: '配置编辑',
|
title: '配置编辑',
|
||||||
data: () => ({}),
|
data: () => ({}),
|
||||||
fieldMap: () => ({}),
|
fieldMap: () => ({}),
|
||||||
|
uploadFile: () => Promise.resolve({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'submit'])
|
const emit = defineEmits(['close', 'submit'])
|
||||||
|
|
@ -65,9 +94,41 @@ const emit = defineEmits(['close', 'submit'])
|
||||||
const isFullscreen = ref(false)
|
const isFullscreen = ref(false)
|
||||||
const formData = ref<Record<string, any>>({})
|
const formData = ref<Record<string, any>>({})
|
||||||
const activeTab = ref('')
|
const activeTab = ref('')
|
||||||
|
const translating = ref(false)
|
||||||
|
const translatingPath = ref('')
|
||||||
|
|
||||||
const langKeys = computed(() => Object.keys(formData.value))
|
const langKeys = computed(() => Object.keys(formData.value))
|
||||||
|
|
||||||
|
function hasChinese(str: string): boolean {
|
||||||
|
return /[\u4e00-\u9fa5]/.test(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTranslateResult(res: any): string {
|
||||||
|
return res?.data?.text ?? res?.data ?? res?.result ?? (typeof res === 'string' ? res : '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectChineseStrings(obj: any, result: { parent: any; key: string | number }[]) {
|
||||||
|
if (obj === null || obj === undefined) return
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
obj.forEach((item, i) => {
|
||||||
|
if (typeof item === 'string' && hasChinese(item)) {
|
||||||
|
result.push({ parent: obj, key: i })
|
||||||
|
} else if (typeof item === 'object') {
|
||||||
|
collectChineseStrings(item, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (typeof obj === 'object') {
|
||||||
|
for (const k of Object.keys(obj)) {
|
||||||
|
const val = obj[k]
|
||||||
|
if (typeof val === 'string' && hasChinese(val)) {
|
||||||
|
result.push({ parent: obj, key: k })
|
||||||
|
} else if (typeof val === 'object') {
|
||||||
|
collectChineseStrings(val, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getSectionKeys(lang: string) {
|
function getSectionKeys(lang: string) {
|
||||||
const langData = formData.value[lang]
|
const langData = formData.value[lang]
|
||||||
return langData && typeof langData === 'object' ? Object.keys(langData) : []
|
return langData && typeof langData === 'object' ? Object.keys(langData) : []
|
||||||
|
|
@ -116,6 +177,74 @@ function submit() {
|
||||||
emit('submit', JSON.parse(JSON.stringify(formData.value)))
|
emit('submit', JSON.parse(JSON.stringify(formData.value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BATCH_SIZE = 3
|
||||||
|
const BATCH_DELAY = 300
|
||||||
|
|
||||||
|
function delay(ms: number) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTranslateAll() {
|
||||||
|
if (!formData.value.ZH || translating.value) return
|
||||||
|
formData.value.EN = JSON.parse(JSON.stringify(formData.value.ZH))
|
||||||
|
const items: { parent: any; key: string | number }[] = []
|
||||||
|
collectChineseStrings(formData.value.EN, items)
|
||||||
|
if (!items.length) {
|
||||||
|
ElMessage.info('没有需要翻译的中文内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
translating.value = true
|
||||||
|
let failCount = 0
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < items.length; i += BATCH_SIZE) {
|
||||||
|
const batch = items.slice(i, i + BATCH_SIZE)
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
batch.map(async ({ parent, key }) => {
|
||||||
|
const text = String(parent[key]).trim()
|
||||||
|
const res = await translateApi.translate({ text, from: 'ZH', to: 'EN' })
|
||||||
|
return { parent, key, translated: parseTranslateResult(res) }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
const { parent, key, translated } = result.value
|
||||||
|
if (translated) parent[key] = translated
|
||||||
|
} else {
|
||||||
|
failCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i + BATCH_SIZE < items.length) await delay(BATCH_DELAY)
|
||||||
|
}
|
||||||
|
if (failCount > 0) {
|
||||||
|
ElMessage.warning(`${failCount} 个字段翻译失败`)
|
||||||
|
} else {
|
||||||
|
ElMessage.success('翻译完成')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Translate failed:', e)
|
||||||
|
ElMessage.error('翻译失败')
|
||||||
|
} finally {
|
||||||
|
translating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTranslateField(parent: any, key: string | number, zhParent: any) {
|
||||||
|
if (!zhParent || translatingPath.value) return
|
||||||
|
const zhText = String(zhParent[key] ?? '').trim()
|
||||||
|
if (!hasChinese(zhText)) return
|
||||||
|
translatingPath.value = `${key}`
|
||||||
|
try {
|
||||||
|
const res = await translateApi.translate({ text: zhText, from: 'ZH', to: 'EN' })
|
||||||
|
const translated = parseTranslateResult(res)
|
||||||
|
if (translated) parent[key] = translated
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Translate failed:', e)
|
||||||
|
ElMessage.error('翻译失败')
|
||||||
|
} finally {
|
||||||
|
translatingPath.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createEmpty(template: any): any {
|
function createEmpty(template: any): any {
|
||||||
if (typeof template === 'string') return ''
|
if (typeof template === 'string') return ''
|
||||||
if (typeof template === 'number') return 0
|
if (typeof template === 'number') return 0
|
||||||
|
|
@ -136,6 +265,8 @@ const NodeEditor: Component = defineComponent({
|
||||||
fieldKey: { type: [String, Number] as PropType<string | number>, required: true },
|
fieldKey: { type: [String, Number] as PropType<string | number>, required: true },
|
||||||
fieldMap: { type: Object as PropType<Record<string, string>>, default: () => ({}) },
|
fieldMap: { type: Object as PropType<Record<string, string>>, default: () => ({}) },
|
||||||
depth: { type: Number, default: 0 },
|
depth: { type: Number, default: 0 },
|
||||||
|
lang: { type: String, default: '' },
|
||||||
|
zhParent: { type: Object as PropType<any>, default: undefined },
|
||||||
},
|
},
|
||||||
setup(p) {
|
setup(p) {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -167,15 +298,54 @@ const NodeEditor: Component = defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const isLong = value.length > 50
|
// const isLong = value.length > 50
|
||||||
return h(ElFormItem, { label, class: 'jfd-form-item' }, () =>
|
|
||||||
h(ElInput, {
|
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')
|
||||||
|
|
||||||
|
let uploadNode = null;
|
||||||
|
if (needUpload && (isFile || isImage || isVideo)) {
|
||||||
|
uploadNode = h(UploadInput, {
|
||||||
|
value: [{url: value}],
|
||||||
|
type: isImage ? 'image' : isVideo ? 'video' : 'file',
|
||||||
|
uploadFile: props.uploadFile,
|
||||||
|
transport: `#jfd-${key}`,
|
||||||
|
onUpdateValue: (v: any) => {
|
||||||
|
if(v.length > 0) {
|
||||||
|
p.parent[key] = v[0].url || ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const inputNode = h(ElInput, {
|
||||||
modelValue: value,
|
modelValue: value,
|
||||||
...(isLong ? { type: 'textarea', autosize: { minRows: 2, maxRows: 6 } } : {}),
|
...({ type: 'textarea', autosize: { minRows: 1, maxRows: 6 } }),
|
||||||
'onUpdate:modelValue': (v: string) => { p.parent[key] = v },
|
'onUpdate:modelValue': (v: string) => { p.parent[key] = v },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const formItemNode = [
|
||||||
|
uploadNode || inputNode,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
const zhVal = p.zhParent?.[key]
|
||||||
|
const showTranslateBtn = p.lang === 'EN' && typeof zhVal === 'string' && hasChinese(zhVal)
|
||||||
|
if (showTranslateBtn) {
|
||||||
|
return h(ElFormItem, { label, class: 'jfd-form-item' }, () =>
|
||||||
|
h('div', { class: 'jfd-field-with-translate' }, [
|
||||||
|
formItemNode,
|
||||||
|
h(ElButton, {
|
||||||
|
link: true, size: 'small', class: 'jfd-translate-icon',
|
||||||
|
loading: translatingPath.value === `${key}`,
|
||||||
|
onClick: () => handleTranslateField(p.parent, key, p.zhParent),
|
||||||
|
}, () => h(ElIcon, { color: '#409eff' }, () => h(Promotion)))
|
||||||
|
])
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return h(ElFormItem, { label, class: 'jfd-form-item' }, () => formItemNode)
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const keyStr = String(key)
|
const keyStr = String(key)
|
||||||
|
|
@ -192,18 +362,39 @@ const NodeEditor: Component = defineComponent({
|
||||||
onClick: () => value.push(''),
|
onClick: () => value.push(''),
|
||||||
}, () => [h(ElIcon, { size: 14 }, () => h(Plus)), ' 添加'])
|
}, () => [h(ElIcon, { size: 14 }, () => h(Plus)), ' 添加'])
|
||||||
]),
|
]),
|
||||||
...value.map((_: any, i: number) =>
|
...value.map((_: any, i: number) => {
|
||||||
h('div', { class: 'jfd-string-item', key: i }, [
|
const zhArr = p.zhParent?.[key]
|
||||||
|
const zhStr = Array.isArray(zhArr) && typeof zhArr[i] === 'string' ? zhArr[i] : undefined
|
||||||
|
const showBtn = p.lang === 'EN' && zhStr && hasChinese(zhStr)
|
||||||
|
|
||||||
|
|
||||||
|
return h('div', { class: 'jfd-string-item', key: i }, [
|
||||||
h(ElInput, {
|
h(ElInput, {
|
||||||
modelValue: value[i],
|
modelValue: value[i],
|
||||||
'onUpdate:modelValue': (v: string) => { value[i] = v },
|
'onUpdate:modelValue': (v: string) => { value[i] = v },
|
||||||
}),
|
}),
|
||||||
|
showBtn ? h(ElButton, {
|
||||||
|
link: true, size: 'small', class: 'jfd-translate-icon',
|
||||||
|
loading: translatingPath.value === `${key}-${i}`,
|
||||||
|
onClick: async () => {
|
||||||
|
translatingPath.value = `${key}-${i}`
|
||||||
|
try {
|
||||||
|
const res = await translateApi.translate({ text: zhStr!, from: 'ZH', to: 'EN' })
|
||||||
|
const translated = parseTranslateResult(res)
|
||||||
|
if (translated) value[i] = translated
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('翻译失败')
|
||||||
|
} finally {
|
||||||
|
translatingPath.value = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, () => h(ElIcon, { color: '#409eff' }, () => h(Promotion))) : null,
|
||||||
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)))
|
||||||
])
|
])
|
||||||
)
|
})
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,15 +413,17 @@ const NodeEditor: Component = defineComponent({
|
||||||
]),
|
]),
|
||||||
h(ElCollapse, { class: 'jfd-obj-array' }, () =>
|
h(ElCollapse, { class: 'jfd-obj-array' }, () =>
|
||||||
value.map((item: any, i: number) => {
|
value.map((item: any, i: number) => {
|
||||||
const tag = item.title || item.name || item.text || ''
|
const tag = item.title || item.name || item.text || item.label || item.year || ''
|
||||||
return h(ElCollapseItem, {
|
return h(ElCollapseItem, {
|
||||||
title: `${label} #${i + 1}${tag ? ' - ' + tag : ''}`,
|
title: `${label} #${i + 1}${tag ? ' - ' + tag : ''}`,
|
||||||
name: `${String(key)}-${i}`,
|
name: `${String(key)}-${i}`,
|
||||||
key: i,
|
key: i,
|
||||||
}, () => [
|
}, () => [
|
||||||
...Object.keys(item).map(k =>
|
...Object.keys(item).map(k => {
|
||||||
h(NodeEditor, { parent: item, fieldKey: k, fieldMap: fm, depth: depth + 1, key: k })
|
const zhArr = p.zhParent?.[key]
|
||||||
),
|
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('div', { class: 'jfd-item-actions' },
|
||||||
h(ElButton, {
|
h(ElButton, {
|
||||||
size: 'small', type: 'danger',
|
size: 'small', type: 'danger',
|
||||||
|
|
@ -244,8 +437,9 @@ const NodeEditor: Component = defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
|
const zhObj = p.zhParent?.[key]
|
||||||
const children = Object.keys(value).map(k =>
|
const children = Object.keys(value).map(k =>
|
||||||
h(NodeEditor, { parent: value, fieldKey: k, fieldMap: fm, depth: depth + 1, key: k })
|
h(NodeEditor, { parent: value, fieldKey: k, fieldMap: fm, depth: depth + 1, key: k, lang: p.lang, zhParent: zhObj })
|
||||||
)
|
)
|
||||||
|
|
||||||
if (depth === 0) {
|
if (depth === 0) {
|
||||||
|
|
@ -358,6 +552,23 @@ const NodeEditor: Component = defineComponent({
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jfd-field-with-translate {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.el-input,
|
||||||
|
.el-textarea {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jfd-translate-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.jfd-obj-array .el-collapse-item__header {
|
.jfd-obj-array .el-collapse-item__header {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,9 @@ const data: TableData = {
|
||||||
addNods: [
|
addNods: [
|
||||||
(self: any) => {
|
(self: any) => {
|
||||||
return self.bean && <JsonFormDialog
|
return self.bean && <JsonFormDialog
|
||||||
|
uploadFile={async (file: FormData) => {
|
||||||
|
return await self.api?.upload(file)
|
||||||
|
}}
|
||||||
show={self.bean.showJsonFormDialog}
|
show={self.bean.showJsonFormDialog}
|
||||||
title={self.bean?.currentRow?.name || '配置'}
|
title={self.bean?.currentRow?.name || '配置'}
|
||||||
data={self.bean.jsonFormData}
|
data={self.bean.jsonFormData}
|
||||||
|
|
@ -65,6 +68,7 @@ const data: TableData = {
|
||||||
await self.api?.updateData({
|
await self.api?.updateData({
|
||||||
id: String(self.bean.currentRow.id),
|
id: String(self.bean.currentRow.id),
|
||||||
content: JSON.stringify(data),
|
content: JSON.stringify(data),
|
||||||
|
pid: String(self.bean.currentRow.pid)
|
||||||
})
|
})
|
||||||
self.bean.showJsonFormDialog = false
|
self.bean.showJsonFormDialog = false
|
||||||
self.methods.fetchData()
|
self.methods.fetchData()
|
||||||
|
|
@ -134,7 +138,7 @@ const data: TableData = {
|
||||||
{
|
{
|
||||||
key: 'id',
|
key: 'id',
|
||||||
name: 'ID',
|
name: 'ID',
|
||||||
width: '80px',
|
width: '100px',
|
||||||
showJson: '*'
|
showJson: '*'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -159,8 +163,8 @@ const data: TableData = {
|
||||||
width: '200px',
|
width: '200px',
|
||||||
editor: {
|
editor: {
|
||||||
type: 'json',
|
type: 'json',
|
||||||
subFun(self, data) {
|
subFun(self, data, row) {
|
||||||
return self.api?.updateData({...data, id: String(data.id)})
|
return self.api?.updateData({...data, id: String(data.id), pid: String(row.pid)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -195,6 +199,7 @@ const data: TableData = {
|
||||||
key: 'weight',
|
key: 'weight',
|
||||||
name: '权重',
|
name: '权重',
|
||||||
width: '100px',
|
width: '100px',
|
||||||
|
sort: 'desc',
|
||||||
editor: {
|
editor: {
|
||||||
type: 'input',
|
type: 'input',
|
||||||
subFun(self, data, row) {
|
subFun(self, data, row) {
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,13 @@ const data: TableData = {
|
||||||
must: true,
|
must: true,
|
||||||
shouldTranslate: false,
|
shouldTranslate: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '封面',
|
||||||
|
key: 'cover',
|
||||||
|
type: 'upload:image',
|
||||||
|
must: false,
|
||||||
|
shouldTranslate: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: '文件名',
|
name: '文件名',
|
||||||
key: 'file_name',
|
key: 'file_name',
|
||||||
|
|
@ -136,6 +143,7 @@ const data: TableData = {
|
||||||
return self.bean ? self.bean.docTypes : []
|
return self.bean ? self.bean.docTypes : []
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: 'file_name',
|
key: 'file_name',
|
||||||
name: '文件名',
|
name: '文件名',
|
||||||
|
|
@ -162,6 +170,12 @@ const data: TableData = {
|
||||||
width: '80px',
|
width: '80px',
|
||||||
showJson: '*'
|
showJson: '*'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'cover',
|
||||||
|
name: '封面',
|
||||||
|
type: 'input',
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
name: '文件名',
|
name: '文件名',
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,11 @@ const data: TableData = {
|
||||||
self.bean.id = null;
|
self.bean.id = null;
|
||||||
}}
|
}}
|
||||||
onSubmit={async (data) => {
|
onSubmit={async (data) => {
|
||||||
await self.api?.addData(data)
|
if(data.id) {
|
||||||
|
await self.api?.updateData({...data, id: String(data.id), category_id: String(data.category_id)})
|
||||||
|
} else {
|
||||||
|
await self.api?.addData({...data, category_id: String(data.category_id)})
|
||||||
|
}
|
||||||
self.bean.showEditorDialog = false;
|
self.bean.showEditorDialog = false;
|
||||||
self.bean.id = null;
|
self.bean.id = null;
|
||||||
self.methods.fetchData()
|
self.methods.fetchData()
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const data: TableData = {
|
||||||
return (
|
return (
|
||||||
self.bean &&
|
self.bean &&
|
||||||
<In18FormDialog
|
<In18FormDialog
|
||||||
|
fullscreen={true}
|
||||||
show={self.bean.showEditorDialog}
|
show={self.bean.showEditorDialog}
|
||||||
id={self.bean.id}
|
id={self.bean.id}
|
||||||
form={self.bean.form}
|
form={self.bean.form}
|
||||||
|
|
@ -44,7 +45,10 @@ const data: TableData = {
|
||||||
Object.keys(langData).forEach((fieldKey) => {
|
Object.keys(langData).forEach((fieldKey) => {
|
||||||
const field = self.bean.form.find((f:any) => f.key === fieldKey);
|
const field = self.bean.form.find((f:any) => f.key === fieldKey);
|
||||||
if (fieldKey === 'covers') {
|
if (fieldKey === 'covers') {
|
||||||
data[langKey]['cover_resource'] = langData[fieldKey];
|
data[langKey]['cover_resource'] = {
|
||||||
|
image: langData[fieldKey].image.map((item: any) => ({url: item})).filter(Boolean),
|
||||||
|
video: langData[fieldKey].video.map((item: any) => ({url: item})).filter(Boolean),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!field) return;
|
if (!field) return;
|
||||||
data[langKey][fieldKey] = langData[fieldKey];
|
data[langKey][fieldKey] = langData[fieldKey];
|
||||||
|
|
@ -107,7 +111,7 @@ const data: TableData = {
|
||||||
{
|
{
|
||||||
name: '内容',
|
name: '内容',
|
||||||
key: 'content',
|
key: 'content',
|
||||||
type: 'textarea',
|
type: 'richtext',
|
||||||
value: '',
|
value: '',
|
||||||
must: true,
|
must: true,
|
||||||
shouldTranslate: true,
|
shouldTranslate: true,
|
||||||
|
|
@ -234,7 +238,8 @@ const data: TableData = {
|
||||||
{
|
{
|
||||||
key: 'create_time',
|
key: 'create_time',
|
||||||
name: '发布时间',
|
name: '发布时间',
|
||||||
width: '150px'
|
width: '150px',
|
||||||
|
sort: 'desc'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'count',
|
key: 'count',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue