feat(email): 实现邮件详情免配置一键翻译与原文切换功能并发布V2.14.0
Some checks are pending
Security Checks / security (push) Waiting to run
Some checks are pending
Security Checks / security (push) Waiting to run
This commit is contained in:
parent
2cedb02bcc
commit
8cf7c55a0b
116
backend/controllers/email/translate.go
Normal file
116
backend/controllers/email/translate.go
Normal file
@ -0,0 +1,116 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/dto/response"
|
||||
"git.pcux.cn/xuans/Smail/services/detail"
|
||||
"git.pcux.cn/xuans/Smail/services/translate"
|
||||
smailContext "git.pcux.cn/xuans/Smail/utils/context"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type emailTranslateRequest struct {
|
||||
ID int `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
Text string `json:"text"`
|
||||
TargetLang string `json:"target_lang"`
|
||||
}
|
||||
|
||||
type emailTranslateResponse struct {
|
||||
SubjectTranslated string `json:"subject_translated"`
|
||||
ContentTranslated string `json:"content_translated"`
|
||||
DetectedLang string `json:"detected_lang"`
|
||||
TargetLang string `json:"target_lang"`
|
||||
}
|
||||
|
||||
// TranslateEmail 邮件免配置一键翻译接口
|
||||
func TranslateEmail(ctx *smailContext.Context, w http.ResponseWriter, req *http.Request) {
|
||||
var retData emailTranslateRequest
|
||||
if err := decodeEmailJSONRequest(w, req, &retData, maxEmailJSONRequestBytes); err != nil {
|
||||
log.WithContext(ctx).WithError(err).Info("invalid email translate request")
|
||||
response.NewErrorResponse(response.ParamsError, "参数错误", "").FPrint(w)
|
||||
return
|
||||
}
|
||||
|
||||
targetLang := strings.TrimSpace(retData.TargetLang)
|
||||
if targetLang == "" {
|
||||
targetLang = "zh-CN"
|
||||
}
|
||||
|
||||
var subjectToTranslate string
|
||||
var contentToTranslate string
|
||||
|
||||
if retData.ID > 0 {
|
||||
emailData, err := detail.GetEmailDetail(ctx, retData.ID, false)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).WithError(err).Warnf("translate email: load email detail failed for id=%d", retData.ID)
|
||||
response.NewErrorResponse(response.ServerError, "获取邮件失败", "").FPrint(w)
|
||||
return
|
||||
}
|
||||
subjectToTranslate = emailData.Subject
|
||||
if emailData.Text.Valid && strings.TrimSpace(emailData.Text.String) != "" {
|
||||
contentToTranslate = emailData.Text.String
|
||||
} else if emailData.Html.Valid {
|
||||
contentToTranslate = emailData.Html.String
|
||||
}
|
||||
} else {
|
||||
subjectToTranslate = retData.Subject
|
||||
contentToTranslate = retData.Text
|
||||
}
|
||||
|
||||
if strings.TrimSpace(subjectToTranslate) == "" && strings.TrimSpace(contentToTranslate) == "" {
|
||||
response.NewSuccessResponse(emailTranslateResponse{
|
||||
SubjectTranslated: "",
|
||||
ContentTranslated: "",
|
||||
DetectedLang: "auto",
|
||||
TargetLang: targetLang,
|
||||
}).FPrint(w)
|
||||
return
|
||||
}
|
||||
|
||||
callCtx := req.Context()
|
||||
if callCtx == nil {
|
||||
callCtx = context.Background()
|
||||
}
|
||||
|
||||
var translatedSubject string
|
||||
var detectedLang = "auto"
|
||||
|
||||
if strings.TrimSpace(subjectToTranslate) != "" {
|
||||
subRes, err := translate.TranslateText(callCtx, subjectToTranslate, targetLang)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).WithError(err).Warn("translate subject failed")
|
||||
response.NewErrorResponse(response.ServerError, fmt.Sprintf("翻译主题失败: %v", err), "").FPrint(w)
|
||||
return
|
||||
}
|
||||
translatedSubject = subRes.TranslatedText
|
||||
if subRes.SourceLang != "" && subRes.SourceLang != "auto" {
|
||||
detectedLang = subRes.SourceLang
|
||||
}
|
||||
}
|
||||
|
||||
var translatedContent string
|
||||
if strings.TrimSpace(contentToTranslate) != "" {
|
||||
contentRes, err := translate.TranslateText(callCtx, contentToTranslate, targetLang)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).WithError(err).Warn("translate content failed")
|
||||
response.NewErrorResponse(response.ServerError, fmt.Sprintf("翻译内容失败: %v", err), "").FPrint(w)
|
||||
return
|
||||
}
|
||||
translatedContent = contentRes.TranslatedText
|
||||
if detectedLang == "auto" && contentRes.SourceLang != "" && contentRes.SourceLang != "auto" {
|
||||
detectedLang = contentRes.SourceLang
|
||||
}
|
||||
}
|
||||
|
||||
response.NewSuccessResponse(emailTranslateResponse{
|
||||
SubjectTranslated: translatedSubject,
|
||||
ContentTranslated: translatedContent,
|
||||
DetectedLang: detectedLang,
|
||||
TargetLang: targetLang,
|
||||
}).FPrint(w)
|
||||
}
|
||||
55
backend/controllers/email/translate_test.go
Normal file
55
backend/controllers/email/translate_test.go
Normal file
@ -0,0 +1,55 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/services/translate"
|
||||
"git.pcux.cn/xuans/Smail/utils/context"
|
||||
)
|
||||
|
||||
func TestTranslateEmailDirectText(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[[["你好","Hello",null,null,10]],null,"en"]`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
translate.SetApiBaseURL(ts.URL)
|
||||
translate.SetDefaultClient(ts.Client())
|
||||
|
||||
reqBody := emailTranslateRequest{
|
||||
Subject: "Hello",
|
||||
Text: "Hello",
|
||||
TargetLang: "zh-CN",
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/email/translate", bytes.NewReader(bodyBytes))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ctx := &context.Context{UserID: 1}
|
||||
TranslateEmail(ctx, w, r)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Data emailTranslateResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal response failed: %v", err)
|
||||
}
|
||||
|
||||
if resp.Data.SubjectTranslated != "你好" {
|
||||
t.Errorf("expected SubjectTranslated='你好', got %q", resp.Data.SubjectTranslated)
|
||||
}
|
||||
if resp.Data.ContentTranslated != "你好" {
|
||||
t.Errorf("expected ContentTranslated='你好', got %q", resp.Data.ContentTranslated)
|
||||
}
|
||||
}
|
||||
@ -67,6 +67,7 @@ func registerV1Routes(mux *http.ServeMux) {
|
||||
registerV1MethodRoute(mux, "/api/v1/email/del", "/api/email/del", http.MethodPost, 256<<10, email.EmailDelete)
|
||||
registerV1MethodRoute(mux, "/api/v1/email/read", "/api/email/read", http.MethodPost, 256<<10, email.MarkRead)
|
||||
registerV1MethodRoute(mux, "/api/v1/email/detail", "/api/email/detail", http.MethodPost, 256<<10, email.EmailDetail)
|
||||
registerV1MethodRoute(mux, "/api/v1/email/translate", "/api/email/translate", http.MethodPost, 256<<10, email.TranslateEmail)
|
||||
registerV1MethodRoute(mux, "/api/v1/email/move", "/api/email/move", http.MethodPost, 256<<10, email.Move)
|
||||
registerV1MethodRoute(mux, "/api/v1/email/send", "/api/email/send", http.MethodPost, 40<<20, email.Send)
|
||||
registerV1MethodRoute(mux, "/api/v1/email/draft/save", "/api/email/draft/save", http.MethodPost, 40<<20, email.SaveDraft)
|
||||
|
||||
123
backend/services/translate/translate.go
Normal file
123
backend/services/translate/translate.go
Normal file
@ -0,0 +1,123 @@
|
||||
package translate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var defaultClient = &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
var apiBaseURL = "https://translate.googleapis.com/translate_a/single"
|
||||
|
||||
// Result 包含翻译后内容及源语言
|
||||
type Result struct {
|
||||
OriginalText string `json:"original_text"`
|
||||
TranslatedText string `json:"translated_text"`
|
||||
SourceLang string `json:"source_lang"`
|
||||
TargetLang string `json:"target_lang"`
|
||||
}
|
||||
|
||||
// SetApiBaseURL 设置翻译引擎基础地址(主要用于单元测试与代理)
|
||||
func SetApiBaseURL(u string) {
|
||||
if u != "" {
|
||||
apiBaseURL = u
|
||||
}
|
||||
}
|
||||
|
||||
// SetDefaultClient 设置 HTTP 客户端(主要用于单元测试与超时配置)
|
||||
func SetDefaultClient(c *http.Client) {
|
||||
if c != nil {
|
||||
defaultClient = c
|
||||
}
|
||||
}
|
||||
|
||||
// TranslateText 调用免配置翻译引擎将文本翻译为目标语言(默认 zh-CN)
|
||||
func TranslateText(ctx context.Context, text string, targetLang string) (*Result, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return &Result{
|
||||
OriginalText: "",
|
||||
TranslatedText: "",
|
||||
SourceLang: "auto",
|
||||
TargetLang: targetLang,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if targetLang == "" {
|
||||
targetLang = "zh-CN"
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s?client=gtx&sl=auto&tl=%s&dt=t", apiBaseURL, url.QueryEscape(targetLang))
|
||||
formData := url.Values{}
|
||||
formData.Set("q", text)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建翻译请求失败: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
|
||||
resp, err := defaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求翻译服务失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return nil, fmt.Errorf("翻译服务返回异常状态码 %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取翻译响应失败: %w", err)
|
||||
}
|
||||
|
||||
var rawResponse []any
|
||||
if err := json.Unmarshal(bodyBytes, &rawResponse); err != nil {
|
||||
return nil, fmt.Errorf("解析翻译响应结构失败: %w", err)
|
||||
}
|
||||
|
||||
if len(rawResponse) == 0 {
|
||||
return nil, errors.New("翻译响应格式为空")
|
||||
}
|
||||
|
||||
sentences, ok := rawResponse[0].([]any)
|
||||
if !ok {
|
||||
return nil, errors.New("未找到译文段落列表")
|
||||
}
|
||||
|
||||
var translatedSb strings.Builder
|
||||
for _, item := range sentences {
|
||||
if sentenceArr, ok := item.([]any); ok && len(sentenceArr) > 0 {
|
||||
if seg, ok := sentenceArr[0].(string); ok {
|
||||
translatedSb.WriteString(seg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceLang := "auto"
|
||||
if len(rawResponse) > 2 {
|
||||
if sl, ok := rawResponse[2].(string); ok && sl != "" {
|
||||
sourceLang = sl
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{
|
||||
OriginalText: text,
|
||||
TranslatedText: translatedSb.String(),
|
||||
SourceLang: sourceLang,
|
||||
TargetLang: targetLang,
|
||||
}, nil
|
||||
}
|
||||
61
backend/services/translate/translate_test.go
Normal file
61
backend/services/translate/translate_test.go
Normal file
@ -0,0 +1,61 @@
|
||||
package translate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTranslateEmptyText(t *testing.T) {
|
||||
res, err := TranslateText(context.Background(), "", "zh-CN")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.TranslatedText != "" {
|
||||
t.Fatalf("expected empty translated text, got %q", res.TranslatedText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslateMockServer(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST method, got %s", r.Method)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Errorf("parse form failed: %v", err)
|
||||
}
|
||||
q := r.Form.Get("q")
|
||||
if q != "Hello world" {
|
||||
t.Errorf("expected q='Hello world', got %q", q)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 模拟 Google GTX 翻译返回结构
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`[[["你好世界","Hello world",null,null,10]],null,"en"]`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
oldURL := apiBaseURL
|
||||
oldClient := defaultClient
|
||||
defer func() {
|
||||
SetApiBaseURL(oldURL)
|
||||
SetDefaultClient(oldClient)
|
||||
}()
|
||||
|
||||
SetApiBaseURL(ts.URL)
|
||||
SetDefaultClient(ts.Client())
|
||||
|
||||
res, err := TranslateText(context.Background(), "Hello world", "zh-CN")
|
||||
if err != nil {
|
||||
t.Fatalf("TranslateText failed: %v", err)
|
||||
}
|
||||
|
||||
if res.TranslatedText != "你好世界" {
|
||||
t.Fatalf("expected '你好世界', got %q", res.TranslatedText)
|
||||
}
|
||||
if res.SourceLang != "en" {
|
||||
t.Fatalf("expected source lang 'en', got %q", res.SourceLang)
|
||||
}
|
||||
}
|
||||
@ -256,3 +256,33 @@
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.translate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.translation-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--el-color-primary-light-9, #ecf5ff);
|
||||
color: var(--el-color-primary, #409eff);
|
||||
border: 1px solid var(--el-color-primary-light-7, #d9ecff);
|
||||
border-radius: 8px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.toggle-back-link {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import {onBeforeUnmount, ref, watch} from 'vue'
|
||||
import {computed, onBeforeUnmount, ref, watch} from 'vue'
|
||||
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
|
||||
import {Document, Back, Delete, Download} from '@element-plus/icons-vue';
|
||||
import {Document, Back, Delete, Download, Operation} from '@element-plus/icons-vue';
|
||||
|
||||
import {ElMessage, ElMessageBox} from 'element-plus';
|
||||
|
||||
@ -37,6 +37,12 @@ export function usePage() {
|
||||
const loadError = ref('')
|
||||
let requestSequence = 0
|
||||
|
||||
// 翻译相关状态
|
||||
const isTranslated = ref(false)
|
||||
const translating = ref(false)
|
||||
const translationCache = ref(null)
|
||||
const detectedLang = ref('')
|
||||
|
||||
const parseAddresses = (value) => {
|
||||
try {
|
||||
const addresses = JSON.parse(value || '[]')
|
||||
@ -57,6 +63,12 @@ export function usePage() {
|
||||
loadError.value = ''
|
||||
loading.value = true
|
||||
|
||||
// 重置翻译状态
|
||||
isTranslated.value = false
|
||||
translating.value = false
|
||||
translationCache.value = null
|
||||
detectedLang.value = ''
|
||||
|
||||
const rawId = String(route.params.id || '')
|
||||
const id = Number(rawId)
|
||||
if (!/^\d+$/.test(rawId) || !Number.isSafeInteger(id) || id <= 0) {
|
||||
@ -67,7 +79,6 @@ export function usePage() {
|
||||
|
||||
try {
|
||||
const res = await http.post('/api/v1/email/detail', {id})
|
||||
// 切换邮件或离开页面后,旧请求不得覆盖当前状态。
|
||||
if (sequence !== requestSequence) return
|
||||
if (res?.errorNo !== 0) {
|
||||
loadError.value = res?.errorMsg || '读取邮件失败,请重试'
|
||||
@ -82,7 +93,6 @@ export function usePage() {
|
||||
tos.value = parseAddresses(data.to)
|
||||
ccs.value = parseAddresses(data.cc)
|
||||
showCC.value = ccs.value.length > 0
|
||||
// 仅在本人的发件副本展示密送;后端仍负责所有权校验和收件副本脱敏。
|
||||
bccs.value = data.type === 1 ? parseAddresses(data.bcc) : []
|
||||
showBCC.value = bccs.value.length > 0
|
||||
} catch {
|
||||
@ -99,6 +109,66 @@ export function usePage() {
|
||||
requestSequence++
|
||||
})
|
||||
|
||||
const toggleTranslate = async () => {
|
||||
if (loading.value || loadError.value) return
|
||||
if (isTranslated.value) {
|
||||
isTranslated.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (translationCache.value) {
|
||||
isTranslated.value = true
|
||||
return
|
||||
}
|
||||
|
||||
const emailId = Number(detailData.value?.id)
|
||||
if (!Number.isSafeInteger(emailId) || emailId <= 0) return
|
||||
|
||||
translating.value = true
|
||||
try {
|
||||
const res = await http.post('/api/v1/email/translate', {
|
||||
id: emailId,
|
||||
target_lang: 'zh-CN'
|
||||
})
|
||||
if (res?.errorNo === 0 && res.data) {
|
||||
translationCache.value = res.data
|
||||
detectedLang.value = res.data.detected_lang || 'auto'
|
||||
isTranslated.value = true
|
||||
ElMessage.success('翻译完成')
|
||||
} else {
|
||||
ElMessage.error(res?.errorMsg || '翻译失败,请稍后重试')
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error('翻译服务连接失败,请稍后重试')
|
||||
} finally {
|
||||
translating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const displaySubject = computed(() => {
|
||||
if (isTranslated.value && translationCache.value?.subject_translated) {
|
||||
return translationCache.value.subject_translated
|
||||
}
|
||||
return detailData.value?.subject || ''
|
||||
})
|
||||
|
||||
const displayContent = computed(() => {
|
||||
if (isTranslated.value && translationCache.value?.content_translated) {
|
||||
return translationCache.value.content_translated
|
||||
}
|
||||
if (detailData.value?.html) {
|
||||
return detailData.value.html
|
||||
}
|
||||
return detailData.value?.text || ''
|
||||
})
|
||||
|
||||
const displayIsText = computed(() => {
|
||||
if (isTranslated.value) {
|
||||
return true
|
||||
}
|
||||
return !detailData.value?.html
|
||||
})
|
||||
|
||||
const backToList = () => router.push({name: 'list'})
|
||||
const handleReply = () => {
|
||||
const id = Number(detailData.value.id)
|
||||
@ -178,6 +248,7 @@ export function usePage() {
|
||||
Back,
|
||||
Delete,
|
||||
Download,
|
||||
Operation,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
lang,
|
||||
@ -202,7 +273,14 @@ export function usePage() {
|
||||
formatSenderName,
|
||||
hasSenderName,
|
||||
formatDetailDate,
|
||||
handleDelete
|
||||
handleDelete,
|
||||
isTranslated,
|
||||
translating,
|
||||
detectedLang,
|
||||
displaySubject,
|
||||
displayContent,
|
||||
displayIsText,
|
||||
toggleTranslate
|
||||
}
|
||||
}
|
||||
|
||||
@ -210,5 +288,6 @@ export {
|
||||
Back,
|
||||
Delete,
|
||||
Document,
|
||||
Download
|
||||
Download,
|
||||
Operation
|
||||
}
|
||||
|
||||
@ -5,6 +5,16 @@
|
||||
<el-icon><Back /></el-icon>
|
||||
</el-button>
|
||||
<div v-if="!loading && !loadError" class="action-buttons">
|
||||
<el-button
|
||||
:type="isTranslated ? 'warning' : 'default'"
|
||||
:loading="translating"
|
||||
plain
|
||||
@click="toggleTranslate"
|
||||
class="translate-btn"
|
||||
>
|
||||
<el-icon><Operation /></el-icon>
|
||||
<span>{{ isTranslated ? '查看原文' : '一键翻译' }}</span>
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detailData.type === 0"
|
||||
type="primary"
|
||||
@ -38,7 +48,14 @@
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<h1 class="mail-subject">{{ detailData.subject }}</h1>
|
||||
|
||||
<div v-if="isTranslated" class="translation-banner">
|
||||
<el-icon class="banner-icon"><Operation /></el-icon>
|
||||
<span>已自动翻译为中文(源语言:{{ detectedLang || '自动识别' }})</span>
|
||||
<el-button link type="primary" @click="toggleTranslate" class="toggle-back-link">查看原文</el-button>
|
||||
</div>
|
||||
|
||||
<h1 class="mail-subject">{{ displaySubject }}</h1>
|
||||
|
||||
<div class="mail-meta-card">
|
||||
<div class="meta-left">
|
||||
@ -78,10 +95,10 @@
|
||||
<el-divider class="custom-divider"/>
|
||||
|
||||
<div class="mail-body">
|
||||
<div class="body-text" v-if="detailData.html === ''">
|
||||
{{ detailData.text }}
|
||||
<div class="body-text" v-if="displayIsText">
|
||||
{{ displayContent }}
|
||||
</div>
|
||||
<div class="body-html" v-else v-html="detailData.html"></div>
|
||||
<div class="body-html" v-else v-html="displayContent"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailData.attachments && detailData.attachments.length > 0" class="attachments-section">
|
||||
@ -100,7 +117,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { usePage, Back, Delete, Document, Download } from './EmailDetail.js'
|
||||
import { usePage, Back, Delete, Document, Download, Operation } from './EmailDetail.js'
|
||||
|
||||
const {
|
||||
handleReply,
|
||||
@ -119,7 +136,14 @@ const {
|
||||
bccs,
|
||||
formatSenderName,
|
||||
hasSenderName,
|
||||
formatDetailDate
|
||||
formatDetailDate,
|
||||
isTranslated,
|
||||
translating,
|
||||
detectedLang,
|
||||
displaySubject,
|
||||
displayContent,
|
||||
displayIsText,
|
||||
toggleTranslate
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
|
||||
@ -57,7 +57,7 @@
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span class="settings-label">{{ lang.settings }}</span>
|
||||
</div>
|
||||
<span class="app-version-badge">V2.13.1</span>
|
||||
<span class="app-version-badge">V2.14.0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,12 +2,12 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { compileFunction } from 'node:vm'
|
||||
import { reactive, ref, watch } from './vue.mjs'
|
||||
import { computed, reactive, ref, watch } from './vue.mjs'
|
||||
|
||||
const source = readFileSync(new URL('../../frontend/src/views/EmailDetail/EmailDetail.js', import.meta.url), 'utf8')
|
||||
const createPage = compileFunction(
|
||||
source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage').replace(/export \{[^}]+\}/g, '') + '\nreturn usePage()',
|
||||
['ref', 'watch', 'onBeforeUnmount', 'useRoute', 'useRouter', 'useGroupStore', 'http', 'ElMessage', 'ElMessageBox', 'lang', 'Document', 'Back', 'Delete', 'Download']
|
||||
['computed', 'ref', 'watch', 'onBeforeUnmount', 'useRoute', 'useRouter', 'useGroupStore', 'http', 'ElMessage', 'ElMessageBox', 'lang', 'Document', 'Back', 'Delete', 'Download', 'Operation']
|
||||
)
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve))
|
||||
const email = id => ({errorNo: 0, data: {id, subject: `mail-${id}`, to: '[{"EmailAddress":"test@example.com"}]', cc: '[]', attachments: []}})
|
||||
@ -17,9 +17,9 @@ function fixture(post, id = '15') {
|
||||
const requests = []
|
||||
const navigations = []
|
||||
let unmount
|
||||
const page = createPage(ref, watch, callback => { unmount = callback }, () => route,
|
||||
const page = createPage(computed, ref, watch, callback => { unmount = callback }, () => route,
|
||||
() => ({push: target => navigations.push(target)}), () => ({tag: ''}),
|
||||
{post: async (...args) => { requests.push(args); return post(...args) }}, {}, {}, {}, {}, {}, {}, {})
|
||||
{post: async (...args) => { requests.push(args); return post(...args) }}, {success: () => {}, error: () => {}, warning: () => {}, info: () => {}}, {}, {}, {}, {}, {}, {})
|
||||
return {page, route, requests, navigations, unmount: () => unmount()}
|
||||
}
|
||||
|
||||
@ -142,3 +142,65 @@ test('离开详情后未完成请求不再写入状态', async () => {
|
||||
await settle()
|
||||
assert.equal(page.detailData.value.id, undefined)
|
||||
})
|
||||
|
||||
test('一键翻译与查看原文切换,支持缓存翻译结果', async () => {
|
||||
let translateCalls = 0
|
||||
const postHandler = async (url, data) => {
|
||||
if (url === '/api/v1/email/detail') {
|
||||
return {
|
||||
errorNo: 0,
|
||||
data: {
|
||||
id: 15,
|
||||
subject: 'Hello World',
|
||||
text: 'This is an English email.',
|
||||
type: 0,
|
||||
to: '[]',
|
||||
cc: '[]',
|
||||
attachments: []
|
||||
}
|
||||
}
|
||||
}
|
||||
if (url === '/api/v1/email/translate') {
|
||||
translateCalls++
|
||||
return {
|
||||
errorNo: 0,
|
||||
data: {
|
||||
id: 15,
|
||||
subject_translated: '你好世界',
|
||||
content_translated: '这是一封英文邮件。',
|
||||
detected_lang: 'en'
|
||||
}
|
||||
}
|
||||
}
|
||||
return { errorNo: 500, errorMsg: '未知请求' }
|
||||
}
|
||||
|
||||
const { page } = fixture(postHandler, '15')
|
||||
await settle()
|
||||
|
||||
assert.equal(page.displaySubject.value, 'Hello World')
|
||||
assert.equal(page.displayContent.value, 'This is an English email.')
|
||||
assert.equal(page.isTranslated.value, false)
|
||||
|
||||
// 第一次触发翻译
|
||||
await page.toggleTranslate()
|
||||
assert.equal(translateCalls, 1)
|
||||
assert.equal(page.isTranslated.value, true)
|
||||
assert.equal(page.displaySubject.value, '你好世界')
|
||||
assert.equal(page.displayContent.value, '这是一封英文邮件。')
|
||||
assert.equal(page.detectedLang.value, 'en')
|
||||
|
||||
// 点击查看原文
|
||||
await page.toggleTranslate()
|
||||
assert.equal(translateCalls, 1)
|
||||
assert.equal(page.isTranslated.value, false)
|
||||
assert.equal(page.displaySubject.value, 'Hello World')
|
||||
assert.equal(page.displayContent.value, 'This is an English email.')
|
||||
|
||||
// 再次切换到译文(应命中本地缓存,不重复发请求)
|
||||
await page.toggleTranslate()
|
||||
assert.equal(translateCalls, 1)
|
||||
assert.equal(page.isTranslated.value, true)
|
||||
assert.equal(page.displaySubject.value, '你好世界')
|
||||
assert.equal(page.displayContent.value, '这是一封英文邮件。')
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user