- API Key
- {credentialLabels[credentialStatus]}
+ {draft.translationProvider === "baidu" ? `${copy.appId} + ${copy.secretKey}` : copy.apiKey}
+ {credentialLabel}
-
- Save API Key
+ {draft.translationProvider === "baidu" && onSaveCredential("baidu", "app-id")}>{copy.configure} {copy.appId} }
+ onSaveCredential(draft.translationProvider, "api-key")}>
+ {copy.configure} {draft.translationProvider === "baidu" ? copy.secretKey : copy.apiKey}
onTestCredential(draft.translationProvider)}
disabled={credentialStatus === "missing" || credentialStatus === "testing"}
>
- {credentialStatus === "testing" ? "Testing…" : "Test API Key"}
+ {credentialStatus === "testing" ? copy.testing : copy.test}
{confirmRemoval ? (
<>
@@ -223,18 +254,18 @@ export function SettingsPanel({
className="button button--danger"
type="button"
onClick={() => {
- onRemoveCredential();
+ onRemoveCredential(draft.translationProvider);
setConfirmRemoval(false);
}}
>
- Confirm Removal
+ {copy.confirmRemoval}
setConfirmRemoval(false)}
>
- Cancel
+ {copy.cancel}
>
) : (
@@ -244,7 +275,7 @@ export function SettingsPanel({
onClick={() => setConfirmRemoval(true)}
disabled={credentialStatus === "missing"}
>
- Remove API Key
+ {copy.remove}
)}
@@ -255,27 +286,24 @@ export function SettingsPanel({
05
-
Privacy & Usage
-
Know what leaves your Mac and when.
+
{copy.privacy}
+
{copy.privacyHint}
- Selected text is sent to Google only after you choose Translate.
-
- API usage may incur charges and is subject to billing, quota, and API key
- restrictions.
-
- No content history is stored.
+ {copy.privacyOne}
+ {copy.privacyTwo}
+ {copy.privacyThree}
- Quit Desktop Translator
+ {copy.quit}
- Save Changes
+ {copy.save}
diff --git a/src/components/vocabulary/PracticeView.tsx b/src/components/vocabulary/PracticeView.tsx
index d243ade..3999ee1 100644
--- a/src/components/vocabulary/PracticeView.tsx
+++ b/src/components/vocabulary/PracticeView.tsx
@@ -1,18 +1,25 @@
import { useEffect, useRef, useState } from "react";
-import type { PartOfSpeech, PracticeDirection, StudyPracticeOutcome, StudyPracticeQuestion } from "../../contracts/ipc";
+import type { PartOfSpeech, PracticeDirection, StudyPracticeOutcome, StudyPracticeQuestion, UiLocale } from "../../contracts/ipc";
import type { StudyApi } from "./VocabularyWindow";
interface PracticeViewProps {
api: StudyApi;
revision: number;
+ locale?: UiLocale;
}
-const directions: { value: PracticeDirection; label: string; note: string }[] = [
- { value: "random", label: "Mix", note: "Both directions" },
- { value: "source-to-target", label: "Word → meaning", note: "Recognition" },
- { value: "target-to-source", label: "Meaning → word", note: "Production" },
-];
+function directions(zh: boolean): { value: PracticeDirection; label: string; note: string }[] {
+ return zh ? [
+ { value: "random", label: "双向混合", note: "随机方向" },
+ { value: "source-to-target", label: "单词 → 释义", note: "识别" },
+ { value: "target-to-source", label: "释义 → 单词", note: "回忆" },
+ ] : [
+ { value: "random", label: "Mix", note: "Both directions" },
+ { value: "source-to-target", label: "Word → meaning", note: "Recognition" },
+ { value: "target-to-source", label: "Meaning → word", note: "Production" },
+ ];
+}
const partOfSpeechLabels: Record
= {
adjective: "adj.", adverb: "adv.", article: "art.", conjunction: "conj.", determiner: "det.",
@@ -30,7 +37,8 @@ function lexicalTextClass(value: string) {
return value.length >= 11 ? "lexical-text lexical-text--long" : "lexical-text";
}
-export function PracticeView({ api, revision }: PracticeViewProps) {
+export function PracticeView({ api, revision, locale = "en" }: PracticeViewProps) {
+ const zh = locale === "zh-CN";
const [direction, setDirection] = useState("random");
const [question, setQuestion] = useState();
const [outcome, setOutcome] = useState();
@@ -54,7 +62,7 @@ export function PracticeView({ api, revision }: PracticeViewProps) {
}).catch(() => {
if (request !== questionRequest.current) return;
setQuestion(null);
- setError("A practice question could not be prepared.");
+ setError(zh ? "无法生成练习题。" : "A practice question could not be prepared.");
});
};
@@ -62,7 +70,7 @@ export function PracticeView({ api, revision }: PracticeViewProps) {
let current = true;
void api.getPracticePreferences().then((preferences) => {
if (current) setDirection(preferences.direction);
- }).catch(() => setError("Your practice preference could not be opened."));
+ }).catch(() => setError(zh ? "无法读取练习偏好。" : "Your practice preference could not be opened."));
loadQuestion();
return () => { current = false; };
}, [api]);
@@ -82,7 +90,7 @@ export function PracticeView({ api, revision }: PracticeViewProps) {
loadQuestion();
}).catch(() => {
setFailedDirection(next);
- setError("Your practice direction could not be saved.");
+ setError(zh ? "无法保存练习方向。" : "Your practice direction could not be saved.");
}).finally(() => setSaving(false));
};
@@ -93,7 +101,7 @@ export function PracticeView({ api, revision }: PracticeViewProps) {
setError(undefined);
void api.submitPracticeAnswer(questionValue.entryId, questionValue.direction, answer)
.then(setOutcome)
- .catch(() => setError("Your answer could not be saved."))
+ .catch(() => setError(zh ? "无法保存答案。" : "Your answer could not be saved."))
.finally(() => {
submittingRef.current = false;
setSubmitting(false);
@@ -101,13 +109,13 @@ export function PracticeView({ api, revision }: PracticeViewProps) {
};
return
-
- Practice direction {directions.map((item) => chooseDirection(item.value)} />{item.label} {item.note} )}
- {error && {error} failedDirection ? chooseDirection(failedDirection) : loadQuestion()}>{failedDirection ? "Try saving again" : "Try again"}
}
- {question === undefined ? Choosing what needs attention…
: question === null ? You have practised every available word. Come back after recall has faded, or add another word to continue.
:
+
+ {zh ? "练习方向" : "Practice direction"} {directions(zh).map((item) => chooseDirection(item.value)} />{item.label} {item.note} )}
+ {error && {error} failedDirection ? chooseDirection(failedDirection) : loadQuestion()}>{failedDirection ? (zh ? "重新保存" : "Try saving again") : (zh ? "重试" : "Try again")}
}
+ {question === undefined ? {zh ? "正在挑选需要复习的词汇…" : "Choosing what needs attention…"}
: question === null ? {zh ? "当前词汇都已练习完毕。" : "You have practised every available word."} {zh ? "记忆分数回落后再来,或添加新词继续练习。" : "Come back after recall has faded, or add another word to continue."}
:
{question.promptLanguage.toUpperCase()} → {question.answerLanguage.toUpperCase()}
- {question.choices.map((choice) =>
setSelected(choice.value)}>{choice.value} )}
- {outcome ? <>
{outcome.correct ? "✓" : "↺"} {outcome.correct ? "Correct" : "Review this answer"} {outcome.correct ? `Recall is now ${Math.round(outcome.entry.effectiveRecall)}.` : `The answer is “${outcome.correctAnswer}”.`}
Next word > :
selected && submit(question, selected)}>{submitting ? "Checking…" : "Check answer"} }
+ {question.choices.map((choice) =>
setSelected(choice.value)}>{choice.value} )}
+ {outcome ? <>
{outcome.correct ? "✓" : "↺"} {outcome.correct ? (zh ? "正确" : "Correct") : (zh ? "复习这个答案" : "Review this answer")} {outcome.correct ? (zh ? `记忆分数现为 ${Math.round(outcome.entry.effectiveRecall)}。` : `Recall is now ${Math.round(outcome.entry.effectiveRecall)}.`) : (zh ? `正确答案是“${outcome.correctAnswer}”。` : `The answer is “${outcome.correctAnswer}”.`)}
{zh ? "下一个词" : "Next word"} > :
selected && submit(question, selected)}>{submitting ? (zh ? "正在检查…" : "Checking…") : (zh ? "检查答案" : "Check answer")} }
}
;
}
diff --git a/src/components/vocabulary/RelatedWordsView.tsx b/src/components/vocabulary/RelatedWordsView.tsx
index 2a9e4e6..1a69fd3 100644
--- a/src/components/vocabulary/RelatedWordsView.tsx
+++ b/src/components/vocabulary/RelatedWordsView.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
-import type { RelatedWord, VocabularyEntry, VocabularyProvenance } from "../../contracts/ipc";
+import type { RelatedWord, UiLocale, VocabularyEntry, VocabularyProvenance } from "../../contracts/ipc";
import { PartOfSpeechBadge } from "./PracticeView";
import type { StudyApi } from "./VocabularyWindow";
@@ -8,10 +8,12 @@ interface RelatedWordsViewProps {
anchor?: VocabularyEntry;
api: StudyApi;
revision: number;
+ locale?: UiLocale;
onBack: () => void;
}
-export function RelatedWordsView({ anchor, api, revision, onBack }: RelatedWordsViewProps) {
+export function RelatedWordsView({ anchor, api, revision, locale = "en", onBack }: RelatedWordsViewProps) {
+ const zh = locale === "zh-CN";
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
@@ -39,25 +41,25 @@ export function RelatedWordsView({ anchor, api, revision, onBack }: RelatedWords
setError(undefined);
void api.listRelated(anchor.id)
.then((next) => { if (request === relatedRequest.current) setItems(next); })
- .catch(() => { if (request === relatedRequest.current) setError("Related words could not be loaded."); })
+ .catch(() => { if (request === relatedRequest.current) setError(zh ? "无法加载相关词。" : "Related words could not be loaded."); })
.finally(() => { if (request === relatedRequest.current) setLoading(false); });
- }, [anchor, api, revision]);
+ }, [anchor, api, revision, zh]);
return
-
+
{provenance.length > 0 &&
- Textbook source details {provenance.length}
+ {zh ? "词书来源详情" : "Textbook source details"} {provenance.length}
{provenance.map((item) =>
{item.textbookTitle}
-
Version {item.textbookVersion} · {item.license}
+
{zh ? "版本" : "Version"} {item.textbookVersion} · {item.license}
{item.attribution}
-
View source
+
{zh ? "查看来源" : "View source"}
)}
}
{error && {error}
}
- {!anchor ? Choose a word first. Open a card in My wordbook to make it the connection anchor.
: loading ? Tracing connections…
: items.length === 0 ? No compatible connections yet. Try another word as your local collection grows.
: {items.map((item) =>
- {item.reason === "root" ? "shared root" : "shared meaning"} {item.sourceText} {item.translatedText}
- {item.origins.map((origin) => {origin.kind === "personal" ? "Personal" : origin.textbookTitle} )}
+ {!anchor ? {zh ? "请先选择一个词。" : "Choose a word first."} {zh ? "从“我的词汇本”打开一张卡片作为关联词。" : "Open a card in My wordbook to make it the connection anchor."}
: loading ? {zh ? "正在查找关联…" : "Tracing connections…"}
: items.length === 0 ? {zh ? "暂未找到兼容的关联。" : "No compatible connections yet."} {zh ? "随着本地词汇积累,可以尝试其他词。" : "Try another word as your local collection grows."}
: {items.map((item) =>
+ {item.reason === "root" ? (zh ? "同词根" : "shared root") : (zh ? "同义项" : "shared meaning")} {item.sourceText} {item.translatedText}
+ {item.origins.map((origin) => {origin.kind === "personal" ? (zh ? "个人词汇本" : "Personal") : origin.textbookTitle} )}
{item.kind === "textbook" && {
if (item.promoted || !item.textbookEntryId) return;
setAdding(item.textbookEntryId);
@@ -65,8 +67,8 @@ export function RelatedWordsView({ anchor, api, revision, onBack }: RelatedWords
void api.addTextbookEntry(item.textbookEntryId).then(() => {
setItems((current) => current.map((candidate) => candidate.textbookEntryId === item.textbookEntryId ? { ...candidate, promoted: true } : candidate));
api.refreshPersonal();
- }).catch(() => setError("This related word could not be added.")).finally(() => setAdding(undefined));
- }}>{item.promoted ? "Added" : adding === item.textbookEntryId ? "Adding…" : "Add"} }
+ }).catch(() => setError(zh ? "无法添加这个相关词。" : "This related word could not be added.")).finally(() => setAdding(undefined));
+ }}>{item.promoted ? (zh ? "已添加" : "Added") : adding === item.textbookEntryId ? (zh ? "正在添加…" : "Adding…") : (zh ? "添加" : "Add")}}
)}
}
;
}
diff --git a/src/components/vocabulary/TextbooksView.tsx b/src/components/vocabulary/TextbooksView.tsx
index e287c16..773923d 100644
--- a/src/components/vocabulary/TextbooksView.tsx
+++ b/src/components/vocabulary/TextbooksView.tsx
@@ -4,6 +4,7 @@ import type {
InstalledTextbook,
TextbookCatalogItem,
TextbookEntry,
+ UiLocale,
} from "../../contracts/ipc";
import type { StudyApi } from "./VocabularyWindow";
import { PartOfSpeechBadge } from "./PracticeView";
@@ -11,26 +12,27 @@ import { PartOfSpeechBadge } from "./PracticeView";
type ShelfTab = "discover" | "downloaded";
const PAGE_SIZE = 40;
-const catalogPresentation: Record = {
- "wikdict-en-zh-2026-06": { description: "A broad English reference for lookup, discovery, and uncommon words.", scope: "General reference", count: 30_518 },
- "ngsl-en-zh-1-2": { description: "High-frequency vocabulary for daily reading and conversation.", scope: "Everyday · NGSL", count: 2_809 },
- "nawl-en-zh-1-2": { description: "Vocabulary with high coverage across general academic texts.", scope: "Academic · NAWL", count: 957 },
- "tsl-en-zh-1-2": { description: "A focused service list for TOEIC listening and reading preparation.", scope: "TOEIC · TSL", count: 1_250 },
- "bsl-en-zh-1-20": { description: "High-frequency vocabulary for workplace and business communication.", scope: "Business · BSL", count: 1_744 },
+const catalogPresentation: Record = {
+ "wikdict-en-zh-2026-06": { description: "A broad English reference for lookup, discovery, and uncommon words.", descriptionZh: "适合查询、拓展和学习非常用词的综合英语词典。", scope: "General reference", scopeZh: "综合参考", count: 30_518 },
+ "ngsl-en-zh-1-2": { description: "High-frequency vocabulary for daily reading and conversation.", descriptionZh: "覆盖日常阅读和交流中的高频词汇。", scope: "Everyday · NGSL", scopeZh: "日常 · NGSL", count: 2_809 },
+ "nawl-en-zh-1-2": { description: "Vocabulary with high coverage across general academic texts.", descriptionZh: "覆盖一般学术文本中的常用词汇。", scope: "Academic · NAWL", scopeZh: "学术 · NAWL", count: 957 },
+ "tsl-en-zh-1-2": { description: "A focused service list for TOEIC listening and reading preparation.", descriptionZh: "面向 TOEIC 听力和阅读备考的精简词表。", scope: "TOEIC · TSL", scopeZh: "TOEIC · TSL", count: 1_250 },
+ "bsl-en-zh-1-20": { description: "High-frequency vocabulary for workplace and business communication.", descriptionZh: "覆盖职场和商务交流中的高频词汇。", scope: "Business · BSL", scopeZh: "商务 · BSL", count: 1_744 },
};
-function presentation(item: TextbookCatalogItem) {
+function presentation(item: TextbookCatalogItem, zh: boolean) {
const curated = catalogPresentation[item.id];
return {
- description: item.description ?? curated?.description ?? "A curated English vocabulary reference.",
- scope: item.scope ?? curated?.scope ?? "Curated vocabulary",
+ description: zh ? curated?.descriptionZh ?? item.description ?? "精选英语词汇参考。" : item.description ?? curated?.description ?? "A curated English vocabulary reference.",
+ scope: zh ? curated?.scopeZh ?? item.scope ?? "精选词汇" : item.scope ?? curated?.scope ?? "Curated vocabulary",
count: item.estimatedEntryCount ?? curated?.count,
- script: item.script ?? "Simplified Chinese",
+ script: zh ? "简体中文" : item.script ?? "Simplified Chinese",
};
}
interface TextbooksViewProps {
api: StudyApi;
+ locale?: UiLocale;
}
function message(error: unknown, fallback: string) {
@@ -40,7 +42,9 @@ function message(error: unknown, fallback: string) {
return fallback;
}
-export function TextbooksView({ api }: TextbooksViewProps) {
+export function TextbooksView({ api, locale = "en" }: TextbooksViewProps) {
+ const zh = locale === "zh-CN";
+ const tr = (english: string, chinese: string) => zh ? chinese : english;
const [tab, setTab] = useState("discover");
const [catalog, setCatalog] = useState([]);
const [downloaded, setDownloaded] = useState([]);
@@ -151,24 +155,24 @@ export function TextbooksView({ api }: TextbooksViewProps) {
- {refreshItem && This download predates word details such as parts of speech. install(refreshItem)}>{busyBook === openBook.id ? "Refreshing…" : "Add parts of speech"}
}
- {entryError && {entryError} loadEntries(openBook, entrySearch, entryOffset)}>Try again
}
+ {refreshItem && {tr("This download predates word details such as parts of speech.", "此版本缺少词性等词汇详情。")} install(refreshItem)}>{busyBook === openBook.id ? tr("Refreshing…", "更新中…") : tr("Add parts of speech", "补充词性")}
}
+ {entryError && {entryError} loadEntries(openBook, entrySearch, entryOffset)}>{tr("Try again", "重试")}
}
{entryLoading ? (
- Opening this textbook… Reading its local index.
+ {tr("Opening this textbook…", "正在打开词书…")} {tr("Reading its local index.", "正在读取本地索引。")}
) : entries.length === 0 ? (
- No matching words. Try a shorter spelling or clear the search.
+ {tr("No matching words.", "没有匹配的词汇。")} {tr("Try a shorter spelling or clear the search.", "可缩短拼写或清空搜索条件。")}
) : (
<>
@@ -186,7 +190,7 @@ export function TextbooksView({ api }: TextbooksViewProps) {
setAdded((current) => new Set(current).add(entry.id));
api.refreshPersonal();
}).catch((error) => setEntryError(message(error, "This word could not be added."))).finally(() => setAdding(undefined));
- }}>{isAdded ? "Added" : adding === entry.id ? "Adding…" : "Add to my wordbook"}
+ }}>{isAdded ? tr("Added", "已添加") : adding === entry.id ? tr("Adding…", "添加中…") : tr("Add to my wordbook", "添加到我的词汇本")}
);
})}
@@ -194,8 +198,8 @@ export function TextbooksView({ api }: TextbooksViewProps) {
{pageStart.toLocaleString()}–{pageEnd.toLocaleString()} of {entryTotal.toLocaleString()}
- loadEntries(openBook, entrySearch, Math.max(0, entryOffset - PAGE_SIZE))}>Previous
- = entryTotal} onClick={() => loadEntries(openBook, entrySearch, entryOffset + PAGE_SIZE)}>Next
+ loadEntries(openBook, entrySearch, Math.max(0, entryOffset - PAGE_SIZE))}>{tr("Previous", "上一页")}
+ = entryTotal} onClick={() => loadEntries(openBook, entrySearch, entryOffset + PAGE_SIZE)}>{tr("Next", "下一页")}
>
@@ -207,10 +211,10 @@ export function TextbooksView({ api }: TextbooksViewProps) {
return (
- Textbooks
Textbook shelf Choose a learning path with clear Simplified Chinese meanings, then make one book active.
+ {tr("Textbooks", "词书")}
{tr("Textbook shelf", "词书架")} {tr("Choose a learning path with clear Simplified Chinese meanings, then make one book active.", "选择带有清晰简体中文释义的学习路径,并将其中一本设为当前词书。")}
- setTab("discover")}>Discover
- setTab("downloaded")}>Downloaded
+ setTab("discover")}>{tr("Discover", "发现")}
+ setTab("downloaded")}>{tr("Downloaded", "已下载")}
@@ -222,7 +226,7 @@ export function TextbooksView({ api }: TextbooksViewProps) {
const installed = installedById.get(item.id);
const update = installed && installed.version !== item.version;
const metadataRefresh = installed?.metadataRefreshAvailable === true;
- const details = presentation(item);
+ const details = presentation(item, zh);
return
{details.scope}
@@ -231,13 +235,13 @@ export function TextbooksView({ api }: TextbooksViewProps) {
{details.description}
{details.scope}
- {details.count && {details.count.toLocaleString()} words }
+ {details.count && {details.count.toLocaleString()} {tr("words", "词")} }
{details.script}
{item.attribution}
-
+
- install(item)}>{busyBook === item.id ? "Downloading…" : metadataRefresh ? "Add parts of speech" : update ? "Update" : installed ? "Downloaded" : "Download"}
+ install(item)}>{busyBook === item.id ? tr("Downloading…", "下载中…") : metadataRefresh ? tr("Add parts of speech", "补充词性") : update ? tr("Update", "更新") : installed ? tr("Downloaded", "已下载") : tr("Download", "下载")}
;
})}
)}
@@ -245,16 +249,16 @@ export function TextbooksView({ api }: TextbooksViewProps) {
) : (
{downloadedError &&
{downloadedError}
}
- {downloadedLoading ?
Reading your shelf…
: downloaded.length === 0 ?
No downloaded textbooks yet. Open Discover to add a curated local reference. setTab("discover")}>Browse Discover
: (
+ {downloadedLoading ?
{tr("Reading your shelf…", "正在读取词书架…")}
: downloaded.length === 0 ?
{tr("No downloaded textbooks yet.", "尚未下载词书。")} {tr("Open Discover to add a curated local reference.", "前往“发现”添加精选本地词书。")} setTab("discover")}>{tr("Browse Discover", "浏览发现")}
: (
{downloaded.map((book) => {
const refreshItem = book.metadataRefreshAvailable ? catalog.find((item) => item.id === book.id) : undefined;
return
- Aa {book.active ? "Active textbook" : "Downloaded"} {book.title} {book.sourceLanguage.toUpperCase()} → {book.targetLanguage.toUpperCase()} · {book.entryCount.toLocaleString()} words
+ Aa {book.active ? tr("Active textbook", "当前词书") : tr("Downloaded", "已下载")} {book.title} {book.sourceLanguage.toUpperCase()} → {book.targetLanguage.toUpperCase()} · {book.entryCount.toLocaleString()} {tr("words", "词")}
- { setEntrySearch(""); loadEntries(book, "", 0); }}>Browse words
- {refreshItem && install(refreshItem)}>{busyBook === book.id ? "Refreshing…" : "Add parts of speech"} }
- updateShelf(() => api.setActiveTextbook(book.active ? undefined : book.id), book.id)}>{book.active ? "Deactivate" : "Make active"}
- {confirmRemove === book.id ? Remove local copy? updateShelf(() => api.removeTextbook(book.id), book.id).then((removed) => { if (removed) setConfirmRemove(undefined); })}>Remove setConfirmRemove(undefined)}>Cancel : setConfirmRemove(book.id)}>Remove }
+ { setEntrySearch(""); loadEntries(book, "", 0); }}>{tr("Browse words", "浏览词汇")}
+ {refreshItem && install(refreshItem)}>{busyBook === book.id ? tr("Refreshing…", "更新中…") : tr("Add parts of speech", "补充词性")} }
+ updateShelf(() => api.setActiveTextbook(book.active ? undefined : book.id), book.id)}>{book.active ? tr("Deactivate", "停用") : tr("Make active", "设为当前")}
+ {confirmRemove === book.id ? {tr("Remove local copy?", "移除本地副本?")} updateShelf(() => api.removeTextbook(book.id), book.id).then((removed) => { if (removed) setConfirmRemove(undefined); })}>{tr("Remove", "移除")} setConfirmRemove(undefined)}>{tr("Cancel", "取消")} : setConfirmRemove(book.id)}>{tr("Remove", "移除")} }
})}
)}
diff --git a/src/components/vocabulary/VocabularyWindow.test.tsx b/src/components/vocabulary/VocabularyWindow.test.tsx
index a5f5959..9cc6f7c 100644
--- a/src/components/vocabulary/VocabularyWindow.test.tsx
+++ b/src/components/vocabulary/VocabularyWindow.test.tsx
@@ -141,6 +141,18 @@ describe("VocabularyWindow", () => {
expect(ruler?.querySelector(".recall-ruler__label")).toBeNull();
});
+ it("localizes word-card learning and action labels in Simplified Chinese", () => {
+ act(() => root.render(
));
+
+ expect(container.textContent).toContain("4 次查词");
+ expect(container.textContent).toContain("正在形成");
+ expect(container.querySelector(".recall-ruler .sr-only")?.textContent).toBe("记忆度 43/100");
+ expect(container.querySelector
('[aria-label="朗读 hello"]')?.title).toBe("朗读 hello");
+ expect(container.querySelector('[aria-label="朗读 hola"]')?.title).toBe("没有已安装的语音支持此语言");
+ expect(container.querySelector('[aria-label="管理 hello"]')?.title).toBe("管理词汇");
+ expect(container.querySelector('[aria-label="查看 hello 的相关词"]')?.title).toBe("查找相关词");
+ });
+
it("keeps the frame fixed and gives the active content one explicit scroll owner", () => {
act(() => root.render( ));
@@ -153,6 +165,38 @@ describe("VocabularyWindow", () => {
expect(scroller?.getAttribute("data-scroll-owner")).toBe("study-content");
});
+ it("localizes the complete practice experience in Simplified Chinese", async () => {
+ const api = makeStudyApi({
+ getPracticeQuestion: vi.fn().mockResolvedValue({
+ entryId: 1,
+ direction: "target-to-source",
+ prompt: "知识",
+ promptLanguage: "zh-CN",
+ answerLanguage: "en",
+ promptPartOfSpeech: "noun",
+ correctAnswer: "knowledge",
+ choices: [
+ { value: "knowledge", partOfSpeech: "noun" },
+ { value: "cognizance", partOfSpeech: "noun" },
+ ],
+ }),
+ });
+ act(() => root.render( ));
+
+ const practice = [...container.querySelectorAll(".study-nav")]
+ .find((button) => button.textContent === "练习");
+ act(() => practice?.click());
+ await flushEffects();
+
+ expect(container.textContent).toContain("选择答案");
+ expect(container.textContent).toContain("双向混合");
+ expect(container.textContent).toContain("单词 → 释义");
+ expect(container.textContent).toContain("释义 → 单词");
+ expect(container.textContent).toContain("检查答案");
+ expect(container.textContent).not.toContain("Choose the answer");
+ expect(container.textContent).not.toContain("Both directions");
+ });
+
it("contains document scrolling and gives the content scroller a quiet visual treatment", () => {
expect(appCss).toMatch(/\.app-surface\.app-surface--study\s*\{[^}]*position:\s*fixed[^}]*inset:\s*0[^}]*overflow:\s*hidden/s);
expect(appCss).not.toMatch(/html,\s*body,\s*#root\s*\{[^}]*overflow:\s*hidden/s);
@@ -526,6 +570,17 @@ describe("VocabularyWindow", () => {
expect(container.textContent).not.toContain("Connections for hello");
});
+ it("localizes the card-scoped related view in Simplified Chinese", async () => {
+ const api = makeStudyApi();
+ act(() => root.render( ));
+ act(() => [...container.querySelectorAll("button")].find((button) => button.getAttribute("aria-label") === "查看 hello 的相关词")?.click());
+ await flushEffects();
+
+ expect(container.textContent).toContain("返回我的词汇本");
+ expect(container.textContent).toContain("hello 的关联");
+ expect(container.textContent).not.toContain("Connections for hello");
+ });
+
it("masks textbook entries above and below the sticky pagination dock", () => {
expect(appCss).toMatch(/\.textbook-pagination\s*\{[^}]*isolation:\s*isolate/s);
expect(appCss).toMatch(/\.textbook-pagination::before\s*\{[^}]*bottom:\s*-40px[^}]*background:\s*var\(--study-paper\)/s);
diff --git a/src/components/vocabulary/VocabularyWindow.tsx b/src/components/vocabulary/VocabularyWindow.tsx
index 4f5a411..9c6ea53 100644
--- a/src/components/vocabulary/VocabularyWindow.tsx
+++ b/src/components/vocabulary/VocabularyWindow.tsx
@@ -14,6 +14,7 @@ import type {
TextbookPromotionResult,
VocabularyEntry,
VocabularyProvenance,
+ UiLocale,
} from "../../contracts/ipc";
import { PartOfSpeechBadge, PracticeView } from "./PracticeView";
import { RelatedWordsView } from "./RelatedWordsView";
@@ -22,6 +23,7 @@ import { TextbooksView } from "./TextbooksView";
type StudyView = "library" | "related" | "practice" | "textbooks";
interface VocabularyWindowProps {
+ locale?: UiLocale;
entries: readonly VocabularyEntry[];
loading: boolean;
error?: string;
@@ -57,14 +59,17 @@ export interface StudyApi {
refreshPersonal: () => void;
}
-const familiarityNames = ["New", "Fragile", "Forming", "Steady", "Strong", "Fluent"];
+const familiarityNames = {
+ en: ["New", "Fragile", "Forming", "Steady", "Strong", "Fluent"],
+ "zh-CN": ["新词", "生疏", "正在形成", "稳定", "熟练", "流利"],
+} as const;
-function RecallRuler({ entry }: { entry: VocabularyEntry }) {
+function RecallRuler({ entry, locale }: { entry: VocabularyEntry; locale: UiLocale }) {
const recall = Math.round(entry.effectiveRecall);
return (
-
Recall {recall} out of 100
+
{locale === "zh-CN" ? `记忆度 ${recall}/100` : `Recall ${recall} out of 100`}
@@ -95,6 +100,7 @@ function lexicalTextClass(value: string) {
function EntryCard({
entry,
+ locale,
speechAvailability,
onOpen,
onPronounce,
@@ -102,25 +108,28 @@ function EntryCard({
managed,
}: {
entry: VocabularyEntry;
+ locale: UiLocale;
speechAvailability: Readonly
>;
onOpen: () => void;
onPronounce: (text: string, language: VocabularyEntry["effectiveSourceLanguage"]) => void;
onManage: (trigger: HTMLButtonElement) => void;
managed: boolean;
}) {
+ const zh = locale === "zh-CN";
const speak = (text: string, language: string) => {
const availability = speechAvailability[language];
const title = availability === true
- ? `Pronounce ${text}`
+ ? (zh ? `朗读 ${text}` : `Pronounce ${text}`)
: availability === false
- ? "No installed voice supports this language"
- : "Checking installed voice availability";
- return onPronounce(text, language)}> ;
+ ? (zh ? "没有已安装的语音支持此语言" : "No installed voice supports this language")
+ : (zh ? "正在检查已安装语音" : "Checking installed voice availability");
+ const unavailableLabel = zh ? `${text}:${title}` : `${title} for ${text}`;
+ return onPronounce(text, language)}> ;
};
return (
-
+
{speak(entry.sourceText, entry.effectiveSourceLanguage)}{entry.sourceText}
{speak(entry.translatedText, entry.targetLanguage)}{entry.translatedText}
@@ -128,12 +137,12 @@ function EntryCard({
- {entry.lookupCount} {entry.lookupCount === 1 ? "lookup" : "lookups"}
- {familiarityNames[entry.familiarityLevel] ?? "New"}
+ {zh ? `${entry.lookupCount} 次查词` : `${entry.lookupCount} ${entry.lookupCount === 1 ? "lookup" : "lookups"}`}
+ {familiarityNames[locale][entry.familiarityLevel] ?? familiarityNames[locale][0]}
- onManage(event.currentTarget)}>
-
+ onManage(event.currentTarget)}>
+
@@ -141,6 +150,7 @@ function EntryCard({
}
export function VocabularyWindow({
+ locale = "en",
entries,
loading,
error,
@@ -156,6 +166,7 @@ export function VocabularyWindow({
onSubmitAnswer,
studyApi,
}: VocabularyWindowProps) {
+ const zh = locale === "zh-CN";
const [view, setView] = useState(question !== undefined ? "practice" : "library");
const [search, setSearch] = useState("");
const [selectedChoice, setSelectedChoice] = useState();
@@ -245,66 +256,66 @@ export function VocabularyWindow({
return (
-
+
Aa
-
Personal lexicon
-
Wordbook
-
Built quietly from the words you translate.
+
{zh ? "个人词库" : "Personal lexicon"}
+
{zh ? "词汇本" : "Wordbook"}
+
{zh ? "从你翻译的词汇中安静积累。" : "Built quietly from the words you translate."}
{(["library", "practice", "textbooks"] as const).map((item) => (
navigate(item)}>
- {item === "library" ? "My wordbook" : item === "practice" ? "Practice" : "Textbooks"}
+ {item === "library" ? (zh ? "我的词汇本" : "My wordbook") : item === "practice" ? (zh ? "练习" : "Practice") : (zh ? "词书" : "Textbooks")}
))}
- Your wordbook and practice activity stay on this device.
+ {zh ? "词汇本和练习记录仅保存在本机。" : "Your wordbook and practice activity stay on this device."}
{view === "library" && (
<>
{error &&
{error}
}
{loading ? (
-
Opening your wordbook… Reading local study history.
+
{zh ? "正在打开词汇本…" : "Opening your wordbook…"} {zh ? "正在读取本地学习记录。" : "Reading local study history."}
) : entries.length === 0 ? (
-
Translate a word to begin. Eligible words and short phrases will appear here automatically.
+
{zh ? "翻译一个单词即可开始。" : "Translate a word to begin."} {zh ? "符合条件的单词和短语会自动出现在这里。" : "Eligible words and short phrases will appear here automatically."}
) : (
-
{entries.map((entry) => { manageTrigger.current = trigger; setManagedEntry(entry); setCorrection(entry.effectiveSourceLanguage); setConfirmDelete(false); setManageError(undefined); }} onOpen={() => { setRelatedAnchor(entry); onSelectEntry(entry.id); setView("related"); }} />)}
+
{entries.map((entry) => { manageTrigger.current = trigger; setManagedEntry(entry); setCorrection(entry.effectiveSourceLanguage); setConfirmDelete(false); setManageError(undefined); }} onOpen={() => { setRelatedAnchor(entry); onSelectEntry(entry.id); setView("related"); }} />)}
)}
>
)}
- {view === "related" && studyApi &&
navigate("library")} />}
+ {view === "related" && studyApi && navigate("library")} />}
{view === "related" && !studyApi && (
<>
-
+
{related.length === 0 ? (
- No local connections yet. Open a word from the textbook as your collection grows.
+ {zh ? "暂未找到本地关联。" : "No local connections yet."} {zh ? "随着词汇积累,可从词汇卡片打开相关词。" : "Open a word from the textbook as your collection grows."}
) : (
{related.map(({ entry, reason }) =>
{reason === "root" ? "shared root" : "shared meaning"} {entry.sourceText} {entry.translatedText} )}
)}
>
)}
- {view === "practice" && studyApi && }
+ {view === "practice" && studyApi && }
{view === "practice" && !studyApi && (
<>
-
+
{question === undefined ? (
- Choosing what needs attention…
+ {zh ? "正在挑选需要复习的词汇…" : "Choosing what needs attention…"}
) : question === null ? (
- Add at least two distinct translations. Practice questions are assembled only from your local wordbook.
+ {zh ? "请至少添加两个释义不同的词汇。" : "Add at least two distinct translations."} {zh ? "练习题仅从你的本地词汇本中生成。" : "Practice questions are assembled only from your local wordbook."}
) : (
{question.effectiveSourceLanguage} → {question.targetLanguage} {question.sourceText}
@@ -316,12 +327,12 @@ export function VocabularyWindow({
<>
{outcome.correct ? "✓" : "↺"}
- {outcome.correct ? "Correct" : "Keep this one close"} {outcome.correct ? `Recall is now ${Math.round(outcome.entry.effectiveRecall)}.` : `The translation is “${outcome.correctTranslation}”.`}
+ {outcome.correct ? (zh ? "正确" : "Correct") : (zh ? "再记一遍" : "Keep this one close")} {outcome.correct ? (zh ? `Recall 已更新为 ${Math.round(outcome.entry.effectiveRecall)}。` : `Recall is now ${Math.round(outcome.entry.effectiveRecall)}.`) : (zh ? `正确译文是“${outcome.correctTranslation}”。` : `The translation is “${outcome.correctTranslation}”.`)}
- Next word
+ {zh ? "下一个词" : "Next word"}
>
) : (
- selectedChoice && onSubmitAnswer(question.entryId, selectedChoice)}>Check answer
+ selectedChoice && onSubmitAnswer(question.entryId, selectedChoice)}>{zh ? "检查答案" : "Check answer"}
)}
@@ -329,9 +340,9 @@ export function VocabularyWindow({
>
)}
- {view === "textbooks" && (studyApi ? : Textbooks are unavailable. Restart the desktop app to reconnect the local textbook service.
)}
+ {view === "textbooks" && (studyApi ? : {zh ? "词书暂不可用。" : "Textbooks are unavailable."} {zh ? "请重启桌面翻译以重新连接本地词书服务。" : "Restart the desktop app to reconnect the local textbook service."}
)}
- {managedEntry && studyApi && Manage {managedEntry.sourceText} Close {manageError &&
{manageError}
}
Source language setCorrection(event.target.value)}>{["en", "zh-CN", "zh-TW", "ja", "ko", "ru", "fr", "de", "es"].map((language) => {language.toUpperCase()} )} { void studyApi.correctVocabularySourceLanguage(managedEntry.id, correction).then(() => { studyApi.refreshPersonal(); closeManage(); }).catch(() => setManageError("The language correction could not be saved.")); }}>Save language {confirmDelete ? Delete this word? { void studyApi.deleteVocabularyEntry(managedEntry.id).then(() => { studyApi.refreshPersonal(); closeManage(); }).catch(() => setManageError("This word could not be deleted.")); }}>Confirm setConfirmDelete(false)}>Cancel : setConfirmDelete(true)}>Delete word } }
+ {managedEntry && studyApi && {zh ? "管理" : "Manage"} {managedEntry.sourceText} {zh ? "关闭" : "Close"} {manageError &&
{manageError}
}
{zh ? "源语言" : "Source language"} setCorrection(event.target.value)}>{["en", "zh-CN", "zh-TW", "ja", "ko", "ru", "fr", "de", "es"].map((language) => {language.toUpperCase()} )} { void studyApi.correctVocabularySourceLanguage(managedEntry.id, correction).then(() => { studyApi.refreshPersonal(); closeManage(); }).catch(() => setManageError(zh ? "无法保存语言更正。" : "The language correction could not be saved.")); }}>{zh ? "保存语言" : "Save language"} {confirmDelete ? {zh ? "删除此词?" : "Delete this word?"} { void studyApi.deleteVocabularyEntry(managedEntry.id).then(() => { studyApi.refreshPersonal(); closeManage(); }).catch(() => setManageError(zh ? "无法删除此词。" : "This word could not be deleted.")); }}>{zh ? "确认" : "Confirm"} setConfirmDelete(false)}>{zh ? "取消" : "Cancel"} : setConfirmDelete(true)}>{zh ? "删除词汇" : "Delete word"} } }
);
diff --git a/src/contracts/fixtures.json b/src/contracts/fixtures.json
index 1ecc9dd..bbc4c58 100644
--- a/src/contracts/fixtures.json
+++ b/src/contracts/fixtures.json
@@ -10,13 +10,16 @@
"capturedAtEpochMs": 1723464000000
},
"settings": {
- "schemaVersion": 1,
+ "schemaVersion": 2,
"enabled": true,
"sourceLanguage": "auto",
"targetLanguage": "zh-CN",
"startAtLogin": false,
"theme": "system",
- "maxSelectionCodePoints": 5000
+ "maxSelectionCodePoints": 5000,
+ "uiLocale": "en",
+ "translationProvider": "google",
+ "microsoftCloud": "global"
},
"translationRequest": {
"selectionId": 42,
diff --git a/src/contracts/ipc.ts b/src/contracts/ipc.ts
index 6f20722..d599d6f 100644
--- a/src/contracts/ipc.ts
+++ b/src/contracts/ipc.ts
@@ -26,6 +26,9 @@ export const partOfSpeechValues = [
export type PartOfSpeech = (typeof partOfSpeechValues)[number];
/** User-selectable application color theme. */
export type Theme = "system" | "light" | "dark";
+export type UiLocale = "en" | "zh-CN";
+export type TranslationProviderId = "google" | "baidu" | "microsoft";
+export type MicrosoftCloud = "global" | "china";
/** Rectangle expressed in global physical screen pixels. */
export interface PhysicalRect {
@@ -47,13 +50,17 @@ export interface SelectionSnapshot {
/** Schema-versioned, non-secret user preferences persisted by the core. */
export interface UserSettings {
- schemaVersion: 1;
+ schemaVersion: 2;
enabled: boolean;
sourceLanguage: "auto" | LanguageCode;
targetLanguage: LanguageCode;
startAtLogin: boolean;
theme: Theme;
maxSelectionCodePoints: number;
+ uiLocale: UiLocale;
+ translationProvider: TranslationProviderId;
+ microsoftCloud: MicrosoftCloud;
+ microsoftRegion?: string;
}
/** Validated translation command payload sent from the overlay to the core. */
@@ -354,7 +361,7 @@ export function isSelectionSnapshot(value: unknown): value is SelectionSnapshot
export function isUserSettings(value: unknown): value is UserSettings {
return (
isRecord(value) &&
- value.schemaVersion === 1 &&
+ value.schemaVersion === 2 &&
typeof value.enabled === "boolean" &&
isNonEmptyString(value.sourceLanguage) &&
isNonEmptyString(value.targetLanguage) &&
@@ -362,6 +369,10 @@ export function isUserSettings(value: unknown): value is UserSettings {
(value.theme === "system" || value.theme === "light" || value.theme === "dark") &&
Number.isSafeInteger(value.maxSelectionCodePoints) &&
Number(value.maxSelectionCodePoints) > 0
+ && (value.uiLocale === "en" || value.uiLocale === "zh-CN")
+ && ["google", "baidu", "microsoft"].includes(String(value.translationProvider))
+ && (value.microsoftCloud === "global" || value.microsoftCloud === "china")
+ && (value.microsoftRegion === undefined || isNonEmptyString(value.microsoftRegion))
);
}
diff --git a/src/i18n/catalog.test.ts b/src/i18n/catalog.test.ts
new file mode 100644
index 0000000..fd65df6
--- /dev/null
+++ b/src/i18n/catalog.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+
+import { messages } from "./catalog";
+
+describe("bilingual message catalog", () => {
+ it("covers the same keys in English and Simplified Chinese", () => {
+ expect(Object.keys(messages("zh-CN"))).toEqual(Object.keys(messages("en")));
+ expect(messages("zh-CN").settings).toBe("设置");
+ expect(messages("en").settings).toBe("Settings");
+ });
+
+ it("keeps familiar service abbreviations where they are clearer", () => {
+ expect(messages("zh-CN").appId).toBe("APP ID");
+ expect(messages("zh-CN").apiKey).toContain("API");
+ });
+});
diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts
new file mode 100644
index 0000000..58c9a54
--- /dev/null
+++ b/src/i18n/catalog.ts
@@ -0,0 +1,118 @@
+import type { UiLocale } from "../contracts/ipc";
+
+const en = {
+ settings: "Settings",
+ monitoringOn: "Monitoring On",
+ monitoringOff: "Monitoring Off",
+ permissionTitle: "Accessibility Permission Required",
+ permissionBody: "Allow access in System Settings, then reopen Desktop Translator.",
+ openSystemSettings: "Open System Settings",
+ general: "General",
+ generalHint: "Choose when translation is available.",
+ enableSelection: "Enable Selection Translation",
+ enableSelectionHint: "Show the translate control when text is selected.",
+ startAtLogin: "Start at Login",
+ startAtLoginHint: "Keep translation ready after you sign in.",
+ languages: "Languages",
+ languagesHint: "Set the default direction for new selections.",
+ sourceLanguage: "Source",
+ targetLanguage: "Target",
+ detectAutomatically: "Auto detect",
+ appearance: "Appearance",
+ appearanceHint: "Choose language and visual theme.",
+ interfaceLanguage: "UI language",
+ theme: "Theme",
+ system: "System",
+ light: "Light",
+ dark: "Dark",
+ serviceAccess: "Translation service",
+ serviceHint: "Choose one provider. The app never silently switches services.",
+ provider: "Provider",
+ google: "Google Cloud",
+ baidu: "Baidu Translate",
+ microsoft: "Microsoft Translator",
+ microsoftCloud: "Microsoft cloud",
+ globalCloud: "Global",
+ chinaCloud: "China",
+ region: "Region (optional)",
+ apiKey: "API key",
+ appId: "APP ID",
+ secretKey: "Secret key",
+ notConfigured: "Not configured",
+ storedSecurely: "Stored securely",
+ needsAttention: "Needs attention",
+ testing: "Testing…",
+ configure: "Configure",
+ test: "Test",
+ remove: "Remove",
+ confirmRemoval: "Confirm removal",
+ cancel: "Cancel",
+ privacy: "Privacy & usage",
+ privacyHint: "Know what leaves your device.",
+ privacyOne: "Text is sent only to the selected provider after you choose Translate.",
+ privacyTwo: "Credentials stay in the operating-system vault.",
+ privacyThree: "Personal vocabulary and practice data stay on this device.",
+ quit: "Quit Desktop Translator",
+ save: "Save changes",
+} as const;
+
+const zh: Record = {
+ settings: "设置",
+ monitoringOn: "划词翻译已开启",
+ monitoringOff: "划词翻译已关闭",
+ permissionTitle: "需要辅助功能权限",
+ permissionBody: "请在系统设置中允许访问,然后重新打开桌面翻译。",
+ openSystemSettings: "打开系统设置",
+ general: "通用",
+ generalHint: "选择翻译功能何时可用。",
+ enableSelection: "启用划词翻译",
+ enableSelectionHint: "选中文本后显示翻译按钮。",
+ startAtLogin: "登录时启动",
+ startAtLoginHint: "登录后自动准备翻译功能。",
+ languages: "语言",
+ languagesHint: "设置新翻译的默认方向。",
+ sourceLanguage: "源语言",
+ targetLanguage: "目标语言",
+ detectAutomatically: "自动检测",
+ appearance: "外观",
+ appearanceHint: "选择界面语言和主题。",
+ interfaceLanguage: "界面语言",
+ theme: "主题",
+ system: "跟随系统",
+ light: "浅色",
+ dark: "深色",
+ serviceAccess: "翻译服务",
+ serviceHint: "请选择一个服务;应用不会在后台擅自切换。",
+ provider: "服务商",
+ google: "Google Cloud",
+ baidu: "百度翻译",
+ microsoft: "微软翻译",
+ microsoftCloud: "微软云环境",
+ globalCloud: "全球",
+ chinaCloud: "中国区",
+ region: "区域(可选)",
+ apiKey: "API 密钥",
+ appId: "APP ID",
+ secretKey: "密钥",
+ notConfigured: "尚未配置",
+ storedSecurely: "已安全存储",
+ needsAttention: "需要处理",
+ testing: "正在测试…",
+ configure: "配置",
+ test: "测试",
+ remove: "移除",
+ confirmRemoval: "确认移除",
+ cancel: "取消",
+ privacy: "隐私与用量",
+ privacyHint: "了解哪些数据会离开设备。",
+ privacyOne: "只有在你点击翻译后,文本才会发送到所选服务。",
+ privacyTwo: "凭据保存在操作系统的安全凭据库中。",
+ privacyThree: "个人词库和练习数据仅保存在本机。",
+ quit: "退出桌面翻译",
+ save: "保存更改",
+};
+
+export type Messages = typeof en;
+export function messages(locale: UiLocale): Messages {
+ return (locale === "zh-CN" ? zh : en) as Messages;
+}
diff --git a/src/main.tsx b/src/main.tsx
index a2c050a..d1a9dd0 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -44,13 +44,16 @@ interface SelectionEvent {
}
const fallbackSettings: UserSettings = {
- schemaVersion: 1,
+ schemaVersion: 2,
enabled: true,
sourceLanguage: "auto",
targetLanguage: "en",
startAtLogin: false,
theme: "system",
maxSelectionCodePoints: 5_000,
+ uiLocale: "en",
+ translationProvider: "google",
+ microsoftCloud: "global",
};
function runningInTauri() {
@@ -133,6 +136,9 @@ export function Bootstrap() {
}
let disposed = false;
+ const settingsSubscription = listen("settings-changed", ({ payload }) => {
+ if (!disposed) setSettings(payload);
+ });
const subscription =
mode === "overlay"
? listen("selection-resolved", ({ payload }) => {
@@ -192,6 +198,7 @@ export function Bootstrap() {
window.clearInterval(permissionPoll);
}
void subscription?.then((unlisten) => unlisten());
+ void settingsSubscription.then((unlisten) => unlisten());
};
}, [mode]);
@@ -438,6 +445,9 @@ export function Bootstrap() {
if (!nextSettings.enabled) {
speaking.current = false;
}
+ void invoke("get_credential_status", { provider: nextSettings.translationProvider })
+ .then(setCredentialStatus)
+ .catch(() => setCredentialStatus("missing"));
})
.catch(async () => {
const status = await invoke("get_permission_status").catch(
@@ -446,21 +456,28 @@ export function Bootstrap() {
setPermissionStatus(status);
});
}}
- onSaveCredential={() => {
- void invoke("prompt_and_save_credential").then((saved) => {
+ onSaveCredential={(provider, field) => {
+ void invoke("prompt_and_save_credential", { provider, field }).then((saved) => {
if (saved) {
- setCredentialStatus("ready");
+ void invoke("get_credential_status", { provider })
+ .then(setCredentialStatus)
+ .catch(() => setCredentialStatus("missing"));
}
});
}}
- onTestCredential={() => {
+ onProviderChange={(provider) => {
+ void invoke("get_credential_status", { provider })
+ .then(setCredentialStatus)
+ .catch(() => setCredentialStatus("missing"));
+ }}
+ onTestCredential={(provider) => {
setCredentialStatus("testing");
- void invoke("test_credential")
+ void invoke("test_credential", { provider })
.then(() => setCredentialStatus("ready"))
.catch(() => setCredentialStatus("invalid"));
}}
- onRemoveCredential={() => {
- void invoke("remove_credential").then(() =>
+ onRemoveCredential={(provider) => {
+ void invoke("remove_credential", { provider }).then(() =>
setCredentialStatus("missing"),
);
}}
diff --git a/src/styles/app.css b/src/styles/app.css
index 869b77b..2dac02b 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -155,7 +155,7 @@ input:focus-visible,
.study-notice { margin-bottom: 20px; border-radius: var(--radius-sm); padding: 12px 14px; }
.study-notice--error { border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent); background: var(--danger-soft); color: var(--danger); }
.study-empty { display: grid; min-height: 280px; place-content: center; gap: 8px; border: 1px dashed var(--study-rule); border-radius: var(--radius-md); color: var(--ink-muted); text-align: center; }
-.study-empty strong { color: var(--study-ink); font-family: Georgia, serif; font-size: 1.35rem; font-weight: 500; }
+.study-empty strong { color: var(--study-ink); font-family: var(--font-lexical); font-size: 1.35rem; font-weight: 500; }
.study-empty__action { justify-self: center; margin-top: 10px; }
.vocabulary-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 14px; }
@@ -178,7 +178,7 @@ input:focus-visible,
.vocabulary-card__open:focus-visible { border-radius: var(--radius-sm); outline: 2px solid var(--accent); outline-offset: 5px; }
.vocabulary-card__copy { display: grid; align-content: start; gap: 5px; min-width: 0; }
.vocabulary-card__word-row { display: grid; grid-template-columns: 36px minmax(0, 1fr); min-height: 38px; align-items: center; gap: 6px; min-width: 0; }
-.vocabulary-card__copy strong { color: var(--study-ink); font-family: Georgia, serif; font-size: clamp(1.05rem, 1.7vw, 1.35rem); font-weight: 500; }
+.vocabulary-card__copy strong { color: var(--study-ink); font-family: var(--font-lexical); font-size: clamp(1.05rem, 1.7vw, 1.35rem); font-weight: 500; }
.vocabulary-card__copy span { color: var(--ink-muted); }
.vocabulary-card__copy small { color: var(--ink-muted); font-family: var(--font-utility); font-size: 0.65rem; text-transform: uppercase; }
.vocabulary-card__lexeme { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; gap: 4px 7px; }
@@ -208,7 +208,7 @@ input:focus-visible,
.relation-list { display: grid; gap: 10px; }
.relation-list article { display: grid; grid-template-columns: 120px minmax(120px, 0.7fr) minmax(0, 1fr) auto; align-items: center; gap: 16px; border-bottom: 1px solid var(--study-rule); padding: 16px 4px; }
-.relation-list article strong { color: var(--study-ink); font-family: Georgia, serif; font-size: 1.2rem; font-weight: 500; }
+.relation-list article strong { color: var(--study-ink); font-family: var(--font-lexical); font-size: 1.2rem; font-weight: 500; }
.relation-lexeme, .textbook-entry__lexeme { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; gap: 5px 8px; }
.relation-badge { width: fit-content; border-radius: 999px; padding: 5px 9px; font-size: 0.65rem; font-weight: 700; }
.relation-badge--root { background: var(--accent-soft); color: var(--accent-strong); }
@@ -292,7 +292,7 @@ input:focus-visible,
.textbook-entry-list { border-top: 1px solid var(--study-rule); }
.textbook-entry { display: grid; grid-template-columns: minmax(140px, 0.8fr) minmax(160px, 1fr) auto; align-items: center; gap: 18px; min-height: 74px; border-bottom: 1px solid var(--study-rule); padding: 10px 4px; }
.textbook-entry > div { display: grid; gap: 4px; }
-.textbook-entry strong { color: var(--study-ink); font-family: Georgia, serif; font-size: 1.12rem; font-weight: 500; }
+.textbook-entry strong { color: var(--study-ink); font-family: var(--font-lexical); font-size: 1.12rem; font-weight: 500; }
.textbook-entry small { color: var(--accent-strong); font-family: var(--font-utility); font-size: 0.68rem; }
.textbook-pagination { position: sticky; z-index: 5; bottom: 0; isolation: isolate; display: flex; min-height: 66px; align-items: center; justify-content: space-between; gap: 16px; border-top: 1px solid var(--study-rule); padding: 10px 4px; background: var(--study-paper); color: var(--ink-muted); font-size: 0.72rem; }
.textbook-pagination::before { position: absolute; z-index: -1; top: -22px; right: 0; bottom: -40px; left: 0; background: var(--study-paper); box-shadow: 0 -12px 18px var(--study-paper); content: ""; pointer-events: none; }
@@ -305,7 +305,7 @@ input:focus-visible,
.practice-prompt { display: grid; gap: 9px; margin: 0; border-bottom: 1px solid var(--study-rule); padding-bottom: 18px; }
.practice-prompt > span { width: fit-content; border: 1px solid var(--study-rule); border-radius: 999px; padding: 4px 8px; background: var(--surface); color: var(--ink-muted); font-family: var(--font-utility); font-size: 0.64rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; }
.practice-prompt__lexeme { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; gap: 8px 12px; }
-.practice-prompt strong { color: var(--study-ink); font-family: Georgia, serif; font-size: clamp(1.75rem, 4.2vw, 3rem); font-weight: 500; line-height: 1.06; }
+.practice-prompt strong { color: var(--study-ink); font-family: var(--font-lexical); font-size: clamp(1.75rem, 4.2vw, 3rem); font-weight: 500; line-height: 1.14; }
.practice-prompt strong.lexical-text--long { font-size: clamp(1.45rem, 3.7vw, 2.6rem); letter-spacing: -0.018em; }
.practice-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 14px; margin-top: 16px; counter-reset: answer; }
.practice-choice { display: grid; min-width: 0; min-height: 52px; grid-template-columns: 30px minmax(0, 1fr) auto; align-items: center; gap: 10px; border: 1px solid transparent; border-bottom-color: var(--study-rule); border-radius: 0; padding: 7px 8px; background: transparent; color: var(--ink); cursor: pointer; text-align: left; transition: border-color 140ms ease, background-color 140ms ease; }
@@ -1085,6 +1085,36 @@ input:focus-visible,
font-size: 0.78rem;
}
+.field input {
+ width: 100%;
+ min-height: var(--target);
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-sm);
+ padding: 0 11px;
+ background: var(--surface-raised);
+ color: var(--ink);
+ font: inherit;
+ font-size: 0.78rem;
+}
+
+.settings-card--appearance {
+ grid-template-columns: 1fr;
+ margin-bottom: 10px;
+}
+
+.settings-card--appearance .theme-picker {
+ border-top: 1px solid var(--line);
+ padding-top: 14px;
+}
+
+.provider-card {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 12px;
+ margin-bottom: 10px;
+ padding: 14px;
+}
+
.field select:hover {
border-color: var(--accent);
}
@@ -1197,9 +1227,11 @@ input:focus-visible,
.credential-card {
display: flex;
+ grid-column: 2;
min-height: 76px;
align-items: center;
justify-content: space-between;
+ flex-wrap: wrap;
gap: 14px;
padding: 13px 14px;
}
@@ -1247,6 +1279,8 @@ input:focus-visible,
.button-group {
display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
gap: 7px;
}
@@ -1354,6 +1388,10 @@ input:focus-visible,
flex-direction: column;
}
+ .credential-card {
+ grid-column: 1;
+ }
+
.button-group {
display: grid;
grid-template-columns: 1fr 1fr;
diff --git a/src/styles/tokens.css b/src/styles/tokens.css
index eb3cb5b..662afbe 100644
--- a/src/styles/tokens.css
+++ b/src/styles/tokens.css
@@ -4,6 +4,8 @@
"Avenir Next", Avenir, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif;
font-synthesis: none;
--font-utility: ui-monospace, "SFMono-Regular", "Cascadia Code", monospace;
+ --font-cjk-sans: "PingFang SC", "Microsoft YaHei UI", "Noto Sans CJK SC", "Source Han Sans SC", sans-serif;
+ --font-lexical: Georgia, "Songti SC", STSong, "Noto Serif CJK SC", serif;
--ink: #17201f;
--ink-muted: #66716f;
--canvas: #f3f6f4;
@@ -33,6 +35,12 @@
--study-rule: #c9d8d2;
}
+[data-locale="zh-CN"] {
+ font-family: var(--font-cjk-sans);
+ font-size: 15px;
+ line-height: 1.62;
+}
+
[data-theme="light"] {
color-scheme: light;
}
diff --git a/tools/release/audit-macos-bundle.sh b/tools/release/audit-macos-bundle.sh
new file mode 100755
index 0000000..4f03e73
--- /dev/null
+++ b/tools/release/audit-macos-bundle.sh
@@ -0,0 +1,35 @@
+#!/bin/sh
+set -eu
+
+app=${1:?usage: audit-macos-bundle.sh /path/to/Desktop\ Translator.app}
+executable="$app/Contents/MacOS/desktop-translator"
+test -x "$executable"
+test -f "$app/Contents/Info.plist"
+codesign --verify --verbose=2 "$app"
+
+bytes=$(stat -f %z "$executable")
+limit=$((24 * 1024 * 1024))
+if [ "$bytes" -gt "$limit" ]; then
+ echo "release executable exceeds compactness budget: $bytes > $limit" >&2
+ exit 1
+fi
+
+if otool -L "$executable" | grep -E '/opt/homebrew|/usr/local|\.cargo|node|python'; then
+ echo "release links a developer-machine runtime" >&2
+ exit 1
+fi
+
+sidecars=$(find "$app/Contents/MacOS" -type f ! -name desktop-translator | wc -l | tr -d ' ')
+if [ "$sidecars" -ne 0 ]; then
+ echo "release contains an undeclared sidecar" >&2
+ exit 1
+fi
+
+external_textbooks=$(find "$app/Contents" -type f -name 'starter-en-zh.sqlite3' | wc -l | tr -d ' ')
+if [ "$external_textbooks" -ne 0 ]; then
+ echo "bundled textbook must be embedded once in the executable" >&2
+ exit 1
+fi
+
+app_kib=$(du -sk "$app" | awk '{print $1}')
+echo "bundle audit passed: executable=$bytes bytes app=${app_kib}KiB sidecars=$sidecars external_textbooks=$external_textbooks"
diff --git a/tools/textbooks/build-starter.sh b/tools/textbooks/build-starter.sh
new file mode 100755
index 0000000..4b30f3a
--- /dev/null
+++ b/tools/textbooks/build-starter.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+set -eu
+script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+output="$script_dir/../../src-tauri/resources/textbooks/starter-en-zh.sqlite3"
+mkdir -p "$(dirname -- "$output")"
+rm -f "$output"
+sqlite3 "$output" < "$script_dir/starter-en-zh.sql"
+shasum -a 256 "$output"
+wc -c "$output"
diff --git a/tools/textbooks/starter-en-zh.sql b/tools/textbooks/starter-en-zh.sql
new file mode 100644
index 0000000..e0ecf53
--- /dev/null
+++ b/tools/textbooks/starter-en-zh.sql
@@ -0,0 +1,27 @@
+PRAGMA journal_mode=DELETE;
+CREATE TABLE simple_translation (written_rep TEXT NOT NULL, trans_list TEXT NOT NULL, max_score REAL, rel_importance REAL);
+CREATE TABLE translation (lexentry TEXT, written_rep TEXT NOT NULL, trans_list TEXT NOT NULL, score REAL, is_good INTEGER, importance REAL);
+INSERT INTO simple_translation(written_rep, trans_list) VALUES
+('ability','能力'),('accept','接受'),('achieve','实现'),('action','行动'),('active','积极的'),
+('advice','建议'),('agree','同意'),('allow','允许'),('answer','回答'),('appear','出现'),
+('apply','申请;应用'),('arrive','到达'),('attention','注意'),('available','可用的'),('avoid','避免'),
+('believe','相信'),('benefit','益处'),('build','建造'),('change','改变'),('choose','选择'),
+('clear','清楚的'),('common','常见的'),('compare','比较'),('complete','完成'),('consider','考虑'),
+('continue','继续'),('create','创建'),('decide','决定'),('develop','发展'),('different','不同的'),
+('difficult','困难的'),('discover','发现'),('early','早的'),('easy','容易的'),('effect','影响'),
+('enough','足够的'),('example','例子'),('experience','经验'),('explain','解释'),('follow','跟随'),
+('future','未来'),('general','一般的'),('happen','发生'),('important','重要的'),('improve','改进'),
+('include','包括'),('increase','增加'),('information','信息'),('interest','兴趣'),('language','语言'),
+('learn','学习'),('meaning','含义'),('necessary','必要的'),('notice','注意到'),('option','选项'),
+('practice','练习'),('prepare','准备'),('problem','问题'),('provide','提供'),('reason','原因'),
+('receive','收到'),('remember','记住'),('result','结果'),('select','选择'),('service','服务'),
+('similar','相似的'),('simple','简单的'),('source','来源'),('study','学习'),('support','支持'),
+('system','系统'),('textbook','教材'),('translate','翻译'),('understand','理解'),('useful','有用的'),
+('value','价值'),('vocabulary','词汇'),('window','窗口'),('word','单词'),('work','工作');
+INSERT INTO translation(lexentry,written_rep,trans_list,score,is_good,importance)
+SELECT written_rep || '__verb__1', written_rep, trans_list, 1, 1, 1 FROM simple_translation WHERE written_rep IN ('accept','achieve','agree','allow','apply','arrive','avoid','believe','build','change','choose','compare','complete','consider','continue','create','decide','develop','discover','explain','follow','happen','improve','include','increase','learn','notice','practice','prepare','provide','receive','remember','select','study','support','translate','understand','work');
+INSERT INTO translation(lexentry,written_rep,trans_list,score,is_good,importance)
+SELECT written_rep || '__noun__1', written_rep, trans_list, 1, 1, 1 FROM simple_translation WHERE written_rep IN ('ability','action','advice','answer','attention','benefit','effect','example','experience','future','information','interest','language','meaning','option','problem','reason','result','service','source','system','textbook','value','vocabulary','window','word');
+INSERT INTO translation(lexentry,written_rep,trans_list,score,is_good,importance)
+SELECT written_rep || '__adjective__1', written_rep, trans_list, 1, 1, 1 FROM simple_translation WHERE written_rep IN ('active','available','clear','common','different','difficult','early','easy','enough','general','important','necessary','similar','simple','useful');
+VACUUM;