23a31f7c00
- 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
542 lines
11 KiB
Vue
542 lines
11 KiB
Vue
<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>
|