refactor(web): unify settings panel layouts with shared toolbar/body

- Add PanelToolbar/PanelBody primitives so all four settings tabs share
  the same top-toolbar + scrollable-body rhythm under the unified header.
- API panel: drop the heavy gray shadowed TabsList; move the create
  action into the toolbar next to the tabs, lighten per-tab hints.
- Storage panel: reuse PanelToolbar for the generated-at/refresh bar.
- Account panel: wrap content in PanelBody for consistent padding.
- Models panel: keep the pinned LangBot Models (Space) card at the very
  top, above the add-custom-provider row (intentional pin), using
  PanelBody instead of a top toolbar.
This commit is contained in:
RockChinQ
2026-06-16 06:02:20 -04:00
parent e9db858dcc
commit f592656680
14 changed files with 262 additions and 227 deletions
@@ -48,7 +48,6 @@ interface PipelineOption {
} }
interface RoutingRulesEditorProps { interface RoutingRulesEditorProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: UseFormReturn<any>; form: UseFormReturn<any>;
pipelineNameList: PipelineOption[]; pipelineNameList: PipelineOption[];
} }
@@ -14,6 +14,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http'; import { systemInfo } from '@/app/infra/http';
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react'; import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog'; import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
import { PanelBody } from '../settings-dialog/panel-layout';
interface AccountSettingsPanelProps { interface AccountSettingsPanelProps {
// True when this panel is the active section and the dialog is open. // True when this panel is the active section and the dialog is open.
@@ -86,7 +87,7 @@ export default function AccountSettingsPanel({
}; };
return ( return (
<div className="px-6 py-5"> <PanelBody>
{userEmail && ( {userEmail && (
<p className="mb-4 text-sm text-muted-foreground">{userEmail}</p> <p className="mb-4 text-sm text-muted-foreground">{userEmail}</p>
)} )}
@@ -165,6 +166,6 @@ export default function AccountSettingsPanel({
onOpenChange={handlePasswordDialogClose} onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword} hasPassword={hasPassword}
/> />
</div> </PanelBody>
); );
} }
@@ -36,6 +36,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { backendClient } from '@/app/infra/http'; import { backendClient } from '@/app/infra/http';
import { PanelToolbar } from '../settings-dialog/panel-layout';
interface ApiKey { interface ApiKey {
id: number; id: number;
@@ -252,216 +253,209 @@ export default function ApiIntegrationPanel({
return ( return (
<> <>
<div className="flex h-full min-h-0 flex-col px-6 py-5"> <Tabs
<Tabs value={activeTab}
value={activeTab} onValueChange={setActiveTab}
onValueChange={setActiveTab} className="flex h-full min-h-0 w-full flex-col overflow-hidden"
className="flex w-full flex-1 flex-col overflow-hidden" >
> <PanelToolbar>
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]"> <TabsList>
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys"> <TabsTrigger value="apikeys">{t('common.apiKeys')}</TabsTrigger>
{t('common.apiKeys')} <TabsTrigger value="webhooks">{t('common.webhooks')}</TabsTrigger>
</TabsTrigger>
<TabsTrigger className="px-5 py-4 cursor-pointer" value="webhooks">
{t('common.webhooks')}
</TabsTrigger>
</TabsList> </TabsList>
{activeTab === 'apikeys' ? (
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
) : (
<Button
onClick={() => setShowCreateWebhookDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createWebhook')}
</Button>
)}
</PanelToolbar>
{/* API Keys Tab */} {/* API Keys Tab */}
<TabsContent <TabsContent
value="apikeys" value="apikeys"
className="flex flex-1 flex-col space-y-4 overflow-hidden" className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
> >
<div className="flex items-start gap-2 text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t('common.apiKeyHint')} {t('common.apiKeyHint')}
</p>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div> </div>
) : apiKeys.length === 0 ? (
<div className="flex justify-end"> <div className="text-center py-8 text-muted-foreground">
<Button {t('common.noApiKeys')}
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
</div> </div>
) : (
{loading ? ( <div className="flex-1 overflow-auto rounded-md border">
<div className="text-center py-8 text-muted-foreground"> <Table>
{t('common.loading')} <TableHeader>
</div> <TableRow>
) : apiKeys.length === 0 ? ( <TableHead className="min-w-[120px]">
<div className="text-center py-8 text-muted-foreground"> {t('common.name')}
{t('common.noApiKeys')} </TableHead>
</div> <TableHead className="min-w-[200px]">
) : ( {t('common.apiKeyValue')}
<div className="flex-1 overflow-auto rounded-md border"> </TableHead>
<Table> <TableHead className="w-[100px]">
<TableHeader> {t('common.actions')}
<TableRow> </TableHead>
<TableHead className="min-w-[120px]"> </TableRow>
{t('common.name')} </TableHeader>
</TableHead> <TableBody>
<TableHead className="min-w-[200px]"> {apiKeys.map((item) => (
{t('common.apiKeyValue')} <TableRow key={item.id}>
</TableHead> <TableCell>
<TableHead className="w-[100px]"> <div>
{t('common.actions')} <div className="font-medium">{item.name}</div>
</TableHead> {item.description && (
</TableRow> <div className="text-sm text-muted-foreground">
</TableHeader> {item.description}
<TableBody>
{apiKeys.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div>
<div className="font-medium">{item.name}</div>
{item.description && (
<div className="text-sm text-muted-foreground">
{item.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(item.key)}
</code>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')}
>
{copiedKey === item.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(item.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* Webhooks Tab */}
<TabsContent
value="webhooks"
className="flex flex-1 flex-col space-y-4 overflow-hidden"
>
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.webhookHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateWebhookDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createWebhook')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : webhooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noWebhooks')}
</div>
) : (
<div className="max-w-full flex-1 overflow-auto rounded-md border">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">
{t('common.name')}
</TableHead>
<TableHead className="w-[380px]">
{t('common.webhookUrl')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell className="truncate">
<div className="truncate">
<div
className="font-medium truncate"
title={webhook.name}
>
{webhook.name}
</div> </div>
{webhook.description && ( )}
<div </div>
className="text-sm text-muted-foreground truncate" </TableCell>
title={webhook.description} <TableCell>
> <code className="text-sm bg-muted px-2 py-1 rounded">
{webhook.description} {maskApiKey(item.key)}
</div> </code>
)} </TableCell>
</div> <TableCell>
</TableCell> <div className="flex gap-2">
<TableCell>
<div className="overflow-x-auto max-w-[380px]">
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
{webhook.url}
</code>
</div>
</TableCell>
<TableCell>
<Switch
checked={webhook.enabled}
onCheckedChange={() => handleToggleWebhook(webhook)}
/>
</TableCell>
<TableCell>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setDeleteWebhookId(webhook.id)} type="button"
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')}
>
{copiedKey === item.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(item.id)}
title={t('common.delete')} title={t('common.delete')}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</TableCell> </div>
</TableRow> </TableCell>
))} </TableRow>
</TableBody> ))}
</Table> </TableBody>
</div> </Table>
)} </div>
</TabsContent> )}
</Tabs> </TabsContent>
</div>
{/* Webhooks Tab */}
<TabsContent
value="webhooks"
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
>
<p className="text-sm text-muted-foreground">
{t('common.webhookHint')}
</p>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : webhooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noWebhooks')}
</div>
) : (
<div className="max-w-full flex-1 overflow-auto rounded-md border">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">
{t('common.name')}
</TableHead>
<TableHead className="w-[380px]">
{t('common.webhookUrl')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell className="truncate">
<div className="truncate">
<div
className="font-medium truncate"
title={webhook.name}
>
{webhook.name}
</div>
{webhook.description && (
<div
className="text-sm text-muted-foreground truncate"
title={webhook.description}
>
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="overflow-x-auto max-w-[380px]">
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
{webhook.url}
</code>
</div>
</TableCell>
<TableCell>
<Switch
checked={webhook.enabled}
onCheckedChange={() => handleToggleWebhook(webhook)}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteWebhookId(webhook.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
{/* Create API Key Dialog */} {/* Create API Key Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}> <Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
@@ -47,7 +47,6 @@ export function parseDynamicFormItemType(value: string): DynamicFormItemType {
export function getDefaultValues( export function getDefaultValues(
itemConfigList: IDynamicFormItemSchema[], itemConfigList: IDynamicFormItemSchema[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Record<string, any> { ): Record<string, any> {
return itemConfigList.reduce( return itemConfigList.reduce(
(acc, item) => { (acc, item) => {
@@ -59,7 +58,7 @@ export function getDefaultValues(
acc[item.name] = item.default; acc[item.name] = item.default;
return acc; return acc;
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{} as Record<string, any>, {} as Record<string, any>,
); );
} }
@@ -23,6 +23,7 @@ import {
LANGBOT_MODELS_PROVIDER_REQUESTER, LANGBOT_MODELS_PROVIDER_REQUESTER,
} from './types'; } from './types';
import { CustomApiError } from '@/app/infra/entities/common'; import { CustomApiError } from '@/app/infra/entities/common';
import { PanelBody } from '../settings-dialog/panel-layout';
interface ModelsPanelProps { interface ModelsPanelProps {
// True when this panel is the active section and the dialog is open. // True when this panel is the active section and the dialog is open.
@@ -611,12 +612,13 @@ export default function ModelsPanel({
return ( return (
<> <>
<div className="flex-1 overflow-auto px-6 py-5"> <PanelBody>
{/* LangBot Models Card */} {/* LangBot Models (Space) provider card is intentionally pinned to the
top, above the "add custom provider" action row. */}
{langbotProvider && renderProviderCard(langbotProvider, true)} {langbotProvider && renderProviderCard(langbotProvider, true)}
{/* Add Provider Button */} {/* Add-provider row: stays below the pinned card by design. */}
<div className="mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10"> <div className="mb-3 flex items-center justify-between gap-3">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{otherProviders.length === 0 {otherProviders.length === 0
? t( ? t(
@@ -626,12 +628,10 @@ export default function ModelsPanel({
) )
: t('models.providerCount', { count: otherProviders.length })} : t('models.providerCount', { count: otherProviders.length })}
</span> </span>
<div className="flex gap-2"> <Button size="sm" variant="outline" onClick={handleCreateProvider}>
<Button size="sm" variant="outline" onClick={handleCreateProvider}> <Plus className="h-4 w-4 mr-1" />
<Plus className="h-4 w-4 mr-1" /> {t('models.addProvider')}
{t('models.addProvider')} </Button>
</Button>
</div>
</div> </div>
{/* Provider List */} {/* Provider List */}
@@ -643,7 +643,7 @@ export default function ModelsPanel({
) : ( ) : (
otherProviders.map((p) => renderProviderCard(p)) otherProviders.map((p) => renderProviderCard(p))
)} )}
</div> </PanelBody>
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}> <Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
<DialogContent className="w-[600px] p-6"> <DialogContent className="w-[600px] p-6">
@@ -0,0 +1,45 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Shared layout primitives for the settings-dialog panels.
*
* Every section renders under the dialog's unified header, so the panels
* themselves should share the same vertical rhythm: an optional top toolbar
* (meta on the left, primary action on the right) followed by a scrollable
* body with consistent padding. Keeping these in one place is what makes the
* tabs feel like one cohesive surface instead of four separately-styled views.
*/
export function PanelToolbar({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn(
'flex shrink-0 items-center justify-between gap-3 border-b px-6 py-3',
className,
)}
>
{children}
</div>
);
}
export function PanelBody({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn('min-h-0 flex-1 overflow-auto px-6 py-5', className)}>
{children}
</div>
);
}
@@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { backendClient } from '@/app/infra/http'; import { backendClient } from '@/app/infra/http';
import { PanelToolbar } from '../settings-dialog/panel-layout';
interface StorageSection { interface StorageSection {
key: string; key: string;
@@ -137,7 +138,7 @@ export default function StorageAnalysisPanel({
return ( return (
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-6 py-4"> <PanelToolbar>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{analysis {analysis
? t('storageAnalysis.generatedAt', { ? t('storageAnalysis.generatedAt', {
@@ -156,7 +157,7 @@ export default function StorageAnalysisPanel({
/> />
{t('storageAnalysis.refresh')} {t('storageAnalysis.refresh')}
</Button> </Button>
</div> </PanelToolbar>
<ScrollArea className="min-h-0 flex-1 overflow-hidden"> <ScrollArea className="min-h-0 flex-1 overflow-hidden">
<div className="space-y-5 px-6 py-5"> <div className="space-y-5 px-6 py-5">
@@ -82,7 +82,6 @@ export default function SystemStatusCard({
fetchStatus(); fetchStatus();
const interval = setInterval(fetchStatus, 30_000); const interval = setInterval(fetchStatus, 30_000);
return () => clearInterval(interval); return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchStatus, refreshKey]); }, [fetchStatus, refreshKey]);
const pluginOk = pluginStatus const pluginOk = pluginStatus
@@ -323,7 +323,6 @@ export default function PipelineFormComponent({
const isFirstEmission = !initializedStagesRef.current.has(stageKey); const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const currentValues = const currentValues =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {}; (form.getValues(formName) as Record<string, any>) || {};
form.setValue(formName, { form.setValue(formName, {
...currentValues, ...currentValues,
@@ -368,7 +367,6 @@ export default function PipelineFormComponent({
<DynamicFormComponent <DynamicFormComponent
itemConfigList={stage.config} itemConfigList={stage.config}
initialValues={ initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] || (form.watch(formName) as Record<string, any>)?.[stage.name] ||
{} {}
} }
@@ -402,7 +400,6 @@ export default function PipelineFormComponent({
<N8nAuthFormComponent <N8nAuthFormComponent
itemConfigList={stage.config} itemConfigList={stage.config}
initialValues={ initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] || (form.watch(formName) as Record<string, any>)?.[stage.name] ||
{} {}
} }
@@ -445,7 +442,7 @@ export default function PipelineFormComponent({
// make the locked selector display a scope that is NOT the one actually in // make the locked selector display a scope that is NOT the one actually in
// effect. Coerce the displayed/saved value to the forced template so the UI // effect. Coerce the displayed/saved value to the forced template so the UI
// truthfully reflects runtime behavior. // truthfully reflects runtime behavior.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stageInitialValues: Record<string, any> = const stageInitialValues: Record<string, any> =
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}; (form.watch(formName) as Record<string, any>)?.[stage.name] || {};
const effectiveInitialValues = const effectiveInitialValues =
@@ -9,7 +9,7 @@ export interface IPluginCardVO {
enabled: boolean; enabled: boolean;
priority: number; priority: number;
install_source: string; install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any install_info: Record<string, any>;
status: string; status: string;
components: PluginComponent[]; components: PluginComponent[];
debug: boolean; debug: boolean;
@@ -27,7 +27,7 @@ export class PluginCardVO implements IPluginCardVO {
priority: number; priority: number;
debug: boolean; debug: boolean;
install_source: string; install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any install_info: Record<string, any>;
status: string; status: string;
components: PluginComponent[]; components: PluginComponent[];
hasUpdate?: boolean; hasUpdate?: boolean;
+1 -1
View File
@@ -21,7 +21,7 @@ export interface ComponentManifest {
version?: string; version?: string;
author?: string; author?: string;
}; };
spec: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any spec: Record<string, any>;
} }
export interface CustomApiError { export interface CustomApiError {
+1 -1
View File
@@ -8,7 +8,7 @@ export const SYSTEM_FIELD_PREFIX = '__system.';
export interface IShowIfCondition { export interface IShowIfCondition {
field: string; field: string;
operator: 'eq' | 'neq' | 'in'; operator: 'eq' | 'neq' | 'in';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any; value: any;
} }
+1 -1
View File
@@ -10,7 +10,7 @@ export interface Plugin {
debug: boolean; debug: boolean;
enabled: boolean; enabled: boolean;
install_source: string; install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any install_info: Record<string, any>;
components: PluginComponent[]; components: PluginComponent[];
} }
+1 -1
View File
@@ -86,7 +86,7 @@ export default function WizardPage() {
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null); const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
const [selectedRunner, setSelectedRunner] = useState<string | null>(null); const [selectedRunner, setSelectedRunner] = useState<string | null>(null);
const [botName, setBotName] = useState(''); const [botName, setBotName] = useState('');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [botDescription, _setBotDescription] = useState(''); const [botDescription, _setBotDescription] = useState('');
const [adapterConfig, setAdapterConfig] = useState<Record<string, unknown>>( const [adapterConfig, setAdapterConfig] = useState<Record<string, unknown>>(
{}, {},