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

545 lines
20 KiB
Vue

<template>
<el-dialog :model-value="show" width="800" :fullscreen="isFullscreen" :close-on-click-modal="false" @close="close"
:show-close="false">
<template #header>
<div class="dialog-header">
<span class="dialog-title">{{ title }}</span>
<div class="dialog-header-actions">
<el-button link circle @click="toggleFullscreen" :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-form v-if="show" ref="formRef" :model="formData" :rules="formRules" label-position="left"
v-loading="detailLoading">
<div v-if="normalFields.length" class="in18-form-top">
<el-form-item v-for="item in normalFields" :key="item.key" :prop="item.key">
<template #label>
{{ item.name }}
</template>
<el-input v-if="item.type === 'input'" v-model="formData[item.key]"
:placeholder="`请输入${item.name}`" />
<el-input v-else-if="item.type === 'textarea'" v-model="formData[item.key]" type="textarea"
:rows="4" :placeholder="`请输入${item.name}`" />
<el-select v-else-if="item.type === 'select'" v-model="formData[item.key]"
:placeholder="`请选择${item.name}`" clearable style="width: 100%">
<el-option v-for="opt in getSelectItems(item)" :key="opt.key" :label="opt.name"
:value="opt.key" />
</el-select>
<div v-else-if="isUploadMediaType(item.type)" style="width:100%;">
<div class="upload-group">
<div class="upload-label">图片</div>
<UploadInput :model-value="(formData[item.key]?.image || []).join(',')"
:type="item.type.includes('images') ? 'images' : 'image'"
:uploadFile="uploadFun" @update:modelValue="(val: string) => {
if (!formData[item.key]) formData[item.key] = { image: [], video: [] };
formData[item.key].image = val ? val.split(',').filter(Boolean) : [];
}" />
</div>
<div class="upload-group">
<div class="upload-label">视频</div>
<UploadInput :model-value="(formData[item.key]?.video || []).join(',')" type="video"
:uploadFile="uploadFun" @update:modelValue="(val: string) => {
if (!formData[item.key]) formData[item.key] = { image: [], video: [] };
formData[item.key].video = val ? val.split(',').filter(Boolean) : [];
}" />
</div>
</div>
<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 || '*'"
:uploadFile="uploadFun"
@update:modelValue="(val: string) => { formData[item.key] = val }" />
</div>
</el-form-item>
</div>
<div class="in18-form-header">
<div class="in18-form-header-left">
<span>{{ primaryLocale?.name }}</span>
</div>
<div class="in18-form-header-right">
<span v-for="locale in secondaryLocales" :key="locale.key" class="locale-tab"
:class="{ active: activeSecondaryLocale?.key === locale.key }"
@click="activeSecondaryLocale = locale">
{{ locale.name }}
</span>
</div>
</div>
<div v-if="translateFields.length && secondaryLocales.length" class="in18-form-wrapper">
<div class="in18-form-left">
<el-form-item v-for="item in translateFields" :key="item.key"
:prop="`translations.${primaryLocaleKey}.${item.key}`">
<template #label>
{{ item.name }}
</template>
<el-input v-if="item.type === 'input'"
v-model="formData.translations[primaryLocaleKey][item.key]"
:placeholder="`请输入${item.name}`" />
<el-input v-else-if="item.type === 'textarea'"
v-model="formData.translations[primaryLocaleKey][item.key]" type="textarea"
:rows="4" :placeholder="`请输入${item.name}`" />
<el-select v-else-if="item.type === 'select'"
v-model="formData.translations[primaryLocaleKey][item.key]"
:placeholder="`请选择${item.name}`" clearable style="width: 100%">
<el-option v-for="opt in getSelectItems(item)" :key="opt.key" :label="opt.name"
:value="opt.key" />
</el-select>
</el-form-item>
</div>
<div class="in18-form-center">
<el-button v-if="activeSecondaryLocale" type="primary" :loading="translating"
@click="handleTranslate">
翻译
</el-button>
</div>
<div v-if="activeSecondaryLocale" class="in18-form-right">
<el-form-item v-for="item in translateFields" :key="item.key">
<template #label>
{{ item.name }}
</template>
<div v-if="item.type === 'input' || item.type === 'textarea'" class="field-with-translate">
<el-input
v-model="formData.translations[activeSecondaryLocale?.key || ''][item.key]"
:type="item.type === 'textarea' ? 'textarea' : undefined"
:rows="item.type === 'textarea' ? 4 : undefined"
: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>
<el-select v-else-if="item.type === 'select'"
v-model="formData.translations[activeSecondaryLocale?.key || ''][item.key]"
:placeholder="`请选择${item.name}`" clearable style="width: 100%">
<el-option v-for="opt in getSelectItems(item)" :key="opt.key" :label="opt.name"
:value="opt.key" />
</el-select>
</el-form-item>
</div>
</div>
</el-form>
<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 { reactive, computed, watch, ref, nextTick } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
import { Close, Promotion, FullScreen, CopyDocument } from '@element-plus/icons-vue'
import UploadInput from "lib/components/UploadInput.vue";
type FormItemType =
| 'input'
| 'select'
| 'textarea'
| 'upload:images,video'
| 'upload:image,video'
| 'upload:file'
type FormItem = {
name: string
key: string
type: FormItemType
value: string | number | { image: string[]; video: string[] }
items?: { key: string; name: string }[]
getItems?: () => { key: string; name: string }[]
must?: boolean
shouldTranslate?: boolean
accept?: string
[prop: string]: any
}
type LocaleItem = {
name: string
key: string
isPrimary?: boolean
}
const props = withDefaults(
defineProps<{
show: boolean
id: string | number
title?: string
form: FormItem[]
locales: LocaleItem[]
translateApi: (data: { text: string; from?: string; to?: string }) => Promise<any>
detailApi: (id: number) => Promise<any>
uploadFun?: (formData: FormData) => Promise<any>
}>(),
{
show: false,
id: '',
title: '详情',
form: () => [],
locales: () => [],
translateApi: () => Promise.reject({ code: 500, message: 'translateApi not implemented' }),
detailApi: () => Promise.reject({ code: 500, message: 'detailApi not implemented' }),
uploadFun: () => Promise.reject({ code: 500, message: 'uploadFun not implemented' }),
}
)
const emit = defineEmits(['close', 'submit'])
const primaryLocale = computed(() => props.locales.find((l) => l.isPrimary) || props.locales[0])
const primaryLocaleKey = computed(() => primaryLocale.value?.key || 'ZH')
const secondaryLocales = computed(() => props.locales.filter((l) => !l.isPrimary))
const activeSecondaryLocale = ref<LocaleItem | null>(null)
const normalFields = computed(() => props.form.filter((f) => !f.shouldTranslate))
const translateFields = computed(() => props.form.filter((f) => f.shouldTranslate))
function isUploadMediaType(type: string): boolean {
return type === 'upload:images,video' || type === 'upload:image,video'
}
function getSelectItems(item: FormItem): { key: string; name: string }[] {
return item.items ?? item.getItems?.() ?? []
}
function parseTranslateResult(res: any): string {
return res?.data?.text ?? res?.data ?? res?.result ?? (typeof res === 'string' ? res : '')
}
const formRef = ref<FormInstance>()
const formRules = computed<FormRules>(() => {
const rules: FormRules = {}
normalFields.value.forEach((item) => {
if (item.must) {
rules[item.key] = [{ required: true, message: `请输入${item.name}`, trigger: ['blur', 'change'] }]
}
})
translateFields.value.forEach((item) => {
if (item.must) {
const prop = `translations.${primaryLocaleKey.value}.${item.key}`
rules[prop] = [{ required: true, message: `请输入${item.name}`, trigger: ['blur', 'change'] }]
}
})
return rules
})
const formData = reactive<{
[key: string]: any
translations: Record<string, Record<string, any>>
}>({
translations: {}
})
const translating = ref(false)
const translatingFieldKey = ref<string>('')
const isFullscreen = ref(false)
const detailLoading = ref(false)
function toggleFullscreen() {
isFullscreen.value = !isFullscreen.value
}
function resetFormData() {
Object.keys(formData).forEach((k) => {
if (k !== 'translations') delete formData[k]
})
formData.translations = {}
}
function initFormData() {
const normal: Record<string, any> = {}
const translations: Record<string, Record<string, any>> = {}
props.form.forEach((item) => {
if (item.shouldTranslate) {
props.locales.forEach((locale) => {
if (!translations[locale.key]) translations[locale.key] = {}
translations[locale.key][item.key] = item.value ?? ''
})
} else if (isUploadMediaType(item.type)) {
const val: any = item.value
normal[item.key] = (val && typeof val === 'object' && Array.isArray(val.image) && Array.isArray(val.video))
? val
: { image: [], video: [] }
} else {
normal[item.key] = item.value ?? ''
}
})
Object.keys(formData).forEach((k) => {
if (k !== 'translations') delete formData[k]
})
Object.keys(normal).forEach((k) => {
formData[k] = normal[k]
})
formData.translations = translations
if (secondaryLocales.value.length > 0 && !activeSecondaryLocale.value) {
activeSecondaryLocale.value = secondaryLocales.value[0]
} else if (secondaryLocales.value.length > 0) {
const stillExists = secondaryLocales.value.some((l) => l.key === activeSecondaryLocale.value?.key)
if (!stillExists) {
activeSecondaryLocale.value = secondaryLocales.value[0]
}
} else {
activeSecondaryLocale.value = null
}
nextTick(() => formRef.value?.clearValidate())
}
watch(
() => [props.show, props.form, props.locales],
async () => {
if (props.show) {
initFormData()
if (props.id) {
detailLoading.value = true
try {
const data = await props.detailApi(Number(props.id))
if (data) {
const pKey = primaryLocaleKey.value
Object.keys(data).forEach((localeKey) => {
const localeData = data[localeKey]
if (!localeData || typeof localeData !== 'object' || !props.locales.some((l) => l.key === localeKey)) return
Object.keys(localeData).forEach((fieldKey) => {
const field = props.form.find((f) => f.key === fieldKey)
if (!field) return
if (field.shouldTranslate) {
if (!formData.translations[localeKey]) formData.translations[localeKey] = {}
formData.translations[localeKey][fieldKey] = localeData[fieldKey]
} else if (localeKey === pKey) {
formData[fieldKey] = localeData[fieldKey]
}
})
})
}
} catch (e) {
console.error('Failed to load detail:', e)
ElMessage.error('加载详情失败')
} finally {
detailLoading.value = false
}
}
}
},
{ immediate: true, deep: true }
)
const close = () => {
isFullscreen.value = false
resetFormData()
emit('close')
}
async function handleTranslate() {
if (!primaryLocale.value || !activeSecondaryLocale.value || translating.value) return
const fromKey = primaryLocale.value.key
const toKey = activeSecondaryLocale.value.key
const texts: { key: string; text: string }[] = []
translateFields.value.forEach((item) => {
const val = formData.translations?.[fromKey]?.[item.key]
if (val != null && String(val).trim()) {
texts.push({ key: item.key, text: String(val).trim() })
}
})
if (texts.length === 0) return
translating.value = true
try {
const results = await Promise.allSettled(
texts.map(async ({ key, text }) => {
const res = await props.translateApi({ text, from: fromKey, to: toKey })
return { key, translated: parseTranslateResult(res) }
})
)
let failCount = 0
for (const result of results) {
if (result.status === 'fulfilled') {
const { key, translated } = result.value
if (formData.translations[toKey]) {
formData.translations[toKey][key] = translated
}
} else {
failCount++
}
}
if (failCount > 0) {
ElMessage.warning(`${failCount} 个字段翻译失败`)
}
} catch (e) {
console.error('Translate failed:', e)
ElMessage.error('翻译失败')
} finally {
translating.value = false
}
}
async function handleTranslateField(itemKey: string) {
if (!primaryLocale.value || !activeSecondaryLocale.value || translatingFieldKey.value) return
const fromKey = primaryLocale.value.key
const toKey = activeSecondaryLocale.value.key
const text = formData.translations?.[fromKey]?.[itemKey]
if (text == null || !String(text).trim()) return
translatingFieldKey.value = itemKey
try {
const res = await props.translateApi({ text: String(text).trim(), from: fromKey, to: toKey })
if (formData.translations[toKey]) formData.translations[toKey][itemKey] = parseTranslateResult(res)
} catch (e) {
console.error('Translate failed:', e)
ElMessage.error('翻译失败')
} finally {
translatingFieldKey.value = ''
}
}
async function submit() {
try {
await formRef.value?.validate()
} catch {
return
}
const data: Record<string, any> = { id: props.id || '' }
normalFields.value.forEach((item) => {
data[item.key] = formData[item.key]
})
if (Object.keys(formData.translations || {}).length > 0) {
data.translations = formData.translations
}
emit('submit', data)
}
</script>
<style scoped lang="scss">
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 40px;
.dialog-title {
font-size: 18px;
font-weight: 500;
}
.dialog-header-actions {
display: flex;
gap: 4px;
}
}
.in18-form-header {
display: flex;
border-bottom: 1px solid var(--el-border-color);
.in18-form-header-left,
.in18-form-header-right {
flex: 1;
padding: 15px;
font-weight: 500;
}
.in18-form-header-right {
display: flex;
gap: 8px;
align-items: center;
.locale-tab {
padding: 5px 12px;
border: 1px solid var(--el-border-color);
border-radius: 5px;
cursor: pointer;
font-size: 14px;
&.active {
background-color: var(--el-color-primary);
color: #fff;
border-color: var(--el-color-primary);
}
&:hover:not(.active) {
background-color: var(--el-fill-color-light);
}
}
}
}
.in18-form-top {
padding: 15px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
:deep(.el-form-item) {
width: 100%;
max-width: 400px;
margin-bottom: 0;
}
}
.in18-form-wrapper {
display: flex;
flex-direction: row;
min-height: 200px;
border-bottom: 1px solid var(--el-border-color);
.in18-form-left,
.in18-form-right {
flex: 1;
padding: 15px;
overflow-y: auto;
:deep(.el-form-item) {
margin-bottom: 16px;
}
}
.in18-form-left {
border-right: 1px solid var(--el-border-color);
}
.field-with-translate {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
.el-input,
.el-textarea {
flex: 1;
}
.translate-icon {
flex-shrink: 0;
padding: 8px 0;
}
}
.in18-form-center {
display: flex;
align-items: center;
justify-content: center;
padding: 0 15px;
min-width: 80px;
border-right: 1px solid var(--el-border-color);
}
}
</style>