This commit is contained in:
zyronon
2025-08-17 17:03:50 +08:00
parent b94bd61263
commit a969fce5ac
23 changed files with 511 additions and 148 deletions

View File

@@ -22,6 +22,7 @@ watch(store.$state, (n: BaseState) => {
})
watch(settingStore.$state, (n) => {
console.log('watch',settingStore.$state)
set(SAVE_SETTING_KEY.key, JSON.stringify({val: n, version: SAVE_SETTING_KEY.version}))
})

View File

@@ -1,13 +1,13 @@
import {Article, Sentence, TranslateEngine} from "@/types/types.ts";
import Baidu from "@opentranslate/baidu";
import {Translator} from "@opentranslate/translator/src/translator.ts";
import Baidu from "@/libs/translate/baidu";
import {Translator} from "@/libs/translate/translator/index.ts";
export function getSentenceAllTranslateText(article: Article) {
return article.sections.map(v => v.map(s => s.translate.trim()).filter(v=>v).join(' \n')).filter(v=>v).join(' \n\n');
return article.sections.map(v => v.map(s => s.translate.trim()).filter(v => v).join(' \n')).filter(v => v).join(' \n\n');
}
export function getSentenceAllText(article: Article) {
return article.sections.map(v => v.map(s => s.text.trim()).filter(v=>v).join(' \n')).filter(v=>v).join(' \n\n');
return article.sections.map(v => v.map(s => s.text.trim()).filter(v => v).join(' \n')).filter(v => v).join(' \n\n');
}
/***
@@ -48,6 +48,8 @@ export async function getNetworkTranslate(
const translate = async (sentence: Sentence) => {
try {
let r = await translator.translate(sentence.text, 'en', 'zh-CN')
console.log(r)
if (r) {
const cb = () => {
sentence.translate = r.trans.paragraphs[0]

7
src/libs/qs.ts Normal file
View File

@@ -0,0 +1,7 @@
export default {
stringify: (params: Record<string, any>): string => {
return Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
}
}

View File

@@ -0,0 +1 @@
复制这个库是因为他引入了franc-min这个包太大了50多k我用不到

145
src/libs/translate/baidu.ts Normal file
View File

@@ -0,0 +1,145 @@
import {
Language,
Translator,
TranslateError,
TranslateQueryResult
} from "./translator";
import md5 from "md5";
import qs from "../qs";
const langMap: [Language, string][] = [
["auto", "auto"],
["zh-CN", "zh"],
["en", "en"],
["yue", "yue"],
["wyw", "wyw"],
["ja", "jp"],
["ko", "kor"],
["fr", "fra"],
["es", "spa"],
["th", "th"],
["ar", "ara"],
["ru", "ru"],
["pt", "pt"],
["de", "de"],
["it", "it"],
["el", "el"],
["nl", "nl"],
["pl", "pl"],
["bg", "bul"],
["et", "est"],
["da", "dan"],
["fi", "fin"],
["cs", "cs"],
["ro", "rom"],
["sl", "slo"],
["sv", "swe"],
["hu", "hu"],
["zh-TW", "cht"],
["vi", "vie"]
];
export interface BaiduConfig {
placeholder?: string;
appid: string;
key: string;
}
export class Baidu extends Translator<BaiduConfig> {
readonly name = "baidu";
readonly endpoint = "https://api.fanyi.baidu.com/api/trans/vip/translate";
protected async query(
text: string,
from: Language,
to: Language,
config: BaiduConfig
): Promise<TranslateQueryResult> {
type BaiduTranslateError = {
error_code: "54001" | string;
error_msg: "Invalid Sign" | string;
};
type BaiduTranslateResult = {
from: string;
to: string;
trans_result: Array<{
dst: string;
src: string;
}>;
};
const salt = Date.now();
const {endpoint} = this;
const {appid, key} = config;
const res = await this.request<BaiduTranslateResult | BaiduTranslateError>(
endpoint,
{
params: {
from: Baidu.langMap.get(from),
to: Baidu.langMap.get(to),
q: text,
salt,
appid,
sign: md5(appid + text + salt + key)
}
}
).catch(() => {
throw new TranslateError("NETWORK_ERROR");
});
const {data} = res;
if ((data as BaiduTranslateError).error_code) {
console.error(
new Error("[Baidu service]" + (data as BaiduTranslateError).error_msg)
);
throw new TranslateError("API_SERVER_ERROR");
}
const {
trans_result: transResult,
from: langDetected
} = data as BaiduTranslateResult;
const transParagraphs = transResult.map(({dst}) => dst);
const detectedFrom = Baidu.langMapReverse.get(langDetected) as Language;
return {
text,
from: detectedFrom,
to,
origin: {
paragraphs: transResult.map(({src}) => src),
tts: await this.textToSpeech(text, detectedFrom)
},
trans: {
paragraphs: transParagraphs,
tts: await this.textToSpeech(transParagraphs.join(" "), to)
}
};
}
/** Translator lang to custom lang */
private static readonly langMap = new Map(langMap);
/** Custom lang to translator lang */
private static readonly langMapReverse = new Map(
langMap.map(([translatorLang, lang]) => [lang, translatorLang])
);
getSupportLanguages(): Language[] {
return [...Baidu.langMap.keys()];
}
async textToSpeech(text: string, lang: Language): Promise<string> {
return `https://fanyi.baidu.com/gettts?${qs.stringify({
lan: Baidu.langMap.get(lang !== "auto" ? lang : "zh-CN") || "zh",
text,
spd: 5,
})}`;
}
}
export default Baidu;

