feat: DevTools 数据库监看面板 + 隧道控制 + 多项 Bug 修复

**DevTools 新增功能 (Tasks 13-14):**
- 首页仪表盘添加数据库实时监看卡片 (5端口状态 + 记忆数)
- 侧边栏新增数据库面板,支持自动 5 秒刷新
- 数据库面板显示 PostgreSQL/Redis/Qdrant/MinIO/NATS 端口状态
- 隧道控制按钮 (启动/停止/重启/查看状态)
- 新增 API 端点: GET /api/database/status, POST /api/tunnel/:action
- 更新 docs/api-reference/ API 文档

**Bug 修复 (Task 15):**
- 修复 pgrep -f 自匹配导致隧道状态误判 (添加 ^ssh 锚点)
  - devtools/src/index.js (dashboard + database/status)
  - scripts/tunnel.sh (is_tunnel_running + show_status)
- 修复数据库面板缺少自动刷新定时器
- 修复侧边栏数据库徽章永远 display:none
- 修复僵尸进程场景下按钮死锁问题

**其他改进:**
- .gitignore 添加 backend/cmd, backend/iot-debug-service/main
- 前端多项改进 (登录/注册/会话/流式动画等)
This commit is contained in:
2026-05-17 11:42:42 +08:00
parent 0757ad26b5
commit 5d0bb96abe
28 changed files with 1723 additions and 218 deletions
+94 -15
View File
@@ -6,12 +6,16 @@ import { useAuth } from '@/hooks/useAuth';
import { useChat } from '@/hooks/useChat';
export default function App() {
const { isLoggedIn, login, loading: authLoading } = useAuth();
const { isLoggedIn, login, register, loading: authLoading } = useAuth();
const { send } = useChat();
const [authMode, setAuthMode] = useState<'login' | 'register'>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = useState('');
const [verifyCode, setVerifyCode] = useState('');
const [error, setError] = useState('');
const [successMsg, setSuccessMsg] = useState('');
const handleLogin = async () => {
setError('');
@@ -21,7 +25,28 @@ export default function App() {
}
};
// 登录页面 (开发阶段暂时禁用注册)
const handleRegister = async () => {
setError('');
setSuccessMsg('');
if (!email) {
setError('请输入邮箱');
return;
}
const result = await register(username, password, email, verifyCode || '000000');
if (!result.success) {
setError(result.error || '注册失败');
} else {
setSuccessMsg('注册成功!正在进入...');
}
};
const switchMode = (mode: 'login' | 'register') => {
setAuthMode(mode);
setError('');
setSuccessMsg('');
};
// 登录/注册页面
if (!isLoggedIn) {
return (
<div className="min-h-screen bg-[#FFFAF5] dark:bg-[#1a1a2e] flex items-center justify-center p-4">
@@ -35,10 +60,30 @@ export default function App() {
</p>
</div>
{/* 登录表单 */}
{/* 登录/注册表单 */}
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-lg p-6 space-y-4 border border-pink-100 dark:border-pink-900">
<div className="text-center mb-2">
<span className="text-sm font-medium text-pink-500"></span>
{/* 模式切换 */}
<div className="flex rounded-xl bg-pink-50 dark:bg-pink-900/20 p-1">
<button
onClick={() => switchMode('login')}
className={`flex-1 py-1.5 text-sm rounded-lg font-medium transition-colors ${
authMode === 'login'
? 'bg-white dark:bg-gray-800 text-pink-500 shadow-sm'
: 'text-gray-400 hover:text-pink-400'
}`}
>
</button>
<button
onClick={() => switchMode('register')}
className={`flex-1 py-1.5 text-sm rounded-lg font-medium transition-colors ${
authMode === 'register'
? 'bg-white dark:bg-gray-800 text-pink-500 shadow-sm'
: 'text-gray-400 hover:text-pink-400'
}`}
>
</button>
</div>
<input
@@ -46,33 +91,67 @@ export default function App() {
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
onKeyDown={(e) => e.key === 'Enter' && (authMode === 'login' ? handleLogin() : handleRegister())}
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
/>
{authMode === 'register' && (
<input
type="email"
placeholder="邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
/>
)}
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
onKeyDown={(e) => e.key === 'Enter' && (authMode === 'login' ? handleLogin() : handleRegister())}
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
/>
{authMode === 'register' && (
<input
type="text"
placeholder="验证码 (开发环境输入 000000)"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
maxLength={6}
className="w-full px-4 py-2.5 rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400"
/>
)}
{error && (
<p className="text-xs text-red-400 text-center">{error}</p>
)}
{successMsg && (
<p className="text-xs text-green-400 text-center">{successMsg}</p>
)}
<button
onClick={handleLogin}
disabled={authLoading || !username || !password}
className="w-full py-2.5 rounded-xl bg-pink-400 hover:bg-pink-500 disabled:bg-pink-300 text-white font-medium text-sm transition-colors"
>
{authLoading ? '请稍候...' : '进入昔涟的世界 ♪'}
</button>
{authMode === 'login' ? (
<button
onClick={handleLogin}
disabled={authLoading || !username || !password}
className="w-full py-2.5 rounded-xl bg-pink-400 hover:bg-pink-500 disabled:bg-pink-300 text-white font-medium text-sm transition-colors"
>
{authLoading ? '请稍候...' : '进入昔涟的世界 ♪'}
</button>
) : (
<button
onClick={handleRegister}
disabled={authLoading || !username || !password || !email}
className="w-full py-2.5 rounded-xl bg-pink-400 hover:bg-pink-500 disabled:bg-pink-300 text-white font-medium text-sm transition-colors"
>
{authLoading ? '请稍候...' : '注册并进入 ♪'}
</button>
)}
<p className="text-xs text-gray-400 text-center">
· 管理员凭据: admin / cyrene-dev-admin
{authMode === 'register' ? '开发阶段 · 验证码统一使用 000000' : '欢迎回来 ♪'}
</p>
</div>
</div>
+2 -2
View File
@@ -98,10 +98,10 @@ export async function login(username: string, password: string): Promise<ApiResp
return resp;
}
export async function register(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
export async function register(username: string, password: string, email: string, verifyCode: string): Promise<ApiResponse<AuthResponse>> {
const resp = await request<AuthResponse>('/auth/register', {
method: 'POST',
body: { username, password },
body: { username, password, email, verify_code: verifyCode },
auth: false,
});
if (resp.data?.token) {
@@ -1,8 +1,53 @@
import { useChat } from '@/hooks/useChat';
import { useChatStore } from '@/store/chatStore';
import { MessageList } from './MessageList';
import { IoTStatusBar } from './IoTStatusBar';
export function ChatContainer() {
const { messages, isTyping } = useChat();
const continuousMode = useChatStore((s) => s.continuousMode);
const backgroundThinkingStatus = useChatStore((s) => s.backgroundThinkingStatus);
return <MessageList messages={messages} isTyping={isTyping} />;
const thinkingLabel = backgroundThinkingStatus === 'thinking' ? '昔涟正在回忆中...' : '';
const statusLabel = continuousMode ? '主对话 · 进行中' : '';
return (
<div className="flex flex-col h-full">
{/* 状态指示器栏 */}
<div className="flex items-center justify-between px-4 py-1.5 border-b border-pink-100 dark:border-pink-900 bg-pink-50/50 dark:bg-pink-950/20">
<div className="flex items-center gap-2">
{statusLabel && (
<span className="text-xs font-medium text-pink-500 dark:text-pink-400 bg-pink-100 dark:bg-pink-900/50 px-2 py-0.5 rounded-full">
{statusLabel}
</span>
)}
{thinkingLabel && (
<span className="text-xs text-pink-400 dark:text-pink-500 animate-pulse flex items-center gap-1">
<span className="inline-block w-1.5 h-1.5 bg-pink-400 rounded-full animate-bounce" />
<span className="inline-block w-1.5 h-1.5 bg-pink-400 rounded-full animate-bounce" style={{ animationDelay: '0.15s' }} />
<span className="inline-block w-1.5 h-1.5 bg-pink-400 rounded-full animate-bounce" style={{ animationDelay: '0.3s' }} />
{thinkingLabel}
</span>
)}
</div>
{continuousMode && (
<button
onClick={() => useChatStore.getState().setContinuousMode(!continuousMode)}
className="text-[10px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
title="切换连续对话模式"
>
{continuousMode ? '🔗 连续对话' : '⏸️ 暂停中'}
</button>
)}
</div>
{/* 消息列表 */}
<div className="flex-1 overflow-hidden">
<MessageList messages={messages} isTyping={isTyping} />
</div>
{/* IoT 状态栏(底部) */}
<IoTStatusBar />
</div>
);
}
+3 -72
View File
@@ -1,75 +1,6 @@
import { useState, useCallback, useEffect } from 'react';
import { login as apiLogin, register as apiRegister, clearToken, isAuthenticated, getToken } from '@/api/client';
interface AuthState {
isLoggedIn: boolean;
userId: string | null;
token: string | null;
loading: boolean;
}
import { useAuthStore } from '@/store/authStore';
/** 兼容旧代码的 Hook 导出 — 现在基于共享 Zustand store */
export function useAuth() {
const [state, setState] = useState<AuthState>({
isLoggedIn: isAuthenticated(),
userId: localStorage.getItem('user_id'),
token: getToken(),
loading: false,
});
const login = useCallback(async (username: string, password: string) => {
setState(prev => ({ ...prev, loading: true }));
try {
const resp = await apiLogin(username, password);
if (resp.error) {
return { success: false, error: resp.error };
}
setState({
isLoggedIn: true,
userId: resp.data?.user_id || null,
token: resp.data?.token || null,
loading: false,
});
return { success: true };
} catch (err) {
setState(prev => ({ ...prev, loading: false }));
return { success: false, error: err instanceof Error ? err.message : '登录失败' };
}
}, []);
const register = useCallback(async (username: string, password: string) => {
setState(prev => ({ ...prev, loading: true }));
try {
const resp = await apiRegister(username, password);
if (resp.error) {
return { success: false, error: resp.error };
}
setState({
isLoggedIn: true,
userId: resp.data?.user_id || null,
token: resp.data?.token || null,
loading: false,
});
return { success: true };
} catch (err) {
setState(prev => ({ ...prev, loading: false }));
return { success: false, error: err instanceof Error ? err.message : '注册失败' };
}
}, []);
const logout = useCallback(() => {
clearToken();
setState({
isLoggedIn: false,
userId: null,
token: null,
loading: false,
});
}, []);
return {
...state,
login,
register,
logout,
};
return useAuthStore();
}
+80
View File
@@ -0,0 +1,80 @@
/**
* 认证状态管理 (Zustand Store)
* 用于跨组件共享登录/退出状态
*/
import { create } from 'zustand';
import { login as apiLogin, register as apiRegister, clearToken, isAuthenticated, getToken } from '@/api/client';
interface AuthStore {
isLoggedIn: boolean;
userId: string | null;
token: string | null;
loading: boolean;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
register: (username: string, password: string, email: string, verifyCode: string) => Promise<{ success: boolean; error?: string }>;
logout: () => void;
}
export const useAuthStore = create<AuthStore>((set) => ({
isLoggedIn: isAuthenticated(),
userId: localStorage.getItem('user_id'),
token: getToken(),
loading: false,
login: async (username: string, password: string) => {
set({ loading: true });
try {
const resp = await apiLogin(username, password);
if (resp.error) {
set({ loading: false });
return { success: false, error: resp.error };
}
set({
isLoggedIn: true,
userId: resp.data?.user_id || null,
token: resp.data?.token || null,
loading: false,
});
return { success: true };
} catch (err) {
set({ loading: false });
return { success: false, error: err instanceof Error ? err.message : '登录失败' };
}
},
register: async (username: string, password: string, email: string, verifyCode: string) => {
set({ loading: true });
try {
const resp = await apiRegister(username, password, email, verifyCode);
if (resp.error) {
set({ loading: false });
return { success: false, error: resp.error };
}
set({
isLoggedIn: true,
userId: resp.data?.user_id || null,
token: resp.data?.token || null,
loading: false,
});
return { success: true };
} catch (err) {
set({ loading: false });
return { success: false, error: err instanceof Error ? err.message : '注册失败' };
}
},
logout: () => {
clearToken();
set({
isLoggedIn: false,
userId: null,
token: null,
loading: false,
});
},
}));
/** 兼容旧代码的 Hook 导出 */
export function useAuth() {
return useAuthStore();
}
+34 -1
View File
@@ -6,6 +6,9 @@ export type MessageRole = 'user' | 'assistant' | 'system';
/** 对话模式 */
export type ChatMode = 'text' | 'voice_msg' | 'voice_assistant';
/** 后台思考状态 */
export type BackgroundThinkingStatus = 'idle' | 'thinking' | 'done';
/** 语音片段 */
export interface VoiceSegment {
index: number;
@@ -25,6 +28,32 @@ export interface Message {
isStreaming?: boolean;
}
/** IoT 设备类型定义 */
export type IoTDeviceType = 'light' | 'ac' | 'curtain' | 'sensor' | 'lock';
/** IoT 设备 */
export interface IoTDevice {
id: string;
name: string;
type: IoTDeviceType;
status: string;
brightness?: number;
color?: string;
temperature?: number;
mode?: string;
position?: number;
value?: number;
unit?: string;
battery?: number;
last_updated: string;
}
/** IoT 设备更新消息 */
export interface IoTDeviceUpdate {
devices: IoTDevice[];
total: number;
}
/** WebSocket 客户端消息 */
export interface WSClientMessage {
type: 'message' | 'voice_input' | 'ping' | 'history';
@@ -37,7 +66,7 @@ export interface WSClientMessage {
/** WebSocket 服务端消息 */
export interface WSServerMessage {
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end';
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking';
message_id?: string;
text?: string;
content?: string;
@@ -49,6 +78,8 @@ export interface WSServerMessage {
tool_calls?: ToolCall[];
error?: string;
messages?: Message[];
devices?: IoTDevice[];
thinking_status?: BackgroundThinkingStatus;
timestamp: number;
}
@@ -64,4 +95,6 @@ export interface ChatState {
messages: Message[];
isTyping: boolean;
currentMode: ChatMode;
continuousMode: boolean;
backgroundThinkingStatus: BackgroundThinkingStatus;
}
+2
View File
@@ -41,4 +41,6 @@ export interface LoginParams {
export interface RegisterParams {
username: string;
password: string;
email: string;
verify_code: string;
}