ly0303521

first commit 20251222

"""FastAPI proxy for the Z-Image generator frontend."""
import json
import os
import secrets
import time
from pathlib import Path
from threading import Lock
from typing import List, Literal, Optional
import httpx
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, ConfigDict
import logging
logger = logging.getLogger("uvicorn.error")
logging.basicConfig(level=logging.INFO)
logger.info("your message %s", "hello")
Z_IMAGE_BASE_URL = os.getenv("Z_IMAGE_BASE_URL", "http://106.120.52.146:39009").rstrip("/")
REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "120"))
GALLERY_DATA_PATH = Path(os.getenv("GALLERY_DATA_PATH", Path(__file__).with_name("gallery_data.json")))
GALLERY_MAX_ITEMS = int(os.getenv("GALLERY_MAX_ITEMS", "500"))
class ImageGenerationPayload(BaseModel):
model_config = ConfigDict(populate_by_name=True)
prompt: str = Field(..., min_length=1, max_length=2048)
height: int = Field(1024, ge=64, le=2048)
width: int = Field(1024, ge=64, le=2048)
num_inference_steps: int = Field(8, ge=1, le=200)
guidance_scale: float = Field(0.0, ge=0.0, le=20.0)
seed: Optional[int] = Field(default=None, ge=0)
negative_prompt: Optional[str] = Field(default=None, max_length=2048)
output_format: Literal["base64", "url"] = "base64"
author_id: Optional[str] = Field(default=None, alias="authorId", min_length=1, max_length=64)
class ImageGenerationResponse(BaseModel):
image: Optional[str] = None
url: Optional[str] = None
time_taken: float = 0.0
error: Optional[str] = None
request_params: ImageGenerationPayload
gallery_item: Optional["GalleryImage"] = None
class GalleryImage(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: str
prompt: str = Field(..., min_length=1, max_length=2048)
height: int = Field(..., ge=64, le=2048)
width: int = Field(..., ge=64, le=2048)
num_inference_steps: int = Field(..., ge=1, le=200)
guidance_scale: float = Field(..., ge=0.0, le=20.0)
seed: int = Field(..., ge=0)
url: str
created_at: float = Field(default_factory=lambda: time.time() * 1000, alias="createdAt")
author_id: Optional[str] = Field(default=None, alias="authorId")
likes: int = 0
is_mock: bool = Field(default=False, alias="isMock")
negative_prompt: Optional[str] = None
ImageGenerationResponse.model_rebuild()
class GalleryStore:
"""Simple JSON file backed store for generated images."""
def __init__(self, path: Path, max_items: int = 500) -> None:
self.path = path
self.max_items = max_items
self.lock = Lock()
self.enabled = True
self._memory_cache: List[dict] = []
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
if self.path.exists():
self._memory_cache = self._read().get("images", [])
else:
self._write({"images": []})
except OSError as exc: # pragma: no cover - filesystem guards
self.enabled = False
print(f"[WARN] Gallery store disabled due to filesystem error: {exc}")
def _read(self) -> dict:
if not self.enabled:
return {"images": list(self._memory_cache)}
try:
with self.path.open("r", encoding="utf-8") as file:
return json.load(file)
except (FileNotFoundError, json.JSONDecodeError):
return {"images": []}
def _write(self, data: dict) -> None:
if not self.enabled:
self._memory_cache = list(data.get("images", []))
return
payload = json.dumps(data, ensure_ascii=False, indent=2)
temp_path = self.path.with_suffix(".tmp")
try:
with temp_path.open("w", encoding="utf-8") as file:
file.write(payload)
temp_path.replace(self.path)
except OSError as exc:
# Some filesystems (or permissions) may block atomic replace; fall back to direct write
print(f"[WARN] Atomic gallery write failed, attempting direct write: {exc}")
try:
with self.path.open("w", encoding="utf-8") as file:
file.write(payload)
except OSError as direct_exc:
raise direct_exc
self._memory_cache = list(data.get("images", []))
def list_images(self) -> List[dict]:
with self.lock:
data = self._read()
return list(data.get("images", []))
def add_image(self, image: GalleryImage) -> dict:
payload = image.model_dump(by_alias=True)
with self.lock:
data = self._read()
images = data.get("images", [])
images.insert(0, payload)
data["images"] = images[: self.max_items]
self._write(data)
return payload
gallery_store = GalleryStore(GALLERY_DATA_PATH, GALLERY_MAX_ITEMS)
app = FastAPI(title="Z-Image Proxy", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup() -> None:
timeout = httpx.Timeout(REQUEST_TIMEOUT_SECONDS, connect=5.0)
app.state.http = httpx.AsyncClient(timeout=timeout)
@app.on_event("shutdown")
async def shutdown() -> None:
await app.state.http.aclose()
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
@app.get("/gallery")
async def gallery(
limit: int = Query(200, ge=1, le=1000),
author_id: Optional[str] = Query(default=None, alias="authorId"),
) -> dict:
"""Return the persisted gallery images, optionally filtered by author."""
images = gallery_store.list_images()
if author_id:
images = [item for item in images if item.get("authorId") == author_id]
return {"images": images[:limit]}
@app.post("/generate", response_model=ImageGenerationResponse)
async def generate_image(payload: ImageGenerationPayload) -> ImageGenerationResponse:
request_params_data = payload.model_dump()
body = {
key: value
for key, value in request_params_data.items()
if value is not None and key != "author_id"
}
if "seed" not in body:
body["seed"] = secrets.randbelow(1_000_000_000)
request_params_data["seed"] = body["seed"]
request_params = ImageGenerationPayload(**request_params_data)
url = f"{Z_IMAGE_BASE_URL}/generate"
try:
resp = await app.state.http.post(url, json=body)
except httpx.RequestError as exc: # pragma: no cover - network errors only
raise HTTPException(status_code=502, detail=f"Z-Image service unreachable: {exc}") from exc
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail=f"Z-Image error: {resp.text}")
data = resp.json()
image = data.get("image")
image_url = data.get("url")
if not image and not image_url:
raise HTTPException(status_code=502, detail=f"Malformed response from Z-Image: {data}")
stored_image: Optional[GalleryImage] = None
try:
stored = gallery_store.add_image(
GalleryImage(
id=data.get("id") or secrets.token_hex(16),
prompt=payload.prompt,
width=payload.width,
height=payload.height,
num_inference_steps=payload.num_inference_steps,
guidance_scale=payload.guidance_scale,
seed=request_params.seed,
url=image_url or f"data:image/png;base64,{image}",
author_id=payload.author_id,
negative_prompt=payload.negative_prompt,
)
)
stored_image = GalleryImage.model_validate(stored)
except Exception as exc: # pragma: no cover - diagnostics only
# Persisting gallery data should not block the response
print(f"[WARN] Failed to store gallery image: {exc}")
return ImageGenerationResponse(
image=image,
url=image_url,
time_taken=float(data.get("time_taken", 0.0)),
error=data.get("error"),
request_params=request_params,
gallery_item=stored_image,
)
... ...
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""Client script to test Z-Image server."""
import base64
import time
import requests
from PIL import Image
import io
def test_health_check(base_url="http://localhost:9009"):
"""Test server health check."""
print("Testing health check...")
try:
response = requests.get(f"{base_url}/health", timeout=10)
print(f"Status code: {response.status_code}")
print(f"Response text: {response.text}")
print(f"Response headers: {dict(response.headers)}")
if response.status_code == 200:
print(f"Health check: {response.json()}")
else:
print(f"Error: HTTP {response.status_code}")
print(f"Response: {response.text}")
except requests.exceptions.RequestException as e:
print(f"Request error: {e}")
raise
except ValueError as e:
print(f"JSON decode error: {e}")
print(f"Response content: {response.text if 'response' in locals() else 'No response'}")
raise
print()
def test_generate_image(base_url="http://localhost:9009", save_path="generated_image.png", output_format="base64"):
"""Test image generation with base64 or URL response."""
format_name = "base64" if output_format == "base64" else "URL"
print(f"Testing image generation ({format_name})...")
# Prepare request
request_data = {
"prompt": "一只可爱的熊猫在竹林里吃竹子,阳光透过树叶洒下,4K高清,摄影作品",
"height": 1024,
"width": 1024,
"num_inference_steps": 8,
"guidance_scale": 0.0,
"seed": 42,
"output_format": output_format,
}
print(f"Prompt: {request_data['prompt']}")
print(f"Size: {request_data['width']}x{request_data['height']}")
print(f"Output format: {output_format}")
# Send request
start_time = time.time()
response = requests.post(f"{base_url}/generate", json=request_data)
end_time = time.time()
if response.status_code == 200:
result = response.json()
print(f"Generation successful!")
print(f"Time taken: {result['time_taken']:.2f}s")
print(f"Total request time: {end_time - start_time:.2f}s")
if output_format == "url":
# Handle URL response
if result.get("url"):
image_url = result["url"]
print(f"Image URL: {image_url}")
# Download image from URL and save
try:
img_response = requests.get(image_url, timeout=10)
if img_response.status_code == 200:
img = Image.open(io.BytesIO(img_response.content))
img.save(save_path)
print(f"Image downloaded and saved to: {save_path}")
else:
print(f"Failed to download image from URL: HTTP {img_response.status_code}")
except Exception as e:
print(f"Error downloading image from URL: {e}")
else:
print("Warning: No URL returned in response")
# Fallback: check if base64 is available
if result.get("image"):
print("Fallback: Using base64 image from response")
img_data = base64.b64decode(result["image"])
img = Image.open(io.BytesIO(img_data))
img.save(save_path)
print(f"Image saved to: {save_path}")
else:
# Handle base64 response
if result.get("image"):
img_data = base64.b64decode(result["image"])
img = Image.open(io.BytesIO(img_data))
img.save(save_path)
print(f"Image saved to: {save_path}")
else:
print("Warning: No image data in response")
else:
print(f"Error: {response.status_code}")
print(response.json())
print()
def test_generate_stream(base_url="http://localhost:9009", save_path="generated_stream.png"):
"""Test image generation with stream response."""
print("Testing image generation (stream)...")
# Prepare request
request_data = {
"prompt": "A futuristic cityscape at sunset, flying cars, neon lights, cyberpunk style, highly detailed",
"height": 1024,
"width": 1024,
"num_inference_steps": 8,
"guidance_scale": 0.0,
"seed": 123,
}
print(f"Prompt: {request_data['prompt']}")
print(f"Size: {request_data['width']}x{request_data['height']}")
# Send request
start_time = time.time()
response = requests.post(f"{base_url}/generate_stream", json=request_data)
end_time = time.time()
if response.status_code == 200:
print(f"Generation successful!")
print(f"Total request time: {end_time - start_time:.2f}s")
# Save image
img = Image.open(io.BytesIO(response.content))
img.save(save_path)
print(f"Image saved to: {save_path}")
else:
print(f"Error: {response.status_code}")
print(response.text)
print()
def main():
"""Run all tests."""
base_url = "http://106.120.52.146:39009"
print("=" * 60)
print("Z-Image Server Client Test")
print("=" * 60)
print()
# Test health check
try:
test_health_check(base_url)
except Exception as e:
print(f"Health check failed: {e}")
print("Make sure the server is running!")
return
# Test image generation (base64)
try:
test_generate_image(base_url, "test_output_base64.png", output_format="base64")
except Exception as e:
print(f"Image generation test (base64) failed: {e}")
# Test image generation (URL)
try:
test_generate_image(base_url, "test_output_url.png", output_format="url")
except Exception as e:
print(f"Image generation test (URL) failed: {e}")
# Test image generation (stream)
try:
test_generate_stream(base_url, "test_output_stream.png")
except Exception as e:
print(f"Stream generation test failed: {e}")
print("=" * 60)
print("All tests completed!")
print("=" * 60)
if __name__ == "__main__":
main()
... ...
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
... ...
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;
... ...
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1JB2dpJngXxwHJXkKx61iGeCNPDb5zdd7
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
... ...
import React, { useState, useEffect, useRef } from 'react';
import { ImageItem } from '../types';
import { X, Upload, Save, Trash2, ThumbsUp } from 'lucide-react';
interface AdminModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (image: ImageItem) => void;
onDelete?: (id: string) => void;
initialData?: ImageItem | null;
}
const AdminModal: React.FC<AdminModalProps> = ({ isOpen, onClose, onSave, onDelete, initialData }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState<Partial<ImageItem>>({
prompt: '',
width: 1024,
height: 1024,
num_inference_steps: 20,
guidance_scale: 7.5,
seed: 12345,
url: '',
likes: 0,
});
useEffect(() => {
if (isOpen) {
if (initialData) {
setFormData({ ...initialData });
} else {
// Reset form for new entry
setFormData({
prompt: '',
width: 1024,
height: 1024,
num_inference_steps: 20,
guidance_scale: 7.5,
seed: Math.floor(Math.random() * 1000000),
url: '',
likes: 0,
});
}
}
}, [isOpen, initialData]);
if (!isOpen) return null;
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setFormData(prev => ({ ...prev, url: reader.result as string }));
};
reader.readAsDataURL(file);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.url) {
alert("Please upload an image or provide a URL");
return;
}
const imageItem: ImageItem = {
id: initialData?.id || Date.now().toString(),
createdAt: initialData?.createdAt || Date.now(),
prompt: formData.prompt || 'Untitled',
width: Number(formData.width),
height: Number(formData.height),
num_inference_steps: Number(formData.num_inference_steps),
guidance_scale: Number(formData.guidance_scale),
seed: Number(formData.seed),
url: formData.url,
isMock: true,
likes: Number(formData.likes || 0), // Admin Override Likes
authorId: initialData?.authorId || 'ADMIN',
isLikedByCurrentUser: initialData?.isLikedByCurrentUser,
};
onSave(imageItem);
onClose();
};
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-900 w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center p-6 border-b border-gray-100 dark:border-gray-800">
<h2 className="text-xl font-bold text-gray-800 dark:text-white">
{initialData ? 'Edit Image Details' : 'Upload New Image'}
</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full">
<X size={20} />
</button>
</div>
<div className="overflow-y-auto p-6">
<form id="admin-form" onSubmit={handleSubmit} className="space-y-6">
{/* Image Upload/URL Section */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Image Source</label>
{/* Preview */}
<div className="flex flex-col items-center justify-center">
<div
className="relative w-full h-64 border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-xl flex flex-col items-center justify-center bg-gray-50 dark:bg-gray-800 cursor-pointer hover:bg-gray-100 transition-colors overflow-hidden group"
onClick={() => fileInputRef.current?.click()}
>
{formData.url ? (
<img src={formData.url} alt="Preview" className="w-full h-full object-contain" />
) : (
<div className="flex flex-col items-center text-gray-400">
<Upload size={32} className="mb-2" />
<p className="text-sm">Click to upload or paste URL below</p>
</div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
<p className="text-white font-medium">Change Image</p>
</div>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleImageUpload}
accept="image/*"
className="hidden"
/>
</div>
{/* URL Input */}
<input
type="text"
value={formData.url}
onChange={(e) => setFormData({...formData, url: e.target.value})}
placeholder="Or paste image URL here..."
className="w-full p-2 text-sm bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg"
/>
</div>
{/* Prompt */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Prompt</label>
<textarea
value={formData.prompt}
onChange={(e) => setFormData({...formData, prompt: e.target.value})}
className="w-full p-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:ring-2 focus:ring-black dark:focus:ring-white outline-none min-h-[100px]"
placeholder="Enter the prompt used for this image..."
required
/>
</div>
{/* Parameters Grid */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Width</label>
<input
type="number"
value={formData.width}
onChange={(e) => setFormData({...formData, width: Number(e.target.value)})}
className="w-full p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Height</label>
<input
type="number"
value={formData.height}
onChange={(e) => setFormData({...formData, height: Number(e.target.value)})}
className="w-full p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Steps</label>
<input
type="number"
value={formData.num_inference_steps}
onChange={(e) => setFormData({...formData, num_inference_steps: Number(e.target.value)})}
className="w-full p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Guidance Scale</label>
<input
type="number"
step="0.1"
value={formData.guidance_scale}
onChange={(e) => setFormData({...formData, guidance_scale: Number(e.target.value)})}
className="w-full p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-500 uppercase mb-1">Seed</label>
<input
type="number"
value={formData.seed}
onChange={(e) => setFormData({...formData, seed: Number(e.target.value)})}
className="w-full p-2 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
/>
</div>
{/* ADMIN ONLY: Likes Editor */}
<div>
<label className="block text-xs font-bold text-purple-600 uppercase mb-1 flex items-center gap-1">
<ThumbsUp size={12} /> Likes (Admin Override)
</label>
<input
type="number"
value={formData.likes}
onChange={(e) => setFormData({...formData, likes: Number(e.target.value)})}
className="w-full p-2 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded font-bold text-purple-700 dark:text-purple-300"
/>
</div>
</div>
</form>
</div>
<div className="p-6 border-t border-gray-100 dark:border-gray-800 flex justify-between bg-gray-50 dark:bg-gray-900">
{initialData && onDelete ? (
<button
type="button"
onClick={() => {
if(confirm('Are you sure you want to delete this image?')) {
onDelete(initialData.id);
onClose();
}
}}
className="flex items-center gap-2 px-4 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 size={18} />
<span>Delete</span>
</button>
) : <div />}
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="px-6 py-2 rounded-lg text-gray-600 hover:bg-gray-200 transition-colors"
>
Cancel
</button>
<button
type="submit"
form="admin-form"
className="flex items-center gap-2 px-6 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg hover:opacity-90 transition-opacity font-medium"
>
<Save size={18} />
<span>Save Image</span>
</button>
</div>
</div>
</div>
</div>
);
};
export default AdminModal;
\ No newline at end of file
... ...
import React, { useState } from 'react';
import { User, Lock } from 'lucide-react';
interface AuthModalProps {
isOpen: boolean;
onLogin: (employeeId: string) => void;
}
const AuthModal: React.FC<AuthModalProps> = ({ isOpen, onLogin }) => {
const [inputId, setInputId] = useState('');
const [error, setError] = useState('');
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validate 8-digit requirement
const regex = /^\d{8}$/;
if (!regex.test(inputId)) {
setError('工号必须是8位数字');
return;
}
onLogin(inputId);
};
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
{/* Non-dismissible backdrop */}
<div className="absolute inset-0 bg-black/90 backdrop-blur-md" />
<div className="relative bg-white dark:bg-gray-900 w-full max-w-md rounded-2xl shadow-2xl p-8 animate-fade-in text-center">
<div className="w-16 h-16 bg-black dark:bg-white rounded-full flex items-center justify-center mx-auto mb-6">
<Lock className="text-white dark:text-black" size={32} />
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
欢迎来到 艺云-DESIGN
</h2>
<p className="text-gray-500 mb-8">
请输入您的8位工号以访问平台功能
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative">
<User className="absolute left-3 top-3 text-gray-400" size={20} />
<input
type="text"
value={inputId}
onChange={(e) => {
setInputId(e.target.value);
setError('');
}}
placeholder="请输入8位工号 (例如: 10023456)"
className="w-full pl-10 pr-4 py-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl focus:ring-2 focus:ring-black dark:focus:ring-white outline-none transition-all font-mono"
maxLength={8}
/>
</div>
{error && (
<p className="text-red-500 text-sm text-left pl-2">{error}</p>
)}
<button
type="submit"
className="w-full py-3 bg-black dark:bg-white text-white dark:text-black rounded-xl font-bold hover:opacity-90 transition-opacity"
>
进入系统
</button>
</form>
</div>
</div>
);
};
export default AuthModal;
\ No newline at end of file
... ...
import React from 'react';
import { ImageItem, ImageGenerationParams } from '../types';
import { X, Copy, Download, Edit3, Heart, Zap } from 'lucide-react';
interface DetailModalProps {
image: ImageItem | null;
onClose: () => void;
onEdit?: (image: ImageItem) => void;
onGenerateSimilar?: (params: ImageGenerationParams) => void;
}
const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGenerateSimilar }) => {
if (!image) return null;
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const handleGenerateSimilar = () => {
if (onGenerateSimilar) {
const params: ImageGenerationParams = {
prompt: image.prompt,
width: image.width,
height: image.height,
num_inference_steps: image.num_inference_steps,
guidance_scale: image.guidance_scale,
seed: image.seed
};
onGenerateSimilar(params);
onClose();
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
<div className="absolute inset-0 bg-black/90 backdrop-blur-sm transition-opacity" onClick={onClose}/>
<div className="relative bg-white dark:bg-gray-900 w-full max-w-6xl max-h-[90vh] rounded-2xl overflow-hidden shadow-2xl flex flex-col md:flex-row animate-fade-in">
<button onClick={onClose} className="absolute top-4 right-4 z-10 p-2 bg-black/50 rounded-full text-white md:hidden"><X size={20} /></button>
<div className="w-full md:w-2/3 bg-black flex items-center justify-center overflow-hidden h-[50vh] md:h-auto relative group">
<img src={image.url} alt={image.prompt} className="max-w-full max-h-full object-contain" />
</div>
<div className="w-full md:w-1/3 p-6 md:p-8 flex flex-col overflow-y-auto">
<div className="flex justify-between items-start mb-6">
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">参数详情</h2>
<div className="flex gap-2">
{onEdit && (
<button
onClick={() => onEdit(image)}
className="p-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 rounded-full transition-colors"
title="管理"
>
<Edit3 size={18} />
</button>
)}
<button onClick={onClose} className="hidden md:block p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full transition-colors"><X size={24} /></button>
</div>
</div>
<div className="space-y-6">
<div className="flex items-center gap-2 text-red-500 font-medium">
<Heart size={18} fill="currentColor" />
<span>{image.likes || 0} Likes</span>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">提示词 (Prompt)</label>
<div className="relative group">
<p className="text-gray-700 dark:text-gray-200 text-sm leading-relaxed p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-100 dark:border-gray-700">
{image.prompt}
</p>
<button
onClick={() => copyToClipboard(image.prompt)}
className="absolute top-2 right-2 p-1.5 bg-white dark:bg-gray-700 rounded-md shadow-sm opacity-0 group-hover:opacity-100 transition-opacity text-gray-500 hover:text-blue-500"
title="复制提示词"
>
<Copy size={14} />
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase mb-1">分辨率</label>
<p className="text-gray-800 dark:text-gray-200 font-mono">{image.width} x {image.height}</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase mb-1">随机种子</label>
<p className="text-gray-800 dark:text-gray-200 font-mono">{image.seed}</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase mb-1">生成步数</label>
<p className="text-gray-800 dark:text-gray-200 font-mono">{image.num_inference_steps}</p>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase mb-1">引导系数</label>
<p className="text-gray-800 dark:text-gray-200 font-mono">{image.guidance_scale.toFixed(1)}</p>
</div>
</div>
<div className="pt-6 mt-auto space-y-3">
<button
onClick={handleGenerateSimilar}
className="flex items-center justify-center w-full py-3 bg-purple-600 text-white rounded-xl font-bold hover:bg-purple-700 transition-colors gap-2 shadow-lg shadow-purple-200 dark:shadow-none"
>
<Zap size={18} fill="currentColor" />
生成同款
</button>
<a
href={image.url}
target="_blank"
rel="noopener noreferrer"
download={`z-image-${image.id}.png`}
className="flex items-center justify-center w-full py-3 bg-black dark:bg-white text-white dark:text-black rounded-xl font-medium hover:opacity-90 transition-opacity gap-2"
>
<Download size={18} />
下载原图
</a>
</div>
</div>
</div>
</div>
</div>
);
};
export default DetailModal;
\ No newline at end of file
... ...
import React from 'react';
import { ImageItem } from '../types';
import { Clock } from 'lucide-react';
interface HistoryBarProps {
images: ImageItem[];
onSelect: (image: ImageItem) => void;
}
const HistoryBar: React.FC<HistoryBarProps> = ({ images, onSelect }) => {
if (images.length === 0) return null;
return (
<div className="fixed bottom-24 left-6 z-40 max-w-[calc(100vw-48px)] md:max-w-xs animate-fade-in pointer-events-auto">
<div className="flex items-center gap-2 mb-2 px-1">
<Clock size={14} className="text-gray-400" />
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">我的生成记录</span>
</div>
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-2 mask-linear-right">
{images.map((img) => (
<button
key={img.id}
onClick={() => onSelect(img)}
className="flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 border-white dark:border-gray-800 shadow-sm hover:scale-105 hover:border-purple-400 transition-all"
>
<img
src={img.url}
alt="History"
className="w-full h-full object-cover"
loading="lazy"
/>
</button>
))}
</div>
</div>
);
};
export default HistoryBar;
\ No newline at end of file
... ...
import React, { useState } from 'react';
import { ImageItem } from '../types';
import { Download, Heart } from 'lucide-react';
interface ImageCardProps {
image: ImageItem;
onClick: (image: ImageItem) => void;
onLike: (image: ImageItem) => void;
currentUser?: string;
}
const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUser }) => {
const [isLoaded, setIsLoaded] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const handleLike = (e: React.MouseEvent) => {
e.stopPropagation();
onLike(image);
};
return (
<div
className="group relative mb-4 break-inside-avoid rounded-2xl overflow-hidden bg-gray-200 cursor-zoom-in shadow-sm hover:shadow-xl transition-all duration-300"
onClick={() => onClick(image)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Placeholder / Skeleton */}
{!isLoaded && (
<div className="absolute inset-0 bg-gray-200 animate-pulse min-h-[200px]" />
)}
{/* Main Image */}
<img
src={image.url}
alt={image.prompt}
className={`w-full h-auto object-cover transition-transform duration-700 ease-in-out group-hover:scale-105 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setIsLoaded(true)}
loading="lazy"
/>
{/* Hover Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-4 md:p-5">
{/* Top Right: Download */}
<div className="absolute top-4 right-4 translate-y-[-10px] opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-300 delay-75">
<button
className="p-2 bg-white/20 backdrop-blur-md hover:bg-white/40 rounded-full text-white transition-colors"
title="Download"
onClick={(e) => {
e.stopPropagation();
const link = document.createElement('a');
link.href = image.url;
link.download = `z-image-${image.id}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
>
<Download size={16} />
</button>
</div>
{/* Content Info */}
<div className="text-white transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
<p className="font-medium text-sm line-clamp-2 mb-2 text-shadow-sm">
{image.prompt}
</p>
<div className="flex justify-between items-end">
{/* Author Info */}
<div className="text-xs text-gray-300 font-mono flex flex-col gap-1">
<span className="opacity-75">ID: {image.authorId || 'UNKNOWN'}</span>
<span className="bg-white/10 px-1.5 py-0.5 rounded w-fit">{image.width}x{image.height}</span>
</div>
{/* Like Button */}
<button
onClick={handleLike}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full backdrop-blur-md transition-all duration-200 ${image.isLikedByCurrentUser ? 'bg-red-500/80 text-white' : 'bg-white/20 text-white hover:bg-white/30'}`}
title={currentUser ? "Like this image" : "Login to like"}
>
<Heart size={14} fill={image.isLikedByCurrentUser ? "currentColor" : "none"} />
<span className="text-xs font-bold">{image.likes}</span>
</button>
</div>
</div>
</div>
</div>
);
};
export default ImageCard;
\ No newline at end of file
... ...
import React, { useState, useEffect, KeyboardEvent } from 'react';
import { Loader2, ArrowUp, Sliders, Dices, X, RefreshCw } from 'lucide-react';
import { ImageGenerationParams } from '../types';
interface InputBarProps {
onGenerate: (params: ImageGenerationParams) => void;
isGenerating: boolean;
incomingParams?: ImageGenerationParams | null; // For "Generate Similar"
}
const ASPECT_RATIOS = [
{ label: '1:1', w: 1024, h: 1024 },
{ label: '16:9', w: 1024, h: 576 },
{ label: '9:16', w: 576, h: 1024 },
{ label: '4:3', w: 1024, h: 768 },
{ label: '3:4', w: 768, h: 1024 },
{ label: 'Custom', w: 0, h: 0 },
];
const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams }) => {
const [prompt, setPrompt] = useState('');
const [showSettings, setShowSettings] = useState(false);
// Parameters State
const [width, setWidth] = useState(1024);
const [height, setHeight] = useState(1024);
const [steps, setSteps] = useState(8);
const [guidance, setGuidance] = useState(0);
const [seed, setSeed] = useState(12345);
const [activeRatio, setActiveRatio] = useState('1:1');
// Handle "Generate Similar" incoming data
useEffect(() => {
if (incomingParams) {
setPrompt(incomingParams.prompt);
setWidth(incomingParams.width);
setHeight(incomingParams.height);
setSteps(incomingParams.num_inference_steps);
setGuidance(incomingParams.guidance_scale);
setSeed(incomingParams.seed);
// Match active ratio label
const matched = ASPECT_RATIOS.find(r => r.w === incomingParams.width && r.h === incomingParams.height);
setActiveRatio(matched ? matched.label : 'Custom');
// Open settings so user can see what's loaded
setShowSettings(true);
}
}, [incomingParams]);
useEffect(() => {
if (!incomingParams) {
setSeed(Math.floor(Math.random() * 1000000));
}
}, []);
const handleGenerate = () => {
if (!prompt.trim() || isGenerating) return;
const params: ImageGenerationParams = {
prompt,
width,
height,
num_inference_steps: steps,
guidance_scale: guidance,
seed,
};
onGenerate(params);
// Note: Prompt is NO LONGER cleared as per user request
// Regenerate seed for next time ONLY IF not explicit
// setSeed(Math.floor(Math.random() * 1000000));
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleGenerate();
}
};
const handleRatioSelect = (ratio: typeof ASPECT_RATIOS[0]) => {
setActiveRatio(ratio.label);
if (ratio.label !== 'Custom') {
setWidth(ratio.w);
setHeight(ratio.h);
}
};
const randomizeSeed = () => {
setSeed(Math.floor(Math.random() * 10000000));
};
return (
<div className="fixed bottom-0 left-0 right-0 p-4 md:p-6 z-50 flex flex-col items-center pointer-events-none">
{/* Advanced Settings Panel */}
{showSettings && (
<div className="pointer-events-auto w-full max-w-2xl bg-white/95 dark:bg-gray-900/95 backdrop-blur-xl border border-gray-200 dark:border-gray-700 rounded-2xl shadow-2xl mb-4 p-5 animate-fade-in flex flex-col gap-5">
<div className="flex justify-between items-center border-b border-gray-200 dark:border-gray-700 pb-3">
<h3 className="font-bold text-gray-800 dark:text-white flex items-center gap-2">
<Sliders size={18} className="text-purple-500" />
生成参数设置 {incomingParams && <span className="text-xs bg-purple-100 text-purple-600 px-2 py-0.5 rounded-full ml-2">已加载同款参数</span>}
</h3>
<button onClick={() => setShowSettings(false)} className="text-gray-500 hover:text-gray-800 dark:hover:text-white p-1">
<X size={18} />
</button>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold text-gray-500 uppercase">分辨率 (宽高比)</label>
<div className="flex flex-wrap gap-2">
{ASPECT_RATIOS.map((r) => (
<button
key={r.label}
onClick={() => handleRatioSelect(r)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors border ${
activeRatio === r.label
? 'bg-black dark:bg-white text-white dark:text-black border-transparent shadow-sm'
: 'bg-transparent text-gray-600 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{r.label}
</button>
))}
</div>
{activeRatio === 'Custom' && (
<div className="flex gap-4 mt-2 animate-fade-in">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">W:</span>
<input
type="number"
min="64" max="2048"
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
className="w-20 p-1 bg-gray-50 dark:bg-gray-800 border dark:border-gray-700 rounded text-center text-sm"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">H:</span>
<input
type="number"
min="64" max="2048"
value={height}
onChange={(e) => setHeight(Number(e.target.value))}
className="w-20 p-1 bg-gray-50 dark:bg-gray-800 border dark:border-gray-700 rounded text-center text-sm"
/>
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<div className="flex justify-between">
<label className="text-xs font-semibold text-gray-500 uppercase">生成步数 (Steps)</label>
<span className="text-xs font-mono text-gray-800 dark:text-gray-200">{steps}</span>
</div>
<input
type="range"
min="6"
max="12"
step="1"
value={steps}
onChange={(e) => setSteps(Number(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-black dark:accent-white"
/>
<div className="flex justify-between text-[10px] text-gray-400">
<span>6</span>
<span>12</span>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold text-gray-500 uppercase block">引导系数 (Guidance)</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="10"
step="0.1"
value={guidance}
onChange={(e) => setGuidance(Number(e.target.value))}
className="w-full p-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm"
/>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-semibold text-gray-500 uppercase">随机种子 (Seed)</label>
<div className="flex gap-2">
<input
type="number"
value={seed}
onChange={(e) => setSeed(Number(e.target.value))}
className="flex-1 p-2 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg font-mono text-sm"
/>
<button
onClick={randomizeSeed}
className="p-2 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors text-gray-600 dark:text-gray-300"
title="随机生成"
>
<Dices size={20} />
</button>
</div>
</div>
</div>
)}
{/* Main Input Capsule */}
<div className="pointer-events-auto w-full max-w-2xl transition-all duration-300 ease-in-out transform hover:scale-[1.01]">
<div className="relative group">
<div className="absolute inset-0 bg-white/80 dark:bg-black/80 backdrop-blur-2xl rounded-full shadow-2xl border border-white/20 dark:border-white/10" />
<div className="relative flex items-center p-2 pr-2">
<button
onClick={() => setShowSettings(!showSettings)}
className={`flex-shrink-0 w-10 h-10 md:w-12 md:h-12 flex items-center justify-center rounded-full ml-1 transition-all ${
showSettings
? 'bg-purple-600 text-white shadow-lg rotate-90'
: 'bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<Sliders size={20} />
</button>
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isGenerating}
placeholder="描述您的创意内容..."
className="flex-grow bg-transparent border-none outline-none px-4 py-3 text-gray-800 dark:text-white placeholder-gray-400 text-base md:text-lg"
/>
<div className="flex items-center gap-1">
{/* Optional: Quick Regenerate button if prompt exists */}
{prompt.trim() && !isGenerating && (
<button
onClick={handleGenerate}
className="p-3 text-gray-400 hover:text-purple-500 transition-colors"
title="重新生成"
>
<RefreshCw size={18} />
</button>
)}
<button
onClick={handleGenerate}
disabled={!prompt.trim() || isGenerating}
className={`
flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full transition-all duration-200
${prompt.trim() && !isGenerating
? 'bg-black dark:bg-white text-white dark:text-black hover:scale-105 active:scale-95 shadow-md'
: 'bg-gray-200 dark:bg-gray-700 text-gray-400 cursor-not-allowed'}
`}
>
{isGenerating ? (
<Loader2 className="animate-spin" size={20} />
) : (
<ArrowUp size={20} strokeWidth={3} />
)}
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default InputBar;
\ No newline at end of file
... ...
import React from 'react';
import { ImageItem } from '../types';
import ImageCard from './ImageCard';
interface MasonryGridProps {
images: ImageItem[];
onImageClick: (image: ImageItem) => void;
onLike: (image: ImageItem) => void;
currentUser?: string;
}
const MasonryGrid: React.FC<MasonryGridProps> = ({ images, onImageClick, onLike, currentUser }) => {
return (
<div className="w-full px-4 md:px-8 py-6">
<div className="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4 space-y-4">
{images.map((img) => (
<ImageCard
key={img.id}
image={img}
onClick={onImageClick}
onLike={onLike}
currentUser={currentUser}
/>
))}
</div>
{images.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-gray-400">
<p>暂无图片,快来生成第一张吧!</p>
</div>
)}
</div>
);
};
export default MasonryGrid;
\ No newline at end of file
... ...
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];
// This is the specific Administrator ID requested
export const ADMIN_ID = '86427531';
export const DEFAULT_PARAMS = {
height: 1024,
width: 1024,
num_inference_steps: 20,
guidance_scale: 7.5,
};
// ==============================================================================
// VIRTUAL FOLDER (DATABASE)
// ==============================================================================
// This array represents the "Folder" of images on your server.
// The app sorts these by 'likes' automatically.
// To update this "Folder":
// 1. Login as 86427531.
// 2. Add/Delete/Edit images in the UI.
// 3. Click "Export Config" and overwrite this file.
// ==============================================================================
export const SHOWCASE_IMAGES: ImageItem[] = [
{
"id": "showcase-3",
"url": "https://images.unsplash.com/photo-1655720357761-f18ea9e5e7e6?q=80&w=1000&auto=format&fit=crop",
"prompt": "3D render of a futuristic glass architecture, parametric design, minimal, white background",
"width": 1024,
"height": 1024,
"num_inference_steps": 20,
"guidance_scale": 6,
"seed": 1003,
"createdAt": 1715000000000,
"authorId": "OFFICIAL",
"likes": 3,
"isMock": true
},
{
"id": "showcase-4",
"url": "https://images.unsplash.com/photo-1614730341194-75c60740a0d3?q=80&w=1000&auto=format&fit=crop",
"prompt": "Macro photography of a soap bubble, iridescent colors, intricate patterns",
"width": 800,
"height": 800,
"num_inference_steps": 50,
"guidance_scale": 5,
"seed": 1004,
"createdAt": 1715000000000,
"authorId": "OFFICIAL",
"likes": 4,
"isMock": true
},
{
"id": "showcase-5",
"url": "https://images.unsplash.com/photo-1620641788421-7a1c342ea42e?q=80&w=1000&auto=format&fit=crop",
"prompt": "Digital art, surreal landscape with floating islands, dreamy pastel colors",
"width": 800,
"height": 1200,
"num_inference_steps": 20,
"guidance_scale": 7.5,
"seed": 1005,
"createdAt": 1715000000000,
"authorId": "OFFICIAL",
"likes": 5,
"isMock": true
}
];
... ...
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Z-Image Generator</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
},
},
}
</script>
<style>
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
</style>
<script type="importmap">
{
"imports": {
"lucide-react": "https://esm.sh/lucide-react@^0.561.0",
"react/": "https://esm.sh/react@^19.2.3/",
"react": "https://esm.sh/react@^19.2.3",
"react-dom/": "https://esm.sh/react-dom@^19.2.3/"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body class="bg-gray-50 text-gray-900 antialiased">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
\ No newline at end of file
... ...
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
\ No newline at end of file
... ...
{
"name": "Z-Image Generator",
"description": "A masonry-style AI image gallery and generator interacting with a custom Z-Image server.",
"requestFramePermissions": []
}
\ No newline at end of file
... ...
{
"name": "z-image-generator",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.561.0",
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}
... ...
import { API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL } from '../constants';
import { GalleryResponse, ImageItem } from '../types';
export const fetchGallery = async (authorId?: string): Promise<ImageItem[]> => {
if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) {
return [];
}
const params = new URLSearchParams();
if (authorId) {
params.set('authorId', authorId);
}
const query = params.toString();
const response = await fetch(`${API_BASE_URL}/gallery${query ? `?${query}` : ''}`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gallery fetch failed (${response.status}): ${errorText}`);
}
const data: GalleryResponse = await response.json();
return data.images ?? [];
};
... ...
import { API_ENDPOINTS } from '../constants';
import { ImageGenerationParams, APIResponse, ImageItem } from '../types';
export interface GenerateImageResult {
imageUrl: string;
galleryItem?: ImageItem;
}
/**
* Calls the /generate endpoint of the Z-Image server.
* Returns the image URL (http link or base64 data URI) or throws an error.
*/
export const generateImage = async (
params: ImageGenerationParams,
authorId?: string
): Promise<GenerateImageResult> => {
const payload = {
...params,
output_format: 'url',
authorId,
};
let lastError: unknown = null;
for (const baseUrl of API_ENDPOINTS) {
try {
const response = await fetch(`${baseUrl}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Server Error ${response.status}: ${errorText}`);
}
const data: APIResponse = await response.json();
const imageUrl = data.url
? data.url
: data.image
? `data:image/png;base64,${data.image}`
: null;
if (!imageUrl) {
throw new Error('No image URL or data received in response');
}
return {
imageUrl,
galleryItem: data.gallery_item,
};
} catch (error) {
console.warn(`Image generation via ${baseUrl} failed`, error);
lastError = error;
}
}
console.error("Image generation failed after trying all endpoints.", lastError);
throw lastError instanceof Error ? lastError : new Error('Image generation failed');
};
... ...
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}
\ No newline at end of file
... ...
export interface ImageGenerationParams {
prompt: string;
height: number;
width: number;
num_inference_steps: number;
guidance_scale: number;
seed: number;
output_format?: 'url' | 'base64';
authorId?: string;
}
export interface ImageItem extends ImageGenerationParams {
id: string;
url: string; // base64 data URI or http URL
createdAt: number;
// New fields for community features
authorId?: string; // The 8-digit employee ID
likes: number;
isLikedByCurrentUser?: boolean; // UI state
isMock?: boolean; // True if it's a static showcase image
}
export interface APIResponse {
image?: string; // Base64 string
url?: string; // Direct URL
time_taken: number;
error?: string;
gallery_item?: ImageItem;
}
export interface UserProfile {
employeeId: string;
hasAccess: boolean;
}
export interface GalleryResponse {
images: ImageItem[];
}
... ...
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});
... ...