App.tsx 14.1 KB
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { ImageItem, ImageGenerationParams, UserProfile, VideoStatus } from './types';
import { SHOWCASE_IMAGES, ADMIN_ID, VIDEO_OSS_BASE_URL } from './constants';
import { generateImage } from './services/imageService';
import { submitVideoJob, pollVideoStatus } from './services/videoService';
import { fetchGallery, fetchVideoGallery, saveVideo, toggleLike } from './services/galleryService';
import MasonryGrid from './components/MasonryGrid';
import InputBar from './components/InputBar';
import HistoryBar from './components/HistoryBar';
import DetailModal from './components/DetailModal';
import AdminModal from './components/AdminModal';
import AuthModal from './components/AuthModal';
import WhitelistModal from './components/WhitelistModal';
import { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles, Users, Video, Hourglass } from 'lucide-react';

const STORAGE_KEY_USER = 'z-image-user-profile';
const MIN_GALLERY_ITEMS = 8;

enum GalleryMode {
  Image,
  Video,
}

const App: React.FC = () => {
  // State
  const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
  const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
  const [galleryMode, setGalleryMode] = useState<GalleryMode>(GalleryMode.Image);
  const [images, setImages] = useState<ImageItem[]>(SHOWCASE_IMAGES);
  const [videos, setVideos] = useState<ImageItem[]>([]);
  const [isGenerating, setIsGenerating] = useState(false);
  const [videoStatus, setVideoStatus] = useState<VideoStatus | null>(null);
  const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null);
  const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
  const [isWhitelistModalOpen, setIsWhitelistModalOpen] = useState(false);
  const [editingImage, setEditingImage] = useState<ImageItem | null>(null);

  const isAdmin = currentUser?.employeeId === ADMIN_ID;
  const isGeneratingVideo = videoStatus !== null;

  const sortedImages = useMemo(() => [...images].sort((a, b) => (b.likes || 0) - (a.likes || 0)), [images]);
  const sortedVideos = useMemo(() => [...videos].sort((a, b) => b.createdAt - a.createdAt), [videos]);
  
  const userHistory = useMemo(() => {
    if (!currentUser) return [];
    return images.filter(img => img.authorId === currentUser.employeeId).sort((a, b) => b.createdAt - a.createdAt);
  }, [images, currentUser]);

  const userVideoHistory = useMemo(() => {
    if (!currentUser) return [];
    return videos.filter(vid => vid.authorId === currentUser.employeeId).sort((a, b) => b.createdAt - a.createdAt);
  }, [videos, currentUser]);

  // --- Auth Effect ---
  useEffect(() => {
    const savedUser = localStorage.getItem(STORAGE_KEY_USER);
    if (savedUser) setCurrentUser(JSON.parse(savedUser));
    else setIsAuthModalOpen(true);
  }, []);

  // --- Data Sync ---
  const syncImageGallery = useCallback(async () => {
    try {
      const remoteImages = await fetchGallery();
      setImages(prev => {
        const normalized = remoteImages.map(img => ({ ...img, isLikedByCurrentUser: currentUser ? (img.likedBy || []).includes(currentUser.employeeId) : false }));
        if (normalized.length >= MIN_GALLERY_ITEMS) return normalized;
        const existingIds = new Set(normalized.map(img => img.id));
        const filler = SHOWCASE_IMAGES.filter(img => !existingIds.has(img.id)).slice(0, MIN_GALLERY_ITEMS - normalized.length);
        return [...normalized, ...filler];
      });
    } catch (err) { console.error("Failed to sync image gallery", err); }
  }, [currentUser]);

  const syncVideoGallery = useCallback(async () => {
    try {
      const remoteVideos = await fetchVideoGallery();
      setVideos(remoteVideos.map(vid => ({ ...vid, isLikedByCurrentUser: currentUser ? (vid.likedBy || []).includes(currentUser.employeeId) : false })));
    } catch (err) { console.error("Failed to sync video gallery", err); }
  }, [currentUser]);

  useEffect(() => {
    syncImageGallery();
    syncVideoGallery();
    const interval = setInterval(() => {
      syncImageGallery();
      syncVideoGallery();
    }, 30000);
    return () => clearInterval(interval);
  }, [syncImageGallery, syncVideoGallery]);


  // --- Handlers ---
  const handleLogin = (employeeId: string) => {
    const user: UserProfile = { employeeId, hasAccess: true };
    setCurrentUser(user);
    localStorage.setItem(STORAGE_KEY_USER, JSON.stringify(user));
    setIsAuthModalOpen(false);
  };

  const handleLogout = () => {
    if (confirm("确定要退出登录吗?")) {
      localStorage.removeItem(STORAGE_KEY_USER);
      setCurrentUser(null);
      setIsAuthModalOpen(true);
    }
  };

  const handleGenerateVideo = async (params: ImageGenerationParams, imageFile: File) => {
    if (!currentUser) { setIsAuthModalOpen(true); return; }
    setError(null);
    setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' });

    try {
      const taskId = await submitVideoJob(params.prompt, imageFile, currentUser.employeeId);
      const finalStatus = await pollVideoStatus(taskId, setVideoStatus);

      if (!finalStatus.video_filename) {
        throw new Error("视频生成完成,但未找到有效的视频文件名。");
      }

      const newVideoData: ImageItem = {
        id: `vid-${Date.now()}`,
        url: `${VIDEO_OSS_BASE_URL}/${finalStatus.video_filename}`,
        prompt: params.prompt,
        authorId: currentUser.employeeId,
        createdAt: Date.now(),
        likes: 0,
        isLikedByCurrentUser: false,
        generationTime: finalStatus.processing_time,
      };
      
      const savedVideo = await saveVideo(newVideoData);
      setVideos(prev => [savedVideo, ...prev]);

      setTimeout(() => setVideoStatus(null), 3000);
    } catch (err: any) {
      console.error(err);
      setError("视频生成失败。请确保视频生成服务正常运行。");
      setVideoStatus(null);
    }
  };
  
  const handleGenerate = async (uiParams: ImageGenerationParams, imageFile?: File) => {
    if (galleryMode === GalleryMode.Video) {
      if (!imageFile) { alert("请上传一张图片以生成视频。"); return; }
      handleGenerateVideo(uiParams, imageFile);
      return;
    }
    if (!currentUser) { setIsAuthModalOpen(true); return; }
    setIsGenerating(true);
    setError(null);
    setIncomingParams(null);
    try {
      const result = await generateImage(uiParams, currentUser.employeeId);
      const serverImage = result.galleryItem ? { ...result.galleryItem, isLikedByCurrentUser: false } : null;
      const newImage: ImageItem = serverImage || { id: Date.now().toString(), url: result.imageUrl, createdAt: Date.now(), authorId: currentUser.employeeId, likes: 0, isLikedByCurrentUser: false, ...uiParams };
      setImages(prev => [newImage, ...prev.filter(img => img.id !== newImage.id)]);
      if (!serverImage) await syncImageGallery();
    } catch (err: any) {
      console.error(err);
      setError("生成失败。请确保服务器正常运行。");
    } finally {
      setIsGenerating(false);
    }
  };

  const handleLike = async (item: ImageItem) => {
    if (!currentUser) { setIsAuthModalOpen(true); return; }
    const isVideo = item.id.startsWith('vid-');
    const stateSetter = isVideo ? setVideos : setImages;
    
    stateSetter(prev => prev.map(i => {
      if (i.id === item.id) {
        const isLiked = !!i.isLikedByCurrentUser;
        return { ...i, isLikedByCurrentUser: !isLiked, likes: (i.likes || 0) + (isLiked ? -1 : 1) };
      }
      return i;
    }));

    try {
      await toggleLike(item.id, currentUser.employeeId);
    } catch (e) {
      console.error("Like failed", e);
      if (isVideo) syncVideoGallery(); else syncImageGallery();
      alert("操作失败");
    }
  };

  const handleGenerateSimilar = (params: ImageGenerationParams) => {
    setIncomingParams(params);
    const banner = document.getElementById('similar-feedback');
    if (banner) { banner.style.display = 'flex'; setTimeout(() => { banner.style.display = 'none'; }, 3000); }
    window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
  };
  
  // Admin handlers...
  const handleOpenCreateModal = () => { if (isAdmin) { setEditingImage(null); setIsAdminModalOpen(true); } };
  const handleOpenEditModal = (image: ImageItem) => { if (isAdmin) { setEditingImage(image); setSelectedImage(null); setIsAdminModalOpen(true); } };
  const handleSaveImage = (savedImage: ImageItem) => setImages(prev => { const exists = prev.some(img => img.id === savedImage.id); if (exists) return prev.map(img => img.id === savedImage.id ? savedImage : img); return [{ ...savedImage, authorId: currentUser?.employeeId || 'ADMIN', likes: savedImage.likes || 0 }, ...prev]; });
  const handleDeleteImage = (id: string) => { if (isAdmin) setImages(prev => prev.filter(img => img.id !== id)); };
  const handleResetData = () => { if (isAdmin && confirm('警告:确定要重置为初始演示数据吗?')) { setImages(SHOWCASE_IMAGES); } };

  const handleExportShowcase = () => {
    const exportData = images.map(img => ({ ...img, isLikedByCurrentUser: false, isMock: true }));
    const fileContent = `import { ImageItem } from './types';\n\nexport const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2)};`;
    const blob = new Blob([fileContent], { type: 'text/typescript' });
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = 'showcase_images.ts';
    a.click();
  };

  return (
    <div className="min-h-screen bg-white text-gray-900 font-sans pb-40">
      <AuthModal isOpen={isAuthModalOpen} onLogin={handleLogin} />
      <header className="px-6 py-6 md:px-12 md:py-8 flex justify-between items-end sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-gray-100">
        <div>
           <h1 className="text-3xl md:text-4xl font-extrabold tracking-tight mb-2">艺云-DESIGN</h1>
           <p className="text-gray-400 text-sm md:text-base">东方设计 · 全局创作灵感库</p>
        </div>
        
        <div className="flex items-center gap-3">
            {currentUser && (
              <div className="hidden md:flex flex-col items-end mr-2">
                <span className={`text-xs uppercase tracking-wider px-2 py-0.5 rounded font-bold ${isAdmin ? 'text-purple-600 bg-purple-50' : 'text-gray-400 bg-gray-50'}`}>
                  {isAdmin ? 'Administrator' : '设计师'}
                </span>
                <span className="font-mono font-bold text-gray-800">{currentUser.employeeId}</span>
              </div>
            )}

            {isAdmin && (
              <div className="flex gap-2">
                 <button onClick={handleExportShowcase} className="hidden md:flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-full hover:bg-purple-700 transition-colors text-sm font-medium shadow-sm"><Save size={16} /><span>导出库</span></button>
                 <button onClick={() => setIsWhitelistModalOpen(true)} className="hidden md:flex items-center justify-center p-2 rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors" title="白名单管理"><Users size={20} /></button>
                 <button onClick={handleResetData} className="p-2 md:p-3 rounded-full hover:bg-red-50 text-gray-300 hover:text-red-500 transition-colors"><Trash2 size={20} /></button>
                 <button onClick={handleOpenCreateModal} className="p-2 md:p-3 rounded-full bg-black text-white hover:bg-gray-800 transition-colors shadow-sm"><Settings size={20} /></button>
              </div>
            )}

            {currentUser && <button onClick={handleLogout} className="p-2 md:p-3 rounded-full hover:bg-gray-100 transition-colors text-gray-600"><UserIcon size={20} /></button>}
        </div>
      </header>

      <div className="px-6 md:px-12 mt-6 mb-4 flex border-b">
        <button onClick={() => setGalleryMode(GalleryMode.Image)} className={`px-4 py-2 font-medium text-sm transition-colors ${galleryMode === GalleryMode.Image ? 'border-b-2 border-purple-600 text-purple-600' : 'text-gray-500 hover:text-gray-800'}`}>灵感图库</button>
        <button onClick={() => setGalleryMode(GalleryMode.Video)} className={`px-4 py-2 font-medium text-sm transition-colors ${galleryMode === GalleryMode.Video ? 'border-b-2 border-purple-600 text-purple-600' : 'text-gray-500 hover:text-gray-800'}`}>动感视界</button>
      </div>

      {error && <div className="mx-6 md:mx-12 mt-4 p-4 bg-red-50 border border-red-200 text-red-600 rounded-lg text-sm flex justify-between"><span>{error}</span><button onClick={() => setError(null)}><Trash2 size={14}/></button></div>}

      <main>
        {isGenerating && (
          <div className="w-full flex justify-center py-12"><div className="flex flex-col items-center animate-pulse"><Loader2 className="animate-spin text-purple-600 mb-3" size={40} /><span className="text-gray-500 font-medium">绘图引擎全力启动中...</span></div></div>
        )}
        
        {galleryMode === GalleryMode.Image ? (
            <MasonryGrid images={sortedImages} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId}/>
        ) : (
            <MasonryGrid images={sortedVideos} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId} isVideoGallery={true} />
        )}
      </main>

      <HistoryBar images={galleryMode === GalleryMode.Image ? userHistory : userVideoHistory} onSelect={setSelectedImage} />
      <InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} videoStatus={videoStatus} />
      {selectedImage && <DetailModal image={selectedImage} onClose={() => setSelectedImage(null)} onEdit={isAdmin ? handleOpenEditModal : undefined} onGenerateSimilar={handleGenerateSimilar}/>}
      <AdminModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} onSave={handleSaveImage} onDelete={handleDeleteImage} initialData={editingImage} />
      <WhitelistModal isOpen={isWhitelistModalOpen} onClose={() => setIsWhitelistModalOpen(false)} />
    </div>
  );
};

export default App;