feat: enhance channel configuration UI and validation
- Updated ChannelInstructionsPanel to include a button for viewing documentation, improving user guidance. - Enhanced ChannelTokenField to support showing/hiding secret values with appropriate labels and icons. - Refined ChannelTypeSelector to display connection type icons and improved layout for better user experience. - Added new messages for documentation links, validation feedback, and secret management in i18n. - Extended ChannelMeta to include optional documentation URLs for better context on configuration fields. - Implemented credential validation logic in ChannelsPage to ensure user inputs are validated before saving. - Introduced ChannelLogo component to display channel icons in the UI. - Added tests for channel credential validation to ensure proper error handling and feedback.
This commit is contained in:
@@ -1,27 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import ChannelConfigModal from '../../components/channels/ChannelConfigModal';
|
||||
import ChannelConfigModal, { type ChannelCredentialsValidationResult } from '../../components/channels/ChannelConfigModal';
|
||||
import { getChannelMeta, getPrimaryChannelOptions, type ChannelMeta } from '../../lib/channel-meta';
|
||||
import type { ChannelConfigFieldValueMap } from '../../lib/channel-types';
|
||||
import { hostApiFetch } from '../../lib/host-api';
|
||||
import telegramIcon from '../../assets/channels/telegram.svg';
|
||||
import discordIcon from '../../assets/channels/discord.svg';
|
||||
import whatsappIcon from '../../assets/channels/whatsapp.svg';
|
||||
import wechatIcon from '../../assets/channels/wechat.svg';
|
||||
import dingtalkIcon from '../../assets/channels/dingtalk.svg';
|
||||
import feishuIcon from '../../assets/channels/feishu.svg';
|
||||
import wecomIcon from '../../assets/channels/wecom.svg';
|
||||
import qqIcon from '../../assets/channels/qq.svg';
|
||||
|
||||
function cn(...tokens: Array<string | false | null | undefined>): string {
|
||||
return tokens.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function getChannelMonogram(channelType: string): string {
|
||||
const meta = getChannelMeta(channelType);
|
||||
const initials = meta.name
|
||||
.split(/[\s/]+/)
|
||||
.map((part) => part.trim().charAt(0))
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return initials || channelType.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function buildSyntheticChannelUrl(channelType: string, accountId: string): string {
|
||||
return `channel://${encodeURIComponent(channelType)}/${encodeURIComponent(accountId || 'default')}`;
|
||||
}
|
||||
@@ -72,10 +67,11 @@ export default function ChannelsPage() {
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalChannelType, setModalChannelType] = useState<string>('telegram');
|
||||
const [modalAccountId, setModalAccountId] = useState<string>('');
|
||||
const [modalValues, setModalValues] = useState<ChannelConfigFieldValueMap>({});
|
||||
const [modalError, setModalError] = useState<string | null>(null);
|
||||
const [modalSubmitting, setModalSubmitting] = useState(false);
|
||||
const [modalValidating, setModalValidating] = useState(false);
|
||||
const [modalValidationResult, setModalValidationResult] = useState<ChannelCredentialsValidationResult | null>(null);
|
||||
|
||||
function loadSupportedChannels(nextFeedback?: string): void {
|
||||
const channels = getPrimaryChannelOptions();
|
||||
@@ -89,17 +85,18 @@ export default function ChannelsPage() {
|
||||
function resetModalState(): void {
|
||||
setModalOpen(false);
|
||||
setModalChannelType(supportedChannels[0]?.type ?? 'telegram');
|
||||
setModalAccountId('');
|
||||
setModalValues({});
|
||||
setModalError(null);
|
||||
setModalSubmitting(false);
|
||||
setModalValidating(false);
|
||||
setModalValidationResult(null);
|
||||
}
|
||||
|
||||
function openCreateChannelModal(channelType: string): void {
|
||||
setModalChannelType(channelType);
|
||||
setModalAccountId('');
|
||||
setModalValues({});
|
||||
setModalError(null);
|
||||
setModalValidationResult(null);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
@@ -111,15 +108,62 @@ export default function ChannelsPage() {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function handleValidateModal(): Promise<ChannelCredentialsValidationResult | null> {
|
||||
setModalValidating(true);
|
||||
setModalError(null);
|
||||
|
||||
try {
|
||||
const config = Object.fromEntries(
|
||||
Object.entries(modalValues)
|
||||
.map(([key, value]) => [key, value.trim()])
|
||||
.filter(([, value]) => value.length > 0),
|
||||
);
|
||||
|
||||
const response = await hostApiFetch<ChannelCredentialsValidationResult & { success?: boolean }>(
|
||||
'/api/channels/credentials/validate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
channelType: modalChannelType,
|
||||
config,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const result: ChannelCredentialsValidationResult = {
|
||||
valid: response.valid,
|
||||
errors: response.errors ?? [],
|
||||
warnings: response.warnings ?? [],
|
||||
details: response.details,
|
||||
};
|
||||
setModalValidationResult(result);
|
||||
return result;
|
||||
} catch (requestError) {
|
||||
const message = requestError instanceof Error ? requestError.message : String(requestError);
|
||||
setModalError(message);
|
||||
setModalValidationResult(null);
|
||||
return null;
|
||||
} finally {
|
||||
setModalValidating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveModal(): Promise<void> {
|
||||
const trimmedAccountId = modalAccountId.trim();
|
||||
const accountIdForSave = trimmedAccountId || 'default';
|
||||
const accountIdForSave = 'default';
|
||||
const meta = getChannelMeta(modalChannelType);
|
||||
const requiredError = validateRequiredFields(modalChannelType, modalValues);
|
||||
if (requiredError) {
|
||||
setModalError(requiredError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.connectionType !== 'qr') {
|
||||
const validation = await handleValidateModal();
|
||||
if (!validation?.valid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setModalSubmitting(true);
|
||||
setModalError(null);
|
||||
|
||||
@@ -134,7 +178,6 @@ export default function ChannelsPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
channelType: modalChannelType,
|
||||
accountId: trimmedAccountId || undefined,
|
||||
channelLabel: getChannelMeta(modalChannelType).name,
|
||||
accountName: buildSavedAccountName(modalChannelType, accountIdForSave),
|
||||
channelUrl: deriveChannelUrl(modalChannelType, accountIdForSave, config),
|
||||
@@ -173,7 +216,7 @@ export default function ChannelsPage() {
|
||||
支持的频道
|
||||
</h1>
|
||||
<div className="mt-1 text-[13px] text-[#667085] dark:text-gray-500">
|
||||
统一管理消息频道、账号、账号与智能体的绑定关系,以及频道默认账号
|
||||
当前页面聚焦支持的频道模块、配置入口与刷新能力。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,7 +249,7 @@ export default function ChannelsPage() {
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
{getChannelMonogram(meta.type)}
|
||||
<ChannelLogo type={meta.type} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -246,34 +289,46 @@ export default function ChannelsPage() {
|
||||
<ChannelConfigModal
|
||||
open={modalOpen}
|
||||
selectedChannelType={modalChannelType}
|
||||
channelTypes={supportedChannels}
|
||||
accountId={modalAccountId}
|
||||
values={modalValues}
|
||||
onChannelTypeChange={(value) => {
|
||||
setModalChannelType(value);
|
||||
setModalAccountId('');
|
||||
setModalValues({});
|
||||
setModalError(null);
|
||||
}}
|
||||
onValueChange={(key, value) => {
|
||||
setModalValues((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
}));
|
||||
if (modalError) setModalError(null);
|
||||
}}
|
||||
onAccountIdChange={(value) => {
|
||||
setModalAccountId(value);
|
||||
if (modalError) setModalError(null);
|
||||
if (modalValidationResult) setModalValidationResult(null);
|
||||
}}
|
||||
onClose={resetModalState}
|
||||
onValidate={handleValidateModal}
|
||||
onConfirm={handleSaveModal}
|
||||
error={modalError}
|
||||
submitting={modalSubmitting}
|
||||
title="配置频道"
|
||||
description="选择一个支持的频道模块并填写接入信息。"
|
||||
confirmLabel="保存频道"
|
||||
validating={modalValidating}
|
||||
validationResult={modalValidationResult}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelLogo({ type }: { type: string }) {
|
||||
switch (type) {
|
||||
case 'telegram':
|
||||
return <img src={telegramIcon} alt="Telegram" 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" />;
|
||||
case 'whatsapp':
|
||||
return <img src={whatsappIcon} alt="WhatsApp" 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" />;
|
||||
case 'dingtalk':
|
||||
return <img src={dingtalkIcon} alt="DingTalk" 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" />;
|
||||
case 'wecom':
|
||||
return <img src={wecomIcon} alt="WeCom" 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" />;
|
||||
default:
|
||||
return <span className="text-[22px] font-semibold">{type.slice(0, 1).toUpperCase()}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user