feat: 消息翻译支持切换语言

This commit is contained in:
kuaifan 2024-10-30 19:54:10 +08:00
parent cce7523f45
commit 621726ab3b
10 changed files with 333 additions and 81 deletions

View File

@ -1543,12 +1543,13 @@ class DialogController extends AbstractController
/**
* @api {get} api/dialog/msg/translation 32. 翻译消息
*
* @apiDescription 将文本消息翻译成当前语言,需要token身份
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__translation
*
* @apiParam {Number} msg_id 消息ID
* @apiParam {String} [language] 目标语言,默认当前语言
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
@ -1559,7 +1560,7 @@ class DialogController extends AbstractController
User::auth();
//
$msg_id = intval(Request::input("msg_id"));
$language = Base::headerOrInput('language');
$language = Base::inputOrHeader('language');
$targetLanguage = match ($language) {
"zh" => "简体中文",
"zh-CHT" => "繁体中文",

View File

@ -97,6 +97,16 @@ class Base
return Base::nullShow(Request::header($key), Request::input($key));
}
/**
* 如果input没有则通过header读取
* @param $key
* @return mixed|string
*/
public static function inputOrHeader($key)
{
return Base::nullShow(Request::input($key), Request::header($key));
}
/**
* 获取版本号
* @return string

View File

@ -7,6 +7,9 @@
<!--任务操作-->
<TaskOperation/>
<!--下拉菜单-->
<DropdownMenu/>
<!--全局浮窗加载器-->
<FloatSpinner/>
@ -40,10 +43,19 @@ import PreviewImageState from "./components/PreviewImage/state";
import NetworkException from "./components/NetworkException";
import GuidePage from "./components/GuidePage";
import TaskOperation from "./pages/manage/components/TaskOperation";
import DropdownMenu from "./components/DropdownMenu";
import {mapState} from "vuex";
export default {
components: {TaskOperation, NetworkException, PreviewImageState, RightBottom, FloatSpinner, GuidePage},
components: {
DropdownMenu,
TaskOperation,
NetworkException,
PreviewImageState,
RightBottom,
FloatSpinner,
GuidePage
},
data() {
return {

View File

@ -0,0 +1,176 @@
<template>
<EDropdown
ref="dropdown"
trigger="click"
class="task-operation-dropdown"
placement="bottom"
size="small"
:style="styles"
@command="onCommand"
@visible-change="visibleChange">
<div ref="icon" class="task-operation-icon"></div>
<EDropdownMenu ref="dropdownMenu" slot="dropdown" class="task-operation-more-dropdown">
<li class="task-operation-more-warp small">
<ul>
<EDropdownItem
v-for="(item, key) in list"
:key="key"
:command="item.value"
:disabled="active === item.value">
<div class="item">{{item.label}}</div>
</EDropdownItem>
</ul>
</li>
</EDropdownMenu>
</EDropdown>
</template>
<script>
import {mapState} from "vuex";
export default {
data() {
return {
visible: false,
list: [], // : [{label: '', value: ''}]
active: '', //
onUpdate: null, //
scrollHide: false, //
element: null,
target: null,
styles: {},
}
},
beforeDestroy() {
if (this.target) {
this.target.removeEventListener('scroll', this.handlerEventListeners);
}
},
computed: {
...mapState(['menuOperation'])
},
watch: {
menuOperation(data) {
if (data.event && data.list) {
if (this.$refs.dropdown.visible && this.element === data.event.target) {
this.hide();
return;
}
const eventRect = data.event.target.getBoundingClientRect();
this.styles = {
left: `${eventRect.left}px`,
top: `${eventRect.top}px`,
width: `${eventRect.width}px`,
height: `${eventRect.height}px`,
}
this.list = data.list;
this.active = data.active && this.list.find(item => item.value === data.active) ? data.active : '';
this.onUpdate = typeof data.onUpdate === "function" ? data.onUpdate : null;
this.scrollHide = typeof data.scrollHide === "boolean" ? data.scrollHide : false;
//
this.$refs.icon.focus();
this.updatePopper();
this.show();
this.setupEventListeners(data.event)
} else {
this.hide()
}
}
},
methods: {
show() {
this.$refs.dropdown.show()
},
hide() {
this.$refs.dropdown.hide()
},
onCommand(value) {
this.hide();
if (typeof this.onUpdate === "function") {
this.onUpdate(value);
}
},
visibleChange(visible) {
this.visible = visible;
},
updatePopper() {
this.$nextTick(this.$refs.dropdownMenu.updatePopper)
},
setupEventListeners(event) {
this.element = event.target;
let target = this.getScrollParent(this.element);
if (target === window.document.body || target === window.document.documentElement) {
target = window;
}
if (this.target) {
if (this.target === target) {
return;
}
this.target.removeEventListener('scroll', this.handlerEventListeners);
}
this.target = target;
this.target.addEventListener('scroll', this.handlerEventListeners);
},
handlerEventListeners(e) {
if (!this.visible || !this.element) {
return
}
if (this.scrollHide) {
this.hide();
return;
}
const scrollRect = e.target.getBoundingClientRect();
const eventRect = this.element.getBoundingClientRect();
if (eventRect.top < scrollRect.top || eventRect.top > scrollRect.top + scrollRect.height) {
this.hide();
return;
}
this.styles = {
left: `${eventRect.left}px`,
top: `${eventRect.top}px`,
width: `${eventRect.width}px`,
height: `${eventRect.height}px`,
};
this.updatePopper();
},
getScrollParent(element) {
const parent = element.parentNode;
if (!parent) {
return element;
}
if (parent === window.document) {
if (window.document.body.scrollTop || window.document.body.scrollLeft) {
return window.document.body;
} else {
return window.document.documentElement;
}
}
if (
['scroll', 'auto'].indexOf(this.getStyleComputedProperty(parent, 'overflow')) !== -1 ||
['scroll', 'auto'].indexOf(this.getStyleComputedProperty(parent, 'overflow-x')) !== -1 ||
['scroll', 'auto'].indexOf(this.getStyleComputedProperty(parent, 'overflow-y')) !== -1
) {
return parent;
}
return this.getScrollParent(element.parentNode);
},
getStyleComputedProperty(element, property) {
const css = window.getComputedStyle(element, null);
return css[property];
}
}
}
</script>

View File

@ -15,10 +15,10 @@
<template v-if="translation">
<div class="content-divider">
<span></span>
<div class="divider-label">{{ translation.label }}</div>
<div class="divider-label translation-label" @click="viewText">{{ translation.label }}</div>
<span></span>
</div>
<div class="content-additional">{{translation.value}}</div>
<div class="content-additional">{{translation.content}}</div>
</template>
</div>
</template>
@ -26,7 +26,6 @@
<script>
import {mapState} from "vuex";
import DialogMarkdown from "../DialogMarkdown.vue";
import {languageName} from "../../../../language";
export default {
components: {DialogMarkdown},
@ -35,11 +34,11 @@ export default {
msg: Object,
},
computed: {
...mapState(['audioPlaying', 'cacheTranslations']),
...mapState(['audioPlaying', 'cacheTranslations', 'cacheTranslationLanguage']),
translation() {
const translation = this.cacheTranslations.find(item => {
return item.key === `msg-${this.msgId}` && item.lang === languageName;
translation({cacheTranslations, msgId, cacheTranslationLanguage}) {
const translation = cacheTranslations.find(item => {
return item.key === `msg-${msgId}` && item.language === cacheTranslationLanguage;
});
return translation ? translation : null;
},
@ -63,6 +62,9 @@ export default {
}
return `${Math.max(1, seconds)}`
},
viewText(e) {
this.$emit('viewText', e);
},
},
}
</script>

View File

@ -6,11 +6,11 @@
<template v-if="translation">
<div class="content-divider">
<span></span>
<div class="divider-label">{{ translation.label }}</div>
<div class="divider-label translation-label" @click="viewText">{{ translation.label }}</div>
<span></span>
</div>
<DialogMarkdown v-if="msg.type === 'md'" :text="translation.value"/>
<pre v-else v-html="$A.formatTextMsg(translation.value, userId)"></pre>
<DialogMarkdown v-if="msg.type === 'md'" :text="translation.content"/>
<pre v-else v-html="$A.formatTextMsg(translation.content, userId)"></pre>
</template>
</div>
</template>
@ -18,7 +18,6 @@
<script>
import {mapState} from "vuex";
import DialogMarkdown from "../DialogMarkdown.vue";
import {languageName} from "../../../../language";
export default {
components: {DialogMarkdown},
@ -27,11 +26,11 @@ export default {
msg: Object,
},
computed: {
...mapState(['cacheTranslations']),
...mapState(['cacheTranslations', 'cacheTranslationLanguage']),
translation() {
const translation = this.cacheTranslations.find(item => {
return item.key === `msg-${this.msgId}` && item.lang === languageName;
translation({cacheTranslations, msgId, cacheTranslationLanguage}) {
const translation = cacheTranslations.find(item => {
return item.key === `msg-${msgId}` && item.language === cacheTranslationLanguage;
});
return translation ? translation : null;
},

View File

@ -672,6 +672,7 @@ import DialogGroupWordChain from "./DialogGroupWordChain";
import DialogGroupVote from "./DialogGroupVote";
import DialogComplaint from "./DialogComplaint";
import touchclick from "../../../directives/touchclick";
import {languageList} from "../../../language";
export default {
name: "DialogWrapper",
@ -884,7 +885,8 @@ export default {
'keyboardType',
'keyboardHeight',
'safeAreaBottom',
'formOptions'
'formOptions',
'cacheTranslationLanguage'
]),
...mapGetters(['isLoad']),
@ -2994,27 +2996,43 @@ export default {
return;
}
const {id: msg_id} = this.operateItem
if (this.isLoad(`msg-${msg_id}`)) {
const key = `msg-${msg_id}`
if (this.isLoad(key)) {
return;
}
this.$store.dispatch("setLoad", `msg-${msg_id}`)
this.$store.dispatch("setLoad", key)
this.$store.dispatch("call", {
url: 'dialog/msg/translation',
data: {
msg_id
msg_id,
language: this.cacheTranslationLanguage
},
}).then(({data}) => {
this.$store.dispatch("saveTranslation", {
key: `msg-${msg_id}`,
value: data.content,
});
this.$store.dispatch("saveTranslation", Object.assign(data, {key}));
}).catch(({msg}) => {
$A.messageError(msg);
}).finally(_ => {
this.$store.dispatch("cancelLoad", `msg-${msg_id}`)
this.$store.dispatch("cancelLoad", key)
});
},
openTranslationMenu(event) {
const list = Object.keys(languageList).map(item => ({
label: languageList[item],
value: item
}))
this.$store.state.menuOperation = {
event,
list,
active: this.cacheTranslationLanguage,
scrollHide: true,
onUpdate: async (language) => {
await this.$store.dispatch("setTranslationLanguage", language);
this.onTranslation();
}
}
},
onCopy(data) {
if (!$A.isJson(data)) {
return
@ -3097,10 +3115,18 @@ export default {
this.onPositionId(data.reply_id, data.msg_id)
},
onViewText({target, clientX}, el) {
onViewText(event, el) {
if (this.operateVisible) {
return
}
const {target, clientX} = event
//
if (target.classList.contains('translation-label')) {
this.operateItem = this.findMsgByElement(el)
this.openTranslationMenu(event)
return
}
//
let approveElement = target;
@ -3144,56 +3170,60 @@ export default {
if (clientX - target.getBoundingClientRect().x > 18) {
return;
}
let listElement = el.parentElement;
while (listElement) {
if (listElement.classList.contains('dialog-scroller')) {
break;
}
if (listElement.classList.contains('dialog-view')) {
const dataId = listElement.getAttribute("data-id")
const dataMsg = this.allMsgs.find(item => item.id == dataId) || {}
if (dataMsg.userid != this.userId) {
return;
}
const dataIndex = [].indexOf.call(el.querySelectorAll(target.tagName), target);
if (dataClass === 'checked') {
target.setAttribute('data-list', 'unchecked')
} else {
target.setAttribute('data-list', 'checked')
}
this.$store.dispatch("setLoad", {
key: `msg-${dataId}`,
delay: 600
})
this.$store.dispatch("call", {
url: 'dialog/msg/checked',
data: {
dialog_id: this.dialogId,
msg_id: dataId,
index: dataIndex,
checked: dataClass === 'checked' ? 0 : 1
},
}).then(({data}) => {
this.$store.dispatch("saveDialogMsg", data);
}).catch(({msg}) => {
if (dataClass === 'checked') {
target.setAttribute('data-list', 'checked')
} else {
target.setAttribute('data-list', 'unchecked')
}
$A.modalError(msg)
}).finally(_ => {
this.$store.dispatch("cancelLoad", `msg-${dataId}`)
});
break;
}
listElement = listElement.parentElement;
const dataMsg = this.findMsgByElement(el)
if (dataMsg.userid != this.userId) {
return;
}
const dataIndex = [].indexOf.call(el.querySelectorAll(target.tagName), target);
if (dataClass === 'checked') {
target.setAttribute('data-list', 'unchecked')
} else {
target.setAttribute('data-list', 'checked')
}
this.$store.dispatch("setLoad", {
key: `msg-${dataMsg.id}`,
delay: 600
})
this.$store.dispatch("call", {
url: 'dialog/msg/checked',
data: {
dialog_id: this.dialogId,
msg_id: dataMsg.id,
index: dataIndex,
checked: dataClass === 'checked' ? 0 : 1
},
}).then(({data}) => {
this.$store.dispatch("saveDialogMsg", data);
}).catch(({msg}) => {
if (dataClass === 'checked') {
target.setAttribute('data-list', 'checked')
} else {
target.setAttribute('data-list', 'unchecked')
}
$A.modalError(msg)
}).finally(_ => {
this.$store.dispatch("cancelLoad", `msg-${dataMsg.id}`)
});
}
break;
}
},
findMsgByElement(el) {
let element = el.parentElement;
while (element) {
if (element.classList.contains('dialog-scroller')) {
break;
}
if (element.classList.contains('dialog-view')) {
const dataId = element.getAttribute("data-id")
return this.allMsgs.find(item => item.id == dataId) || {}
}
element = element.parentElement;
}
return {};
},
onViewFile(data) {
if (this.operateVisible) {
return

View File

@ -826,6 +826,7 @@ export default {
const cacheLoginEmail = await $A.IDBString("cacheLoginEmail");
const cacheFileSort = await $A.IDBJson("cacheFileSort");
const cacheTaskBrowse = await $A.IDBArray("cacheTaskBrowse")
const cacheTranslationLanguage = await $A.IDBString("cacheTranslationLanguage")
const cacheTranslations = await $A.IDBArray("cacheTranslations")
const cacheEmojis = await $A.IDBArray("cacheEmojis")
const userInfo = await $A.IDBJson("userInfo")
@ -836,6 +837,7 @@ export default {
await $A.IDBSet("cacheLoginEmail", cacheLoginEmail);
await $A.IDBSet("cacheFileSort", cacheFileSort);
await $A.IDBSet("cacheTaskBrowse", cacheTaskBrowse);
await $A.IDBSet("cacheTranslationLanguage", cacheTranslationLanguage);
await $A.IDBSet("cacheTranslations", cacheTranslations);
await $A.IDBSet("cacheEmojis", cacheEmojis);
await $A.IDBSet("cacheVersion", state.cacheVersion)
@ -867,6 +869,7 @@ export default {
state.cacheTasks = await $A.IDBArray("cacheTasks")
state.cacheProjectParameter = await $A.IDBArray("cacheProjectParameter")
state.cacheTaskBrowse = await $A.IDBArray("cacheTaskBrowse")
state.cacheTranslationLanguage = await $A.IDBString("cacheTranslationLanguage")
state.cacheTranslations = await $A.IDBArray("cacheTranslations")
state.dialogMsgs = await $A.IDBArray("dialogMsgs")
state.fileLists = await $A.IDBArray("fileLists")
@ -874,6 +877,9 @@ export default {
state.callAt = await $A.IDBArray("callAt")
state.cacheEmojis = await $A.IDBArray("cacheEmojis")
// TranslationLanguage
typeof languageList[state.cacheTranslationLanguage] === "undefined" && (state.cacheTranslationLanguage = languageName)
// 会员信息
if (state.userInfo.userid) {
state.userId = state.userInfo.userid = $A.runNum(state.userInfo.userid)
@ -3351,24 +3357,32 @@ export default {
/**
* 保存翻译
* @param state
* @param dispatch
* @param data {key, value}
* @param data {key, content, language}
*/
saveTranslation({state, dispatch}, data) {
saveTranslation({state}, data) {
if (!$A.isJson(data)) {
return
}
const item = state.cacheTranslations.find(item => item.key == data.key && item.lang == languageName)
if (item) {
item.value = data.value
const translation = state.cacheTranslations.find(item => item.key == data.key && item.language == data.language)
if (translation) {
translation.content = data.content
} else {
data.lang = languageName
data.label = languageList[languageName] || languageName
state.cacheTranslations.push(data)
const label = languageList[data.language] || data.language
state.cacheTranslations.push(Object.assign(data, {label}))
}
$A.IDBSave("cacheTranslations", state.cacheTranslations.slice(-200))
},
/**
* 设置翻译语言
* @param state
* @param language
*/
setTranslationLanguage({state}, language) {
state.cacheTranslationLanguage = language
$A.IDBSave('cacheTranslationLanguage', language);
},
/** *****************************************************************************************/
/** ************************************* loads *********************************************/
/** *****************************************************************************************/

View File

@ -234,5 +234,9 @@ export default {
},
// 翻译
cacheTranslationLanguage: '',
cacheTranslations: [],
// 下拉菜单操作
menuOperation: {}
};

View File

@ -1413,6 +1413,10 @@
font-size: 12px;
padding: 0 8px;
opacity: 0.6;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
}