Chever John b1c2c33aa2
refactor: replace Supabase SDK with self-hosted REST API client layer
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>
2026-06-14 12:38:47 +08:00

528 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import { Share2, Copy, Check, X, AlertTriangle, Building2, Eye, EyeOff, Link2, Send } from 'lucide-react';
import { useResponsive } from '../../hooks/useResponsive';
import { encryptShareParams } from '../../utils/shareCrypto';
import { http, commonApi } from '../../api';
import { useAuth } from '../../contexts/AuthContext';
interface ShareModalProps {
isOpen: boolean;
onClose: () => void;
planId: string;
factoryType: 'textile' | 'washing';
factoryId?: string;
factoryName?: string;
planProductName?: string;
planColor?: string;
planColorCode?: string;
onDirectPush?: () => void; // 直接推送回调
}
// 合作关系类型
interface CompanyRelationship {
id: string;
purchaser_company_id: string;
factory_company_id: string;
factory_type: 'textile' | 'washing';
status: 'active' | 'inactive';
}
// 分享链接状态
interface ShareLinkStatus {
id: string;
short_code: string;
status: 'pending' | 'clicked' | 'confirmed' | 'rejected' | 'expired' | 'cancelled';
clicked_at: string | null;
responded_at: string | null;
response_result: 'confirmed' | 'rejected' | null;
reject_reason: string | null;
expires_at: string;
created_at: string;
}
export function ShareModal({
isOpen,
onClose,
planId,
factoryType,
factoryId,
factoryName,
planProductName,
planColor,
planColorCode,
onDirectPush
}: ShareModalProps) {
const { isMobile } = useResponsive();
const { auth } = useAuth();
const [copied, setCopied] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [recipientCompany, setRecipientCompany] = useState<string>('');
const [confirmFactoryName, setConfirmFactoryName] = useState('');
const [confirmCompanyName, setConfirmCompanyName] = useState('');
const [error, setError] = useState('');
const [shortCode, setShortCode] = useState<string>('');
const [hideSensitive, setHideSensitive] = useState(false);
const shortLinkGenerated = useRef(false);
// 新流程状态
const [hasRelationship, setHasRelationship] = useState<boolean | null>(null);
const [isCheckingRelationship, setIsCheckingRelationship] = useState(false);
const [existingLink, setExistingLink] = useState<ShareLinkStatus | null>(null);
const [isGeneratingLink, setIsGeneratingLink] = useState(false);
const [pushSuccess, setPushSuccess] = useState(false);
// 获取当前用户的公司名称和检查合作关系
useEffect(() => {
const fetchData = async () => {
if (!isOpen || !factoryId) return;
setIsCheckingRelationship(true);
if (!auth.user) {
setIsCheckingRelationship(false);
return;
}
try {
if (auth.company) {
setRecipientCompany(auth.company.name);
const res = await http.get<{ has_relationship: boolean }>('/common/companies/check-relationship', {
factory_id: factoryId,
});
setHasRelationship(res.data.has_relationship);
if (!res.data.has_relationship) {
try {
const linkRes = await http.get<ShareLinkStatus | null>('/common/share-links/active', {
plan_id: planId,
factory_type: factoryType,
});
if (linkRes.data) {
const link = linkRes.data;
const expiresAt = link.expires_at ? new Date(link.expires_at) : null;
if (expiresAt && expiresAt > new Date()) {
setExistingLink(link);
setShortCode(link.short_code);
}
}
} catch {
// no existing link
}
}
}
} catch {
setHasRelationship(false);
}
setIsCheckingRelationship(false);
};
fetchData();
return () => {
shortLinkGenerated.current = false;
setShortCode('');
setExistingLink(null);
setHasRelationship(null);
setPushSuccess(false);
};
}, [isOpen, planId, factoryType, factoryId]);
// 生成短链30分钟有效
const generateShortLink = useCallback(async () => {
if (!factoryId) return;
setIsGeneratingLink(true);
try {
const res = await commonApi.createShareLink({
type: factoryType,
target_id: planId,
});
if (res.code) {
setShortCode(res.code);
} else {
setError('生成链接失败,请重试');
}
} catch {
setError('生成链接失败,请重试');
}
setIsGeneratingLink(false);
}, [planId, factoryType, hideSensitive, factoryId]);
const handleDirectPush = async () => {
if (!factoryId || !onDirectPush) return;
setIsGeneratingLink(true);
try {
await http.post(`/purchaser/plans/${planId}/factories`, {
factory_id: factoryId,
factory_type: factoryType,
});
setPushSuccess(true);
setTimeout(() => {
onDirectPush();
onClose();
}, 1500);
} catch {
setError('推送失败,请重试');
}
setIsGeneratingLink(false);
};
// 长链接 token异步生成
const [longLinkToken, setLongLinkToken] = useState<string>('');
useEffect(() => {
if (isOpen && !hasRelationship) {
encryptShareParams(planId, factoryType, hideSensitive).then(token => {
setLongLinkToken(token);
});
}
}, [isOpen, planId, factoryType, hideSensitive, hasRelationship]);
// 生成分享链接
const generateShareLink = useCallback(() => {
const baseUrl = window.location.origin + window.location.pathname;
if (shortCode) {
return `${baseUrl}#/import?s=${shortCode}`;
}
if (longLinkToken) {
return `${baseUrl}#/import?token=${longLinkToken}`;
}
return '';
}, [shortCode, longLinkToken]);
const handleCopy = useCallback(async () => {
const link = generateShareLink();
try {
await navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const textarea = document.createElement('textarea');
textarea.value = link;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [generateShareLink]);
// 检查链接状态
const checkLinkStatus = async () => {
if (!existingLink) return;
try {
const data = await commonApi.getShareLink(existingLink.short_code || existingLink.id);
if (data) {
setExistingLink(data as ShareLinkStatus);
}
} catch {
// ignore
}
};
if (!isOpen) return null;
const link = generateShareLink();
const factoryLabel = factoryType === 'textile' ? '织布厂' : '水洗厂';
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-3 sm:p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white rounded-2xl p-4 sm:p-6 w-full max-w-md shadow-2xl"
>
{/* 加载中 */}
{isCheckingRelationship && (
<div className="flex flex-col items-center justify-center py-8">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mb-4" />
<p className="text-gray-600">...</p>
</div>
)}
{/* 已有合作关系 - 直接推送 */}
{!isCheckingRelationship && hasRelationship === true && (
<>
<div className="flex items-center gap-2 sm:gap-3 mb-4">
<div className="w-9 h-9 sm:w-10 sm:h-10 bg-emerald-100 rounded-xl flex items-center justify-center flex-shrink-0">
<Send className="w-4 h-4 sm:w-5 sm:h-5 text-emerald-600" />
</div>
<div className="min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-gray-900"></h3>
<p className="text-xs sm:text-sm text-gray-500"></p>
</div>
</div>
<div className="bg-emerald-50 rounded-xl p-4 mb-4 border border-emerald-200">
<div className="flex items-center gap-2 mb-2">
<Building2 className="w-4 h-4 text-emerald-600" />
<span className="text-sm font-medium text-emerald-800"></span>
</div>
<p className="text-gray-900 font-medium">{factoryName || '未指定'}</p>
<p className="text-xs text-emerald-600 mt-1"> </p>
</div>
{pushSuccess && (
<div className="bg-emerald-100 rounded-xl p-3 mb-4 border border-emerald-200">
<p className="text-sm text-emerald-700 text-center"> </p>
</div>
)}
{error && (
<div className="bg-red-50 rounded-lg p-3 mb-4 border border-red-100">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<div className="flex gap-2 sm:gap-3">
<button
onClick={onClose}
className="flex-1 py-2 sm:py-2.5 border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-50 text-xs sm:text-sm font-medium transition-colors"
>
</button>
<button
onClick={handleDirectPush}
disabled={isGeneratingLink || pushSuccess}
className="flex-1 py-2 sm:py-2.5 bg-emerald-500 hover:bg-emerald-600 disabled:bg-gray-300 rounded-xl text-white text-xs sm:text-sm font-medium transition-colors flex items-center justify-center gap-2"
>
{isGeneratingLink ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Send size={isMobile ? 14 : 16} />
)}
{pushSuccess ? '已推送' : '直接推送'}
</button>
</div>
</>
)}
{/* 无合作关系 - 生成分享链接 */}
{!isCheckingRelationship && hasRelationship === false && (
<>
{!showConfirm ? (
<>
<div className="flex items-center gap-2 sm:gap-3 mb-3 sm:mb-4">
<div className="w-9 h-9 sm:w-10 sm:h-10 bg-blue-100 rounded-xl flex items-center justify-center flex-shrink-0">
<Link2 className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
</div>
<div className="min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-gray-900"></h3>
<p className="text-xs sm:text-sm text-gray-500"></p>
</div>
</div>
{/* 工厂信息 */}
<div className="bg-amber-50 rounded-xl p-3 mb-3 border border-amber-200">
<div className="flex items-center gap-2 mb-1">
<Building2 className="w-4 h-4 text-amber-600" />
<span className="text-xs text-amber-600 font-medium"></span>
</div>
<p className="text-sm text-gray-900 font-medium">{factoryName || '未指定'}</p>
<p className="text-xs text-amber-600 mt-1"> </p>
</div>
{/* 已有链接状态显示 */}
{existingLink && (
<div className={`rounded-xl p-3 mb-3 border ${
existingLink.status === 'pending' ? 'bg-blue-50 border-blue-200' :
existingLink.status === 'clicked' ? 'bg-yellow-50 border-yellow-200' :
'bg-gray-50 border-gray-200'
}`}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-800"></span>
<span className={`text-xs px-2 py-0.5 rounded ${
existingLink.status === 'pending' ? 'bg-blue-100 text-blue-700' :
existingLink.status === 'clicked' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{existingLink.status === 'pending' && '待点击'}
{existingLink.status === 'clicked' && '已点击'}
{existingLink.status === 'confirmed' && '已确认'}
{existingLink.status === 'rejected' && '已拒绝'}
</span>
</div>
{existingLink.status === 'clicked' && (
<p className="text-xs text-yellow-600 mt-1">
...
</p>
)}
{existingLink.status === 'rejected' && existingLink.reject_reason && (
<p className="text-xs text-red-600 mt-1">
{existingLink.reject_reason}
</p>
)}
</div>
)}
{/* 隐藏敏感信息开关 */}
<div className="bg-gray-50 rounded-xl p-3 sm:p-4 mb-3 sm:mb-4 border border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{hideSensitive ? <EyeOff className="w-4 h-4 text-amber-500" /> : <Eye className="w-4 h-4 text-emerald-500" />}
<div>
<p className="text-sm font-medium text-gray-800"></p>
<p className="text-xs text-gray-500"></p>
</div>
</div>
<button
onClick={() => setHideSensitive(!hideSensitive)}
className={`relative w-11 h-6 rounded-full transition-colors duration-200 ${hideSensitive ? 'bg-amber-500' : 'bg-gray-300'}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow-sm transition-transform duration-200 ${hideSensitive ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
{hideSensitive && (
<div className="mt-2 pt-2 border-t border-gray-200">
<p className="text-xs text-amber-600"></p>
<div className="flex flex-wrap gap-1 mt-1">
{planProductName && <span className="px-2 py-0.5 text-xs bg-amber-50 text-amber-700 rounded">: {planProductName}</span>}
{planColor && <span className="px-2 py-0.5 text-xs bg-amber-50 text-amber-700 rounded">: {planColor}</span>}
{planColorCode && <span className="px-2 py-0.5 text-xs bg-amber-50 text-amber-700 rounded">: {planColorCode}</span>}
</div>
</div>
)}
</div>
{/* 链接显示 */}
{shortCode && (
<div className="bg-gray-50 rounded-xl p-2.5 sm:p-3 mb-3 sm:mb-4 border border-gray-200">
<p className="text-xs text-gray-500 mb-1">30</p>
<p className="text-xs sm:text-sm text-gray-700 break-all font-mono">{link}</p>
</div>
)}
{error && (
<div className="bg-red-50 rounded-lg p-2 border border-red-100 mb-3">
<p className="text-xs text-red-600">{error}</p>
</div>
)}
<div className="flex gap-2 sm:gap-3">
<button
onClick={onClose}
className="flex-1 py-2 sm:py-2.5 border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-50 text-xs sm:text-sm font-medium transition-colors"
>
</button>
{shortCode ? (
<button
onClick={handleCopy}
className={`flex-1 py-2 sm:py-2.5 rounded-xl text-white flex items-center justify-center gap-1.5 sm:gap-2 transition-colors text-xs sm:text-sm font-medium ${
copied ? 'bg-emerald-500' : 'bg-blue-600 hover:bg-blue-700'
}`}
>
{copied ? <Check size={isMobile ? 14 : 16} /> : <Copy size={isMobile ? 14 : 16} />}
{copied ? '已复制' : '复制链接'}
</button>
) : (
<button
onClick={generateShortLink}
disabled={isGeneratingLink}
className="flex-1 py-2 sm:py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 rounded-xl text-white text-xs sm:text-sm font-medium transition-colors flex items-center justify-center gap-2"
>
{isGeneratingLink ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Link2 size={isMobile ? 14 : 16} />
)}
</button>
)}
</div>
</>
) : (
<>
<div className="flex items-center gap-2 sm:gap-3 mb-4">
<div className="w-9 h-9 sm:w-10 sm:h-10 bg-amber-100 rounded-xl flex items-center justify-center flex-shrink-0">
<AlertTriangle className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600" />
</div>
<div className="min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-gray-900"></h3>
<p className="text-xs sm:text-sm text-gray-500"></p>
</div>
</div>
<div className="space-y-3 mb-4">
<div className="bg-blue-50 rounded-xl p-3 border border-blue-100">
<div className="flex items-center gap-2 mb-1">
<Building2 className="w-4 h-4 text-blue-600" />
<span className="text-xs text-blue-600 font-medium"></span>
</div>
<p className="text-sm text-gray-900 font-medium">{factoryName || '未指定'}</p>
</div>
<div className="bg-green-50 rounded-xl p-3 border border-green-100">
<div className="flex items-center gap-2 mb-1">
<Building2 className="w-4 h-4 text-green-600" />
<span className="text-xs text-green-600 font-medium"></span>
</div>
<p className="text-sm text-gray-900 font-medium">{recipientCompany || '加载中...'}</p>
</div>
<div className="space-y-2">
<div>
<label className="text-xs text-gray-500 mb-1 block"></label>
<input
type="text"
value={confirmFactoryName}
onChange={(e) => setConfirmFactoryName(e.target.value)}
placeholder="输入工厂名称"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500"
/>
</div>
<div>
<label className="text-xs text-gray-500 mb-1 block"></label>
<input
type="text"
value={confirmCompanyName}
onChange={(e) => setConfirmCompanyName(e.target.value)}
placeholder="输入对方公司名称"
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500"
/>
</div>
</div>
{error && (
<div className="bg-red-50 rounded-lg p-2 border border-red-100">
<p className="text-xs text-red-600">{error}</p>
</div>
)}
</div>
<div className="flex gap-2 sm:gap-3">
<button
onClick={() => setShowConfirm(false)}
className="flex-1 py-2 sm:py-2.5 border border-gray-300 rounded-xl text-gray-700 hover:bg-gray-50 text-xs sm:text-sm font-medium transition-colors"
>
</button>
<button
onClick={() => setShowConfirm(false)}
className="flex-1 py-2 sm:py-2.5 bg-amber-500 hover:bg-amber-600 rounded-xl text-white text-xs sm:text-sm font-medium transition-colors"
>
</button>
</div>
</>
)}
</>
)}
</motion.div>
</div>
);
}