refactor(web): load channel configs without exposing secret values
- add a dedicated channel config API that returns sanitized config plus configured secret metadata - update channel config pages and forms to use secret presence for placeholders, validation, reset, and save behavior - refresh the channel settings layout and clean up related i18n copy - add backend tests for the new channel config endpoint
This commit is contained in:
parent
bae4342af1
commit
91abc83281
15 changed files with 1231 additions and 809 deletions
|
|
@ -3,6 +3,8 @@ package api
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type channelCatalogItem struct {
|
type channelCatalogItem struct {
|
||||||
|
|
@ -30,9 +32,22 @@ var channelCatalog = []channelCatalogItem{
|
||||||
{Name: "irc", ConfigKey: "irc"},
|
{Name: "irc", ConfigKey: "irc"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type channelConfigResponse struct {
|
||||||
|
Config any `json:"config"`
|
||||||
|
ConfiguredSecrets []string `json:"configured_secrets"`
|
||||||
|
ConfigKey string `json:"config_key"`
|
||||||
|
Variant string `json:"variant,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelSecretPresence struct {
|
||||||
|
key string
|
||||||
|
configured bool
|
||||||
|
}
|
||||||
|
|
||||||
// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.
|
// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux.
|
||||||
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
|
func (h *Handler) registerChannelRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
|
mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog)
|
||||||
|
mux.HandleFunc("GET /api/channels/{name}/config", h.handleGetChannelConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleListChannelCatalog returns the channels supported by backend.
|
// handleListChannelCatalog returns the channels supported by backend.
|
||||||
|
|
@ -44,3 +59,172 @@ func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Reques
|
||||||
"channels": channelCatalog,
|
"channels": channelCatalog,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleGetChannelConfig returns safe channel config plus secret presence metadata.
|
||||||
|
//
|
||||||
|
// GET /api/channels/{name}/config
|
||||||
|
func (h *Handler) handleGetChannelConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channelName := r.PathValue("name")
|
||||||
|
item, ok := findChannelCatalogItem(channelName)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Channel not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to load config", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildChannelConfigResponse(cfg, item)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findChannelCatalogItem(name string) (channelCatalogItem, bool) {
|
||||||
|
for _, item := range channelCatalog {
|
||||||
|
if item.Name == name {
|
||||||
|
return item, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return channelCatalogItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) channelConfigResponse {
|
||||||
|
resp := channelConfigResponse{
|
||||||
|
ConfiguredSecrets: []string{},
|
||||||
|
ConfigKey: item.ConfigKey,
|
||||||
|
Variant: item.Variant,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch item.Name {
|
||||||
|
case "weixin":
|
||||||
|
channelCfg := cfg.Channels.Weixin
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.Token = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "telegram":
|
||||||
|
channelCfg := cfg.Channels.Telegram
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.Token = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "discord":
|
||||||
|
channelCfg := cfg.Channels.Discord
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.Token = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "slack":
|
||||||
|
channelCfg := cfg.Channels.Slack
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "bot_token", configured: channelCfg.BotToken.String() != ""},
|
||||||
|
channelSecretPresence{key: "app_token", configured: channelCfg.AppToken.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.BotToken = config.SecureString{}
|
||||||
|
channelCfg.AppToken = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "feishu":
|
||||||
|
channelCfg := cfg.Channels.Feishu
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "app_secret", configured: channelCfg.AppSecret.String() != ""},
|
||||||
|
channelSecretPresence{key: "encrypt_key", configured: channelCfg.EncryptKey.String() != ""},
|
||||||
|
channelSecretPresence{key: "verification_token", configured: channelCfg.VerificationToken.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.AppSecret = config.SecureString{}
|
||||||
|
channelCfg.EncryptKey = config.SecureString{}
|
||||||
|
channelCfg.VerificationToken = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "dingtalk":
|
||||||
|
channelCfg := cfg.Channels.DingTalk
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "client_secret", configured: channelCfg.ClientSecret.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.ClientSecret = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "line":
|
||||||
|
channelCfg := cfg.Channels.LINE
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "channel_secret", configured: channelCfg.ChannelSecret.String() != ""},
|
||||||
|
channelSecretPresence{
|
||||||
|
key: "channel_access_token",
|
||||||
|
configured: channelCfg.ChannelAccessToken.String() != "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
channelCfg.ChannelSecret = config.SecureString{}
|
||||||
|
channelCfg.ChannelAccessToken = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "qq":
|
||||||
|
channelCfg := cfg.Channels.QQ
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "app_secret", configured: channelCfg.AppSecret.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.AppSecret = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "onebot":
|
||||||
|
channelCfg := cfg.Channels.OneBot
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "access_token", configured: channelCfg.AccessToken.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.AccessToken = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "wecom":
|
||||||
|
channelCfg := cfg.Channels.WeCom
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "secret", configured: channelCfg.Secret.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.Secret = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "whatsapp", "whatsapp_native":
|
||||||
|
resp.Config = cfg.Channels.WhatsApp
|
||||||
|
case "pico":
|
||||||
|
channelCfg := cfg.Channels.Pico
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "token", configured: channelCfg.Token.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.Token = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "maixcam":
|
||||||
|
resp.Config = cfg.Channels.MaixCam
|
||||||
|
case "matrix":
|
||||||
|
channelCfg := cfg.Channels.Matrix
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "access_token", configured: channelCfg.AccessToken.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.AccessToken = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
case "irc":
|
||||||
|
channelCfg := cfg.Channels.IRC
|
||||||
|
resp.ConfiguredSecrets = collectConfiguredSecrets(
|
||||||
|
channelSecretPresence{key: "password", configured: channelCfg.Password.String() != ""},
|
||||||
|
channelSecretPresence{key: "nickserv_password", configured: channelCfg.NickServPassword.String() != ""},
|
||||||
|
channelSecretPresence{key: "sasl_password", configured: channelCfg.SASLPassword.String() != ""},
|
||||||
|
)
|
||||||
|
channelCfg.Password = config.SecureString{}
|
||||||
|
channelCfg.NickServPassword = config.SecureString{}
|
||||||
|
channelCfg.SASLPassword = config.SecureString{}
|
||||||
|
resp.Config = channelCfg
|
||||||
|
default:
|
||||||
|
resp.Config = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectConfiguredSecrets(secrets ...channelSecretPresence) []string {
|
||||||
|
configured := make([]string, 0, len(secrets))
|
||||||
|
for _, secret := range secrets {
|
||||||
|
if secret.configured {
|
||||||
|
configured = append(configured, secret.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
|
|
||||||
87
web/backend/api/channels_test.go
Normal file
87
web/backend/api/channels_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleGetChannelConfig_ReturnsSecretPresenceWithoutLeakingSecrets(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.Channels.Feishu.Enabled = true
|
||||||
|
cfg.Channels.Feishu.AppID = "cli_test_app"
|
||||||
|
cfg.Channels.Feishu.AppSecret = *config.NewSecureString("feishu-secret-from-security")
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf(
|
||||||
|
"GET /api/channels/feishu/config status = %d, want %d, body=%s",
|
||||||
|
rec.Code,
|
||||||
|
http.StatusOK,
|
||||||
|
rec.Body.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "feishu-secret-from-security") {
|
||||||
|
t.Fatalf("response leaked secret value: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Config map[string]any `json:"config"`
|
||||||
|
ConfiguredSecrets []string `json:"configured_secrets"`
|
||||||
|
ConfigKey string `json:"config_key"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := resp.ConfigKey; got != "feishu" {
|
||||||
|
t.Fatalf("config_key = %q, want %q", got, "feishu")
|
||||||
|
}
|
||||||
|
if got := resp.Config["app_id"]; got != "cli_test_app" {
|
||||||
|
t.Fatalf("config.app_id = %#v, want %q", got, "cli_test_app")
|
||||||
|
}
|
||||||
|
if _, exists := resp.Config["app_secret"]; exists {
|
||||||
|
t.Fatalf("config should omit app_secret, got %#v", resp.Config["app_secret"])
|
||||||
|
}
|
||||||
|
if len(resp.ConfiguredSecrets) != 1 || resp.ConfiguredSecrets[0] != "app_secret" {
|
||||||
|
t.Fatalf("configured_secrets = %#v, want [\"app_secret\"]", resp.ConfiguredSecrets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGetChannelConfig_ReturnsNotFoundForUnknownChannel(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/channels/not-a-channel/config", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("GET /api/channels/not-a-channel/config status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
// API client for channels navigation and channel-specific config flows.
|
|
||||||
|
|
||||||
import { launcherFetch } from "@/api/http"
|
import { launcherFetch } from "@/api/http"
|
||||||
|
|
||||||
export type ChannelConfig = Record<string, unknown>
|
export type ChannelConfig = Record<string, unknown>
|
||||||
|
|
@ -12,6 +10,13 @@ export interface SupportedChannel {
|
||||||
variant?: string
|
variant?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChannelConfigResponse {
|
||||||
|
config: ChannelConfig
|
||||||
|
configured_secrets: string[]
|
||||||
|
config_key: string
|
||||||
|
variant?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ChannelsCatalogResponse {
|
interface ChannelsCatalogResponse {
|
||||||
channels: SupportedChannel[]
|
channels: SupportedChannel[]
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +59,14 @@ export async function getAppConfig(): Promise<AppConfig> {
|
||||||
return request<AppConfig>("/api/config")
|
return request<AppConfig>("/api/config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getChannelConfig(
|
||||||
|
channelName: string,
|
||||||
|
): Promise<ChannelConfigResponse> {
|
||||||
|
return request<ChannelConfigResponse>(
|
||||||
|
`/api/channels/${encodeURIComponent(channelName)}/config`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export async function patchAppConfig(
|
export async function patchAppConfig(
|
||||||
patch: Record<string, unknown>,
|
patch: Record<string, unknown>,
|
||||||
): Promise<ConfigActionResponse> {
|
): Promise<ConfigActionResponse> {
|
||||||
|
|
|
||||||
101
web/frontend/src/components/channels/channel-config-fields.ts
Normal file
101
web/frontend/src/components/channels/channel-config-fields.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
|
|
||||||
|
export const SECRET_FIELD_MAP = {
|
||||||
|
token: "_token",
|
||||||
|
app_secret: "_app_secret",
|
||||||
|
client_secret: "_client_secret",
|
||||||
|
corp_secret: "_corp_secret",
|
||||||
|
channel_secret: "_channel_secret",
|
||||||
|
channel_access_token: "_channel_access_token",
|
||||||
|
access_token: "_access_token",
|
||||||
|
bot_token: "_bot_token",
|
||||||
|
app_token: "_app_token",
|
||||||
|
encoding_aes_key: "_encoding_aes_key",
|
||||||
|
encrypt_key: "_encrypt_key",
|
||||||
|
verification_token: "_verification_token",
|
||||||
|
secret: "_secret",
|
||||||
|
password: "_password",
|
||||||
|
nickserv_password: "_nickserv_password",
|
||||||
|
sasl_password: "_sasl_password",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const CHANNEL_SECRET_FIELDS: Record<string, string[]> = {
|
||||||
|
weixin: ["token"],
|
||||||
|
telegram: ["token"],
|
||||||
|
discord: ["token"],
|
||||||
|
slack: ["bot_token", "app_token"],
|
||||||
|
feishu: ["app_secret", "encrypt_key", "verification_token"],
|
||||||
|
dingtalk: ["client_secret"],
|
||||||
|
line: ["channel_secret", "channel_access_token"],
|
||||||
|
qq: ["app_secret"],
|
||||||
|
onebot: ["access_token"],
|
||||||
|
wecom: ["secret"],
|
||||||
|
pico: ["token"],
|
||||||
|
matrix: ["access_token"],
|
||||||
|
irc: ["password", "nickserv_password", "sasl_password"],
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECRET_FIELD_SET = new Set(Object.keys(SECRET_FIELD_MAP))
|
||||||
|
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSecretField(key: string): boolean {
|
||||||
|
return SECRET_FIELD_SET.has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEditConfig(
|
||||||
|
channelName: string,
|
||||||
|
config: ChannelConfig,
|
||||||
|
): ChannelConfig {
|
||||||
|
const edit: ChannelConfig = { ...config }
|
||||||
|
|
||||||
|
for (const key of CHANNEL_SECRET_FIELDS[channelName] ?? []) {
|
||||||
|
if (!(key in edit)) {
|
||||||
|
edit[key] = ""
|
||||||
|
}
|
||||||
|
const editKey = SECRET_FIELD_MAP[key as keyof typeof SECRET_FIELD_MAP]
|
||||||
|
if (editKey) {
|
||||||
|
edit[editKey] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return edit
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasConfiguredSecret(
|
||||||
|
configuredSecrets: readonly string[],
|
||||||
|
key: string,
|
||||||
|
): boolean {
|
||||||
|
return configuredSecrets.includes(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFieldValueForValidation(
|
||||||
|
config: ChannelConfig,
|
||||||
|
configuredSecrets: readonly string[],
|
||||||
|
key: string,
|
||||||
|
): unknown {
|
||||||
|
const editKey = SECRET_FIELD_MAP[key as keyof typeof SECRET_FIELD_MAP]
|
||||||
|
if (editKey) {
|
||||||
|
const incoming = asString(config[editKey]).trim()
|
||||||
|
if (incoming !== "") {
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
if (hasConfiguredSecret(configuredSecrets, key)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSecretInputPlaceholder(
|
||||||
|
configuredSecrets: readonly string[],
|
||||||
|
key: string,
|
||||||
|
configuredPlaceholder: string,
|
||||||
|
fallback = "",
|
||||||
|
): string {
|
||||||
|
return hasConfiguredSecret(configuredSecrets, key)
|
||||||
|
? configuredPlaceholder
|
||||||
|
: fallback
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import { IconAlertTriangle, IconLoader2 } from "@tabler/icons-react"
|
import { IconLoader2 } from "@tabler/icons-react"
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ChannelConfig,
|
type ChannelConfig,
|
||||||
type SupportedChannel,
|
type SupportedChannel,
|
||||||
getAppConfig,
|
getChannelConfig,
|
||||||
getChannelsCatalog,
|
getChannelsCatalog,
|
||||||
patchAppConfig,
|
patchAppConfig,
|
||||||
} from "@/api/channels"
|
} from "@/api/channels"
|
||||||
|
import {
|
||||||
|
SECRET_FIELD_MAP,
|
||||||
|
buildEditConfig,
|
||||||
|
getFieldValueForValidation,
|
||||||
|
isSecretField,
|
||||||
|
} from "@/components/channels/channel-config-fields"
|
||||||
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
|
import { getChannelDisplayName } from "@/components/channels/channel-display-name"
|
||||||
import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
|
import { DiscordForm } from "@/components/channels/channel-forms/discord-form"
|
||||||
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
|
import { FeishuForm } from "@/components/channels/channel-forms/feishu-form"
|
||||||
|
|
@ -27,24 +33,6 @@ interface ChannelConfigPageProps {
|
||||||
channelName: string
|
channelName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SECRET_FIELD_MAP: Record<string, string> = {
|
|
||||||
token: "_token",
|
|
||||||
app_secret: "_app_secret",
|
|
||||||
client_secret: "_client_secret",
|
|
||||||
corp_secret: "_corp_secret",
|
|
||||||
channel_secret: "_channel_secret",
|
|
||||||
channel_access_token: "_channel_access_token",
|
|
||||||
access_token: "_access_token",
|
|
||||||
bot_token: "_bot_token",
|
|
||||||
app_token: "_app_token",
|
|
||||||
encoding_aes_key: "_encoding_aes_key",
|
|
||||||
encrypt_key: "_encrypt_key",
|
|
||||||
verification_token: "_verification_token",
|
|
||||||
password: "_password",
|
|
||||||
nickserv_password: "_nickserv_password",
|
|
||||||
sasl_password: "_sasl_password",
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> {
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
return value as Record<string, unknown>
|
return value as Record<string, unknown>
|
||||||
|
|
@ -60,14 +48,6 @@ function asBool(value: unknown): boolean {
|
||||||
return value === true
|
return value === true
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEditConfig(config: ChannelConfig): ChannelConfig {
|
|
||||||
const edit: ChannelConfig = { ...config }
|
|
||||||
for (const editKey of Object.values(SECRET_FIELD_MAP)) {
|
|
||||||
edit[editKey] = ""
|
|
||||||
}
|
|
||||||
return edit
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeConfig(
|
function normalizeConfig(
|
||||||
channel: SupportedChannel,
|
channel: SupportedChannel,
|
||||||
rawConfig: ChannelConfig,
|
rawConfig: ChannelConfig,
|
||||||
|
|
@ -92,7 +72,7 @@ function buildSavePayload(
|
||||||
for (const [key, value] of Object.entries(editConfig)) {
|
for (const [key, value] of Object.entries(editConfig)) {
|
||||||
if (key.startsWith("_")) continue
|
if (key.startsWith("_")) continue
|
||||||
if (key === "enabled") continue
|
if (key === "enabled") continue
|
||||||
if (key in SECRET_FIELD_MAP) continue
|
if (isSecretField(key)) continue
|
||||||
|
|
||||||
payload[key] = value
|
payload[key] = value
|
||||||
}
|
}
|
||||||
|
|
@ -103,8 +83,9 @@ function buildSavePayload(
|
||||||
payload[secretKey] = incoming
|
payload[secretKey] = incoming
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (secretKey in editConfig) {
|
const existing = asString(editConfig[secretKey]).trim()
|
||||||
payload[secretKey] = editConfig[secretKey]
|
if (existing !== "") {
|
||||||
|
payload[secretKey] = existing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,51 +102,50 @@ function buildSavePayload(
|
||||||
function isConfigured(
|
function isConfigured(
|
||||||
channel: SupportedChannel,
|
channel: SupportedChannel,
|
||||||
config: ChannelConfig,
|
config: ChannelConfig,
|
||||||
|
configuredSecrets: readonly string[],
|
||||||
): boolean {
|
): boolean {
|
||||||
|
const hasValue = (key: string) =>
|
||||||
|
!isMissingRequiredValue(
|
||||||
|
getFieldValueForValidation(config, configuredSecrets, key),
|
||||||
|
)
|
||||||
|
|
||||||
switch (channel.name) {
|
switch (channel.name) {
|
||||||
case "telegram":
|
case "telegram":
|
||||||
return asString(config.token) !== ""
|
return hasValue("token")
|
||||||
case "discord":
|
case "discord":
|
||||||
return asString(config.token) !== ""
|
return hasValue("token")
|
||||||
case "slack":
|
case "slack":
|
||||||
return asString(config.bot_token) !== ""
|
return hasValue("bot_token")
|
||||||
case "feishu":
|
case "feishu":
|
||||||
return (
|
return hasValue("app_id") && hasValue("app_secret")
|
||||||
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
|
|
||||||
)
|
|
||||||
case "dingtalk":
|
case "dingtalk":
|
||||||
return (
|
return hasValue("client_id") && hasValue("client_secret")
|
||||||
asString(config.client_id) !== "" &&
|
|
||||||
asString(config.client_secret) !== ""
|
|
||||||
)
|
|
||||||
case "line":
|
case "line":
|
||||||
return asString(config.channel_access_token) !== ""
|
return hasValue("channel_secret") && hasValue("channel_access_token")
|
||||||
case "qq":
|
case "qq":
|
||||||
return (
|
return hasValue("app_id") && hasValue("app_secret")
|
||||||
asString(config.app_id) !== "" && asString(config.app_secret) !== ""
|
|
||||||
)
|
|
||||||
case "onebot":
|
case "onebot":
|
||||||
return asString(config.ws_url) !== ""
|
return hasValue("ws_url")
|
||||||
case "weixin":
|
case "weixin":
|
||||||
return asString(config.account_id) !== ""
|
return hasValue("account_id")
|
||||||
case "wecom":
|
case "wecom":
|
||||||
return asString(config.bot_id) !== ""
|
return hasValue("bot_id")
|
||||||
case "whatsapp":
|
case "whatsapp":
|
||||||
return asString(config.bridge_url) !== ""
|
return hasValue("bridge_url")
|
||||||
case "whatsapp_native":
|
case "whatsapp_native":
|
||||||
return asBool(config.use_native)
|
return asBool(config.use_native)
|
||||||
case "pico":
|
case "pico":
|
||||||
return asString(config.token) !== ""
|
return hasValue("token")
|
||||||
case "maixcam":
|
case "maixcam":
|
||||||
return asString(config.host) !== ""
|
return hasValue("host")
|
||||||
case "matrix":
|
case "matrix":
|
||||||
return (
|
return (
|
||||||
asString(config.homeserver) !== "" &&
|
hasValue("homeserver") &&
|
||||||
asString(config.user_id) !== "" &&
|
hasValue("user_id") &&
|
||||||
asString(config.access_token) !== ""
|
hasValue("access_token")
|
||||||
)
|
)
|
||||||
case "irc":
|
case "irc":
|
||||||
return asString(config.server) !== ""
|
return hasValue("server")
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -245,21 +225,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
const [channel, setChannel] = useState<SupportedChannel | null>(null)
|
const [channel, setChannel] = useState<SupportedChannel | null>(null)
|
||||||
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
|
const [baseConfig, setBaseConfig] = useState<ChannelConfig>({})
|
||||||
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
|
||||||
|
const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([])
|
||||||
const [enabled, setEnabled] = useState(false)
|
const [enabled, setEnabled] = useState(false)
|
||||||
|
|
||||||
const loadData = useCallback(
|
const loadData = useCallback(
|
||||||
async (silent = false) => {
|
async (silent = false) => {
|
||||||
if (!silent) setLoading(true)
|
if (!silent) setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [catalog, appConfig] = await Promise.all([
|
const catalog = await getChannelsCatalog()
|
||||||
getChannelsCatalog(),
|
|
||||||
getAppConfig(),
|
|
||||||
])
|
|
||||||
const matched =
|
const matched =
|
||||||
catalog.channels.find((item) => item.name === channelName) ?? null
|
catalog.channels.find((item) => item.name === channelName) ?? null
|
||||||
|
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
setChannel(null)
|
setChannel(null)
|
||||||
|
setBaseConfig({})
|
||||||
|
setEditConfig({})
|
||||||
|
setConfiguredSecrets([])
|
||||||
|
setEnabled(false)
|
||||||
setFetchError(
|
setFetchError(
|
||||||
t("channels.page.notFound", {
|
t("channels.page.notFound", {
|
||||||
name: channelName,
|
name: channelName,
|
||||||
|
|
@ -268,18 +250,20 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelsConfig = asRecord(asRecord(appConfig).channels)
|
const channelConfig = await getChannelConfig(channelName)
|
||||||
const raw = asRecord(channelsConfig[matched.config_key])
|
const raw = asRecord(channelConfig.config)
|
||||||
const normalized = normalizeConfig(matched, raw)
|
const normalized = normalizeConfig(matched, raw)
|
||||||
|
|
||||||
setChannel(matched)
|
setChannel(matched)
|
||||||
setBaseConfig(normalized)
|
setBaseConfig(normalized)
|
||||||
setEditConfig(buildEditConfig(normalized))
|
setEditConfig(buildEditConfig(matched.name, normalized))
|
||||||
|
setConfiguredSecrets(channelConfig.configured_secrets ?? [])
|
||||||
setEnabled(asBool(normalized.enabled))
|
setEnabled(asBool(normalized.enabled))
|
||||||
setFetchError("")
|
setFetchError("")
|
||||||
setServerError("")
|
setServerError("")
|
||||||
setFieldErrors({})
|
setFieldErrors({})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setConfiguredSecrets([])
|
||||||
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setLoading(false)
|
if (!silent) setLoading(false)
|
||||||
|
|
@ -307,9 +291,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
}, [channel, editConfig, enabled])
|
}, [channel, editConfig, enabled])
|
||||||
|
|
||||||
const configured = useMemo(() => {
|
const configured = useMemo(() => {
|
||||||
if (!channel || !savePayload) return false
|
if (!channel) return false
|
||||||
return isConfigured(channel, savePayload)
|
return isConfigured(channel, editConfig, configuredSecrets)
|
||||||
}, [channel, savePayload])
|
}, [channel, configuredSecrets, editConfig])
|
||||||
|
|
||||||
const docsUrl = useMemo(() => {
|
const docsUrl = useMemo(() => {
|
||||||
if (!channel) return ""
|
if (!channel) return ""
|
||||||
|
|
@ -362,7 +346,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setEditConfig(buildEditConfig(baseConfig))
|
if (!channel) return
|
||||||
|
setEditConfig(buildEditConfig(channel.name, baseConfig))
|
||||||
setEnabled(asBool(baseConfig.enabled))
|
setEnabled(asBool(baseConfig.enabled))
|
||||||
setServerError("")
|
setServerError("")
|
||||||
setFieldErrors({})
|
setFieldErrors({})
|
||||||
|
|
@ -372,7 +357,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
if (!channel || !savePayload) return
|
if (!channel || !savePayload) return
|
||||||
|
|
||||||
const missingRequiredFields = requiredKeys.filter((key) =>
|
const missingRequiredFields = requiredKeys.filter((key) =>
|
||||||
isMissingRequiredValue(savePayload[key]),
|
isMissingRequiredValue(
|
||||||
|
getFieldValueForValidation(editConfig, configuredSecrets, key),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (missingRequiredFields.length > 0) {
|
if (missingRequiredFields.length > 0) {
|
||||||
const requiredFieldError = t("channels.validation.requiredField")
|
const requiredFieldError = t("channels.validation.requiredField")
|
||||||
|
|
@ -456,7 +443,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<TelegramForm
|
<TelegramForm
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
configuredSecrets={configuredSecrets}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -465,7 +452,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<DiscordForm
|
<DiscordForm
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
configuredSecrets={configuredSecrets}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -474,7 +461,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<SlackForm
|
<SlackForm
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
configuredSecrets={configuredSecrets}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -483,7 +470,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<FeishuForm
|
<FeishuForm
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
configuredSecrets={configuredSecrets}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
@ -510,7 +497,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<GenericForm
|
<GenericForm
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
configuredSecrets={configuredSecrets}
|
||||||
hiddenKeys={[...hiddenKeys, "bot_id"]}
|
hiddenKeys={[...hiddenKeys, "bot_id"]}
|
||||||
requiredKeys={requiredKeys}
|
requiredKeys={requiredKeys}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
|
|
@ -522,7 +509,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<GenericForm
|
<GenericForm
|
||||||
config={editConfig}
|
config={editConfig}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
isEdit={isEdit}
|
configuredSecrets={configuredSecrets}
|
||||||
hiddenKeys={hiddenKeys}
|
hiddenKeys={hiddenKeys}
|
||||||
requiredKeys={requiredKeys}
|
requiredKeys={requiredKeys}
|
||||||
fieldErrors={fieldErrors}
|
fieldErrors={fieldErrors}
|
||||||
|
|
@ -536,19 +523,17 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={channelDisplayName}
|
title={channelDisplayName}
|
||||||
titleExtra={
|
titleExtra={
|
||||||
channel ? (
|
channel &&
|
||||||
<div className="flex items-center gap-1.5">
|
docsUrl && (
|
||||||
{enabled ? (
|
<a
|
||||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
|
href={docsUrl}
|
||||||
{t("channels.page.enabled")}
|
target="_blank"
|
||||||
</span>
|
rel="noreferrer"
|
||||||
) : configured ? (
|
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2"
|
||||||
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
|
>
|
||||||
{t("channels.status.configured")}
|
{t("channels.page.docLink")}
|
||||||
</span>
|
</a>
|
||||||
) : null}
|
)
|
||||||
</div>
|
|
||||||
) : undefined
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -562,46 +547,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
{fetchError}
|
{fetchError}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full max-w-250 space-y-5 pt-2">
|
<div className="w-full max-w-4xl space-y-6 pt-5">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<p className="font-medium">
|
|
||||||
{t("channels.edit", {
|
|
||||||
name: channelDisplayName,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
{channel && docsUrl && (
|
|
||||||
<a
|
|
||||||
href={docsUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground text-xs underline underline-offset-2"
|
|
||||||
>
|
|
||||||
{t("channels.page.docLink")}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{channel?.name === "weixin" && (
|
|
||||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<IconAlertTriangle
|
|
||||||
size={18}
|
|
||||||
className="mt-0.5 shrink-0 text-amber-600 dark:text-amber-400"
|
|
||||||
/>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
|
|
||||||
{t("channels.weixin.warningTitle")}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-amber-700/90 dark:text-amber-300/90">
|
|
||||||
{t("channels.weixin.warningDesc")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hidesPageLevelEnableToggle && (
|
{!hidesPageLevelEnableToggle && (
|
||||||
<div className="border-border/60 bg-background flex items-center justify-between rounded-lg border px-4 py-3">
|
<div className="bg-card text-card-foreground border-border/60 flex items-center justify-between rounded-xl border px-6 py-4 shadow-sm">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{t("channels.page.enableLabel")}
|
{t("channels.page.enableLabel")}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface DiscordFormProps {
|
interface DiscordFormProps {
|
||||||
config: ChannelConfig
|
config: ChannelConfig
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
isEdit: boolean
|
configuredSecrets: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,75 +36,83 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||||
export function DiscordForm({
|
export function DiscordForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
isEdit,
|
configuredSecrets,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
}: DiscordFormProps) {
|
}: DiscordFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const groupTriggerConfig = asRecord(config.group_trigger)
|
const groupTriggerConfig = asRecord(config.group_trigger)
|
||||||
const tokenExtraHint =
|
|
||||||
isEdit && asString(config.token)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
<Field
|
<Card className="shadow-sm">
|
||||||
label={t("channels.field.token")}
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
required
|
<Field
|
||||||
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
|
label={t("channels.field.token")}
|
||||||
error={fieldErrors.token}
|
required
|
||||||
>
|
hint={t("channels.form.desc.token")}
|
||||||
<KeyInput
|
error={fieldErrors.token}
|
||||||
value={asString(config._token)}
|
>
|
||||||
onChange={(v) => onChange("_token", v)}
|
<KeyInput
|
||||||
placeholder={maskedSecretPlaceholder(
|
value={asString(config._token)}
|
||||||
config.token,
|
onChange={(v) => onChange("_token", v)}
|
||||||
t("channels.field.tokenPlaceholder"),
|
placeholder={getSecretInputPlaceholder(
|
||||||
)}
|
configuredSecrets,
|
||||||
/>
|
"token",
|
||||||
</Field>
|
t("channels.field.secretHintSet"),
|
||||||
|
t("channels.field.tokenPlaceholder"),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Field
|
<Card className="shadow-sm">
|
||||||
label={t("channels.field.proxy")}
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
hint={t("channels.form.desc.proxy")}
|
<Field
|
||||||
>
|
label={t("channels.field.proxy")}
|
||||||
<Input
|
hint={t("channels.form.desc.proxy")}
|
||||||
value={asString(config.proxy)}
|
>
|
||||||
onChange={(e) => onChange("proxy", e.target.value)}
|
<Input
|
||||||
placeholder="http://127.0.0.1:7890"
|
value={asString(config.proxy)}
|
||||||
/>
|
onChange={(e) => onChange("proxy", e.target.value)}
|
||||||
</Field>
|
placeholder="http://127.0.0.1:7890"
|
||||||
<Field
|
/>
|
||||||
label={t("channels.field.allowFrom")}
|
</Field>
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
<Field
|
||||||
>
|
label={t("channels.field.allowFrom")}
|
||||||
<Input
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
value={asStringArray(config.allow_from).join(", ")}
|
>
|
||||||
onChange={(e) =>
|
<Input
|
||||||
onChange(
|
value={asStringArray(config.allow_from).join(", ")}
|
||||||
"allow_from",
|
onChange={(e) =>
|
||||||
e.target.value
|
onChange(
|
||||||
.split(",")
|
"allow_from",
|
||||||
.map((s: string) => s.trim())
|
e.target.value
|
||||||
.filter(Boolean),
|
.split(",")
|
||||||
)
|
.map((s: string) => s.trim())
|
||||||
}
|
.filter(Boolean),
|
||||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
)
|
||||||
/>
|
}
|
||||||
</Field>
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<SwitchCardField
|
<div>
|
||||||
label={t("channels.field.mentionOnly")}
|
<SwitchCardField
|
||||||
hint={t("channels.form.desc.mentionOnly")}
|
label={t("channels.field.mentionOnly")}
|
||||||
checked={asBool(groupTriggerConfig.mention_only)}
|
hint={t("channels.form.desc.mentionOnly")}
|
||||||
onCheckedChange={(checked) => {
|
checked={asBool(groupTriggerConfig.mention_only)}
|
||||||
onChange("group_trigger", {
|
onCheckedChange={(checked) => {
|
||||||
...groupTriggerConfig,
|
onChange("group_trigger", {
|
||||||
mention_only: checked,
|
...groupTriggerConfig,
|
||||||
})
|
mention_only: checked,
|
||||||
}}
|
})
|
||||||
ariaLabel={t("channels.field.mentionOnly")}
|
}}
|
||||||
/>
|
ariaLabel={t("channels.field.mentionOnly")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface FeishuFormProps {
|
interface FeishuFormProps {
|
||||||
config: ChannelConfig
|
config: ChannelConfig
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
isEdit: boolean
|
configuredSecrets: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -28,104 +29,111 @@ function asStringArray(value: unknown): string[] {
|
||||||
export function FeishuForm({
|
export function FeishuForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
isEdit,
|
configuredSecrets,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
}: FeishuFormProps) {
|
}: FeishuFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const appSecretExtraHint =
|
|
||||||
isEdit && asString(config.app_secret)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
const verificationExtraHint =
|
|
||||||
isEdit && asString(config.verification_token)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
const encryptExtraHint =
|
|
||||||
isEdit && asString(config.encrypt_key)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
<Field
|
<Card className="py-3 shadow-sm">
|
||||||
label={t("channels.field.appId")}
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
required
|
<Field
|
||||||
hint={t("channels.form.desc.appId")}
|
label={t("channels.field.appId")}
|
||||||
error={fieldErrors.app_id}
|
required
|
||||||
>
|
hint={t("channels.form.desc.appId")}
|
||||||
<Input
|
error={fieldErrors.app_id}
|
||||||
value={asString(config.app_id)}
|
>
|
||||||
onChange={(e) => onChange("app_id", e.target.value)}
|
<Input
|
||||||
placeholder="cli_xxxx"
|
value={asString(config.app_id)}
|
||||||
/>
|
onChange={(e) => onChange("app_id", e.target.value)}
|
||||||
</Field>
|
placeholder="cli_xxxx"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.appSecret")}
|
label={t("channels.field.appSecret")}
|
||||||
required
|
required
|
||||||
hint={`${t("channels.form.desc.appSecret")}${appSecretExtraHint}`}
|
hint={t("channels.form.desc.appSecret")}
|
||||||
error={fieldErrors.app_secret}
|
error={fieldErrors.app_secret}
|
||||||
>
|
>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={asString(config._app_secret)}
|
value={asString(config._app_secret)}
|
||||||
onChange={(v) => onChange("_app_secret", v)}
|
onChange={(v) => onChange("_app_secret", v)}
|
||||||
placeholder={maskedSecretPlaceholder(
|
placeholder={getSecretInputPlaceholder(
|
||||||
config.app_secret,
|
configuredSecrets,
|
||||||
t("channels.field.secretPlaceholder"),
|
"app_secret",
|
||||||
)}
|
t("channels.field.secretHintSet"),
|
||||||
/>
|
t("channels.field.secretPlaceholder"),
|
||||||
</Field>
|
)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Field
|
<Card className="py-3 shadow-sm">
|
||||||
label={t("channels.field.verificationToken")}
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
hint={`${t("channels.form.desc.verificationToken")}${verificationExtraHint}`}
|
<Field
|
||||||
>
|
label={t("channels.field.verificationToken")}
|
||||||
<KeyInput
|
hint={t("channels.form.desc.verificationToken")}
|
||||||
value={asString(config._verification_token)}
|
>
|
||||||
onChange={(v) => onChange("_verification_token", v)}
|
<KeyInput
|
||||||
placeholder={maskedSecretPlaceholder(
|
value={asString(config._verification_token)}
|
||||||
config.verification_token,
|
onChange={(v) => onChange("_verification_token", v)}
|
||||||
t("channels.field.secretPlaceholder"),
|
placeholder={getSecretInputPlaceholder(
|
||||||
)}
|
configuredSecrets,
|
||||||
/>
|
"verification_token",
|
||||||
</Field>
|
t("channels.field.secretHintSet"),
|
||||||
<Field
|
t("channels.field.secretPlaceholder"),
|
||||||
label={t("channels.field.encryptKey")}
|
)}
|
||||||
hint={`${t("channels.form.desc.encryptKey")}${encryptExtraHint}`}
|
/>
|
||||||
>
|
</Field>
|
||||||
<KeyInput
|
<Field
|
||||||
value={asString(config._encrypt_key)}
|
label={t("channels.field.encryptKey")}
|
||||||
onChange={(v) => onChange("_encrypt_key", v)}
|
hint={t("channels.form.desc.encryptKey")}
|
||||||
placeholder={maskedSecretPlaceholder(
|
>
|
||||||
config.encrypt_key,
|
<KeyInput
|
||||||
t("channels.field.secretPlaceholder"),
|
value={asString(config._encrypt_key)}
|
||||||
)}
|
onChange={(v) => onChange("_encrypt_key", v)}
|
||||||
/>
|
placeholder={getSecretInputPlaceholder(
|
||||||
</Field>
|
configuredSecrets,
|
||||||
<SwitchCardField
|
"encrypt_key",
|
||||||
label={t("channels.field.isLark")}
|
t("channels.field.secretHintSet"),
|
||||||
hint={t("channels.form.desc.isLark")}
|
t("channels.field.secretPlaceholder"),
|
||||||
checked={asBool(config.is_lark)}
|
)}
|
||||||
onCheckedChange={(checked) => onChange("is_lark", checked)}
|
/>
|
||||||
/>
|
</Field>
|
||||||
<Field
|
|
||||||
label={t("channels.field.allowFrom")}
|
<Field
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
label={t("channels.field.allowFrom")}
|
||||||
>
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
<Input
|
>
|
||||||
value={asStringArray(config.allow_from).join(", ")}
|
<Input
|
||||||
onChange={(e) =>
|
value={asStringArray(config.allow_from).join(", ")}
|
||||||
onChange(
|
onChange={(e) =>
|
||||||
"allow_from",
|
onChange(
|
||||||
e.target.value
|
"allow_from",
|
||||||
.split(",")
|
e.target.value
|
||||||
.map((s: string) => s.trim())
|
.split(",")
|
||||||
.filter(Boolean),
|
.map((s: string) => s.trim())
|
||||||
)
|
.filter(Boolean),
|
||||||
}
|
)
|
||||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
}
|
||||||
/>
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
</Field>
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("channels.field.isLark")}
|
||||||
|
hint={t("channels.form.desc.isLark")}
|
||||||
|
checked={asBool(config.is_lark)}
|
||||||
|
onCheckedChange={(checked) => onChange("is_lark", checked)}
|
||||||
|
ariaLabel={t("channels.field.isLark")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,23 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import {
|
||||||
|
getSecretInputPlaceholder,
|
||||||
|
isSecretField,
|
||||||
|
} from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface GenericFormProps {
|
interface GenericFormProps {
|
||||||
config: ChannelConfig
|
config: ChannelConfig
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
isEdit: boolean
|
configuredSecrets?: string[]
|
||||||
hiddenKeys?: string[]
|
hiddenKeys?: string[]
|
||||||
requiredKeys?: string[]
|
requiredKeys?: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secret field names that should use masked input.
|
|
||||||
const SECRET_FIELDS = new Set([
|
|
||||||
"token",
|
|
||||||
"app_secret",
|
|
||||||
"client_secret",
|
|
||||||
"corp_secret",
|
|
||||||
"channel_secret",
|
|
||||||
"channel_access_token",
|
|
||||||
"access_token",
|
|
||||||
"bot_token",
|
|
||||||
"app_token",
|
|
||||||
"encoding_aes_key",
|
|
||||||
"encrypt_key",
|
|
||||||
"verification_token",
|
|
||||||
"secret",
|
|
||||||
"password",
|
|
||||||
"nickserv_password",
|
|
||||||
"sasl_password",
|
|
||||||
])
|
|
||||||
|
|
||||||
// Fields to skip in the generic form (handled by enabled toggle or internal).
|
// Fields to skip in the generic form (handled by enabled toggle or internal).
|
||||||
const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"])
|
const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"])
|
||||||
|
|
||||||
|
|
@ -83,7 +67,7 @@ function asBool(value: unknown): boolean {
|
||||||
export function GenericForm({
|
export function GenericForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
isEdit,
|
configuredSecrets = [],
|
||||||
hiddenKeys = [],
|
hiddenKeys = [],
|
||||||
requiredKeys = [],
|
requiredKeys = [],
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
|
|
@ -96,7 +80,7 @@ export function GenericForm({
|
||||||
const placeholderConfig = asRecord(config.placeholder)
|
const placeholderConfig = asRecord(config.placeholder)
|
||||||
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
||||||
|
|
||||||
const fields = Object.keys(config).filter(
|
const rawFields = Object.keys(config).filter(
|
||||||
(k) =>
|
(k) =>
|
||||||
!k.startsWith("_") &&
|
!k.startsWith("_") &&
|
||||||
!SKIP_FIELDS.has(k) &&
|
!SKIP_FIELDS.has(k) &&
|
||||||
|
|
@ -160,231 +144,291 @@ export function GenericForm({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const renderField = (key: string) => {
|
||||||
<div className="space-y-5">
|
const isRequired = requiredFieldSet.has(key)
|
||||||
{fields.map((key) => {
|
if (isSecretField(key)) {
|
||||||
const isRequired = requiredFieldSet.has(key)
|
const editKey = `_${key}`
|
||||||
if (SECRET_FIELDS.has(key)) {
|
return (
|
||||||
const editKey = `_${key}`
|
|
||||||
const extraHint =
|
|
||||||
isEdit && config[key] ? ` ${t("channels.field.secretHintSet")}` : ""
|
|
||||||
return (
|
|
||||||
<Field
|
|
||||||
key={key}
|
|
||||||
label={formatLabel(key)}
|
|
||||||
required={isRequired}
|
|
||||||
hint={`${buildHint(key)}${extraHint}`}
|
|
||||||
error={fieldErrors[key]}
|
|
||||||
>
|
|
||||||
<KeyInput
|
|
||||||
value={asString(config[editKey])}
|
|
||||||
onChange={(v) => onChange(editKey, v)}
|
|
||||||
placeholder={maskedSecretPlaceholder(config[key])}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = config[key]
|
|
||||||
if (typeof value === "boolean") {
|
|
||||||
return (
|
|
||||||
<SwitchCardField
|
|
||||||
key={key}
|
|
||||||
label={formatLabel(key)}
|
|
||||||
hint={buildHint(key)}
|
|
||||||
error={fieldErrors[key]}
|
|
||||||
checked={value}
|
|
||||||
onCheckedChange={(checked) => onChange(key, checked)}
|
|
||||||
ariaLabel={formatLabel(key)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return (
|
|
||||||
<Field
|
|
||||||
key={key}
|
|
||||||
label={formatLabel(key)}
|
|
||||||
required={isRequired}
|
|
||||||
hint={buildHint(key)}
|
|
||||||
error={fieldErrors[key]}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={asStringArray(value).join(", ")}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange(
|
|
||||||
key,
|
|
||||||
e.target.value
|
|
||||||
.split(",")
|
|
||||||
.map((s: string) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Field
|
|
||||||
key={key}
|
|
||||||
label={formatLabel(key)}
|
|
||||||
required={isRequired}
|
|
||||||
hint={buildHint(key)}
|
|
||||||
error={fieldErrors[key]}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={String(value ?? "")}
|
|
||||||
onChange={(e) => {
|
|
||||||
// Attempt to preserve number types
|
|
||||||
const v = e.target.value
|
|
||||||
if (typeof config[key] === "number") {
|
|
||||||
onChange(key, v === "" ? 0 : Number(v))
|
|
||||||
} else {
|
|
||||||
onChange(key, v)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Allow From field */}
|
|
||||||
{config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && (
|
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.allowFrom")}
|
key={key}
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
label={formatLabel(key)}
|
||||||
|
required={isRequired}
|
||||||
|
hint={buildHint(key)}
|
||||||
|
error={fieldErrors[key]}
|
||||||
|
>
|
||||||
|
<KeyInput
|
||||||
|
value={asString(config[editKey])}
|
||||||
|
onChange={(v) => onChange(editKey, v)}
|
||||||
|
placeholder={getSecretInputPlaceholder(
|
||||||
|
configuredSecrets,
|
||||||
|
key,
|
||||||
|
t("channels.field.secretHintSet"),
|
||||||
|
t("channels.field.secretPlaceholder"),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = config[key]
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return (
|
||||||
|
<SwitchCardField
|
||||||
|
key={key}
|
||||||
|
label={formatLabel(key)}
|
||||||
|
hint={buildHint(key)}
|
||||||
|
error={fieldErrors[key]}
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={(checked) => onChange(key, checked)}
|
||||||
|
ariaLabel={formatLabel(key)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={key}
|
||||||
|
label={formatLabel(key)}
|
||||||
|
required={isRequired}
|
||||||
|
hint={buildHint(key)}
|
||||||
|
error={fieldErrors[key]}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={asStringArray(config.allow_from).join(", ")}
|
value={asStringArray(value).join(", ")}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange(
|
onChange(
|
||||||
"allow_from",
|
key,
|
||||||
e.target.value
|
e.target.value
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((s: string) => s.trim())
|
.map((s: string) => s.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{config.allow_origins !== undefined &&
|
return (
|
||||||
!hiddenFieldSet.has("allow_origins") && (
|
<Field
|
||||||
<Field
|
key={key}
|
||||||
label={t("channels.field.allowOrigins")}
|
label={formatLabel(key)}
|
||||||
hint={t("channels.form.desc.allowOrigins")}
|
required={isRequired}
|
||||||
>
|
hint={buildHint(key)}
|
||||||
<Input
|
error={fieldErrors[key]}
|
||||||
value={asStringArray(config.allow_origins).join(", ")}
|
>
|
||||||
onChange={(e) =>
|
<Input
|
||||||
onChange(
|
value={String(value ?? "")}
|
||||||
"allow_origins",
|
onChange={(e) => {
|
||||||
e.target.value
|
const v = e.target.value
|
||||||
.split(",")
|
if (typeof config[key] === "number") {
|
||||||
.map((s: string) => s.trim())
|
onChange(key, v === "" ? 0 : Number(v))
|
||||||
.filter(Boolean),
|
} else {
|
||||||
)
|
onChange(key, v)
|
||||||
}
|
|
||||||
placeholder={t("channels.field.allowOriginsPlaceholder")}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{config.allow_token_query !== undefined &&
|
|
||||||
!hiddenFieldSet.has("allow_token_query") && (
|
|
||||||
<SwitchCardField
|
|
||||||
label={formatLabel("allow_token_query")}
|
|
||||||
hint={buildHint("allow_token_query")}
|
|
||||||
checked={asBool(config.allow_token_query)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onChange("allow_token_query", checked)
|
|
||||||
}
|
}
|
||||||
ariaLabel={formatLabel("allow_token_query")}
|
}}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{config.group_trigger !== undefined &&
|
|
||||||
!hiddenFieldSet.has("group_trigger") && (
|
|
||||||
<>
|
|
||||||
<SwitchCardField
|
|
||||||
label={t("channels.field.groupTriggerMentionOnly")}
|
|
||||||
hint={t("channels.form.desc.groupTriggerMentionOnly")}
|
|
||||||
checked={asBool(groupTriggerConfig.mention_only)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onChange("group_trigger", {
|
|
||||||
...groupTriggerConfig,
|
|
||||||
mention_only: checked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ariaLabel={t("channels.field.groupTriggerMentionOnly")}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label={t("channels.field.groupTriggerPrefixes")}
|
|
||||||
hint={t("channels.form.desc.groupTriggerPrefixes")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={asStringArray(groupTriggerConfig.prefixes).join(", ")}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange("group_trigger", {
|
|
||||||
...groupTriggerConfig,
|
|
||||||
prefixes: e.target.value
|
|
||||||
.split(",")
|
|
||||||
.map((s: string) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder={t("channels.field.groupTriggerPrefixes")}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{config.typing !== undefined && !hiddenFieldSet.has("typing") && (
|
|
||||||
<SwitchCardField
|
|
||||||
label={t("channels.field.typingEnabled")}
|
|
||||||
hint={t("channels.form.desc.typingEnabled")}
|
|
||||||
checked={asBool(typingConfig.enabled)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onChange("typing", { ...typingConfig, enabled: checked })
|
|
||||||
}
|
|
||||||
ariaLabel={t("channels.field.typingEnabled")}
|
|
||||||
/>
|
/>
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBasicField = (key: string) => {
|
||||||
|
if (requiredFieldSet.has(key)) return true
|
||||||
|
if (
|
||||||
|
key.endsWith("id") ||
|
||||||
|
key.endsWith("token") ||
|
||||||
|
key.endsWith("secret") ||
|
||||||
|
key.endsWith("url") ||
|
||||||
|
key === "server" ||
|
||||||
|
key === "host" ||
|
||||||
|
key === "port"
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const basicFields = rawFields.filter(isBasicField)
|
||||||
|
const advancedFields = rawFields.filter((key) => !isBasicField(key))
|
||||||
|
|
||||||
|
const hasAdvancedContent =
|
||||||
|
advancedFields.length > 0 ||
|
||||||
|
(config.allow_from !== undefined && !hiddenFieldSet.has("allow_from")) ||
|
||||||
|
(config.allow_origins !== undefined &&
|
||||||
|
!hiddenFieldSet.has("allow_origins")) ||
|
||||||
|
(config.allow_token_query !== undefined &&
|
||||||
|
!hiddenFieldSet.has("allow_token_query")) ||
|
||||||
|
(config.group_trigger !== undefined &&
|
||||||
|
!hiddenFieldSet.has("group_trigger")) ||
|
||||||
|
(config.typing !== undefined && !hiddenFieldSet.has("typing")) ||
|
||||||
|
(config.placeholder !== undefined && !hiddenFieldSet.has("placeholder"))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{basicFields.length > 0 && (
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
|
{basicFields.map(renderField)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{config.placeholder !== undefined &&
|
{hasAdvancedContent && (
|
||||||
!hiddenFieldSet.has("placeholder") && (
|
<Card className="shadow-sm">
|
||||||
<SwitchCardField
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
label={t("channels.field.placeholderEnabled")}
|
{advancedFields.map(renderField)}
|
||||||
hint={t("channels.form.desc.placeholderEnabled")}
|
|
||||||
checked={placeholderEnabled}
|
{config.allow_from !== undefined &&
|
||||||
onCheckedChange={(checked) =>
|
!hiddenFieldSet.has("allow_from") && (
|
||||||
onChange("placeholder", {
|
<Field
|
||||||
...placeholderConfig,
|
label={t("channels.field.allowFrom")}
|
||||||
enabled: checked,
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
})
|
>
|
||||||
}
|
<Input
|
||||||
ariaLabel={t("channels.field.placeholderEnabled")}
|
value={asStringArray(config.allow_from).join(", ")}
|
||||||
>
|
onChange={(e) =>
|
||||||
{placeholderEnabled && (
|
onChange(
|
||||||
<div className="space-y-1">
|
"allow_from",
|
||||||
<Input
|
e.target.value
|
||||||
value={asString(placeholderConfig.text)}
|
.split(",")
|
||||||
onChange={(e) =>
|
.map((s: string) => s.trim())
|
||||||
onChange("placeholder", {
|
.filter(Boolean),
|
||||||
...placeholderConfig,
|
)
|
||||||
text: e.target.value,
|
}
|
||||||
})
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.allow_origins !== undefined &&
|
||||||
|
!hiddenFieldSet.has("allow_origins") && (
|
||||||
|
<Field
|
||||||
|
label={t("channels.field.allowOrigins")}
|
||||||
|
hint={t("channels.form.desc.allowOrigins")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={asStringArray(config.allow_origins).join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(
|
||||||
|
"allow_origins",
|
||||||
|
e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((s: string) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={t("channels.field.allowOriginsPlaceholder")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.allow_token_query !== undefined &&
|
||||||
|
!hiddenFieldSet.has("allow_token_query") && (
|
||||||
|
<div>
|
||||||
|
<SwitchCardField
|
||||||
|
label={formatLabel("allow_token_query")}
|
||||||
|
hint={buildHint("allow_token_query")}
|
||||||
|
checked={asBool(config.allow_token_query)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange("allow_token_query", checked)
|
||||||
|
}
|
||||||
|
ariaLabel={formatLabel("allow_token_query")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.group_trigger !== undefined &&
|
||||||
|
!hiddenFieldSet.has("group_trigger") && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("channels.field.groupTriggerMentionOnly")}
|
||||||
|
hint={t("channels.form.desc.groupTriggerMentionOnly")}
|
||||||
|
checked={asBool(groupTriggerConfig.mention_only)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange("group_trigger", {
|
||||||
|
...groupTriggerConfig,
|
||||||
|
mention_only: checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ariaLabel={t("channels.field.groupTriggerMentionOnly")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("channels.field.groupTriggerPrefixes")}
|
||||||
|
hint={t("channels.form.desc.groupTriggerPrefixes")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={asStringArray(groupTriggerConfig.prefixes).join(
|
||||||
|
", ",
|
||||||
|
)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange("group_trigger", {
|
||||||
|
...groupTriggerConfig,
|
||||||
|
prefixes: e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((s: string) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t("channels.field.groupTriggerPrefixes")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.typing !== undefined && !hiddenFieldSet.has("typing") && (
|
||||||
|
<div>
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("channels.field.typingEnabled")}
|
||||||
|
hint={t("channels.form.desc.typingEnabled")}
|
||||||
|
checked={asBool(typingConfig.enabled)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange("typing", { ...typingConfig, enabled: checked })
|
||||||
}
|
}
|
||||||
placeholder={t("channels.field.placeholderText")}
|
ariaLabel={t("channels.field.typingEnabled")}
|
||||||
aria-label={t("channels.field.placeholderText")}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SwitchCardField>
|
|
||||||
)}
|
{config.placeholder !== undefined &&
|
||||||
|
!hiddenFieldSet.has("placeholder") && (
|
||||||
|
<div>
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("channels.field.placeholderEnabled")}
|
||||||
|
hint={t("channels.form.desc.placeholderEnabled")}
|
||||||
|
checked={placeholderEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange("placeholder", {
|
||||||
|
...placeholderConfig,
|
||||||
|
enabled: checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ariaLabel={t("channels.field.placeholderEnabled")}
|
||||||
|
>
|
||||||
|
{placeholderEnabled && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
value={asString(placeholderConfig.text)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange("placeholder", {
|
||||||
|
...placeholderConfig,
|
||||||
|
text: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t("channels.field.placeholderText")}
|
||||||
|
aria-label={t("channels.field.placeholderText")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SwitchCardField>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput } from "@/components/shared-form"
|
import { Field, KeyInput } from "@/components/shared-form"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface SlackFormProps {
|
interface SlackFormProps {
|
||||||
config: ChannelConfig
|
config: ChannelConfig
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
isEdit: boolean
|
configuredSecrets: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,63 +25,73 @@ function asStringArray(value: unknown): string[] {
|
||||||
export function SlackForm({
|
export function SlackForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
isEdit,
|
configuredSecrets,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
}: SlackFormProps) {
|
}: SlackFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const botTokenExtraHint =
|
|
||||||
isEdit && asString(config.bot_token)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
const appTokenExtraHint =
|
|
||||||
isEdit && asString(config.app_token)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
<Field
|
<Card className="shadow-sm">
|
||||||
label={t("channels.field.botToken")}
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
required
|
<Field
|
||||||
hint={`${t("channels.form.desc.botToken")}${botTokenExtraHint}`}
|
label={t("channels.field.botToken")}
|
||||||
error={fieldErrors.bot_token}
|
required
|
||||||
>
|
hint={t("channels.form.desc.botToken")}
|
||||||
<KeyInput
|
error={fieldErrors.bot_token}
|
||||||
value={asString(config._bot_token)}
|
>
|
||||||
onChange={(v) => onChange("_bot_token", v)}
|
<KeyInput
|
||||||
placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")}
|
value={asString(config._bot_token)}
|
||||||
/>
|
onChange={(v) => onChange("_bot_token", v)}
|
||||||
</Field>
|
placeholder={getSecretInputPlaceholder(
|
||||||
|
configuredSecrets,
|
||||||
|
"bot_token",
|
||||||
|
t("channels.field.secretHintSet"),
|
||||||
|
"xoxb-xxxx",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.appToken")}
|
label={t("channels.field.appToken")}
|
||||||
hint={`${t("channels.form.desc.appToken")}${appTokenExtraHint}`}
|
hint={t("channels.form.desc.appToken")}
|
||||||
>
|
>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={asString(config._app_token)}
|
value={asString(config._app_token)}
|
||||||
onChange={(v) => onChange("_app_token", v)}
|
onChange={(v) => onChange("_app_token", v)}
|
||||||
placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")}
|
placeholder={getSecretInputPlaceholder(
|
||||||
/>
|
configuredSecrets,
|
||||||
</Field>
|
"app_token",
|
||||||
|
t("channels.field.secretHintSet"),
|
||||||
|
"xapp-xxxx",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Field
|
<Card className="shadow-sm">
|
||||||
label={t("channels.field.allowFrom")}
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
<Field
|
||||||
>
|
label={t("channels.field.allowFrom")}
|
||||||
<Input
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
value={asStringArray(config.allow_from).join(", ")}
|
>
|
||||||
onChange={(e) =>
|
<Input
|
||||||
onChange(
|
value={asStringArray(config.allow_from).join(", ")}
|
||||||
"allow_from",
|
onChange={(e) =>
|
||||||
e.target.value
|
onChange(
|
||||||
.split(",")
|
"allow_from",
|
||||||
.map((s: string) => s.trim())
|
e.target.value
|
||||||
.filter(Boolean),
|
.split(",")
|
||||||
)
|
.map((s: string) => s.trim())
|
||||||
}
|
.filter(Boolean),
|
||||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
)
|
||||||
/>
|
}
|
||||||
</Field>
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
|
||||||
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface TelegramFormProps {
|
interface TelegramFormProps {
|
||||||
config: ChannelConfig
|
config: ChannelConfig
|
||||||
onChange: (key: string, value: unknown) => void
|
onChange: (key: string, value: unknown) => void
|
||||||
isEdit: boolean
|
configuredSecrets: string[]
|
||||||
fieldErrors?: Record<string, string>
|
fieldErrors?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,113 +36,124 @@ function asBool(value: unknown): boolean {
|
||||||
export function TelegramForm({
|
export function TelegramForm({
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
isEdit,
|
configuredSecrets,
|
||||||
fieldErrors = {},
|
fieldErrors = {},
|
||||||
}: TelegramFormProps) {
|
}: TelegramFormProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const typingConfig = asRecord(config.typing)
|
const typingConfig = asRecord(config.typing)
|
||||||
const placeholderConfig = asRecord(config.placeholder)
|
const placeholderConfig = asRecord(config.placeholder)
|
||||||
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
const placeholderEnabled = asBool(placeholderConfig.enabled)
|
||||||
const tokenExtraHint =
|
|
||||||
isEdit && asString(config.token)
|
|
||||||
? ` ${t("channels.field.secretHintSet")}`
|
|
||||||
: ""
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
<Field
|
<Card className="shadow-sm">
|
||||||
label={t("channels.field.token")}
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
required
|
<Field
|
||||||
hint={`${t("channels.form.desc.token")}${tokenExtraHint}`}
|
label={t("channels.field.token")}
|
||||||
error={fieldErrors.token}
|
required
|
||||||
>
|
hint={t("channels.form.desc.token")}
|
||||||
<KeyInput
|
error={fieldErrors.token}
|
||||||
value={asString(config._token)}
|
>
|
||||||
onChange={(v) => onChange("_token", v)}
|
<KeyInput
|
||||||
placeholder={maskedSecretPlaceholder(
|
value={asString(config._token)}
|
||||||
config.token,
|
onChange={(v) => onChange("_token", v)}
|
||||||
t("channels.field.tokenPlaceholder"),
|
placeholder={getSecretInputPlaceholder(
|
||||||
)}
|
configuredSecrets,
|
||||||
/>
|
"token",
|
||||||
</Field>
|
t("channels.field.secretHintSet"),
|
||||||
|
t("channels.field.tokenPlaceholder"),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("channels.field.baseUrl")}
|
label={t("channels.field.baseUrl")}
|
||||||
hint={t("channels.form.desc.baseUrl")}
|
hint={t("channels.form.desc.baseUrl")}
|
||||||
>
|
>
|
||||||
<Input
|
|
||||||
value={asString(config.base_url)}
|
|
||||||
onChange={(e) => onChange("base_url", e.target.value)}
|
|
||||||
placeholder="https://api.telegram.org"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
label={t("channels.field.proxy")}
|
|
||||||
hint={t("channels.form.desc.proxy")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={asString(config.proxy)}
|
|
||||||
onChange={(e) => onChange("proxy", e.target.value)}
|
|
||||||
placeholder="http://127.0.0.1:7890"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field
|
|
||||||
label={t("channels.field.allowFrom")}
|
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
value={asStringArray(config.allow_from).join(", ")}
|
|
||||||
onChange={(e) =>
|
|
||||||
onChange(
|
|
||||||
"allow_from",
|
|
||||||
e.target.value
|
|
||||||
.split(",")
|
|
||||||
.map((s: string) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<SwitchCardField
|
|
||||||
label={t("channels.field.typingEnabled")}
|
|
||||||
hint={t("channels.form.desc.typingEnabled")}
|
|
||||||
checked={asBool(typingConfig.enabled)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onChange("typing", { ...typingConfig, enabled: checked })
|
|
||||||
}
|
|
||||||
ariaLabel={t("channels.field.typingEnabled")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SwitchCardField
|
|
||||||
label={t("channels.field.placeholderEnabled")}
|
|
||||||
hint={t("channels.form.desc.placeholderEnabled")}
|
|
||||||
checked={placeholderEnabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onChange("placeholder", {
|
|
||||||
...placeholderConfig,
|
|
||||||
enabled: checked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
ariaLabel={t("channels.field.placeholderEnabled")}
|
|
||||||
>
|
|
||||||
{placeholderEnabled && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Input
|
<Input
|
||||||
value={asString(placeholderConfig.text)}
|
value={asString(config.base_url)}
|
||||||
|
onChange={(e) => onChange("base_url", e.target.value)}
|
||||||
|
placeholder="https://api.telegram.org"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
|
<Field
|
||||||
|
label={t("channels.field.proxy")}
|
||||||
|
hint={t("channels.form.desc.proxy")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={asString(config.proxy)}
|
||||||
|
onChange={(e) => onChange("proxy", e.target.value)}
|
||||||
|
placeholder="http://127.0.0.1:7890"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label={t("channels.field.allowFrom")}
|
||||||
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={asStringArray(config.allow_from).join(", ")}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onChange("placeholder", {
|
onChange(
|
||||||
...placeholderConfig,
|
"allow_from",
|
||||||
text: e.target.value,
|
e.target.value
|
||||||
})
|
.split(",")
|
||||||
|
.map((s: string) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
placeholder={t("channels.field.placeholderText")}
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
aria-label={t("channels.field.placeholderText")}
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("channels.field.typingEnabled")}
|
||||||
|
hint={t("channels.form.desc.typingEnabled")}
|
||||||
|
checked={asBool(typingConfig.enabled)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange("typing", { ...typingConfig, enabled: checked })
|
||||||
|
}
|
||||||
|
ariaLabel={t("channels.field.typingEnabled")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</SwitchCardField>
|
<div>
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("channels.field.placeholderEnabled")}
|
||||||
|
hint={t("channels.form.desc.placeholderEnabled")}
|
||||||
|
checked={placeholderEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange("placeholder", {
|
||||||
|
...placeholderConfig,
|
||||||
|
enabled: checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ariaLabel={t("channels.field.placeholderEnabled")}
|
||||||
|
>
|
||||||
|
{placeholderEnabled && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
value={asString(placeholderConfig.text)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange("placeholder", {
|
||||||
|
...placeholderConfig,
|
||||||
|
text: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={t("channels.field.placeholderText")}
|
||||||
|
aria-label={t("channels.field.placeholderText")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SwitchCardField>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ import { useTranslation } from "react-i18next"
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
import type { ChannelConfig } from "@/api/channels"
|
||||||
import { patchAppConfig, pollWecomFlow, startWecomFlow } from "@/api/channels"
|
import { patchAppConfig, pollWecomFlow, startWecomFlow } from "@/api/channels"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
|
||||||
type BindingState =
|
type BindingState =
|
||||||
|
|
@ -329,39 +336,32 @@ export function WecomForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
<div className="bg-card text-card-foreground border-border/60 flex items-center justify-between rounded-xl border px-6 py-4 shadow-sm">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<p className="text-sm font-medium">{t("channels.page.enableLabel")}</p>
|
||||||
<div>
|
<div className="flex flex-col items-end gap-2">
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{t("channels.page.enableLabel")}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
||||||
{isBound
|
|
||||||
? t("channels.wecom.enableDesc")
|
|
||||||
: t("channels.wecom.enableBindFirst")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
disabled={!isBound || toggleSaving}
|
disabled={!isBound || toggleSaving}
|
||||||
onCheckedChange={(checked) => void handleEnabledChange(checked)}
|
onCheckedChange={(checked) => void handleEnabledChange(checked)}
|
||||||
/>
|
/>
|
||||||
|
{toggleError && (
|
||||||
|
<p className="text-destructive max-w-60 text-right text-xs leading-normal">
|
||||||
|
{toggleError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{toggleError && (
|
|
||||||
<p className="text-destructive mt-2 text-sm">{toggleError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-border/60 bg-muted/30 rounded-xl border">
|
<Card className="shadow-sm">
|
||||||
<div className="border-border/60 border-b px-4 py-3">
|
<CardHeader className="border-border/60 border-b px-6">
|
||||||
<p className="text-sm font-medium">{t("channels.wecom.bindTitle")}</p>
|
<CardTitle className="text-foreground text-sm font-medium">
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
{t("channels.wecom.bindTitle")}
|
||||||
{t("channels.wecom.bindDesc")}
|
</CardTitle>
|
||||||
</p>
|
<CardDescription>{t("channels.wecom.bindDesc")}</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
{renderBindSection()}
|
<CardContent className="p-0">{renderBindSection()}</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@ import type { ChannelConfig } from "@/api/channels"
|
||||||
import { pollWeixinFlow, startWeixinFlow } from "@/api/channels"
|
import { pollWeixinFlow, startWeixinFlow } from "@/api/channels"
|
||||||
import { Field } from "@/components/shared-form"
|
import { Field } from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
type BindingState =
|
type BindingState =
|
||||||
|
|
@ -301,51 +308,50 @@ export function WeixinForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
{/* QR Bind Section */}
|
<Card className="shadow-sm">
|
||||||
<div className="border-border/60 bg-muted/30 rounded-xl border">
|
<CardHeader className="border-border/60 border-b px-6">
|
||||||
<div className="border-border/60 border-b px-4 py-3">
|
<CardTitle className="text-foreground text-sm font-medium">
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{t("channels.weixin.bindTitle")}
|
{t("channels.weixin.bindTitle")}
|
||||||
</p>
|
</CardTitle>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<CardDescription>{t("channels.weixin.bindDesc")}</CardDescription>
|
||||||
{t("channels.weixin.bindDesc")}
|
</CardHeader>
|
||||||
</p>
|
<CardContent className="p-0">{renderBindSection()}</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
{renderBindSection()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* allow_from */}
|
<Card className="shadow-sm">
|
||||||
<Field
|
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
|
||||||
label={t("channels.field.allowFrom")}
|
<Field
|
||||||
hint={t("channels.form.desc.allowFrom")}
|
label={t("channels.field.allowFrom")}
|
||||||
>
|
hint={t("channels.form.desc.allowFrom")}
|
||||||
<Input
|
>
|
||||||
value={asStringArray(config.allow_from).join(", ")}
|
<Input
|
||||||
onChange={(e) =>
|
value={asStringArray(config.allow_from).join(", ")}
|
||||||
onChange(
|
onChange={(e) =>
|
||||||
"allow_from",
|
onChange(
|
||||||
e.target.value
|
"allow_from",
|
||||||
.split(",")
|
e.target.value
|
||||||
.map((s: string) => s.trim())
|
.split(",")
|
||||||
.filter(Boolean),
|
.map((s: string) => s.trim())
|
||||||
)
|
.filter(Boolean),
|
||||||
}
|
)
|
||||||
placeholder={t("channels.field.allowFromPlaceholder")}
|
}
|
||||||
/>
|
placeholder={t("channels.field.allowFromPlaceholder")}
|
||||||
</Field>
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
{/* proxy */}
|
<Field
|
||||||
<Field
|
label={t("channels.field.proxy")}
|
||||||
label={t("channels.field.proxy")}
|
hint={t("channels.form.desc.proxy")}
|
||||||
hint={t("channels.form.desc.proxy")}
|
>
|
||||||
>
|
<Input
|
||||||
<Input
|
value={asString(config.proxy)}
|
||||||
value={asString(config.proxy)}
|
onChange={(e) => onChange("proxy", e.target.value)}
|
||||||
onChange={(e) => onChange("proxy", e.target.value)}
|
placeholder="http://localhost:7890"
|
||||||
placeholder="http://localhost:7890"
|
/>
|
||||||
/>
|
</Field>
|
||||||
</Field>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,23 +34,28 @@ export function Field({
|
||||||
}: FieldProps) {
|
}: FieldProps) {
|
||||||
if (layout === "setting-row") {
|
if (layout === "setting-row") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_minmax(240px,320px)] md:items-center md:gap-6">
|
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[280px_minmax(0,1fr)] md:items-center md:gap-8">
|
||||||
<div className="max-w-full space-y-1 md:max-w-[clamp(18rem,42vw,28rem)]">
|
<div className="w-full min-w-0">
|
||||||
<FieldLabel>
|
<FieldLabel className="leading-relaxed break-words whitespace-normal">
|
||||||
{label}
|
{label}
|
||||||
{required && <span className="text-destructive ml-1">*</span>}
|
{required && <span className="text-destructive ml-1">*</span>}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
{hint && (
|
{hint && (
|
||||||
<FieldDescription className="text-xs leading-normal break-words">
|
<FieldDescription className="mt-1 text-xs leading-relaxed break-words whitespace-normal">
|
||||||
{hint}
|
{hint}
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("w-full md:justify-self-center", controlClassName)}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full md:max-w-[28rem] md:justify-self-end",
|
||||||
|
controlClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2">
|
<FieldDescription className="text-destructive text-xs leading-normal md:col-start-2 md:justify-self-end">
|
||||||
{error}
|
{error}
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
)}
|
)}
|
||||||
|
|
@ -125,6 +130,7 @@ interface SwitchCardFieldProps {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
layout?: FieldLayout
|
layout?: FieldLayout
|
||||||
|
transparent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SwitchCardField({
|
export function SwitchCardField({
|
||||||
|
|
@ -137,19 +143,22 @@ export function SwitchCardField({
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
layout = "default",
|
layout = "default",
|
||||||
|
transparent,
|
||||||
}: SwitchCardFieldProps) {
|
}: SwitchCardFieldProps) {
|
||||||
if (layout === "setting-row") {
|
if (layout === "setting-row") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[minmax(0,1fr)_auto] md:items-center md:gap-6">
|
<div className="flex flex-col gap-4 py-4 md:grid md:grid-cols-[280px_minmax(0,1fr)] md:items-center md:gap-8">
|
||||||
<div className="max-w-full min-w-0 md:max-w-[clamp(18rem,42vw,28rem)]">
|
<div className="w-full min-w-0">
|
||||||
<p className="text-sm font-medium">{label}</p>
|
<p className="text-sm leading-relaxed font-medium break-words whitespace-normal">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
{hint && (
|
{hint && (
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs leading-normal break-words">
|
<p className="text-muted-foreground mt-1 text-xs leading-relaxed break-words whitespace-normal">
|
||||||
{hint}
|
{hint}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center md:justify-self-center">
|
<div className="flex items-center md:justify-self-end">
|
||||||
<Switch
|
<Switch
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={onCheckedChange}
|
onCheckedChange={onCheckedChange}
|
||||||
|
|
@ -157,9 +166,13 @@ export function SwitchCardField({
|
||||||
aria-label={ariaLabel ?? label}
|
aria-label={ariaLabel ?? label}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{children && <div className="md:col-start-2">{children}</div>}
|
{children && (
|
||||||
|
<div className="mt-1 flex w-full justify-end md:col-start-2">
|
||||||
|
<div className="w-full md:max-w-[28rem]">{children}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-destructive text-xs leading-normal md:col-start-2">
|
<p className="text-destructive text-xs leading-normal md:col-start-2 md:justify-self-end">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -168,7 +181,11 @@ export function SwitchCardField({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border/60 bg-background rounded-lg border px-4 py-3">
|
<div
|
||||||
|
className={cn(
|
||||||
|
transparent ? "py-1" : "border-border/60 rounded-lg border px-4 py-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium">{label}</p>
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
|
@ -185,7 +202,7 @@ export function SwitchCardField({
|
||||||
aria-label={ariaLabel ?? label}
|
aria-label={ariaLabel ?? label}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{children && <div className="mt-3">{children}</div>}
|
{children && <div className="mt-4">{children}</div>}
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-destructive mt-2 text-xs leading-normal">{error}</p>
|
<p className="text-destructive mt-2 text-xs leading-normal">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -244,10 +244,6 @@
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"loadError": "Failed to load channels",
|
"loadError": "Failed to load channels",
|
||||||
"edit": "Configure {{name}}",
|
|
||||||
"status": {
|
|
||||||
"configured": "Configured"
|
|
||||||
},
|
|
||||||
"name": {
|
"name": {
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"discord": "Discord",
|
"discord": "Discord",
|
||||||
|
|
@ -267,8 +263,6 @@
|
||||||
"weixin": "WeChat"
|
"weixin": "WeChat"
|
||||||
},
|
},
|
||||||
"weixin": {
|
"weixin": {
|
||||||
"warningTitle": "Testing phase, use with caution",
|
|
||||||
"warningDesc": "The WeChat channel is still experimental and may carry a risk of account suspension. Use it only if you understand and accept the risk.",
|
|
||||||
"bindTitle": "WeChat Account Binding",
|
"bindTitle": "WeChat Account Binding",
|
||||||
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
|
"bindDesc": "Scan the QR code with WeChat to bind your personal account.",
|
||||||
"bind": "Bind WeChat",
|
"bind": "Bind WeChat",
|
||||||
|
|
@ -286,8 +280,6 @@
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"bindTitle": "WeCom Binding",
|
"bindTitle": "WeCom Binding",
|
||||||
"bindDesc": "Scan the QR code with WeCom to bind your AI Bot.",
|
"bindDesc": "Scan the QR code with WeCom to bind your AI Bot.",
|
||||||
"enableDesc": "Once bound, you can enable or disable the channel here.",
|
|
||||||
"enableBindFirst": "Bind the bot first, then enable the channel.",
|
|
||||||
"bind": "Bind WeCom",
|
"bind": "Bind WeCom",
|
||||||
"rebind": "Re-bind",
|
"rebind": "Re-bind",
|
||||||
"bound": "WeCom Bound",
|
"bound": "WeCom Bound",
|
||||||
|
|
@ -329,7 +321,6 @@
|
||||||
"notFound": "Channel \"{{name}}\" is not supported.",
|
"notFound": "Channel \"{{name}}\" is not supported.",
|
||||||
"saveSuccess": "Channel configuration saved.",
|
"saveSuccess": "Channel configuration saved.",
|
||||||
"saveError": "Failed to save channel configuration",
|
"saveError": "Failed to save channel configuration",
|
||||||
"enabled": "enabled",
|
|
||||||
"docLink": "Documentation",
|
"docLink": "Documentation",
|
||||||
"enableLabel": "Enable channel",
|
"enableLabel": "Enable channel",
|
||||||
"restartRequiredTitle": "Gateway restart required",
|
"restartRequiredTitle": "Gateway restart required",
|
||||||
|
|
|
||||||
|
|
@ -244,10 +244,6 @@
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"loadError": "加载频道列表失败",
|
"loadError": "加载频道列表失败",
|
||||||
"edit": "配置 {{name}}",
|
|
||||||
"status": {
|
|
||||||
"configured": "已配置"
|
|
||||||
},
|
|
||||||
"name": {
|
"name": {
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"discord": "Discord",
|
"discord": "Discord",
|
||||||
|
|
@ -267,8 +263,6 @@
|
||||||
"weixin": "微信"
|
"weixin": "微信"
|
||||||
},
|
},
|
||||||
"weixin": {
|
"weixin": {
|
||||||
"warningTitle": "测试阶段,请谨慎使用",
|
|
||||||
"warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。",
|
|
||||||
"bindTitle": "微信账号绑定",
|
"bindTitle": "微信账号绑定",
|
||||||
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
|
"bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。",
|
||||||
"bind": "绑定微信",
|
"bind": "绑定微信",
|
||||||
|
|
@ -286,8 +280,6 @@
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"bindTitle": "企业微信绑定",
|
"bindTitle": "企业微信绑定",
|
||||||
"bindDesc": "使用企业微信扫描二维码以绑定您的 AI Bot。",
|
"bindDesc": "使用企业微信扫描二维码以绑定您的 AI Bot。",
|
||||||
"enableDesc": "绑定后可在这里直接启用或停用频道。",
|
|
||||||
"enableBindFirst": "请先完成绑定,然后再启用频道。",
|
|
||||||
"bind": "绑定企业微信",
|
"bind": "绑定企业微信",
|
||||||
"rebind": "重新绑定",
|
"rebind": "重新绑定",
|
||||||
"bound": "企业微信已绑定",
|
"bound": "企业微信已绑定",
|
||||||
|
|
@ -323,13 +315,12 @@
|
||||||
"allowOrigins": "允许来源域名",
|
"allowOrigins": "允许来源域名",
|
||||||
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
|
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
|
||||||
"secretPlaceholder": "输入密钥",
|
"secretPlaceholder": "输入密钥",
|
||||||
"secretHintSet": "已设置密钥,留空表示不修改。"
|
"secretHintSet": "配置已保存,留空表示不修改"
|
||||||
},
|
},
|
||||||
"page": {
|
"page": {
|
||||||
"notFound": "不支持频道“{{name}}”。",
|
"notFound": "不支持频道“{{name}}”。",
|
||||||
"saveSuccess": "频道配置已保存。",
|
"saveSuccess": "频道配置已保存。",
|
||||||
"saveError": "保存频道配置失败",
|
"saveError": "保存频道配置失败",
|
||||||
"enabled": "已启用",
|
|
||||||
"docLink": "配置文档",
|
"docLink": "配置文档",
|
||||||
"enableLabel": "启用频道",
|
"enableLabel": "启用频道",
|
||||||
"restartRequiredTitle": "需要重启服务",
|
"restartRequiredTitle": "需要重启服务",
|
||||||
|
|
@ -337,58 +328,58 @@
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"desc": {
|
"desc": {
|
||||||
"token": "机器人访问令牌,用于连接平台 API。",
|
"token": "机器人访问令牌,用于连接平台 API",
|
||||||
"botToken": "Bot Token,用于发送与接收消息。",
|
"botToken": "Bot Token,用于发送与接收消息",
|
||||||
"appToken": "App Token,用于 Socket 模式连接。",
|
"appToken": "App Token,用于 Socket 模式连接。",
|
||||||
"appId": "应用唯一标识,用于平台鉴权。",
|
"appId": "应用唯一标识,用于平台鉴权",
|
||||||
"appSecret": "应用密钥,用于请求签名和鉴权。",
|
"appSecret": "应用密钥,用于请求签名和鉴权",
|
||||||
"verificationToken": "事件回调验证令牌。",
|
"verificationToken": "事件回调验证令牌",
|
||||||
"encryptKey": "消息加密密钥,用于解密回调内容。",
|
"encryptKey": "消息加密密钥,用于解密回调内容",
|
||||||
"baseUrl": "平台 API 地址,默认使用官方地址。",
|
"baseUrl": "平台 API 地址,默认使用官方地址",
|
||||||
"proxy": "HTTP 代理地址,用于网络访问。",
|
"proxy": "HTTP 代理地址,用于网络访问",
|
||||||
"mentionOnly": "在群聊中仅当明确提及时才响应。",
|
"mentionOnly": "在群聊中仅当明确提及时才响应",
|
||||||
"typingEnabled": "在生成回复时显示“正在输入”状态。",
|
"typingEnabled": "在生成回复时显示“正在输入”状态",
|
||||||
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息。",
|
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息",
|
||||||
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应。",
|
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应",
|
||||||
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔。",
|
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔",
|
||||||
"isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)。",
|
"isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)",
|
||||||
"allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔。",
|
"allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔",
|
||||||
"allowOrigins": "允许访问的来源域名,多个值用逗号分隔。",
|
"allowOrigins": "允许访问的来源域名,多个值用逗号分隔",
|
||||||
"wsUrl": "WebSocket 服务地址。",
|
"wsUrl": "WebSocket 服务地址",
|
||||||
"reconnectInterval": "断线后的重连间隔(秒)。",
|
"reconnectInterval": "断线后的重连间隔(秒)",
|
||||||
"bridgeUrl": "桥接服务地址。",
|
"bridgeUrl": "桥接服务地址",
|
||||||
"sessionStorePath": "本地会话存储目录路径。",
|
"sessionStorePath": "本地会话存储目录路径",
|
||||||
"useNative": "是否使用原生客户端模式连接。",
|
"useNative": "是否使用原生客户端模式连接",
|
||||||
"host": "服务监听主机地址。",
|
"host": "服务监听主机地址",
|
||||||
"port": "服务监听端口。",
|
"port": "服务监听端口",
|
||||||
"homeserver": "Matrix homeserver 地址。",
|
"homeserver": "Matrix homeserver 地址",
|
||||||
"userId": "账号 ID。",
|
"userId": "账号 ID",
|
||||||
"deviceId": "设备 ID。",
|
"deviceId": "设备 ID",
|
||||||
"joinOnInvite": "收到邀请时是否自动加入房间。",
|
"joinOnInvite": "收到邀请时是否自动加入房间",
|
||||||
"clientId": "应用客户端 ID,用于平台鉴权。",
|
"clientId": "应用客户端 ID,用于平台鉴权",
|
||||||
"corpId": "企业 ID。",
|
"corpId": "企业 ID",
|
||||||
"agentId": "企业应用 Agent ID。",
|
"agentId": "企业应用 Agent ID",
|
||||||
"webhookUrl": "Webhook 完整地址。",
|
"webhookUrl": "Webhook 完整地址",
|
||||||
"webhookHost": "Webhook 监听主机。",
|
"webhookHost": "Webhook 监听主机",
|
||||||
"webhookPort": "Webhook 监听端口。",
|
"webhookPort": "Webhook 监听端口",
|
||||||
"webhookPath": "Webhook 路径。",
|
"webhookPath": "Webhook 路径",
|
||||||
"replyTimeout": "回复超时时间(秒)。",
|
"replyTimeout": "回复超时时间(秒)",
|
||||||
"maxSteps": "最大步骤数。",
|
"maxSteps": "最大步骤数",
|
||||||
"welcomeMessage": "新会话欢迎语内容。",
|
"welcomeMessage": "新会话欢迎语内容",
|
||||||
"allowTokenQuery": "是否允许 URL Query 方式传递 Token。",
|
"allowTokenQuery": "是否允许 URL Query 方式传递 Token",
|
||||||
"pingInterval": "连接心跳间隔(秒)。",
|
"pingInterval": "连接心跳间隔(秒)",
|
||||||
"readTimeout": "读取超时时间(秒)。",
|
"readTimeout": "读取超时时间(秒)",
|
||||||
"writeTimeout": "写入超时时间(秒)。",
|
"writeTimeout": "写入超时时间(秒)",
|
||||||
"maxConnections": "最大并发连接数。",
|
"maxConnections": "最大并发连接数",
|
||||||
"server": "IRC 服务器地址。",
|
"server": "IRC 服务器地址",
|
||||||
"tls": "是否启用 TLS 连接。",
|
"tls": "是否启用 TLS 连接",
|
||||||
"nick": "机器人昵称。",
|
"nick": "机器人昵称",
|
||||||
"user": "IRC 用户名。",
|
"user": "IRC 用户名",
|
||||||
"realName": "显示名称。",
|
"realName": "显示名称",
|
||||||
"channels": "要加入的 IRC 频道列表。",
|
"channels": "要加入的 IRC 频道列表",
|
||||||
"requestCaps": "连接时请求的 IRC 扩展能力列表。",
|
"requestCaps": "连接时请求的 IRC 扩展能力列表",
|
||||||
"maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传。",
|
"maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传",
|
||||||
"genericField": "用于配置{{field}}。"
|
"genericField": "用于配置{{field}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue