feat: 翻译工具改版为有道风格双栏界面(词典释义/发音/逐句对照)
This commit is contained in:
parent
3a2423c430
commit
d52e85a107
@ -9,6 +9,7 @@
|
||||
- 暗黑 / 浅色主题切换,选择持久化,刷新不丢失
|
||||
- 每个工具独立路由、独立本地历史记录(localStorage)
|
||||
- 所有文件上传支持点击选择 + 拖拽上传,图片自动缩略图回显;图片压缩支持批量、按质量/限制大小、等比缩放与 ZIP 批量下载
|
||||
- 翻译工具参考有道翻译的交互:中/英/韩/日互译、双栏对照、单词释义(拼音 + 词性释义 + 例句)、真人发音朗读、逐句对照与来源标注
|
||||
- 通用组件:拖拽上传、复制按钮、下载按钮、错误提示、历史记录面板
|
||||
- 全部能力由浏览器原生 API、WebAssembly 与 npm 前端库实现,无任何 mock 后端接口、无 CDN 脚本引入
|
||||
|
||||
@ -24,7 +25,7 @@
|
||||
| 样式 | UnoCSS 原子类 + SCSS(使用 TDesign 设计令牌变量) |
|
||||
| 路由 | Vue Router 4(History 模式) |
|
||||
|
||||
工具依赖:`qrcode`、`jsqr`、`cropperjs`、`browser-image-compression`、`tesseract.js`、`cron-parser`、`spark-md5`、`jszip`(全部通过 npm 安装)。
|
||||
工具依赖:`qrcode`、`jsqr`、`cropperjs`、`browser-image-compression`、`tesseract.js`、`cron-parser`、`spark-md5`、`jszip`、`pinyin-pro`(全部通过 npm 安装)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@ -109,7 +110,7 @@ location / {
|
||||
|
||||
## 隐私说明
|
||||
|
||||
所有计算均在浏览器本地完成:图片压缩/裁剪/OCR 使用本地 Canvas/WASM,哈希使用本地 Web Crypto 与 spark-md5,历史记录仅存于本机 localStorage。项目不包含任何后端接口调用,用户文件永远不会离开浏览器。唯一例外是「翻译」工具:为调用第三方免费翻译接口(有道 / 腾讯),待翻译文本会发送至对应接口,页面已做提示。
|
||||
所有计算均在浏览器本地完成:图片压缩/裁剪/OCR 使用本地 Canvas/WASM,哈希使用本地 Web Crypto 与 spark-md5,历史记录仅存于本机 localStorage。项目不包含任何后端接口调用,用户文件永远不会离开浏览器。唯一例外是「翻译」工具:待翻译文本会发送至第三方免费翻译接口(有道 / 腾讯),单词释义与发音会请求 Free Dictionary API / Datamuse 与有道词典语音接口,页面已做提示。
|
||||
|
||||
## OCR 语言包说明
|
||||
|
||||
|
||||
@ -101,7 +101,7 @@ export interface ToolItem {
|
||||
| 身份证 | `views/idcard` | 纯 TS 实现 GB 11643-1999 校验 | 内置全部省级行政区代码表;校验位权重表勿改动 |
|
||||
| 地址解析 | `views/address-parse` | 内置省份表 + 正则规则 | 纯规则近似解析,非地理数据库;直辖市无地级市层级需特殊处理 |
|
||||
| URL 解析 | `views/url-parse` | 原生 `URL` / `URLSearchParams` | 无协议输入自动补 `https://`;构造器用 `searchParams.append` 自动编码 |
|
||||
| 翻译 | `views/translate` | 有道演示接口 + 腾讯翻译君(均免 key、支持 CORS) | **唯一需要联网的工具**:浏览器直接 POST 第三方接口,不经过自建后端;语言码映射 zh-CN→zh-CHS(有道)/zh(腾讯);「自动检测」用脚本特征兜底判定(中文/日文/韩文/英文);两个接口依次尝试降级;免费接口有频率限制,页面已提示 |
|
||||
| 翻译 | `views/translate` | 有道演示接口 + 腾讯翻译君(翻译);`dict.youdao.com/dictvoice`(发音);Free Dictionary API → Datamuse(词典);`pinyin-pro`(拼音) | **唯一需要联网的工具**,界面参考有道翻译:语言对/场景下拉、双栏对照、字符计数、单词释义(拼音+词性释义+例句)、发音朗读、逐句对照、反馈按钮、来源标注(有道AI翻译标准模型/腾讯翻译君)。翻译接口:语言码映射 zh-CN→zh-CHS(有道)/zh(腾讯);「自动检测」用脚本特征兜底判定(中文/日文/韩文/英文);两个接口依次尝试降级;免费接口有频率限制(411),页面已提示。**发音**:`dict.youdao.com/dictvoice?audio=<文本>&type=<2=美音|0=中文/韩文>` 免签,日文不支持(500),日文译文改用有道接口返回的签名 `tSpeakUrl`(仅有道成功时可用,否则隐藏按钮)。**词典**:英文单词先查 `api.dictionaryapi.dev`(含音标/例句,部分地区网络不稳),超时降级 `api.datamuse.com/words?sp=<词>&md=d`(defs 为 "词性\t释义" 文本,需 split)。中文源词拼音由 pinyin-pro 本地生成(`toneType: 'symbol'`)。**已知限制**:有道演示接口不支持 en→ja(errorCode 102,自动降级腾讯);「逐句对照」按句拆分后逐句翻译(并发 3、最多 15 句);「AI 润色」仅保留入口(需官方 Key,点击展示说明) |
|
||||
| OCR | `views/ocr` | `tesseract.js`(WASM) | 见第 5 节离线说明 |
|
||||
|
||||
## 5. OCR 特殊说明(重要)
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
"jsqr": "^1.4.0",
|
||||
"jszip": "^3.10.1",
|
||||
"pinia": "^3.0.1",
|
||||
"pinyin-pro": "^3.29.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"spark-md5": "^3.0.2",
|
||||
"tdesign-icons-vue-next": "^0.3.4",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -26,6 +26,9 @@ importers:
|
||||
pinia:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.4(typescript@5.7.3)(vue@3.5.41(typescript@5.7.3))
|
||||
pinyin-pro:
|
||||
specifier: ^3.29.3
|
||||
version: 3.29.3
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@ -1260,6 +1263,9 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
pinyin-pro@3.29.3:
|
||||
resolution: {integrity: sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
@ -2560,6 +2566,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.7.3
|
||||
|
||||
pinyin-pro@3.29.3: {}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
|
||||
@ -1,41 +1,170 @@
|
||||
<template>
|
||||
<div class="page-wrap">
|
||||
<h2 class="page-title">翻译</h2>
|
||||
<p class="page-desc">中文、英文、韩文、日文互译,调用第三方免费翻译接口,无需 API Key。</p>
|
||||
<p class="page-desc">
|
||||
参考有道翻译的交互:中/英/韩/日互译,支持单词释义、真人发音与逐句对照,译文来自第三方免费接口。
|
||||
</p>
|
||||
|
||||
<t-card class="tool-section" title="翻译">
|
||||
<div class="lang-row">
|
||||
<t-select v-model="fromLang" :options="sourceLangOptions" class="lang-select" />
|
||||
<t-button variant="outline" shape="circle" title="交换语言" @click="swapLangs">
|
||||
<t-card class="tool-section translate-panel" title="翻译">
|
||||
<!-- 顶部工具条:语言对 + 术语库/场景(参考有道) -->
|
||||
<div class="topbar">
|
||||
<div class="langpair">
|
||||
<t-select v-model="fromLang" :options="sourceLangOptions" class="lang-select" @change="onLangChange" />
|
||||
<t-button variant="text" shape="circle" title="交换语言" @click="swapLangs">
|
||||
<template #icon><SwapIcon /></template>
|
||||
</t-button>
|
||||
<t-select v-model="toLang" :options="targetLangOptions" class="lang-select" />
|
||||
<t-select v-model="toLang" :options="targetLangOptions" class="lang-select" @change="onLangChange" />
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<t-select
|
||||
v-model="termLib"
|
||||
:options="termLibOptions"
|
||||
class="term-select"
|
||||
disabled
|
||||
title="术语库需有道官方 API Key,演示接口暂不支持"
|
||||
/>
|
||||
<t-select v-model="scene" :options="sceneOptions" class="term-select" title="翻译场景(仅用于结果标注)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-grid">
|
||||
<div class="text-col">
|
||||
<!-- 双栏:原文 | 译文 -->
|
||||
<div class="columns">
|
||||
<!-- 左:原文 -->
|
||||
<div class="col">
|
||||
<div class="col-head">
|
||||
<span class="col-title">原文</span>
|
||||
<span class="col-actions">
|
||||
<t-button
|
||||
variant="text"
|
||||
size="small"
|
||||
shape="square"
|
||||
:disabled="!sourceTtsSupported"
|
||||
title="朗读原文"
|
||||
@click="playSourceTts"
|
||||
>
|
||||
<template #icon><SoundIcon /></template>
|
||||
</t-button>
|
||||
<t-button variant="text" size="small" shape="square" :disabled="!input" title="复制原文" @click="copyText(input)">
|
||||
<template #icon><CopyIcon /></template>
|
||||
</t-button>
|
||||
</span>
|
||||
</div>
|
||||
<t-textarea
|
||||
v-model="input"
|
||||
placeholder="输入要翻译的文本(Ctrl + Enter 快速翻译)"
|
||||
:maxlength="5000"
|
||||
:autosize="{ minRows: 8, maxRows: 16 }"
|
||||
placeholder="请输入要翻译的文本,Ctrl + Enter 快速翻译"
|
||||
@keydown.ctrl.enter.prevent="translate"
|
||||
@input="onInput"
|
||||
/>
|
||||
<p class="char-count">{{ input.length }}/5000</p>
|
||||
|
||||
<!-- 词典释义:单词/短语时展示(拼音 + 英文释义) -->
|
||||
<div v-if="dictWord || dictEntry" class="dict">
|
||||
<div class="dict-head">
|
||||
<span class="dict-word">{{ dictWord }}</span>
|
||||
<span v-if="pinyinText" class="dict-pinyin">/{{ pinyinText }}/</span>
|
||||
</div>
|
||||
<div v-if="dictEntry" class="dict-gloss">
|
||||
<div class="gloss-row">
|
||||
<span class="gloss-word">{{ dictEntry.word }}</span>
|
||||
<span v-if="dictEntry.phonetic" class="dict-phonetic">{{ dictEntry.phonetic }}</span>
|
||||
<t-button variant="text" size="small" shape="square" title="朗读发音" @click="playDictAudio">
|
||||
<template #icon><SoundIcon /></template>
|
||||
</t-button>
|
||||
</div>
|
||||
<div v-for="m in dictEntry.meanings.slice(0, 3)" :key="m.partOfSpeech" class="dict-meaning">
|
||||
<span class="dict-pos">{{ m.partOfSpeech }}.</span>
|
||||
<span class="dict-defs">{{ m.definitions.slice(0, 2).map((d) => d.definition).join(';') }}</span>
|
||||
</div>
|
||||
<p v-if="dictEntry.meanings[0]?.definitions[0]?.example" class="dict-example">
|
||||
例:{{ dictEntry.meanings[0].definitions[0].example }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="dictEntry" class="dict-src">释义来自 {{ dictEntry.source }}(英文释义)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右:译文 -->
|
||||
<div class="col">
|
||||
<div class="col-head">
|
||||
<span class="col-title">译文</span>
|
||||
<span class="col-actions">
|
||||
<t-button
|
||||
variant="text"
|
||||
size="small"
|
||||
shape="square"
|
||||
:disabled="!targetTtsSupported"
|
||||
title="朗读译文"
|
||||
@click="playTargetTts"
|
||||
>
|
||||
<template #icon><SoundIcon /></template>
|
||||
</t-button>
|
||||
<t-button variant="text" size="small" shape="square" :disabled="!output" title="复制译文" @click="copyText(output)">
|
||||
<template #icon><CopyIcon /></template>
|
||||
</t-button>
|
||||
<t-button variant="text" size="small" :disabled="!output" title="逐句对照" @click="toggleSentenceView">
|
||||
<template #icon><ViewListIcon /></template>
|
||||
{{ showSentences ? '退出对照' : '逐句对照' }}
|
||||
</t-button>
|
||||
<t-button variant="text" size="small" theme="primary" title="AI 润色" @click="onPolish">
|
||||
<template #icon><BrushIcon /></template>
|
||||
AI润色
|
||||
</t-button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 逐句对照模式 -->
|
||||
<div v-if="showSentences" class="sentence-list">
|
||||
<div v-for="(p, i) in sentencePairs" :key="i" class="sentence-row">
|
||||
<div class="s-src">{{ p.src }}</div>
|
||||
<div class="s-dst">{{ p.dst }}</div>
|
||||
</div>
|
||||
<p class="s-hint">逐句对照由每句单独调用翻译接口生成,与整段译文可能略有差异</p>
|
||||
</div>
|
||||
<t-textarea
|
||||
v-else
|
||||
v-model="output"
|
||||
readonly
|
||||
placeholder="译文将显示在这里"
|
||||
:autosize="{ minRows: 8, maxRows: 16 }"
|
||||
/>
|
||||
|
||||
<!-- 反馈 + 来源标注(参考有道) -->
|
||||
<div v-if="output" class="col-foot">
|
||||
<div class="feedback">
|
||||
<t-button
|
||||
:variant="feedback === 'up' ? 'outline' : 'text'"
|
||||
size="small"
|
||||
shape="square"
|
||||
title="有帮助"
|
||||
@click="sendFeedback('up')"
|
||||
>
|
||||
<template #icon><ThumbUpIcon /></template>
|
||||
</t-button>
|
||||
<t-button
|
||||
:variant="feedback === 'down' ? 'outline' : 'text'"
|
||||
size="small"
|
||||
shape="square"
|
||||
title="没帮助"
|
||||
@click="sendFeedback('down')"
|
||||
>
|
||||
<template #icon><ThumbDownIcon /></template>
|
||||
</t-button>
|
||||
</div>
|
||||
<p v-if="providerLabel" class="attr">以上翻译结果来自{{ providerLabel }} · {{ scene }}</p>
|
||||
</div>
|
||||
<div class="text-col">
|
||||
<t-textarea v-model="output" readonly placeholder="译文将显示在这里" :autosize="{ minRows: 8, maxRows: 16 }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<t-button theme="primary" :loading="busy" @click="translate">翻译</t-button>
|
||||
<t-button variant="outline" @click="clearAll">清空</t-button>
|
||||
<CopyButton v-if="output" :text="output" label="复制译文" />
|
||||
</div>
|
||||
|
||||
<t-alert v-if="infoTip" class="notice" theme="info" :message="infoTip" @close="infoTip = ''" />
|
||||
<t-alert class="notice" theme="info">
|
||||
翻译文本将发送至第三方免费翻译接口(有道 / 腾讯),请勿粘贴敏感信息;免费接口有频率限制,失败时请稍后重试。
|
||||
译文由有道演示接口 / 腾讯翻译君提供,词典释义来自 Free Dictionary API / Datamuse,发音来自有道词典语音接口;翻译内容会发送至第三方服务器,请勿输入敏感信息。免费接口有频率限制,失败请稍后重试。
|
||||
</t-alert>
|
||||
</t-card>
|
||||
|
||||
@ -51,29 +180,43 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { SwapIcon } from 'tdesign-icons-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
import { pinyin } from 'pinyin-pro'
|
||||
import {
|
||||
SwapIcon,
|
||||
SoundIcon,
|
||||
CopyIcon,
|
||||
ThumbUpIcon,
|
||||
ThumbDownIcon,
|
||||
ViewListIcon,
|
||||
BrushIcon,
|
||||
} from 'tdesign-icons-vue-next'
|
||||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||||
import CopyButton from '@/components/common/CopyButton.vue'
|
||||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||||
import { useHistory } from '@/composables/useHistory'
|
||||
import { copyText } from '@/composables/useClipboard'
|
||||
|
||||
/** 页面统一语言代码:zh-CN / en / ja / ko,auto 表示自动检测 */
|
||||
type Lang = 'zh-CN' | 'en' | 'ja' | 'ko'
|
||||
|
||||
const sourceLangOptions = [
|
||||
{ label: '自动检测', value: 'auto' },
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: '英文', value: 'en' },
|
||||
{ label: '韩文', value: 'ko' },
|
||||
{ label: '日文', value: 'ja' },
|
||||
]
|
||||
const targetLangOptions = [
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: '英文', value: 'en' },
|
||||
{ label: '韩文', value: 'ko' },
|
||||
{ label: '日文', value: 'ja' },
|
||||
]
|
||||
interface TranslateResult {
|
||||
text: string
|
||||
provider: 'youdao' | 'tencent'
|
||||
/** 有道返回的签名 TTS 地址(日文目标语言专用,dictvoice 不支持日文) */
|
||||
speakUrl?: string
|
||||
}
|
||||
|
||||
/** 词典词条(两个来源归一化后的结构) */
|
||||
interface DictEntry {
|
||||
word: string
|
||||
phonetic?: string
|
||||
audio?: string
|
||||
meanings: { partOfSpeech: string; definitions: { definition: string; example?: string }[] }[]
|
||||
/** 释义数据来源,用于页面标注 */
|
||||
source: 'Free Dictionary API' | 'Datamuse'
|
||||
}
|
||||
|
||||
const LANG_NAMES: Record<Lang, string> = { 'zh-CN': '中文', en: '英语', ja: '日语', ko: '韩语' }
|
||||
|
||||
const fromLang = ref('auto')
|
||||
const toLang = ref('zh-CN')
|
||||
@ -81,9 +224,67 @@ const input = ref('')
|
||||
const output = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const infoTip = ref('')
|
||||
|
||||
/** 自动检测出的源语言(仅 fromLang === 'auto' 时展示为「检测到中文」) */
|
||||
const detectedLang = ref<Lang | null>(null)
|
||||
/** 本次翻译实际使用的接口,用于来源标注与 TTS 策略 */
|
||||
const provider = ref<'youdao' | 'tencent' | null>(null)
|
||||
const speakUrl = ref('')
|
||||
|
||||
/** 词典释义相关状态 */
|
||||
const dictWord = ref('')
|
||||
const pinyinText = ref('')
|
||||
const dictEntry = ref<DictEntry | null>(null)
|
||||
|
||||
/** 逐句对照 */
|
||||
const showSentences = ref(false)
|
||||
const sentencePairs = ref<{ src: string; dst: string }[]>([])
|
||||
|
||||
/** 反馈(仅本地状态,不持久化) */
|
||||
const feedback = ref<'up' | 'down' | null>(null)
|
||||
|
||||
/** 术语库/场景下拉:参考有道布局;术语库需官方 Key,仅作展示 */
|
||||
const termLib = ref('暂无术语库')
|
||||
const termLibOptions = [{ label: '暂无术语库', value: '暂无术语库' }]
|
||||
const scene = ref('通用场景')
|
||||
const sceneOptions = ['通用场景', '医学领域', '信息技术', '商务外贸'].map((s) => ({ label: s, value: s }))
|
||||
|
||||
const history = useHistory('translate')
|
||||
|
||||
/** 源语言下拉:自动检测命中后标签变为「检测到中文」 */
|
||||
const sourceLangOptions = computed(() => [
|
||||
{ label: detectedLang.value ? `检测到${LANG_NAMES[detectedLang.value]}` : '自动检测', value: 'auto' },
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: '英语', value: 'en' },
|
||||
{ label: '韩语', value: 'ko' },
|
||||
{ label: '日语', value: 'ja' },
|
||||
])
|
||||
const targetLangOptions = [
|
||||
{ label: '中文', value: 'zh-CN' },
|
||||
{ label: '英语', value: 'en' },
|
||||
{ label: '韩语', value: 'ko' },
|
||||
{ label: '日语', value: 'ja' },
|
||||
]
|
||||
|
||||
/** 当前生效的源语言(auto 时取检测结果) */
|
||||
const resolvedFrom = computed<Lang | null>(() =>
|
||||
fromLang.value === 'auto' ? detectedLang.value : (fromLang.value as Lang),
|
||||
)
|
||||
|
||||
const providerLabel = computed(() => (provider.value === 'youdao' ? '有道AI翻译标准模型' : '腾讯翻译君'))
|
||||
|
||||
/** dictvoice 不支持日文,日文源语隐藏朗读按钮 */
|
||||
const sourceTtsSupported = computed(() => !!input.value.trim() && !!resolvedFrom.value && resolvedFrom.value !== 'ja')
|
||||
/** 日文译文依赖有道签名 TTS;其余语言可用 dictvoice */
|
||||
const targetTtsSupported = computed(() => {
|
||||
if (!output.value) return false
|
||||
if (toLang.value === 'ja') return provider.value === 'youdao' && !!speakUrl.value
|
||||
return true
|
||||
})
|
||||
|
||||
/* ---------------- 翻译接口 ---------------- */
|
||||
|
||||
/** 按文字脚本粗略检测语言(中英韩日),供「自动检测」与降级接口使用 */
|
||||
function detectLang(text: string): Lang {
|
||||
if (/[\u4e00-\u9fff]/.test(text)) return 'zh-CN'
|
||||
@ -96,16 +297,16 @@ function detectLang(text: string): Lang {
|
||||
const YOUDAO_LANG: Record<Lang, string> = { 'zh-CN': 'zh-CHS', en: 'en', ja: 'ja', ko: 'ko' }
|
||||
|
||||
/** 翻译接口一:有道演示接口(https://aidemo.youdao.com/trans),免 key、支持 CORS */
|
||||
async function translateYoudao(text: string, from: Lang, to: Lang): Promise<string> {
|
||||
async function translateYoudao(text: string, from: Lang, to: Lang): Promise<TranslateResult> {
|
||||
const res = await fetch('https://aidemo.youdao.com/trans', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ q: text, from: YOUDAO_LANG[from], to: YOUDAO_LANG[to] }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = (await res.json()) as { errorCode?: string; translation?: string[] }
|
||||
const data = (await res.json()) as { errorCode?: string; translation?: string[]; tSpeakUrl?: string }
|
||||
if (data.errorCode !== '0' || !data.translation?.length) throw new Error('有道返回空结果')
|
||||
return data.translation.join('')
|
||||
return { text: data.translation.join(''), provider: 'youdao', speakUrl: data.tSpeakUrl }
|
||||
}
|
||||
|
||||
/** 腾讯接口语言码映射 */
|
||||
@ -114,7 +315,7 @@ const TENCENT_LANG: Record<Lang, string> = { 'zh-CN': 'zh', en: 'en', ja: 'ja',
|
||||
const CLIENT_KEY = `browser-chrome-110.0.0.0-windows-${crypto.randomUUID()}`
|
||||
|
||||
/** 翻译接口二:腾讯翻译君(transmart.qq.com),免 key、支持 CORS,作为有道失败时的降级 */
|
||||
async function translateTencent(text: string, from: Lang, to: Lang): Promise<string> {
|
||||
async function translateTencent(text: string, from: Lang, to: Lang): Promise<TranslateResult> {
|
||||
const res = await fetch('https://transmart.qq.com/api/imt', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -130,15 +331,15 @@ async function translateTencent(text: string, from: Lang, to: Lang): Promise<str
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = (await res.json()) as { header?: { ret_code?: string }; auto_translation?: string[] }
|
||||
if (data.header?.ret_code !== 'succ' || !data.auto_translation?.length) throw new Error('腾讯返回空结果')
|
||||
return data.auto_translation.join('')
|
||||
return { text: data.auto_translation.join(''), provider: 'tencent' }
|
||||
}
|
||||
|
||||
/** 依次尝试两个免费接口,任一成功即返回,全部失败则抛错 */
|
||||
async function requestTranslate(text: string, from: Lang, to: Lang): Promise<string> {
|
||||
async function requestTranslate(text: string, from: Lang, to: Lang): Promise<TranslateResult> {
|
||||
let lastErr: unknown = null
|
||||
for (const provider of [translateYoudao, translateTencent]) {
|
||||
for (const providerFn of [translateYoudao, translateTencent]) {
|
||||
try {
|
||||
return await provider(text, from, to)
|
||||
return await providerFn(text, from, to)
|
||||
} catch (e) {
|
||||
lastErr = e
|
||||
}
|
||||
@ -146,6 +347,152 @@ async function requestTranslate(text: string, from: Lang, to: Lang): Promise<str
|
||||
throw lastErr ?? new Error('无可用翻译接口')
|
||||
}
|
||||
|
||||
/* ---------------- 词典释义 ---------------- */
|
||||
|
||||
/** 竞态令牌:多次翻译时丢弃过期的词典查询结果 */
|
||||
let dictToken = 0
|
||||
|
||||
/** 判断是否为单个英文单词(供词典查询) */
|
||||
function isEnWord(s: string): boolean {
|
||||
return /^[a-zA-Z][a-zA-Z'’ -]{0,40}$/.test(s.trim())
|
||||
}
|
||||
|
||||
/** 查询英文单词释义:优先 Free Dictionary API(含音标/例句),失败降级 Datamuse(更稳定) */
|
||||
async function lookupDict(word: string): Promise<DictEntry | null> {
|
||||
// 来源一:Free Dictionary API(部分地区网络不稳定,超时即放弃)
|
||||
try {
|
||||
const res = await fetch(`https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`, {
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as {
|
||||
word: string
|
||||
phonetic?: string
|
||||
phonetics?: { text?: string; audio?: string }[]
|
||||
meanings: { partOfSpeech: string; definitions: { definition: string; example?: string }[] }[]
|
||||
}[]
|
||||
const first = data?.[0]
|
||||
if (first) {
|
||||
return {
|
||||
word: first.word,
|
||||
phonetic: first.phonetic,
|
||||
audio: first.phonetics?.find((p) => p.audio)?.audio,
|
||||
meanings: first.meanings.slice(0, 3).map((m) => ({
|
||||
partOfSpeech: m.partOfSpeech,
|
||||
definitions: m.definitions.slice(0, 2).map((d) => ({ definition: d.definition, example: d.example })),
|
||||
})),
|
||||
source: 'Free Dictionary API',
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 网络不可达,继续降级 */
|
||||
}
|
||||
// 来源二:Datamuse(免 key、CORS 开放、CloudFront 分发较稳定;defs 为 "词性\t释义" 文本)
|
||||
try {
|
||||
const res = await fetch(`https://api.datamuse.com/words?sp=${encodeURIComponent(word)}&md=d&max=3`, {
|
||||
signal: AbortSignal.timeout(6000),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { word: string; defs?: string[] }[]
|
||||
const hit = data?.find((d) => d.word.toLowerCase() === word.toLowerCase()) ?? data?.[0]
|
||||
if (hit?.defs?.length) {
|
||||
const meanings: DictEntry['meanings'] = []
|
||||
for (const def of hit.defs) {
|
||||
if (meanings.length >= 3) break
|
||||
const [pos = '', text = ''] = def.split('\t')
|
||||
let meaning = meanings.find((m) => m.partOfSpeech === pos)
|
||||
if (!meaning) {
|
||||
meaning = { partOfSpeech: pos, definitions: [] }
|
||||
meanings.push(meaning)
|
||||
}
|
||||
if (meaning.definitions.length < 2) meaning.definitions.push({ definition: text.trim() })
|
||||
}
|
||||
if (meanings.length) {
|
||||
return { word: hit.word, meanings, source: 'Datamuse' }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 两个来源都失败则返回 null,词典区不展示 */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译成功后刷新词典区:
|
||||
* - 中文源词用 pinyin-pro 本地生成拼音(不发请求);
|
||||
* - 源或译文为单个英文单词时,异步查询英文释义。
|
||||
*/
|
||||
async function refreshDict(from: Lang, src: string, dst: string) {
|
||||
const token = ++dictToken
|
||||
dictWord.value = ''
|
||||
pinyinText.value = ''
|
||||
dictEntry.value = null
|
||||
|
||||
if (from === 'zh-CN' && src.length <= 20 && !src.includes('\n')) {
|
||||
dictWord.value = src
|
||||
try {
|
||||
pinyinText.value = pinyin(src, { toneType: 'symbol' })
|
||||
} catch {
|
||||
pinyinText.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
let enWord = ''
|
||||
if (from === 'en' && isEnWord(src)) enWord = src.trim()
|
||||
else if (toLang.value === 'en' && isEnWord(dst)) enWord = dst.trim()
|
||||
|
||||
if (enWord) {
|
||||
const entry = await lookupDict(enWord)
|
||||
if (token === dictToken && entry) {
|
||||
dictEntry.value = entry
|
||||
if (!dictWord.value) dictWord.value = entry.word
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 语音朗读 ---------------- */
|
||||
|
||||
/**
|
||||
* 有道词典语音接口(免签、免 key),type=2 美音、type=0 支持中文/韩文;
|
||||
* 日文不支持(返回 500),改用有道翻译接口返回的签名 TTS。
|
||||
*/
|
||||
function dictVoiceUrl(text: string, lang: Lang): string {
|
||||
const type = lang === 'en' ? 2 : 0
|
||||
return `https://dict.youdao.com/dictvoice?audio=${encodeURIComponent(text)}&type=${type}`
|
||||
}
|
||||
|
||||
function playUrl(url: string) {
|
||||
const audio = new Audio(url)
|
||||
audio.play().catch(() => {
|
||||
error.value = '语音播放失败,请稍后重试'
|
||||
})
|
||||
}
|
||||
|
||||
function playSourceTts() {
|
||||
const from = resolvedFrom.value
|
||||
if (!from || !input.value.trim()) return
|
||||
playUrl(dictVoiceUrl(input.value.trim(), from))
|
||||
}
|
||||
|
||||
function playTargetTts() {
|
||||
if (!output.value) return
|
||||
if (toLang.value === 'ja') {
|
||||
if (speakUrl.value) playUrl(speakUrl.value)
|
||||
return
|
||||
}
|
||||
playUrl(dictVoiceUrl(output.value, toLang.value as Lang))
|
||||
}
|
||||
|
||||
function playDictAudio() {
|
||||
const entry = dictEntry.value
|
||||
if (!entry) return
|
||||
playUrl(entry.audio ?? dictVoiceUrl(entry.word, 'en'))
|
||||
}
|
||||
|
||||
/* ---------------- 主流程 ---------------- */
|
||||
|
||||
async function translate() {
|
||||
const text = input.value.trim()
|
||||
if (!text) {
|
||||
@ -154,17 +501,24 @@ async function translate() {
|
||||
}
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
output.value = ''
|
||||
resetResultState()
|
||||
try {
|
||||
// 「自动检测」用脚本特征兜底判断,保证两个接口都拿到明确的源语言
|
||||
const from: Lang = fromLang.value === 'auto' ? detectLang(text) : (fromLang.value as Lang)
|
||||
if (from === toLang.value) {
|
||||
detectedLang.value = fromLang.value === 'auto' ? from : null
|
||||
const to = toLang.value as Lang
|
||||
if (from === to) {
|
||||
// 自动检测结果与目标语言相同(如中文→中文),直接原文返回
|
||||
output.value = text
|
||||
refreshDict(from, text, text)
|
||||
return
|
||||
}
|
||||
output.value = await requestTranslate(text, from, toLang.value as Lang)
|
||||
const res = await requestTranslate(text, from, to)
|
||||
output.value = res.text
|
||||
provider.value = res.provider
|
||||
speakUrl.value = res.speakUrl ?? ''
|
||||
history.add(text.length > 300 ? text.slice(0, 300) : text)
|
||||
refreshDict(from, text, res.text)
|
||||
} catch {
|
||||
error.value = '翻译失败:免费接口可能暂时不可用或请求过于频繁,请稍后重试'
|
||||
} finally {
|
||||
@ -172,11 +526,99 @@ async function translate() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 逐句对照:按句拆分后逐句翻译(并发 3,最多 15 句) */
|
||||
async function toggleSentenceView() {
|
||||
if (showSentences.value) {
|
||||
showSentences.value = false
|
||||
return
|
||||
}
|
||||
const text = input.value.trim()
|
||||
if (!text || !output.value) return
|
||||
const from = resolvedFrom.value
|
||||
if (!from) return
|
||||
const to = toLang.value as Lang
|
||||
if (from === to) {
|
||||
error.value = '源语言与目标语言相同,无需逐句对照'
|
||||
return
|
||||
}
|
||||
const sentences = text.match(/[^。!?!?;;\n]+[。!?!?;;\n]*/g) ?? [text]
|
||||
if (sentences.length > 15) {
|
||||
error.value = '逐句对照最多支持 15 句,请分段后再试'
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
sentencePairs.value = []
|
||||
try {
|
||||
if (sentences.length === 1) {
|
||||
sentencePairs.value = [{ src: sentences[0], dst: output.value }]
|
||||
} else {
|
||||
const pairs: { src: string; dst: string }[] = new Array(sentences.length)
|
||||
let cursor = 0
|
||||
let failed = false
|
||||
const worker = async () => {
|
||||
while (!failed) {
|
||||
const i = cursor++
|
||||
if (i >= sentences.length) return
|
||||
try {
|
||||
const res = await requestTranslate(sentences[i], from, to)
|
||||
pairs[i] = { src: sentences[i], dst: res.text }
|
||||
} catch {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all([worker(), worker(), worker()])
|
||||
if (failed) throw new Error('partial failure')
|
||||
sentencePairs.value = pairs
|
||||
}
|
||||
showSentences.value = true
|
||||
} catch {
|
||||
error.value = '逐句对照失败:免费接口可能暂时不可用或请求过于频繁,请稍后重试'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 润色:演示接口无润色能力,保留入口并如实说明 */
|
||||
function onPolish() {
|
||||
infoTip.value = 'AI 润色需要有道官方 API Key,当前免费演示接口暂不支持该功能,已保留入口供后续接入。'
|
||||
}
|
||||
|
||||
function sendFeedback(dir: 'up' | 'down') {
|
||||
feedback.value = feedback.value === dir ? null : dir
|
||||
}
|
||||
|
||||
/** 交换语言;源为「自动检测」时目标语言补位为中文 */
|
||||
function swapLangs() {
|
||||
const t = fromLang.value
|
||||
fromLang.value = toLang.value
|
||||
toLang.value = t === 'auto' ? 'zh-CN' : t
|
||||
detectedLang.value = null
|
||||
resetResultState()
|
||||
}
|
||||
|
||||
/** 手动切换语言:清空上次结果,避免与原文不对应 */
|
||||
function onLangChange() {
|
||||
resetResultState()
|
||||
}
|
||||
|
||||
/** 输入变化:参考有道交互,清空旧译文与词典 */
|
||||
function onInput() {
|
||||
resetResultState()
|
||||
}
|
||||
|
||||
/** 清空所有结果态(不含输入框内容) */
|
||||
function resetResultState() {
|
||||
output.value = ''
|
||||
provider.value = null
|
||||
speakUrl.value = ''
|
||||
dictWord.value = ''
|
||||
pinyinText.value = ''
|
||||
dictEntry.value = null
|
||||
showSentences.value = false
|
||||
sentencePairs.value = []
|
||||
feedback.value = null
|
||||
}
|
||||
|
||||
/** 点击历史:回填原文并自动翻译 */
|
||||
@ -187,38 +629,177 @@ function onHistorySelect(text: string) {
|
||||
|
||||
function clearAll() {
|
||||
input.value = ''
|
||||
output.value = ''
|
||||
detectedLang.value = null
|
||||
error.value = ''
|
||||
resetResultState()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lang-row {
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.langpair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.lang-select {
|
||||
width: 160px;
|
||||
width: 150px;
|
||||
}
|
||||
.text-grid {
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.term-select {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.text-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.col {
|
||||
min-width: 0;
|
||||
padding: 0 14px;
|
||||
}
|
||||
.col:first-child {
|
||||
padding-left: 0;
|
||||
border-right: 1px solid var(--td-border-level-1-color);
|
||||
}
|
||||
.col:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.col-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.col-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.col-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 词典释义区 */
|
||||
.dict {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.dict-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dict-word {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.dict-pinyin {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.dict-gloss {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.gloss-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.gloss-word {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.dict-phonetic {
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.dict-meaning {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.dict-pos {
|
||||
flex-shrink: 0;
|
||||
color: var(--td-brand-color);
|
||||
}
|
||||
.dict-example {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.dict-src {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
/* 逐句对照 */
|
||||
.sentence-list {
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sentence-row {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed var(--td-border-level-1-color);
|
||||
}
|
||||
.s-src {
|
||||
margin-bottom: 2px;
|
||||
font-size: 13px;
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
.s-dst {
|
||||
font-size: 14px;
|
||||
color: var(--td-text-color-primary);
|
||||
}
|
||||
.s-hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
/* 反馈与来源标注 */
|
||||
.col-foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.attr {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--td-text-color-placeholder);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@ -228,4 +809,26 @@ function clearAll() {
|
||||
.notice {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.col {
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
.col:first-child {
|
||||
padding-left: 0;
|
||||
padding-top: 0;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--td-border-level-1-color);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
.lang-select {
|
||||
width: 132px;
|
||||
}
|
||||
.term-select {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user