diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index e5ea40c..0dae25b 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -1,6 +1,6 @@ "use strict"; require("electron"); -require("./main-UxAT51-x.js"); +require("./main-CDA0NBwu.js"); require("electron-squirrel-startup"); require("electron-log"); require("bytenode"); diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts index f07994f..bfe3989 100644 --- a/electron/api/routes/channels.ts +++ b/electron/api/routes/channels.ts @@ -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 { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function collectValidationValues(body: Record | null | undefined): Record { + const merged: Record = {}; + 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>(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<{ diff --git a/src/assets/channels/dingtalk.svg b/src/assets/channels/dingtalk.svg new file mode 100644 index 0000000..b76c6b8 --- /dev/null +++ b/src/assets/channels/dingtalk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/channels/discord.svg b/src/assets/channels/discord.svg new file mode 100644 index 0000000..1e407ef --- /dev/null +++ b/src/assets/channels/discord.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/channels/feishu.svg b/src/assets/channels/feishu.svg new file mode 100644 index 0000000..66bbda7 --- /dev/null +++ b/src/assets/channels/feishu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/channels/qq.svg b/src/assets/channels/qq.svg new file mode 100644 index 0000000..d6dc4d6 --- /dev/null +++ b/src/assets/channels/qq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/channels/telegram.svg b/src/assets/channels/telegram.svg new file mode 100644 index 0000000..6d9a85e --- /dev/null +++ b/src/assets/channels/telegram.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/channels/wechat.svg b/src/assets/channels/wechat.svg new file mode 100644 index 0000000..08c7657 --- /dev/null +++ b/src/assets/channels/wechat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/channels/wecom.svg b/src/assets/channels/wecom.svg new file mode 100644 index 0000000..4ca23c7 --- /dev/null +++ b/src/assets/channels/wecom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/channels/whatsapp.svg b/src/assets/channels/whatsapp.svg new file mode 100644 index 0000000..584fa55 --- /dev/null +++ b/src/assets/channels/whatsapp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/channels/ChannelAccountIdField.tsx b/src/components/channels/ChannelAccountIdField.tsx index fd037a2..19fd85c 100644 --- a/src/components/channels/ChannelAccountIdField.tsx +++ b/src/components/channels/ChannelAccountIdField.tsx @@ -1,3 +1,5 @@ +import { Hash } from 'lucide-react'; + type ChannelAccountIdFieldProps = { label: string; value: string; @@ -16,18 +18,23 @@ export default function ChannelAccountIdField({ disabled, }: ChannelAccountIdFieldProps) { return ( -
-
{label}
+
+
+ + {label} +
+ onChange(event.target.value)} placeholder={placeholder} disabled={disabled} autoComplete="off" - className="h-[44px] w-full rounded-[12px] border border-[#E5E8EE] bg-white px-3 text-[13px] text-[#171717] outline-none transition-colors placeholder:text-[#99A0AE] focus:border-[#2B7FFF] disabled:cursor-not-allowed disabled:opacity-60 dark:border-[#2a2a2d] dark:bg-[#101013] dark:text-gray-100" + className="h-[44px] w-full rounded-[14px] border border-black/10 bg-white px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-foreground/35 focus:border-blue-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#101013] dark:text-gray-100" /> + {helpText ? ( -
{helpText}
+
{helpText}
) : null}
); diff --git a/src/components/channels/ChannelConfigActions.tsx b/src/components/channels/ChannelConfigActions.tsx index 8927ba4..8155da3 100644 --- a/src/components/channels/ChannelConfigActions.tsx +++ b/src/components/channels/ChannelConfigActions.tsx @@ -1,38 +1,66 @@ +import { Check, Loader2, ShieldCheck } from 'lucide-react'; + type ChannelConfigActionsProps = { - cancelLabel: string; confirmLabel: string; - onClose: () => void; + validateLabel?: string; + validatingLabel?: string; onConfirm: () => void; + onValidate?: () => void; disabled?: boolean; submitting?: boolean; + validating?: boolean; }; export default function ChannelConfigActions({ - cancelLabel, confirmLabel, - onClose, + validateLabel, + validatingLabel, onConfirm, + onValidate, disabled, submitting, + validating, }: ChannelConfigActionsProps) { return ( -
- +
+ {onValidate ? ( + + ) : null}
); diff --git a/src/components/channels/ChannelConfigFields.tsx b/src/components/channels/ChannelConfigFields.tsx index ed7f655..79b9144 100644 --- a/src/components/channels/ChannelConfigFields.tsx +++ b/src/components/channels/ChannelConfigFields.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react'; import type { ChannelConfigFieldMeta, ChannelConfigFieldValueMap } from '../../lib/channel-types'; -import ChannelAccountIdField from './ChannelAccountIdField'; import ChannelTokenField from './ChannelTokenField'; type ChannelConfigFieldsProps = { @@ -8,9 +7,12 @@ type ChannelConfigFieldsProps = { values: ChannelConfigFieldValueMap; onValueChange: (key: string, value: string) => void; disabled?: boolean; - accountIdLabel: string; - accountIdHelpText?: string; - tokenHelpText?: string; + showSecretMap?: Record; + onToggleSecret?: (key: string) => void; + emptyStateText?: string; + envVarLabel?: string; + showSecretLabel?: string; + hideSecretLabel?: string; }; function renderField({ @@ -18,66 +20,116 @@ function renderField({ value, onChange, disabled, - accountIdLabel, - accountIdHelpText, - tokenHelpText, + showSecretMap, + onToggleSecret, + envVarLabel, + showSecretLabel, + hideSecretLabel, }: { field: ChannelConfigFieldMeta; value: string; onChange: (nextValue: string) => void; disabled?: boolean; - accountIdLabel: string; - accountIdHelpText?: string; - tokenHelpText?: string; + showSecretMap?: Record; + onToggleSecret?: (key: string) => void; + envVarLabel?: string; + showSecretLabel?: string; + hideSecretLabel?: string; }): ReactNode { - const commonProps = { - label: field.label, - value, - onChange, - placeholder: field.placeholder, - helpText: field.description, - disabled, - }; - - if (field.key === 'accountId') { - return ( - - ); - } - if (field.kind === 'token' || field.kind === 'password') { - return ; + return ( +
+ onToggleSecret(field.key) : undefined + } + /> + {renderFieldMeta(field, envVarLabel)} +
+ ); } if (field.kind === 'textarea') { return ( -
-
{field.label}
-