效果
先上最终效果图
前言
最近将seaurl.com网站的AI空间重新规划了一下,添加了AI工具库,并新加了功能:翻译,于是着手实现翻译功能并记录下来分享给大家。
思路
要实现翻译,首先我们要想清楚翻译是如何交互的,我们以百度、谷歌还有deepl为例,它们的共同点是:
- 左侧源语言选择器
- 左侧源语言输入框(可输入)
- 中间切换语言按钮
- 右侧目标语言选择器
- 右侧目标语言输出框(不可输入)
这是页面的5个元素,现在我们来分析如何交互的,这个非常重要,因为只要你分析清楚交互的底层逻辑,代码实现起来就容易多了,所以这块是整篇文章的核心。
综合使用了上述三个翻译器之后,我们可以画出序列图来加深理解,如下所示:
上面一共有四个步骤来触发翻译,分别如下所示:
- 源语言文本框输入文字
- 源语言选择器变化
- 目标语言选择器变化
- 切换语言按钮变化
好了,下面我们分别来实现,但是在实现之前我们要解决以下几个问题。
语言检测
这里我们使用了cld3-asm
来实现检测语言功能,代码如下所示:
import {loadModule} from 'cld3-asm';
const cldFactory = await loadModule();
const identifier = cldFactory.create(0, 100);
const result = identifier.findLanguage(//待检测的文字);
这样就完成了检测功能
防抖问题
用户在输入文字的时候,我们就要用到防抖功能,这里我们使用的是lodash的debounce来实现的,代码如下所示:
const gotoExec = useCallback(_.debounce((sTxt, sl, tl) => exec(sTxt, sl, tl), 500), [sourceLng, targetLng])
上面代码意思就是::当用户输入停止后的500秒后才执行翻译。
取消翻译问题
当用户输入完文字没过1秒又继续输入了,但是刚才的接口已经在请求了,怎么办?
这里我们可以结合axios + AbortController
来取消接口的请求动作,代码如下所示:
if (abortExecControllerRef.current) {
abortExecControllerRef.current.abort();
}
// 创建新的 AbortController
const newAbortController = new AbortController();
abortExecControllerRef.current = newAbortController;
const res = await dispatch(axios.post(`/space/crud/translator/exec`,{
sourceTxt: sTxt,
sourceLng: sl || sourceLng,
targetLng: tl || targetLng
},{
signal: newAbortController.signal,
} )
语言切换问题
这个问题是整篇文章最复杂的交互问题,大家可以跟我一起来探讨探讨。
- 源语言 不等于 目标语言
- 源语言 等于目标语言
1不用多解释,因为不等于就直接翻译,现在我们把问题集中在2上面。
当用户切换源语言时,发现源语言等于了目标语言,换个方向,当用户切换目标语言时,发现目标语言等于了源语言,这里如果是你的话,你怎么处理?
这里我是这样处理的,如果发现切换后两个语言相同,则把两个语言调换位置,如:源语言:中文,目标语言:英文,现在切换源语言是英文,那么调换位置就是:源语言:英文,目标语言:中文。这个很好理解吧,应该也还好不是很难对吧。
下面我们再加入一个问题探讨,就是默认的源语言:自动检测,目标语言:英文。
现在再来讨论2的问题,你怎么处理?
那么,我们同样来分析问题,如下所示 :
切换前:
源语言:自动检测,目标语言:英文
切换后:
源语言:英文,目标语言:?
这里目标语言你会选择哪个?这里我是这样操作的:目标语言等于语言库里面筛选出不等于英文的第1个语言值,这样就解决了。
好,现在我们再来分析一下:
切换前:
源语言:英文,目标语言:中文
切换后:
源语言:自动检测(中文),目标语言:?
这里我们源语言切换成自动检测,并且检测到的语言是中文,那么目标语言你会怎么设置?
我是这样操作的:因为目标语言跟检测出来的语言相同了,所以我也会从语言库里面筛选出不等于中文的第1个语言值,这样就解决了。
问题都差不多解决了,现在我们再来讲最后一个问题:点击切换语言按钮问题!
点击切换语言按钮问题
点击切换语言按钮,如果是你的话你会怎么处理?
我是这样处理的:首先两个语言调换位置,这个没问题对吧,接着,我们把目标语言文本复制到淅语言输入框中。就这样!
完整代码
上面已经解决了大部分问题了,小的细节问题我就不一一展开了,大家有兴趣可以看完整代码,如下所示:
translator.js
'use client'
import {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {useSession} from "next-auth/react";
import {useRouter} from "next/navigation";
import {withRouter} from "@/hoc/withRouter";
import {useDispatch} from "react-redux";
import {Select, Input, Button, Space} from "antd";
import {CloseOutlined, PlusOutlined, SwapOutlined} from "@ant-design/icons";
import {startTranslation} from "@/lib/slices/aiSpaceSlice";
import _ from 'lodash'
import {loadModule} from 'cld3-asm';
import DropdownList from "@/components/aiSpace/translator/dropDownList";
const {TextArea} = Input;
const Translator = (props) => {
const {data: session} = useSession()
const router = useRouter();
const dispatch = useDispatch()
const [sourceLng, setSourceLng] = useState('auto')
const [targetLng, setTargetLng] = useState('en')
const [sourceLngTxt, setSourceLngTxt] = useState('')
const [targetLngTxt, setTargetLngTxt] = useState('<span style="color:#5f6368">翻译</span>')
const sourceLngTxtRef = useRef(null);
const [isLoading, setLoading] = useState(false)
const [detectLng, setDetectLng] = useState({
label: '',
value: ''
})
const [lngList, setLngList] = useState([
{"label": "中文", "value": "zh"},
{"label": "英语", "value": "en"},
{"label": '日语', "value": 'ja'},
{"label": '韩语', "value": 'ko'},
{"label": "阿拉伯语", "value": "ar"},
{"label": "爱沙尼亚语", "value": "et"},
{"label": "保加利亚语", "value": "bg"},
{"label": "波兰语", "value": "pl"},
{"label": "波斯语", "value": "fa"},
{"label": "拉丁语", "value": "la"},
{"label": "丹麦语", "value": "da"},
{"label": "德语", "value": "de"},
{"label": "俄语", "value": "ru"},
{"label": "法语", "value": "fr"},
{"label": "芬兰语", "value": "fi"},
{"label": "荷兰语", "value": "nl"},
{"label": "捷克语", "value": "cs"},
{"label": "拉脱维亚语", "value": "lv"},
{"label": "立陶宛语", "value": "lt"},
{"label": "罗马尼亚语", "value": "ro"},
{"label": "挪威语", "value": "no"},
{"label": "葡萄牙语", "value": "pt"},
{"label": "瑞典语", "value": "sv"},
{"label": "斯洛伐克语", "value": "sk"},
{"label": "斯洛文尼亚语", "value": "sl"},
{"label": "土耳其语", "value": "tr"},
{"label": "西班牙语", "value": "es"},
{"label": "希腊语", "value": "el"},
{"label": "匈牙利语", "value": "hu"},
{"label": "意大利语", "value": "it"},
{"label": "印地语", "value": "hi"},
{"label": "越南语", "value": "vi"}
])
const abortExecControllerRef = useRef(null);
// const abortDetectControllerRef = useRef(null);
useEffect(() => {
sourceLngTxtRef.current.focus({
cursor: 'start',
})
return () => {
if (abortExecControllerRef.current) {
abortExecControllerRef.current.abort();
}
};
}, [])
// 仅在sourceLng才检测语言,因为targetLng会跟sourceLng同步修改,所以不必在targetLng变化时也检测语言
useEffect(() => {
startExec(sourceLngTxt)
if (sourceLng === 'auto') {
gotoDetect(sourceLngTxt)
}
}, [sourceLngTxt, sourceLng])
// 仅在sourceLng才检测语言,因为targetLng会跟sourceLng同步修改,所以不必在targetLng变化时也检测语言
useEffect(() => {
if (!targetLngTxt) {
setTargetLngTxt('<span style="color:#5f6368">翻译</span>')
}
}, [targetLngTxt, targetLng])
useEffect(() => {
if (detectLng?.value) {
if (detectLng.value === targetLng) {
const fs = lngList.filter(f => f.value !== detectLng.value)
if (fs.length > 0) {
setTargetLng(fs[0].value)
startExec(sourceLngTxt, detectLng.value, fs[0].value)
}
}
}
}, [detectLng])
function changeLng(type, d) {
// 重新选择源语言
if (type === 'source') {
setSourceLng(d)
if (d === targetLng || d === 'auto') {
if (sourceLng === 'auto') {
// 换个边
setTargetLng(detectLng.value || 'zh')
} else {
// 换个边
setTargetLng(sourceLng)
}
}
}
// 重新选择目标语言
if (type === 'target') {
setTargetLng(d)
if (d === sourceLng || detectLng.value === d) {
// 继续换边
setSourceLng(targetLng)
} else {
// 仅targetLng变化的话,重新翻译下
startExec(sourceLngTxt, sourceLng, d)
}
}
}
function startExec(val, sl, tl) {
if (abortExecControllerRef.current) {
abortExecControllerRef.current.abort();
}
gotoExec(val, sl, tl)
}
const gotoDetect = useCallback(_.debounce((val) => onDetectLng(val), 500), [sourceLng])
/**
* 检测语言
* @param val
* @returns {Promise<void>}
*/
async function onDetectLng(val) {
const newVal = val.trim()
if (!newVal) {
setDetectLng({
label: `自动检测`,
value: 'auto'
})
return
}
// 创建新的 AbortController
// const abortController = new AbortController();
// abortDetectControllerRef.current = abortController;
const cldFactory = await loadModule();
const identifier = cldFactory.create(0, 100);
// 只有有值并且是自动检测情况下才会检测
const result = identifier.findLanguage(newVal);
if (result?.language) {
const fs = lngList.filter(f => f.value === result.language)
if (fs.length > 0) {
setDetectLng({
label: `检测到:${fs[0].label}`,
value: fs[0].value
})
}
// 如果检测到源是英语,则目标设置成中文
if (result.language === 'en' && targetLng === 'zh') {
setTargetLng('zh')
startExec(val, 'en', 'zh')
}
}
}
function onChangeSourceLngTxt(e) {
setSourceLngTxt(e.target.value)
if (!e.target.value) {
setTargetLngTxt('<span style="color:#5f6368">翻译</span>')
}
}
const gotoExec = useCallback(_.debounce((sTxt, sl, tl) => exec(sTxt, sl, tl), 500), [sourceLng, targetLng])
async function onSave() {
}
/**
* 切换语言
*/
function onSwitch() {
const _sourceLng = sourceLng
const _targetLng = targetLng
if (targetLngTxt === '<span style="color:#5f6368">翻译</span>') {
// setSourceLngTxt('')
} else {
const rs = targetLngTxt.replace(/<br\s*\/?>/gi, '\n');
setSourceLngTxt(rs)
}
setSourceLng(_targetLng)
//如果是自动检测
if (_sourceLng === 'auto') {
//检测到值
if (detectLng.value && detectLng.value !== 'auto') {
setTargetLng(detectLng.value)
} else {
// 没办法之举,只能设置成zh
setTargetLng('zh')
}
} else {
setTargetLng(_sourceLng)
}
}
const exec = async function (sTxt, sl, tl) {
// 判断是否为空,为空则不往下执行
if (!sTxt) {
if (abortExecControllerRef.current) {
abortExecControllerRef.current.abort();
}
return
}
// 创建新的 AbortController
const newAbortController = new AbortController();
abortExecControllerRef.current = newAbortController;
setLoading(true)
if (sourceLng && targetLng && sTxt.trim()) {
const res = await dispatch(startTranslation({
sourceTxt: sTxt,
sourceLng: sl || sourceLng,
targetLng: tl || targetLng,
abortController: newAbortController
}))
setLoading(false)
if (res.payload.status === 0) {
const resJson = res.payload.data
if (resJson) {
const resData = JSON.parse(resJson)
console.log('resData = ', resData)
if (resData?.Code === '200') {
const result = resData?.Data?.Translated || ''
const formattedText = result.replace(/\n/g, '<br />');
console.log('formattedText =', JSON.stringify(formattedText))
setTargetLngTxt(formattedText)
}
}
}
} else {
setLoading(false)
setTargetLngTxt('')
}
}
function onClearSource() {
setTargetLngTxt('')
setSourceLngTxt('')
sourceLngTxtRef.current.focus({
cursor: 'start'
})
}
return <div className={'flex flex-col h-[calc(100vh-56px)] overflow-hidden'}>
<div className={'flex items-center justify-center p-2 border-b border-[#ddd] relative'}>
<div className={'w-full flex items-center gap-4'}>
<div className={'flex items-center'}>
<DropdownList value={sourceLng}
detect={detectLng}
menus={[{label: '自动检测', value: 'auto'}].concat(lngList)}
onChange={(d) => changeLng('source', d)}/>
</div>
<div className={''}>
<Button icon={<SwapOutlined/>} type={'text'} onClick={onSwitch} disabled={isLoading}/>
</div>
<div className={'flex items-center'}>
<DropdownList value={targetLng}
menus={lngList}
onChange={(d) => changeLng('target', d)}/>
</div>
</div>
{/*<div className={'w-1/2 flex items-center justify-end pr-2'}>*/}
{/* <Button type={'primary'} icon={<PlusOutlined/>} onClick={onSave}>保存到笔记</Button>*/}
{/*</div>*/}
</div>
<div className={'w-full flex flex-row h-full'}>
<div className={'w-1/2 '}>
<div className={'flex flex-row h-full border-r border-[#ddd] relative'}>
<TextArea placeholder="请输入您想要翻译的文字"
ref={sourceLngTxtRef}
variant={'borderless'}
value={sourceLngTxt}
showCount={false}
maxLength={2000}
onChange={(e) => onChangeSourceLngTxt(e)}
style={{height: '94%', fontSize: '24px', resize: 'none'}}/>
<div className={'flex pr-2 pt-2'}>
{
sourceLngTxt ? <Button icon={<CloseOutlined/>} type={'text'} shape={'circle'}
onClick={onClearSource}></Button> : null
}
</div>
</div>
</div>
<div className={'w-1/2'}>
<div className={'px-4 py-2 overflow-y-auto bg-[#fafafa]'}
style={{height: '94%', fontSize: '24px', whiteSpace: 'pre-wrap'}}
dangerouslySetInnerHTML={{__html: targetLngTxt}}>
</div>
</div>
</div>
</div>
};
export default withRouter(Translator);
dropdownList.js
'use client'
import {useEffect, useState} from "react";
import {useSession} from "next-auth/react";
import {useRouter} from "next/navigation";
import {withRouter} from "@/hoc/withRouter";
import {useDispatch} from "react-redux";
import {Dropdown} from "antd";
import {CheckOutlined, DownOutlined} from "@ant-design/icons";
import classNames from "classnames/bind";
import IconFont from "@/utils/iconFont";
const DropdownList = (props) => {
const {data: session} = useSession()
const router = useRouter();
const dispatch = useDispatch()
const [isOpen, setOpen] = useState(false)
// const [chooseLng, setChooseLng] = useState('')
const [lngName, setLngName] = useState('')
useEffect(() => {
if (props.detect) {
setLngName(props.detect.label)
}
}, [props.detect])
useEffect(() => {
let _lngName = ''
if (props.value) {
const fs = props.menus.filter(f => f.value === props.value)
if (fs.length > 0) {
_lngName = fs[0].label
}
}
setLngName(_lngName)
}, [props.value])
function renderMenus() {
console.log('renderMenus=', props.menus)
return props.menus?.map((item, key) => {
return <div
key={key}
className={'w-[180px] px-2 py-2 cursor-pointer text-base hover:bg-[#eee] flex items-center rounded-md'}
onClick={() => {
if (props.onChange) {
props.onChange(item.value)
}
setOpen(false)
}}>
{item.value === props.value ?
<div className={'w-[30px] flex items-center justify-center'}><CheckOutlined/></div>
: <span className={'w-[30px]'}></span>}
{item.label}
</div>
})
}
return <div className={'flex flex-col gap-2'}>
<Dropdown
open={isOpen}
trigger="click"
onOpenChange={async visible => {
setOpen(visible);
}}
dropdownRender={() => {
return <div style={{gridTemplateRows: 'repeat(11, minmax(40px, max-content))'}}
className={'grid grid-cols-3 relative h-full grid-flow-col justify-items-start gap-x-2 p-4 bg-white shadow border border-[#ddd] rounded-md'}>
{renderMenus()}
</div>
}}>
<div className={classNames({
'cursor-pointer hover:bg-[#eee] rounded-sm py-1 px-4 flex items-center justify-center gap-2': true,
'bg-[#eee]': isOpen
})} onClick={() => setOpen(true)}>
{lngName}<IconFont
type="icon-jiantouxia"
className={''}
/>
</div>
</Dropdown>
</div>
};
export default withRouter(DropdownList);
总结
1、翻译这个交互是有点复杂的,大家一定要理清思路再写代码实现起来就简单些
Q&A
Q:现在市面上那么多翻译软件为什么你还要自己写?
A:因为我想把好多平时用到的小软件小工具全部集成到seaurl,这样不需要在不同的软件中来回切换非常的麻烦,极大的提高工作效率和降低时间成本。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。