From 8cf7c55a0b0bcd870124c1307d28a28d7a11f439 Mon Sep 17 00:00:00 2001 From: smail Date: Sun, 18 Oct 2026 11:02:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(email):=20=E5=AE=9E=E7=8E=B0=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E8=AF=A6=E6=83=85=E5=85=8D=E9=85=8D=E7=BD=AE=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E7=BF=BB=E8=AF=91=E4=B8=8E=E5=8E=9F=E6=96=87=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E5=8A=9F=E8=83=BD=E5=B9=B6=E5=8F=91=E5=B8=83V2.14.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/email/translate.go | 116 +++++++++++++++++ backend/controllers/email/translate_test.go | 55 ++++++++ backend/listen/http_server/api_v1_compat.go | 1 + backend/services/translate/translate.go | 123 ++++++++++++++++++ backend/services/translate/translate_test.go | 61 +++++++++ .../src/views/EmailDetail/EmailDetail.css | 30 +++++ frontend/src/views/EmailDetail/EmailDetail.js | 91 ++++++++++++- .../src/views/EmailDetail/EmailDetail.vue | 36 ++++- .../views/components/HomeAside/HomeAside.vue | 2 +- test/frontend/email-detail.test.mjs | 70 +++++++++- 10 files changed, 568 insertions(+), 17 deletions(-) create mode 100644 backend/controllers/email/translate.go create mode 100644 backend/controllers/email/translate_test.go create mode 100644 backend/services/translate/translate.go create mode 100644 backend/services/translate/translate_test.go diff --git a/backend/controllers/email/translate.go b/backend/controllers/email/translate.go new file mode 100644 index 00000000..80e0052f --- /dev/null +++ b/backend/controllers/email/translate.go @@ -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) +} diff --git a/backend/controllers/email/translate_test.go b/backend/controllers/email/translate_test.go new file mode 100644 index 00000000..65f0e7e0 --- /dev/null +++ b/backend/controllers/email/translate_test.go @@ -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) + } +} diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index 95b32b21..f2fa7409 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -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) diff --git a/backend/services/translate/translate.go b/backend/services/translate/translate.go new file mode 100644 index 00000000..a30c9cd9 --- /dev/null +++ b/backend/services/translate/translate.go @@ -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 +} diff --git a/backend/services/translate/translate_test.go b/backend/services/translate/translate_test.go new file mode 100644 index 00000000..853d974f --- /dev/null +++ b/backend/services/translate/translate_test.go @@ -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) + } +} diff --git a/frontend/src/views/EmailDetail/EmailDetail.css b/frontend/src/views/EmailDetail/EmailDetail.css index 409303e0..143f8f86 100644 --- a/frontend/src/views/EmailDetail/EmailDetail.css +++ b/frontend/src/views/EmailDetail/EmailDetail.css @@ -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; +} diff --git a/frontend/src/views/EmailDetail/EmailDetail.js b/frontend/src/views/EmailDetail/EmailDetail.js index 62ccd289..6699d170 100644 --- a/frontend/src/views/EmailDetail/EmailDetail.js +++ b/frontend/src/views/EmailDetail/EmailDetail.js @@ -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 } diff --git a/frontend/src/views/EmailDetail/EmailDetail.vue b/frontend/src/views/EmailDetail/EmailDetail.vue index cb4d4230..0a41b114 100644 --- a/frontend/src/views/EmailDetail/EmailDetail.vue +++ b/frontend/src/views/EmailDetail/EmailDetail.vue @@ -5,6 +5,16 @@
+ + + {{ isTranslated ? '查看原文' : '一键翻译' }} + -

{{ detailData.subject }}

+ +
+ + 已自动翻译为中文(源语言:{{ detectedLang || '自动识别' }}) + 查看原文 +
+ +

{{ displaySubject }}

@@ -78,10 +95,10 @@
-
- {{ detailData.text }} +
+ {{ displayContent }}
-
+
@@ -100,7 +117,7 @@ diff --git a/frontend/src/views/components/HomeAside/HomeAside.vue b/frontend/src/views/components/HomeAside/HomeAside.vue index e1451e3d..b8e10084 100644 --- a/frontend/src/views/components/HomeAside/HomeAside.vue +++ b/frontend/src/views/components/HomeAside/HomeAside.vue @@ -57,7 +57,7 @@ {{ lang.settings }}
- V2.13.1 + V2.14.0
diff --git a/test/frontend/email-detail.test.mjs b/test/frontend/email-detail.test.mjs index 4378fba5..b390a4ea 100644 --- a/test/frontend/email-detail.test.mjs +++ b/test/frontend/email-detail.test.mjs @@ -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, '这是一封英文邮件。') +})