feat: 新增翻译工具(中英韩日互译)

- 新增 /translate 页面:源语言自动检测 + 中英韩日选择、一键互换、
  双语对照输入输出、Ctrl+Enter 快捷翻译、复制译文、本地历史
- 调用免费第三方接口(有道演示接口 + 腾讯翻译君),均免 API Key、
  支持 CORS;两个接口依次尝试自动降级,免费接口有频率限制已提示
- 自动检测用脚本特征兜底(中文/日文/韩文/英文),保证降级接口可用
- tools.ts 注册翻译工具项;README 与 agents.md 同步更新(工具数 15,
  并注明翻译为唯一需要联网的工具)
This commit is contained in:
夏犀麟 2026-08-21 16:08:50 +08:00
parent 85db6a3373
commit 3a2423c430
4 changed files with 246 additions and 5 deletions

View File

@ -1,10 +1,10 @@
# 在线工具箱tool.xpcool.com # 在线工具箱tool.xpcool.com
纯前端在线工具站:**Vite + Vue3 + Pinia + TypeScript + UnoCSS + TDesign**。14 个常用工具,所有文件处理与计算均在浏览器本地完成,**不上传任何用户数据到服务器**。 纯前端在线工具站:**Vite + Vue3 + Pinia + TypeScript + UnoCSS + TDesign**。15 个常用工具,所有文件处理与计算均在浏览器本地完成,**不上传任何用户数据到服务器**。
## 功能特性 ## 功能特性
- 14 个实用工具:图片压缩/裁剪、二维码生成解析、JSON 格式化、时间戳转换、Base64 编解码、JWT 解析、Cron 表达式、进制转换、哈希计算、身份证解析、地址解析、URL 解析、图片 OCR 识别 - 15 个实用工具:图片压缩/裁剪、二维码生成解析、JSON 格式化、时间戳转换、Base64 编解码、JWT 解析、Cron 表达式、进制转换、哈希计算、身份证解析、地址解析、URL 解析、翻译、图片 OCR 识别
- 左侧固定导航 + 中间内容区PC Web 与移动端 H5 响应式适配(移动端为抽屉导航) - 左侧固定导航 + 中间内容区PC Web 与移动端 H5 响应式适配(移动端为抽屉导航)
- 暗黑 / 浅色主题切换,选择持久化,刷新不丢失 - 暗黑 / 浅色主题切换,选择持久化,刷新不丢失
- 每个工具独立路由、独立本地历史记录localStorage - 每个工具独立路由、独立本地历史记录localStorage
@ -65,6 +65,7 @@ pnpm preview
| `/idcard` | 身份证解析 | | `/idcard` | 身份证解析 |
| `/address-parse` | 地址解析 | | `/address-parse` | 地址解析 |
| `/url-parse` | URL 解析 | | `/url-parse` | URL 解析 |
| `/translate` | 翻译(中英韩日) |
| `/ocr` | 图片 OCR 识别 | | `/ocr` | 图片 OCR 识别 |
## 目录结构 ## 目录结构
@ -108,7 +109,7 @@ location / {
## 隐私说明 ## 隐私说明
所有计算均在浏览器本地完成:图片压缩/裁剪/OCR 使用本地 Canvas/WASM哈希使用本地 Web Crypto 与 spark-md5历史记录仅存于本机 localStorage。项目不包含任何后端接口调用用户文件永远不会离开浏览器。 所有计算均在浏览器本地完成:图片压缩/裁剪/OCR 使用本地 Canvas/WASM哈希使用本地 Web Crypto 与 spark-md5历史记录仅存于本机 localStorage。项目不包含任何后端接口调用用户文件永远不会离开浏览器。唯一例外是「翻译」工具:为调用第三方免费翻译接口(有道 / 腾讯),待翻译文本会发送至对应接口,页面已做提示。
## OCR 语言包说明 ## OCR 语言包说明

View File

@ -5,7 +5,7 @@
## 1. 项目定位 ## 1. 项目定位
- 名称tool.xpcool.com在线工具箱 - 名称tool.xpcool.com在线工具箱
- 形态:**纯前端 SPA**,无后端、无 mock 接口。所有计算与文件处理在浏览器本地完成,用户文件不出浏览器。 - 形态:**纯前端 SPA**,无后端、无 mock 接口。所有计算与文件处理在浏览器本地完成,用户文件不出浏览器(唯一例外:翻译工具直接调用第三方免费翻译接口,见第 4 节)
- 技术栈Vite 6 + Vue 3`<script setup>`+ Pinia + TypeScriptstrict+ UnoCSS + TDesign Vue Next + pnpm - 技术栈Vite 6 + Vue 3`<script setup>`+ Pinia + TypeScriptstrict+ UnoCSS + TDesign Vue Next + pnpm
- 客户端PC Web + 移动端 H5 响应式(`<768px` 时侧边栏变为抽屉导航 - 客户端PC Web + 移动端 H5 响应式(`<768px` 时侧边栏变为抽屉导航
- 包管理:**pnpm**(存在 `pnpm-lock.yaml`,禁止换成 npm/yarn 安装)。 - 包管理:**pnpm**(存在 `pnpm-lock.yaml`,禁止换成 npm/yarn 安装)。
@ -57,7 +57,7 @@ export interface ToolItem {
2. 在 `tools.ts``tools` 数组末尾追加一条配置,`component` 使用 `() => import('@/views/<tool-name>/<ToolName>View.vue')` 2. 在 `tools.ts``tools` 数组末尾追加一条配置,`component` 使用 `() => import('@/views/<tool-name>/<ToolName>View.vue')`
3. 完成——菜单、首页卡片、路由、文档标题自动生效。可运行 `pnpm type-check` 验证。 3. 完成——菜单、首页卡片、路由、文档标题自动生效。可运行 `pnpm type-check` 验证。
**注意:** `path` 必须与页面内 `useHistory('xxx')` 的 key 一致(历史记录约定),并避免与现有 14 个 path 重复。 **注意:** `path` 必须与页面内 `useHistory('xxx')` 的 key 一致(历史记录约定),并避免与现有 15 个 path 重复。
### 3.2 历史记录约定 ### 3.2 历史记录约定
@ -101,6 +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腾讯「自动检测」用脚本特征兜底判定中文/日文/韩文/英文);两个接口依次尝试降级;免费接口有频率限制,页面已提示 |
| OCR | `views/ocr` | `tesseract.js`WASM | 见第 5 节离线说明 | | OCR | `views/ocr` | `tesseract.js`WASM | 见第 5 节离线说明 |
## 5. OCR 特殊说明(重要) ## 5. OCR 特殊说明(重要)

View File

@ -13,6 +13,7 @@ import {
CreditcardIcon, CreditcardIcon,
LocationIcon, LocationIcon,
LinkIcon, LinkIcon,
TranslateIcon,
BrowseIcon, BrowseIcon,
} from 'tdesign-icons-vue-next' } from 'tdesign-icons-vue-next'
@ -130,6 +131,13 @@ export const tools: ToolItem[] = [
icon: LinkIcon, icon: LinkIcon,
component: () => import('@/views/url-parse/UrlParseView.vue'), component: () => import('@/views/url-parse/UrlParseView.vue'),
}, },
{
path: 'translate',
name: '翻译',
desc: '中文、英文、韩文、日文互译(免费在线接口)',
icon: TranslateIcon,
component: () => import('@/views/translate/TranslateView.vue'),
},
{ {
path: 'ocr', path: 'ocr',
name: '图片 OCR 识别', name: '图片 OCR 识别',

View File

@ -0,0 +1,231 @@
<template>
<div class="page-wrap">
<h2 class="page-title">翻译</h2>
<p class="page-desc">中文英文韩文日文互译调用第三方免费翻译接口无需 API Key</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">
<template #icon><SwapIcon /></template>
</t-button>
<t-select v-model="toLang" :options="targetLangOptions" class="lang-select" />
</div>
<div class="text-grid">
<div class="text-col">
<t-textarea
v-model="input"
placeholder="输入要翻译的文本Ctrl + Enter 快速翻译)"
:maxlength="5000"
:autosize="{ minRows: 8, maxRows: 16 }"
@keydown.ctrl.enter.prevent="translate"
/>
<p class="char-count">{{ input.length }} / 5000</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 class="notice" theme="info">
翻译文本将发送至第三方免费翻译接口有道 / 腾讯请勿粘贴敏感信息免费接口有频率限制失败时请稍后重试
</t-alert>
</t-card>
<ErrorAlert :message="error" @close="error = ''" />
<HistoryPanel
:items="history.items"
@select="onHistorySelect"
@remove="history.remove"
@clear="history.clear"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { SwapIcon } 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'
/** 页面统一语言代码zh-CN / en / ja / koauto 表示自动检测 */
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' },
]
const fromLang = ref('auto')
const toLang = ref('zh-CN')
const input = ref('')
const output = ref('')
const busy = ref(false)
const error = ref('')
const history = useHistory('translate')
/** 按文字脚本粗略检测语言(中英韩日),供「自动检测」与降级接口使用 */
function detectLang(text: string): Lang {
if (/[\u4e00-\u9fff]/.test(text)) return 'zh-CN'
if (/[\u3040-\u30ff\u31f0-\u31ff]/.test(text)) return 'ja'
if (/[\uac00-\ud7af]/.test(text)) return 'ko'
return 'en'
}
/** 有道演示接口语言码映射(简体中文为 zh-CHS */
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> {
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[] }
if (data.errorCode !== '0' || !data.translation?.length) throw new Error('有道返回空结果')
return data.translation.join('')
}
/** 腾讯接口语言码映射 */
const TENCENT_LANG: Record<Lang, string> = { 'zh-CN': 'zh', en: 'en', ja: 'ja', ko: 'ko' }
// client_key
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> {
const res = await fetch('https://transmart.qq.com/api/imt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
header: { fn: 'auto_translation', client_key: CLIENT_KEY },
type: 'plain',
model_category: 'normal',
source: { lang: TENCENT_LANG[from], text_list: [text] },
target: { lang: TENCENT_LANG[to] },
}),
signal: AbortSignal.timeout(15000),
})
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('')
}
/** 依次尝试两个免费接口,任一成功即返回,全部失败则抛错 */
async function requestTranslate(text: string, from: Lang, to: Lang): Promise<string> {
let lastErr: unknown = null
for (const provider of [translateYoudao, translateTencent]) {
try {
return await provider(text, from, to)
} catch (e) {
lastErr = e
}
}
throw lastErr ?? new Error('无可用翻译接口')
}
async function translate() {
const text = input.value.trim()
if (!text) {
error.value = '请输入要翻译的文本'
return
}
busy.value = true
error.value = ''
output.value = ''
try {
//
const from: Lang = fromLang.value === 'auto' ? detectLang(text) : (fromLang.value as Lang)
if (from === toLang.value) {
//
output.value = text
return
}
output.value = await requestTranslate(text, from, toLang.value as Lang)
history.add(text.length > 300 ? text.slice(0, 300) : text)
} catch {
error.value = '翻译失败:免费接口可能暂时不可用或请求过于频繁,请稍后重试'
} finally {
busy.value = false
}
}
/** 交换语言;源为「自动检测」时目标语言补位为中文 */
function swapLangs() {
const t = fromLang.value
fromLang.value = toLang.value
toLang.value = t === 'auto' ? 'zh-CN' : t
}
/** 点击历史:回填原文并自动翻译 */
function onHistorySelect(text: string) {
input.value = text
translate()
}
function clearAll() {
input.value = ''
output.value = ''
error.value = ''
}
</script>
<style scoped>
.lang-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.lang-select {
width: 160px;
}
.text-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 12px;
}
@media (max-width: 767px) {
.text-grid {
grid-template-columns: 1fr;
}
}
.char-count {
margin: 4px 0 0;
font-size: 12px;
color: var(--td-text-color-placeholder);
text-align: right;
}
.form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
.notice {
margin-top: 12px;
}
</style>