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

View File

@@ -48,7 +48,6 @@ interface PipelineOption {
}
interface RoutingRulesEditorProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: UseFormReturn<any>;
pipelineNameList: PipelineOption[];
}

View File

@@ -14,6 +14,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
import { PanelBody } from '../settings-dialog/panel-layout';
interface AccountSettingsPanelProps {
// True when this panel is the active section and the dialog is open.
@@ -86,7 +87,7 @@ export default function AccountSettingsPanel({
};
return (
<div className="px-6 py-5">
<PanelBody>
{userEmail && (
<p className="mb-4 text-sm text-muted-foreground">{userEmail}</p>
)}
@@ -165,6 +166,6 @@ export default function AccountSettingsPanel({
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
</div>
</PanelBody>
);
}

View File

@@ -36,6 +36,7 @@ import {
} from '@/components/ui/alert-dialog';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { backendClient } from '@/app/infra/http';
import { PanelToolbar } from '../settings-dialog/panel-layout';
interface ApiKey {
id: number;
@@ -252,216 +253,209 @@ export default function ApiIntegrationPanel({
return (
<>
<div className="flex h-full min-h-0 flex-col px-6 py-5">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex w-full flex-1 flex-col overflow-hidden"
>
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
{t('common.apiKeys')}
</TabsTrigger>
<TabsTrigger className="px-5 py-4 cursor-pointer" value="webhooks">
{t('common.webhooks')}
</TabsTrigger>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex h-full min-h-0 w-full flex-col overflow-hidden"
>
<PanelToolbar>
<TabsList>
<TabsTrigger value="apikeys">{t('common.apiKeys')}</TabsTrigger>
<TabsTrigger value="webhooks">{t('common.webhooks')}</TabsTrigger>
</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 */}
<TabsContent
value="apikeys"
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.apiKeyHint')}
{/* API Keys Tab */}
<TabsContent
value="apikeys"
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
>
<p className="text-sm text-muted-foreground">
{t('common.apiKeyHint')}
</p>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noApiKeys')}
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noApiKeys')}
</div>
) : (
<div className="flex-1 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">
{t('common.name')}
</TableHead>
<TableHead className="min-w-[200px]">
{t('common.apiKeyValue')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<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 className="flex-1 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">
{t('common.name')}
</TableHead>
<TableHead className="min-w-[200px]">
{t('common.apiKeyValue')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<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>
{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>
)}
</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"
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')}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
</div>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* 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 */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>

View File

@@ -47,7 +47,6 @@ export function parseDynamicFormItemType(value: string): DynamicFormItemType {
export function getDefaultValues(
itemConfigList: IDynamicFormItemSchema[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Record<string, any> {
return itemConfigList.reduce(
(acc, item) => {
@@ -59,7 +58,7 @@ export function getDefaultValues(
acc[item.name] = item.default;
return acc;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{} as Record<string, any>,
);
}

View File

@@ -23,6 +23,7 @@ import {
LANGBOT_MODELS_PROVIDER_REQUESTER,
} from './types';
import { CustomApiError } from '@/app/infra/entities/common';
import { PanelBody } from '../settings-dialog/panel-layout';
interface ModelsPanelProps {
// True when this panel is the active section and the dialog is open.
@@ -611,12 +612,13 @@ export default function ModelsPanel({
return (
<>
<div className="flex-1 overflow-auto px-6 py-5">
{/* LangBot Models Card */}
<PanelBody>
{/* LangBot Models (Space) provider card is intentionally pinned to the
top, above the "add custom provider" action row. */}
{langbotProvider && renderProviderCard(langbotProvider, true)}
{/* Add Provider Button */}
<div className="mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10">
{/* Add-provider row: stays below the pinned card by design. */}
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
{otherProviders.length === 0
? t(
@@ -626,12 +628,10 @@ export default function ModelsPanel({
)
: t('models.providerCount', { count: otherProviders.length })}
</span>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
</div>
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
</div>
{/* Provider List */}
@@ -643,7 +643,7 @@ export default function ModelsPanel({
) : (
otherProviders.map((p) => renderProviderCard(p))
)}
</div>
</PanelBody>
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
<DialogContent className="w-[600px] p-6">

View File

@@ -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>
);
}

View File

@@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { backendClient } from '@/app/infra/http';
import { PanelToolbar } from '../settings-dialog/panel-layout';
interface StorageSection {
key: string;
@@ -137,7 +138,7 @@ export default function StorageAnalysisPanel({
return (
<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">
{analysis
? t('storageAnalysis.generatedAt', {
@@ -156,7 +157,7 @@ export default function StorageAnalysisPanel({
/>
{t('storageAnalysis.refresh')}
</Button>
</div>
</PanelToolbar>
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<div className="space-y-5 px-6 py-5">

View File

@@ -82,7 +82,6 @@ export default function SystemStatusCard({
fetchStatus();
const interval = setInterval(fetchStatus, 30_000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchStatus, refreshKey]);
const pluginOk = pluginStatus

View File

@@ -323,7 +323,6 @@ export default function PipelineFormComponent({
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const currentValues =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {};
form.setValue(formName, {
...currentValues,
@@ -368,7 +367,6 @@ export default function PipelineFormComponent({
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
{}
}
@@ -402,7 +400,6 @@ export default function PipelineFormComponent({
<N8nAuthFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(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
// effect. Coerce the displayed/saved value to the forced template so the UI
// truthfully reflects runtime behavior.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stageInitialValues: Record<string, any> =
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
const effectiveInitialValues =

View File

@@ -9,7 +9,7 @@ export interface IPluginCardVO {
enabled: boolean;
priority: number;
install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
install_info: Record<string, any>;
status: string;
components: PluginComponent[];
debug: boolean;
@@ -27,7 +27,7 @@ export class PluginCardVO implements IPluginCardVO {
priority: number;
debug: boolean;
install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
install_info: Record<string, any>;
status: string;
components: PluginComponent[];
hasUpdate?: boolean;

View File

@@ -21,7 +21,7 @@ export interface ComponentManifest {
version?: string;
author?: string;
};
spec: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
spec: Record<string, any>;
}
export interface CustomApiError {

View File

@@ -8,7 +8,7 @@ export const SYSTEM_FIELD_PREFIX = '__system.';
export interface IShowIfCondition {
field: string;
operator: 'eq' | 'neq' | 'in';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
}

View File

@@ -10,7 +10,7 @@ export interface Plugin {
debug: boolean;
enabled: boolean;
install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
install_info: Record<string, any>;
components: PluginComponent[];
}

View File

@@ -86,7 +86,7 @@ export default function WizardPage() {
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
const [selectedRunner, setSelectedRunner] = useState<string | null>(null);
const [botName, setBotName] = useState('');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [botDescription, _setBotDescription] = useState('');
const [adapterConfig, setAdapterConfig] = useState<Record<string, unknown>>(
{},