Files
zclaw_openfang/desktop/src/components/OfflineIndicator.tsx
iven 185763868a 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>
2026-03-22 00:03:22 +08:00

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;