feat: 渠道管理语言国际化

This commit is contained in:
DEV_DSW
2026-04-21 14:27:19 +08:00
parent 5db3e5714f
commit 0c068e9f4d
2 changed files with 259 additions and 62 deletions

View File

@@ -248,6 +248,59 @@ export const messages: I18nMessages = {
}, },
}, },
channels: { channels: {
page: {
title: 'Supported channels',
description: 'This page focuses on supported channel modules, configuration entry points, and refresh actions.',
refresh: 'Refresh',
pluginBadge: 'Plugin',
configure: 'Start setup',
empty: 'No configurable channel modules are available yet.',
refreshed: 'Supported channel list refreshed.',
saved: '{name} configuration saved.',
defaultAccountLabel: 'Default account',
},
validation: {
requiredField: 'Please fill in {field}',
},
connectionType: {
qr: 'QR code',
webhook: 'Webhook',
token: 'Token',
},
meta: {
telegram: {
name: 'Telegram',
description: 'Connect Telegram using the bot token provided by @BotFather.',
},
discord: {
name: 'Discord',
description: 'Connect Discord using the bot token provided in the developer portal.',
},
whatsapp: {
name: 'WhatsApp',
description: 'Connect WhatsApp by scanning a QR code, without requiring a phone number.',
},
wechat: {
name: 'WeChat',
description: 'Connect personal WeChat by scanning through the official OpenClaw plugin.',
},
dingtalk: {
name: 'DingTalk',
description: 'Connect DingTalk through the OpenClaw channel plugin in Stream mode.',
},
feishu: {
name: 'Feishu / Lark',
description: 'Connect a Feishu/Lark bot through Feishu\'s official OpenClaw plugin.',
},
wecom: {
name: 'WeCom',
description: 'Connect a WeCom bot through the plugin.',
},
qqbot: {
name: 'QQ Bot',
description: 'Connect a QQ bot channel, built in since OpenClaw 3.31.',
},
},
modal: { modal: {
title: 'Channel configuration', title: 'Channel configuration',
description: 'Choose a channel type, then fill in the connection fields that are available for this template.', description: 'Choose a channel type, then fill in the connection fields that are available for this template.',
@@ -737,6 +790,59 @@ export const messages: I18nMessages = {
}, },
}, },
channels: { channels: {
page: {
title: '支持的频道',
description: '当前页面聚焦支持的频道模块、配置入口与刷新能力。',
refresh: '刷新',
pluginBadge: '插件',
configure: '开始配置',
empty: '暂无可配置的频道模块。',
refreshed: '支持频道列表已刷新。',
saved: '{name} 配置已保存。',
defaultAccountLabel: '默认账号',
},
validation: {
requiredField: '请填写 {field}',
},
connectionType: {
qr: '扫码',
webhook: 'Webhook',
token: 'Token',
},
meta: {
telegram: {
name: 'Telegram',
description: '使用 @BotFather 提供的机器人令牌连接 Telegram。',
},
discord: {
name: 'Discord',
description: '使用开发者门户提供的机器人令牌连接 Discord。',
},
whatsapp: {
name: 'WhatsApp',
description: '通过扫描二维码连接 WhatsApp无需手机号。',
},
wechat: {
name: '微信',
description: '通过腾讯官方 OpenClaw 插件扫码连接个人微信。',
},
dingtalk: {
name: '钉钉',
description: '通过 OpenClaw 渠道插件连接钉钉Stream 模式)。',
},
feishu: {
name: '飞书 / Lark',
description: '通过飞书官方推出的 OpenClaw 插件连接飞书/Lark 机器人。',
},
wecom: {
name: '企业微信',
description: '通过插件连接企业微信机器人。',
},
qqbot: {
name: 'QQ 机器人',
description: '连接 QQ 机器人频道OpenClaw 3.31 起内置)。',
},
},
modal: { modal: {
title: '渠道配置', title: '渠道配置',
description: '先选择渠道类型,再填写这个模板可用的连接字段。', description: '先选择渠道类型,再填写这个模板可用的连接字段。',
@@ -1226,6 +1332,59 @@ export const messages: I18nMessages = {
}, },
}, },
channels: { channels: {
page: {
title: '対応チャンネル',
description: 'このページでは、対応チャンネルのモジュール、設定入口、更新操作をまとめて扱います。',
refresh: '更新',
pluginBadge: 'プラグイン',
configure: '設定を開始',
empty: '設定可能なチャンネルモジュールはまだありません。',
refreshed: '対応チャンネル一覧を更新しました。',
saved: '{name} の設定を保存しました。',
defaultAccountLabel: 'デフォルトアカウント',
},
validation: {
requiredField: '{field} を入力してください',
},
connectionType: {
qr: 'QRコード',
webhook: 'Webhook',
token: 'Token',
},
meta: {
telegram: {
name: 'Telegram',
description: '@BotFather が発行したボットトークンで Telegram に接続します。',
},
discord: {
name: 'Discord',
description: '開発者ポータルのボットトークンで Discord に接続します。',
},
whatsapp: {
name: 'WhatsApp',
description: 'QR コードをスキャンして WhatsApp に接続します。電話番号は不要です。',
},
wechat: {
name: 'WeChat',
description: '公式 OpenClaw プラグイン経由で QR スキャンし、個人 WeChat に接続します。',
},
dingtalk: {
name: 'DingTalk',
description: 'OpenClaw チャンネルプラグイン経由で DingTalk に接続しますStream モード)。',
},
feishu: {
name: 'Feishu / Lark',
description: 'Feishu 公式の OpenClaw プラグイン経由で Feishu/Lark ボットに接続します。',
},
wecom: {
name: 'WeCom',
description: 'プラグイン経由で WeCom ボットに接続します。',
},
qqbot: {
name: 'QQ Bot',
description: 'QQ ボットチャンネルに接続しますOpenClaw 3.31 以降は内蔵)。',
},
},
modal: { modal: {
title: 'チャンネル設定', title: 'チャンネル設定',
description: 'まずチャンネル種類を選び、このテンプレートで使える接続項目を入力します。', description: 'まずチャンネル種類を選び、このテンプレートで使える接続項目を入力します。',

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import ChannelConfigModal, { type ChannelCredentialsValidationResult } from '../../components/channels/ChannelConfigModal'; import ChannelConfigModal, { type ChannelCredentialsValidationResult } from '../../components/channels/ChannelConfigModal';
import { useI18n } from '../../i18n';
import { getChannelMeta, getPrimaryChannelOptions, type ChannelMeta } from '../../lib/channel-meta'; import { getChannelMeta, getPrimaryChannelOptions, type ChannelMeta } from '../../lib/channel-meta';
import type { ChannelConfigFieldValueMap } from '../../lib/channel-types'; import type { ChannelConfigFieldValueMap } from '../../lib/channel-types';
import { hostApiFetch } from '../../lib/host-api'; import { hostApiFetch } from '../../lib/host-api';
@@ -13,6 +14,10 @@ import feishuIcon from '../../assets/channels/feishu.svg';
import wecomIcon from '../../assets/channels/wecom.svg'; import wecomIcon from '../../assets/channels/wecom.svg';
import qqIcon from '../../assets/channels/qq.svg'; import qqIcon from '../../assets/channels/qq.svg';
type TranslateParams = Record<string, string | number>;
type TranslateFn = (path: string, params?: TranslateParams) => string;
type HasMessageFn = (path: string) => boolean;
function cn(...tokens: Array<string | false | null | undefined>): string { function cn(...tokens: Array<string | false | null | undefined>): string {
return tokens.filter(Boolean).join(' '); return tokens.filter(Boolean).join(' ');
} }
@@ -38,30 +43,50 @@ function deriveChannelUrl(channelType: string, accountId: string, values: Channe
return buildSyntheticChannelUrl(channelType, accountId || 'default'); return buildSyntheticChannelUrl(channelType, accountId || 'default');
} }
function buildSavedAccountName(channelType: string, accountId: string): string { function resolveChannelMetaText(
const meta = getChannelMeta(channelType); channelType: string,
field: 'name' | 'description',
fallback: string,
t: TranslateFn,
hasMessage: HasMessageFn,
): string {
const path = `channels.meta.${channelType}.${field}`;
return hasMessage(path) ? t(path) : fallback;
}
function buildSavedAccountName(channelName: string, accountId: string, defaultAccountLabel: string): string {
const resolvedAccountId = accountId.trim() || 'default'; const resolvedAccountId = accountId.trim() || 'default';
if (resolvedAccountId === 'default') { if (resolvedAccountId === 'default') {
return `${meta.name} 默认账号`; return `${channelName} ${defaultAccountLabel}`;
} }
return resolvedAccountId; return resolvedAccountId;
} }
function validateRequiredFields(channelType: string, values: ChannelConfigFieldValueMap): string | null { function validateRequiredFields(
channelType: string,
values: ChannelConfigFieldValueMap,
t: TranslateFn,
): string | null {
const meta = getChannelMeta(channelType); const meta = getChannelMeta(channelType);
const missingField = meta.configFields.find((field) => field.required && !String(values[field.key] ?? '').trim()); const missingField = meta.configFields.find((field) => field.required && !String(values[field.key] ?? '').trim());
if (!missingField) return null; if (!missingField) return null;
return `请填写 ${missingField.label}`; return t('channels.validation.requiredField', { field: missingField.label });
} }
function getConnectionLabel(connectionType: string): string { function getConnectionLabel(connectionType: string, t: TranslateFn): string {
if (connectionType === 'qr') return '扫码'; switch (connectionType) {
if (connectionType === 'webhook') return 'Webhook'; case 'qr':
return 'Token'; return t('channels.connectionType.qr');
case 'webhook':
return t('channels.connectionType.webhook');
default:
return t('channels.connectionType.token');
}
} }
export default function ChannelsPage() { export default function ChannelsPage() {
const { t, hasMessage } = useI18n();
const [supportedChannels, setSupportedChannels] = useState<ChannelMeta[]>([]); const [supportedChannels, setSupportedChannels] = useState<ChannelMeta[]>([]);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null); const [feedback, setFeedback] = useState<string | null>(null);
@@ -73,6 +98,15 @@ export default function ChannelsPage() {
const [modalValidating, setModalValidating] = useState(false); const [modalValidating, setModalValidating] = useState(false);
const [modalValidationResult, setModalValidationResult] = useState<ChannelCredentialsValidationResult | null>(null); const [modalValidationResult, setModalValidationResult] = useState<ChannelCredentialsValidationResult | null>(null);
function getChannelName(channelType: string, fallbackName?: string): string {
const meta = getChannelMeta(channelType);
return resolveChannelMetaText(channelType, 'name', fallbackName ?? meta.name, t, hasMessage);
}
function getChannelDescription(meta: ChannelMeta): string {
return resolveChannelMetaText(meta.type, 'description', meta.description, t, hasMessage);
}
function loadSupportedChannels(nextFeedback?: string): void { function loadSupportedChannels(nextFeedback?: string): void {
const channels = getPrimaryChannelOptions(); const channels = getPrimaryChannelOptions();
setSupportedChannels(channels); setSupportedChannels(channels);
@@ -103,7 +137,7 @@ export default function ChannelsPage() {
function refreshPage(): void { function refreshPage(): void {
setRefreshing(true); setRefreshing(true);
window.setTimeout(() => { window.setTimeout(() => {
loadSupportedChannels('支持频道列表已刷新。'); loadSupportedChannels(t('channels.page.refreshed'));
setRefreshing(false); setRefreshing(false);
}, 0); }, 0);
} }
@@ -151,7 +185,7 @@ export default function ChannelsPage() {
async function handleSaveModal(): Promise<void> { async function handleSaveModal(): Promise<void> {
const accountIdForSave = 'default'; const accountIdForSave = 'default';
const meta = getChannelMeta(modalChannelType); const meta = getChannelMeta(modalChannelType);
const requiredError = validateRequiredFields(modalChannelType, modalValues); const requiredError = validateRequiredFields(modalChannelType, modalValues, t);
if (requiredError) { if (requiredError) {
setModalError(requiredError); setModalError(requiredError);
return; return;
@@ -178,19 +212,19 @@ export default function ChannelsPage() {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
channelType: modalChannelType, channelType: modalChannelType,
channelLabel: getChannelMeta(modalChannelType).name, channelLabel: meta.name,
accountName: buildSavedAccountName(modalChannelType, accountIdForSave), accountName: buildSavedAccountName(meta.name, accountIdForSave, t('channels.page.defaultAccountLabel')),
channelUrl: deriveChannelUrl(modalChannelType, accountIdForSave, config), channelUrl: deriveChannelUrl(modalChannelType, accountIdForSave, config),
enabled: true, enabled: true,
config, config,
metadata: { metadata: {
connectionType: getChannelMeta(modalChannelType).connectionType, connectionType: meta.connectionType,
managedBy: 'channels-page', managedBy: 'channels-page',
}, },
}), }),
}); });
setFeedback(`${getChannelMeta(modalChannelType).name} 配置已保存。`); setFeedback(t('channels.page.saved', { name: getChannelName(modalChannelType, meta.name) }));
resetModalState(); resetModalState();
} catch (requestError) { } catch (requestError) {
setModalError(requestError instanceof Error ? requestError.message : String(requestError)); setModalError(requestError instanceof Error ? requestError.message : String(requestError));
@@ -213,10 +247,10 @@ export default function ChannelsPage() {
className="text-[36px] font-normal tracking-tight text-[#0F172A] dark:text-gray-100" className="text-[36px] font-normal tracking-tight text-[#0F172A] dark:text-gray-100"
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }} style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
> >
{t('channels.page.title')}
</h1> </h1>
<div className="mt-1 text-[13px] text-[#667085] dark:text-gray-500"> <div className="mt-1 text-[13px] text-[#667085] dark:text-gray-500">
{t('channels.page.description')}
</div> </div>
</div> </div>
@@ -227,7 +261,7 @@ export default function ChannelsPage() {
onClick={refreshPage} onClick={refreshPage}
> >
<RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} /> <RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} />
{t('channels.page.refresh')}
</button> </button>
</div> </div>
@@ -239,48 +273,52 @@ export default function ChannelsPage() {
{supportedChannels.length > 0 ? ( {supportedChannels.length > 0 ? (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{supportedChannels.map((meta) => ( {supportedChannels.map((meta) => {
<button const channelName = getChannelName(meta.type, meta.name);
key={meta.type}
type="button"
className="group flex items-start gap-4 rounded-[20px] border border-transparent bg-white/55 p-5 text-left transition-all hover:border-black/8 hover:bg-white/85 dark:bg-[#202024] dark:hover:border-white/8 dark:hover:bg-[#232327]"
onClick={() => {
openCreateChannelModal(meta.type);
}}
>
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full border border-black/5 bg-[#F7F4EB] text-[16px] font-semibold text-[#0F172A] shadow-sm dark:border-white/10 dark:bg-[#17171a] dark:text-gray-100">
<ChannelLogo type={meta.type} />
</div>
<div className="min-w-0 flex-1"> return (
<div className="flex flex-wrap items-center gap-2"> <button
<div className="text-[16px] font-semibold text-[#0F172A] dark:text-gray-100"> key={meta.type}
{meta.name} type="button"
className="group flex items-start gap-4 rounded-[20px] border border-transparent bg-white/55 p-5 text-left transition-all hover:border-black/8 hover:bg-white/85 dark:bg-[#202024] dark:hover:border-white/8 dark:hover:bg-[#232327]"
onClick={() => {
openCreateChannelModal(meta.type);
}}
>
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full border border-black/5 bg-[#F7F4EB] text-[16px] font-semibold text-[#0F172A] shadow-sm dark:border-white/10 dark:bg-[#17171a] dark:text-gray-100">
<ChannelLogo type={meta.type} label={channelName} />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="text-[16px] font-semibold text-[#0F172A] dark:text-gray-100">
{channelName}
</div>
{meta.isPlugin ? (
<span className="rounded-full border border-black/8 bg-black/3 px-2.5 py-1 text-[11px] text-[#525866] dark:border-white/10 dark:bg-white/6 dark:text-gray-300">
{t('channels.page.pluginBadge')}
</span>
) : null}
</div> </div>
{meta.isPlugin ? (
<span className="rounded-full border border-black/8 bg-black/3 px-2.5 py-1 text-[11px] text-[#525866] dark:border-white/10 dark:bg-white/6 dark:text-gray-300"> <div className="mt-2 text-[13px] leading-5.25 text-[#667085] dark:text-gray-400">
{getChannelDescription(meta)}
</div>
<div className="mt-3 inline-flex items-center gap-2 text-[12px] font-medium text-[#525866] transition-colors group-hover:text-[#2B7FFF] dark:text-gray-400 dark:group-hover:text-[#93c5fd]">
<span>{t('channels.page.configure')}</span>
<span className="rounded-full border border-current/20 px-2 py-0.5">
{getConnectionLabel(meta.connectionType, t)}
</span> </span>
) : null} </div>
</div> </div>
</button>
<div className="mt-2 text-[13px] leading-5.25 text-[#667085] dark:text-gray-400"> );
{meta.description} })}
</div>
<div className="mt-3 inline-flex items-center gap-2 text-[12px] font-medium text-[#525866] transition-colors group-hover:text-[#2B7FFF] dark:text-gray-400 dark:group-hover:text-[#93c5fd]">
<span></span>
<span className="rounded-full border border-current/20 px-2 py-0.5">
{getConnectionLabel(meta.connectionType)}
</span>
</div>
</div>
</button>
))}
</div> </div>
) : ( ) : (
<div className="rounded-[20px] border border-dashed border-[#DCE5F1] bg-white/75 px-5 py-6 text-sm text-[#525866] dark:border-[#2a2a2d] dark:bg-[#202024] dark:text-gray-300"> <div className="rounded-[20px] border border-dashed border-[#DCE5F1] bg-white/75 px-5 py-6 text-sm text-[#525866] dark:border-[#2a2a2d] dark:bg-[#202024] dark:text-gray-300">
{t('channels.page.empty')}
</div> </div>
)} )}
</div> </div>
@@ -310,24 +348,24 @@ export default function ChannelsPage() {
); );
} }
function ChannelLogo({ type }: { type: string }) { function ChannelLogo({ type, label }: { type: string; label: string }) {
switch (type) { switch (type) {
case 'telegram': case 'telegram':
return <img src={telegramIcon} alt="Telegram" className="h-5.5 w-5.5 dark:invert" />; return <img src={telegramIcon} alt={label} className="h-5.5 w-5.5 dark:invert" />;
case 'discord': case 'discord':
return <img src={discordIcon} alt="Discord" className="h-5.5 w-5.5 dark:invert" />; return <img src={discordIcon} alt={label} className="h-5.5 w-5.5 dark:invert" />;
case 'whatsapp': case 'whatsapp':
return <img src={whatsappIcon} alt="WhatsApp" className="h-5.5 w-5.5 dark:invert" />; return <img src={whatsappIcon} alt={label} className="h-5.5 w-5.5 dark:invert" />;
case 'wechat': case 'wechat':
return <img src={wechatIcon} alt="WeChat" className="h-5.5 w-5.5 dark:invert" />; return <img src={wechatIcon} alt={label} className="h-5.5 w-5.5 dark:invert" />;
case 'dingtalk': case 'dingtalk':
return <img src={dingtalkIcon} alt="DingTalk" className="h-5.5 w-5.5 dark:invert" />; return <img src={dingtalkIcon} alt={label} className="h-5.5 w-5.5 dark:invert" />;
case 'feishu': case 'feishu':
return <img src={feishuIcon} alt="Feishu" className="h-5.5 w-5.5 dark:invert" />; return <img src={feishuIcon} alt={label} className="h-5.5 w-5.5 dark:invert" />;
case 'wecom': case 'wecom':
return <img src={wecomIcon} alt="WeCom" className="h-5.5 w-5.5 dark:invert" />; return <img src={wecomIcon} alt={label} className="h-5.5 w-5.5 dark:invert" />;
case 'qqbot': case 'qqbot':
return <img src={qqIcon} alt="QQ" className="h-5.5 w-5.5 dark:invert" />; return <img src={qqIcon} alt={label} className="h-5.5 w-5.5 dark:invert" />;
default: default:
return <span className="text-[22px] font-semibold">{type.slice(0, 1).toUpperCase()}</span>; return <span className="text-[22px] font-semibold">{type.slice(0, 1).toUpperCase()}</span>;
} }