feat: 渠道管理语言国际化
This commit is contained in:
@@ -248,6 +248,59 @@ export const messages: I18nMessages = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
title: 'Channel configuration',
|
||||
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: {
|
||||
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: {
|
||||
title: '渠道配置',
|
||||
description: '先选择渠道类型,再填写这个模板可用的连接字段。',
|
||||
@@ -1226,6 +1332,59 @@ export const messages: I18nMessages = {
|
||||
},
|
||||
},
|
||||
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: {
|
||||
title: 'チャンネル設定',
|
||||
description: 'まずチャンネル種類を選び、このテンプレートで使える接続項目を入力します。',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import ChannelConfigModal, { type ChannelCredentialsValidationResult } from '../../components/channels/ChannelConfigModal';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { getChannelMeta, getPrimaryChannelOptions, type ChannelMeta } from '../../lib/channel-meta';
|
||||
import type { ChannelConfigFieldValueMap } from '../../lib/channel-types';
|
||||
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 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 {
|
||||
return tokens.filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -38,30 +43,50 @@ function deriveChannelUrl(channelType: string, accountId: string, values: Channe
|
||||
return buildSyntheticChannelUrl(channelType, accountId || 'default');
|
||||
}
|
||||
|
||||
function buildSavedAccountName(channelType: string, accountId: string): string {
|
||||
const meta = getChannelMeta(channelType);
|
||||
function resolveChannelMetaText(
|
||||
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';
|
||||
if (resolvedAccountId === 'default') {
|
||||
return `${meta.name} 默认账号`;
|
||||
return `${channelName} ${defaultAccountLabel}`;
|
||||
}
|
||||
|
||||
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 missingField = meta.configFields.find((field) => field.required && !String(values[field.key] ?? '').trim());
|
||||
if (!missingField) return null;
|
||||
return `请填写 ${missingField.label}`;
|
||||
return t('channels.validation.requiredField', { field: missingField.label });
|
||||
}
|
||||
|
||||
function getConnectionLabel(connectionType: string): string {
|
||||
if (connectionType === 'qr') return '扫码';
|
||||
if (connectionType === 'webhook') return 'Webhook';
|
||||
return 'Token';
|
||||
function getConnectionLabel(connectionType: string, t: TranslateFn): string {
|
||||
switch (connectionType) {
|
||||
case 'qr':
|
||||
return t('channels.connectionType.qr');
|
||||
case 'webhook':
|
||||
return t('channels.connectionType.webhook');
|
||||
default:
|
||||
return t('channels.connectionType.token');
|
||||
}
|
||||
}
|
||||
|
||||
export default function ChannelsPage() {
|
||||
const { t, hasMessage } = useI18n();
|
||||
const [supportedChannels, setSupportedChannels] = useState<ChannelMeta[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
@@ -73,6 +98,15 @@ export default function ChannelsPage() {
|
||||
const [modalValidating, setModalValidating] = useState(false);
|
||||
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 {
|
||||
const channels = getPrimaryChannelOptions();
|
||||
setSupportedChannels(channels);
|
||||
@@ -103,7 +137,7 @@ export default function ChannelsPage() {
|
||||
function refreshPage(): void {
|
||||
setRefreshing(true);
|
||||
window.setTimeout(() => {
|
||||
loadSupportedChannels('支持频道列表已刷新。');
|
||||
loadSupportedChannels(t('channels.page.refreshed'));
|
||||
setRefreshing(false);
|
||||
}, 0);
|
||||
}
|
||||
@@ -151,7 +185,7 @@ export default function ChannelsPage() {
|
||||
async function handleSaveModal(): Promise<void> {
|
||||
const accountIdForSave = 'default';
|
||||
const meta = getChannelMeta(modalChannelType);
|
||||
const requiredError = validateRequiredFields(modalChannelType, modalValues);
|
||||
const requiredError = validateRequiredFields(modalChannelType, modalValues, t);
|
||||
if (requiredError) {
|
||||
setModalError(requiredError);
|
||||
return;
|
||||
@@ -178,19 +212,19 @@ export default function ChannelsPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
channelType: modalChannelType,
|
||||
channelLabel: getChannelMeta(modalChannelType).name,
|
||||
accountName: buildSavedAccountName(modalChannelType, accountIdForSave),
|
||||
channelLabel: meta.name,
|
||||
accountName: buildSavedAccountName(meta.name, accountIdForSave, t('channels.page.defaultAccountLabel')),
|
||||
channelUrl: deriveChannelUrl(modalChannelType, accountIdForSave, config),
|
||||
enabled: true,
|
||||
config,
|
||||
metadata: {
|
||||
connectionType: getChannelMeta(modalChannelType).connectionType,
|
||||
connectionType: meta.connectionType,
|
||||
managedBy: 'channels-page',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
setFeedback(`${getChannelMeta(modalChannelType).name} 配置已保存。`);
|
||||
setFeedback(t('channels.page.saved', { name: getChannelName(modalChannelType, meta.name) }));
|
||||
resetModalState();
|
||||
} catch (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"
|
||||
style={{ fontFamily: "Georgia, Cambria, 'Times New Roman', Times, serif" }}
|
||||
>
|
||||
支持的频道
|
||||
{t('channels.page.title')}
|
||||
</h1>
|
||||
<div className="mt-1 text-[13px] text-[#667085] dark:text-gray-500">
|
||||
当前页面聚焦支持的频道模块、配置入口与刷新能力。
|
||||
{t('channels.page.description')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -227,7 +261,7 @@ export default function ChannelsPage() {
|
||||
onClick={refreshPage}
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', refreshing && 'animate-spin')} />
|
||||
刷新
|
||||
{t('channels.page.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -239,48 +273,52 @@ export default function ChannelsPage() {
|
||||
|
||||
{supportedChannels.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{supportedChannels.map((meta) => (
|
||||
<button
|
||||
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>
|
||||
{supportedChannels.map((meta) => {
|
||||
const channelName = getChannelName(meta.type, meta.name);
|
||||
|
||||
<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">
|
||||
{meta.name}
|
||||
return (
|
||||
<button
|
||||
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} 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>
|
||||
{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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
暂无可配置的频道模块。
|
||||
{t('channels.page.empty')}
|
||||
</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) {
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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:
|
||||
return <span className="text-[22px] font-semibold">{type.slice(0, 1).toUpperCase()}</span>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user