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

675 lines
27 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.

<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 :key="forceUpdateKey" v-if="show" ref="formRef" :model="formData" :rules="formRules"
label-position="left" v-loading="detailLoading" style="border-top: 1px solid var(--el-border-color);">
<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" v-if="item.type.includes('image')">
<div class="upload-label">图片</div>
<UploadInput :value="formData[item.key].image"
:type="item.type.includes('images') ? 'images' : 'image'" :uploadFile="uploadFun"
hideUploadButton :onUpdateValue="(val: any) => {
if (!formData[item.key]) formData[item.key] = { image: [], video: [] };
formData[item.key].image = val.map((item: any) => item.url).filter(Boolean);
}" />
</div>
<div class="upload-group" v-if="item.type.includes('video')">
<div class="upload-label">视频</div>
<UploadInput :value="formData[item.key]?.video.map((item: any) => ({...item, name: item.url}))" type="video" :uploadFile="uploadFun"
:onUpdateValue="(val: any) => {
if (!formData[item.key]) formData[item.key] = { image: [], video: [] };
formData[item.key].video = val.map((item: any) => item.url).filter(Boolean);
}" />
</div>
</div>
<div v-else-if="item.type === 'upload:file'" style="width:100%;" class="upload-file">
<UploadInput :value="formData[item.key] && [{ url: formData[item.key], name: formData[item.key] }]" type="file" :accept="item.accept || '*'"
:uploadFile="uploadFun" hideUploadButton
:onUpdateValue="(val: any) => { formData[item.key] = val[0]?.url || '' }" />
</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}`" />
<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'"
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>
<div v-else-if="item.type === 'upload:file'" style="width:100%;" class="upload-file">
<UploadInput :value="formData.translations[primaryLocaleKey][item.key]" type="file"
:accept="item.accept || '*'" :uploadFile="uploadFun" hideUploadButton
:onUpdateValue="(val: any) => { formData.translations[primaryLocaleKey][item.key] = val[0]?.url || '' }" />
</div>
</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>
<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"
: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>
<div v-else-if="item.type === 'upload:file'" style="width:100%;" class="upload-file">
<UploadInput :value="formData.translations[activeSecondaryLocale?.key || ''][item.key]" type="file"
:accept="item.accept || '*'" :uploadFile="uploadFun" hideUploadButton
:onUpdateValue="(val: any) => { formData.translations[activeSecondaryLocale?.key || ''][item.key] = val[0]?.url || '' }" />
</div>
</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 "./UploadInput.vue";
import WangEditor from "lib/components/WangEditor.vue";
type FormItemType =
| 'input'
| 'select'
| 'textarea'
| 'richtext'
| '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<{
fullscreen?: boolean
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>
}>(),
{
fullscreen: false,
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' || type === 'upload:image' || type === 'upload: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 : '')
}
function extractMediaFromHtml(html: string): { textWithPlaceholders: string; mediaParts: string[] } {
if (!html || typeof html !== 'string') {
return { textWithPlaceholders: html || '', mediaParts: [] }
}
if (!html.includes('<')) {
return { textWithPlaceholders: html, mediaParts: [] }
}
const div = document.createElement('div')
div.innerHTML = html
const mediaParts: string[] = []
const mediaElements = Array.from(div.querySelectorAll('img, video, iframe, audio'))
mediaElements.forEach((el, index) => {
mediaParts.push((el as HTMLElement).outerHTML)
// 使用类 HTML 占位符,翻译 API 通常会原样保留
const placeholder = document.createTextNode(`<x-media id="${index}"/>`)
el.parentNode?.replaceChild(placeholder, el)
})
return {
textWithPlaceholders: div.innerHTML,
mediaParts
}
}
// 方案二:占位符不发给翻译 API。用于 split 时保留分隔符
const PLACEHOLDER_SPLIT_REGEX = /(&lt;x-media\s+id\s*=\s*["']?\d+["']?\s*\/&gt;)/gi
function reassembleHtmlWithMedia(translatedText: string, mediaParts: string[]): string {
if (!mediaParts.length) return translatedText
let result = translatedText
mediaParts.forEach((media, index) => {
// 只匹配我们产生的占位符(未经过 API格式确定
const placeholderRegex = new RegExp(
`&lt;x-media\\s+id\\s*=\\s*["']?${index}["']?\\s*\\/&gt;`,
'gi'
)
result = result.replace(placeholderRegex, media)
})
return result
}
/** 方案二:按占位符拆分,只翻译文本段落,占位符不发给 API */
async function translateRichtextBySegments(
textWithPlaceholders: string,
mediaParts: string[],
fromKey: string,
toKey: string
): Promise<string> {
const segments = textWithPlaceholders.split(PLACEHOLDER_SPLIT_REGEX)
const placeholderTest = /^&lt;x-media\s+id\s*=\s*["']?\d+["']?\s*\/&gt;$/i
const translatedSegments: string[] = []
for (const seg of segments) {
if (seg === '' || placeholderTest.test(seg)) {
translatedSegments.push(seg)
} else {
const res = await props.translateApi({ text: seg, from: fromKey, to: toKey })
translatedSegments.push(parseTranslateResult(res) || seg)
}
}
const concatenated = translatedSegments.join('')
return reassembleHtmlWithMedia(concatenated, mediaParts)
}
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)
const forceUpdateKey = ref(0)
function forceUpdate() {
forceUpdateKey.value++
}
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) {
isFullscreen.value = props.fullscreen
initFormData()
if (props.id) {
detailLoading.value = true
try {
const data = await props.detailApi(Number(props.id))
console.log('data', data)
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;
// 强制刷新
forceUpdate()
}
}
}
},
{ 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; type: FormItemType; mediaParts?: string[] }[] = []
translateFields.value.forEach((item) => {
const val = formData.translations?.[fromKey]?.[item.key]
if (val != null && String(val).trim() && !val.startsWith('http')) {
if (item.type === 'richtext') {
const { textWithPlaceholders, mediaParts } = extractMediaFromHtml(String(val).trim())
texts.push({ key: item.key, text: textWithPlaceholders, type: item.type, mediaParts })
} else {
texts.push({ key: item.key, text: String(val).trim(), type: item.type })
}
}
})
if (texts.length === 0) return
translating.value = true
try {
const results = await Promise.allSettled(
texts.map(async ({ key, text, type, mediaParts }) => {
let translated: string
if (type === 'richtext' && mediaParts && mediaParts.length > 0) {
translated = await translateRichtextBySegments(text, mediaParts, fromKey, toKey)
} else {
const res = await props.translateApi({ text, from: fromKey, to: toKey })
translated = parseTranslateResult(res)
}
return { key, translated }
})
)
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
const item = translateFields.value.find((f) => f.key === itemKey)
const isRichtext = item?.type === 'richtext'
let textToTranslate = String(text).trim()
let mediaParts: string[] = []
if (isRichtext) {
const extracted = extractMediaFromHtml(textToTranslate)
textToTranslate = extracted.textWithPlaceholders
mediaParts = extracted.mediaParts
}
translatingFieldKey.value = itemKey
try {
let translated: string
if (isRichtext && mediaParts.length > 0) {
translated = await translateRichtextBySegments(textToTranslate, mediaParts, fromKey, toKey)
} else {
const res = await props.translateApi({ text: textToTranslate, from: fromKey, to: toKey })
translated = parseTranslateResult(res)
}
if (formData.translations[toKey]) formData.translations[toKey][itemKey] = translated
} 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>