2026-06-04 15:09:31 +00:00
|
|
|
|
import React, { useRef, useState } from 'react';
|
|
|
|
|
|
import { motion } from 'framer-motion';
|
|
|
|
|
|
import { Camera, X, Upload, ImageIcon } from 'lucide-react';
|
2026-06-30 16:27:17 +08:00
|
|
|
|
import { commonApi } from '../api/common';
|
2026-06-04 15:09:31 +00:00
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-06-30 16:27:17 +08:00
|
|
|
|
const publicUrl = await commonApi.uploadFile(file, bucket, pathPrefix);
|
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
|
|
|
|
setPreviewUrl(publicUrl);
|
|
|
|
|
|
onChange(publicUrl);
|
|
|
|
|
|
setUploading(false);
|
2026-06-04 15:09:31 +00:00
|
|
|
|
} 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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|