feat(i18n): localize backend messages by workspace language

This commit is contained in:
Hyu
2026-10-15 05:31:04 +00:00
committed by RockChinQ
parent b1d9484876
commit e29fd27f02
74 changed files with 2296 additions and 177 deletions
@@ -51,6 +51,17 @@ const ASSIGNABLE_ROLES: Exclude<WorkspaceRole, 'owner'>[] = [
'viewer',
];
const MESSAGE_LANGUAGES: Record<string, string> = {
en_US: 'English',
zh_Hans: '简体中文',
zh_Hant: '繁體中文',
ja_JP: '日本語',
es_ES: 'Español',
ru_RU: 'Русский',
th_TH: 'ไทย',
vi_VN: 'Tiếng Việt',
};
export default function WorkspaceSettingsPanel({
active,
}: WorkspaceSettingsPanelProps) {
@@ -61,6 +72,9 @@ export default function WorkspaceSettingsPanel({
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
const [invitations, setInvitations] = useState<WorkspaceInvitation[]>([]);
const [loading, setLoading] = useState(false);
const [messageLanguage, setMessageLanguage] = useState('en_US');
const [supportedLanguages, setSupportedLanguages] = useState<string[]>([]);
const [savingLanguage, setSavingLanguage] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] =
useState<Exclude<WorkspaceRole, 'owner'>>('viewer');
@@ -86,8 +100,13 @@ export default function WorkspaceSettingsPanel({
const loadWorkspace = useCallback(async () => {
setLoading(true);
try {
const current = await backendClient.getCurrentWorkspace();
const [current, languageSettings] = await Promise.all([
backendClient.getCurrentWorkspace(),
backendClient.getWorkspaceLanguage(),
]);
setWorkspaceInfo(current);
setMessageLanguage(languageSettings.language);
setSupportedLanguages(languageSettings.supported_languages);
const [memberResponse, invitationResponse] = await Promise.all([
current.permissions.includes('member.view')
@@ -106,6 +125,19 @@ export default function WorkspaceSettingsPanel({
}
}, [t]);
async function saveLanguage(locale: string) {
setSavingLanguage(true);
try {
const result = await backendClient.setWorkspaceLanguage(locale);
setMessageLanguage(result.language);
toast.success(t('workspaceLanguage.saved'));
} catch {
toast.error(t('workspaceLanguage.saveFailed'));
} finally {
setSavingLanguage(false);
}
}
useEffect(() => {
if (active) void loadWorkspace();
}, [active, loadWorkspace]);
@@ -222,6 +254,41 @@ export default function WorkspaceSettingsPanel({
</PanelToolbar>
<PanelBody className="space-y-6">
<section className="space-y-3">
<div>
<h3 id="workspace-language-label" className="text-sm font-semibold">
{t('workspaceLanguage.label')}
</h3>
<p
id="workspace-language-description"
className="text-xs text-muted-foreground"
>
{t('workspaceLanguage.description')}
</p>
</div>
<Select
value={messageLanguage}
onValueChange={saveLanguage}
disabled={
loading || savingLanguage || !permissions.has('workspace.update')
}
>
<SelectTrigger
className="w-full sm:w-64"
aria-labelledby="workspace-language-label"
aria-describedby="workspace-language-description"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{supportedLanguages.map((locale) => (
<SelectItem key={locale} value={locale}>
{MESSAGE_LANGUAGES[locale] || locale}
</SelectItem>
))}
</SelectContent>
</Select>
</section>
{canInvite && (
<section className="space-y-3">
<div>
+11
View File
@@ -111,6 +111,17 @@ import type {
* 负责与后端 API 的所有交互
*/
export class BackendClient extends BaseHttpClient {
public getWorkspaceLanguage(): Promise<{
language: string;
supported_languages: string[];
}> {
return this.get('/api/v1/workspaces/current/language');
}
public setWorkspaceLanguage(language: string): Promise<{ language: string }> {
return this.put('/api/v1/workspaces/current/language', { language });
}
constructor(baseURL: string) {
super(baseURL, false);
}
+1
View File
@@ -11,6 +11,7 @@ import {
} from './workspaceContext';
import { setCurrentWorkspaceSnapshot } from './currentWorkspaceStore';
import { clearWorkspaceBootstrapSnapshot } from './workspaceBootstrapStore';
import i18n from 'i18next';
type JSONValue = string | number | boolean | JSONObject | JSONArray | null;
interface JSONObject {
+7
View File
@@ -1,6 +1,13 @@
import slackSetup from './slack-setup/en-US';
import pipelineMigration from './pipeline-migration/en-US';
const enUS = {
workspaceLanguage: {
label: 'Workspace language',
description:
'Language for backend messages and default bot replies. Your interface language stays independent.',
saved: 'Language saved',
saveFailed: 'Failed to save language',
},
slackSetup,
inflight: {
autoCollapse: 'Auto-collapse',
+7
View File
@@ -1,5 +1,12 @@
import pipelineMigration from './pipeline-migration/es-ES';
const esES = {
workspaceLanguage: {
label: 'Idioma del espacio de trabajo',
description:
'Idioma de los mensajes del servidor y respuestas predeterminadas del bot. No cambia tu interfaz.',
saved: 'Idioma guardado',
saveFailed: 'No se pudo guardar el idioma',
},
inflight: {
autoCollapse: 'Plegar auto.',
empty: 'No hay eventos en proceso',
+7
View File
@@ -1,6 +1,13 @@
import slackSetup from './slack-setup/ja-JP';
import pipelineMigration from './pipeline-migration/ja-JP';
const jaJP = {
workspaceLanguage: {
label: 'ワークスペースの言語',
description:
'バックエンドの通知とボットの既定の返信に使用します。個人の表示言語には影響しません。',
saved: '言語設定を保存しました',
saveFailed: '言語設定の保存に失敗しました',
},
slackSetup,
inflight: {
autoCollapse: '自動折りたたみ',
+7
View File
@@ -1,5 +1,12 @@
import pipelineMigration from './pipeline-migration/ru-RU';
const ruRU = {
workspaceLanguage: {
label: 'Язык рабочего пространства',
description:
'Язык сообщений сервера и стандартных ответов бота. Язык интерфейса не меняется.',
saved: 'Язык сохранён',
saveFailed: 'Не удалось сохранить язык',
},
inflight: {
autoCollapse: 'Автосворачивание',
empty: 'Нет событий в обработке',
+7
View File
@@ -1,5 +1,12 @@
import pipelineMigration from './pipeline-migration/th-TH';
const thTH = {
workspaceLanguage: {
label: 'ภาษาของพื้นที่ทำงาน',
description:
'ใช้สำหรับข้อความจากระบบและคำตอบเริ่มต้นของบอต โดยไม่เปลี่ยนภาษาอินเทอร์เฟซของคุณ',
saved: 'บันทึกภาษาแล้ว',
saveFailed: 'บันทึกภาษาไม่สำเร็จ',
},
inflight: {
autoCollapse: 'ยุบอัตโนมัติ',
empty: 'ไม่มีเหตุการณ์ที่กำลังประมวลผล',
+7
View File
@@ -1,5 +1,12 @@
import pipelineMigration from './pipeline-migration/vi-VN';
const viVN = {
workspaceLanguage: {
label: 'Ngôn ngữ không gian làm việc',
description:
'Dùng cho thông báo máy chủ và phản hồi mặc định của bot. Không thay đổi ngôn ngữ giao diện cá nhân.',
saved: 'Đã lưu ngôn ngữ',
saveFailed: 'Không thể lưu ngôn ngữ',
},
inflight: {
autoCollapse: 'Tự thu gọn',
empty: 'Không có sự kiện đang xử lý',
+6
View File
@@ -1,6 +1,12 @@
import slackSetup from './slack-setup/zh-Hans';
import pipelineMigration from './pipeline-migration/zh-Hans';
const zhHans = {
workspaceLanguage: {
label: '工作区语言',
description: '用于后端提示和机器人的默认回复,不影响个人界面语言。',
saved: '语言设置已保存',
saveFailed: '语言设置保存失败',
},
slackSetup,
inflight: {
autoCollapse: '自动折叠',
+6
View File
@@ -1,6 +1,12 @@
import slackSetup from './slack-setup/zh-Hant';
import pipelineMigration from './pipeline-migration/zh-Hant';
const zhHant = {
workspaceLanguage: {
label: '工作區語言',
description: '用於後端提示和機器人的預設回覆,不影響個人介面語言。',
saved: '語言設定已儲存',
saveFailed: '語言設定儲存失敗',
},
slackSetup,
inflight: {
autoCollapse: '自動摺疊',
+16
View File
@@ -750,6 +750,22 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { workspaces: state.workspaces });
}
if (path === '/api/v1/workspaces/current/language') {
return fulfillJson(route, {
language: 'en_US',
supported_languages: [
'en_US',
'zh_Hans',
'zh_Hant',
'ja_JP',
'es_ES',
'ru_RU',
'th_TH',
'vi_VN',
],
});
}
if (path === '/api/v1/workspaces/current') {
const selectedWorkspaceUuid = request.headers()['x-workspace-id'];
const entry = state.workspaces.find(
@@ -5765,12 +5765,110 @@
"zh_Hans": "失败提示文本"
},
"description": {
"en_US": "The text to display when a request fails. Only effective when Exception Handling Strategy is set to \"Show Failure Hint\"",
"zh_Hans": "请求失败时显示的提示文本,仅在异常处理策略设置为\"仅文字提示\"时生效"
"en_US": "Leave empty to use the Workspace language. Used for request and runner execution failures; custom text is not translated.",
"zh_Hans": "留空时使用工作区语言的默认提示。用于请求和运行器执行失败;自定义文本不会自动翻译。"
},
"type": "string",
"required": false,
"default": "Request failed."
"default": ""
},
{
"name": "rate-limit-hint",
"label": {
"en_US": "Rate Limit Hint",
"zh_Hans": "限速提示"
},
"description": {
"en_US": "Leave empty to use the Workspace language.",
"zh_Hans": "留空时使用工作区语言的默认提示。"
},
"type": "string",
"required": false,
"default": ""
},
{
"name": "content-filter-hint",
"label": {
"en_US": "Content Filter Hint",
"zh_Hans": "内容过滤提示"
},
"description": {
"en_US": "Leave empty to use the Workspace language.",
"zh_Hans": "留空时使用工作区语言的默认提示。"
},
"type": "string",
"required": false,
"default": ""
},
{
"name": "content-filter-error-hint",
"label": {
"en_US": "Content Check Failure Hint",
"zh_Hans": "内容检查失败提示"
},
"description": {
"en_US": "Leave empty to use the Workspace language.",
"zh_Hans": "留空时使用工作区语言的默认提示。"
},
"type": "string",
"required": false,
"default": ""
},
{
"name": "tool-call-hint",
"label": {
"en_US": "Tool Call Hint",
"zh_Hans": "工具调用提示"
},
"description": {
"en_US": "Leave empty to use the Workspace language. Use {tools} for tool names. Requires Track Function Calls.",
"zh_Hans": "留空时使用工作区语言的默认提示。 可用 {tools} 插入工具名称,需开启跟踪函数调用。"
},
"type": "string",
"required": false,
"default": ""
},
{
"name": "runner-not-found-hint",
"label": {
"en_US": "Runner Missing Hint",
"zh_Hans": "运行器不存在提示"
},
"description": {
"en_US": "Leave empty to use the Workspace language. Use {runner} for the runner ID.",
"zh_Hans": "留空时使用工作区语言的默认提示。 可用 {runner} 插入运行器 ID。"
},
"type": "string",
"required": false,
"default": ""
},
{
"name": "runner-not-authorized-hint",
"label": {
"en_US": "Runner Authorization Hint",
"zh_Hans": "运行器未授权提示"
},
"description": {
"en_US": "Leave empty to use the Workspace language.",
"zh_Hans": "留空时使用工作区语言的默认提示。"
},
"type": "string",
"required": false,
"default": ""
},
{
"name": "runner-unavailable-hint",
"label": {
"en_US": "Runner Unavailable Hint",
"zh_Hans": "运行器不可用提示"
},
"description": {
"en_US": "Leave empty to use the Workspace language.",
"zh_Hans": "留空时使用工作区语言的默认提示。"
},
"type": "string",
"required": false,
"default": ""
},
{
"name": "at-sender",
+80
View File
@@ -0,0 +1,80 @@
import { expect, test } from '@playwright/test';
import {
installLangBotApiMocks,
makeWorkspaceEntry,
} from './fixtures/langbot-api';
test('workspace language persists independently of personal UI language', async ({
page,
}, testInfo) => {
const workspace = makeWorkspaceEntry(
'workspace-language',
'Language Workspace',
'local',
);
workspace.permissions = ['workspace.view', 'workspace.update'];
await installLangBotApiMocks(page, {
authenticated: true,
workspaces: [workspace],
});
let language = 'en_US';
await page.route('**/api/v1/workspaces/current/language', async (route) => {
if (route.request().method() === 'PUT') {
expect(route.request().headers()['x-workspace-id']).toBe(
'workspace-language',
);
language = route.request().postDataJSON().language;
}
await route.fulfill({
json: {
code: 0,
msg: 'ok',
data: {
language,
supported_languages: ['en_US', 'zh_Hans', 'ja_JP'],
},
},
});
});
await page.goto('/home?action=showWorkspaceSettings');
const select = page.getByRole('combobox', {
name: 'Workspace language',
exact: true,
});
await expect(select).toHaveText('English');
await select.click();
await page.getByRole('option', { name: '日本語', exact: true }).click();
await expect(select).toHaveText('日本語');
await expect(page.getByText('Language saved', { exact: true })).toBeVisible();
await page.reload();
await page.goto('/home?action=showWorkspaceSettings');
await expect(select).toHaveText('日本語');
await expect(
page.getByText('Workspace language', { exact: true }),
).toBeVisible();
await page.screenshot({
path: testInfo.outputPath('workspace-language.png'),
fullPage: true,
});
});
test('viewer can read but cannot edit workspace language', async ({ page }) => {
const workspace = makeWorkspaceEntry(
'workspace-language',
'Language Workspace',
'local',
);
workspace.membership.role = 'viewer';
workspace.permissions = ['workspace.view'];
await installLangBotApiMocks(page, {
authenticated: true,
workspaces: [workspace],
});
await page.goto('/home?action=showWorkspaceSettings');
const select = page.getByRole('combobox', {
name: 'Workspace language',
exact: true,
});
await expect(select).toHaveText('English');
await expect(select).toBeDisabled();
});