App.tsx 12.3 KB
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { ImageItem, ImageGenerationParams, UserProfile } from './types';
import { SHOWCASE_IMAGES, ADMIN_ID } from './constants';
import { generateImage } from './services/imageService';
import { fetchGallery } 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 { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles } from 'lucide-react';

const STORAGE_KEY_DATA = 'z-image-gallery-data-v2';
const STORAGE_KEY_USER = 'z-image-user-profile';
const MIN_GALLERY_ITEMS = 8;

const App: React.FC = () => {
  // --- State: User ---
  const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
  const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);

  // --- State: Data ---
  const [images, setImages] = useState<ImageItem[]>(() => {
    try {
      const saved = localStorage.getItem(STORAGE_KEY_DATA);
      if (saved) return JSON.parse(saved);
    } catch (e) { console.error(e); }
    return SHOWCASE_IMAGES;
  });

  const [isGenerating, setIsGenerating] = useState(false);
  const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null);

  // --- State: Admin/Edit ---
  const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
  const [editingImage, setEditingImage] = useState<ImageItem | null>(null);

  const isAdmin = currentUser?.employeeId === ADMIN_ID;

  // GLOBAL GALLERY: Everyone sees everything, sorted by likes
  const sortedImages = useMemo(() => {
    return [...images].sort((a, b) => (b.likes || 0) - (a.likes || 0));
  }, [images]);

  // USER HISTORY: Only current user's generations
  const userHistory = useMemo(() => {
    if (!currentUser) return [];
    return images
      .filter(img => img.authorId === currentUser.employeeId)
      .sort((a, b) => b.createdAt - a.createdAt);
  }, [images, currentUser]);

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

  useEffect(() => {
    try { localStorage.setItem(STORAGE_KEY_DATA, JSON.stringify(images)); } 
    catch (e) { console.error("Storage full", e); }
  }, [images]);

  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 syncGallery = useCallback(async () => {
    try {
      const remoteImages = await fetchGallery();
      setImages(prev => {
        const localState = new Map(
          prev.map(img => [
            img.id,
            { likes: img.likes, isLikedByCurrentUser: !!img.isLikedByCurrentUser },
          ])
        );

        const normalized = remoteImages.map(img => ({
          ...img,
          likes: localState.get(img.id)?.likes ?? img.likes ?? 0,
          isLikedByCurrentUser: localState.get(img.id)?.isLikedByCurrentUser ?? 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,
          Math.max(0, MIN_GALLERY_ITEMS - normalized.length)
        );

        return [...normalized, ...filler];
      });
    } catch (err) {
      console.error("Failed to sync gallery", err);
      setImages(prev => {
        if (prev.length >= MIN_GALLERY_ITEMS) return prev;
        const existingIds = new Set(prev.map(img => img.id));
        const filler = SHOWCASE_IMAGES.filter(img => !existingIds.has(img.id)).slice(
          0,
          Math.max(0, MIN_GALLERY_ITEMS - prev.length)
        );
        return [...prev, ...filler];
      });
    }
  }, []);

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

  const handleGenerate = async (uiParams: ImageGenerationParams) => {
    if (!currentUser) {
      setIsAuthModalOpen(true);
      return;
    }

    setIsGenerating(true);
    setError(null);
    setIncomingParams(null); // Reset syncing state once action starts

    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 => {
        const existing = prev.filter(img => img.id !== newImage.id);
        return [newImage, ...existing];
      });

      if (!serverImage) {
        await syncGallery();
      }
    } catch (err: any) {
      console.error(err);
      setError("生成失败。请确保服务器正常运行。");
    } finally {
      setIsGenerating(false);
    }
  };

  const handleLike = (image: ImageItem) => {
    if (!currentUser) {
      setIsAuthModalOpen(true);
      return;
    }
    setImages(prev => prev.map(img => {
      if (img.id === image.id) {
        const isLiked = !!img.isLikedByCurrentUser;
        return {
          ...img,
          isLikedByCurrentUser: !isLiked,
          likes: isLiked ? Math.max(0, (img.likes || 0) - 1) : (img.likes || 0) + 1
        };
      }
      return img;
    }));
  };

  const handleGenerateSimilar = (params: ImageGenerationParams) => {
    setIncomingParams(params);
    // Visual feedback
    const banner = document.getElementById('similar-feedback');
    if (banner) {
      banner.style.display = 'flex';
      setTimeout(() => { banner.style.display = 'none'; }, 3000);
    }
    // Scroll to bottom where input is
    window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
  };

  // --- Management (ADMIN) ---
  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';
export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009";
const ENV_PROXY_URL = import.meta.env?.VITE_API_BASE_URL?.trim();
const DEFAULT_PROXY_URL = ENV_PROXY_URL && ENV_PROXY_URL.length > 0
  ? ENV_PROXY_URL
  : "http://localhost:9009";
export const API_BASE_URL = DEFAULT_PROXY_URL;
export const API_ENDPOINTS = API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL
  ? [Z_IMAGE_DIRECT_BASE_URL]
  : [API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL];
export const ADMIN_ID = '${ADMIN_ID}';
export const DEFAULT_PARAMS = { height: 1024, width: 1024, num_inference_steps: 20, guidance_scale: 7.5 };
export 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 = 'constants.ts';
    a.click();
  };

  return (
    <div className="min-h-screen bg-white text-gray-900 font-sans pb-40">
      <AuthModal isOpen={isAuthModalOpen} onLogin={handleLogin} />

      {/* Sync Feedback Banner */}
      <div id="similar-feedback" className="fixed top-24 left-1/2 -translate-x-1/2 z-[60] bg-purple-600 text-white px-6 py-2 rounded-full shadow-lg hidden items-center gap-2 animate-bounce">
         <Sparkles size={16} />
         <span className="text-sm font-bold tracking-wide">参数已同步到输入框</span>
      </div>

      <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={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>

      {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>
        )}
        
        <MasonryGrid images={sortedImages} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId}/>
      </main>

      {/* History Album (Bottom Left) */}
      <HistoryBar images={userHistory} onSelect={setSelectedImage} />

      <InputBar onGenerate={handleGenerate} isGenerating={isGenerating} incomingParams={incomingParams} />

      {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} />
    </div>
  );
};

export default App;