mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
feat(i18n): localize backend messages by workspace language
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '自動折りたたみ',
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import pipelineMigration from './pipeline-migration/ru-RU';
|
||||
const ruRU = {
|
||||
workspaceLanguage: {
|
||||
label: 'Язык рабочего пространства',
|
||||
description:
|
||||
'Язык сообщений сервера и стандартных ответов бота. Язык интерфейса не меняется.',
|
||||
saved: 'Язык сохранён',
|
||||
saveFailed: 'Не удалось сохранить язык',
|
||||
},
|
||||
inflight: {
|
||||
autoCollapse: 'Автосворачивание',
|
||||
empty: 'Нет событий в обработке',
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import pipelineMigration from './pipeline-migration/th-TH';
|
||||
const thTH = {
|
||||
workspaceLanguage: {
|
||||
label: 'ภาษาของพื้นที่ทำงาน',
|
||||
description:
|
||||
'ใช้สำหรับข้อความจากระบบและคำตอบเริ่มต้นของบอต โดยไม่เปลี่ยนภาษาอินเทอร์เฟซของคุณ',
|
||||
saved: 'บันทึกภาษาแล้ว',
|
||||
saveFailed: 'บันทึกภาษาไม่สำเร็จ',
|
||||
},
|
||||
inflight: {
|
||||
autoCollapse: 'ยุบอัตโนมัติ',
|
||||
empty: 'ไม่มีเหตุการณ์ที่กำลังประมวลผล',
|
||||
|
||||
@@ -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ý',
|
||||
|
||||
@@ -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: '自动折叠',
|
||||
|
||||
@@ -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: '自動摺疊',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user