yintai-company-home-am/components/JsonFormDialog.vue

609 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!--
JsonFormDialog - JSON 多语言配置编辑弹窗
功能
- JSON 对象按语言 key ZHEN拆分为 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>
<el-dialog :model-value="show" width="900" :fullscreen="isFullscreen" :close-on-click-modal="false" @close="close"
:show-close="false">
<template #header>
<div class="jfd-header">
<span class="jfd-title">{{ title }}</span>
<div class="jfd-header-actions">
<el-button link circle @click="isFullscreen = !isFullscreen"
:title="isFullscreen ? '退出全屏' : '全屏'">
<el-icon>
<CopyDocument v-if="isFullscreen" />
<FullScreen v-else />
</el-icon>
</el-button>
<el-button link circle @click="close" title="关闭">
<el-icon size="18">
<Close />
</el-icon>
</el-button>
</div>
</div>
</template>
<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-scrollbar max-height="60vh">
<div class="jfd-content">
<NodeEditor v-for="sKey in getSectionKeys(lang)" :key="sKey" :parent="formData[lang]"
:field-key="sKey" :field-map="fieldMap" :depth="0" :lang="lang"
:zh-parent="lang === 'EN' ? formData['ZH'] : undefined" />
</div>
</el-scrollbar>
</el-tab-pane>
</el-tabs>
<el-empty v-else description="无数据" />
<template #footer>
<el-button v-if="activeTab === 'EN'" type="warning" :loading="translating"
@click="handleTranslateAll">一键翻译</el-button>
<el-button @click="close">取消</el-button>
<el-button type="primary" @click="submit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, defineComponent, h, type PropType, type VNode, type Component } from 'vue'
import { Close, FullScreen, CopyDocument, Plus, Delete, Promotion } from '@element-plus/icons-vue'
import {
ElFormItem, ElInput, ElInputNumber, ElSwitch, ElButton,
ElCollapse, ElCollapseItem, ElIcon, ElMessage
} from 'element-plus'
import { translateApi } from 'src/api/common'
import UploadInput from './UploadInput.vue'
import WangEditor from 'lib/components/WangEditor.vue'
const props = withDefaults(defineProps<{
show: boolean
title?: string
data: Record<string, any>
fieldMap?: Record<string, string>
uploadFile: (file: FormData) => Promise<any>
}>(), {
show: false,
title: '配置编辑',
data: () => ({}),
fieldMap: () => ({}),
uploadFile: () => Promise.resolve({}),
})
const emit = defineEmits(['close', 'submit'])
const isFullscreen = ref(false)
const formData = ref<Record<string, any>>({})
const activeTab = ref('')
const translating = ref(false)
const translatingPath = ref('')
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) {
const langData = formData.value[lang]
return langData && typeof langData === 'object' ? Object.keys(langData) : []
}
const itemTemplates: Record<string, any> = {}
function collectItemTemplates(data: any) {
if (data === null || data === undefined) return
if (Array.isArray(data)) {
if (data.length > 0 && typeof data[0] === 'object' && !Array.isArray(data[0])) {
for (const key of Object.keys(data[0])) {
collectItemTemplates(data[0][key])
}
}
data.forEach(item => collectItemTemplates(item))
} else if (typeof data === 'object') {
for (const key of Object.keys(data)) {
const val = data[key]
if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'object' && !Array.isArray(val[0])) {
if (!itemTemplates[key]) {
itemTemplates[key] = createEmpty(val[0])
}
}
collectItemTemplates(val)
}
}
}
watch(() => props.show, (val) => {
if (val && props.data) {
formData.value = JSON.parse(JSON.stringify(props.data))
Object.keys(itemTemplates).forEach(k => delete itemTemplates[k])
const firstLang = langKeys.value[0]
if (firstLang) collectItemTemplates(formData.value[firstLang])
activeTab.value = firstLang || ''
}
}, { immediate: true })
function close() {
isFullscreen.value = false
emit('close')
}
function submit() {
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 {
if (typeof template === 'string') return ''
if (typeof template === 'number') return 0
if (typeof template === 'boolean') return false
if (Array.isArray(template)) return []
if (typeof template === 'object' && template !== null) {
const obj: any = {}
for (const k of Object.keys(template)) obj[k] = createEmpty(template[k])
return obj
}
return ''
}
const NodeEditor: Component = defineComponent({
name: 'NodeEditor',
props: {
parent: { type: Object as PropType<any>, required: true },
fieldKey: { type: [String, Number] as PropType<string | number>, required: true },
fieldMap: { type: Object as PropType<Record<string, string>>, default: () => ({}) },
depth: { type: Number, default: 0 },
lang: { type: String, default: '' },
zhParent: { type: Object as PropType<any>, default: undefined },
},
setup(p) {
return () => {
const value = p.parent[p.fieldKey]
const key = p.fieldKey
const fm = p.fieldMap
const depth = p.depth
const label = typeof key === 'string' ? (fm[key] || key) : `#${Number(key) + 1}`
if (value === null || value === undefined) return null
if (typeof value === 'boolean') {
return h(ElFormItem, { label, class: 'jfd-form-item' }, () =>
h(ElSwitch, {
modelValue: value,
'onUpdate:modelValue': (v: any) => { p.parent[key] = v },
})
)
}
if (typeof value === 'number') {
return h(ElFormItem, { label, class: 'jfd-form-item' }, () =>
h(ElInputNumber, {
modelValue: value,
'onUpdate:modelValue': (v: number | undefined) => { p.parent[key] = v ?? 0 },
controlsPosition: 'right',
})
)
}
if (typeof value === 'string') {
// 含任意 HTML 闭合标签(如 </p>、</div>)时用富文本编辑器
const hasHtmlClosingTag = /<\/[a-zA-Z][\w:-]*\s*>/i.test(value)
// const isLong = value.length > 50
// 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 ((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) {
p.parent[key] = v[0].url || ''
}
},
})
}
const inputNode = h(ElInput, {
modelValue: value,
...({ type: 'textarea', autosize: { minRows: 1, maxRows: 6 } }),
'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 || (hasHtmlClosingTag ? wangEditor : 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)) {
const keyStr = String(key)
const hasLiveObj = value.length > 0 && typeof value[0] === 'object' && !Array.isArray(value[0])
const savedTemplate = itemTemplates[keyStr]
const isObjArr = hasLiveObj || !!savedTemplate
if (!isObjArr) {
return h('div', { class: 'jfd-array-group' }, [
h('div', { class: 'jfd-group-header' }, [
h('span', { class: 'jfd-group-title' }, label),
h(ElButton, {
size: 'small', type: 'primary', link: true,
onClick: () => value.push(''),
}, () => [h(ElIcon, { size: 14 }, () => h(Plus)), ' 添加'])
]),
...value.map((_: any, i: number) => {
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)
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 }, [
uploadNode || h(ElInput, {
modelValue: value[i],
'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, {
size: 'small', type: 'danger', link: true,
onClick: () => value.splice(i, 1),
}, () => h(ElIcon, { size: 14 }, () => h(Delete))),
])
})
])
}
const makeNewItem = () => {
const tpl = hasLiveObj ? value[0] : savedTemplate
return JSON.parse(JSON.stringify(tpl ? createEmpty(tpl) : {}))
}
return h('div', { class: 'jfd-array-group' }, [
h('div', { class: 'jfd-group-header' }, [
h('span', { class: 'jfd-group-title' }, label),
h(ElButton, {
size: 'small', type: 'primary', link: true,
onClick: () => value.push(makeNewItem()),
}, () => [h(ElIcon, { size: 14 }, () => h(Plus)), ' 添加'])
]),
h(ElCollapse, { class: 'jfd-obj-array' }, () =>
value.map((item: any, i: number) => {
const tag = item.title || item.name || item.text || item.label || item.year || ''
return h(ElCollapseItem, {
title: `${label} #${i + 1}${tag ? ' - ' + tag : ''}`,
name: `${String(key)}-${i}`,
key: i,
}, () => [
...Object.keys(item).map(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(ElButton, {
// size: 'small', type: 'danger',
// onClick: () => value.splice(i, 1),
// }, () => [h(ElIcon, { size: 14 }, () => h(Delete)), ' 删除此项'])
// )
])
})
)
])
}
if (typeof value === 'object') {
const zhObj = p.zhParent?.[key]
const children = Object.keys(value).map(k =>
h(NodeEditor, { parent: value, fieldKey: k, fieldMap: fm, depth: depth + 1, key: k, lang: p.lang, zhParent: zhObj })
)
if (depth === 0) {
return h('div', { class: 'jfd-section' }, [
h('div', { class: 'jfd-section-header' }, label),
h('div', { class: 'jfd-section-body' }, children)
])
}
return h('div', { class: 'jfd-nested' }, [
h('div', { class: 'jfd-nested-title' }, label),
...children
])
}
return null
}
}
})
</script>
<style lang="scss">
.jfd-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.jfd-title {
font-size: 18px;
font-weight: 500;
}
.jfd-header-actions {
display: flex;
gap: 4px;
}
}
.jfd-content {
padding: 12px;
}
.jfd-form-item {
margin-bottom: 16px;
}
.jfd-section {
margin-bottom: 16px;
border: 1px solid var(--el-border-color);
border-radius: 6px;
overflow: hidden;
}
.jfd-section-header {
padding: 10px 16px;
font-weight: 600;
font-size: 15px;
background-color: var(--el-fill-color-light);
border-bottom: 1px solid var(--el-border-color);
}
.jfd-section-body {
padding: 16px;
}
.jfd-array-group {
margin-bottom: 12px;
}
.jfd-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.jfd-group-title {
font-weight: 500;
font-size: 14px;
color: var(--el-text-color-primary);
}
.jfd-string-item {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.jfd-item-actions {
text-align: right;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--el-border-color-lighter);
}
.jfd-nested {
margin-left: 12px;
padding-left: 12px;
border-left: 2px solid var(--el-border-color);
margin-bottom: 12px;
}
.jfd-nested-title {
font-weight: 500;
font-size: 13px;
color: var(--el-text-color-regular);
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 {
font-size: 13px;
}
</style>