View File

@@ -0,0 +1,2 @@
export * from "./languages";
export * from "./locales";

View File

@@ -0,0 +1,123 @@
// eslint-disable-next-line @typescript-eslint/no-use-before-define
export type Language = (typeof languages)[number];
export const languages = [
"af",
"am",
"ar",
"auto",
"az",
"be",
"bg",
"bn",
"bs",
"ca",
"ceb",
"co",
"cs",
"cy",
"da",
"de",
"el",
"en",
"eo",
"es",
"et",
"eu",
"fa",
"fi",
"fil",
"fj",
"fr",
"fy",
"ga",
"gd",
"gl",
"gu",
"ha",
"haw",
"he",
"hi",
"hmn",
"hr",
"ht",
"hu",
"hy",
"id",
"ig",
"is",
"it",
"ja",
"jw",
"ka",
"kk",
"km",
"kn",
"ko",
"ku",
"ky",
"la",
"lb",
"lo",
"lt",
"lv",
"mg",
"mi",
"mk",
"ml",
"mn",
"mr",
"ms",
"mt",
"mww",
"my",
"ne",
"nl",
"no",
"ny",
"otq",
"pa",
"pl",
"ps",
"pt",
"ro",
"ru",
"sd",
"si",
"sk",
"sl",
"sm",
"sn",
"so",
"sq",
"sr",
"sr-Cyrl",
"sr-Latn",
"st",
"su",
"sv",
"sw",
"ta",
"te",
"tg",
"th",
"tlh",
"tlh-Qaak",
"to",
"tr",
"ty",
"ug",
"uk",
"ur",
"uz",
"vi",
"wyw",
"xh",
"yi",
"yo",
"yua",
"yue",
"zh-CN",
"zh-TW",
"zu"
] as const;

View File

@@ -0,0 +1,3 @@
import { Language } from "./languages";
export type Locale = { [key in Language]: string };

View File

@@ -0,0 +1,3 @@
export * from "../languages";
export * from "./type";
export * from "./translator";

View File

