feat: 翻译工具改版为有道风格双栏界面(词典释义/发音/逐句对照)

This commit is contained in:
夏犀麟 2026-08-21 16:40:50 +08:00
parent 3a2423c430
commit d52e85a107
5 changed files with 669 additions and 56 deletions

View File

@ -9,6 +9,7 @@
- 暗黑 / 浅色主题切换,选择持久化,刷新不丢失 - 暗黑 / 浅色主题切换,选择持久化,刷新不丢失
- 每个工具独立路由、独立本地历史记录localStorage - 每个工具独立路由、独立本地历史记录localStorage
- 所有文件上传支持点击选择 + 拖拽上传,图片自动缩略图回显;图片压缩支持批量、按质量/限制大小、等比缩放与 ZIP 批量下载 - 所有文件上传支持点击选择 + 拖拽上传,图片自动缩略图回显;图片压缩支持批量、按质量/限制大小、等比缩放与 ZIP 批量下载
- 翻译工具参考有道翻译的交互:中/英/韩/日互译、双栏对照、单词释义(拼音 + 词性释义 + 例句)、真人发音朗读、逐句对照与来源标注
- 通用组件:拖拽上传、复制按钮、下载按钮、错误提示、历史记录面板 - 通用组件:拖拽上传、复制按钮、下载按钮、错误提示、历史记录面板
- 全部能力由浏览器原生 API、WebAssembly 与 npm 前端库实现,无任何 mock 后端接口、无 CDN 脚本引入 - 全部能力由浏览器原生 API、WebAssembly 与 npm 前端库实现,无任何 mock 后端接口、无 CDN 脚本引入
@ -24,7 +25,7 @@
| 样式 | UnoCSS 原子类 + SCSS使用 TDesign 设计令牌变量) | | 样式 | UnoCSS 原子类 + SCSS使用 TDesign 设计令牌变量) |
| 路由 | Vue Router 4History 模式) | | 路由 | Vue Router 4History 模式) |
工具依赖:`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 语言包说明 ## OCR 语言包说明

View File

@ -101,7 +101,7 @@ export interface ToolItem {
| 身份证 | `views/idcard` | 纯 TS 实现 GB 11643-1999 校验 | 内置全部省级行政区代码表;校验位权重表勿改动 | | 身份证 | `views/idcard` | 纯 TS 实现 GB 11643-1999 校验 | 内置全部省级行政区代码表;校验位权重表勿改动 |
| 地址解析 | `views/address-parse` | 内置省份表 + 正则规则 | 纯规则近似解析,非地理数据库;直辖市无地级市层级需特殊处理 | | 地址解析 | `views/address-parse` | 内置省份表 + 正则规则 | 纯规则近似解析,非地理数据库;直辖市无地级市层级需特殊处理 |
| URL 解析 | `views/url-parse` | 原生 `URL` / `URLSearchParams` | 无协议输入自动补 `https://`;构造器用 `searchParams.append` 自动编码 | | 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→jaerrorCode 102自动降级腾讯「逐句对照」按句拆分后逐句翻译并发 3、最多 15 句「AI 润色」仅保留入口(需官方 Key点击展示说明 |
| OCR | `views/ocr` | `tesseract.js`WASM | 见第 5 节离线说明 | | OCR | `views/ocr` | `tesseract.js`WASM | 见第 5 节离线说明 |
## 5. OCR 特殊说明(重要) ## 5. OCR 特殊说明(重要)

View File

@ -18,6 +18,7 @@
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"pinyin-pro": "^3.29.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"spark-md5": "^3.0.2", "spark-md5": "^3.0.2",
"tdesign-icons-vue-next": "^0.3.4", "tdesign-icons-vue-next": "^0.3.4",

8
pnpm-lock.yaml generated
View File

@ -26,6 +26,9 @@ importers:
pinia: pinia:
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.4(typescript@5.7.3)(vue@3.5.41(typescript@5.7.3)) 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: qrcode:
specifier: ^1.5.4 specifier: ^1.5.4
version: 1.5.4 version: 1.5.4
@ -1260,6 +1263,9 @@ packages:
typescript: typescript:
optional: true optional: true
pinyin-pro@3.29.3:
resolution: {integrity: sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ==}
pkg-types@1.3.1: pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@ -2560,6 +2566,8 @@ snapshots:
optionalDependencies: optionalDependencies:
typescript: 5.7.3 typescript: 5.7.3
pinyin-pro@3.29.3: {}
pkg-types@1.3.1: pkg-types@1.3.1:
dependencies: dependencies:
confbox: 0.1.8 confbox: 0.1.8

