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:
435
src/views/demo/translate/index.vue
Normal file
435
src/views/demo/translate/index.vue
Normal 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>
|
||||
122
src/views/demo/translate/translate.js
Normal file
122
src/views/demo/translate/translate.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user