@@ -0,0 +1,100 @@
import {
Languages,
TranslatorEnv,
TranslatorInit,
TranslateResult,
TranslateQueryResult
} from "./type";
import {Language} from "../languages";
import Axios, {AxiosInstance, AxiosRequestConfig, AxiosPromise} from "axios";
export abstract class Translator<Config extends {} = {}> {
axios: AxiosInstance;
protected readonly env: TranslatorEnv;
/**
* 自定义选项
*/
config: Config;
/**
* 翻译源标识符
*/
abstract readonly name: string;
/**
* 可选的axios实例
*/
constructor(init: TranslatorInit<Config> = {}) {
this.env = init.env || "node";
this.axios = init.axios || Axios;
this.config = init.config || ({} as Config);
}
/**
* 获取翻译器所支持的语言列表: 语言标识符数组
*/
abstract getSupportLanguages(): Languages;
/**
* 下游应用调用的接口
*/
async translate(
text: string,
from: Language,
to: Language,
config = {} as Config
): Promise<TranslateResult> {
const queryResult = await this.query(text, from, to, {
...this.config,
...config
});
return {
...queryResult,
engine: this.name
};
}
/**
* 更新 token 的方法
*/
updateToken?(): Promise<void>;
/**
* 翻译源需要实现的方法
*/
protected abstract query(
text: string,
from: Language,
to: Language,
config: Config
): Promise<TranslateQueryResult>;
protected request<R = {}>(
url: string,
config?: AxiosRequestConfig
): AxiosPromise<R> {
return this.axios(url, config);
}
/**
* 如果翻译源提供了单独的检测语言的功能,请实现此接口
*/
async detect(text: string): Promise<Language> {
return
}
/**
* 文本转换为语音
* @returns {Promise<string|null>} 语言文件地址
*/
textToSpeech(
text: string,
lang: Language,
meta?: any // eslint-disable-line @typescript-eslint/no-explicit-any
): Promise<string | null> {
return Promise.resolve(null);
}
}

View File

@@ -0,0 +1,45 @@
import {Language} from "../languages";
import {AxiosInstance} from "axios";
export type Languages = Array<Language>;
export type TranslatorEnv = "node" | "ext";
export interface TranslatorInit<Config extends {}> {
env?: TranslatorEnv;
axios?: AxiosInstance;
config?: Config;
}
export type TranslateErrorType =
| "NETWORK_ERROR"
| "NETWORK_TIMEOUT"
| "API_SERVER_ERROR"
| "UNSUPPORTED_LANG"
| "UNKNOWN";
export class TranslateError extends Error {
constructor(message: TranslateErrorType) {
super(message);
}
}
/** 统一的查询结果的数据结构 */
export interface TranslateResult {
engine: string;
text: string;
from: Language;
to: Language;
/** 原文 */
origin: {
paragraphs: string[];
tts?: string;
};
/** 译文 */
trans: {
paragraphs: string[];
tts?: string;
};
}
export type TranslateQueryResult = Omit<TranslateResult, "engine">;

View File

