1
0
mirror of https://github.com/zclzone/vue-naive-admin.git synced 2026-06-21 13:54:07 +08:00
Files
vue-naive-admin/src/views/demo/translate/index.vue
2026-06-17 17:02:39 +08:00

436 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>