refactor(web): redesign bot config page with card-based layout and dirty-aware save button

- Restructure bot edit page from flat form to card-based layout (Basic Info, Pipeline Binding, Adapter Config, Danger Zone)
- Move enable switch and save button to sticky header for quick access
- Move webhook URL display into adapter config card (contextually related)
- Remove redundant adapter icon card; show description as FormDescription
- Add dedicated Danger Zone card with red border for delete action
- Remove duplicate delete dialog from BotForm (single source in BotDetailContent)
- Implement form dirty tracking: save button is disabled until user modifies content
- Add i18n keys for new card titles/descriptions across all 4 locales
This commit is contained in:
Junyan Qin
2026-03-27 12:29:18 +08:00
parent e8dc6fde53
commit 127dc455c3
6 changed files with 379 additions and 311 deletions
+137 -36
View File
@@ -1,9 +1,18 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -19,8 +28,9 @@ import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-ses
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Settings, FileText, Users, RefreshCw } from 'lucide-react'; import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner';
export default function BotDetailContent({ id }: { id: string }) { export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new'; const isCreateMode = id === 'new';
@@ -44,7 +54,52 @@ export default function BotDetailContent({ id }: { id: string }) {
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false); const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null); const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
// Track whether the form has unsaved changes
const [formDirty, setFormDirty] = useState(false);
// Enable state managed here so the header switch works
const [botEnabled, setBotEnabled] = useState(true);
const [enableLoaded, setEnableLoaded] = useState(false);
// Fetch bot enable state
useEffect(() => {
if (!isCreateMode) {
httpClient.getBot(id).then((res) => {
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
});
}
}, [id, isCreateMode]);
const handleEnableToggle = useCallback(
async (checked: boolean) => {
const prev = botEnabled;
setBotEnabled(checked);
try {
// Fetch current bot data to send a complete update
const res = await httpClient.getBot(id);
const bot = res.bot;
await httpClient.updateBot(id, {
name: bot.name,
description: bot.description,
adapter: bot.adapter,
adapter_config: bot.adapter_config,
enable: checked,
});
refreshBots();
} catch {
setBotEnabled(prev);
toast.error(t('bots.setBotEnableError'));
}
},
[id, botEnabled, refreshBots, t],
);
function handleFormSubmit() { function handleFormSubmit() {
// Re-sync enable state after form save (form may update enable too)
httpClient.getBot(id).then((res) => {
setBotEnabled(res.bot.enable ?? true);
});
refreshBots(); refreshBots();
} }
@@ -55,57 +110,79 @@ export default function BotDetailContent({ id }: { id: string }) {
function handleNewBotCreated(newBotId: string) { function handleNewBotCreated(newBotId: string) {
refreshBots(); refreshBots();
// Navigate to the newly created bot's detail view via query param
router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`); router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`);
} }
function handleDelete() {
setShowDeleteConfirm(true);
}
function confirmDelete() { function confirmDelete() {
httpClient.deleteBot(id).then(() => { httpClient
setShowDeleteConfirm(false); .deleteBot(id)
handleBotDeleted(); .then(() => {
}); setShowDeleteConfirm(false);
toast.success(t('bots.deleteSuccess'));
handleBotDeleted();
})
.catch((err) => {
toast.error(t('bots.deleteError') + err.msg);
});
} }
// Create mode: simple form layout // ==================== Create Mode ====================
if (isCreateMode) { if (isCreateMode) {
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-3 pb-4 shrink-0"> {/* Header */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1> <h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
<Button type="submit" form="bot-form">
{t('common.submit')}
</Button>
</div> </div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-2xl space-y-6"> <div className="mx-auto max-w-3xl pb-8">
<BotForm <BotForm
initBotId={undefined} initBotId={undefined}
onFormSubmit={handleFormSubmit} onFormSubmit={handleFormSubmit}
onBotDeleted={handleBotDeleted} onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated} onNewBotCreated={handleNewBotCreated}
/> />
<div className="flex justify-end gap-2 pb-4">
<Button type="submit" form="bot-form">
{t('common.submit')}
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
// Edit mode: tabbed layout with config, logs, sessions // ==================== Edit Mode ====================
return ( return (
<> <>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-3 pb-4 shrink-0"> {/* Sticky Header: title + enable switch + save button */}
<h1 className="text-xl font-semibold">{t('bots.editBot')}</h1> <div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">{t('bots.editBot')}</h1>
{enableLoaded && (
<div className="flex items-center gap-2">
<Switch
id="bot-enable-switch"
checked={botEnabled}
onCheckedChange={handleEnableToggle}
/>
<Label
htmlFor="bot-enable-switch"
className="text-sm text-muted-foreground cursor-pointer"
>
{t('common.enable')}
</Label>
</div>
)}
</div>
<Button type="submit" form="bot-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div> </div>
{/* Horizontal Tabs */}
<Tabs <Tabs
key={id} key={id}
value={activeTab} value={activeTab}
@@ -152,33 +229,56 @@ export default function BotDetailContent({ id }: { id: string }) {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* Tab: Configuration */}
<TabsContent <TabsContent
value="config" value="config"
className="flex-1 min-h-0 overflow-y-auto mt-4" className="flex-1 min-h-0 overflow-y-auto mt-4"
> >
<div className="mx-auto max-w-2xl"> <div className="mx-auto max-w-3xl space-y-6 pb-8">
<BotForm <BotForm
initBotId={id} initBotId={id}
onFormSubmit={handleFormSubmit} onFormSubmit={handleFormSubmit}
onBotDeleted={handleBotDeleted} onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated} onNewBotCreated={handleNewBotCreated}
onDirtyChange={setFormDirty}
/> />
<div className="flex justify-end gap-2 mt-6 pb-4"> {/* Card: Danger Zone */}
<Button <Card className="border-destructive/50">
type="button" <CardHeader>
variant="destructive" <CardTitle className="text-destructive">
onClick={handleDelete} {t('bots.dangerZone')}
> </CardTitle>
{t('common.delete')} <CardDescription>
</Button> {t('bots.dangerZoneDescription')}
<Button type="submit" form="bot-form"> </CardDescription>
{t('common.save')} </CardHeader>
</Button> <CardContent>
</div> <div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('bots.deleteBotAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('bots.deleteBotHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</div> </div>
</TabsContent> </TabsContent>
{/* Tab: Logs */}
<TabsContent <TabsContent
value="logs" value="logs"
className="flex-1 min-h-0 overflow-y-auto mt-4" className="flex-1 min-h-0 overflow-y-auto mt-4"
@@ -186,6 +286,7 @@ export default function BotDetailContent({ id }: { id: string }) {
<BotLogListComponent botId={id} /> <BotLogListComponent botId={id} />
</TabsContent> </TabsContent>
{/* Tab: Sessions */}
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4"> <TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
<BotSessionMonitor ref={sessionMonitorRef} botId={id} /> <BotSessionMonitor ref={sessionMonitorRef} botId={id} />
</TabsContent> </TabsContent>
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
IChooseAdapterEntity, IChooseAdapterEntity,
IPipelineEntity, IPipelineEntity,
@@ -21,18 +21,11 @@ import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Copy, Check } from 'lucide-react'; import { Copy, Check } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -47,7 +40,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch'; import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { CustomApiError } from '@/app/infra/entities/common'; import { CustomApiError } from '@/app/infra/entities/common';
@@ -68,11 +67,13 @@ export default function BotForm({
onFormSubmit, onFormSubmit,
onBotDeleted, onBotDeleted,
onNewBotCreated, onNewBotCreated,
onDirtyChange,
}: { }: {
initBotId?: string; initBotId?: string;
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void; onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
onBotDeleted: () => void; onBotDeleted: () => void;
onNewBotCreated: (botId: string) => void; onNewBotCreated: (botId: string) => void;
onDirtyChange?: (dirty: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const formSchema = getFormSchema(t); const formSchema = getFormSchema(t);
@@ -89,19 +90,16 @@ export default function BotForm({
}, },
}); });
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); // Track whether initial data loading is complete.
// setValue calls during init should NOT mark the form as dirty.
const isInitializing = useRef(true);
const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] = const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
useState(new Map<string, IDynamicFormItemSchema[]>()); useState(new Map<string, IDynamicFormItemSchema[]>());
// const [form] = Form.useForm<IBotFormEntity>();
const [showDynamicForm, setShowDynamicForm] = useState<boolean>(false); const [showDynamicForm, setShowDynamicForm] = useState<boolean>(false);
// const [dynamicForm] = Form.useForm();
const [adapterNameList, setAdapterNameList] = useState< const [adapterNameList, setAdapterNameList] = useState<
IChooseAdapterEntity[] IChooseAdapterEntity[]
>([]); >([]);
const [adapterIconList, setAdapterIconList] = useState<
Record<string, string>
>({});
const [adapterDescriptionList, setAdapterDescriptionList] = useState< const [adapterDescriptionList, setAdapterDescriptionList] = useState<
Record<string, string> Record<string, string>
>({}); >({});
@@ -140,11 +138,16 @@ export default function BotForm({
return dynamicFormConfigList; return dynamicFormConfigList;
}, [currentAdapter, enableWebhook, dynamicFormConfigList]); }, [currentAdapter, enableWebhook, dynamicFormConfigList]);
// Notify parent when dirty state changes
const { isDirty } = form.formState;
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
useEffect(() => { useEffect(() => {
setBotFormValues(); setBotFormValues();
}, []); }, []);
// 复制到剪贴板的辅助函数
const copyToClipboard = ( const copyToClipboard = (
text: string, text: string,
setStatus: React.Dispatch<React.SetStateAction<boolean>>, setStatus: React.Dispatch<React.SetStateAction<boolean>>,
@@ -157,7 +160,6 @@ export default function BotForm({
setTimeout(() => setStatus(false), 2000); setTimeout(() => setStatus(false), 2000);
}) })
.catch(() => { .catch(() => {
// 降级:创建临时textarea复制
fallbackCopy(text, setStatus); fallbackCopy(text, setStatus);
}); });
} else { } else {
@@ -184,21 +186,23 @@ export default function BotForm({
}; };
function setBotFormValues() { function setBotFormValues() {
isInitializing.current = true;
initBotFormComponent().then(() => { initBotFormComponent().then(() => {
// 拉取初始化表单信息
if (initBotId) { if (initBotId) {
getBotConfig(initBotId) getBotConfig(initBotId)
.then((val) => { .then((val) => {
form.setValue('name', val.name); // Use form.reset() to set values AND update the dirty baseline,
form.setValue('description', val.description); // so isDirty stays false after initial load.
form.setValue('adapter', val.adapter); form.reset({
form.setValue('adapter_config', val.adapter_config); name: val.name,
form.setValue('enable', val.enable); description: val.description,
form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || ''); adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
use_pipeline_uuid: val.use_pipeline_uuid || '',
});
handleAdapterSelect(val.adapter); handleAdapterSelect(val.adapter);
// dynamicForm.setFieldsValue(val.adapter_config);
// 设置 webhook 地址(如果有)
if (val.webhook_full_url) { if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url); setWebhookUrl(val.webhook_full_url);
} else { } else {
@@ -210,17 +214,20 @@ export default function BotForm({
toast.error( toast.error(
t('bots.getBotConfigError') + (err as CustomApiError).msg, t('bots.getBotConfigError') + (err as CustomApiError).msg,
); );
})
.finally(() => {
isInitializing.current = false;
}); });
} else { } else {
form.reset(); form.reset();
setWebhookUrl(''); setWebhookUrl('');
setExtraWebhookUrl(''); setExtraWebhookUrl('');
isInitializing.current = false;
} }
}); });
} }
async function initBotFormComponent() { async function initBotFormComponent() {
// 初始化流水线列表
const pipelinesRes = await httpClient.getPipelines(); const pipelinesRes = await httpClient.getPipelines();
setPipelineNameList( setPipelineNameList(
pipelinesRes.pipelines.map((item) => { pipelinesRes.pipelines.map((item) => {
@@ -231,7 +238,6 @@ export default function BotForm({
}), }),
); );
// 拉取adapter
const adaptersRes = await httpClient.getAdapters(); const adaptersRes = await httpClient.getAdapters();
setAdapterNameList( setAdapterNameList(
adaptersRes.adapters.map((item) => { adaptersRes.adapters.map((item) => {
@@ -242,18 +248,6 @@ export default function BotForm({
}), }),
); );
// 初始化适配器图标列表
setAdapterIconList(
adaptersRes.adapters.reduce(
(acc, item) => {
acc[item.name] = httpClient.getAdapterIconURL(item.name);
return acc;
},
{} as Record<string, string>,
),
);
// 初始化适配器描述列表
setAdapterDescriptionList( setAdapterDescriptionList(
adaptersRes.adapters.reduce( adaptersRes.adapters.reduce(
(acc, item) => { (acc, item) => {
@@ -264,7 +258,6 @@ export default function BotForm({
), ),
); );
// 初始化适配器表单map
adaptersRes.adapters.forEach((rawAdapter) => { adaptersRes.adapters.forEach((rawAdapter) => {
adapterNameToDynamicConfigMap.set( adapterNameToDynamicConfigMap.set(
rawAdapter.name, rawAdapter.name,
@@ -341,11 +334,9 @@ export default function BotForm({
} }
} }
// 只有通过外层固定表单验证才会走到这里,真正的提交逻辑在这里
function onDynamicFormSubmit() { function onDynamicFormSubmit() {
setIsLoading(true); setIsLoading(true);
if (initBotId) { if (initBotId) {
// 编辑提交
const updateBot: Bot = { const updateBot: Bot = {
uuid: initBotId, uuid: initBotId,
name: form.getValues().name, name: form.getValues().name,
@@ -358,6 +349,8 @@ export default function BotForm({
httpClient httpClient
.updateBot(initBotId, updateBot) .updateBot(initBotId, updateBot)
.then(() => { .then(() => {
// Reset dirty baseline to current values so isDirty becomes false
form.reset(form.getValues());
onFormSubmit(form.getValues()); onFormSubmit(form.getValues());
toast.success(t('bots.saveSuccess')); toast.success(t('bots.saveSuccess'));
}) })
@@ -366,11 +359,8 @@ export default function BotForm({
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
// form.reset();
// dynamicForm.resetFields();
}); });
} else { } else {
// 创建提交
const newBot: Bot = { const newBot: Bot = {
name: form.getValues().name, name: form.getValues().name,
description: form.getValues().description, description: form.getValues().description,
@@ -393,181 +383,30 @@ export default function BotForm({
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
form.reset(); form.reset();
// dynamicForm.resetFields();
}); });
} }
} }
function deleteBot() { // --- Webhook URL display helper ---
if (initBotId) { const showWebhook =
httpClient initBotId &&
.deleteBot(initBotId) webhookUrl &&
.then(() => { (currentAdapter !== 'lark' || enableWebhook !== false);
onBotDeleted();
toast.success(t('bots.deleteSuccess'));
})
.catch((err) => {
toast.error(t('bots.deleteError') + err.msg);
});
}
}
return ( return (
<div> <Form {...form}>
<Dialog <form
open={showDeleteConfirmModal} id="bot-form"
onOpenChange={setShowDeleteConfirmModal} onSubmit={form.handleSubmit(onDynamicFormSubmit)}
className="space-y-6"
> >
<DialogContent> {/* Card 1: Basic Information */}
<DialogHeader> <Card>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle> <CardHeader>
</DialogHeader> <CardTitle>{t('bots.basicInfo')}</CardTitle>
<DialogDescription>{t('bots.deleteConfirmation')}</DialogDescription> <CardDescription>{t('bots.basicInfoDescription')}</CardDescription>
<DialogFooter> </CardHeader>
<Button <CardContent className="space-y-4">
variant="outline"
onClick={() => setShowDeleteConfirmModal(false)}
>
</Button>
<Button
variant="destructive"
onClick={() => {
deleteBot();
setShowDeleteConfirmModal(false);
}}
>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Form {...form}>
<form
id="bot-form"
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
className="space-y-8"
>
<div className="space-y-4">
{/* 是否启用 & 绑定流水线 仅在编辑模式 */}
{initBotId && (
<>
<div className="flex items-center gap-6">
<FormField
control={form.control}
name="enable"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('common.enable')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="use_pipeline_uuid"
render={({ field }) => (
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{pipelineNameList.map((item) => (
<SelectItem
key={item.value}
value={item.value}
>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
{/* Webhook 地址显示(统一 Webhook 模式) */}
{webhookUrl &&
(currentAdapter !== 'lark' || enableWebhook !== false) && (
<FormItem>
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
<div className="flex items-center gap-2">
<Input
value={webhookUrl}
readOnly
className="flex-1 bg-gray-50 dark:bg-gray-900"
onClick={(e) => {
// 点击输入框时自动全选
(e.target as HTMLInputElement).select();
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => copyToClipboard(webhookUrl, setCopied)}
>
{copied ? (
<Check className="h-4 w-4 text-green-600 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
{t('common.copy')}
</Button>
</div>
{extraWebhookUrl && (
<div className="flex items-center gap-2 mt-2">
<Input
value={extraWebhookUrl}
readOnly
className="flex-1 bg-gray-50 dark:bg-gray-900"
onClick={(e) => {
(e.target as HTMLInputElement).select();
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
copyToClipboard(extraWebhookUrl, setExtraCopied)
}
>
{extraCopied ? (
<Check className="h-4 w-4 text-green-600 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
{t('common.copy')}
</Button>
</div>
)}
<p className="text-sm text-gray-500 mt-1">
{extraWebhookUrl
? t('bots.webhookUrlHintEither')
: t('bots.webhookUrlHint')}
</p>
</FormItem>
)}
</>
)}
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -575,7 +414,7 @@ export default function BotForm({
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('bots.botName')} {t('bots.botName')}
<span className="text-red-500">*</span> <span className="text-destructive">*</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
@@ -591,7 +430,7 @@ export default function BotForm({
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t('bots.botDescription')} {t('bots.botDescription')}
<span className="text-red-500">*</span> <span className="text-destructive">*</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
@@ -600,31 +439,33 @@ export default function BotForm({
</FormItem> </FormItem>
)} )}
/> />
</CardContent>
</Card>
<FormField {/* Card 2: Pipeline Binding (edit mode only) */}
control={form.control} {initBotId && (
name="adapter" <Card>
render={({ field }) => ( <CardHeader>
<FormItem> <CardTitle>{t('bots.routingConnection')}</CardTitle>
<FormLabel> <CardDescription>
{t('bots.platformAdapter')} {t('bots.routingConnectionDescription')}
<span className="text-red-500">*</span> </CardDescription>
</FormLabel> </CardHeader>
<FormControl> <CardContent>
<div className="relative"> <FormField
<Select control={form.control}
onValueChange={(value) => { name="use_pipeline_uuid"
field.onChange(value); render={({ field }) => (
handleAdapterSelect(value); <FormItem>
}} <FormLabel>{t('bots.bindPipeline')}</FormLabel>
value={field.value} <FormControl>
> <Select onValueChange={field.onChange} {...field}>
<SelectTrigger className="w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]"> <SelectTrigger>
<SelectValue placeholder={t('bots.selectAdapter')} /> <SelectValue placeholder={t('bots.selectPipeline')} />
</SelectTrigger> </SelectTrigger>
<SelectContent className="fixed z-[1000]"> <SelectContent>
<SelectGroup> <SelectGroup>
{adapterNameList.map((item) => ( {pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}> <SelectItem key={item.value} value={item.value}>
{item.label} {item.label}
</SelectItem> </SelectItem>
@@ -632,52 +473,138 @@ export default function BotForm({
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </FormControl>
</FormItem>
)}
/>
</CardContent>
</Card>
)}
{/* Card 3: Adapter Configuration */}
<Card>
<CardHeader>
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
<CardDescription>
{t('bots.adapterConfigDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="adapter"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('bots.platformAdapter')}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
field.onChange(value);
handleAdapterSelect(value);
}}
value={field.value}
>
<SelectTrigger className="w-[240px]">
<SelectValue placeholder={t('bots.selectAdapter')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{adapterNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl> </FormControl>
{currentAdapter && adapterDescriptionList[currentAdapter] && (
<FormDescription>
{adapterDescriptionList[currentAdapter]}
</FormDescription>
)}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{form.watch('adapter') && ( {/* Webhook URL: shown after adapter is selected (edit mode only) */}
<div className="flex items-start gap-3 p-4 rounded-lg border"> {showWebhook && (
<img <FormItem>
src={adapterIconList[form.watch('adapter')]} <FormLabel>{t('bots.webhookUrl')}</FormLabel>
alt="adapter icon" <div className="flex items-center gap-2">
className="w-12 h-12 rounded-[8%]" <Input
/> value={webhookUrl}
<div className="flex flex-col gap-1"> readOnly
<div className="font-medium"> className="flex-1 bg-muted"
{ onClick={(e) => {
adapterNameList.find( (e.target as HTMLInputElement).select();
(item) => item.value === form.watch('adapter'), }}
)?.label />
} <Button
</div> type="button"
<div className="text-sm text-gray-500"> variant="outline"
{adapterDescriptionList[form.watch('adapter')]} size="sm"
</div> onClick={() => copyToClipboard(webhookUrl, setCopied)}
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div> </div>
</div> {extraWebhookUrl && (
<div className="flex items-center gap-2 mt-2">
<Input
value={extraWebhookUrl}
readOnly
className="flex-1 bg-muted"
onClick={(e) => {
(e.target as HTMLInputElement).select();
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
copyToClipboard(extraWebhookUrl, setExtraCopied)
}
>
{extraCopied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
)}
<FormDescription>
{extraWebhookUrl
? t('bots.webhookUrlHintEither')
: t('bots.webhookUrlHint')}
</FormDescription>
</FormItem>
)} )}
{showDynamicForm && filteredDynamicFormConfigList.length > 0 && ( {showDynamicForm && filteredDynamicFormConfigList.length > 0 && (
<div className="space-y-4"> <DynamicFormComponent
<div className="text-lg font-medium"> itemConfigList={filteredDynamicFormConfigList}
{t('bots.adapterConfig')} initialValues={currentAdapterConfig}
</div> onSubmit={(values) => {
<DynamicFormComponent form.setValue('adapter_config', values, {
itemConfigList={filteredDynamicFormConfigList} shouldDirty: !isInitializing.current,
initialValues={currentAdapterConfig} });
onSubmit={(values) => { }}
form.setValue('adapter_config', values); />
}}
/>
</div>
)} )}
</div> </CardContent>
</form> </Card>
</Form> </form>
</div> </Form>
); );
} }
+11
View File
@@ -294,6 +294,17 @@ const enUS = {
log: 'Log', log: 'Log',
configuration: 'Configuration', configuration: 'Configuration',
logs: 'Logs', logs: 'Logs',
basicInfo: 'Basic Information',
basicInfoDescription: 'Set the bot name and description',
routingConnection: 'Routing & Connection',
routingConnectionDescription:
'Bind the pipeline that processes messages for this bot',
adapterConfigDescription: 'Configure the selected platform adapter',
dangerZone: 'Danger Zone',
dangerZoneDescription: 'Irreversible and destructive actions',
deleteBotAction: 'Delete this bot',
deleteBotHint:
'Once deleted, all associated configuration will be permanently removed.',
webhookUrl: 'Webhook Callback URL', webhookUrl: 'Webhook Callback URL',
webhookUrlCopied: 'Webhook URL copied', webhookUrlCopied: 'Webhook URL copied',
webhookUrlHint: webhookUrlHint:
+11
View File
@@ -299,6 +299,17 @@
log: 'ログ', log: 'ログ',
configuration: '設定', configuration: '設定',
logs: 'ログ', logs: 'ログ',
basicInfo: '基本情報',
basicInfoDescription: 'ボットの名前と説明を設定',
routingConnection: 'ルーティングと接続',
routingConnectionDescription:
'このボットのメッセージを処理するパイプラインを紐付け',
adapterConfigDescription: '選択したプラットフォームアダプターを設定',
dangerZone: '危険ゾーン',
dangerZoneDescription: '元に戻せない操作',
deleteBotAction: 'このボットを削除',
deleteBotHint:
'削除すると、関連する全ての設定が完全に削除され、復元できません。',
webhookUrl: 'Webhook コールバック URL', webhookUrl: 'Webhook コールバック URL',
webhookUrlCopied: 'Webhook URL をコピーしました', webhookUrlCopied: 'Webhook URL をコピーしました',
webhookUrlHint: webhookUrlHint:
+9
View File
@@ -282,6 +282,15 @@ const zhHans = {
log: '日志', log: '日志',
configuration: '配置', configuration: '配置',
logs: '日志', logs: '日志',
basicInfo: '基础信息',
basicInfoDescription: '设置机器人名称和描述',
routingConnection: '路由与连接',
routingConnectionDescription: '绑定处理此机器人消息的流水线',
adapterConfigDescription: '配置所选平台适配器',
dangerZone: '危险区域',
dangerZoneDescription: '不可逆的操作',
deleteBotAction: '删除此机器人',
deleteBotHint: '删除后,所有关联配置将被永久移除,且无法恢复。',
webhookUrl: 'Webhook 回调地址', webhookUrl: 'Webhook 回调地址',
webhookUrlCopied: 'Webhook 地址已复制', webhookUrlCopied: 'Webhook 地址已复制',
webhookUrlHint: webhookUrlHint:
+9
View File
@@ -281,6 +281,15 @@ const zhHant = {
log: '日誌', log: '日誌',
configuration: '設定', configuration: '設定',
logs: '日誌', logs: '日誌',
basicInfo: '基礎資訊',
basicInfoDescription: '設定機器人名稱和描述',
routingConnection: '路由與連接',
routingConnectionDescription: '綁定處理此機器人訊息的流程線',
adapterConfigDescription: '設定所選平台適配器',
dangerZone: '危險區域',
dangerZoneDescription: '不可逆的操作',
deleteBotAction: '刪除此機器人',
deleteBotHint: '刪除後,所有關聯設定將被永久移除,且無法復原。',
webhookUrl: 'Webhook 回調位址', webhookUrl: 'Webhook 回調位址',
webhookUrlCopied: 'Webhook 位址已複製', webhookUrlCopied: 'Webhook 位址已複製',
webhookUrlHint: webhookUrlHint: