<template>
<plugin-panel
title="国际化资源"
class="plugin-i18n"
:fixed-name="PLUGIN_NAME.I18n"
:fixedPanels="fixedPanels"
:docsUrl="docsUrl"
:docsContent="docsContent"
:isShowDocsIcon="true"
>
<template #content>
<div class="language-search-box">
<tiny-select v-model="currentSearchType" :options="i18nSearchTypes"></tiny-select>
<tiny-input v-model="searchKey" class="plugin-i18n-search" placeholder="请输入关键字" type="text" clearable>
<template #prefix>
<span class="icon">
<svg-icon name="basic-search"></svg-icon>
</span>
</template>
</tiny-input>
</div>
<div class="btn-box">
<tiny-button @click="openEditor($event, {})">
<svg-icon name="add" class="btn-icon"></svg-icon>添加词条
</tiny-button>
<tiny-button class="middle-btn" @click="batchDelete" :disabled="!selectedRowLength"
><svg-icon class="btn-icon" name="delete"></svg-icon>删除</tiny-button
>
<tiny-file-upload
ref="upload"
size="small"
:auto-upload="false"
:show-file-list="false"
action="/"
@change="handleChange"
>
<template #trigger>
<tiny-button><svg-icon class="btn-icon" name="upload"></svg-icon>批量上传</tiny-button>
</template>
</tiny-file-upload>
<a class="download-btn" @click="downloadFile"> 下载导入模板 </a>
<p v-show="isLoading && notEmpty">
<span id="boxeight" class="i18n-loading"></span><span>正在导入,请稍后...</span>
</p>
</div>
<div class="language-plugin-table lowcode-scrollbar">
<tiny-grid
ref="i18nTable"
:data="langList"
auto-resize
class="stripe-tiny-grid"
@edit-closed="editClosed($event)"
:edit-config="{ trigger: 'manual', mode: 'row', showStatus: false }"
:tooltip-config="{ appendToBody: false, placement: 'right' }"
:edit-rules="validRules"
>
<tiny-grid-column type="selection" width="42"></tiny-grid-column>
<tiny-grid-column
v-if="isEditMode"
width="120"
field="key"
title="key"
show-overflow
:show-icon="false"
></tiny-grid-column>
<tiny-grid-column
v-else
width="120"
field="key"
title="key"
show-overflow
:show-icon="false"
:editor="{ component: 'input', autoselect: true }"
></tiny-grid-column>
<tiny-grid-column
width="160"
field="zh_CN"
title="简体中文"
:show-icon="false"
:editor="{ component: 'input', autoselect: true }"
></tiny-grid-column>
<tiny-grid-column
width="160"
field="en_US"
title="English"
:show-icon="false"
:editor="{ component: 'input', autoselect: true }"
></tiny-grid-column>
<tiny-grid-column width="90" field="operation" title="操作">
<template v-slot="data">
<div v-if="editingRow !== data.row" class="i18n-opera">
<span class="icon">
<svg-icon name="to-edit" @click.stop="openEditor($event, data.row)"></svg-icon>
</span>
<tiny-tooltip class="item" effect="light" placement="bottom" :open-delay="OPEN_DELAY.Default">
<template #content>
<div>
复制键值(唯一标识)<br />
{{ data.row.key }}
</div>
</template>
<tiny-popover
placement="top"
:visible-arrow="false"
trigger="manual"
:content="copyTipContent"
:modelValue="data.row.key && data.rowIndex === copyRowIndex"
>
<template #reference>
<span class="icon">
<svg-icon name="copy" @click="copyId(data.row, data.rowIndex)"></svg-icon>
</span>
</template>
</tiny-popover>
</tiny-tooltip>
<span class="icon">
<svg-icon name="delete" @click="openDeletePopover(data.row)"></svg-icon>
</span>
</div>
</template>
</tiny-grid-column>
<template #empty>
<div v-if="isLoading" id="empty-loading-box" class="i18n-loading"></div>
<search-empty v-else />
</template>
</tiny-grid>
</div>
</template>
</plugin-panel>
</template>
<script lang="tsx">
import { computed, ref, watchEffect, reactive, onMounted, nextTick, resolveComponent, watch, provide } from 'vue'
import type { Component } from 'vue'
import useClipboard from 'vue-clipboard3'
import { Grid, GridColumn, Input, Popover, Button, FileUpload, Loading, Tooltip, Select } from '@opentiny/vue'
import { iconLoadingShadow } from '@opentiny/vue-icon'
import { PluginPanel, SearchEmpty } from '@opentiny/tiny-engine-common'
import {
useTranslate,
useModal,
useHelp,
getMetaApi,
META_SERVICE,
useLayout
} from '@opentiny/tiny-engine-meta-register'
import { getMergeMeta } from '@opentiny/tiny-engine-meta-register'
import { utils, constants } from '@opentiny/tiny-engine-utils'
import { BASE_URL } from '@opentiny/tiny-engine-common/js/environments'
const { OPEN_DELAY } = constants
export default {
components: {
TinyPopover: Popover as Component,
TinyTooltip: Tooltip as Component,
TinyInput: Input,
TinyButton: Button as Component,
TinyGrid: Grid,
TinyGridColumn: GridColumn,
PluginPanel,
TinySelect: Select,
TinyFileUpload: FileUpload,
SearchEmpty
},
props: {
fixedPanels: {
type: Array
}
},
setup(props, { emit }) {
const SvgIcon = resolveComponent('SvgIcon')
const lightSpinnerIcon = iconLoadingShadow()
const darkSpinnerIcon = () => <SvgIcon name="loading" />
const isLightTheme = getMergeMeta('engine.config').theme === 'light'
const { getLangs, i18nResource, currentLanguage, getI18nData } = useTranslate()
const { toClipboard } = useClipboard()
const { PLUGIN_NAME } = useLayout()
const panelState = reactive({
emitEvent: emit
})
provide('panelState', panelState)
const fullLangList = computed(() => {
const langs = getLangs()
return Object.keys(langs)
.map((key) => ({ ...langs[key] }))
.reverse()
})
const i18nSearchTypes = [
{
value: 'byTimeDesc',
label: '按时间倒序'
},
{
value: 'byTimeAsc',
label: '按时间正序'
},
{
value: 'byLetterZh',
label: '按中文排序'
},
{
value: 'byLetterEn',
label: '按英文排序'
}
]
const docsUrl = useHelp().getDocsUrl('i18n')
const docsContent =
'针对画布中的项目,可能需要同时支持多个语言,设计器提供了中英文切换,可一键切换语言,国际化资源是应用级别的,在任何一个页面都可以访问。'
const currentSearchType = ref('')
const copyTipContent = ref('')
const searchKey = ref('')
const activedRow = ref('')
const langList = ref([])
const copyRowIndex = ref('')
const isEditMode = ref(false)
const isLoading = ref(false)
const upload = ref('upload')
const i18nTable = ref(null)
const selectedRowLength = computed(() => {
return i18nTable.value?.getAllSelection?.().length || 0
})
const notEmpty = computed(() => langList.value.length > 0)
const current = ref({
lang: 'zh_CN',
label: '中文'
})
const editingRow = ref(null)
const validateKey = (rule, value, callback) => {
if (!isEditMode.value && fullLangList.value.some(({ key }) => value === key)) {
callback(new Error('不可与现有 key 重复'))
return
}
callback()
}
const validRules = reactive({
key: [{ required: true, message: '必填' }, { validator: validateKey }]
})
onMounted(() => {
currentSearchType.value = i18nSearchTypes[0].value
})
const sortByLetter = (sortType = 'zh_CN') => {
langList.value = langList.value.sort((a, b) => {
if (typeof a[sortType] === 'undefined' && typeof b[sortType] === 'undefined') return 0
else if (typeof a[sortType] === 'undefined') return 1
else if (typeof b[sortType] === 'undefined') return -1
else
return sortType === 'zh_CN'
? a[sortType].localeCompare(b[sortType], 'zh')
: a[sortType].localeCompare(b[sortType])
})
}
const sortTypeChanges = (event) => {
switch (event) {
case 'byTimeAsc':
langList.value.reverse()
break
case 'byLetterZh':
sortByLetter()
break
case 'byLetterEn':
sortByLetter('en_US')
break
default:
}
}
watch(
() => [fullLangList.value, currentSearchType.value, searchKey.value],
() => {
langList.value = fullLangList.value.filter((item) => {
const reg = new RegExp(searchKey.value, 'i')
return reg.test(item?.zh_CN) || reg.test(item?.en_US) || reg.test(item?.key)
})
sortTypeChanges(currentSearchType.value)
}
)
watchEffect(() => {
if (i18nResource.locales.length) {
current.value = i18nResource.locales.find((item) => item.lang === currentLanguage.value)
}
})
const confirm = (rowData) => {
useTranslate().ensureI18n(rowData, true)
}
const editClosed = (event) => {
i18nTable.value.validate(event.row, (valid) => {
if (valid) {
confirm(event.row)
}
})
}
const batchDelete = () => {
const i18nData = i18nTable.value.getSelectRecords()
if (!i18nData.length) {
return
}
const { confirm } = useModal()
confirm({
title: '批量删除',
message: `您确定删除 ${i18nData.length} 条数据吗?`,
exec: () => {
const keys = i18nData.map(({ key }) => key)
useTranslate().removeI18n(keys)
i18nTable.value.clearSelection()
}
})
}
const downloadFile = () => {
const { batchImportTempDownloadUrl, batchImportTempDownMethod } =
getMergeMeta('engine.plugins.i18n').options || {}
if (batchImportTempDownMethod && typeof batchImportTempDownMethod === 'function') {
batchImportTempDownMethod()
return
}
const defaultDownloadUrl = `${
BASE_URL.endsWith('/') ? BASE_URL.slice(0, -1) : BASE_URL
}/i18n-template-for-batch-import.zip`
const linkElement = document.createElement('a')
linkElement.href = batchImportTempDownloadUrl || defaultDownloadUrl
linkElement.download = 'i18n-template-for-batch-import.zip'
linkElement.target = '_blank'
document.body.appendChild(linkElement)
linkElement.click()
document.body.removeChild(linkElement)
}
const openDeletePopover = (row) => {
const { confirm } = useModal()
confirm({
title: '删除词条',
message: `您确定删除 key 为 ${row.key} 的词条吗?`,
exec: () => {
const keys = [row.key]
useTranslate().removeI18n(keys)
i18nTable.value.clearSelection()
}
})
}
const getActiveRow = () => {
activedRow.value = i18nTable.value.getActiveRow()?.rowIndex ?? ''
}
const openEditor = (_event, row) => {
isEditMode.value = Boolean(row.key)
editingRow.value = row
if (!isEditMode.value) {
row.key = `lowcode.${utils.guid()}`
langList.value.unshift(row)
}
i18nTable.value.setActiveRow(row).then(() => {
getActiveRow()
})
}
const copyId = async (row, rowIndex) => {
copyRowIndex.value = rowIndex
try {
await toClipboard(row.key)
copyTipContent.value = '复制成功'
} catch (e) {
copyTipContent.value = '复制失败'
console.error('Clipboard operation failed:', e)
} finally {
setTimeout(() => {
copyRowIndex.value = ''
}, 3000)
}
}
const handleAvatarSuccess = () => {
getI18nData().then((res) => {
const zhData = res?.messages?.zh_CN || {}
const enData = res?.messages?.en_US || {}
const allI18nKey = [...Object.keys(zhData), ...Object.keys(enData)]
const arr = [...new Set(allI18nKey)]
arr.forEach((item) => {
if (item) {
useTranslate().ensureI18n(
{
en_US: enData[item] || '',
key: item,
type: 'i18n',
zh_CN: zhData[item] || ''
},
false
)
}
})
})
}
const handleChange = (data) => {
const appId = getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id
const action = `/app-center/api/apps/${appId}/i18n/entries/update`
const loadingTarget = notEmpty.value ? '#boxeight' : '#empty-loading-box'
const loadintText = notEmpty.value ? '' : '正在导入'
const loadingIcon = isLightTheme ? lightSpinnerIcon : darkSpinnerIcon
isLoading.value = true
let loadingInstance
nextTick(() => {
loadingInstance = Loading.service({
lock: true,
text: loadintText,
spinner: loadingIcon,
target: loadingTarget,
background: 'transparent'
})
})
const formdata = new FormData()
let key = '1'
if (data.name.indexOf('en') > -1) {
key = '2'
}
formdata.set(key, data.raw)
getMetaApi(META_SERVICE.Http)
.post(action, formdata)
.then(() => {
handleAvatarSuccess()
})
.finally(() => {
loadingInstance.close()
isLoading.value = false
})
}
return {
PLUGIN_NAME,
sortTypeChanges,
currentSearchType,
i18nSearchTypes,
selectedRowLength,
notEmpty,
copyTipContent,
validRules,
langList,
searchKey,
activedRow,
i18nResource,
copyRowIndex,
editClosed,
openEditor,
openDeletePopover,
copyId,
handleChange,
upload,
handleAvatarSuccess,
isLoading,
current,
confirm,
i18nTable,
downloadFile,
isEditMode,
editingRow,
batchDelete,
docsUrl,
docsContent,
OPEN_DELAY
}
}
}
</script>
<style lang="less" scoped>
.plugin-i18n {
border-right: none;
box-shadow: 6px 0px 3px 0px var(--te-i18n-panel-shadow-color);
}
.stripe-tiny-grid {
word-wrap: break-word;
#empty-loading-box {
width: 100%;
color: red;
:deep(.tiny-loading__spinner) svg {
width: 36px;
height: 36px;
font-size: 36px;
}
:deep(.tiny-loading__text) {
margin-top: 8px;
}
}
.i18n-loading {
:deep(.tiny-loading__spinner) {
svg {
color: var(--te-i18n-loading-icon-color);
}
}
:deep(.tiny-loading__text) {
color: var(--te-i18n-loading-text-color);
}
}
}
.language-search-box {
padding: 0 12px;
margin-bottom: 12px;
display: flex;
.tiny-input {
margin-left: 8px;
}
.tiny-select {
width: 210px;
}
:deep(.tiny-input) {
.tiny-input__prefix {
line-height: 1;
}
&.tiny-input-prefix .tiny-input__inner {
padding: 0 8px 0 26px;
}
}
}
.btn-box {
color: var(--te-i18n-button-text-color);
font-size: 12px;
margin-bottom: 12px;
padding: 0 12px;
display: flex;
align-items: center;
column-gap: 8px;
.btn-icon {
font-size: 16px;
color: var(--te-i18n-button-icon-color);
margin-right: 4px;
}
.middle-btn {
margin-left: 0;
}
span {
padding-left: 12px;
}
#boxeight {
:deep(.circular) {
width: 16px;
height: 16px;
margin-top: 13px;
}
:deep(.path) {
stroke: var(--te-i18n-common-tip-text-color);
}
}
.download-btn {
cursor: pointer;
text-decoration: none;
display: inline-block;
font-size: 12px;
text-align: left;
padding: 0;
color: var(--te-i18n-button-text-color);
svg {
font-size: 16px;
}
.tiny-button.tiny-button--text {
color: var(--te-i18n-button-text-color);
}
&:hover {
text-decoration: underline;
}
}
}
.language-plugin-table {
height: calc(100% - 48px);
flex: 1;
padding: 12px;
border-top: 1px solid var(--te-i18n-border-color);
overflow-y: scroll;
.operation-column {
display: flex;
width: 100%;
justify-content: space-around;
svg {
font-size: 14px;
}
}
}
.stripe-tiny-grid {
.i18n-opera {
display: flex;
justify-content: space-between;
:deep(.icon) {
color: var(--te-i18n-grid-opt-icon-color);
svg {
font-size: 16px;
}
&:hover {
svg {
opacity: 0.75;
}
}
}
}
}
</style>