View File

@ -1,41 +1,170 @@
<template> <template>
<div class="page-wrap"> <div class="page-wrap">
<h2 class="page-title">翻译</h2> <h2 class="page-title">翻译</h2>
<p class="page-desc">中文英文韩文日文互译调用第三方免费翻译接口无需 API Key</p> <p class="page-desc">
参考有道翻译的交互///日互译支持单词释义真人发音与逐句对照译文来自第三方免费接口
</p>
<t-card class="tool-section" title="翻译"> <t-card class="tool-section translate-panel" title="翻译">
<div class="lang-row"> <!-- 顶部工具条语言对 + 术语库/场景参考有道 -->
<t-select v-model="fromLang" :options="sourceLangOptions" class="lang-select" /> <div class="topbar">
<t-button variant="outline" shape="circle" title="交换语言" @click="swapLangs"> <div class="langpair">
<template #icon><SwapIcon /></template> <t-select v-model="fromLang" :options="sourceLangOptions" class="lang-select" @change="onLangChange" />
</t-button> <t-button variant="text" shape="circle" title="交换语言" @click="swapLangs">
<t-select v-model="toLang" :options="targetLangOptions" class="lang-select" /> <template #icon><SwapIcon /></template>
</t-button>
<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>
<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 <t-textarea
v-model="input" v-model="input"
placeholder="输入要翻译的文本Ctrl + Enter 快速翻译)"
:maxlength="5000" :maxlength="5000"
:autosize="{ minRows: 8, maxRows: 16 }" :autosize="{ minRows: 8, maxRows: 16 }"
placeholder="请输入要翻译的文本Ctrl + Enter 快速翻译"
@keydown.ctrl.enter.prevent="translate" @keydown.ctrl.enter.prevent="translate"
@input="onInput"
/> />
<p class="char-count">{{ input.length }} / 5000</p> <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>
<div class="text-col">
<t-textarea v-model="output" readonly placeholder="译文将显示在这里" :autosize="{ minRows: 8, maxRows: 16 }" /> <!-- 译文 -->
<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> </div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<t-button theme="primary" :loading="busy" @click="translate">翻译</t-button> <t-button theme="primary" :loading="busy" @click="translate">翻译</t-button>
<t-button variant="outline" @click="clearAll">清空</t-button> <t-button variant="outline" @click="clearAll">清空</t-button>
<CopyButton v-if="output" :text="output" label="复制译文" />
</div> </div>
<t-alert v-if="infoTip" class="notice" theme="info" :message="infoTip" @close="infoTip = ''" />
<t-alert class="notice" theme="info"> <t-alert class="notice" theme="info">
翻译文本将发送至第三方免费翻译接口有道 / 腾讯请勿粘贴敏感信息免费接口有频率限制失败时请稍后重试 译文由有道演示接口 / 腾讯翻译君提供词典释义来自 Free Dictionary API / Datamuse发音来自有道词典语音接口翻译内容会发送至第三方服务器请勿输入敏感信息免费接口有频率限制失败请稍后重试
</t-alert> </t-alert>
</t-card> </t-card>
@ -51,29 +180,43 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, ref } from 'vue'
import { SwapIcon } from 'tdesign-icons-vue-next' 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 ErrorAlert from '@/components/common/ErrorAlert.vue'
import CopyButton from '@/components/common/CopyButton.vue'
import HistoryPanel from '@/components/common/HistoryPanel.vue' import HistoryPanel from '@/components/common/HistoryPanel.vue'
import { useHistory } from '@/composables/useHistory' import { useHistory } from '@/composables/useHistory'
import { copyText } from '@/composables/useClipboard'
/** 页面统一语言代码zh-CN / en / ja / koauto 表示自动检测 */ /** 页面统一语言代码zh-CN / en / ja / koauto 表示自动检测 */
type Lang = 'zh-CN' | 'en' | 'ja' | 'ko' type Lang = 'zh-CN' | 'en' | 'ja' | 'ko'
const sourceLangOptions = [ interface TranslateResult {
{ label: '自动检测', value: 'auto' }, text: string
{ label: '中文', value: 'zh-CN' }, provider: 'youdao' | 'tencent'
{ label: '英文', value: 'en' }, /** 有道返回的签名 TTS 地址日文目标语言专用dictvoice 不支持日文) */
{ label: '韩文', value: 'ko' }, speakUrl?: string
{ label: '日文', value: 'ja' }, }
]
const targetLangOptions = [ /** 词典词条(两个来源归一化后的结构) */
{ label: '中文', value: 'zh-CN' }, interface DictEntry {
{ label: '英文', value: 'en' }, word: string
{ label: '韩文', value: 'ko' }, phonetic?: string
{ label: '日文', value: 'ja' }, 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 fromLang = ref('auto')
const toLang = ref('zh-CN') const toLang = ref('zh-CN')
@ -81,9 +224,67 @@ const input = ref('')
const output = ref('') const output = ref('')
const busy = ref(false) const busy = ref(false)
const error = ref('') 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 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 { function detectLang(text: string): Lang {
if (/[\u4e00-\u9fff]/.test(text)) return 'zh-CN' 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' } const YOUDAO_LANG: Record<Lang, string> = { 'zh-CN': 'zh-CHS', en: 'en', ja: 'ja', ko: 'ko' }
/** 翻译接口一有道演示接口https://aidemo.youdao.com/trans免 key、支持 CORS */ /** 翻译接口一有道演示接口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', { const res = await fetch('https://aidemo.youdao.com/trans', {
method: 'POST', method: 'POST',
body: new URLSearchParams({ q: text, from: YOUDAO_LANG[from], to: YOUDAO_LANG[to] }), body: new URLSearchParams({ q: text, from: YOUDAO_LANG[from], to: YOUDAO_LANG[to] }),
signal: AbortSignal.timeout(15000), signal: AbortSignal.timeout(15000),
}) })
if (!res.ok) throw new Error(`HTTP ${res.status}`) 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('有道返回空结果') 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()}` const CLIENT_KEY = `browser-chrome-110.0.0.0-windows-${crypto.randomUUID()}`
/** 翻译接口二腾讯翻译君transmart.qq.com免 key、支持 CORS作为有道失败时的降级 */ /** 翻译接口二腾讯翻译君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', { const res = await fetch('https://transmart.qq.com/api/imt', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = (await res.json()) as { header?: { ret_code?: string }; auto_translation?: string[] } 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('腾讯返回空结果') 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 let lastErr: unknown = null
for (const provider of [translateYoudao, translateTencent]) { for (const providerFn of [translateYoudao, translateTencent]) {
try { try {
return await provider(text, from, to) return await providerFn(text, from, to)
} catch (e) { } catch (e) {
lastErr = e lastErr = e
} }
@ -146,6 +347,152 @@ async function requestTranslate(text: string, from: Lang, to: Lang): Promise<str
throw lastErr ?? new Error('无可用翻译接口') 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 keyCORS 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
}
}
}
/* ---------------- 语音朗读 ---------------- */
/**
* 有道词典语音接口免签 keytype=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() { async function translate() {
const text = input.value.trim() const text = input.value.trim()
if (!text) { if (!text) {
@ -154,17 +501,24 @@ async function translate() {
} }
busy.value = true busy.value = true
error.value = '' error.value = ''
output.value = '' resetResultState()
try { try {
// //
const from: Lang = fromLang.value === 'auto' ? detectLang(text) : (fromLang.value as Lang) 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 output.value = text
refreshDict(from, text, text)
return 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) history.add(text.length > 300 ? text.slice(0, 300) : text)
refreshDict(from, text, res.text)
} catch { } catch {
error.value = '翻译失败:免费接口可能暂时不可用或请求过于频繁,请稍后重试' error.value = '翻译失败:免费接口可能暂时不可用或请求过于频繁,请稍后重试'
} finally { } 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() { function swapLangs() {
const t = fromLang.value const t = fromLang.value
fromLang.value = toLang.value fromLang.value = toLang.value
toLang.value = t === 'auto' ? 'zh-CN' : t 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() { function clearAll() {
input.value = '' input.value = ''
output.value = '' detectedLang.value = null
error.value = '' error.value = ''
resetResultState()
} }
</script> </script>
<style scoped> <style scoped>
.lang-row { .topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.langpair {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
.lang-select { .lang-select {
width: 160px; width: 150px;
} }
.text-grid { .topbar-right {
display: flex;
gap: 8px;
}
.term-select {
width: 130px;
}
.columns {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 12px; margin-top: 12px;
} }
@media (max-width: 767px) { .col {
.text-grid { min-width: 0;
grid-template-columns: 1fr; 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 { .char-count {
margin: 4px 0 0; margin: 4px 0 0;
font-size: 12px; font-size: 12px;
color: var(--td-text-color-placeholder); color: var(--td-text-color-placeholder);
text-align: right; 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 { .form-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@ -228,4 +809,26 @@ function clearAll() {
.notice { .notice {
margin-top: 12px; 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> </style>