feat(web): support list editing for channel array fields (#2595)

Add reusable channel array list controls and parsing utilities for channel forms.
Normalize channel string-array payloads in the backend, including pasted values,
numeric IDs, hidden characters, duplicates, and empty clears.
Also allow FlexibleStringSlice to unmarshal null values and cover the new behavior
with backend and config tests.
This commit is contained in:
wenjie
2026-04-21 16:04:28 +08:00
committed by GitHub
parent dcb4b67e00
commit ba6992234f
15 changed files with 1025 additions and 195 deletions
@@ -0,0 +1,180 @@
import { IconX } from "@tabler/icons-react"
import {
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useTranslation } from "react-i18next"
import {
mergeUniqueStringItems,
parseConservativeStringListInput,
} from "@/components/channels/channel-array-utils"
import { Field } from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
type StringListParser = (raw: string) => string[]
export type ArrayFieldFlusher = () => string[] | null
type RegisterArrayFieldFlusher = (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
function areStringArraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false
}
return left.every((item, index) => item === right[index])
}
interface ChannelArrayListFieldProps {
label: string
hint?: string
error?: string
required?: boolean
value: string[]
onChange: (value: string[]) => void
placeholder?: string
parser?: StringListParser
fieldPath?: string
registerFlusher?: RegisterArrayFieldFlusher
resetVersion?: number
}
export function ChannelArrayListField({
label,
hint,
error,
required,
value,
onChange,
placeholder,
parser = parseConservativeStringListInput,
fieldPath,
registerFlusher,
resetVersion,
}: ChannelArrayListFieldProps) {
const { t } = useTranslation()
const [draft, setDraft] = useState("")
const draftRef = useRef("")
const valueRef = useRef(value)
const localValueRef = useRef(value)
const parserRef = useRef(parser)
const onChangeRef = useRef(onChange)
useEffect(() => {
valueRef.current = value
localValueRef.current = value
}, [value])
useEffect(() => {
draftRef.current = ""
setDraft("")
}, [resetVersion])
useEffect(() => {
parserRef.current = parser
}, [parser])
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
const commitDraft = useCallback(() => {
const rawDraft = draftRef.current
if (rawDraft.trim() === "") {
if (!areStringArraysEqual(localValueRef.current, valueRef.current)) {
return localValueRef.current
}
draftRef.current = ""
setDraft("")
return null
}
draftRef.current = ""
setDraft("")
const nextItems = parserRef.current(rawDraft)
if (nextItems.length === 0) {
return null
}
const mergedItems = mergeUniqueStringItems(localValueRef.current, nextItems)
localValueRef.current = mergedItems
onChangeRef.current(mergedItems)
return mergedItems
}, [])
useEffect(() => {
if (!fieldPath || !registerFlusher) {
return
}
registerFlusher(fieldPath, commitDraft)
return () => registerFlusher(fieldPath, null)
}, [commitDraft, fieldPath, registerFlusher])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== "Enter") {
return
}
event.preventDefault()
commitDraft()
}
const handleRemove = (index: number) => {
const nextValue = value.filter((_, itemIndex) => itemIndex !== index)
localValueRef.current = nextValue
onChangeRef.current(nextValue)
}
return (
<Field label={label} hint={hint} error={error} required={required}>
<div className="space-y-3">
{value.length > 0 && (
<div className="flex flex-wrap gap-2">
{value.map((item, index) => (
<span
key={`${item}-${index}`}
className="bg-muted text-foreground inline-flex max-w-full items-center gap-1 rounded-md border px-2 py-1 text-xs"
>
<span className="break-all">{item}</span>
<button
type="button"
onClick={() => handleRemove(index)}
className="text-muted-foreground hover:text-foreground shrink-0 transition-colors"
aria-label={t("channels.field.removeListItem", {
value: item,
})}
>
<IconX className="size-3" />
</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<Input
value={draft}
onChange={(event) => {
const nextDraft = event.target.value
draftRef.current = nextDraft
setDraft(nextDraft)
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
/>
<Button
type="button"
size="sm"
onClick={commitDraft}
disabled={draft.trim() === ""}
>
{t("common.confirm")}
</Button>
</div>
</div>
</Field>
)
}
@@ -0,0 +1,72 @@
const ALLOW_FROM_HIDDEN_CHARS_RE =
/\u200b|\u200c|\u200d|\u200e|\u200f|\u202a|\u202b|\u202c|\u202d|\u202e|\u2060|\u2061|\u2062|\u2063|\u2064|\u2066|\u2067|\u2068|\u2069|\ufeff/g
function normalizeStringListItems(
items: string[],
options: { stripHiddenChars?: boolean } = {},
): string[] {
const result: string[] = []
const seen = new Set<string>()
for (const item of items) {
const normalized = options.stripHiddenChars
? item.replace(ALLOW_FROM_HIDDEN_CHARS_RE, "")
: item
const trimmed = normalized.trim()
if (trimmed.length === 0 || seen.has(trimmed)) {
continue
}
seen.add(trimmed)
result.push(trimmed)
}
return result
}
function splitStringList(
raw: string,
separators: RegExp,
options: { stripHiddenChars?: boolean } = {},
): string[] {
if (raw.trim() === "") {
return []
}
return normalizeStringListItems(raw.split(separators), options)
}
export function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value.filter((item): item is string => typeof item === "string")
}
export function parseAllowFromInput(raw: string): string[] {
return splitStringList(raw, /[,\uFF0C、;\n\r\t]+/, {
stripHiddenChars: true,
})
}
export function parseConservativeStringListInput(raw: string): string[] {
return splitStringList(raw, /[,\uFF0C\n\r\t]+/)
}
export function normalizeAllowFromValues(value: unknown): string[] {
return normalizeStringListItems(asStringArray(value), {
stripHiddenChars: true,
})
}
export function mergeUniqueStringItems(
currentItems: string[],
nextItems: string[],
): string[] {
return normalizeStringListItems([...currentItems, ...nextItems])
}
export function serializeStringArrayForSubmit(value: unknown): unknown {
if (!Array.isArray(value)) {
return value
}
return normalizeStringListItems(asStringArray(value)).join("\n")
}
@@ -9,6 +9,11 @@ import {
getChannelsCatalog,
patchAppConfig,
} from "@/api/channels"
import { type ArrayFieldFlusher } from "@/components/channels/channel-array-list-field"
import {
normalizeAllowFromValues,
serializeStringArrayForSubmit,
} from "@/components/channels/channel-array-utils"
import {
SECRET_FIELD_MAP,
buildEditConfig,
@@ -48,6 +53,43 @@ function asBool(value: unknown): boolean {
return value === true
}
function setRecordValueByPath(
source: Record<string, unknown>,
pathSegments: string[],
value: unknown,
): Record<string, unknown> {
const [segment, ...rest] = pathSegments
if (!segment) {
return source
}
if (rest.length === 0) {
return { ...source, [segment]: value }
}
return {
...source,
[segment]: setRecordValueByPath(asRecord(source[segment]), rest, value),
}
}
function setConfigValueByPath(
source: ChannelConfig,
fieldPath: string,
value: unknown,
): ChannelConfig {
return setRecordValueByPath(source, fieldPath.split("."), value)
}
function serializeGroupTriggerForSubmit(value: unknown): unknown {
const groupTrigger = asRecord(value)
if (Object.keys(groupTrigger).length === 0) {
return value
}
return {
...groupTrigger,
prefixes: serializeStringArrayForSubmit(groupTrigger.prefixes),
}
}
const CHANNEL_COMMON_CONFIG_KEYS = new Set([
"allow_from",
"group_trigger",
@@ -82,12 +124,20 @@ function buildSavePayload(
if (key.startsWith("_")) continue
if (key === "enabled") continue
if (CHANNEL_COMMON_CONFIG_KEYS.has(key)) {
payload[key] = value
if (key === "allow_from") {
payload[key] = serializeStringArrayForSubmit(
normalizeAllowFromValues(value),
)
} else if (key === "group_trigger") {
payload[key] = serializeGroupTriggerForSubmit(value)
} else {
payload[key] = value
}
continue
}
if (isSecretField(key)) continue
settings[key] = value
settings[key] = serializeStringArrayForSubmit(value)
}
for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) {
@@ -244,6 +294,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
const [editConfig, setEditConfig] = useState<ChannelConfig>({})
const [configuredSecrets, setConfiguredSecrets] = useState<string[]>([])
const [enabled, setEnabled] = useState(false)
const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0)
const arrayFieldFlushersRef = useRef(new Map<string, ArrayFieldFlusher>())
const loadData = useCallback(
async (silent = false) => {
@@ -302,11 +354,6 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
previousGatewayStatusRef.current = gatewayState
}, [gatewayState, loadData])
const savePayload = useMemo(() => {
if (!channel) return null
return buildSavePayload(channel, editConfig, enabled)
}, [channel, editConfig, enabled])
const configured = useMemo(() => {
if (!channel) return false
return isConfigured(channel, editConfig, configuredSecrets)
@@ -362,20 +409,52 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
})
}, [])
const registerArrayFieldFlusher = useCallback(
(fieldPath: string, flusher: ArrayFieldFlusher | null) => {
if (flusher) {
arrayFieldFlushersRef.current.set(fieldPath, flusher)
return
}
arrayFieldFlushersRef.current.delete(fieldPath)
},
[],
)
const flushPendingArrayFieldDrafts = useCallback(
(sourceConfig: ChannelConfig): ChannelConfig => {
let nextConfig = sourceConfig
for (const [fieldPath, flusher] of arrayFieldFlushersRef.current) {
const flushedValue = flusher()
if (flushedValue === null) {
continue
}
nextConfig = setConfigValueByPath(nextConfig, fieldPath, flushedValue)
}
return nextConfig
},
[],
)
const handleReset = () => {
if (!channel) return
setEditConfig(buildEditConfig(channel.name, baseConfig))
setEnabled(asBool(baseConfig.enabled))
setServerError("")
setFieldErrors({})
setArrayFieldResetVersion((version) => version + 1)
}
const handleSave = async () => {
if (!channel || !savePayload) return
if (!channel) return
const preparedEditConfig = flushPendingArrayFieldDrafts(editConfig)
if (preparedEditConfig !== editConfig) {
setEditConfig(preparedEditConfig)
}
const missingRequiredFields = requiredKeys.filter((key) =>
isMissingRequiredValue(
getFieldValueForValidation(editConfig, configuredSecrets, key),
getFieldValueForValidation(preparedEditConfig, configuredSecrets, key),
),
)
if (missingRequiredFields.length > 0) {
@@ -393,6 +472,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
setServerError("")
setFieldErrors({})
try {
const savePayload = buildSavePayload(channel, preparedEditConfig, enabled)
await patchAppConfig({
channel_list: {
[channel.config_key]: savePayload,
@@ -462,6 +542,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
onChange={handleChange}
configuredSecrets={configuredSecrets}
fieldErrors={fieldErrors}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/>
)
case "discord":
@@ -471,6 +553,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
onChange={handleChange}
configuredSecrets={configuredSecrets}
fieldErrors={fieldErrors}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/>
)
case "slack":
@@ -480,6 +564,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
onChange={handleChange}
configuredSecrets={configuredSecrets}
fieldErrors={fieldErrors}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/>
)
case "feishu":
@@ -489,6 +575,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
onChange={handleChange}
configuredSecrets={configuredSecrets}
fieldErrors={fieldErrors}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/>
)
case "weixin":
@@ -498,6 +586,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
onChange={handleChange}
isEdit={isEdit}
onBindSuccess={() => void handleWeixinBindSuccess()}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/>
)
case "wecom":
@@ -518,6 +608,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
hiddenKeys={[...hiddenKeys, "bot_id"]}
requiredKeys={requiredKeys}
fieldErrors={fieldErrors}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/>
</>
)
@@ -530,6 +622,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
hiddenKeys={hiddenKeys}
requiredKeys={requiredKeys}
fieldErrors={fieldErrors}
registerArrayFieldFlusher={registerArrayFieldFlusher}
arrayFieldResetVersion={arrayFieldResetVersion}
/>
)
}
@@ -1,6 +1,14 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import {
type ArrayFieldFlusher,
ChannelArrayListField,
} from "@/components/channels/channel-array-list-field"
import {
asStringArray,
parseAllowFromInput,
} from "@/components/channels/channel-array-utils"
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card"
@@ -11,17 +19,17 @@ interface DiscordFormProps {
onChange: (key: string, value: unknown) => void
configuredSecrets: string[]
fieldErrors?: Record<string, string>
registerArrayFieldFlusher?: (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
arrayFieldResetVersion?: number
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
function asBool(value: unknown): boolean {
return value === true
}
@@ -38,6 +46,8 @@ export function DiscordForm({
onChange,
configuredSecrets,
fieldErrors = {},
registerArrayFieldFlusher,
arrayFieldResetVersion,
}: DiscordFormProps) {
const { t } = useTranslation()
const groupTriggerConfig = asRecord(config.group_trigger)
@@ -78,24 +88,17 @@ export function DiscordForm({
placeholder="http://127.0.0.1:7890"
/>
</Field>
<Field
<ChannelArrayListField
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>
value={asStringArray(config.allow_from)}
onChange={(value) => onChange("allow_from", value)}
placeholder={t("channels.field.allowFromPlaceholder")}
parser={parseAllowFromInput}
fieldPath="allow_from"
registerFlusher={registerArrayFieldFlusher}
resetVersion={arrayFieldResetVersion}
/>
<div>
<SwitchCardField
@@ -1,6 +1,14 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import {
type ArrayFieldFlusher,
ChannelArrayListField,
} from "@/components/channels/channel-array-list-field"
import {
asStringArray,
parseAllowFromInput,
} from "@/components/channels/channel-array-utils"
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card"
@@ -11,6 +19,11 @@ interface FeishuFormProps {
onChange: (key: string, value: unknown) => void
configuredSecrets: string[]
fieldErrors?: Record<string, string>
registerArrayFieldFlusher?: (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
arrayFieldResetVersion?: number
}
function asString(value: unknown): string {
@@ -21,16 +34,13 @@ function asBool(value: unknown): boolean {
return typeof value === "boolean" ? value : false
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
export function FeishuForm({
config,
onChange,
configuredSecrets,
fieldErrors = {},
registerArrayFieldFlusher,
arrayFieldResetVersion,
}: FeishuFormProps) {
const { t } = useTranslation()
@@ -104,24 +114,17 @@ export function FeishuForm({
/>
</Field>
<Field
<ChannelArrayListField
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>
value={asStringArray(config.allow_from)}
onChange={(value) => onChange("allow_from", value)}
placeholder={t("channels.field.allowFromPlaceholder")}
parser={parseAllowFromInput}
fieldPath="allow_from"
registerFlusher={registerArrayFieldFlusher}
resetVersion={arrayFieldResetVersion}
/>
<div>
<SwitchCardField
@@ -1,6 +1,14 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import {
type ArrayFieldFlusher,
ChannelArrayListField,
} from "@/components/channels/channel-array-list-field"
import {
asStringArray,
parseAllowFromInput,
} from "@/components/channels/channel-array-utils"
import {
getSecretInputPlaceholder,
isSecretField,
@@ -16,6 +24,11 @@ interface GenericFormProps {
hiddenKeys?: string[]
requiredKeys?: string[]
fieldErrors?: Record<string, string>
registerArrayFieldFlusher?: (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
arrayFieldResetVersion?: number
}
// Fields to skip in the generic form (handled by enabled toggle or internal).
@@ -48,11 +61,6 @@ function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
@@ -71,6 +79,8 @@ export function GenericForm({
hiddenKeys = [],
requiredKeys = [],
fieldErrors = {},
registerArrayFieldFlusher,
arrayFieldResetVersion,
}: GenericFormProps) {
const { t } = useTranslation()
const hiddenFieldSet = new Set(hiddenKeys)
@@ -187,26 +197,18 @@ export function GenericForm({
if (Array.isArray(value)) {
return (
<Field
<ChannelArrayListField
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>
value={asStringArray(value)}
onChange={(nextValue) => onChange(key, nextValue)}
fieldPath={key}
registerFlusher={registerArrayFieldFlusher}
resetVersion={arrayFieldResetVersion}
/>
)
}
@@ -281,46 +283,31 @@ export function GenericForm({
{config.allow_from !== undefined &&
!hiddenFieldSet.has("allow_from") && (
<Field
<ChannelArrayListField
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>
value={asStringArray(config.allow_from)}
onChange={(value) => onChange("allow_from", value)}
placeholder={t("channels.field.allowFromPlaceholder")}
parser={parseAllowFromInput}
fieldPath="allow_from"
registerFlusher={registerArrayFieldFlusher}
resetVersion={arrayFieldResetVersion}
/>
)}
{config.allow_origins !== undefined &&
!hiddenFieldSet.has("allow_origins") && (
<Field
<ChannelArrayListField
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>
value={asStringArray(config.allow_origins)}
onChange={(value) => onChange("allow_origins", value)}
placeholder={t("channels.field.allowOriginsPlaceholder")}
fieldPath="allow_origins"
registerFlusher={registerArrayFieldFlusher}
resetVersion={arrayFieldResetVersion}
/>
)}
{config.allow_token_query !== undefined &&
@@ -356,26 +343,21 @@ export function GenericForm({
/>
</div>
<Field
<ChannelArrayListField
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>
value={asStringArray(groupTriggerConfig.prefixes)}
onChange={(value) =>
onChange("group_trigger", {
...groupTriggerConfig,
prefixes: value,
})
}
placeholder={t("channels.field.groupTriggerPrefixes")}
fieldPath="group_trigger.prefixes"
registerFlusher={registerArrayFieldFlusher}
resetVersion={arrayFieldResetVersion}
/>
</>
)}
@@ -1,32 +1,41 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import {
type ArrayFieldFlusher,
ChannelArrayListField,
} from "@/components/channels/channel-array-list-field"
import {
asStringArray,
parseAllowFromInput,
} from "@/components/channels/channel-array-utils"
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
interface SlackFormProps {
config: ChannelConfig
onChange: (key: string, value: unknown) => void
configuredSecrets: string[]
fieldErrors?: Record<string, string>
registerArrayFieldFlusher?: (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
arrayFieldResetVersion?: number
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
export function SlackForm({
config,
onChange,
configuredSecrets,
fieldErrors = {},
registerArrayFieldFlusher,
arrayFieldResetVersion,
}: SlackFormProps) {
const { t } = useTranslation()
@@ -72,24 +81,17 @@ export function SlackForm({
<Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<Field
<ChannelArrayListField
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>
value={asStringArray(config.allow_from)}
onChange={(value) => onChange("allow_from", value)}
placeholder={t("channels.field.allowFromPlaceholder")}
parser={parseAllowFromInput}
fieldPath="allow_from"
registerFlusher={registerArrayFieldFlusher}
resetVersion={arrayFieldResetVersion}
/>
</CardContent>
</Card>
</div>
@@ -1,6 +1,14 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import {
type ArrayFieldFlusher,
ChannelArrayListField,
} from "@/components/channels/channel-array-list-field"
import {
asStringArray,
parseAllowFromInput,
} from "@/components/channels/channel-array-utils"
import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Card, CardContent } from "@/components/ui/card"
@@ -11,17 +19,17 @@ interface TelegramFormProps {
onChange: (key: string, value: unknown) => void
configuredSecrets: string[]
fieldErrors?: Record<string, string>
registerArrayFieldFlusher?: (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
arrayFieldResetVersion?: number
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
@@ -38,6 +46,8 @@ export function TelegramForm({
onChange,
configuredSecrets,
fieldErrors = {},
registerArrayFieldFlusher,
arrayFieldResetVersion,
}: TelegramFormProps) {
const { t } = useTranslation()
const typingConfig = asRecord(config.typing)
@@ -91,24 +101,17 @@ export function TelegramForm({
placeholder="http://127.0.0.1:7890"
/>
</Field>
<Field
<ChannelArrayListField
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>
value={asStringArray(config.allow_from)}
onChange={(value) => onChange("allow_from", value)}
placeholder={t("channels.field.allowFromPlaceholder")}
parser={parseAllowFromInput}
fieldPath="allow_from"
registerFlusher={registerArrayFieldFlusher}
resetVersion={arrayFieldResetVersion}
/>
<div>
<SwitchCardField
@@ -10,6 +10,14 @@ import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { pollWeixinFlow, startWeixinFlow } from "@/api/channels"
import {
type ArrayFieldFlusher,
ChannelArrayListField,
} from "@/components/channels/channel-array-list-field"
import {
asStringArray,
parseAllowFromInput,
} from "@/components/channels/channel-array-utils"
import { Field } from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import {
@@ -35,22 +43,24 @@ interface WeixinFormProps {
onChange: (key: string, value: unknown) => void
isEdit: boolean
onBindSuccess?: () => void
registerArrayFieldFlusher?: (
fieldPath: string,
flusher: ArrayFieldFlusher | null,
) => void
arrayFieldResetVersion?: number
}
function asString(value: unknown): string {
return typeof value === "string" ? value : ""
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return []
return value.filter((item): item is string => typeof item === "string")
}
export function WeixinForm({
config,
onChange,
isEdit,
onBindSuccess,
registerArrayFieldFlusher,
arrayFieldResetVersion,
}: WeixinFormProps) {
const { t } = useTranslation()
@@ -321,24 +331,17 @@ export function WeixinForm({
<Card className="shadow-sm">
<CardContent className="divide-border/60 divide-y px-6 py-0 [&>div]:py-5">
<Field
<ChannelArrayListField
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>
value={asStringArray(config.allow_from)}
onChange={(value) => onChange("allow_from", value)}
placeholder={t("channels.field.allowFromPlaceholder")}
parser={parseAllowFromInput}
fieldPath="allow_from"
registerFlusher={registerArrayFieldFlusher}
resetVersion={arrayFieldResetVersion}
/>
<Field
label={t("channels.field.proxy")}
+4 -3
View File
@@ -362,6 +362,7 @@
"allowFromPlaceholder": "e.g. 123456, 789012",
"allowOrigins": "Allow Origins",
"allowOriginsPlaceholder": "e.g. https://example.com, http://localhost:5173",
"removeListItem": "Remove {{value}}",
"secretPlaceholder": "Enter secret",
"secretHintSet": "A value is already set. Leave blank to keep it unchanged."
},
@@ -389,10 +390,10 @@
"typingEnabled": "Display typing status while the assistant is generating a response.",
"placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.",
"groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.",
"groupTriggerPrefixes": "Custom group-chat trigger prefixes, separated by commas.",
"groupTriggerPrefixes": "Custom group-chat trigger prefixes. Add items one by one, or paste multiple values at once.",
"isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).",
"allowFrom": "Allowed user or group IDs, separated by commas.",
"allowOrigins": "Allowed origin domains, separated by commas.",
"allowFrom": "Allowed user or group IDs. Add items one by one, or paste multiple values at once.",
"allowOrigins": "Allowed origin domains. Add items one by one, or paste multiple values at once.",
"wsUrl": "WebSocket service URL.",
"reconnectInterval": "Reconnect interval after disconnection (seconds).",
"bridgeUrl": "Bridge service URL.",
+4 -3
View File
@@ -362,6 +362,7 @@
"allowFromPlaceholder": "例如 123456, 789012",
"allowOrigins": "允许来源域名",
"allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173",
"removeListItem": "删除 {{value}}",
"secretPlaceholder": "输入密钥",
"secretHintSet": "配置已保存,留空表示不修改"
},
@@ -389,10 +390,10 @@
"typingEnabled": "在生成回复时显示“正在输入”状态",
"placeholderEnabled": "在最终回复发送前,先发送临时占位消息",
"groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应",
"groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔",
"groupTriggerPrefixes": "群聊触发前缀。可逐项添加,也支持一次粘贴多个值。",
"isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn",
"allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔",
"allowOrigins": "允许访问的来源域名,多个值用逗号分隔",
"allowFrom": "允许访问的用户或群组 ID。可逐项添加,也支持一次粘贴多个值。",
"allowOrigins": "允许访问的来源域名。可逐项添加,也支持一次粘贴多个值。",
"wsUrl": "WebSocket 服务地址",
"reconnectInterval": "断线后的重连间隔(秒)",
"bridgeUrl": "桥接服务地址",