630 lines
25 KiB
Vue
630 lines
25 KiB
Vue
<!--
|
||
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>
|
||
<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 { translateRichText } from 'src/tools'
|
||
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 hasHtmlTag(str: string): boolean {
|
||
return /<\/[a-zA-Z][\w:-]*\s*>/i.test(str)
|
||
}
|
||
|
||
async function translateTexts(texts: string[]): Promise<string[]> {
|
||
const results = await Promise.allSettled(
|
||
texts.map(text => translateApi.translate({ text, from: 'ZH', to: 'EN' }))
|
||
)
|
||
return results.map(r => r.status === 'fulfilled' ? parseTranslateResult(r.value) : '')
|
||
}
|
||
|
||
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()
|
||
if (hasHtmlTag(text)) {
|
||
const translated = await translateRichText(text, translateTexts)
|
||
return { parent, key, translated }
|
||
}
|
||
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 {
|
||
let translated: string
|
||
if (hasHtmlTag(zhText)) {
|
||
translated = await translateRichText(zhText, translateTexts)
|
||
} else {
|
||
const res = await translateApi.translate({ text: zhText, from: 'ZH', to: 'EN' })
|
||
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>
|