feat: production readiness improvements
## Error Handling - Add GlobalErrorBoundary with error classification and recovery - Add custom error types (SecurityError, ConnectionError, TimeoutError) - Fix ErrorAlert component syntax errors ## Offline Mode - Add offlineStore for offline state management - Implement message queue with localStorage persistence - Add exponential backoff reconnection (1s→60s) - Add OfflineIndicator component with status display - Queue messages when offline, auto-retry on reconnect ## Security Hardening - Add AES-256-GCM encryption for chat history storage - Add secure API key storage with OS keychain integration - Add security audit logging system - Add XSS prevention and input validation utilities - Add rate limiting and token generation helpers ## CI/CD (Gitea Actions) - Add .gitea/workflows/ci.yml for continuous integration - Add .gitea/workflows/release.yml for release automation - Support Windows Tauri build and release ## UI Components - Add LoadingSpinner, LoadingOverlay, LoadingDots components - Add MessageSkeleton, ConversationListSkeleton skeletons - Add EmptyMessages, EmptyConversations empty states - Integrate loading states in ChatArea and ConversationList ## E2E Tests - Fix WebSocket mock for streaming response tests - Fix approval endpoint route matching - Add store state exposure for testing - All 19 core-features tests now passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
378
desktop/src/components/OfflineIndicator.tsx
Normal file
378
desktop/src/components/OfflineIndicator.tsx
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* OfflineIndicator Component
|
||||
*
|
||||
* Displays offline mode status, pending message count, and reconnection info.
|
||||
* Shows a prominent banner when the app is offline with visual feedback.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
WifiOff,
|
||||
CloudOff,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Send,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { useOfflineStore, type QueuedMessage } from '../store/offlineStore';
|
||||
import { useConnectionStore } from '../store/connectionStore';
|
||||
|
||||
interface OfflineIndicatorProps {
|
||||
/** Show compact version (minimal) */
|
||||
compact?: boolean;
|
||||
/** Show pending messages list */
|
||||
showQueue?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Callback when reconnect button is clicked */
|
||||
onReconnect?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
|
||||
if (seconds < 60) return '刚刚';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟前`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}小时前`;
|
||||
return `${Math.floor(seconds / 86400)}天前`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reconnect delay for display
|
||||
*/
|
||||
function formatReconnectDelay(delay: number): string {
|
||||
if (delay < 1000) return '立即';
|
||||
if (delay < 60000) return `${Math.ceil(delay / 1000)}秒`;
|
||||
return `${Math.ceil(delay / 60000)}分钟`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate message content for display
|
||||
*/
|
||||
function truncateContent(content: string, maxLength: number = 50): string {
|
||||
if (content.length <= maxLength) return content;
|
||||
return content.slice(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Full offline indicator with banner, queue, and reconnect info
|
||||
*/
|
||||
export function OfflineIndicator({
|
||||
compact = false,
|
||||
showQueue = true,
|
||||
className = '',
|
||||
onReconnect,
|
||||
}: OfflineIndicatorProps) {
|
||||
const {
|
||||
isOffline,
|
||||
isReconnecting,
|
||||
reconnectAttempt,
|
||||
nextReconnectDelay,
|
||||
queuedMessages,
|
||||
cancelReconnect,
|
||||
} = useOfflineStore();
|
||||
|
||||
const connect = useConnectionStore((s) => s.connect);
|
||||
|
||||
const [showMessageQueue, setShowMessageQueue] = useState(false);
|
||||
const [countdown, setCountdown] = useState<number | null>(null);
|
||||
|
||||
// Countdown timer for reconnection
|
||||
useEffect(() => {
|
||||
if (!isReconnecting || !nextReconnectDelay) {
|
||||
setCountdown(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const endTime = Date.now() + nextReconnectDelay;
|
||||
setCountdown(nextReconnectDelay);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const remaining = Math.max(0, endTime - Date.now());
|
||||
setCountdown(remaining);
|
||||
|
||||
if (remaining === 0) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isReconnecting, nextReconnectDelay]);
|
||||
|
||||
// Handle manual reconnect
|
||||
const handleReconnect = async () => {
|
||||
onReconnect?.();
|
||||
try {
|
||||
await connect();
|
||||
} catch (err) {
|
||||
console.error('[OfflineIndicator] Manual reconnect failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const pendingCount = queuedMessages.filter(
|
||||
(m) => m.status === 'pending' || m.status === 'failed'
|
||||
).length;
|
||||
|
||||
// Don't show if online and no pending messages
|
||||
if (!isOffline && pendingCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compact version for headers/toolbars
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{isOffline ? (
|
||||
<>
|
||||
<CloudOff className="w-4 h-4 text-orange-500" />
|
||||
<span className="text-sm text-orange-500 font-medium">
|
||||
离线模式
|
||||
</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 px-1.5 py-0.5 rounded">
|
||||
{pendingCount} 条待发
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm text-green-500">已恢复连接</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 rounded">
|
||||
发送中 {pendingCount} 条
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full banner version
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className={`${className}`}
|
||||
>
|
||||
{/* Main Banner */}
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg ${
|
||||
isOffline
|
||||
? 'bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800'
|
||||
: 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||
}`}
|
||||
>
|
||||
{/* Status Icon */}
|
||||
<motion.div
|
||||
animate={isReconnecting ? { rotate: 360 } : {}}
|
||||
transition={
|
||||
isReconnecting
|
||||
? { duration: 1, repeat: Infinity, ease: 'linear' }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{isOffline ? (
|
||||
<WifiOff className="w-5 h-5 text-orange-500" />
|
||||
) : (
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Status Text */}
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
isOffline ? 'text-orange-700 dark:text-orange-400' : 'text-green-700 dark:text-green-400'
|
||||
}`}
|
||||
>
|
||||
{isOffline ? '后端服务不可用' : '连接已恢复'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{isReconnecting ? (
|
||||
<>
|
||||
正在尝试重连 ({reconnectAttempt}次)
|
||||
{countdown !== null && (
|
||||
<span className="ml-2">
|
||||
{formatReconnectDelay(countdown)}后重试
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : isOffline ? (
|
||||
'消息将保存在本地,连接后自动发送'
|
||||
) : pendingCount > 0 ? (
|
||||
`正在发送 ${pendingCount} 条排队消息...`
|
||||
) : (
|
||||
'所有消息已同步'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isOffline && !isReconnecting && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-md transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
重连
|
||||
</button>
|
||||
)}
|
||||
{isReconnecting && (
|
||||
<button
|
||||
onClick={cancelReconnect}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
{showQueue && pendingCount > 0 && (
|
||||
<button
|
||||
onClick={() => setShowMessageQueue(!showMessageQueue)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{showMessageQueue ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
{pendingCount} 条待发
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Queue */}
|
||||
<AnimatePresence>
|
||||
{showMessageQueue && pendingCount > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
排队消息
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{queuedMessages
|
||||
.filter((m) => m.status === 'pending' || m.status === 'failed')
|
||||
.map((msg) => (
|
||||
<QueuedMessageItem key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual queued message item
|
||||
*/
|
||||
function QueuedMessageItem({ message }: { message: QueuedMessage }) {
|
||||
const { removeMessage } = useOfflineStore();
|
||||
|
||||
const statusConfig = {
|
||||
pending: { icon: Clock, color: 'text-gray-400', label: '等待中' },
|
||||
sending: { icon: Send, color: 'text-blue-500', label: '发送中' },
|
||||
failed: { icon: AlertCircle, color: 'text-red-500', label: '发送失败' },
|
||||
sent: { icon: CheckCircle, color: 'text-green-500', label: '已发送' },
|
||||
};
|
||||
|
||||
const config = statusConfig[message.status];
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-4 py-2 border-b border-gray-100 dark:border-gray-800 last:border-b-0">
|
||||
<StatusIcon className={`w-4 h-4 mt-0.5 ${config.color}`} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{truncateContent(message.content)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-gray-400">
|
||||
{formatRelativeTime(message.timestamp)}
|
||||
</span>
|
||||
{message.status === 'failed' && message.lastError && (
|
||||
<span className="text-xs text-red-500">{message.lastError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message.status === 'failed' && (
|
||||
<button
|
||||
onClick={() => removeMessage(message.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="删除消息"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal connection status indicator for headers
|
||||
*/
|
||||
export function ConnectionStatusBadge({ className = '' }: { className?: string }) {
|
||||
const connectionState = useConnectionStore((s) => s.connectionState);
|
||||
const queuedMessages = useOfflineStore((s) => s.queuedMessages);
|
||||
|
||||
const pendingCount = queuedMessages.filter(
|
||||
(m) => m.status === 'pending' || m.status === 'failed'
|
||||
).length;
|
||||
|
||||
const isConnected = connectionState === 'connected';
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1.5 ${className}`}>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
isConnected
|
||||
? 'bg-green-400'
|
||||
: connectionState === 'reconnecting'
|
||||
? 'bg-orange-400 animate-pulse'
|
||||
: 'bg-red-400'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
isConnected
|
||||
? 'text-green-500'
|
||||
: connectionState === 'reconnecting'
|
||||
? 'text-orange-500'
|
||||
: 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{isConnected ? '在线' : connectionState === 'reconnecting' ? '重连中' : '离线'}
|
||||
</span>
|
||||
{pendingCount > 0 && (
|
||||
<span className="text-xs bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 px-1.5 py-0.5 rounded">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OfflineIndicator;
|
||||
Reference in New Issue
Block a user