Files
trade-assistant/uni-app/src/pages/translate/translate.vue
T
TradeMate Dev 23a31f7c00 feat: silent wechat login, marketing tab optimization, admin page foundation
- Add silent WeChat login for MP/browser environments
- Fix Python 3.6 compatibility (remove typing.Annotated usage)
- Marketing page: tab-based content generation with category support
- Translate page: add auto-detect language default
- Homepage: add TTS playback, announcement ticker, remove redundant quick-actions
- Fix FAB button overlap with custom tabbar on customers/quotation pages
- Make openai/anthropic imports lazy for Python 3.6 compat
2026-05-14 00:30:48 +08:00

542 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.
<template>
<view class="translate-container">
<view class="mode-switch">
<view
class="mode-item"
:class="{ active: mode === 'translate' }"
@click="mode = 'translate'"
>
文本翻译
</view>
<view
class="mode-item"
:class="{ active: mode === 'reply' }"
@click="mode = 'reply'"
>
回复建议
</view>
</view>
<view class="input-section">
<view class="input-header">
<text class="input-label">{{ mode === 'translate' ? '输入原文' : '客户询盘' }}</text>
<picker :range="targetLangs" range-key="name" @change="onTargetChange">
<text class="target-lang">{{ targetLangs[targetIndex].name }}</text>
</picker>
</view>
<textarea
class="input-area"
v-model="inputText"
:placeholder="mode === 'translate' ? '输入需要翻译的文本...' : '输入客户的询盘内容...'"
@input="onInput"
/>
</view>
<view class="action-section">
<button class="translate-btn" @click="handleTranslate" :disabled="loading">
{{ loading ? '翻译中...' : '翻译' }}
</button>
<button class="clear-btn" @click="clearAll">清空</button>
</view>
<view class="result-section" v-if="result">
<view class="result-header">
<text class="result-label">翻译结果</text>
<view class="result-actions">
<text class="action-btn extract-btn" @click="handleExtract">抽取信息</text>
<text class="action-btn" @click="playTts">朗读</text>
<text class="copy-btn" @click="copyResult">复制</text>
</view>
</view>
<view class="result-content">
<text class="result-text">{{ result }}</text>
</view>
<view class="rating-section" v-if="result">
<text class="rating-label">评价:</text>
<view class="stars">
<text
class="star"
v-for="s in 5"
:key="s"
@click="rateTranslation(s)"
>{{ s <= rating ? '★' : '☆' }}</text>
</view>
</view>
</view>
<view class="extract-section" v-if="extractedInfo">
<view class="extract-header">
<text class="extract-label">抽取信息</text>
<text class="extract-close" @click="extractedInfo = null">×</text>
</view>
<view class="extract-content">
<text class="extract-text">{{ extractedInfo }}</text>
</view>
</view>
<view class="suggestions-section" v-if="suggestions.length > 0">
<view class="suggestions-header">
<text class="suggestions-label">回复建议</text>
</view>
<view class="suggestions-list">
<view
class="suggestion-item"
v-for="(item, index) in suggestions"
:key="index"
@click="selectSuggestion(index)"
>
<text class="suggestion-text">{{ item.text }}</text>
<text class="suggestion-tone">{{ item.tone }}</text>
</view>
</view>
</view>
<view class="preferences-section" v-if="preferences">
<view class="preferences-header">
<text class="preferences-label">AI偏好设置</text>
</view>
<view class="preferences-body">
<text class="pref-text">{{ preferences.preferred_tone || '尚未分析偏好' }}</text>
<text class="pref-detail" v-if="preferences.common_words">常用词: {{ preferences.common_words.join(', ') }}</text>
</view>
</view>
<view class="keyboard-height" :style="{ height: keyboardHeight + 'px' }"></view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { translateApi, interactionApi, BASE_URL } from '@/utils/api.js'
const mode = ref('translate')
const inputText = ref('')
const result = ref('')
const suggestions = ref([])
const loading = ref(false)
const targetIndex = ref(0)
const keyboardHeight = ref(0)
const rating = ref(0)
const extractedInfo = ref(null)
const preferences = ref(null)
const targetLangs = ref([
{ code: 'auto', name: '自动检测' },
{ code: 'en', name: 'English' },
{ code: 'zh', name: '中文' },
{ code: 'es', name: 'Español' },
])
const onTargetChange = (e) => {
targetIndex.value = e.detail.value
}
const onInput = () => {
}
const handleTranslate = async () => {
if (!inputText.value.trim()) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
loading.value = true
try {
if (mode.value === 'translate') {
let targetLang = targetLangs[targetIndex.value].code
if (targetLang === 'auto') {
const hasChinese = /[\u4e00-\u9fa5]/.test(inputText.value)
targetLang = hasChinese ? 'en' : 'zh'
}
const res = await translateApi.translate(
inputText.value,
targetLang
)
result.value = res.translated
loadPreferences()
} else {
const res = await translateApi.getReply(inputText.value, 'professional', 3)
suggestions.value = res.suggestions || []
result.value = ''
}
} catch (err) {
uni.showToast({ title: err.message || '翻译失败', icon: 'none' })
} finally {
loading.value = false
}
}
const clearAll = () => {
inputText.value = ''
result.value = ''
suggestions.value = []
extractedInfo.value = null
rating.value = 0
}
const copyResult = () => {
uni.setClipboardData({
data: result.value,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
},
})
}
const playTts = () => {
if (!result.value) return
const lang = targetLangs[targetIndex.value].code
const token = uni.getStorageSync('token')
const url = `${BASE_URL}/translate/tts?text=${encodeURIComponent(result.value)}&lang=${lang}`
uni.showLoading({ title: '语音生成中...' })
uni.downloadFile({
url,
header: { Authorization: `Bearer ${token}` },
success: (res) => {
uni.hideLoading()
if (res.statusCode === 200) {
const audioCtx = uni.createInnerAudioContext()
audioCtx.src = res.tempFilePath
audioCtx.play()
audioCtx.onEnded(() => audioCtx.destroy())
} else {
uni.showToast({ title: '语音生成失败', icon: 'none' })
}
},
fail: () => {
uni.hideLoading()
uni.showToast({ title: '语音生成失败', icon: 'none' })
},
})
}
const handleExtract = async () => {
if (!result.value) return
try {
const res = await translateApi.extract(result.value)
extractedInfo.value = typeof res.extracted === 'string' ? res.extracted : JSON.stringify(res.extracted, null, 2)
} catch (err) {
uni.showToast({ title: err.message || '抽取失败', icon: 'none' })
}
}
const rateTranslation = async (s) => {
rating.value = s
try {
await translateApi.sendFeedback(null, s)
uni.showToast({ title: '感谢评价', icon: 'success' })
} catch (err) {
console.error('反馈提交失败', err)
}
}
const loadPreferences = async () => {
try {
const res = await interactionApi.getPreferences()
preferences.value = res
} catch (err) {
console.error('加载偏好失败', err)
}
}
const selectSuggestion = (index) => {
const selected = suggestions.value[index]
uni.setClipboardData({
data: selected.text,
success: () => {
interactionApi.recordEdit(null, selected.text).catch(() => {})
uni.showToast({ title: '已复制建议内容', icon: 'success' })
},
})
}
</script>
<style lang="scss" scoped>
.translate-container {
min-height: 100%;
background: #f5f5f5;
padding: 20rpx;
box-sizing: border-box;
}
.mode-switch {
display: flex;
background: #fff;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
.mode-item {
flex: 1;
text-align: center;
padding: 24rpx;
font-size: 28rpx;
color: #666;
}
.mode-item.active {
color: #1890ff;
border-bottom: 4rpx solid #1890ff;
}
.input-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.input-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.input-label {
font-size: 26rpx;
color: #999;
}
.target-lang {
font-size: 26rpx;
color: #1890ff;
padding: 8rpx 16rpx;
background: #e6f7ff;
border-radius: 8rpx;
}
.input-area {
width: 100%;
min-height: 200rpx;
font-size: 28rpx;
line-height: 1.6;
}
.action-section {
display: flex;
gap: 20rpx;
margin-bottom: 20rpx;
}
.translate-btn {
flex: 1;
height: 88rpx;
background: #1890ff;
color: #fff;
border-radius: 12rpx;
font-size: 30rpx;
}
.clear-btn {
width: 160rpx;
height: 88rpx;
background: #fff;
border: 2rpx solid #d9d9d9;
border-radius: 12rpx;
font-size: 30rpx;
color: #666;
}
.result-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.result-label {
font-size: 26rpx;
color: #999;
}
.result-actions {
display: flex;
gap: 12rpx;
}
.action-btn, .copy-btn {
font-size: 24rpx;
padding: 8rpx 16rpx;
border-radius: 6rpx;
}
.action-btn {
color: #52c41a;
background: #f6ffed;
}
.extract-btn {
color: #722ed1;
background: #f9f0ff;
}
.copy-btn {
color: #1890ff;
background: #e6f7ff;
}
.result-content {
padding: 20rpx;
background: #f9f9f9;
border-radius: 12rpx;
}
.result-text {
font-size: 28rpx;
line-height: 1.6;
}
.rating-section {
display: flex;
align-items: center;
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 2rpx solid #f5f5f5;
}
.rating-label {
font-size: 24rpx;
color: #999;
margin-right: 12rpx;
}
.stars {
display: flex;
gap: 8rpx;
}
.star {
font-size: 36rpx;
color: #faad14;
}
.extract-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.extract-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.extract-label {
font-size: 26rpx;
color: #722ed1;
font-weight: 600;
}
.extract-close {
font-size: 36rpx;
color: #999;
}
.extract-content {
padding: 16rpx;
background: #f9f0ff;
border-radius: 8rpx;
}
.extract-text {
font-size: 26rpx;
line-height: 1.5;
white-space: pre-wrap;
}
.suggestions-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.suggestions-header {
margin-bottom: 16rpx;
}
.suggestions-label {
font-size: 26rpx;
color: #999;
}
.suggestions-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.suggestion-item {
padding: 20rpx;
background: #f9f9f9;
border-radius: 12rpx;
}
.suggestion-text {
font-size: 26rpx;
line-height: 1.5;
display: block;
margin-bottom: 12rpx;
}
.suggestion-tone {
font-size: 22rpx;
color: #1890ff;
background: #e6f7ff;
padding: 4rpx 12rpx;
border-radius: 6rpx;
}
.preferences-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.preferences-header {
margin-bottom: 12rpx;
}
.preferences-label {
font-size: 26rpx;
color: #666;
font-weight: 600;
}
.preferences-body {
padding: 12rpx;
background: #f0f5ff;
border-radius: 8rpx;
}
.pref-text {
font-size: 26rpx;
color: #1890ff;
display: block;
margin-bottom: 8rpx;
}
.pref-detail {
font-size: 24rpx;
color: #666;
}
.keyboard-height {
width: 100%;
}
</style>