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>
221 lines
6.0 KiB
TypeScript
221 lines
6.0 KiB
TypeScript
import React, { useRef, useState } from 'react';
|
||
import { motion } from 'framer-motion';
|
||
import { Camera, X, Upload, ImageIcon } from 'lucide-react';
|
||
import { http } from '../api';
|
||
|
||
export type UploadBucket = 'avatars' | 'product_images';
|
||
|
||
interface ImageUploaderProps {
|
||
/** 当前图片 URL */
|
||
value?: string | null;
|
||
/** 图片变更回调 */
|
||
onChange: (url: string | null) => void;
|
||
/** 存储桶名称 */
|
||
bucket?: UploadBucket;
|
||
/** 上传路径前缀(仅 product_images 使用) */
|
||
pathPrefix?: string;
|
||
/** 是否圆形裁剪(头像模式) */
|
||
circular?: boolean;
|
||
/** 容器尺寸(px) */
|
||
size?: number;
|
||
/** 占位图标 */
|
||
placeholderIcon?: React.ReactNode;
|
||
/** 是否禁用 */
|
||
disabled?: boolean;
|
||
/** 自定义类名 */
|
||
className?: string;
|
||
/** 最大文件大小(MB) */
|
||
maxFileSize?: number;
|
||
}
|
||
|
||
export function ImageUploader({
|
||
value,
|
||
onChange,
|
||
bucket = 'product_images',
|
||
pathPrefix = 'products',
|
||
circular = false,
|
||
size = 80,
|
||
placeholderIcon,
|
||
disabled = false,
|
||
className = '',
|
||
maxFileSize = 5
|
||
}: ImageUploaderProps) {
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [previewUrl, setPreviewUrl] = useState<string | null>(value || null);
|
||
|
||
// 同步外部 value 变化
|
||
React.useEffect(() => {
|
||
setPreviewUrl(value || null);
|
||
}, [value]);
|
||
|
||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
// 验证文件类型
|
||
if (!file.type.startsWith('image/')) {
|
||
alert('请选择图片文件');
|
||
return;
|
||
}
|
||
|
||
// 验证文件大小
|
||
if (file.size > maxFileSize * 1024 * 1024) {
|
||
alert(`图片大小不能超过 ${maxFileSize}MB`);
|
||
return;
|
||
}
|
||
|
||
setUploading(true);
|
||
|
||
try {
|
||
const formDataObj = new FormData();
|
||
formDataObj.append('file', file);
|
||
formDataObj.append('bucket', bucket);
|
||
formDataObj.append('path_prefix', pathPrefix);
|
||
|
||
// TODO: 文件上传接口待后端实现,当前使用 fetch 直接发送 FormData
|
||
const res = await fetch('/api/v1/common/upload', {
|
||
method: 'POST',
|
||
body: formDataObj,
|
||
});
|
||
|
||
if (!res.ok) {
|
||
alert('图片上传失败');
|
||
setUploading(false);
|
||
return;
|
||
}
|
||
|
||
const json = await res.json();
|
||
const publicUrl = json.data?.url || '';
|
||
setPreviewUrl(publicUrl);
|
||
onChange(publicUrl);
|
||
setUploading(false);
|
||
} catch (error) {
|
||
alert('图片上传失败');
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleRemove = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
setPreviewUrl(null);
|
||
onChange(null);
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
const triggerFileSelect = () => {
|
||
if (!disabled && !uploading) {
|
||
fileInputRef.current?.click();
|
||
}
|
||
};
|
||
|
||
const borderRadius = circular ? 'rounded-full' : 'rounded-xl';
|
||
const containerStyle = { width: size, height: size };
|
||
|
||
return (
|
||
<div className={`relative inline-block ${className}`}>
|
||
<label
|
||
className={`${borderRadius} flex items-center justify-center overflow-hidden relative group cursor-pointer transition-all ${disabled || uploading ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-lg'}`}
|
||
style={containerStyle}
|
||
>
|
||
{/* 背景色 */}
|
||
<div className="absolute inset-0 bg-gradient-to-br from-gray-100 to-gray-200" />
|
||
|
||
{/* 图片预览 */}
|
||
{previewUrl ? (
|
||
<img
|
||
src={previewUrl}
|
||
alt="预览"
|
||
className={`w-full h-full object-cover relative z-10 ${circular ? 'rounded-full' : ''}`}
|
||
/>
|
||
) : (
|
||
<div className="relative z-10 flex flex-col items-center justify-center text-gray-400">
|
||
{placeholderIcon || <ImageIcon className="w-8 h-8" />}
|
||
</div>
|
||
)}
|
||
|
||
{/* 上传遮罩层 */}
|
||
{!disabled && !uploading && (
|
||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||
{previewUrl ? (
|
||
<Camera className="w-6 h-6 text-white" />
|
||
) : (
|
||
<Upload className="w-6 h-6 text-white" />
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 上传中状态 */}
|
||
{uploading && (
|
||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-30">
|
||
<motion.div
|
||
animate={{ rotate: 360 }}
|
||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||
className="w-6 h-6 border-2 border-white border-t-transparent rounded-full"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 隐藏的文件输入 */}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleFileSelect}
|
||
disabled={disabled || uploading}
|
||
/>
|
||
</label>
|
||
|
||
{/* 删除按钮 */}
|
||
{previewUrl && !disabled && !uploading && (
|
||
<button
|
||
onClick={handleRemove}
|
||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center shadow-md transition-colors z-30"
|
||
title="删除图片"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 头像上传专用组件(简化版)
|
||
*/
|
||
interface AvatarUploaderProps {
|
||
value?: string | null;
|
||
onChange: (url: string | null) => void;
|
||
size?: number;
|
||
placeholderIcon?: React.ReactNode;
|
||
disabled?: boolean;
|
||
className?: string;
|
||
}
|
||
|
||
export function AvatarUploader({
|
||
value,
|
||
onChange,
|
||
size = 64,
|
||
placeholderIcon,
|
||
disabled = false,
|
||
className = ''
|
||
}: AvatarUploaderProps) {
|
||
return (
|
||
<ImageUploader
|
||
value={value}
|
||
onChange={onChange}
|
||
bucket="avatars"
|
||
circular
|
||
size={size}
|
||
placeholderIcon={placeholderIcon}
|
||
disabled={disabled}
|
||
className={className}
|
||
maxFileSize={5}
|
||
/>
|
||
);
|
||
}
|