365 lines
12 KiB
Vue
365 lines
12 KiB
Vue
<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">
|
|
<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" />
|
|
</div>
|
|
</el-scrollbar>
|
|
</el-tab-pane>
|
|
</el-tabs>
|
|
<el-empty v-else description="无数据" />
|
|
|
|
<template #footer>
|
|
<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 } from '@element-plus/icons-vue'
|
|
import {
|
|
ElFormItem, ElInput, ElInputNumber, ElSwitch, ElButton,
|
|
ElCollapse, ElCollapseItem, ElIcon
|
|
} from 'element-plus'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
show: boolean
|
|
title?: string
|
|
data: Record<string, any>
|
|
fieldMap?: Record<string, string>
|
|
}>(), {
|
|
show: false,
|
|
title: '配置编辑',
|
|
data: () => ({}),
|
|
fieldMap: () => ({}),
|
|
})
|
|
|
|
const emit = defineEmits(['close', 'submit'])
|
|
|
|
const isFullscreen = ref(false)
|
|
const formData = ref<Record<string, any>>({})
|
|
const activeTab = ref('')
|
|
|
|
const langKeys = computed(() => Object.keys(formData.value))
|
|
|
|
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)))
|
|
}
|
|
|
|
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 },
|
|
},
|
|
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') {
|
|
const isLong = value.length > 50
|
|
return h(ElFormItem, { label, class: 'jfd-form-item' }, () =>
|
|
h(ElInput, {
|
|
modelValue: value,
|
|
...(isLong ? { type: 'textarea', autosize: { minRows: 2, maxRows: 6 } } : {}),
|
|
'onUpdate:modelValue': (v: string) => { p.parent[key] = v },
|
|
})
|
|
)
|
|
}
|
|
|
|
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) =>
|
|
h('div', { class: 'jfd-string-item', key: i }, [
|
|
h(ElInput, {
|
|
modelValue: value[i],
|
|
'onUpdate:modelValue': (v: string) => { value[i] = v },
|
|
}),
|
|
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 || ''
|
|
return h(ElCollapseItem, {
|
|
title: `${label} #${i + 1}${tag ? ' - ' + tag : ''}`,
|
|
name: `${String(key)}-${i}`,
|
|
key: i,
|
|
}, () => [
|
|
...Object.keys(item).map(k =>
|
|
h(NodeEditor, { parent: item, fieldKey: k, fieldMap: fm, depth: depth + 1, key: k })
|
|
),
|
|
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 children = Object.keys(value).map(k =>
|
|
h(NodeEditor, { parent: value, fieldKey: k, fieldMap: fm, depth: depth + 1, key: k })
|
|
)
|
|
|
|
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-obj-array .el-collapse-item__header {
|
|
font-size: 13px;
|
|
}
|
|
</style>
|