## 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>
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
/**
|
|
* 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;
|