UI: dashboards split

This commit is contained in:
qixinbo
2026-03-22 16:26:23 +08:00
parent 256832d2e7
commit 995de29981
7 changed files with 592 additions and 167 deletions
@@ -1,7 +1,8 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Code, Table as TableIcon, BarChart as ChartIcon, LayoutDashboard, Copy, Check } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -31,11 +32,24 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
const { addChart } = useDashboardStore();
const [selectedDashboardId, setSelectedDashboardId] = useState<string>('');
const { dashboards, addChart, loadDashboards } = useDashboardStore();
const { currentProject } = useProjectStore();
const objectRows = viz.rows.filter((row) => row && typeof row === "object" && !Array.isArray(row)) as Record<string, unknown>[];
const columns = objectRows.length > 0 ? Object.keys(objectRows[0]) : [];
useEffect(() => {
if (currentProject) {
loadDashboards(currentProject.id);
}
}, [currentProject, loadDashboards]);
useEffect(() => {
if (dashboards.length > 0 && !selectedDashboardId) {
setSelectedDashboardId(dashboards[0].id);
}
}, [dashboards, selectedDashboardId]);
const buildPendingChart = (): Omit<ChartConfig, 'layout'> => {
if (view === "table") {
return {
@@ -68,8 +82,8 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
};
const handleConfirmAdd = () => {
if (!pendingChart || !currentProject) return;
addChart(pendingChart, currentProject.id);
if (!pendingChart || !currentProject || !selectedDashboardId) return;
addChart(pendingChart, selectedDashboardId, currentProject.id);
setConfirmOpen(false);
setPendingChart(null);
};
@@ -165,7 +179,7 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
</Dialog>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAddToDashboard} disabled={objectRows.length === 0}>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAddToDashboard} disabled={objectRows.length === 0 || dashboards.length === 0}>
<LayoutDashboard className="h-3.5 w-3.5 mr-1.5" />
Add to Dashboard
</Button>
@@ -206,11 +220,26 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('confirmAddToDashboard')}</DialogTitle>
<DialogTitle>{t('pinChartToDashboard')}</DialogTitle>
<DialogDescription>
{t('confirmAddChartToDashboardDesc')}
{t('selectDashboardToPin')}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<label className="text-sm font-medium mb-2 block">{t('dashboardMenu')}</label>
<Select value={selectedDashboardId} onValueChange={(val) => { if (val) setSelectedDashboardId(val); }}>
<SelectTrigger>
<SelectValue placeholder={t('selectDashboard')}>
{dashboards.find(d => d.id === selectedDashboardId)?.name || t('selectDashboard')}
</SelectValue>
</SelectTrigger>
<SelectContent>
{dashboards.map(d => (
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
variant="outline"
@@ -221,8 +250,8 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
>
{t('cancel')}
</Button>
<Button onClick={handleConfirmAdd}>
{t('confirmAdd')}
<Button onClick={handleConfirmAdd} disabled={!selectedDashboardId}>
{t('submit')}
</Button>
</DialogFooter>
</DialogContent>
+317 -78
View File
@@ -6,6 +6,8 @@ import { useState, useRef, useEffect } from "react";
import { Link, useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/store/authStore";
import { useProjectStore } from "@/store/projectStore";
import { useDashboardStore } from "@/store/dashboardStore";
import { api } from "@/lib/api";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
@@ -23,39 +25,26 @@ interface SessionInfo {
};
}
function Section({
function SectionHeader({
title,
count,
isSelectionMode,
setIsSelectionMode,
selectedKeys,
setSelectedKeys,
items,
onSelect,
onDelete,
onRename,
onTogglePinned,
onToggleArchived,
onBatchDelete,
activeKey
onBatchDelete
}: {
title: string;
count: number;
isSelectionMode: boolean;
setIsSelectionMode: (val: boolean) => void;
selectedKeys: string[];
setSelectedKeys: (val: string[] | ((prev: string[]) => string[])) => void;
items: SessionInfo[];
onSelect: (key: string) => void;
onDelete: (key: string) => void;
onRename: (key: string, currentTitle: string) => void;
onTogglePinned: (key: string, pinned: boolean) => void;
onToggleArchived: (key: string, archived: boolean) => void;
onBatchDelete: (keys: string[]) => void;
activeKey: string | null;
}) {
const { t } = useTranslation();
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const toggleSelect = (key: string, e: React.MouseEvent) => {
e.stopPropagation();
setSelectedKeys(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const handleSelectAll = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -84,12 +73,12 @@ function Section({
if (!isSelectionMode) {
setSelectedKeys([]);
}
}, [isSelectionMode]);
}, [isSelectionMode, setSelectedKeys]);
return (
<div className="px-3 pt-6">
<div className="flex items-center justify-between mb-2 px-1 group">
<div className="text-xs font-semibold text-zinc-500 flex items-center gap-1 uppercase tracking-wider">
<div className="px-3 pt-4 pb-1">
<div className="flex items-center justify-between px-1 group">
<div className="text-[14px] font-semibold text-zinc-500 flex items-center gap-1">
{title}
<span>({count})</span>
</div>
@@ -140,7 +129,49 @@ function Section({
)}
</div>
</div>
<div className="space-y-0.5 mt-2">
</div>
);
}
function Section({
items,
onSelect,
onDelete,
onRename,
onTogglePinned,
onToggleArchived,
onBatchDelete,
activeKey,
isSelectionMode,
setIsSelectionMode,
selectedKeys,
setSelectedKeys
}: {
items: SessionInfo[];
onSelect: (key: string) => void;
onDelete: (key: string) => void;
onRename: (key: string, currentTitle: string) => void;
onTogglePinned: (key: string, pinned: boolean) => void;
onToggleArchived: (key: string, archived: boolean) => void;
onBatchDelete: (keys: string[]) => void;
activeKey: string | null;
isSelectionMode: boolean;
setIsSelectionMode: (val: boolean) => void;
selectedKeys: string[];
setSelectedKeys: (val: string[] | ((prev: string[]) => string[])) => void;
}) {
const { t } = useTranslation();
const toggleSelect = (key: string, e: React.MouseEvent) => {
e.stopPropagation();
setSelectedKeys(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
return (
<div className="px-3 pb-2">
<div className="space-y-0 mt-1">
{items.map((item) => {
const displayTitle = item.metadata?.title || item.key.replace("api:", "");
const isActive = activeKey === item.key;
@@ -149,11 +180,11 @@ function Section({
return (
<div
key={item.key}
className={`w-full h-9 px-2 text-left rounded-md text-[14px] flex items-center justify-between group transition-colors cursor-pointer ${
className={`w-full h-8 px-2 text-left rounded-md text-[14px] flex items-center justify-between group transition-colors cursor-pointer ${
isActive && !isSelectionMode ? 'bg-zinc-100 text-zinc-900 font-medium' : 'text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900'
} ${isSelected ? 'bg-indigo-50/50 text-indigo-700' : ''}`}
onClick={(e) => isSelectionMode ? toggleSelect(item.key, e) : onSelect(item.key)}
>
onClick={(e) => isSelectionMode ? toggleSelect(item.key, e) : onSelect(item.key)}
>
<div className="truncate pr-2 flex-1 flex items-center gap-1.5 min-w-0">
{isSelectionMode ? (
<span
@@ -242,7 +273,7 @@ function Section({
<span>{t('deleteSession')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenu>
)}
</div>
);
@@ -252,10 +283,100 @@ function Section({
);
}
function DashboardSection({
title,
count,
items,
onSelect,
onDelete,
onRename,
onCreate,
activeId
}: {
title: string;
count: number;
items: {id: string, name: string}[];
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onRename: (id: string, currentName: string) => void;
onCreate: () => void;
activeId: string | null;
}) {
const { t } = useTranslation();
return (
<div className="px-3 pt-4">
<div className="flex items-center justify-between mb-1.5 px-1 group">
<div className="text-[14px] font-semibold text-zinc-500 flex items-center gap-1">
{title}
<span>({count})</span>
</div>
<button
onClick={onCreate}
className="text-[10px] font-medium px-1.5 py-0.5 hover:bg-zinc-200 rounded text-zinc-500 transition-colors opacity-0 group-hover:opacity-100 flex items-center gap-0.5"
>
<Plus className="h-3.5 w-3.5" />
{t('new')}
</button>
</div>
<div className="space-y-0 mt-1">
{items.map((item) => {
const isActive = activeId === item.id;
return (
<div
key={item.id}
className={`w-full h-8 px-2 text-left rounded-md text-[14px] flex items-center justify-between group transition-colors cursor-pointer ${
isActive ? 'bg-zinc-100 text-zinc-900 font-medium' : 'text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900'
}`}
onClick={() => onSelect(item.id)}
>
<div className="truncate pr-2 flex-1 flex items-center gap-1.5 min-w-0">
<span className="w-4 shrink-0 flex items-center justify-center">
<LayoutDashboard className="h-3.5 w-3.5 text-zinc-400" />
</span>
<span className="truncate">{item.name}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()} className="h-6 w-6 flex items-center justify-center rounded hover:bg-zinc-200 text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity outline-none">
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRename(item.id, item.name);
}}
>
<Pencil className="mr-2 h-4 w-4" />
<span>{t('rename')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(item.id);
}}
className="text-red-600 focus:text-red-600 focus:bg-red-50"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t('deleteSession')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
</div>
);
}
function SidebarBody() {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const { currentProject } = useProjectStore();
const { dashboards, activeDashboardId, loadDashboards, createDashboard, deleteDashboard, renameDashboard, setActiveDashboard } = useDashboardStore();
const { t, i18n } = useTranslation();
const [showUserMenu, setShowUserMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -267,10 +388,25 @@ function SidebarBody() {
const [sessionToRename, setSessionToRename] = useState<{key: string, title: string} | null>(null);
const [newTitle, setNewTitle] = useState("");
const [activeSelectionMode, setActiveSelectionMode] = useState(false);
const [activeSelectedKeys, setActiveSelectedKeys] = useState<string[]>([]);
const [archivedSelectionMode, setArchivedSelectionMode] = useState(false);
const [archivedSelectedKeys, setArchivedSelectedKeys] = useState<string[]>([]);
const [dashboardRenameDialogOpen, setDashboardRenameDialogOpen] = useState(false);
const [dashboardToRename, setDashboardToRename] = useState<{id: string, name: string} | null>(null);
const [newDashboardName, setNewDashboardName] = useState("");
// Try to parse active session from URL query
const queryParams = new URLSearchParams(location.search);
const activeSessionKey = queryParams.get("session") || "api:default";
useEffect(() => {
if (currentProject) {
loadDashboards(currentProject.id);
}
}, [currentProject, loadDashboards]);
const fetchSessions = async () => {
try {
const data = await api.get<SessionInfo[]>("/nanobot/sessions");
@@ -440,6 +576,44 @@ function SidebarBody() {
return title.includes(normalizedFilter);
});
const handleCreateDashboard = () => {
if (!currentProject) return;
if (dashboards.length >= 3) {
alert(t('dashboardLimitReached') || "You can only create up to 3 dashboards.");
return;
}
createDashboard(t('newDashboardNameDefault'), currentProject.id);
navigate(`/dashboard`);
};
const handleSelectDashboard = (id: string) => {
setActiveDashboard(id);
navigate(`/dashboard`);
};
const openDashboardRenameDialog = (id: string, name: string) => {
setDashboardToRename({ id, name });
setNewDashboardName(name);
setDashboardRenameDialogOpen(true);
};
const handleDashboardRename = () => {
if (!currentProject || !dashboardToRename || !newDashboardName.trim()) return;
renameDashboard(dashboardToRename.id, newDashboardName.trim(), currentProject.id);
setDashboardRenameDialogOpen(false);
};
const handleDashboardDelete = (id: string) => {
if (!currentProject) return;
if (!window.confirm(t('confirmDeleteDashboard'))) return;
deleteDashboard(id, currentProject.id);
};
const filteredDashboards = dashboards.filter((d) => {
if (!normalizedFilter) return true;
return d.name.toLowerCase().includes(normalizedFilter);
});
return (
<div className="h-full min-h-0 flex flex-col bg-zinc-50/30 border-r border-zinc-200 relative">
{/* Header */}
@@ -453,63 +627,103 @@ function SidebarBody() {
<div className="w-8" />
</div>
<div className="px-3 pt-4 space-y-2">
<Button
variant="secondary"
className="w-full justify-start h-10 px-3 rounded-lg bg-zinc-200/50 hover:bg-zinc-200 text-zinc-900 font-medium"
onClick={() => navigate("/dashboard")}
>
<LayoutDashboard className="h-4.5 w-4.5 mr-2 text-zinc-600" />
{t('dashboardMenu')}
</Button>
<div className="flex-none">
<DashboardSection
title={t('dashboards') || 'Dashboards'}
count={filteredDashboards.length}
items={filteredDashboards.map(d => ({ id: d.id, name: d.name }))}
onSelect={handleSelectDashboard}
onDelete={handleDashboardDelete}
onRename={openDashboardRenameDialog}
onCreate={handleCreateDashboard}
activeId={location.pathname === "/dashboard" ? activeDashboardId : null}
/>
<Button
variant="outline"
className="w-full justify-start h-10 px-3 rounded-lg border-zinc-200 bg-white hover:bg-zinc-50 text-zinc-600 font-medium"
onClick={handleNewThread}
>
<Plus className="h-4 w-4 mr-2" />
{t('newThread')}
</Button>
</div>
<div className="px-3 pt-4 mb-2">
<Button
variant="outline"
className="w-full justify-start h-10 px-3 rounded-lg border-zinc-200 bg-white hover:bg-zinc-50 text-zinc-600 font-medium text-[14px]"
onClick={handleNewThread}
>
<Plus className="h-4 w-4 mr-2" />
{t('newThread')}
</Button>
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="px-3 pt-4">
<div className="px-3 pt-2">
<div className="relative">
<Search className="h-4 w-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
<Input
value={sessionFilter}
onChange={(e) => setSessionFilter(e.target.value)}
placeholder={t('filterSessionName')}
className="pl-9 h-9 border-zinc-200 bg-white"
className="pl-9 h-9 border-zinc-200 bg-white text-[14px]"
/>
</div>
</div>
<Section
title={t('threads')}
count={activeSessions.length}
items={activeSessions}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onBatchDelete={handleBatchDelete}
onRename={openRenameDialog}
onTogglePinned={handleTogglePinned}
onToggleArchived={handleToggleArchived}
activeKey={activeSessionKey}
/>
<Section
title={t('archivedThreads')}
count={archivedSessions.length}
items={archivedSessions}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onBatchDelete={handleBatchDelete}
onRename={openRenameDialog}
onTogglePinned={handleTogglePinned}
onToggleArchived={handleToggleArchived}
activeKey={activeSessionKey}
/>
</ScrollArea>
</div>
<div className="flex-1 min-h-0 flex flex-col mt-2">
<div className="flex-1 min-h-0 flex flex-col">
<SectionHeader
title={t('threads')}
count={activeSessions.length}
isSelectionMode={activeSelectionMode}
setIsSelectionMode={setActiveSelectionMode}
selectedKeys={activeSelectedKeys}
setSelectedKeys={setActiveSelectedKeys}
items={activeSessions}
onBatchDelete={handleBatchDelete}
/>
<ScrollArea className="flex-1 min-h-0">
<Section
items={activeSessions}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onBatchDelete={handleBatchDelete}
onRename={openRenameDialog}
onTogglePinned={handleTogglePinned}
onToggleArchived={handleToggleArchived}
activeKey={activeSessionKey}
isSelectionMode={activeSelectionMode}
setIsSelectionMode={setActiveSelectionMode}
selectedKeys={activeSelectedKeys}
setSelectedKeys={setActiveSelectedKeys}
/>
</ScrollArea>
</div>
{archivedSessions.length > 0 && (
<div className="h-[35%] min-h-[150px] shrink-0 border-t border-zinc-200 bg-zinc-50/50 flex flex-col">
<SectionHeader
title={t('archivedThreads')}
count={archivedSessions.length}
isSelectionMode={archivedSelectionMode}
setIsSelectionMode={setArchivedSelectionMode}
selectedKeys={archivedSelectedKeys}
setSelectedKeys={setArchivedSelectedKeys}
items={archivedSessions}
onBatchDelete={handleBatchDelete}
/>
<ScrollArea className="flex-1 min-h-0">
<Section
items={archivedSessions}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onBatchDelete={handleBatchDelete}
onRename={openRenameDialog}
onTogglePinned={handleTogglePinned}
onToggleArchived={handleToggleArchived}
activeKey={activeSessionKey}
isSelectionMode={archivedSelectionMode}
setIsSelectionMode={setArchivedSelectionMode}
selectedKeys={archivedSelectedKeys}
setSelectedKeys={setArchivedSelectedKeys}
/>
</ScrollArea>
</div>
)}
</div>
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
@@ -536,6 +750,31 @@ function SidebarBody() {
</DialogContent>
</Dialog>
<Dialog open={dashboardRenameDialogOpen} onOpenChange={setDashboardRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('renameDashboard')}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
value={newDashboardName}
onChange={(e) => setNewDashboardName(e.target.value)}
placeholder={t('enterNewDashboardName')}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleDashboardRename();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDashboardRenameDialogOpen(false)}>{t('cancel')}</Button>
<Button className="bg-indigo-600 hover:bg-indigo-700 text-white" onClick={handleDashboardRename}>{t('save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="p-4 border-t border-zinc-200 mt-auto relative" ref={menuRef}>
<div className="flex items-center justify-between text-zinc-600">
<button
+37 -8
View File
@@ -1,7 +1,8 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Code, Table as TableIcon, BarChart as ChartIcon, Download, LayoutDashboard, Loader2 } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -16,10 +17,23 @@ export function VisualizationPanel() {
const [view, setView] = useState<'table' | 'chart'>('chart');
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
const { addChart } = useDashboardStore();
const [selectedDashboardId, setSelectedDashboardId] = useState<string>('');
const { dashboards, addChart, loadDashboards } = useDashboardStore();
const { currentProject } = useProjectStore();
const { currentData, currentSQL, currentChartSpec, currentChartInfo, isLoading, error } = useVisualizationStore();
useEffect(() => {
if (currentProject) {
loadDashboards(currentProject.id);
}
}, [currentProject, loadDashboards]);
useEffect(() => {
if (dashboards.length > 0 && !selectedDashboardId) {
setSelectedDashboardId(dashboards[0].id);
}
}, [dashboards, selectedDashboardId]);
const buildPendingChart = (): Omit<ChartConfig, 'layout'> | null => {
if (!currentData || !currentSQL) return null;
if (view === "table") {
@@ -54,8 +68,8 @@ export function VisualizationPanel() {
};
const handleConfirmAdd = () => {
if (!pendingChart || !currentProject) return;
addChart(pendingChart, currentProject.id);
if (!pendingChart || !currentProject || !selectedDashboardId) return;
addChart(pendingChart, selectedDashboardId, currentProject.id);
setConfirmOpen(false);
setPendingChart(null);
};
@@ -127,7 +141,7 @@ export function VisualizationPanel() {
</Button>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAddToDashboard}>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAddToDashboard} disabled={dashboards.length === 0}>
<LayoutDashboard className="h-3.5 w-3.5 mr-1.5" />
Add to Dashboard
</Button>
@@ -208,11 +222,26 @@ export function VisualizationPanel() {
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('confirmAddToDashboard')}</DialogTitle>
<DialogTitle>{t('pinChartToDashboard')}</DialogTitle>
<DialogDescription>
{t('confirmAddChartToDashboardDesc')}
{t('selectDashboardToPin')}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<label className="text-sm font-medium mb-2 block">{t('dashboardMenu')}</label>
<Select value={selectedDashboardId} onValueChange={(val) => { if (val) setSelectedDashboardId(val); }}>
<SelectTrigger>
<SelectValue placeholder={t('selectDashboard')}>
{dashboards.find(d => d.id === selectedDashboardId)?.name || t('selectDashboard')}
</SelectValue>
</SelectTrigger>
<SelectContent>
{dashboards.map(d => (
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
variant="outline"
@@ -223,7 +252,7 @@ export function VisualizationPanel() {
>
{t('cancel')}
</Button>
<Button onClick={handleConfirmAdd}>{t('confirmAdd')}</Button>
<Button onClick={handleConfirmAdd} disabled={!selectedDashboardId}>{t('submit')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+23 -9
View File
@@ -10,18 +10,18 @@
"unarchive": "Unarchive",
"deleteSession": "Delete Session",
"confirmDeleteSession": "Are you sure you want to delete this session?",
"confirmBatchDeleteSessions": "Are you sure you want to delete the selected {{count}} sessions?",
"filterSessionName": "Filter session name",
"renameSession": "Rename Session",
"enterNewSessionTitle": "Enter new session title",
"confirmBatchDeleteSessions": "Are you sure you want to delete the selected {{count}} threads?",
"filterSessionName": "Filter thread name",
"renameSession": "Rename thread",
"enterNewSessionTitle": "Enter new thread title",
"save": "Save",
"lobsterDataQA": "DataClaw",
"skillCenter": "Skill Center",
"projectManagement": "Project Management",
"dataSourceManagement": "Data Sources",
"personalSettings": "Settings",
"modelConfig": "Model Config",
"userManagement": "Users",
"dataSourceManagement": "Data Source Management",
"personalSettings": "Personal Settings",
"modelConfig": "Model Configuration",
"userManagement": "User Management",
"logout": "Logout",
"searchModel": "Search model...",
"modelNotFound": "Model not found",
@@ -204,5 +204,19 @@
"newThread": "New Thread",
"threads": "THREADS",
"archivedThreads": "ARCHIVED THREADS",
"defaultUser": "User"
"defaultUser": "Default User",
"searchSkills": "Search skills...",
"selectDashboard": "Select a dashboard",
"submit": "Submit",
"noDashboardsInCurrentProject": "No dashboards in current project",
"createDashboardToGetStarted": "Create a new dashboard from the sidebar to get started",
"confirmDeleteDashboard": "Are you sure you want to delete this dashboard?",
"renameDashboard": "Rename Dashboard",
"enterNewDashboardName": "Enter new dashboard name",
"newDashboardNameDefault": "New Dashboard",
"dashboardLimitReached": "You can only create up to 3 dashboards.",
"dashboards": "Dashboards",
"new": "New",
"pinChartToDashboard": "Pin chart to dashboard",
"selectDashboardToPin": "Select a dashboard to pin this chart to."
}
+16 -2
View File
@@ -1,5 +1,5 @@
{
"selectAllOrCancel": "全选/取消全选",
"selectAllOrCancel": "全选 / 取消全选",
"invertSelection": "反选",
"batchDelete": "批量删除",
"cancel": "取消",
@@ -11,6 +11,19 @@
"deleteSession": "删除会话",
"confirmDeleteSession": "确定要删除这个会话吗?",
"confirmBatchDeleteSessions": "确定要删除选中的 {{count}} 个会话吗?",
"confirmDeleteDashboard": "确定要删除此仪表盘吗?",
"renameDashboard": "重命名仪表盘",
"enterNewDashboardName": "输入新的仪表盘名称",
"newDashboardNameDefault": "新仪表盘",
"dashboardLimitReached": "最多只能创建 3 个仪表盘。",
"dashboards": "仪表盘",
"new": "新建",
"pinChartToDashboard": "固定图表到仪表盘",
"selectDashboardToPin": "选择一个仪表盘以固定此图表。",
"selectDashboard": "选择一个仪表盘",
"submit": "提交",
"noDashboardsInCurrentProject": "当前项目下没有仪表盘",
"createDashboardToGetStarted": "从侧边栏创建一个新仪表盘以开始",
"filterSessionName": "过滤会话名称",
"renameSession": "重命名会话",
"enterNewSessionTitle": "输入新的会话标题",
@@ -204,5 +217,6 @@
"newThread": "新会话",
"threads": "会话",
"archivedThreads": "已归档会话",
"defaultUser": "用户"
"defaultUser": "默认用户",
"searchSkills": "搜索技能..."
}
+23 -7
View File
@@ -45,14 +45,20 @@ function inferChartKeys(data: Record<string, unknown>[]) {
export function Dashboard() {
const { t } = useTranslation();
const { charts, removeChart, updateLayout, loadCharts } = useDashboardStore();
const { dashboards, activeDashboardId, removeChart, updateLayout, loadDashboards } = useDashboardStore();
const { currentProject } = useProjectStore();
useEffect(() => {
if (currentProject) {
loadCharts(currentProject.id);
loadDashboards(currentProject.id);
}
}, [currentProject, loadCharts]);
}, [currentProject, loadDashboards]);
const activeDashboard = useMemo(() => {
return dashboards.find((d) => d.id === activeDashboardId) || dashboards[0] || null;
}, [dashboards, activeDashboardId]);
const charts = activeDashboard?.charts || [];
const ResponsiveGridLayout = useMemo(
() => WidthProvider(Responsive as any) as any,
@@ -64,7 +70,7 @@ export function Dashboard() {
}), [charts]);
const onLayoutChange = (currentLayout: any[]) => {
if (currentProject) {
if (currentProject && activeDashboard) {
updateLayout(
currentLayout.map((item) => ({
i: item.i,
@@ -73,6 +79,7 @@ export function Dashboard() {
w: item.w,
h: item.h,
})),
activeDashboard.id,
currentProject.id
);
}
@@ -86,7 +93,16 @@ export function Dashboard() {
);
}
if (charts.length === 0) {
if (dashboards.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<p>{t('noDashboardsInCurrentProject')}</p>
<p className="text-sm">{t('createDashboardToGetStarted')}</p>
</div>
);
}
if (!activeDashboard || charts.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<p>{t('noChartsInCurrentProject')}</p>
@@ -97,7 +113,7 @@ export function Dashboard() {
return (
<div className="p-4 h-full overflow-y-auto">
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
<h1 className="text-2xl font-bold mb-4">{activeDashboard.name || t('dashboardMenu')}</h1>
<ResponsiveGridLayout
className="layout"
layouts={layouts}
@@ -129,7 +145,7 @@ export function Dashboard() {
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeChart(chart.id, currentProject.id)}
onClick={() => removeChart(chart.id, activeDashboard.id, currentProject.id)}
>
<X className="h-4 w-4" />
</Button>
+137 -53
View File
@@ -14,80 +14,164 @@ export interface ChartConfig {
layout: GridLayout;
}
interface DashboardState {
export interface DashboardConfig {
id: string;
name: string;
createdAt: number;
charts: ChartConfig[];
addChart: (chart: Omit<ChartConfig, 'layout'>, projectId: number) => void;
removeChart: (id: string, projectId: number) => void;
updateLayout: (layouts: GridLayout[], projectId: number) => void;
loadCharts: (projectId: number) => void;
}
const DASHBOARD_STORAGE_KEY_PREFIX = 'dashboard_charts_v1_project_';
interface DashboardState {
dashboards: DashboardConfig[];
activeDashboardId: string | null;
loadDashboards: (projectId: number) => void;
createDashboard: (name: string, projectId: number) => string;
deleteDashboard: (id: string, projectId: number) => void;
renameDashboard: (id: string, newName: string, projectId: number) => void;
setActiveDashboard: (id: string | null) => void;
addChart: (chart: Omit<ChartConfig, 'layout'>, dashboardId: string, projectId: number) => void;
removeChart: (chartId: string, dashboardId: string, projectId: number) => void;
updateLayout: (layouts: GridLayout[], dashboardId: string, projectId: number) => void;
}
const DASHBOARD_STORAGE_KEY_PREFIX = 'dashboards_v2_project_';
function getStorageKey(projectId: number) {
return `${DASHBOARD_STORAGE_KEY_PREFIX}${projectId}`;
}
function loadChartsFromStorage(projectId: number): ChartConfig[] {
function loadDashboardsFromStorage(projectId: number): DashboardConfig[] {
if (typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(getStorageKey(projectId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter((item): item is ChartConfig => Boolean(item?.id && item?.layout))
.map((item) => ({
...item,
layout: {
i: item.layout.i,
x: Number.isFinite(item.layout.x) ? item.layout.x : 0,
y: Number.isFinite(item.layout.y) ? item.layout.y : 0,
w: Number.isFinite(item.layout.w) ? item.layout.w : 4,
h: Number.isFinite(item.layout.h) ? item.layout.h : 4,
},
}));
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((d: any) => ({
...d,
charts: Array.isArray(d.charts) ? d.charts.map((item: any) => ({
...item,
layout: {
i: item.layout?.i || item.id,
x: Number.isFinite(item.layout?.x) ? item.layout.x : 0,
y: Number.isFinite(item.layout?.y) ? item.layout.y : 0,
w: Number.isFinite(item.layout?.w) ? item.layout.w : 4,
h: Number.isFinite(item.layout?.h) ? item.layout.h : 4,
},
})) : []
}));
}
}
// Migration from v1
const oldRaw = window.localStorage.getItem(`dashboard_charts_v1_project_${projectId}`);
if (oldRaw) {
const parsed = JSON.parse(oldRaw);
if (Array.isArray(parsed) && parsed.length > 0) {
const defaultDashboard: DashboardConfig = {
id: 'default',
name: 'Default Dashboard',
createdAt: Date.now(),
charts: parsed.map((item: any) => ({
...item,
layout: {
i: item.layout?.i || item.id,
x: Number.isFinite(item.layout?.x) ? item.layout.x : 0,
y: Number.isFinite(item.layout?.y) ? item.layout.y : 0,
w: Number.isFinite(item.layout?.w) ? item.layout.w : 4,
h: Number.isFinite(item.layout?.h) ? item.layout.h : 4,
},
})),
};
saveDashboardsToStorage([defaultDashboard], projectId);
return [defaultDashboard];
}
}
return [];
} catch {
return [];
}
}
function saveChartsToStorage(charts: ChartConfig[], projectId: number) {
function saveDashboardsToStorage(dashboards: DashboardConfig[], projectId: number) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(getStorageKey(projectId), JSON.stringify(charts));
window.localStorage.setItem(getStorageKey(projectId), JSON.stringify(dashboards));
}
export const useDashboardStore = create<DashboardState>((set) => ({
charts: [],
loadCharts: (projectId) => {
set({ charts: loadChartsFromStorage(projectId) });
dashboards: [],
activeDashboardId: null,
loadDashboards: (projectId) => {
const dashboards = loadDashboardsFromStorage(projectId);
set({ dashboards, activeDashboardId: dashboards.length > 0 ? dashboards[0].id : null });
},
addChart: (chart, projectId) => set((state) => {
const colSize = 4;
const cols = 12 / colSize;
const index = state.charts.length;
const newLayout: GridLayout = {
i: chart.id,
x: (index % cols) * colSize,
y: Math.floor(index / cols) * 4,
w: colSize,
h: 4,
};
const nextCharts = [...state.charts, { ...chart, layout: newLayout }];
saveChartsToStorage(nextCharts, projectId);
return { charts: nextCharts };
}),
removeChart: (id, projectId) => set((state) => {
const nextCharts = state.charts.filter((c) => c.id !== id);
saveChartsToStorage(nextCharts, projectId);
return { charts: nextCharts };
}),
updateLayout: (layouts, projectId) => set((state) => {
const nextCharts = state.charts.map((chart) => {
const layout = layouts.find((l) => l.i === chart.id);
return layout ? { ...chart, layout } : chart;
createDashboard: (name, projectId) => {
const newId = Date.now().toString();
set((state) => {
const newDashboard: DashboardConfig = {
id: newId,
name,
createdAt: Date.now(),
charts: [],
};
const nextDashboards = [...state.dashboards, newDashboard];
saveDashboardsToStorage(nextDashboards, projectId);
return { dashboards: nextDashboards, activeDashboardId: newId };
});
saveChartsToStorage(nextCharts, projectId);
return { charts: nextCharts };
return newId;
},
deleteDashboard: (id, projectId) => set((state) => {
const nextDashboards = state.dashboards.filter((d) => d.id !== id);
saveDashboardsToStorage(nextDashboards, projectId);
return {
dashboards: nextDashboards,
activeDashboardId: state.activeDashboardId === id ? (nextDashboards.length > 0 ? nextDashboards[0].id : null) : state.activeDashboardId,
};
}),
renameDashboard: (id, newName, projectId) => set((state) => {
const nextDashboards = state.dashboards.map((d) => d.id === id ? { ...d, name: newName } : d);
saveDashboardsToStorage(nextDashboards, projectId);
return { dashboards: nextDashboards };
}),
setActiveDashboard: (id) => set({ activeDashboardId: id }),
addChart: (chart, dashboardId, projectId) => set((state) => {
const nextDashboards = state.dashboards.map((d) => {
if (d.id !== dashboardId) return d;
const colSize = 4;
const cols = 12 / colSize;
const index = d.charts.length;
const newLayout: GridLayout = {
i: chart.id,
x: (index % cols) * colSize,
y: Math.floor(index / cols) * 4,
w: colSize,
h: 4,
};
return { ...d, charts: [...d.charts, { ...chart, layout: newLayout }] };
});
saveDashboardsToStorage(nextDashboards, projectId);
return { dashboards: nextDashboards };
}),
removeChart: (chartId, dashboardId, projectId) => set((state) => {
const nextDashboards = state.dashboards.map((d) => {
if (d.id !== dashboardId) return d;
return { ...d, charts: d.charts.filter((c) => c.id !== chartId) };
});
saveDashboardsToStorage(nextDashboards, projectId);
return { dashboards: nextDashboards };
}),
updateLayout: (layouts, dashboardId, projectId) => set((state) => {
const nextDashboards = state.dashboards.map((d) => {
if (d.id !== dashboardId) return d;
return {
...d,
charts: d.charts.map((chart) => {
const layout = layouts.find((l) => l.i === chart.id);
return layout ? { ...chart, layout } : chart;
})
};
});
saveDashboardsToStorage(nextDashboards, projectId);
return { dashboards: nextDashboards };
}),
}));