@@ -6,17 +6,17 @@ import EditAbleText from "@/pages/pc/components/EditAbleText.vue";
import {getNetworkTranslate, getSentenceAllText, getSentenceAllTranslateText} from "@/hooks/translate.ts";
import {genArticleSectionData, splitCNArticle2, splitEnArticle2, usePlaySentenceAudio} from "@/hooks/article.ts";
import {_nextTick, _parseLRC, cloneDeep, last} from "@/utils";
import {watch} from "vue";
import {defineAsyncComponent, watch} from "vue";
import Empty from "@/components/Empty.vue";
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
import * as Comparison from "string-comparison"
import BaseIcon from "@/components/BaseIcon.vue";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import {getDefaultArticle} from "@/types/func.ts";
import copy from "copy-to-clipboard";
import {Option, Select} from "@/pages/pc/components/base/select";
import Tooltip from "@/pages/pc/components/base/Tooltip.vue";
import InputNumber from "@/pages/pc/components/base/InputNumber.vue";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
interface IProps {
article?: Article,
@@ -38,8 +38,8 @@ let progress = $ref(0)
let failCount = $ref(0)
let textareaRef = $ref<HTMLTextAreaElement>()
const TranslateEngineOptions = [
{value: 'baidu', label: '百度'},
{value: 'youdao', label: '有道'},
{value: 'baidu', label: '百度'},
]
let editArticle = $ref<Article>(getDefaultArticle())
@@ -494,11 +494,11 @@ function setStartTime(val: Sentence, i: number, j: number) {
<span v-if="editSentence.audioPosition?.[1] !== -1"> - {{ editSentence.audioPosition?.[1] }}s</span>
<span v-else> - 结束</span>
</div>
<BaseIcon2
<BaseIcon
title="试听"
@click="playSentenceAudio(editSentence,sentenceAudioRef,editArticle)">
<IconHugeiconsPlay/>
</BaseIcon2>
</BaseIcon>
</div>
</div>
<div class="flex flex-col gap-2">
@@ -507,18 +507,18 @@ function setStartTime(val: Sentence, i: number, j: number) {
<div class="flex justify-between flex-1">
<div class="flex items-center gap-2">
<InputNumber v-model="editSentence.audioPosition[0]" :precision="2" :step="0.1"/>
<BaseIcon2
<BaseIcon
@click="jumpAudio(editSentence.audioPosition[0])"
title="跳转"
>
<IconIcSharpMyLocation/>
</BaseIcon2>
<BaseIcon2
</BaseIcon>
<BaseIcon
@click="setPreEndTimeToCurrentStartTime"
title="使用前一句的结束时间"
>
<IconTwemojiEndArrow/>
</BaseIcon2>
</BaseIcon>
</div>
<BaseButton @click="recordStart">记录</BaseButton>
</div>

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import {Article} from "@/types/types.ts";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import {useDisableEventListener} from "@/hooks/event.ts";
import EditArticle from "@/pages/pc/article/components/EditArticle.vue";
import {getDefaultArticle} from "@/types/func.ts";
import {defineAsyncComponent} from "vue";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
interface IProps {
article?: Article

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import {onMounted, watch} from "vue";
import {defineAsyncComponent, onMounted, watch} from "vue";
import {useSettingStore} from "@/stores/setting.ts";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
let settingStore = useSettingStore()
let show = $ref(false)

View File

@@ -158,9 +158,9 @@ async function cancel() {
>
<Tooltip title="关闭">
<IconIonCloseOutline @click="close"
v-if="showClose"
class="close hvr-grow cursor-pointer"
width="24" color="#929596"/>
v-if="showClose"
class="close hvr-grow cursor-pointer"
width="24" color="#929596"/>
</Tooltip>
<div class="modal-header" v-if="header">
<div class="title">{{ props.title }}</div>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import {useBaseStore} from "@/stores/base.ts";
import BaseButton from "@/components/BaseButton.vue";
import {ShortcutKey, Statistics} from "@/types/types.ts";
@@ -8,7 +7,8 @@ import {useSettingStore} from "@/stores/setting.ts";
import {usePracticeStore} from "@/stores/practice.ts";
import dayjs from "dayjs";
import isBetween from "dayjs/plugin/isBetween";
import {watch} from "vue";
import {defineAsyncComponent, watch} from "vue";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
dayjs.extend(isBetween);

View File

@@ -2,11 +2,10 @@
import {useBaseStore} from "@/stores/base.ts";
import {useRouter} from "vue-router";
import BaseIcon from "@/components/BaseIcon.vue";
import Dialog from "@/pages/pc/components/dialog/Dialog.vue";
import {_getAccomplishDate, _getAccomplishDays, _getDictDataByUrl, useNav} from "@/utils";
import BasePage from "@/pages/pc/components/BasePage.vue";
import {DictResource} from "@/types/types.ts";
import {onMounted, watch} from "vue";
import {defineAsyncComponent, onMounted, watch} from "vue";
import {getCurrentStudyWord} from "@/hooks/dict.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import Book from "@/pages/pc/components/Book.vue";
@@ -18,6 +17,9 @@ import {getDefaultDict} from "@/types/func.ts";
import Slider from "@/pages/pc/components/base/Slider.vue";
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
const Dialog = defineAsyncComponent(() => import('@/pages/pc/components/dialog/Dialog.vue'))
const store = useBaseStore()
const router = useRouter()
const {nav} = useNav()
@@ -136,8 +138,8 @@ const progressTextRight = $computed(() => {
<span @click="goDictDetail(store.sdict)"
class="text-lg font-bold cursor-pointer">{{ store.sdict.name || '请选择词典开始学习' }}</span>
<BaseIcon title="切换词典"
class="ml-4"
@click="router.push('/dict-list')"
class="ml-4"
@click="router.push('/dict-list')"
>
<IconGgArrowsExchange v-if="store.sdict.name"/>

View File

@@ -1,6 +1,5 @@
import * as VueRouter from 'vue-router'
import {RouteRecordRaw} from 'vue-router'
import Test from "@/pages/test/test.vue";
import {useRuntimeStore} from "@/stores/runtime.ts";
import WordHomePage from "@/pages/pc/word/WordHomePage.vue";
import PC from "@/pages/pc/index.vue";
@@ -10,8 +9,8 @@ import DictDetail from "@/pages/pc/word/DictDetail.vue";
import StudyWord from "@/pages/pc/word/StudyWord.vue";
import BookDetail from "@/pages/pc/article/BookDetail.vue";
import DictList from "@/pages/pc/word/DictList.vue";
import Setting from "@/pages/pc/Setting.vue";
import BookList from "@/pages/pc/article/BookList.vue";
import Setting from "@/pages/pc/Setting.vue";
export const routes: RouteRecordRaw[] = [
{
@@ -27,15 +26,15 @@ export const routes: RouteRecordRaw[] = [
{path: 'article', component: ArticleHomePage},
{path: 'study-article', component: StudyArticle},
{path: 'edit-article', component: () => import("@/pages/pc/article/EditArticlePage.vue")},
{path: 'batch-edit-article', component: () => import("@/pages/pc/article/BatchEditArticlePage.vue")},
{path: 'book-detail', component: BookDetail},
{path: 'book-list', component: BookList},
{path: 'edit-article', component: () => import("@/pages/pc/article/EditArticlePage.vue")},
{path: 'batch-edit-article', component: () => import("@/pages/pc/article/BatchEditArticlePage.vue")},
{path: 'setting', component: Setting},
]
},
{path: '/test', component: Test},
{path: '/test', component: () => import("@/pages/test/test.vue")},
{path: '/:pathMatch(.*)*', redirect: '/word'},
]

View File

@@ -2,6 +2,7 @@ import {defineStore} from "pinia"
import {checkAndUpgradeSaveSetting, cloneDeep} from "@/utils";
import {DefaultShortcutKeyMap} from "@/types/types.ts";
import {SAVE_SETTING_KEY} from "@/utils/const.ts";
import {get} from "idb-keyval";
export interface SettingState {
showToolbar: boolean,
@@ -97,8 +98,11 @@ export const useSettingStore = defineStore('setting', {
this.$patch(obj)
},
init() {
return new Promise(resolve => {
return new Promise(async resolve => {
let configStr = localStorage.getItem(SAVE_SETTING_KEY.key)
if (!configStr) {
configStr = await get(SAVE_SETTING_KEY.key)
}
let data = checkAndUpgradeSaveSetting(configStr)
this.setState(data)
this.load = true

View File

@@ -11,6 +11,7 @@ import {nextTick} from "vue";
import {dictionaryResources, enArticle} from "@/assets/dictionary.ts";
import Toast from '@/pages/pc/components/base/toast/Toast.ts'
import {getDefaultArticle, getDefaultDict, getDefaultWord} from "@/types/func.ts";
import {set} from "idb-keyval";
export function no() {
Toast.warning('未现实')
@@ -50,7 +51,8 @@ export function checkAndUpgradeSaveDict(val: any) {
return defaultState
} else {
if (version === 3) {
localStorage.setItem('type-word-dict-v3',JSON.stringify(state))
localStorage.setItem('type-word-dict-v3', JSON.stringify(state))
set('type-word-dict-v3', JSON.stringify(state))
let studyDictId = ''
if (state.current.index >= 0) {
@@ -114,15 +116,15 @@ export function checkAndUpgradeSaveDict(val: any) {
delete v.id
delete v.name
if (currentType === 'collect') {
if (v.words.length){
if (currentDictId === studyDictId) defaultState.word.studyIndex = 0
checkRiskKey(defaultState.word.bookList[0], cloneDeep(v))
defaultState.word.bookList[0].length = v.words.length
if (v.words.length) {
if (currentDictId === studyDictId) defaultState.word.studyIndex = 0
checkRiskKey(defaultState.word.bookList[0], cloneDeep(v))
defaultState.word.bookList[0].length = v.words.length
}
if (v.articles.length){
if (currentDictId === studyDictId) defaultState.article.studyIndex = 0
checkRiskKey(defaultState.article.bookList[0], cloneDeep(v))
defaultState.article.bookList[0].length = v.articles.length
if (v.articles.length) {
if (currentDictId === studyDictId) defaultState.article.studyIndex = 0
checkRiskKey(defaultState.article.bookList[0], cloneDeep(v))
defaultState.article.bookList[0].length = v.articles.length
}
}
if (currentType === 'simple' || currentType === 'skip') {