1
0
mirror of https://github.com/zclzone/vue-naive-admin.git synced 2026-06-21 13:54:07 +08:00

feat: 添加AI翻译功能

This commit is contained in:
zclzone
2026-06-17 17:02:39 +08:00
parent a1c64f29f7
commit 23eb592269
2 changed files with 557 additions and 0 deletions

View File

@@ -0,0 +1,435 @@
<script setup>
import { useClipboard, useStorage } from '@vueuse/core'
import {
API_BASE_URL_OPTIONS,
DEFAULT_SETTINGS,
EXPERIENCE_KEY_MAX_TEXT_LENGTH,
isExperienceApiKey,
isSettingsReady,
MODEL_OPTIONS,
STORE_KEY,
translateText,
} from './translate'
defineOptions({ name: 'DemoTranslate' })
const { copy, copied } = useClipboard()
const sourceText = ref('')
const resultText = ref('')
const errorText = ref('')
const loading = ref(false)
const showSettings = ref(false)
const settings = useStorage(STORE_KEY, { ...DEFAULT_SETTINGS })
const draftSettings = reactive({ ...settings.value })
const canTranslate = computed(() => Boolean(sourceText.value.trim()) && !loading.value)
const apiReady = computed(() => isSettingsReady(settings.value))
const isUsingExperienceKey = computed(() => isExperienceApiKey(settings.value.apiKey))
const isDraftExperienceKey = computed(() => isExperienceApiKey(draftSettings.apiKey))
const sourceLength = computed(() => sourceText.value.length)
const sourceLimitText = computed(() => `${sourceLength.value}/${EXPERIENCE_KEY_MAX_TEXT_LENGTH}`)
const footerText = computed(() => {
if (!apiReady.value)
return '请先完善模型配置'
if (isUsingExperienceKey.value)
return '当前是体验 Key所有人每天共享使用约 1000 次翻译'
return '已就绪'
})
watch(sourceText, (value) => {
if (isUsingExperienceKey.value && value.length > EXPERIENCE_KEY_MAX_TEXT_LENGTH)
sourceText.value = value.slice(0, EXPERIENCE_KEY_MAX_TEXT_LENGTH)
})
watch(copied, (value) => {
if (value)
$message.success('已复制译文')
})
onMounted(() => {
if (!apiReady.value)
showSettings.value = true
})
function syncDraftSettings() {
Object.assign(draftSettings, settings.value)
}
function openSettings() {
syncDraftSettings()
showSettings.value = true
}
function saveSettings() {
if (!isSettingsReady(draftSettings))
return
settings.value = {
apiKey: draftSettings.apiKey.trim(),
apiBaseUrl: draftSettings.apiBaseUrl.trim().replace(/\/+$/, ''),
model: draftSettings.model.trim(),
}
showSettings.value = false
errorText.value = ''
$message.success('配置已保存')
}
function selectModel(model) {
draftSettings.model = model
}
async function handleTranslate() {
const text = sourceText.value.trim()
if (!text || loading.value)
return
resultText.value = ''
errorText.value = ''
if (!apiReady.value) {
syncDraftSettings()
showSettings.value = true
errorText.value = '请先保存模型配置'
return
}
loading.value = true
try {
resultText.value = await translateText(text, settings.value)
}
catch (error) {
errorText.value = error?.message || '翻译失败'
}
finally {
loading.value = false
}
}
async function handleCopy() {
if (!resultText.value)
return
await copy(resultText.value)
}
</script>
<template>
<CommonPage>
<div class="translate-page">
<div class="translate-toolbar">
<div class="min-w-0 flex items-center gap-12">
<div class="tool-icon">
<i class="i-fe:globe" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-10">
<h3 class="m-0 text-18 font-600">
AI 翻译
</h3>
<n-tag size="small" round :type="apiReady ? 'success' : 'warning'">
{{ settings.model || '未配置模型' }}
</n-tag>
</div>
<div class="mt-4 truncate text-13 text-#7a807f">
{{ footerText }}
</div>
</div>
</div>
<div class="flex items-center gap-10">
<n-button quaternary circle title="模型配置" @click="openSettings">
<template #icon>
<i class="i-fe:settings" />
</template>
</n-button>
</div>
</div>
<div class="translate-workspace">
<section class="translate-panel">
<div class="panel-head">
<div>
<div class="text-14 font-600">
原文
</div>
<div class="mt-4 text-12 text-#9aa0a0">
Enter 翻译
</div>
</div>
<div class="flex items-center gap-10">
<n-tag v-if="isUsingExperienceKey" size="small" :bordered="false">
{{ sourceLimitText }}
</n-tag>
</div>
</div>
<div class="source-input-wrap">
<n-input
v-model:value="sourceText"
class="translate-input"
type="textarea"
:maxlength="isUsingExperienceKey ? EXPERIENCE_KEY_MAX_TEXT_LENGTH : undefined"
:placeholder="isUsingExperienceKey ? `请输入要翻译的内容(体验 Key 最多 ${EXPERIENCE_KEY_MAX_TEXT_LENGTH} 字符)` : '请输入要翻译的内容'"
@keydown.enter.exact.prevent="handleTranslate"
/>
<n-button
class="translate-action"
type="primary"
:loading="loading"
:disabled="!canTranslate"
@click="handleTranslate"
>
<template #icon>
<i class="i-fe:send" />
</template>
翻译
</n-button>
</div>
</section>
<section class="translate-panel">
<div class="panel-head">
<div>
<div class="text-14 font-600">
译文
</div>
<div class="mt-4 text-12 text-#9aa0a0">
自动识别中英方向
</div>
</div>
<n-button quaternary circle :disabled="!resultText" title="复制译文" @click="handleCopy">
<template #icon>
<i :class="copied ? 'i-fe:check' : 'i-fe:copy'" />
</template>
</n-button>
</div>
<div class="result-box">
<div v-if="errorText" class="result-error">
<i class="i-fe:alert-circle text-22" />
<span>{{ errorText }}</span>
</div>
<span v-else-if="resultText" class="whitespace-pre-wrap break-words">{{ resultText }}</span>
<div v-else class="result-empty">
<i class="i-fe:message-square text-34" />
<span>翻译结果会显示在这里</span>
</div>
</div>
</section>
</div>
</div>
<n-drawer v-model:show="showSettings" :width="420" placement="right">
<n-drawer-content title="模型配置" closable>
<div class="flex flex-col gap-16">
<n-alert v-if="isDraftExperienceKey" type="warning" :show-icon="false">
当前是体验 Key所有人每天共享使用约 1000 次翻译单次最多支持 {{ EXPERIENCE_KEY_MAX_TEXT_LENGTH }} 字符
</n-alert>
<div>
<div class="mb-8 text-13 text-#5f6665">
API Key
</div>
<n-input
v-model:value="draftSettings.apiKey"
type="password"
show-password-on="click"
placeholder="sk-..."
/>
</div>
<div>
<div class="mb-8 text-13 text-#5f6665">
API Base URL
</div>
<n-auto-complete
v-model:value="draftSettings.apiBaseUrl"
:options="API_BASE_URL_OPTIONS.filter(item => item.includes(draftSettings.apiBaseUrl)).map(value => ({ label: value, value }))"
:get-show="() => true"
placeholder="例如https://runapi.co/v1"
/>
</div>
<div>
<div class="mb-8 text-13 text-#5f6665">
Model
</div>
<n-auto-complete
v-model:value="draftSettings.model"
:options="MODEL_OPTIONS.filter(item => item.includes(draftSettings.model)).map(value => ({ label: value, value }))"
:get-show="() => true"
placeholder="例如gpt-5.4-mini"
/>
<div class="mt-10 flex flex-wrap gap-8">
<n-tag
v-for="model in MODEL_OPTIONS"
:key="model"
class="cursor-pointer"
size="small"
:type="draftSettings.model === model ? 'primary' : 'default'"
round
@click="selectModel(model)"
>
{{ model }}
</n-tag>
</div>
</div>
<div class="flex justify-end gap-10">
<n-button @click="showSettings = false">
取消
</n-button>
<n-button type="primary" :disabled="!isSettingsReady(draftSettings)" @click="saveSettings">
保存配置
</n-button>
</div>
</div>
</n-drawer-content>
</n-drawer>
</CommonPage>
</template>
<style scoped>
.translate-page {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 16px;
}
.translate-toolbar {
min-height: 70px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 4px 16px;
border-bottom: 1px solid rgba(239, 239, 245, 0.9);
}
.tool-icon {
width: 42px;
height: 42px;
display: grid;
place-items: center;
flex: none;
border-radius: 8px;
color: rgb(var(--primary-color));
background: rgba(var(--primary-color), 0.09);
font-size: 20px;
}
.translate-workspace {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
}
.translate-panel {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border: 1px solid #efeff5;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.panel-head {
min-height: 68px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #f1f1f4;
background: #fbfcfc;
}
.translate-input {
height: 100%;
}
.source-input-wrap {
position: relative;
min-height: 0;
}
.translate-input :deep(.n-input),
.translate-input :deep(.n-input-wrapper),
.translate-input :deep(.n-input__textarea),
.translate-input :deep(.n-input__textarea-el) {
height: 100%;
resize: none;
}
.translate-input :deep(textarea),
.translate-input :deep(.n-input__textarea-el) {
resize: none !important;
}
.translate-input :deep(.n-input) {
border-radius: 0;
}
.translate-input :deep(.n-input__textarea-el) {
padding-bottom: 68px;
}
.translate-input :deep(.n-input__border),
.translate-input :deep(.n-input__state-border) {
display: none;
}
.translate-action {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 1;
}
.result-box {
min-height: 0;
overflow: auto;
padding: 18px;
color: #1f2322;
font-size: 15px;
line-height: 1.8;
background: #fff;
}
.result-empty {
height: 100%;
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
color: #a0a5a4;
}
.result-error {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: #d03050;
font-size: 14px;
}
@media (max-width: 960px) {
.translate-toolbar {
align-items: flex-start;
flex-direction: column;
}
.translate-workspace {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,122 @@
export const STORE_KEY = 'demo-translate-settings'
export const API_BASE_URL_OPTIONS = [
'https://runapi.co/v1',
]
export const MODEL_OPTIONS = [
'gpt-5.4-mini',
'gpt-5.2',
'gpt-5.4',
'gpt-5.5',
'deepseek-v4-flash',
'deepseek-v4-pro',
]
export const DEFAULT_SETTINGS = {
apiKey: 'sk-17UVTOjYEB3M9Erg32yGdEhMVFKEaALuIkjy1CkGrCUdiQqJ',
apiBaseUrl: API_BASE_URL_OPTIONS[0],
model: MODEL_OPTIONS[0],
}
export const EXPERIENCE_KEY_MAX_TEXT_LENGTH = 100
const TRANSLATION_RULES = 'Translate directly. Return only the translation. Preserve formatting, markdown, code, URLs, numbers, placeholders, and line breaks.'
export function isSettingsReady(target) {
return Boolean(target.apiKey?.trim() && target.apiBaseUrl?.trim() && target.model?.trim())
}
export function isExperienceApiKey(apiKey) {
return apiKey?.trim() === DEFAULT_SETTINGS.apiKey
}
export function validateTranslateText(text, settings) {
if (isExperienceApiKey(settings.apiKey) && text.length > EXPERIENCE_KEY_MAX_TEXT_LENGTH)
throw new Error(`体验 Key 最多支持 ${EXPERIENCE_KEY_MAX_TEXT_LENGTH} 字符`)
}
function countMatches(text, pattern) {
return text.match(pattern)?.length || 0
}
function detectSourceLanguage(text) {
const cjkCount = countMatches(text, /[\u3400-\u9FFF]/g)
const latinCount = countMatches(text, /[A-Z]/gi)
if (!cjkCount && !latinCount)
return 'auto'
if (!cjkCount)
return 'en'
if (!latinCount)
return 'zh'
const cjkRatio = cjkCount / (cjkCount + latinCount)
if (cjkRatio >= 0.2)
return 'zh'
if (cjkRatio <= 0.08)
return 'en'
const firstScript = text.match(/[\u3400-\u9FFFA-Z]/i)?.[0]
return firstScript && /[\u3400-\u9FFF]/.test(firstScript) ? 'zh' : 'en'
}
function createSystemPrompt(sourceLanguage) {
if (sourceLanguage === 'zh')
return `${TRANSLATION_RULES} Target: natural English.`
if (sourceLanguage === 'en')
return `${TRANSLATION_RULES} Target: natural Simplified Chinese.`
return `${TRANSLATION_RULES} Target: Simplified Chinese.`
}
function createFastModelOptions(model) {
const normalized = model.trim().toLowerCase()
if (!normalized.startsWith('gpt-5'))
return {}
return {
reasoning_effort: /^gpt-5\.[1-9]\d*/.test(normalized) ? 'none' : 'low',
verbosity: 'low',
}
}
export async function translateText(text, settings) {
validateTranslateText(text, settings)
const baseUrl = settings.apiBaseUrl.trim().replace(/\/+$/, '')
const model = settings.model.trim() || DEFAULT_SETTINGS.model
const sourceLanguage = detectSourceLanguage(text)
const response = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${settings.apiKey.trim()}`,
},
body: JSON.stringify({
model,
temperature: 0,
...createFastModelOptions(model),
messages: [
{
role: 'system',
content: createSystemPrompt(sourceLanguage),
},
{
role: 'user',
content: text,
},
],
}),
})
const data = await response.json().catch(() => ({}))
if (!response.ok)
throw new Error(data?.error?.message || `请求失败:${response.status}`)
const result = data?.choices?.[0]?.message?.content?.trim() || ''
if (!result)
throw new Error('未收到翻译结果')
return result
}