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:
duanshuwen
2026-04-19 16:43:07 +08:00
parent d2e48b21d8
commit 18f12d6ce3
22 changed files with 1131 additions and 301 deletions

View File

@@ -21,6 +21,7 @@ import {
listSelectedChannelAccountGroups,
listSelectedChannelTargets,
} from '../../utils/channels';
import { getChannelMeta } from '@src/lib/channel-meta';
function getProviderSnapshot(ctx: HostApiContext) {
const accounts = ctx.providerApiService
@@ -30,6 +31,120 @@ function getProviderSnapshot(ctx: HostApiContext) {
return { accounts, defaultAccountId };
}
const VALIDATION_RESERVED_KEYS = new Set([
'channelType',
'accountId',
'credentials',
'values',
'config',
'fields',
'success',
'valid',
'errors',
'warnings',
'details',
]);
interface CredentialFieldValidationResult {
key: string;
label: string;
kind: string;
required: boolean;
provided: boolean;
valid: boolean;
errors: string[];
warnings: string[];
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function collectValidationValues(body: Record<string, unknown> | null | undefined): Record<string, unknown> {
const merged: Record<string, unknown> = {};
const sources = [
isPlainRecord(body?.credentials) ? body?.credentials : null,
isPlainRecord(body?.values) ? body?.values : null,
isPlainRecord(body?.config) ? body?.config : null,
isPlainRecord(body?.fields) ? body?.fields : null,
body,
];
for (const source of sources) {
if (!isPlainRecord(source)) continue;
for (const [key, value] of Object.entries(source)) {
if (VALIDATION_RESERVED_KEYS.has(key)) continue;
merged[key] = value;
}
}
return merged;
}
function normalizeCredentialValue(value: unknown): string {
return String(value ?? '').trim();
}
function validateUrlField(label: string, value: string, result: CredentialFieldValidationResult): void {
try {
const parsed = new URL(value);
if (parsed.protocol === 'http:') {
result.warnings.push(`${label} 使用了不安全的 http URL建议改用 https`);
}
} catch {
result.errors.push(`${label} 必须是有效的 URL`);
result.valid = false;
}
}
function validateCredentialField(
field: { key: string; label: string; kind: string; required?: boolean },
rawValue: unknown,
): CredentialFieldValidationResult {
const value = normalizeCredentialValue(rawValue);
const result: CredentialFieldValidationResult = {
key: field.key,
label: field.label,
kind: field.kind,
required: field.required === true,
provided: value.length > 0,
valid: true,
errors: [],
warnings: [],
};
if (field.required && !value) {
result.errors.push(`${field.label} is required`);
result.valid = false;
return result;
}
if (!value) {
return result;
}
if (field.kind === 'url') {
validateUrlField(field.label, value, result);
return result;
}
if (field.kind === 'token' || field.kind === 'password') {
if (/\s/.test(value)) {
result.errors.push(`${field.label} 不能包含空格`);
result.valid = false;
}
return result;
}
if (field.kind === 'text' || field.kind === 'textarea') {
if (/[\r\n]/.test(value)) {
result.warnings.push(`${field.label} 包含换行符,确认是否符合目标渠道格式`);
}
}
return result;
}
export async function handleChannelRoutes(
request: NormalizedHostApiRequest,
ctx: HostApiContext,
@@ -67,6 +182,39 @@ export async function handleChannelRoutes(
});
}
if (pathname === '/api/channels/credentials/validate' && method === 'POST') {
try {
const body = parseJsonBody<Record<string, unknown>>(request.body);
const channelType = String(body?.channelType ?? '').trim();
const accountId = String(body?.accountId ?? '').trim() || undefined;
const meta = getChannelMeta(channelType);
const validationValues = collectValidationValues(body);
const fieldResults = meta.configFields.map((field) => validateCredentialField(field, validationValues[field.key]));
const errors = fieldResults.flatMap((field) => field.errors);
const warnings = fieldResults.flatMap((field) => field.warnings);
if (!channelType) {
errors.unshift('channelType is required');
}
return ok({
success: true,
valid: errors.length === 0,
errors,
warnings,
details: {
channelType,
accountId,
channelName: meta.name,
connectionType: meta.connectionType,
fields: fieldResults,
},
});
} catch (error) {
return fail(500, error instanceof Error ? error.message : String(error));
}
}
if (pathname === '/api/channels/default-account' && method === 'PUT') {
try {
const body = parseJsonBody<{