From 127dc455c33f7f3726fba302b6631335fe2b348b Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Fri, 27 Mar 2026 12:29:18 +0800 Subject: [PATCH] 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 --- web/src/app/home/bots/BotDetailContent.tsx | 173 +++++-- .../home/bots/components/bot-form/BotForm.tsx | 477 ++++++++---------- web/src/i18n/locales/en-US.ts | 11 + web/src/i18n/locales/ja-JP.ts | 11 + web/src/i18n/locales/zh-Hans.ts | 9 + web/src/i18n/locales/zh-Hant.ts | 9 + 6 files changed, 379 insertions(+), 311 deletions(-) diff --git a/web/src/app/home/bots/BotDetailContent.tsx b/web/src/app/home/bots/BotDetailContent.tsx index caff71b8..a613f1f7 100644 --- a/web/src/app/home/bots/BotDetailContent.tsx +++ b/web/src/app/home/bots/BotDetailContent.tsx @@ -1,9 +1,18 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; 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 { Dialog, DialogContent, @@ -19,8 +28,9 @@ import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-ses import { httpClient } from '@/app/infra/http/HttpClient'; import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; 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 { toast } from 'sonner'; export default function BotDetailContent({ id }: { id: string }) { const isCreateMode = id === 'new'; @@ -44,7 +54,52 @@ export default function BotDetailContent({ id }: { id: string }) { const [isRefreshingSessions, setIsRefreshingSessions] = useState(false); const sessionMonitorRef = useRef(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() { + // Re-sync enable state after form save (form may update enable too) + httpClient.getBot(id).then((res) => { + setBotEnabled(res.bot.enable ?? true); + }); refreshBots(); } @@ -55,57 +110,79 @@ export default function BotDetailContent({ id }: { id: string }) { function handleNewBotCreated(newBotId: string) { refreshBots(); - // Navigate to the newly created bot's detail view via query param router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`); } - function handleDelete() { - setShowDeleteConfirm(true); - } - function confirmDelete() { - httpClient.deleteBot(id).then(() => { - setShowDeleteConfirm(false); - handleBotDeleted(); - }); + httpClient + .deleteBot(id) + .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) { return (
-
+ {/* Header */} +

{t('bots.createBot')}

+
+ {/* Content */}
-
+
- -
- -
); } - // Edit mode: tabbed layout with config, logs, sessions + // ==================== Edit Mode ==================== return ( <>
-
-

{t('bots.editBot')}

+ {/* Sticky Header: title + enable switch + save button */} +
+
+

{t('bots.editBot')}

+ {enableLoaded && ( +
+ + +
+ )} +
+
+ {/* Horizontal Tabs */} + {/* Tab: Configuration */} -
+
-
- - -
+ {/* Card: Danger Zone */} + + + + {t('bots.dangerZone')} + + + {t('bots.dangerZoneDescription')} + + + +
+
+

+ {t('bots.deleteBotAction')} +

+

+ {t('bots.deleteBotHint')} +

+
+ +
+
+
+ {/* Tab: Logs */} + {/* Tab: Sessions */} diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index b15cb405..b464d0fc 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { IChooseAdapterEntity, IPipelineEntity, @@ -21,18 +21,11 @@ import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { Copy, Check } from 'lucide-react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Form, FormControl, + FormDescription, FormField, FormItem, FormLabel, @@ -47,7 +40,13 @@ import { SelectTrigger, SelectValue, } 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 { CustomApiError } from '@/app/infra/entities/common'; @@ -68,11 +67,13 @@ export default function BotForm({ onFormSubmit, onBotDeleted, onNewBotCreated, + onDirtyChange, }: { initBotId?: string; onFormSubmit: (value: z.infer>) => void; onBotDeleted: () => void; onNewBotCreated: (botId: string) => void; + onDirtyChange?: (dirty: boolean) => void; }) { const { t } = useTranslation(); 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] = useState(new Map()); - // const [form] = Form.useForm(); const [showDynamicForm, setShowDynamicForm] = useState(false); - // const [dynamicForm] = Form.useForm(); const [adapterNameList, setAdapterNameList] = useState< IChooseAdapterEntity[] >([]); - const [adapterIconList, setAdapterIconList] = useState< - Record - >({}); const [adapterDescriptionList, setAdapterDescriptionList] = useState< Record >({}); @@ -140,11 +138,16 @@ export default function BotForm({ return dynamicFormConfigList; }, [currentAdapter, enableWebhook, dynamicFormConfigList]); + // Notify parent when dirty state changes + const { isDirty } = form.formState; + useEffect(() => { + onDirtyChange?.(isDirty); + }, [isDirty, onDirtyChange]); + useEffect(() => { setBotFormValues(); }, []); - // 复制到剪贴板的辅助函数 const copyToClipboard = ( text: string, setStatus: React.Dispatch>, @@ -157,7 +160,6 @@ export default function BotForm({ setTimeout(() => setStatus(false), 2000); }) .catch(() => { - // 降级:创建临时textarea复制 fallbackCopy(text, setStatus); }); } else { @@ -184,21 +186,23 @@ export default function BotForm({ }; function setBotFormValues() { + isInitializing.current = true; initBotFormComponent().then(() => { - // 拉取初始化表单信息 if (initBotId) { getBotConfig(initBotId) .then((val) => { - form.setValue('name', val.name); - form.setValue('description', val.description); - form.setValue('adapter', val.adapter); - form.setValue('adapter_config', val.adapter_config); - form.setValue('enable', val.enable); - form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || ''); + // Use form.reset() to set values AND update the dirty baseline, + // so isDirty stays false after initial load. + form.reset({ + name: val.name, + description: val.description, + adapter: val.adapter, + adapter_config: val.adapter_config, + enable: val.enable, + use_pipeline_uuid: val.use_pipeline_uuid || '', + }); handleAdapterSelect(val.adapter); - // dynamicForm.setFieldsValue(val.adapter_config); - // 设置 webhook 地址(如果有) if (val.webhook_full_url) { setWebhookUrl(val.webhook_full_url); } else { @@ -210,17 +214,20 @@ export default function BotForm({ toast.error( t('bots.getBotConfigError') + (err as CustomApiError).msg, ); + }) + .finally(() => { + isInitializing.current = false; }); } else { form.reset(); setWebhookUrl(''); setExtraWebhookUrl(''); + isInitializing.current = false; } }); } async function initBotFormComponent() { - // 初始化流水线列表 const pipelinesRes = await httpClient.getPipelines(); setPipelineNameList( pipelinesRes.pipelines.map((item) => { @@ -231,7 +238,6 @@ export default function BotForm({ }), ); - // 拉取adapter const adaptersRes = await httpClient.getAdapters(); setAdapterNameList( 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, - ), - ); - - // 初始化适配器描述列表 setAdapterDescriptionList( adaptersRes.adapters.reduce( (acc, item) => { @@ -264,7 +258,6 @@ export default function BotForm({ ), ); - // 初始化适配器表单map adaptersRes.adapters.forEach((rawAdapter) => { adapterNameToDynamicConfigMap.set( rawAdapter.name, @@ -341,11 +334,9 @@ export default function BotForm({ } } - // 只有通过外层固定表单验证才会走到这里,真正的提交逻辑在这里 function onDynamicFormSubmit() { setIsLoading(true); if (initBotId) { - // 编辑提交 const updateBot: Bot = { uuid: initBotId, name: form.getValues().name, @@ -358,6 +349,8 @@ export default function BotForm({ httpClient .updateBot(initBotId, updateBot) .then(() => { + // Reset dirty baseline to current values so isDirty becomes false + form.reset(form.getValues()); onFormSubmit(form.getValues()); toast.success(t('bots.saveSuccess')); }) @@ -366,11 +359,8 @@ export default function BotForm({ }) .finally(() => { setIsLoading(false); - // form.reset(); - // dynamicForm.resetFields(); }); } else { - // 创建提交 const newBot: Bot = { name: form.getValues().name, description: form.getValues().description, @@ -393,181 +383,30 @@ export default function BotForm({ .finally(() => { setIsLoading(false); form.reset(); - // dynamicForm.resetFields(); }); } } - function deleteBot() { - if (initBotId) { - httpClient - .deleteBot(initBotId) - .then(() => { - onBotDeleted(); - toast.success(t('bots.deleteSuccess')); - }) - .catch((err) => { - toast.error(t('bots.deleteError') + err.msg); - }); - } - } + // --- Webhook URL display helper --- + const showWebhook = + initBotId && + webhookUrl && + (currentAdapter !== 'lark' || enableWebhook !== false); return ( -
- +
- - - {t('common.confirmDelete')} - - {t('bots.deleteConfirmation')} - - - - - -
- - - -
- {/* 是否启用 & 绑定流水线 仅在编辑模式 */} - {initBotId && ( - <> -
- ( - - {t('common.enable')} - - - - - )} - /> - - ( - - {t('bots.bindPipeline')} - - - - - )} - /> -
- - {/* Webhook 地址显示(统一 Webhook 模式) */} - {webhookUrl && - (currentAdapter !== 'lark' || enableWebhook !== false) && ( - - {t('bots.webhookUrl')} -
- { - // 点击输入框时自动全选 - (e.target as HTMLInputElement).select(); - }} - /> - -
- {extraWebhookUrl && ( -
- { - (e.target as HTMLInputElement).select(); - }} - /> - -
- )} -

- {extraWebhookUrl - ? t('bots.webhookUrlHintEither') - : t('bots.webhookUrlHint')} -

-
- )} - - )} - + {/* Card 1: Basic Information */} + + + {t('bots.basicInfo')} + {t('bots.basicInfoDescription')} + + {t('bots.botName')} - * + * @@ -591,7 +430,7 @@ export default function BotForm({ {t('bots.botDescription')} - * + * @@ -600,31 +439,33 @@ export default function BotForm({ )} /> + + - ( - - - {t('bots.platformAdapter')} - * - - -
- + + - + - {adapterNameList.map((item) => ( + {pipelineNameList.map((item) => ( {item.label} @@ -632,52 +473,138 @@ export default function BotForm({ -
+
+
+ )} + /> + + + )} + + {/* Card 3: Adapter Configuration */} + + + {t('bots.adapterConfig')} + + {t('bots.adapterConfigDescription')} + + + + ( + + + {t('bots.platformAdapter')} + * + + + + {currentAdapter && adapterDescriptionList[currentAdapter] && ( + + {adapterDescriptionList[currentAdapter]} + + )} )} /> - {form.watch('adapter') && ( -
- adapter icon -
-
- { - adapterNameList.find( - (item) => item.value === form.watch('adapter'), - )?.label - } -
-
- {adapterDescriptionList[form.watch('adapter')]} -
+ {/* Webhook URL: shown after adapter is selected (edit mode only) */} + {showWebhook && ( + + {t('bots.webhookUrl')} +
+ { + (e.target as HTMLInputElement).select(); + }} + /> +
-
+ {extraWebhookUrl && ( +
+ { + (e.target as HTMLInputElement).select(); + }} + /> + +
+ )} + + {extraWebhookUrl + ? t('bots.webhookUrlHintEither') + : t('bots.webhookUrlHint')} + + )} {showDynamicForm && filteredDynamicFormConfigList.length > 0 && ( -
-
- {t('bots.adapterConfig')} -
- { - form.setValue('adapter_config', values); - }} - /> -
+ { + form.setValue('adapter_config', values, { + shouldDirty: !isInitializing.current, + }); + }} + /> )} -
- - -
+ + + + ); } diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 8f6a80fe..180554be 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -294,6 +294,17 @@ const enUS = { log: 'Log', configuration: 'Configuration', 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', webhookUrlCopied: 'Webhook URL copied', webhookUrlHint: diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 99c18156..3337ee6d 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -299,6 +299,17 @@ log: 'ログ', configuration: '設定', logs: 'ログ', + basicInfo: '基本情報', + basicInfoDescription: 'ボットの名前と説明を設定', + routingConnection: 'ルーティングと接続', + routingConnectionDescription: + 'このボットのメッセージを処理するパイプラインを紐付け', + adapterConfigDescription: '選択したプラットフォームアダプターを設定', + dangerZone: '危険ゾーン', + dangerZoneDescription: '元に戻せない操作', + deleteBotAction: 'このボットを削除', + deleteBotHint: + '削除すると、関連する全ての設定が完全に削除され、復元できません。', webhookUrl: 'Webhook コールバック URL', webhookUrlCopied: 'Webhook URL をコピーしました', webhookUrlHint: diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 27525168..bc75c44f 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -282,6 +282,15 @@ const zhHans = { log: '日志', configuration: '配置', logs: '日志', + basicInfo: '基础信息', + basicInfoDescription: '设置机器人名称和描述', + routingConnection: '路由与连接', + routingConnectionDescription: '绑定处理此机器人消息的流水线', + adapterConfigDescription: '配置所选平台适配器', + dangerZone: '危险区域', + dangerZoneDescription: '不可逆的操作', + deleteBotAction: '删除此机器人', + deleteBotHint: '删除后,所有关联配置将被永久移除,且无法恢复。', webhookUrl: 'Webhook 回调地址', webhookUrlCopied: 'Webhook 地址已复制', webhookUrlHint: diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 6bd8e6e5..a21f750b 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -281,6 +281,15 @@ const zhHant = { log: '日誌', configuration: '設定', logs: '日誌', + basicInfo: '基礎資訊', + basicInfoDescription: '設定機器人名稱和描述', + routingConnection: '路由與連接', + routingConnectionDescription: '綁定處理此機器人訊息的流程線', + adapterConfigDescription: '設定所選平台適配器', + dangerZone: '危險區域', + dangerZoneDescription: '不可逆的操作', + deleteBotAction: '刪除此機器人', + deleteBotHint: '刪除後,所有關聯設定將被永久移除,且無法復原。', webhookUrl: 'Webhook 回調位址', webhookUrlCopied: 'Webhook 位址已複製', webhookUrlHint: