效果

先上最终效果图

image.png

前言

最近将seaurl.com网站的AI空间重新规划了一下,添加了AI工具库,并新加了功能:翻译,于是着手实现翻译功能并记录下来分享给大家。

思路

要实现翻译,首先我们要想清楚翻译是如何交互的,我们以百度、谷歌还有deepl为例,它们的共同点是:

  1. 左侧源语言选择器
  2. 左侧源语言输入框(可输入)
  3. 中间切换语言按钮
  4. 右侧目标语言选择器
  5. 右侧目标语言输出框(不可输入)

这是页面的5个元素,现在我们来分析如何交互的,这个非常重要,因为只要你分析清楚交互的底层逻辑,代码实现起来就容易多了,所以这块是整篇文章的核心。

综合使用了上述三个翻译器之后,我们可以画出序列图来加深理解,如下所示:
image.png

上面一共有四个步骤来触发翻译,分别如下所示:

  1. 源语言文本框输入文字
  2. 源语言选择器变化
  3. 目标语言选择器变化
  4. 切换语言按钮变化

好了,下面我们分别来实现,但是在实现之前我们要解决以下几个问题。

语言检测

这里我们使用了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. 源语言 等于目标语言

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,这样不需要在不同的软件中来回切换非常的麻烦,极大的提高工作效率和降低时间成本。

引用

认识 AbortController控制器对象 及其应用
安排函数防抖与节流


Awbeci
3.1k 声望213 粉丝

Awbeci