feat(i18n): add i18n support

This commit is contained in:
leookun
2026-08-24 03:08:29 +08:00
parent 2abffcc835
commit 03075952a6
20 changed files with 932 additions and 354 deletions
+48
View File
@@ -0,0 +1,48 @@
---
name: i18n
description: Implement and review Cursor BYOK desktop localization, including t() usage, locale catalogs, system-language selection, translation completeness, and language settings under apps/desktop.
---
# Desktop i18n
Keep localization organized as:
```text
apps/desktop/
├── plugins/static-i18n-plugin.ts
└── src/i18n/
├── I18nRoot.tsx
├── runtime.ts
├── store.ts
├── generated/catalog.json
└── locales/
├── zh-CN.json
└── en-US.json
```
## Author messages
- Use the global `t()` function with a static Simplified Chinese string literal: `t("保存")`.
- Do not import or locally declare `t`.
- Keep calls inside render paths when the result must update after a language change. Do not translate module-level UI constants.
- Use named placeholders with a static object: `t("第 {page} 页", { page })`.
- Preserve every placeholder name exactly in all translations.
- Wrap all user-visible labels, descriptions, tooltips, empty states, validation messages, and accessibility labels. Do not wrap protocol tokens, model IDs, URLs, or product names that should remain unchanged.
## Locale behavior
- `system` is the default preference.
- Resolve supported operating-system languages to their locale and fall back to `en-US` for every unsupported language.
- Persist only explicit locale selections. Removing the preference restores system-language behavior.
- Apply changes immediately and update the document `lang` attribute.
## Update catalogs
From `apps/desktop`:
1. Run `npm run i18n:scan` after adding or changing `t()` sources.
2. Translate every empty entry in `src/i18n/locales/en-US.json`.
3. Never edit `src/i18n/generated/catalog.json` or `zh-CN.json` manually; scanning owns them.
4. Run `npm run check`. A normal build must fail on a missing translation or mismatched placeholder.
When adding another locale, add it to the plugin's supported locales, runtime locale type and messages, locale resolver, settings options, and provide a complete locale JSON file.
+11 -3
View File
@@ -295,7 +295,8 @@ function mergeLocaleMessages(
function validateTranslations(
sourceRoot: string,
entries: Record<string, MergedEntry>
entries: Record<string, MergedEntry>,
requireComplete: boolean
): void {
for (const locale of SUPPORTED_LOCALES) {
if (locale === SOURCE_LOCALE) continue
@@ -303,7 +304,14 @@ function validateTranslations(
const messages = readJSONFile(localePath)
for (const [id, entry] of Object.entries(entries)) {
const translation = messages[id]
if (!translation) continue
if (!translation) {
if (requireComplete) {
throw new Error(
`[static-i18n] ${localePath}:${id} 缺少译文:${entry.source}`
)
}
continue
}
const actual = placeholderNames(translation)
assertUniquePlaceholders(actual, `${localePath}:${id}`, translation)
const missing = entry.placeholders.filter((name) => !actual.includes(name))
@@ -446,8 +454,8 @@ export function staticI18nPlugin(): Plugin {
},
buildStart() {
const catalog = collectCatalog(sourceRoot)
validateTranslations(sourceRoot, catalog.entries)
if (shouldScan) syncCatalogFiles(sourceRoot, catalog)
validateTranslations(sourceRoot, catalog.entries, !shouldScan)
},
transform(code, id) {
if (!isSourceFile(id) || isExcludedFile(sourceRoot, id)) return null
+8 -2
View File
@@ -1,4 +1,5 @@
import type { AdRuntime } from "./components/ads/types";
import type { Locale } from "./i18n/runtime";
export type ProviderType = "openai-chat" | "openai-responses" | "anthropic";
@@ -221,9 +222,14 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
export const api = {
ads: (disabledAdIds: Iterable<string>) => {
ads: (disabledAdIds: Iterable<string>, locale: Locale) => {
const value = [...disabledAdIds].join(",");
return request<AdRuntime>("/ads", value ? { headers: { "disable-ad-ids": value } } : undefined);
return request<AdRuntime>("/ads", {
headers: {
"accept-language": locale,
...(value ? { "disable-ad-ids": value } : {}),
},
});
},
dismissAd: (id: string, reason: string) => request<void>(`/ads/${encodeURIComponent(id)}/dismissals`, { method: "POST", body: JSON.stringify({ reason }) }),
providers: () => request<Provider[]>("/providers"),
@@ -11,14 +11,6 @@ import styles from "./OverviewTimeRangeFilter.module.scss";
export type OverviewRangePreset = "ten-minutes" | "hour" | "today" | "week" | "month" | "custom";
const presets: Array<{ value: Exclude<OverviewRangePreset, "custom">; label: string }> = [
{ value: "hour", label: "近1小时" },
{ value: "today", label: "近1自然日" },
{ value: "ten-minutes", label: "近10分钟" },
{ value: "week", label: "近一周" },
{ value: "month", label: "近一个月" },
];
export function OverviewTimeRangeFilter({ value, customOpen, customStart, customEnd, modelOptions, providerOptions, selectedModels, selectedProviders, busy, onSelect, onCustomOpenChange, onCustomStartChange, onCustomEndChange, onSelectedModelsChange, onSelectedProvidersChange, onCustomApply, onRefresh }: {
value: OverviewRangePreset;
customOpen: boolean;
@@ -38,6 +30,13 @@ export function OverviewTimeRangeFilter({ value, customOpen, customStart, custom
onCustomApply: () => void;
onRefresh: () => void;
}) {
const presets: Array<{ value: Exclude<OverviewRangePreset, "custom">; label: string }> = [
{ value: "hour", label: t("近1小时") },
{ value: "today", label: t("近1自然日") },
{ value: "ten-minutes", label: t("近10分钟") },
{ value: "week", label: t("近一周") },
{ value: "month", label: t("近一个月") },
];
const customButton = useRef<HTMLButtonElement>(null);
const popover = useRef<HTMLDivElement>(null);
const popoverId = useId();
+1 -1
View File
@@ -6,7 +6,7 @@ const icon = (body: string, width = 24, height = 24): IconifyIcon => ({ body, wi
export const flatColorAreaChartIcon = icon('<path fill="#3F51B5" d="M42 37H6V25l10-15l14 7L42 6z"/><path fill="#00BCD4" d="M42 42H6V32l10-8l14 2l12-9z"/>', 48, 48);
export const flatColorSalesPerformanceIcon = icon('<g fill="#FFA000"><path d="M32 8h12v32c0 1.1-2.7 2-6 2s-6-.9-6-2zM4 14h12v26c0 1.1-2.7 2-6 2s-6-.9-6-2zM18 23h12v17c0 1.1-2.7 2-6 2s-6-.9-6-2z"/></g><g fill="#FFC107"><ellipse cx="38" cy="8" rx="6" ry="2"/><ellipse cx="10" cy="14" rx="6" ry="2"/><ellipse cx="24" cy="23" rx="6" ry="2"/></g><g fill="none" stroke="#FFC107" stroke-width="2"><path d="M32 11c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2M4 17c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2M18 26c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2m-12 3c0 1.1 2.7 2 6 2s6-.9 6-2"/></g>', 48, 48);
export const flatColorLinkIcon = icon('<path fill="#1976D2" d="M38 13h-3c-5.5 0-10 4.5-10 10s4.5 10 10 10h3c5.5 0 10-4.5 10-10s-4.5-10-10-10m0 16h-3c-3.3 0-6-2.7-6-6s2.7-6 6-6h3c3.3 0 6 2.7 6 6s-2.7 6-6 6M13 13h-3C4.5 13 0 17.5 0 23s4.5 10 10 10h3c5.5 0 10-4.5 10-10s-4.5-10-10-10m0 16h-3c-3.3 0-6-2.7-6-6s2.7-6 6-6h3c3.3 0 6 2.7 6 6s-2.7 6-6 6"/><path fill="#42A5F5" d="M33 21H15c-1.1 0-2 .9-2 2s.9 2 2 2h18c1.1 0 2-.9 2-2s-.9-2-2-2"/>', 48, 48);
export const flatColorAboutIcon = icon('<path fill="#2196F3" d="M37 40H11l-6 6V12c0-3.3 2.7-6 6-6h26c3.3 0 6 2.7 6 6v22c0 3.3-2.7 6-6 6"/><g fill="#fff"><path d="M22 20h4v11h-4z"/><circle cx="24" cy="15" r="2"/></g>', 48, 48);
export const flatColorOrganizationIcon = icon('<path fill="#90CAF9" d="M42 42H6V10c0-2.2 1.8-4 4-4h28c2.2 0 4 1.8 4 4z"/><path fill="#64B5F6" d="M6 42h36v2H6z"/><path fill="#1565C0" d="M31 27h6v5h-6zm-10 0h6v5h-6zm-10 0h6v5h-6zm20 8h6v5h-6zm-20 0h6v5h-6zm20-16h6v5h-6zm-10 0h6v5h-6zm-10 0h6v5h-6zm20-8h6v5h-6zm-10 0h6v5h-6zm-10 0h6v5h-6zm10 24h6v9h-6z"/>', 48, 48);
export const flatColorBarChartIcon = icon('<path fill="#00BCD4" d="M19 22h10v20H19zM6 12h10v30H6zm26-6h10v36H32z"/>', 48, 48);
export const flatColorComboChartIcon = icon('<path fill="#00BCD4" d="M37 18h6v24h-6zm-8 8h6v16h-6zm-8-4h6v20h-6zm-8 10h6v10h-6zm-8-4h6v14H5z"/><g fill="#3F51B5"><circle cx="8" cy="16" r="3"/><circle cx="16" cy="18" r="3"/><circle cx="24" cy="11" r="3"/><circle cx="32" cy="13" r="3"/><circle cx="40" cy="9" r="3"/><path d="m39.1 7.2l-7.3 3.7l-8.3-2.1l-8 7l-7-1.7l-1 3.8l9 2.3l8-7l7.7 1.9l8.7-4.3z"/></g>', 48, 48);
+7
View File
@@ -0,0 +1,7 @@
import { App } from "../App";
import { useI18n } from "./store";
export function I18nRoot() {
useI18n();
return <App />;
}
+354 -56
View File
@@ -87,7 +87,7 @@
},
{
"file": "pages/SettingsPage.tsx",
"line": 145,
"line": 148,
"column": 82
}
]
@@ -111,7 +111,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 231,
"line": 254,
"column": 14
}
]
@@ -163,7 +163,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 222,
"line": 245,
"column": 16
}
]
@@ -312,7 +312,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 205,
"line": 228,
"column": 26
}
]
@@ -595,7 +595,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 209,
"line": 232,
"column": 60
}
]
@@ -621,17 +621,17 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 52,
"line": 55,
"column": 42
},
{
"file": "pages/SettingsPage.tsx",
"line": 150,
"line": 153,
"column": 22
},
{
"file": "pages/SettingsPage.tsx",
"line": 177,
"line": 180,
"column": 58
}
]
@@ -707,6 +707,18 @@
}
]
},
"217cfe7db1e3d10a": {
"source": "跟随系统",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 206,
"column": 43
}
]
},
"22c6b4eb4caee6ae": {
"source": "代理设置已保存",
"kind": "text",
@@ -714,7 +726,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 107,
"line": 110,
"column": 15
}
]
@@ -747,6 +759,18 @@
}
]
},
"29fbbef32a6eb58b": {
"source": "不再显示此广告",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 216,
"column": 14
}
]
},
"2a2773134a829016": {
"source": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
"kind": "text",
@@ -766,7 +790,7 @@
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 115,
"line": 114,
"column": 105
},
{
@@ -777,7 +801,12 @@
{
"file": "components/ui/Modal.tsx",
"line": 7,
"column": 92
"column": 96
},
{
"file": "layouts/AppLayout.tsx",
"line": 217,
"column": 19
},
{
"file": "pages/CursorSettingsPage.tsx",
@@ -786,12 +815,12 @@
},
{
"file": "pages/SettingsPage.tsx",
"line": 141,
"line": 144,
"column": 81
},
{
"file": "pages/SettingsPage.tsx",
"line": 224,
"line": 247,
"column": 21
}
]
@@ -908,6 +937,18 @@
}
]
},
"36f33adaf0942634": {
"source": "确认",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 218,
"column": 20
}
]
},
"378bb0eec39fa8a2": {
"source": "最后一页",
"kind": "text",
@@ -1012,7 +1053,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 216,
"line": 239,
"column": 14
}
]
@@ -1067,6 +1108,23 @@
}
]
},
"3d13868593ae4eeb": {
"source": "界面语言",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 198,
"column": 22
},
{
"file": "pages/SettingsPage.tsx",
"line": 204,
"column": 26
}
]
},
"3da0bf1610ff5db5": {
"source": "推荐内容",
"kind": "text",
@@ -1074,7 +1132,7 @@
"refs": [
{
"file": "components/ads/AdMenu.tsx",
"line": 15,
"line": 18,
"column": 56
}
]
@@ -1091,6 +1149,18 @@
}
]
},
"4125fc7ba333524c": {
"source": "默认亮色",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 223,
"column": 52
}
]
},
"42655ed8e4108ae2": {
"source": "输入(非缓存)",
"kind": "text",
@@ -1149,6 +1219,18 @@
}
]
},
"4510b63fdcf7451f": {
"source": "Cursor 助手 v0.1.0",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppHeader.tsx",
"line": 20,
"column": 18
}
]
},
"470049252e54de6a": {
"source": "成功占比:{rate}",
"kind": "template",
@@ -1215,6 +1297,18 @@
}
]
},
"493e097fac6bd217": {
"source": "上游配置",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 71,
"column": 48
}
]
},
"497c85690c4cc0fc": {
"source": "暂无数据",
"kind": "text",
@@ -1239,8 +1333,13 @@
"refs": [
{
"file": "components/ads/AdMenu.tsx",
"line": 25,
"column": 97
"line": 28,
"column": 99
},
{
"file": "layouts/AppLayout.tsx",
"line": 197,
"column": 84
}
]
},
@@ -1251,7 +1350,7 @@
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 90,
"line": 89,
"column": 9
},
{
@@ -1266,6 +1365,18 @@
}
]
},
"51d04bc3d286f018": {
"source": "近1自然日",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 35,
"column": 30
}
]
},
"5228358a6db59fe7": {
"source": "如:现在、2026-08-23 18:00",
"kind": "text",
@@ -1273,7 +1384,7 @@
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 111,
"line": 110,
"column": 70
}
]
@@ -1285,7 +1396,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 139,
"line": 142,
"column": 26
}
]
@@ -1384,6 +1495,18 @@
}
]
},
"5f9acfb945229062": {
"source": "你确认不想再看到此广告吗?",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 222,
"column": 11
}
]
},
"5fd2ec5a6e9b654c": {
"source": "合计:{cost}",
"kind": "template",
@@ -1479,7 +1602,7 @@
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 116,
"line": 115,
"column": 109
}
]
@@ -1491,7 +1614,7 @@
"refs": [
{
"file": "api.ts",
"line": 255,
"line": 265,
"column": 43
}
]
@@ -1575,6 +1698,18 @@
}
]
},
"68ea5dd4d7af20e6": {
"source": "系统设置",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 74,
"column": 47
}
]
},
"6a9906c79f26c0ba": {
"source": "开始时间",
"kind": "text",
@@ -1582,7 +1717,7 @@
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 110,
"line": 109,
"column": 21
}
]
@@ -1693,7 +1828,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 129,
"line": 132,
"column": 16
}
]
@@ -1717,7 +1852,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 192,
"line": 215,
"column": 26
}
]
@@ -1741,7 +1876,7 @@
"refs": [
{
"file": "api.ts",
"line": 250,
"line": 260,
"column": 43
}
]
@@ -1765,7 +1900,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 225,
"line": 248,
"column": 22
}
]
@@ -1808,7 +1943,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 82,
"line": 85,
"column": 15
}
]
@@ -1868,7 +2003,7 @@
"refs": [
{
"file": "api.ts",
"line": 213,
"line": 214,
"column": 21
}
]
@@ -2016,6 +2151,18 @@
}
]
},
"8df48894086d6fbd": {
"source": "原因(可选)",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 224,
"column": 16
}
]
},
"90800c48a1dd0655": {
"source": "{label} 必须是 JSON 对象",
"kind": "template",
@@ -2047,6 +2194,18 @@
}
]
},
"91aaf184cfc17ffd": {
"source": "数据概览",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 69,
"column": 39
}
]
},
"91ee087c3d4f3220": {
"source": "支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。",
"kind": "text",
@@ -2193,11 +2352,35 @@
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 110,
"line": 109,
"column": 70
}
]
},
"9f6fee1aba17a565": {
"source": "语言",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 195,
"column": 26
}
]
},
"9fb48101d237ff96": {
"source": "近一周",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 37,
"column": 29
}
]
},
"a026f37e613cf48b": {
"source": "输出 Token",
"kind": "text",
@@ -2217,7 +2400,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 45,
"line": 48,
"column": 34
}
]
@@ -2259,11 +2442,11 @@
{
"file": "components/ui/Modal.tsx",
"line": 7,
"column": 115
"column": 119
},
{
"file": "pages/SettingsPage.tsx",
"line": 142,
"line": 145,
"column": 133
}
]
@@ -2372,7 +2555,7 @@
},
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 113,
"line": 112,
"column": 61
},
{
@@ -2487,22 +2670,22 @@
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 92,
"line": 91,
"column": 28
},
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 92,
"line": 91,
"column": 89
},
{
"file": "layouts/AppLayout.tsx",
"line": 140,
"line": 237,
"column": 64
},
{
"file": "layouts/AppLayout.tsx",
"line": 140,
"line": 237,
"column": 125
}
]
@@ -2593,7 +2776,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 208,
"line": 231,
"column": 22
}
]
@@ -2610,6 +2793,18 @@
}
]
},
"b8c9b486c83b5778": {
"source": "不再显示广告",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/ads/AdMenu.tsx",
"line": 44,
"column": 24
}
]
},
"b9670c85a4ab939e": {
"source": "路由",
"kind": "text",
@@ -2678,6 +2873,30 @@
}
]
},
"bb7efdcb6af6e805": {
"source": "默认暗色",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 223,
"column": 40
}
]
},
"bda62ce1d5e4ace9": {
"source": "可以告诉我们原因",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 229,
"column": 24
}
]
},
"bf57afd709694b55": {
"source": "概览时间范围",
"kind": "text",
@@ -2685,7 +2904,7 @@
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 74,
"line": 73,
"column": 51
}
]
@@ -2815,7 +3034,7 @@
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 112,
"line": 111,
"column": 61
}
]
@@ -2892,12 +3111,12 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 127,
"line": 130,
"column": 22
},
{
"file": "pages/SettingsPage.tsx",
"line": 133,
"line": 136,
"column": 20
}
]
@@ -2909,7 +3128,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 164,
"line": 167,
"column": 21
}
]
@@ -2926,6 +3145,18 @@
}
]
},
"d0bfccc77315d887": {
"source": "近一个月",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 38,
"column": 30
}
]
},
"d15a909c3490a7e0": {
"source": "端点类型",
"kind": "text",
@@ -3049,7 +3280,7 @@
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 114,
"line": 180,
"column": 54
}
]
@@ -3066,6 +3297,18 @@
}
]
},
"da7ae985487c38e6": {
"source": "近1小时",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 34,
"column": 29
}
]
},
"db0a1393f05aa4bd": {
"source": "默认继承上游,可为当前模型单独修改。",
"kind": "text",
@@ -3121,7 +3364,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 239,
"line": 262,
"column": 30
}
]
@@ -3227,6 +3470,18 @@
}
]
},
"e25bf3f419bb68f0": {
"source": "调用详细",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 70,
"column": 44
}
]
},
"e3fee05f688708b4": {
"source": "LLM 调用",
"kind": "text",
@@ -3239,6 +3494,18 @@
}
]
},
"e5043c7a2b408271": {
"source": "近10分钟",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 36,
"column": 36
}
]
},
"e59ae97924d62f01": {
"source": "第一页",
"kind": "text",
@@ -3263,7 +3530,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 58,
"line": 61,
"column": 17
}
]
@@ -3316,7 +3583,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 124,
"line": 127,
"column": 26
}
]
@@ -3391,6 +3658,11 @@
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 75,
"column": 53
},
{
"file": "pages/CursorSettingsPage.tsx",
"line": 122,
@@ -3417,7 +3689,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 183,
"line": 186,
"column": 16
}
]
@@ -3509,7 +3781,7 @@
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 111,
"line": 110,
"column": 21
}
]
@@ -3526,6 +3798,20 @@
}
]
},
"f78413c36d36f090": {
"source": "默认跟随操作系统;不支持的系统语言使用英文。当前:{language}",
"kind": "template",
"placeholders": [
"language"
],
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 199,
"column": 21
}
]
},
"f85537d1fd2ef6f6": {
"source": "自定义概览筛选",
"kind": "text",
@@ -3533,7 +3819,7 @@
"refs": [
{
"file": "components/overview/OverviewTimeRangeFilter.tsx",
"line": 100,
"line": 99,
"column": 19
}
]
@@ -3595,7 +3881,7 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 151,
"line": 154,
"column": 21
}
]
@@ -3692,6 +3978,18 @@
}
]
},
"fec45092945f8790": {
"source": "使用教程",
"kind": "text",
"placeholders": [],
"refs": [
{
"file": "layouts/AppLayout.tsx",
"line": 72,
"column": 48
}
]
},
"ff509c9ba052a21c": {
"source": "保存中…",
"kind": "text",
@@ -3704,7 +4002,7 @@
},
{
"file": "pages/SettingsPage.tsx",
"line": 142,
"line": 145,
"column": 121
}
]
@@ -3728,17 +4026,17 @@
"refs": [
{
"file": "pages/SettingsPage.tsx",
"line": 53,
"line": 56,
"column": 46
},
{
"file": "pages/SettingsPage.tsx",
"line": 163,
"line": 166,
"column": 22
},
{
"file": "pages/SettingsPage.tsx",
"line": 178,
"line": 181,
"column": 58
}
]
+258 -235
View File
@@ -1,264 +1,287 @@
{
"00929f23850e4ff0": "",
"01f3e69a5a9b2c9b": "",
"025b70bde54ed1ef": "",
"028a4de61bff743d": "",
"03ff62ab4b818492": "",
"051836569928a9f9": "",
"0580e0a99a6f1afc": "",
"05912a17829faacc": "",
"076832c1b2de22c3": "",
"07879e064ae16542": "",
"07ec86e0f1d44f91": "",
"08d742566ffc9fa3": "",
"099008ea7a42ebd1": "",
"09ebc2643631ba25": "",
"0a5f9a892960a00a": "",
"0b96da34f6fbdd3b": "",
"0bd20614caf56647": "",
"0c2fee621d406ea7": "",
"0c70665b6eb65f1a": "",
"0c72229b7db0e1a9": "",
"0d5e2bdb15579fc4": "",
"0e41f8e3d59ec47b": "",
"0ec1e85b0c3cfa65": "",
"124be3f86f197802": "",
"12ae77e6202d063e": "",
"12b86e89701b0241": "",
"13a9ac7a68c5fd96": "",
"13b61c5f697b6700": "",
"146da2e2a991493e": "",
"15d76f1c211cc140": "",
"168e845a86bc3703": "",
"16aa4ae1427fccce": "",
"16d0d7e2b332af72": "",
"1813d362a82fd437": "",
"18d25065b3f7db39": "",
"00929f23850e4ff0": "Successful calls: {count}",
"01f3e69a5a9b2c9b": "The key required to access the model service.",
"025b70bde54ed1ef": "Whether Cursor can select and use this model.",
"028a4de61bff743d": "Regular input: {tokens} × ${price}/1M = {cost}",
"03ff62ab4b818492": "Cache write: {tokens} × ${price}/1M = {cost}",
"051836569928a9f9": "Edit",
"0580e0a99a6f1afc": "Artifacts",
"05912a17829faacc": "All call records and detailed traces will be deleted. Providers, models, CA, and application settings are unaffected. This action cannot be undone.",
"076832c1b2de22c3": "Cache write: {tokens}",
"07879e064ae16542": "Estimated output: {tokens}",
"07ec86e0f1d44f91": "Created",
"08d742566ffc9fa3": "Clear all statistics?",
"099008ea7a42ebd1": "All custom Header values must be strings",
"09ebc2643631ba25": "Estimated value",
"0a5f9a892960a00a": "Updated",
"0b96da34f6fbdd3b": "Cache read: {tokens} × ${price}/1M = {cost}",
"0bd20614caf56647": "Select or enter multiple models. When adding in bulk, each display name defaults to its model name.",
"0c2fee621d406ea7": "Select an existing provider or create a new one.",
"0c70665b6eb65f1a": "No",
"0c72229b7db0e1a9": "Model output",
"0d5e2bdb15579fc4": "Messages",
"0e41f8e3d59ec47b": "Storage management",
"0ec1e85b0c3cfa65": "Call details",
"124be3f86f197802": "Token usage",
"12ae77e6202d063e": "Custom Headers",
"12b86e89701b0241": "Enter a model name and press Enter to add it",
"13a9ac7a68c5fd96": "The CA is stored only on this device and is used to securely inspect Cursor HTTPS requests.",
"13b61c5f697b6700": "Cache hit rate",
"146da2e2a991493e": "Fetching…",
"15d76f1c211cc140": "Leave blank to keep the current API Key.",
"168e845a86bc3703": "Add model",
"16aa4ae1427fccce": "Custom Headers JSON",
"16d0d7e2b332af72": "Total calls: {count}",
"1813d362a82fd437": "Maximize window",
"18d25065b3f7db39": "The full request URL is required",
"19658d9fa9aa8de4": "Installing…",
"1a3f0617d6de8e52": "",
"1ae6b0a0f8266382": "",
"1b5932b8946d2d68": "",
"1b7d5b1a9315fc64": "",
"1c6926877b1bbfb2": "",
"1cb7e646ac883fb2": "",
"1cdde778cfe6a130": "",
"1cef73a53d7ad0f2": "",
"1f9b46e61efcccb5": "",
"21296ab18ad9af25": "",
"22c6b4eb4caee6ae": "",
"1a3f0617d6de8e52": "Username",
"1ae6b0a0f8266382": "Close window",
"1b5932b8946d2d68": "Delete model",
"1b7d5b1a9315fc64": "Calculating…",
"1c6926877b1bbfb2": "Total requests: {tokens}",
"1cb7e646ac883fb2": "Proxy port",
"1cdde778cfe6a130": "Formula: cache read / (cache read + non-cached input)",
"1cef73a53d7ad0f2": "For example: OpenAI",
"1f9b46e61efcccb5": "Filter: {label}, {summary}",
"21296ab18ad9af25": "Extra parameters JSON",
"217cfe7db1e3d10a": "Use system language",
"22c6b4eb4caee6ae": "Proxy settings saved",
"23e49479e15e6770": "Version {version} is available",
"29585d7193539200": "Current version {version}",
"2a2773134a829016": "",
"2cd0f3be8738a86c": "",
"2d30c2a98ebb5278": "",
"2eb2bf7c6597ab9a": "",
"2f1b67cdaa23351a": "",
"2f4a361f878176d1": "",
"2f5f1d6fbfb061ed": "",
"2f6416a2c424856b": "",
"2f7dec3be28d7597": "",
"2f9daa828907b93f": "",
"378bb0eec39fa8a2": "",
"37cb98ff4d5dcfcc": "",
"393df9bb13ea4900": "",
"393e1241552b1870": "",
"398f8e6c6f0a0b97": "",
"39f52eee100131d7": "",
"3a0fb74abe2460b6": "",
"3a3f595df70ec8ff": "",
"3a5040b68abf75f9": "",
"3bd5b3f538e3270a": "",
"3c94b4c75940c178": "",
"3cfae5728b92b334": "",
"3da0bf1610ff5db5": "",
"3fd47edce45b3603": "",
"42655ed8e4108ae2": "",
"42a1d9e5b037c210": "",
"42aa8e01e98c0d8c": "",
"43cb41d62de2d179": "",
"470049252e54de6a": "",
"48b970b568a7f8f9": "",
"48d8db17bae06246": "",
"29fbbef32a6eb58b": "Do not show this ad again",
"2a2773134a829016": "Aggregated from historical LLM calls; in-progress calls are excluded.",
"2cd0f3be8738a86c": "Cancel",
"2d30c2a98ebb5278": "Current: {rate}",
"2eb2bf7c6597ab9a": "Detailed records",
"2f1b67cdaa23351a": "Custom full request URL",
"2f4a361f878176d1": "{label} must be valid JSON",
"2f5f1d6fbfb061ed": "Not set",
"2f6416a2c424856b": "Final request URL",
"2f7dec3be28d7597": "{count} selected",
"2f9daa828907b93f": "Delete",
"36f33adaf0942634": "Confirm",
"378bb0eec39fa8a2": "Last page",
"37cb98ff4d5dcfcc": "Successful {successful} / failed {failed}",
"393df9bb13ea4900": "Hit",
"393e1241552b1870": "Request",
"398f8e6c6f0a0b97": "Continue selecting or typing",
"39f52eee100131d7": "Cached input",
"3a0fb74abe2460b6": "Chunks",
"3a3f595df70ec8ff": "Clear storage",
"3a5040b68abf75f9": "Select all",
"3bd5b3f538e3270a": "New provider",
"3c94b4c75940c178": "Input Tokens",
"3cfae5728b92b334": "Token usage: {tokens}",
"3d13868593ae4eeb": "Display language",
"3da0bf1610ff5db5": "Recommended",
"3fd47edce45b3603": "Close",
"4125fc7ba333524c": "Default light",
"42655ed8e4108ae2": "Input (non-cached)",
"42a1d9e5b037c210": "Bytes",
"42aa8e01e98c0d8c": "Total duration",
"43cb41d62de2d179": "Proxy requires authentication",
"4510b63fdcf7451f": "Cursor Assistant v0.1.0",
"470049252e54de6a": "Success rate: {rate}",
"48b970b568a7f8f9": "Proxy settings",
"48d8db17bae06246": "{count} total",
"492042ed1fdc29ed": "Version {version} is ready to install",
"4927a53bcc886afb": "",
"497c85690c4cc0fc": "",
"4d0680f9efaef147": "",
"4eafa9e925b30bcd": "",
"5228358a6db59fe7": "",
"54644705e9c61009": "",
"54c53e5fe791d1f3": "",
"550eddc3c7fefa99": "",
"555737734a6371e6": "",
"56432ba297009bdc": "",
"5ae715656ffbc35d": "",
"5c55a67935af8f45": "",
"4927a53bcc886afb": "Loading…",
"493e097fac6bd217": "Providers",
"497c85690c4cc0fc": "No data",
"4d0680f9efaef147": "Unread",
"4eafa9e925b30bcd": "Custom",
"51d04bc3d286f018": "Last calendar day",
"5228358a6db59fe7": "For example: now, 2026-08-23 18:00",
"54644705e9c61009": "Port settings",
"54c53e5fe791d1f3": "Initialize CA",
"550eddc3c7fefa99": "Sponsored",
"555737734a6371e6": "Delete provider",
"56432ba297009bdc": "Initialize the CA first",
"5ae715656ffbc35d": "Merge into the request body for every model from this provider.",
"5c55a67935af8f45": "All",
"5f8d556a9c47da3c": "Launch at login disabled",
"5fd2ec5a6e9b654c": "",
"6078a681a306930d": "",
"61a4c7bac12dc125": "",
"61c7d1f758b647d5": "",
"621f63a5f08384ac": "",
"5f9acfb945229062": "Are you sure you no longer want to see this ad?",
"5fd2ec5a6e9b654c": "Total: {cost}",
"6078a681a306930d": "Cache write",
"61a4c7bac12dc125": "Failed calls: {count}",
"61c7d1f758b647d5": "Call information",
"621f63a5f08384ac": "Cache read: {tokens}",
"6320b4a8722a851f": "Status",
"63c73c4730f4473e": "",
"6478a5f1218c484e": "",
"63c73c4730f4473e": "Apply",
"6478a5f1218c484e": "Use the desktop app to copy to the system clipboard",
"651f274470153a05": "Software updates",
"653b123c956d3bcb": "Calls",
"656ab25e264cc4e4": "",
"65a6318e07ec1e07": "",
"65cb9a7b4f620b6b": "",
"68152165b3348852": "",
"6a9906c79f26c0ba": "",
"6ae80538c2b2572d": "",
"6d1876364ac6457d": "",
"656ab25e264cc4e4": "No models are available to Cursor yet",
"65a6318e07ec1e07": "Tools",
"65cb9a7b4f620b6b": "Prompt {tokens}",
"68152165b3348852": "Select the request protocol used by this provider.",
"68ea5dd4d7af20e6": "System settings",
"6a9906c79f26c0ba": "Start time",
"6ae80538c2b2572d": "Minimize window",
"6d1876364ac6457d": "Proxy mode",
"6e86570183c3cdd0": "You're up to date",
"7005693f4f050bce": "",
"71eb2c6fce991a29": "",
"722e156c20650a3f": "",
"7005693f4f050bce": "Cache I/O {cost}",
"71eb2c6fce991a29": "Add provider",
"722e156c20650a3f": "Use macOS or Windows.",
"7392e20d61abaa07": "Also store complete requests and streamed responses; by default only timing, status, and usage are stored.",
"75844712d53a24de": "",
"75844712d53a24de": "Values must be strings; when editing, null keeps the original value of the corresponding sensitive Header.",
"788db1cfec2a3db5": "Theme",
"7995087e5a3dfe66": "",
"7a2229f6a6d330a5": "",
"7a3cec4ca715de80": "",
"7ba2d6728fe2531b": "",
"7995087e5a3dfe66": "Restore window",
"7a2229f6a6d330a5": "Open a terminal from the desktop app to install the CA",
"7a3cec4ca715de80": "Call statistics",
"7ba2d6728fe2531b": "Confirm clear",
"7d9f043f8f7ab45c": "Version {version} is available in Settings",
"7e1845870b528392": "",
"7e1f06318e80c3af": "",
"7e9ab9ada2cbf2cb": "",
"7f3c8312816fe26a": "",
"7e1845870b528392": "Enable model",
"7e1f06318e80c3af": "Statistics cleared",
"7e9ab9ada2cbf2cb": "Name and Base URL are required",
"7f3c8312816fe26a": "Refreshing…",
"7f68ebad19ba6bcd": "Check for updates",
"80a57e03f0717f91": "",
"811a3b22a5a7f2d5": "",
"83fcfb4c1f2c1641": "",
"80a57e03f0717f91": "Not configured",
"811a3b22a5a7f2d5": "Unable to connect to the local management service",
"83fcfb4c1f2c1641": "Fetch models",
"842b9f11cdd96bda": "Launch at login",
"84924374710e03bd": "",
"8716e1344b0daddb": "",
"878a8ab176429a86": "",
"883cc47637fe70f3": "",
"8a8542f6964852dc": "",
"84924374710e03bd": "Base URL must be a valid URL",
"8716e1344b0daddb": "Cursor official",
"878a8ab176429a86": "View instructions",
"883cc47637fe70f3": "Custom request headers appended to every request for this provider. Values must be strings.",
"8a8542f6964852dc": "Next page",
"8b6ff498515bcc2f": "Time",
"8d0c47eb9eac2d34": "",
"90800c48a1dd0655": "",
"919cb0ce0c8db4e7": "",
"91ee087c3d4f3220": "",
"940a168911ade998": "",
"946b3ffc02f026c0": "",
"94803f35c825e47a": "",
"94df1e7f04815daf": "",
"95f76d30c25d5eda": "",
"9850ed41a5bfbb0c": "",
"997ec8201c2adeda": "",
"9a026819dd1af5c5": "",
"9ac0ac940982895d": "",
"9c41b3a9e12ac994": "",
"9e46da6923836182": "",
"a026f37e613cf48b": "",
"a03a1a0cb35414f8": "",
"8d0c47eb9eac2d34": "Call type",
"8df48894086d6fbd": "Reason (optional)",
"90800c48a1dd0655": "{label} must be a JSON object",
"919cb0ce0c8db4e7": "Leave blank to keep the current password",
"91aaf184cfc17ffd": "Overview",
"91ee087c3d4f3220": "Supports a complete HTTP(S) URL or a relative path beginning with /, combined with the provider URL.",
"940a168911ade998": "Items per page",
"946b3ffc02f026c0": "Delete this model?",
"94803f35c825e47a": "Full request URL",
"94df1e7f04815daf": "Used only for display; does not change the model name sent to the provider.",
"95f76d30c25d5eda": "CA installation is not supported on this system",
"9850ed41a5bfbb0c": "{count} selected",
"997ec8201c2adeda": "Open terminal to install CA",
"9a026819dd1af5c5": "Enter a model identifier directly or select one returned by the current provider.",
"9ac0ac940982895d": "Select provider",
"9c41b3a9e12ac994": "Reasoning effort",
"9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago",
"9f6fee1aba17a565": "Language",
"9fb48101d237ff96": "Last week",
"a026f37e613cf48b": "Output Tokens",
"a03a1a0cb35414f8": " must be an integer from 0 to 65535",
"a1a42cd9b16e2162": "Application",
"a2901cd2743c1921": "",
"a3030bf8f16dc63c": "",
"a363743025795ec7": "",
"a3ab741ceb188e9e": "",
"a49ffd73bc85333d": "",
"a621ab606db2a11f": "",
"a748cc074f78de00": "",
"a98585871c5313ff": "",
"a9ab292ea9feecdc": "",
"aa678e13bda90e3c": "",
"a2901cd2743c1921": "The API root URL for the model service. Changing it also updates the routing identity of this provider's models.",
"a3030bf8f16dc63c": "Save",
"a363743025795ec7": "I've initialized it — refresh",
"a3ab741ceb188e9e": "Request content was not recorded. Enable detailed records and try again.",
"a49ffd73bc85333d": "Average",
"a621ab606db2a11f": "Password",
"a748cc074f78de00": "View details",
"a98585871c5313ff": "Display name",
"a9ab292ea9feecdc": "Provider",
"aa678e13bda90e3c": "Custom Header values must be strings or null",
"ab2f31f30acf7fda": "Protocol",
"ab9084a640fbb864": "",
"ab9084a640fbb864": "Deselect all",
"abecab6701177721": "Launch at login enabled",
"ac69f68b7010ec79": "Download and install",
"ad6a60ee93d3ba3e": "",
"ae2d0b7f79cea4a3": "",
"ad6a60ee93d3ba3e": "Loading call details…",
"ae2d0b7f79cea4a3": "Model output: {tokens} × ${price}/1M = {cost}",
"aee88743413144a2": "Refresh",
"b4411558b932266f": "",
"b502b1d414664337": "",
"b5141d3d19e9a048": "",
"b710ec36ad312918": "",
"b75a46aad3e7c132": "",
"b79354009c614ae9": "",
"b89a0e4584f27ab5": "",
"b9670c85a4ab939e": "",
"b97ad406809572e1": "",
"b9af2de88d903be7": "",
"bb2b7736433ae867": "",
"bf57afd709694b55": "",
"bf84379b75735037": "",
"bfc01caf9fe0c841": "",
"c1e98892a77f7a19": "",
"c3760858cdb6d9f4": "",
"c62a58459251b02c": "",
"c7ea2c9bc43134bd": "",
"c8df3c14a003bfcd": "",
"b4411558b932266f": "Provider type",
"b502b1d414664337": "Prompt: {tokens}",
"b5141d3d19e9a048": "Yes",
"b710ec36ad312918": "The model service API root URL, for example https://api.openai.com/v1.",
"b75a46aad3e7c132": "Non-cached input: {tokens}",
"b79354009c614ae9": "Statistics",
"b89a0e4584f27ab5": "Open terminal",
"b8c9b486c83b5778": "Hide ad",
"b9670c85a4ab939e": "Route",
"b97ad406809572e1": "Enable reasoning",
"b9af2de88d903be7": "Proxy address",
"bb2b7736433ae867": "Cursor tracing",
"bb7efdcb6af6e805": "Default dark",
"bda62ce1d5e4ace9": "Tell us why",
"bf57afd709694b55": "Overview time range",
"bf84379b75735037": "Edit provider",
"bfc01caf9fe0c841": "Cache hit rate {rate}",
"c1e98892a77f7a19": "{count} per page",
"c3760858cdb6d9f4": "Request body",
"c62a58459251b02c": "Image generation",
"c7ea2c9bc43134bd": "Edit model",
"c8df3c14a003bfcd": "Unable to load call details",
"c98e118e0a43f078": "Model",
"c9b9ae7a61444ab7": "",
"c9d146d006993cc1": "",
"cb2f1709f983d2f4": "",
"cea1aafe9416de7b": "",
"c9b9ae7a61444ab7": "Previous page",
"c9d146d006993cc1": "Cache statistics policy: default ({rate})",
"cb2f1709f983d2f4": "Model name",
"cea1aafe9416de7b": "Request headers",
"cfae1a14d2120c57": "Detailed mode",
"cfe085015632e9c8": "",
"cfe999e50be8ef54": "",
"d15a909c3490a7e0": "",
"d2d648bd1c94b7f9": "",
"d30d35c5ec4a888d": "",
"cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.",
"cfe999e50be8ef54": "Whether the model declares image-generation support.",
"d0bfccc77315d887": "Last month",
"d15a909c3490a7e0": "Endpoint type",
"d2d648bd1c94b7f9": "Authentication",
"d30d35c5ec4a888d": "Select or enter at least one model",
"d34335433395cd3a": "Start Cursor BYOK automatically after signing in.",
"d3d21191f32e79a5": "",
"d3d21191f32e79a5": "Processing…",
"d44e9b3d3b31d37b": "Name",
"d667c1616e31c4c4": "",
"d86fa42c3848c680": "",
"d8c47e9776cf1082": "",
"da521d1c1cbd36af": "",
"db0a1393f05aa4bd": "",
"ddde16f8839da3ce": "",
"de8184da1ef88d03": "",
"dea7749c4cd77e6d": "",
"df3d58c7d84b85f2": "",
"df8b71c74d9b8478": "",
"dfb802238b38fbd4": "",
"e025f1ff71996425": "",
"e1295adecbb77755": "",
"e14115de7f7c5795": "",
"e14a5eee9b0b0f9f": "",
"e14f20d572c02611": "",
"e18516550b9a5105": "",
"d667c1616e31c4c4": "Leave blank to keep the current key",
"d86fa42c3848c680": "Use system proxy",
"d8c47e9776cf1082": "Main menu",
"da521d1c1cbd36af": "Authorization is required to install the certificate",
"da7ae985487c38e6": "Last hour",
"db0a1393f05aa4bd": "Inherits the provider by default; override it for this model if needed.",
"ddde16f8839da3ce": "Total requests",
"de8184da1ef88d03": "Configured",
"dea7749c4cd77e6d": "Total request Tokens include the prompt and model output.",
"df3d58c7d84b85f2": "Settings",
"df8b71c74d9b8478": "Response stream",
"dfb802238b38fbd4": "Enabled",
"e025f1ff71996425": "Set",
"e1295adecbb77755": "Close ad",
"e14115de7f7c5795": "Token usage over the past year",
"e14a5eee9b0b0f9f": "Whether the model declares reasoning support.",
"e14f20d572c02611": "Provider call sequence",
"e18516550b9a5105": "No usage",
"e25bf3f419bb68f0": "Call history",
"e3fee05f688708b4": "LLM calls",
"e59ae97924d62f01": "",
"e5b9961a0d5242e3": "",
"e6ca887f22288cde": "",
"e77e3d58b0dcffaa": "",
"e828bd3a0151edc2": "",
"e5043c7a2b408271": "Last 10 minutes",
"e59ae97924d62f01": "First page",
"e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.",
"e6ca887f22288cde": "Model ID and display name are required",
"e77e3d58b0dcffaa": "Duration",
"e828bd3a0151edc2": "The local CA must be trusted by the system",
"ea26b760e930a7ca": "Call observability",
"eb11e2df1d8ae387": "",
"eb1be07f2ca6e506": "",
"eb77492c9f76a7e1": "",
"ed31fbb483ee1b0a": "",
"edc70de18c6da1a6": "",
"ede6d08f397df283": "",
"ee239f3943293f87": "",
"ee6b89a6a740a4c4": "",
"f04c91a6bc3a6926": "",
"f396118b8afd2a21": "",
"f3a76d896853c1df": "",
"f4694c46b1e19602": "",
"f4dcb6a3ceb32247": "",
"f4fa9f31ea2ae58d": "",
"f50276449943286c": "",
"f69273dbbebfb3a1": "",
"f85537d1fd2ef6f6": "",
"f95ea7f4c063eea7": "",
"f9615d05d8e18595": "",
"f9aa11dbb15ce647": "",
"f9b55ca75425161b": "",
"fa5b4b8a751c7d1b": "",
"eb11e2df1d8ae387": "Provider URL",
"eb1be07f2ca6e506": "Estimated using Claude Opus 4.7 pricing.",
"eb77492c9f76a7e1": "The install command has been copied. Click “Open terminal”, paste it into the terminal, and enter your password when prompted.",
"ed31fbb483ee1b0a": "Actions",
"edc70de18c6da1a6": "Install local CA",
"ede6d08f397df283": "Cursor settings",
"ee239f3943293f87": "Sunday",
"ee6b89a6a740a4c4": "If a port is occupied, a new random port is selected and saved automatically. Restart the app after changing these settings.",
"f04c91a6bc3a6926": "Extra parameters",
"f396118b8afd2a21": "Cursor interception is active. Add a provider and its model configuration to use BYOK models.",
"f3a76d896853c1df": "Miss",
"f4694c46b1e19602": "Final request type",
"f4dcb6a3ceb32247": "Page {page} of {count}",
"f4fa9f31ea2ae58d": "Token usage calendar for the past year",
"f50276449943286c": "End time",
"f69273dbbebfb3a1": "Format",
"f78413c36d36f090": "Uses the operating system language by default; unsupported languages fall back to English. Current: {language}",
"f85537d1fd2ef6f6": "Custom overview filters",
"f95ea7f4c063eea7": "Disabled",
"f9615d05d8e18595": "Clear {label} filter",
"f9aa11dbb15ce647": "Saturday",
"f9b55ca75425161b": "Response content was not recorded. Enable detailed records and try again.",
"fa5b4b8a751c7d1b": "The local proxy port used by Cursor. Enter 0 to select a random port at startup.",
"fb11aa6f29827095": "Checking…",
"fbe8778fa8b9bab5": "",
"fc3947ebe6b2177b": "",
"fcd311fd8ad42462": "",
"fd415f8e0097c832": "",
"fdc4cabc370fa3f7": "",
"fea405f9b01d1416": "",
"ff509c9ba052a21c": "",
"ff66b1c7e010bbc2": "",
"ff673195f05fb056": ""
"fbe8778fa8b9bab5": "Initialize the local CA first",
"fc3947ebe6b2177b": "Default {defaultRate} / include creation {reuseRate}",
"fcd311fd8ad42462": "Open model list",
"fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.",
"fdc4cabc370fa3f7": "No options",
"fea405f9b01d1416": "Summary",
"fec45092945f8790": "User guide",
"ff509c9ba052a21c": "Saving…",
"ff66b1c7e010bbc2": "Paste the authorization command into the terminal and enter your password, then click the button below",
"ff673195f05fb056": "Service port"
}
+23
View File
@@ -45,9 +45,11 @@
"1cef73a53d7ad0f2": "例如:OpenAI",
"1f9b46e61efcccb5": "筛选项:{label},{summary}",
"21296ab18ad9af25": "额外参数 JSON",
"217cfe7db1e3d10a": "跟随系统",
"22c6b4eb4caee6ae": "代理设置已保存",
"23e49479e15e6770": "发现新版本 {version}",
"29585d7193539200": "当前版本 {version}",
"29fbbef32a6eb58b": "不再显示此广告",
"2a2773134a829016": "按历史 LLM 调用记录汇总,进行中的调用不计入。",
"2cd0f3be8738a86c": "取消",
"2d30c2a98ebb5278": "当前:{rate}",
@@ -58,6 +60,7 @@
"2f6416a2c424856b": "最终请求地址",
"2f7dec3be28d7597": "已选择 {count} 个",
"2f9daa828907b93f": "删除",
"36f33adaf0942634": "确认",
"378bb0eec39fa8a2": "最后一页",
"37cb98ff4d5dcfcc": "成功 {successful} / 异常 {failed}",
"393df9bb13ea4900": "命中",
@@ -70,20 +73,25 @@
"3bd5b3f538e3270a": "新建上游",
"3c94b4c75940c178": "输入 Token",
"3cfae5728b92b334": "Token 用量:{tokens}",
"3d13868593ae4eeb": "界面语言",
"3da0bf1610ff5db5": "推荐内容",
"3fd47edce45b3603": "关闭",
"4125fc7ba333524c": "默认亮色",
"42655ed8e4108ae2": "输入(非缓存)",
"42a1d9e5b037c210": "字节数",
"42aa8e01e98c0d8c": "总耗时",
"43cb41d62de2d179": "代理需要认证",
"4510b63fdcf7451f": "Cursor 助手 v0.1.0",
"470049252e54de6a": "成功占比:{rate}",
"48b970b568a7f8f9": "代理设置",
"48d8db17bae06246": "共 {count} 条",
"492042ed1fdc29ed": "版本 {version} 可以安装",
"4927a53bcc886afb": "加载中…",
"493e097fac6bd217": "上游配置",
"497c85690c4cc0fc": "暂无数据",
"4d0680f9efaef147": "未读",
"4eafa9e925b30bcd": "自定义",
"51d04bc3d286f018": "近1自然日",
"5228358a6db59fe7": "如:现在、2026-08-23 18:00",
"54644705e9c61009": "端口设置",
"54c53e5fe791d1f3": "初始化 CA",
@@ -93,6 +101,7 @@
"5ae715656ffbc35d": "合并到该上游所有模型的请求体。",
"5c55a67935af8f45": "全部",
"5f8d556a9c47da3c": "已关闭开机启动",
"5f9acfb945229062": "你确认不想再看到此广告吗?",
"5fd2ec5a6e9b654c": "合计:{cost}",
"6078a681a306930d": "缓存写入",
"61a4c7bac12dc125": "异常调用:{count}",
@@ -107,6 +116,7 @@
"65a6318e07ec1e07": "工具数",
"65cb9a7b4f620b6b": "提示词 {tokens}",
"68152165b3348852": "选择上游服务使用的请求协议。",
"68ea5dd4d7af20e6": "系统设置",
"6a9906c79f26c0ba": "开始时间",
"6ae80538c2b2572d": "最小化窗口",
"6d1876364ac6457d": "代理方式",
@@ -138,8 +148,10 @@
"8a8542f6964852dc": "下一页",
"8b6ff498515bcc2f": "时间",
"8d0c47eb9eac2d34": "调用类型",
"8df48894086d6fbd": "原因(可选)",
"90800c48a1dd0655": "{label} 必须是 JSON 对象",
"919cb0ce0c8db4e7": "留空表示保留当前密码",
"91aaf184cfc17ffd": "数据概览",
"91ee087c3d4f3220": "支持完整 HTTP(S) 地址或以 / 开头、与上游地址组合的相对路径。",
"940a168911ade998": "每页条数",
"946b3ffc02f026c0": "确定删除这个模型吗?",
@@ -152,6 +164,8 @@
"9ac0ac940982895d": "选择上游",
"9c41b3a9e12ac994": "思考强度",
"9e46da6923836182": "如:2026-08-23 09:00、1小时前",
"9f6fee1aba17a565": "语言",
"9fb48101d237ff96": "近一周",
"a026f37e613cf48b": "输出 Token",
"a03a1a0cb35414f8": "必须是 0–65535 之间的整数",
"a1a42cd9b16e2162": "应用设置",
@@ -179,10 +193,13 @@
"b75a46aad3e7c132": "非缓存输入:{tokens}",
"b79354009c614ae9": "统计数据",
"b89a0e4584f27ab5": "打开终端",
"b8c9b486c83b5778": "不再显示广告",
"b9670c85a4ab939e": "路由",
"b97ad406809572e1": "启用推理",
"b9af2de88d903be7": "代理地址",
"bb2b7736433ae867": "Cursor 追踪",
"bb7efdcb6af6e805": "默认暗色",
"bda62ce1d5e4ace9": "可以告诉我们原因",
"bf57afd709694b55": "概览时间范围",
"bf84379b75735037": "编辑上游",
"bfc01caf9fe0c841": "缓存命中率 {rate}",
@@ -199,6 +216,7 @@
"cfae1a14d2120c57": "详细模式",
"cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。",
"cfe999e50be8ef54": "是否声明模型支持图片生成。",
"d0bfccc77315d887": "近一个月",
"d15a909c3490a7e0": "端点类型",
"d2d648bd1c94b7f9": "认证",
"d30d35c5ec4a888d": "请至少选择或输入一个模型",
@@ -209,6 +227,7 @@
"d86fa42c3848c680": "使用系统代理",
"d8c47e9776cf1082": "主菜单",
"da521d1c1cbd36af": "需要授权安装证书",
"da7ae985487c38e6": "近1小时",
"db0a1393f05aa4bd": "默认继承上游,可为当前模型单独修改。",
"ddde16f8839da3ce": "总请求",
"de8184da1ef88d03": "已配置",
@@ -222,7 +241,9 @@
"e14a5eee9b0b0f9f": "是否声明模型支持推理能力。",
"e14f20d572c02611": "上游调用序号",
"e18516550b9a5105": "无用量",
"e25bf3f419bb68f0": "调用详细",
"e3fee05f688708b4": "LLM 调用",
"e5043c7a2b408271": "近10分钟",
"e59ae97924d62f01": "第一页",
"e5b9961a0d5242e3": "端口设置已保存,重启软件后生效",
"e6ca887f22288cde": "Model ID 和显示名称不能为空",
@@ -245,6 +266,7 @@
"f4fa9f31ea2ae58d": "过去一年的 Token 用量日历",
"f50276449943286c": "结束时间",
"f69273dbbebfb3a1": "格式化",
"f78413c36d36f090": "默认跟随操作系统;不支持的系统语言使用英文。当前:{language}",
"f85537d1fd2ef6f6": "自定义概览筛选",
"f95ea7f4c063eea7": "未启用",
"f9615d05d8e18595": "清除{label}筛选",
@@ -258,6 +280,7 @@
"fd415f8e0097c832": "缓存读写已计入提示词侧统计。",
"fdc4cabc370fa3f7": "暂无选项",
"fea405f9b01d1416": "概览",
"fec45092945f8790": "使用教程",
"ff509c9ba052a21c": "保存中…",
"ff66b1c7e010bbc2": "请在终端中粘贴授权命令并输入密码,完成后点击下方按钮",
"ff673195f05fb056": "服务端口"
+76
View File
@@ -0,0 +1,76 @@
import { useSyncExternalStore } from "react";
import { setRuntimeLocale, type Locale } from "./runtime";
export type LocalePreference = "system" | Locale;
type I18nSnapshot = {
preference: LocalePreference;
locale: Locale;
};
const storageKey = "cursor-byok.locale";
const listeners = new Set<() => void>();
let initialized = false;
let snapshot: I18nSnapshot = { preference: "system", locale: "en-US" };
function isLocale(value: string | null): value is Locale {
return value === "zh-CN" || value === "en-US";
}
export function resolveSystemLocale(
languages: readonly string[] = navigator.languages.length
? navigator.languages
: [navigator.language],
): Locale {
const normalized = languages[0]?.toLowerCase() ?? "";
if (normalized === "zh" || normalized.startsWith("zh-")) return "zh-CN";
if (normalized === "en" || normalized.startsWith("en-")) return "en-US";
return "en-US";
}
function readPreference(): LocalePreference {
try {
const saved = localStorage.getItem(storageKey);
return isLocale(saved) ? saved : "system";
} catch {
return "system";
}
}
function applyPreference(preference: LocalePreference, notify: boolean) {
const locale = preference === "system" ? resolveSystemLocale() : preference;
const changed = snapshot.preference !== preference || snapshot.locale !== locale;
snapshot = { preference, locale };
setRuntimeLocale(locale);
document.documentElement.lang = locale;
if (notify && changed) listeners.forEach((listener) => listener());
}
export function initializeI18n() {
if (initialized) return;
initialized = true;
applyPreference(readPreference(), false);
window.addEventListener("languagechange", () => {
if (snapshot.preference === "system") applyPreference("system", true);
});
}
export function setLocalePreference(preference: LocalePreference) {
try {
if (preference === "system") localStorage.removeItem(storageKey);
else localStorage.setItem(storageKey, preference);
} catch {
// The preference still applies for the current session when storage is unavailable.
}
applyPreference(preference, true);
}
export function useI18n(): I18nSnapshot {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => snapshot,
);
}
+4 -4
View File
@@ -1,15 +1,15 @@
import ReactDOM from "react-dom/client";
import "../node_modules/monaco-editor/min/vs/editor/editor.main.css";
import { App } from "./App";
import { setRuntimeLocale } from "./i18n/runtime";
import { I18nRoot } from "./i18n/I18nRoot";
import { initializeI18n } from "./i18n/store";
import { appStore } from "./store/appStore";
import { applyTheme } from "./theme/theme";
import "./styles/globals.scss";
setRuntimeLocale("zh-CN");
initializeI18n();
applyTheme(appStore.getSnapshot().theme);
void appStore.refresh();
ReactDOM.createRoot(document.getElementById("root")!).render(
<App />,
<I18nRoot />,
);
+1 -1
View File
@@ -17,7 +17,7 @@ export function AppHeader({ platform, nativeDesktop }: AppHeaderProps) {
{showNativeUi && <>
<div className={styles.identity} aria-label="Cursor BYOK">
<img src={appIcon} alt="" />
<span>Cursor 助手 v0.1.0</span>
<span>{t("Cursor 助手 v0.1.0")}</span>
</div>
<WindowControls />
</>}
+18 -1
View File
@@ -53,7 +53,8 @@
display: flex;
align-items: stretch;
a {
a,
button {
width: 100%;
height: 36px;
display: flex;
@@ -73,6 +74,22 @@
background: var(--vscode-list-activeSelectionBackground);
}
}
button {
font: inherit;
text-align: left;
background: transparent;
border: 0;
}
}
.menuUnreadDot {
width: 7px;
height: 7px;
flex: 0 0 7px;
margin-left: auto;
background: #ef4444;
border-radius: 50%;
}
.dismissReason {
+50 -34
View File
@@ -13,50 +13,24 @@ import controls from "../components/ui/Controls.module.scss";
import { Icon } from "../components/ui/Icon";
import { Modal } from "../components/ui/Modal";
import { TooltipTrigger } from "../components/ui/TooltipTrigger";
import { flatColorAreaChartIcon, flatColorOrganizationIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../components/ui/icons";
import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorOrganizationIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../components/ui/icons";
import { useMessage } from "../components/ui/message";
import { VirtualList } from "../components/virtual/VirtualList";
import { useI18n } from "../i18n/store";
import { appStore, useAppStore } from "../store/appStore";
import styles from "./AppLayout.module.scss";
import { PageActionsTarget } from "./PageActions";
type MenuItem =
| { kind: "page"; path: string; label: string; icon: IconifyIcon | string }
| { kind: "external"; id: string; label: string; icon: IconifyIcon | string }
| { kind: "group"; label: string };
const menuItems: MenuItem[] = [
{ kind: "page", path: "/", label: "数据概览", icon: flatColorAreaChartIcon },
{
kind: "page",
path: "/calls",
label: "调用详细",
icon: flatColorSalesPerformanceIcon,
},
{
kind: "page",
path: "/providers",
label: "上游配置",
icon: flatColorOrganizationIcon,
},
{ kind: "group", label: "Harness" },
{
kind: "page",
path: "/settings",
label: "系统设置",
icon: flatColorSettingsIcon,
},
{
kind: "page",
path: "/harness/cursor",
label: "Cursor 设置",
icon: cursorIconUrl,
},
];
const keptAlivePages = ["/", "/calls", "/providers", "/settings", "/harness/cursor"];
const readAdStorageKey = "cursor-byok:read-ad-ids";
const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids";
const tutorialReadStorageKey = "cursor-byok:tutorial-read";
const tutorialUrl = "https://docs.leokun.cn";
function loadStoredAdIds(key: string): Set<string> {
try {
@@ -69,6 +43,7 @@ function loadStoredAdIds(key: string): Set<string> {
export function AppLayout() {
const { busy } = useAppStore();
const { locale } = useI18n();
const message = useMessage();
const location = useLocation();
const [actionTarget, setActionTarget] = useState<HTMLDivElement | null>(null);
@@ -76,6 +51,13 @@ export function AppLayout() {
const [activeAd, setActiveAd] = useState<AdSlot | null>(null);
const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null);
const [dismissReason, setDismissReason] = useState("");
const [tutorialRead, setTutorialRead] = useState(() => {
try {
return localStorage.getItem(tutorialReadStorageKey) === "true";
} catch {
return false;
}
});
const [readAdIds, setReadAdIds] = useState(() => loadStoredAdIds(readAdStorageKey));
const [dismissedAdIds, setDismissedAdIds] = useState(() => loadStoredAdIds(dismissedAdStorageKey));
const dismissedAdIdsRef = useRef(dismissedAdIds);
@@ -83,6 +65,26 @@ export function AppLayout() {
const adTriggers = useRef(new Map<string, HTMLButtonElement>());
const closeAd = useCallback(() => setActiveAd(null), []);
const visibleAds = ads.filter((ad) => ad.enabled && ad.placement === "menu" && !dismissedAdIds.has(ad.id));
const menuItems: MenuItem[] = [
{ kind: "page", path: "/", label: t("数据概览"), icon: flatColorAreaChartIcon },
{ kind: "page", path: "/calls", label: t("调用详细"), icon: flatColorSalesPerformanceIcon },
{ kind: "page", path: "/providers", label: t("上游配置"), icon: flatColorOrganizationIcon },
{ kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon },
{ kind: "group", label: "Harness" },
{ kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon },
{ kind: "page", path: "/harness/cursor", label: t("Cursor 设置"), icon: cursorIconUrl },
];
const openTutorial = useCallback(() => {
setTutorialRead(true);
try {
localStorage.setItem(tutorialReadStorageKey, "true");
} catch {
// Read state remains valid for the current session when storage is unavailable.
}
void api.openExternalUrl(tutorialUrl)
.catch((cause) => message(cause instanceof Error ? cause.message : String(cause)));
}, [message]);
useEffect(() => {
let disposed = false;
@@ -93,7 +95,7 @@ export function AppLayout() {
if (pending || now - lastRequestedAt < 10_000) return;
pending = true;
lastRequestedAt = now;
void api.ads(dismissedAdIdsRef.current)
void api.ads(dismissedAdIdsRef.current, locale)
.then((runtime) => {
if (disposed) return;
setAds(runtime.slots);
@@ -120,7 +122,7 @@ export function AppLayout() {
window.removeEventListener("focus", refreshAds);
document.removeEventListener("visibilitychange", refreshVisibleAds);
};
}, []);
}, [locale]);
const openAd = useCallback((ad: AdSlot) => {
setReadAdIds((current) => {
@@ -178,13 +180,27 @@ export function AppLayout() {
<nav className={styles.navigation} aria-label={t("主菜单")}>
<VirtualList
items={menuItems}
itemKey={(item) => item.kind === "group" ? `group-${item.label}` : item.path}
itemKey={(item) => item.kind === "group" ? `group-${item.label}` : item.kind === "external" ? `external-${item.id}` : item.path}
estimatedItemHeight={36}
itemGap={3}
className={`${styles.navigationList} scroll-shadow-bottom`}
>
{(item) => item.kind === "group"
? <div className={styles.navigationGroup} key={`group-${item.label}`}>{item.label}</div>
: item.kind === "external"
? <div className={styles.navigationRow} key={item.id}>
<button
type="button"
aria-label={`${item.label}${tutorialRead ? "" : `,${t("未读")}`}`}
onClick={openTutorial}
>
{typeof item.icon === "string"
? <Icon src={item.icon} size="1.3em" />
: <Icon icon={item.icon} size="1.3em" />}
<span>{item.label}</span>
{!tutorialRead && <span className={styles.menuUnreadDot} aria-hidden="true" />}
</button>
</div>
: <div className={styles.navigationRow} key={item.path}>
<NavLink to={item.path} end={item.path === "/"}>
{typeof item.icon === "string"
@@ -80,6 +80,11 @@
}
}
.languageControl {
width: 180px;
flex: 0 0 auto;
}
.portSettings {
display: grid;
gap: 14px;
+25 -2
View File
@@ -7,7 +7,9 @@ import { Button } from "../components/ui/Button";
import { Checkbox } from "../components/ui/Checkbox";
import { FormField, TextInput } from "../components/ui/FormControls";
import { Modal } from "../components/ui/Modal";
import { Select } from "../components/ui/Select";
import { TitledCard } from "../components/ui/TitledCard";
import { setLocalePreference, useI18n, type LocalePreference } from "../i18n/store";
import { useMessage } from "../components/ui/message";
import { appStore, useAppStore } from "../store/appStore";
import { themeOptions } from "../theme/theme";
@@ -15,6 +17,7 @@ import styles from "./SettingsPage.module.scss";
export function SettingsPage() {
const { detailed, ports, theme } = useAppStore();
const { preference, locale } = useI18n();
const message = useMessage();
const [proxyPort, setProxyPort] = useState(String(ports.proxy_port));
const [servicePort, setServicePort] = useState(String(ports.service_port));
@@ -189,15 +192,35 @@ export function SettingsPage() {
</TitledCard>
<ProxySettingsCard settings={outboundProxy} draft={proxyDraft} editing={editingProxy} saving={savingProxy} onDraftChange={setProxyDraft} onEdit={editProxy} onCancel={cancelProxyEdit} onSave={() => void saveProxy()} />
<AppLifecycleSettingsCard />
<TitledCard title={t("语言")}>
<div className={styles.settingRow}>
<div>
<strong>{t("界面语言")}</strong>
<small>{t("默认跟随操作系统;不支持的系统语言使用英文。当前:{language}", { language: locale === "zh-CN" ? "简体中文" : "English" })}</small>
</div>
<div className={styles.languageControl}>
<Select
value={preference}
ariaLabel={t("界面语言")}
options={[
{ value: "system", label: t("跟随系统") },
{ value: "zh-CN", label: "简体中文" },
{ value: "en-US", label: "English" },
]}
onChange={(value) => setLocalePreference(value as LocalePreference)}
/>
</div>
</div>
</TitledCard>
<TitledCard title={t("主题")}>
<div className={styles.themeActions}>
{themeOptions.map(({ id, name }) => (
{themeOptions.map(({ id }) => (
<button
className={theme === id ? styles.selected : ""}
key={id}
onClick={() => appStore.selectTheme(id)}
>
{name}
{id === "default-dark" ? t("默认暗色") : t("默认亮色")}
</button>
))}
</div>
+3 -3
View File
@@ -2,9 +2,9 @@ export const themeIds = ["default-dark", "default-light"] as const;
export type ThemeId = (typeof themeIds)[number];
export const themeOptions = [
{ id: "default-dark", name: "默认暗色" },
{ id: "default-light", name: "默认亮色" },
] satisfies { id: ThemeId; name: string }[];
{ id: "default-dark" },
{ id: "default-light" },
] satisfies { id: ThemeId }[];
export function isThemeId(value: string | null): value is ThemeId {
return value !== null && themeIds.some((id) => id === value);
+25 -2
View File
@@ -15,11 +15,12 @@ use super::ControlService;
// 此广告拉取不涉及用户隐私,用户id随机产生
// 开源项目广告为作者唯一收入来源,PR时请勿去除此广告
pub(super) const ADS_ENDPOINT: &str = "http://127.0.0.1:8080/api/v1/ads?placement=menu";
pub(super) const ADS_ENDPOINT: &str = "https://tab.leokun.cn/api/v1/ads?placement=menu";
pub(super) const DEVICE_ID_HEADER: &str = "X-Cursor-Assistant-Device-ID";
pub(super) const OS_HEADER: &str = "X-Cursor-Assistant-OS";
pub(super) const APP_VERSION_HEADER: &str = "X-Cursor-Assistant-Version";
pub(super) const DISABLED_AD_IDS_HEADER: &str = "disable-ad-ids";
pub(super) const LANGUAGE_HEADER: &str = "accept-language";
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AdRuntime {
@@ -121,7 +122,19 @@ pub async fn get(
let disabled_ad_ids = headers
.get(DISABLED_AD_IDS_HEADER)
.and_then(|value| value.to_str().ok());
Ok(Json(service.ads(disabled_ad_ids).await?))
Ok(Json(
service.ads(disabled_ad_ids, ad_language(&headers)).await?,
))
}
fn ad_language(headers: &HeaderMap) -> &'static str {
match headers
.get(LANGUAGE_HEADER)
.and_then(|value| value.to_str().ok())
{
Some(value) if value.eq_ignore_ascii_case("zh-CN") => "zh-CN",
_ => "en-US",
}
}
pub async fn dismiss(
@@ -137,6 +150,16 @@ pub async fn dismiss(
mod tests {
use super::*;
#[test]
fn accepts_supported_ad_language_and_falls_back_to_english() {
let mut headers = HeaderMap::new();
headers.insert(LANGUAGE_HEADER, "zh-CN".parse().unwrap());
assert_eq!(ad_language(&headers), "zh-CN");
headers.insert(LANGUAGE_HEADER, "fr-FR".parse().unwrap());
assert_eq!(ad_language(&headers), "en-US");
}
#[test]
fn filters_disabled_slots_without_limiting_menu_ads() {
let slot = |id: &str, enabled| AdSlot {
+1
View File
@@ -185,6 +185,7 @@ fn desktop_cors() -> CorsLayer {
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([
CONTENT_TYPE,
header::ACCEPT_LANGUAGE,
header::HeaderName::from_static("disable-ad-ids"),
])
}
+7 -2
View File
@@ -7,7 +7,7 @@ use url::Url;
use super::ads::{
AdDismissalInput, AdRuntime, ADS_ENDPOINT, APP_VERSION_HEADER, DEVICE_ID_HEADER,
DISABLED_AD_IDS_HEADER, OS_HEADER,
DISABLED_AD_IDS_HEADER, LANGUAGE_HEADER, OS_HEADER,
};
use crate::{
@@ -83,7 +83,11 @@ impl ControlService {
&self.cursor_harness
}
pub(super) async fn ads(&self, disabled_ad_ids: Option<&str>) -> Result<AdRuntime> {
pub(super) async fn ads(
&self,
disabled_ad_ids: Option<&str>,
language: &str,
) -> Result<AdRuntime> {
let client = crate::network::client(&self.store).await?;
let installation_id = self.store.installation_id().await?;
let mut request = client
@@ -91,6 +95,7 @@ impl ControlService {
.header(DEVICE_ID_HEADER, installation_id)
.header(OS_HEADER, std::env::consts::OS)
.header(APP_VERSION_HEADER, env!("CARGO_PKG_VERSION"))
.header(LANGUAGE_HEADER, language)
.timeout(std::time::Duration::from_secs(5));
if let Some(disabled_ad_ids) = disabled_ad_ids.filter(|value| !value.is_empty()) {
request = request.header(DISABLED_AD_IDS_HEADER, disabled_ad_ids);