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>
528 lines
22 KiB
TypeScript
528 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|