fix: 第二轮修复 — 数据库启动检查、会话持久化、URL路由、设备排序等

1. DevTools 启动前检查数据库状态,失败时自动尝试启动
2. ai-core 添加数据库断线重连机制 (30秒间隔)
3. Dashboard 添加数据库状态卡片 (启动/停止/重启)
4. Gateway 会话空闲超时管理 (30分钟标记空闲)
5. 会话/消息 PostgreSQL 持久化 (SessionStore + REST API)
6. 前端服务端会话持久化 + URL hash 路由 + 侧边栏管理
7. 管理员回到主对话按钮
8. IoT 设备卡片固定排序
9. 更新相关文档
This commit is contained in:
2026-05-17 17:18:02 +08:00
parent 745b1c6aad
commit e7b7eff0d8
21 changed files with 1735 additions and 284 deletions
+180 -42
View File
@@ -1,6 +1,8 @@
import { useState, useCallback } from 'react';
import { useSession } from '@/hooks/useSession';
import { useSessionStore } from '@/store/sessionStore';
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
import type { Session } from '@/types/session';
interface SidebarProps {
onClose?: () => void;
@@ -11,12 +13,25 @@ export function Sidebar({ onClose }: SidebarProps) {
sessions,
createSession,
deleteSession,
deleteAllSessions,
clearMainSession,
setCurrentSession,
isAdmin,
} = useSession();
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const displaySessions = sessions;
const activeSessionId = currentSessionId;
// 确认弹窗状态
const [confirmAction, setConfirmAction] = useState<{
type: 'delete' | 'clearMain' | 'deleteAll';
sessionId?: string;
} | null>(null);
// 按 updated_at 降序排列
const displaySessions = [...sessions].sort((a, b) => {
const ta = typeof a.updated_at === 'string' ? parseInt(a.updated_at, 10) : (a.updated_at as unknown as number);
const tb = typeof b.updated_at === 'string' ? parseInt(b.updated_at, 10) : (b.updated_at as unknown as number);
return (tb || 0) - (ta || 0);
});
const handleNewChat = async () => {
const session = await createSession();
@@ -28,11 +43,43 @@ export function Sidebar({ onClose }: SidebarProps) {
if (onClose) onClose();
};
const handleDeleteSession = (e: { stopPropagation: () => void }, id: string) => {
e.stopPropagation();
deleteSession(id);
const handleMainSession = async () => {
// 找到主对话
const mainSession = displaySessions.find((s) => s.is_main);
if (mainSession) {
setCurrentSession(mainSession.id);
if (onClose) onClose();
}
};
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
const session = displaySessions.find((s) => s.id === id);
// 管理员主对话不可删除
if (isAdmin && session?.is_main) return;
setConfirmAction({ type: 'delete', sessionId: id });
};
const handleConfirmAction = async () => {
if (!confirmAction) return;
switch (confirmAction.type) {
case 'delete':
if (confirmAction.sessionId) {
await deleteSession(confirmAction.sessionId);
}
break;
case 'clearMain':
await clearMainSession();
break;
case 'deleteAll':
await deleteAllSessions();
break;
}
setConfirmAction(null);
};
const cancelConfirm = () => setConfirmAction(null);
/** 格式化时间戳为可读字符串 */
const formatTime = (ts: string | number): string => {
if (!ts) return '';
@@ -52,7 +99,28 @@ export function Sidebar({ onClose }: SidebarProps) {
return (
<aside className="h-full bg-white/90 dark:bg-gray-900/90 border-r border-pink-100 dark:border-pink-900 flex flex-col">
{/* 侧边栏头部 */}
{/* 主对话按钮 (仅管理员可见) */}
{isAdmin && (
<div className="p-3 border-b border-pink-100 dark:border-pink-900 flex gap-2">
<button
onClick={handleMainSession}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-amber-50 hover:bg-amber-100 dark:bg-amber-900/20 dark:hover:bg-amber-900/30 text-amber-600 dark:text-amber-400 rounded-xl text-sm font-medium transition-colors border border-amber-200 dark:border-amber-800"
title="回到主对话"
>
<span>🏠</span>
<span></span>
</button>
<button
onClick={() => setConfirmAction({ type: 'clearMain' })}
className="flex items-center justify-center px-2 py-2 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30 text-red-400 hover:text-red-500 rounded-xl text-sm transition-colors border border-red-200 dark:border-red-800"
title="清空主对话消息"
>
<span>🗑</span>
</button>
</div>
)}
{/* 新对话按钮 */}
<div className="p-4 border-b border-pink-100 dark:border-pink-900">
<button
onClick={handleNewChat}
@@ -70,46 +138,80 @@ export function Sidebar({ onClose }: SidebarProps) {
</p>
) : (
displaySessions.map((session) => (
<div
key={session.id}
onClick={() => handleSelectSession(session.id)}
className={`
group flex items-center justify-between px-4 py-2.5 mx-2 rounded-lg cursor-pointer transition-colors
${
activeSessionId === session.id
? 'bg-pink-50 dark:bg-pink-900/30 text-pink-600'
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-300'
}
`}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<CyreneAvatar size="sm" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{session.title || '新的对话'}</p>
<p className="text-xs text-gray-400 truncate">
{session.message_count != null ? `${session.message_count} 条消息` : ''}
{session.updated_at ? ` · ${formatTime(session.updated_at)}` : ''}
</p>
</div>
</div>
<button
onClick={(e) => handleDeleteSession(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
title="删除会话"
displaySessions.map((session) => {
const isMainSession = session.is_main;
const isActive = currentSessionId === session.id;
// 管理员主对话不可删除
const canDelete = !(isAdmin && isMainSession);
return (
<div
key={session.id}
onClick={() => handleSelectSession(session.id)}
className={`
group flex items-center justify-between px-4 py-2.5 mx-2 rounded-lg cursor-pointer transition-colors
${
isActive
? 'bg-pink-50 dark:bg-pink-900/30 text-pink-600'
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-300'
}
`}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))
<div className="flex items-center gap-2 min-w-0 flex-1">
<CyreneAvatar size="sm" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{isMainSession ? '🏠 ' : ''}
{session.title || '新的对话'}
</p>
<p className="text-xs text-gray-400 truncate">
{session.message_count != null
? `${session.message_count} 条消息`
: ''}
{session.updated_at
? `${session.message_count != null ? ' · ' : ''}${formatTime(session.updated_at)}`
: ''}
</p>
</div>
</div>
{canDelete && (
<button
onClick={(e) => handleDeleteClick(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
title="删除会话"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
</div>
);
})
)}
</div>
{/* 底部信息 */}
<div className="p-4 border-t border-pink-100 dark:border-pink-900">
<div className="flex items-center gap-2 text-xs text-gray-400">
{/* 底部:一键清空所有对话 */}
<div className="p-3 border-t border-pink-100 dark:border-pink-900">
<button
onClick={() => setConfirmAction({ type: 'deleteAll' })}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30 text-red-400 hover:text-red-500 rounded-xl text-xs font-medium transition-colors border border-red-200 dark:border-red-800"
>
<span>🗑</span>
<span></span>
</button>
<div className="flex items-center gap-2 mt-3 text-xs text-gray-400">
<CyreneAvatar size="sm" />
<div>
<p className="font-medium text-pink-400"> AI</p>
@@ -117,6 +219,42 @@ export function Sidebar({ onClose }: SidebarProps) {
</div>
</div>
</div>
{/* 确认弹窗 */}
{confirmAction && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-6 m-4 max-w-sm w-full border border-pink-100 dark:border-pink-700">
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2">
{confirmAction.type === 'delete'
? '删除会话'
: confirmAction.type === 'clearMain'
? '清空主对话'
: '清空所有对话'}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{confirmAction.type === 'delete'
? '确定要删除这个会话吗?此操作不会删除记忆。'
: confirmAction.type === 'clearMain'
? '确定要清空主对话的所有消息吗?此操作不会删除记忆。'
: '确定要删除所有对话吗?此操作不会删除记忆。管理员将回到主对话,普通用户进入新对话。'}
</p>
<div className="flex justify-end gap-3">
<button
onClick={cancelConfirm}
className="px-4 py-2 rounded-lg text-sm text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleConfirmAction}
className="px-4 py-2 rounded-lg text-sm bg-red-400 hover:bg-red-500 text-white font-medium transition-colors"
>
</button>
</div>
</div>
</div>
)}
</aside>
);
}