feat: 新增翻译工具(中英韩日互译)
- 新增 /translate 页面:源语言自动检测 + 中英韩日选择、一键互换、 双语对照输入输出、Ctrl+Enter 快捷翻译、复制译文、本地历史 - 调用免费第三方接口(有道演示接口 + 腾讯翻译君),均免 API Key、 支持 CORS;两个接口依次尝试自动降级,免费接口有频率限制已提示 - 自动检测用脚本特征兜底(中文/日文/韩文/英文),保证降级接口可用 - tools.ts 注册翻译工具项;README 与 agents.md 同步更新(工具数 15, 并注明翻译为唯一需要联网的工具)
This commit is contained in:
parent
85db6a3373
commit
3a2423c430
@ -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 语言包说明
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
## 1. 项目定位
|
## 1. 项目定位
|
||||||
|
|
||||||
- 名称:tool.xpcool.com(在线工具箱)
|
- 名称:tool.xpcool.com(在线工具箱)
|
||||||
- 形态:**纯前端 SPA**,无后端、无 mock 接口。所有计算与文件处理在浏览器本地完成,用户文件不出浏览器。
|
- 形态:**纯前端 SPA**,无后端、无 mock 接口。所有计算与文件处理在浏览器本地完成,用户文件不出浏览器(唯一例外:翻译工具直接调用第三方免费翻译接口,见第 4 节)。
|
||||||
- 技术栈:Vite 6 + Vue 3(`<script setup>`)+ Pinia + TypeScript(strict)+ UnoCSS + TDesign Vue Next + pnpm。
|
- 技术栈:Vite 6 + Vue 3(`<script setup>`)+ Pinia + TypeScript(strict)+ 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 特殊说明(重要)
|
||||||
|
|||||||
@ -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 识别',
|
||||||
|
|||||||
231
src/views/translate/TranslateView.vue
Normal file
231
src/views/translate/TranslateView.vue
Normal 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 / 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' },
|
||||||
|
]
|
||||||
|
|
||||||
|
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>
|
||||||
Loading…
Reference in New Issue
Block a user