MediaUploader.jsx 7.63 KB
import React, { useState, useRef } from 'react';
import { Upload, Link as LinkIcon, X, Film, Music, Image as ImageIcon, ExternalLink } from 'lucide-react';
import { uploadAsset } from '../api';
import { filenameFromUrl, thumbUrl, localAsset } from '../utils';

export default function MediaUploader({
  label,
  accept = 'image/*',
  type = 'image', // 'image' | 'video' | 'audio'
  multiple = false,
  maxCount = 99,
  value, // string | string[]
  onChange,
  hint,
}) {
  const [urlInput, setUrlInput] = useState('');
  const [isUploading, setIsUploading] = useState(false);
  const [isDragOver, setIsDragOver] = useState(false);
  const fileInputRef = useRef(null);

  const items = multiple ? (Array.isArray(value) ? value : []) : (value ? [value] : []);

  const handleFiles = async (files) => {
    if (!files || files.length === 0) return;
    setIsUploading(true);
    try {
      const newUrls = [];
      for (const file of Array.from(files)) {
        if (items.length + newUrls.length >= maxCount) break;
        const res = await uploadAsset(file);
        if (res.url) newUrls.push(res.url);
      }
      if (multiple) {
        onChange([...items, ...newUrls].slice(0, maxCount));
      } else if (newUrls.length > 0) {
        onChange(newUrls[0]);
      }
    } catch (e) {
      alert('上传失败: ' + e.message);
    } finally {
      setIsUploading(false);
      if (fileInputRef.current) fileInputRef.current.value = '';
    }
  };

  const handleAddUrl = (e) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      const u = urlInput.trim();
      if (!u) return;
      if (multiple) {
        if (items.length >= maxCount) {
          alert(`最多只允许添加 ${maxCount} 个`);
          return;
        }
        onChange([...items, u]);
      } else {
        onChange(u);
      }
      setUrlInput('');
    }
  };

  const handleRemove = (index) => {
    if (multiple) {
      const next = items.filter((_, i) => i !== index);
      onChange(next);
    } else {
      onChange(null);
    }
  };

  // 支持键盘粘贴截图
  const handlePaste = (e) => {
    const clipboardFiles = e.clipboardData?.files;
    if (clipboardFiles && clipboardFiles.length > 0) {
      e.preventDefault();
      handleFiles(clipboardFiles);
    }
  };

  return (
    <div className="flex flex-col gap-2.5" onPaste={handlePaste}>
      <div className="flex items-center justify-between">
        <label className="text-xs font-semibold uppercase tracking-wider text-zinc-300">
          {label} {maxCount < 99 && <span className="text-zinc-500 font-normal">({items.length}/{maxCount})</span>}
        </label>
        {hint && <span className="text-xs text-zinc-500">{hint}</span>}
      </div>

      {/* 拖拽上传与输入区 */}
      {(items.length < maxCount || !multiple) && (
        <div
          onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
          onDragLeave={() => setIsDragOver(false)}
          onDrop={(e) => { e.preventDefault(); setIsDragOver(false); handleFiles(e.dataTransfer.files); }}
          className={`relative border-2 border-dashed rounded-xl p-3.5 transition-all flex flex-col sm:flex-row items-center gap-3 bg-zinc-900/60 ${
            isDragOver ? 'border-emerald-500 bg-emerald-950/20 shadow-inner' : 'border-zinc-800 hover:border-zinc-700'
          }`}
        >
          <input
            type="file"
            ref={fileInputRef}
            accept={accept}
            multiple={multiple}
            className="hidden"
            onChange={(e) => handleFiles(e.target.files)}
          />

          <button
            type="button"
            disabled={isUploading}
            onClick={() => fileInputRef.current?.click()}
            className="w-full sm:w-auto px-4 py-2 bg-zinc-800 hover:bg-zinc-700/80 text-zinc-200 text-xs font-medium rounded-lg flex items-center justify-center gap-2 border border-zinc-700/60 transition-colors shrink-0 cursor-pointer shadow-sm disabled:opacity-50"
          >
            <Upload className="w-4 h-4 text-emerald-400" />
            <span>{isUploading ? '正在上传…' : '选择或拖拽文件'}</span>
          </button>

          <div className="relative flex-1 w-full">
            <LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
            <input
              type="url"
              placeholder="或粘贴 URL,回车添加(支持 Ctrl+V 粘贴截图)"
              value={urlInput}
              onChange={(e) => setUrlInput(e.target.value)}
              onKeyDown={handleAddUrl}
              className="w-full pl-9 pr-3 py-1.5 bg-zinc-950/60 border border-zinc-800 focus:border-emerald-500/60 focus:ring-1 focus:ring-emerald-500/40 rounded-lg text-xs text-zinc-200 placeholder-zinc-500 outline-none transition-all"
            />
          </div>
        </div>
      )}

      {/* 媒体缩略预览网格 */}
      {items.length > 0 && (
        <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mt-1">
          {items.map((u, idx) => {
            const rawName = filenameFromUrl(u);
            return (
              <div
                key={idx}
                className="group relative bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden flex flex-col shadow-md hover:border-zinc-700 transition-all"
              >
                {/* 媒体呈现 */}
                <div className="relative aspect-video bg-black flex items-center justify-center overflow-hidden">
                  {type === 'image' && (
                    <img
                      src={thumbUrl(u)}
                      alt={rawName}
                      className="w-full h-full object-cover transition-transform group-hover:scale-105 duration-300"
                    />
                  )}
                  {type === 'video' && (
                    <video
                      src={localAsset(u)}
                      controls
                      className="w-full h-full object-contain bg-black"
                    />
                  )}
                  {type === 'audio' && (
                    <div className="p-2 w-full flex flex-col items-center justify-center gap-1 text-zinc-400">
                      <Music className="w-6 h-6 text-emerald-400 mb-1" />
                      <audio src={localAsset(u)} controls className="w-full h-8" />
                    </div>
                  )}

                  {/* 悬停快速操作 */}
                  <div className="absolute top-1.5 right-1.5 flex gap-1 z-10">
                    <a
                      href={u}
                      target="_blank"
                      rel="noreferrer"
                      className="p-1 bg-black/60 hover:bg-black/90 text-zinc-300 hover:text-white rounded-md backdrop-blur-md transition-colors"
                      title="打开原图"
                    >
                      <ExternalLink className="w-3.5 h-3.5" />
                    </a>
                    <button
                      type="button"
                      onClick={() => handleRemove(idx)}
                      className="p-1 bg-rose-950/80 hover:bg-rose-600 text-rose-300 hover:text-white rounded-md backdrop-blur-md transition-colors cursor-pointer"
                      title="删除"
                    >
                      <X className="w-3.5 h-3.5" />
                    </button>
                  </div>
                </div>

                {/* 底部文件名 */}
                <div className="px-2.5 py-1.5 bg-zinc-900/90 text-[11px] text-zinc-400 truncate border-t border-zinc-800/80 font-mono" title={rawName}>
                  {rawName || '媒体文件'}
                </div>
              </div>
            );
          })}
        </div>
      )}
    </div>
  );
}