WHY: migrating from Supabase BaaS to self-hosted iloom backend (Go microservices) deployed on K3s. Supabase client and realtime subscriptions no longer applicable. HOW: - add src/api/ layer (client.ts, auth.ts, purchaser.ts, textile.ts, washing.ts, websocket.ts) targeting iloom-gateway REST endpoints - rewrite all hooks (usePlanData, useInventoryData, useDashboardData, useRealtime, etc.) to use new API client instead of Supabase queries - rewrite all page/component data fetching and mutations accordingly - delete src/supabase/client.ts, remove @supabase/supabase-js dep - add Dockerfile (multi-stage node build + nginx) and nginx.conf with reverse proxy to iloom-gateway for /api/ and /ws/ routes - adjust webpack.config.js for API_URL environment variable injection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
264 lines
11 KiB
TypeScript
264 lines
11 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { Bell, X, Check, CheckCheck, Clock, FileText, Package, CreditCard, AlertCircle, Edit3 } from 'lucide-react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { commonApi } from '../api';
|
||
import { useAuth } from '../contexts/AuthContext';
|
||
|
||
interface Notification {
|
||
id: string;
|
||
type: 'step_completed' | 'plan_created' | 'plan_updated' | 'inventory_in' | 'payment_pending' | 'system';
|
||
title: string;
|
||
content: string;
|
||
is_read: boolean;
|
||
created_at: string;
|
||
plan_id?: string | null;
|
||
sender_company_name?: string;
|
||
}
|
||
|
||
const typeConfig: Record<string, { icon: any; color: string; bgColor: string }> = {
|
||
step_completed: { icon: Check, color: 'text-emerald-600', bgColor: 'bg-emerald-50' },
|
||
plan_created: { icon: FileText, color: 'text-blue-600', bgColor: 'bg-blue-50' },
|
||
plan_updated: { icon: AlertCircle, color: 'text-amber-600', bgColor: 'bg-amber-50' },
|
||
plan_confirmed: { icon: Check, color: 'text-emerald-600', bgColor: 'bg-emerald-50' },
|
||
plan_changed: { icon: Edit3, color: 'text-orange-600', bgColor: 'bg-orange-50' },
|
||
plan_cancelled: { icon: X, color: 'text-red-600', bgColor: 'bg-red-50' },
|
||
plan_rejected: { icon: AlertCircle, color: 'text-rose-600', bgColor: 'bg-rose-50' },
|
||
inventory_in: { icon: Package, color: 'text-indigo-600', bgColor: 'bg-indigo-50' },
|
||
payment_pending: { icon: CreditCard, color: 'text-purple-600', bgColor: 'bg-purple-50' },
|
||
system: { icon: Bell, color: 'text-gray-600', bgColor: 'bg-gray-50' }
|
||
};
|
||
|
||
interface NotificationCenterProps {
|
||
theme?: 'amber' | 'emerald' | 'purple' | 'blue' | 'violet';
|
||
}
|
||
|
||
const themeConfig = {
|
||
amber: { bg: 'bg-amber-100', hover: 'hover:bg-amber-200', icon: 'text-amber-700' },
|
||
emerald: { bg: 'bg-emerald-100', hover: 'hover:bg-emerald-200', icon: 'text-emerald-700' },
|
||
purple: { bg: 'bg-purple-100', hover: 'hover:bg-purple-200', icon: 'text-purple-700' },
|
||
blue: { bg: 'bg-blue-100', hover: 'hover:bg-blue-200', icon: 'text-blue-700' },
|
||
violet: { bg: 'bg-violet-100', hover: 'hover:bg-violet-200', icon: 'text-violet-500' },
|
||
};
|
||
|
||
export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps = {}) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||
const [unreadCount, setUnreadCount] = useState(0);
|
||
const { auth } = useAuth();
|
||
const navigate = useNavigate();
|
||
const colors = themeConfig[theme];
|
||
|
||
useEffect(() => {
|
||
if (!auth.user) return;
|
||
fetchNotifications();
|
||
|
||
const interval = setInterval(fetchNotifications, 30000);
|
||
return () => clearInterval(interval);
|
||
}, [auth.user]);
|
||
|
||
const fetchNotifications = async () => {
|
||
try {
|
||
const data = await commonApi.listNotifications();
|
||
const formatted = (data as any[]).map((n: any) => ({
|
||
id: n.id,
|
||
type: n.type as Notification['type'],
|
||
title: n.title,
|
||
content: n.content,
|
||
is_read: n.is_read || false,
|
||
created_at: n.created_at || '',
|
||
plan_id: n.plan_id,
|
||
sender_company_name: n.sender_company_name
|
||
}));
|
||
setNotifications(formatted);
|
||
setUnreadCount(formatted.filter(n => !n.is_read).length);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
const markAsRead = async (id: string) => {
|
||
await commonApi.markNotificationRead(id);
|
||
|
||
setNotifications(prev => prev.map(n =>
|
||
n.id === id ? { ...n, is_read: true } : n
|
||
));
|
||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||
};
|
||
|
||
const markAllAsRead = async () => {
|
||
const unread = notifications.filter(n => !n.is_read);
|
||
await Promise.all(unread.map(n => commonApi.markNotificationRead(n.id)));
|
||
|
||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||
setUnreadCount(0);
|
||
};
|
||
|
||
const formatTime = (date: string) => {
|
||
const d = new Date(date);
|
||
const now = new Date();
|
||
const diff = now.getTime() - d.getTime();
|
||
const minutes = Math.floor(diff / 60000);
|
||
const hours = Math.floor(diff / 3600000);
|
||
const days = Math.floor(diff / 86400000);
|
||
|
||
if (minutes < 1) return '刚刚';
|
||
if (minutes < 60) return `${minutes}分钟前`;
|
||
if (hours < 24) return `${hours}小时前`;
|
||
if (days < 7) return `${days}天前`;
|
||
return d.toLocaleDateString('zh-CN');
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{/* 通知按钮 */}
|
||
<motion.button
|
||
whileHover={{ scale: 1.05 }}
|
||
whileTap={{ scale: 0.95 }}
|
||
onClick={() => setIsOpen(true)}
|
||
className={`relative flex items-center justify-center w-10 h-10 rounded-xl ${colors.bg} ${colors.hover} transition-colors`}
|
||
>
|
||
<Bell className={`w-5 h-5 ${colors.icon} flex-shrink-0`} strokeWidth={2} />
|
||
{unreadCount > 0 && (
|
||
<motion.span
|
||
initial={{ scale: 0 }}
|
||
animate={{ scale: 1 }}
|
||
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center border-2 border-white"
|
||
>
|
||
{unreadCount > 99 ? '99+' : unreadCount}
|
||
</motion.span>
|
||
)}
|
||
</motion.button>
|
||
|
||
{/* 通知弹窗 */}
|
||
<AnimatePresence>
|
||
{isOpen && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
className="fixed inset-0 z-[9999] flex items-start justify-end p-4"
|
||
onClick={() => setIsOpen(false)}
|
||
>
|
||
<motion.div
|
||
initial={{ opacity: 0, x: 100, scale: 0.95 }}
|
||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||
exit={{ opacity: 0, x: 100, scale: 0.95 }}
|
||
transition={{ duration: 0.2 }}
|
||
className="bg-white rounded-2xl shadow-2xl w-full max-w-md max-h-[80vh] overflow-hidden mt-16 mr-4"
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
{/* 头部 */}
|
||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||
<div className="flex items-center gap-2">
|
||
<Bell className="w-5 h-5 text-amber-500" />
|
||
<h2 className="font-semibold text-gray-900">通知中心</h2>
|
||
{unreadCount > 0 && (
|
||
<span className="px-2 py-0.5 bg-red-100 text-red-600 text-xs rounded-full">
|
||
{unreadCount} 未读
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{unreadCount > 0 && (
|
||
<button
|
||
onClick={markAllAsRead}
|
||
className="text-xs text-blue-600 hover:text-blue-700 flex items-center gap-1"
|
||
>
|
||
<CheckCheck className="w-3 h-3" />
|
||
全部已读
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => setIsOpen(false)}
|
||
className="p-1 hover:bg-gray-100 rounded-lg transition-colors"
|
||
>
|
||
<X className="w-5 h-5 text-gray-400" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 通知列表 */}
|
||
<div className="overflow-y-auto max-h-[60vh]">
|
||
{notifications.length === 0 ? (
|
||
<div className="text-center py-12 text-gray-400">
|
||
<Bell className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||
<p>暂无通知</p>
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-gray-100">
|
||
{notifications.map((notification, index) => {
|
||
const config = typeConfig[notification.type] || typeConfig.system;
|
||
const Icon = config.icon;
|
||
|
||
const handleNotificationClick = async () => {
|
||
await markAsRead(notification.id);
|
||
// 如果有 plan_id,跳转到计划总览页面
|
||
if (notification.plan_id) {
|
||
setIsOpen(false);
|
||
// 根据用户角色跳转到对应页面
|
||
const role = auth.company?.role;
|
||
if (role === 'purchaser') {
|
||
navigate('/purchaser/plans');
|
||
} else if (role === 'textile') {
|
||
navigate('/textile/plans');
|
||
} else if (role === 'washing') {
|
||
navigate('/washing/plans');
|
||
}
|
||
}
|
||
};
|
||
|
||
return (
|
||
<motion.div
|
||
key={notification.id}
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ delay: Math.min(index * 0.02, 0.1) }}
|
||
onClick={handleNotificationClick}
|
||
className={`p-4 hover:bg-gray-50 cursor-pointer transition-colors ${
|
||
!notification.is_read ? 'bg-blue-50/30' : ''
|
||
}`}
|
||
>
|
||
<div className="flex gap-3">
|
||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${config.bgColor}`}>
|
||
<Icon className={`w-5 h-5 ${config.color}`} />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<h3 className={`font-medium text-sm ${!notification.is_read ? 'text-gray-900' : 'text-gray-600'}`}>
|
||
{notification.title}
|
||
</h3>
|
||
{!notification.is_read && (
|
||
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0 mt-1.5" />
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">
|
||
{notification.content}
|
||
</p>
|
||
<div className="flex items-center gap-2 mt-2">
|
||
<Clock className="w-3 h-3 text-gray-400" />
|
||
<span className="text-xs text-gray-400">
|
||
{formatTime(notification.created_at)}
|
||
</span>
|
||
{notification.sender_company_name && (
|
||
<span className="text-xs text-gray-400">
|
||
· {notification.sender_company_name}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</>
|
||
);
|
||
}
|