mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user