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

View File

@ -97,6 +97,16 @@ class Base
return Base::nullShow(Request::header($key), Request::input($key)); 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 * @return string

View File

@ -7,6 +7,9 @@
<!--任务操作--> <!--任务操作-->
<TaskOperation/> <TaskOperation/>
<!--下拉菜单-->
<DropdownMenu/>
<!--全局浮窗加载器--> <!--全局浮窗加载器-->
<FloatSpinner/> <FloatSpinner/>
@ -40,10 +43,19 @@ import PreviewImageState from "./components/PreviewImage/state";
import NetworkException from "./components/NetworkException"; import NetworkException from "./components/NetworkException";
import GuidePage from "./components/GuidePage"; import GuidePage from "./components/GuidePage";
import TaskOperation from "./pages/manage/components/TaskOperation"; import TaskOperation from "./pages/manage/components/TaskOperation";
import DropdownMenu from "./components/DropdownMenu";
import {mapState} from "vuex"; import {mapState} from "vuex";
export default { export default {
components: {TaskOperation, NetworkException, PreviewImageState, RightBottom, FloatSpinner, GuidePage}, components: {
DropdownMenu,
TaskOperation,
NetworkException,
PreviewImageState,
RightBottom,
FloatSpinner,
GuidePage
},
data() { data() {
return { 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"> <template v-if="translation">
<div class="content-divider"> <div class="content-divider">
<span></span> <span></span>
<div class="divider-label">{{ translation.label }}</div> <div class="divider-label translation-label" @click="viewText">{{ translation.label }}</div>
<span></span> <span></span>
</div> </div>
<div class="content-additional">{{translation.value}}</div> <div class="content-additional">{{translation.content}}</div>
</template> </template>
</div> </div>
</template> </template>
@ -26,7 +26,6 @@
<script> <script>
import {mapState} from "vuex"; import {mapState} from "vuex";
import DialogMarkdown from "../DialogMarkdown.vue"; import DialogMarkdown from "../DialogMarkdown.vue";
import {languageName} from "../../../../language";
export default { export default {
components: {DialogMarkdown}, components: {DialogMarkdown},
@ -35,11 +34,11 @@ export default {
msg: Object, msg: Object,
}, },
computed: { computed: {
...mapState(['audioPlaying', 'cacheTranslations']), ...mapState(['audioPlaying', 'cacheTranslations', 'cacheTranslationLanguage']),
translation() { translation({cacheTranslations, msgId, cacheTranslationLanguage}) {
const translation = this.cacheTranslations.find(item => { const translation = cacheTranslations.find(item => {
return item.key === `msg-${this.msgId}` && item.lang === languageName; return item.key === `msg-${msgId}` && item.language === cacheTranslationLanguage;
}); });
return translation ? translation : null; return translation ? translation : null;
}, },
@ -63,6 +62,9 @@ export default {
} }
return `${Math.max(1, seconds)}` return `${Math.max(1, seconds)}`
}, },
viewText(e) {
this.$emit('viewText', e);
},
}, },
} }
</script> </script>

View File

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

View File

@ -672,6 +672,7 @@ import DialogGroupWordChain from "./DialogGroupWordChain";
import DialogGroupVote from "./DialogGroupVote"; import DialogGroupVote from "./DialogGroupVote";
import DialogComplaint from "./DialogComplaint"; import DialogComplaint from "./DialogComplaint";
import touchclick from "../../../directives/touchclick"; import touchclick from "../../../directives/touchclick";
import {languageList} from "../../../language";
export default { export default {
name: "DialogWrapper", name: "DialogWrapper",
@ -884,7 +885,8 @@ export default {
'keyboardType', 'keyboardType',
'keyboardHeight', 'keyboardHeight',
'safeAreaBottom', 'safeAreaBottom',
'formOptions' 'formOptions',
'cacheTranslationLanguage'
]), ]),
...mapGetters(['isLoad']), ...mapGetters(['isLoad']),
@ -2994,27 +2996,43 @@ export default {
return; return;
} }
const {id: msg_id} = this.operateItem const {id: msg_id} = this.operateItem
if (this.isLoad(`msg-${msg_id}`)) { const key = `msg-${msg_id}`
if (this.isLoad(key)) {
return; return;
} }
this.$store.dispatch("setLoad", `msg-${msg_id}`) this.$store.dispatch("setLoad", key)
this.$store.dispatch("call", { this.$store.dispatch("call", {
url: 'dialog/msg/translation', url: 'dialog/msg/translation',
data: { data: {
msg_id msg_id,
language: this.cacheTranslationLanguage
}, },
}).then(({data}) => { }).then(({data}) => {
this.$store.dispatch("saveTranslation", { this.$store.dispatch("saveTranslation", Object.assign(data, {key}));
key: `msg-${msg_id}`,
value: data.content,
});
}).catch(({msg}) => { }).catch(({msg}) => {
$A.messageError(msg); $A.messageError(msg);
}).finally(_ => { }).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) { onCopy(data) {
if (!$A.isJson(data)) { if (!$A.isJson(data)) {
return return
@ -3097,10 +3115,18 @@ export default {
this.onPositionId(data.reply_id, data.msg_id) this.onPositionId(data.reply_id, data.msg_id)
}, },
onViewText({target, clientX}, el) { onViewText(event, el) {
if (this.operateVisible) { if (this.operateVisible) {
return return
} }
const {target, clientX} = event
//
if (target.classList.contains('translation-label')) {
this.operateItem = this.findMsgByElement(el)
this.openTranslationMenu(event)
return
}
// //
let approveElement = target; let approveElement = target;
@ -3144,14 +3170,7 @@ export default {
if (clientX - target.getBoundingClientRect().x > 18) { if (clientX - target.getBoundingClientRect().x > 18) {
return; return;
} }
let listElement = el.parentElement; const dataMsg = this.findMsgByElement(el)
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) { if (dataMsg.userid != this.userId) {
return; return;
} }
@ -3162,14 +3181,14 @@ export default {
target.setAttribute('data-list', 'checked') target.setAttribute('data-list', 'checked')
} }
this.$store.dispatch("setLoad", { this.$store.dispatch("setLoad", {
key: `msg-${dataId}`, key: `msg-${dataMsg.id}`,
delay: 600 delay: 600
}) })
this.$store.dispatch("call", { this.$store.dispatch("call", {
url: 'dialog/msg/checked', url: 'dialog/msg/checked',
data: { data: {
dialog_id: this.dialogId, dialog_id: this.dialogId,
msg_id: dataId, msg_id: dataMsg.id,
index: dataIndex, index: dataIndex,
checked: dataClass === 'checked' ? 0 : 1 checked: dataClass === 'checked' ? 0 : 1
}, },
@ -3183,17 +3202,28 @@ export default {
} }
$A.modalError(msg) $A.modalError(msg)
}).finally(_ => { }).finally(_ => {
this.$store.dispatch("cancelLoad", `msg-${dataId}`) this.$store.dispatch("cancelLoad", `msg-${dataMsg.id}`)
}); });
break;
}
listElement = listElement.parentElement;
}
} }
break; 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) { onViewFile(data) {
if (this.operateVisible) { if (this.operateVisible) {
return return

View File

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

View File

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

View File

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