ly0303521

改善视频生成页面显示,防止多次发送

... ... @@ -13,19 +13,20 @@ from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, ConfigDict
import logging
# --- Constants ---
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_IMAGES_PATH = Path(os.getenv("GALLERY_IMAGES_PATH", Path(__file__).with_name("gallery_images.json")))
GALLERY_VIDEOS_PATH = Path(os.getenv("GALLERY_VIDEOS_PATH", Path(__file__).with_name("gallery_videos.json")))
GALLERY_MAX_ITEMS = int(os.getenv("GALLERY_MAX_ITEMS", "500"))
WHITELIST_PATH = Path(os.getenv("WHITELIST_PATH", Path(__file__).with_name("whitelist.txt")))
# --- Pydantic Models ---
# Define dependent models first to avoid forward reference issues.
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)
... ... @@ -36,309 +37,210 @@ class ImageGenerationPayload(BaseModel):
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):
class GalleryItem(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
liked_by: List[str] = Field(default_factory=list, alias="likedBy")
class GalleryImage(GalleryItem):
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)
negative_prompt: Optional[str] = None
ImageGenerationResponse.model_rebuild()
class GalleryVideo(GalleryItem):
generation_time: Optional[float] = Field(default=None, alias="generationTime")
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 # No forward ref needed now
class WhitelistStore:
"""Simple text file backed whitelist."""
# --- Data Stores ---
class WhitelistStore:
def __init__(self, path: Path) -> None:
self.path = path
self.lock = RLock()
if not self.path.exists():
# Default admin ID if file doesn't exist
self._write(["86427531"])
if not self.path.exists(): self._write(["86427531"])
def _read(self) -> List[str]:
if not self.path.exists():
return []
if not self.path.exists(): return []
try:
with self.path.open("r", encoding="utf-8") as f:
lines = f.read().splitlines()
return [line.strip() for line in lines if line.strip()]
except OSError:
return []
return [line.strip() for line in f.read().splitlines() if line.strip()]
except OSError: return []
def _write(self, ids: List[str]) -> None:
with self.lock:
try:
with self.path.open("w", encoding="utf-8") as f:
f.write("\n".join(ids))
except OSError as exc:
print(f"[WARN] Failed to write whitelist: {exc}")
def is_allowed(self, user_id: str) -> bool:
allowed = self._read()
return user_id in allowed
with self.path.open("w", encoding="utf-8") as f: f.write("\n".join(ids))
except OSError as exc: print(f"[WARN] Failed to write whitelist: {exc}")
def is_allowed(self, user_id: str) -> bool: return user_id in self._read()
def add_users(self, user_ids: List[str]) -> None:
with self.lock:
current = set(self._read())
current.update(user_ids)
self._write(sorted(list(current)))
current = set(self._read()); current.update(user_ids); self._write(sorted(list(current)))
def remove_user(self, user_id: str) -> None:
with self.lock:
current = self._read()
if user_id in current:
current = [uid for uid in current if uid != user_id]
self._write(current)
def get_all(self) -> List[str]:
return self._read()
if user_id in current: self._write([uid for uid in current if uid != user_id])
def get_all(self) -> List[str]: return self._read()
class GalleryStore:
"""Simple JSON file backed store for generated images."""
def __init__(self, path: Path, max_items: int = 500) -> None:
class JsonStore:
"""Generic JSON file backed store for a list of items."""
def __init__(self, path: Path, item_key: str, max_items: int = 500) -> None:
self.path = path
self.item_key = item_key
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}")
if not self.path.exists(): self._write({self.item_key: []})
except OSError as exc: print(f"[WARN] JSON store at {path} disabled: {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": []}
with self.path.open("r", encoding="utf-8") as file: return json.load(file)
except (FileNotFoundError, json.JSONDecodeError): return {self.item_key: []}
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)
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)
except OSError:
with self.path.open("w", encoding="utf-8") as file: file.write(payload)
def list_items(self) -> List[dict]:
with self.lock: return self._read().get(self.item_key, [])
def add_item(self, item: BaseModel) -> dict:
payload = item.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]
items = data.get(self.item_key, [])
items.insert(0, payload)
data[self.item_key] = items[:self.max_items]
self._write(data)
return payload
def toggle_like(self, image_id: str, user_id: str) -> Optional[dict]:
def toggle_like(self, item_id: str, user_id: str) -> Optional[dict]:
with self.lock:
data = self._read()
images = data.get("images", [])
target_image = next((img for img in images if img.get("id") == image_id), None)
if not target_image:
return None
liked_by = target_image.get("likedBy", [])
# Handle legacy data where likedBy might be missing
if not isinstance(liked_by, list):
liked_by = []
items = data.get(self.item_key, [])
target_item = next((i for i in items if i.get("id") == item_id), None)
if not target_item: return None
liked_by = target_item.get("likedBy", [])
if not isinstance(liked_by, list): liked_by = []
if user_id in liked_by:
liked_by.remove(user_id)
target_image["likes"] = max(0, target_image.get("likes", 0) - 1)
target_item["likes"] = max(0, target_item.get("likes", 0) - 1)
else:
liked_by.append(user_id)
target_image["likes"] = target_image.get("likes", 0) + 1
target_image["likedBy"] = liked_by
target_item["likes"] = target_item.get("likes", 0) + 1
target_item["likedBy"] = liked_by
self._write(data)
return target_image
return target_item
gallery_store = GalleryStore(GALLERY_DATA_PATH, GALLERY_MAX_ITEMS)
image_store = JsonStore(GALLERY_IMAGES_PATH, item_key="images", max_items=GALLERY_MAX_ITEMS)
video_store = JsonStore(GALLERY_VIDEOS_PATH, item_key="videos", max_items=GALLERY_MAX_ITEMS)
whitelist_store = WhitelistStore(WHITELIST_PATH)
# --- App Setup ---
app = FastAPI(title="Z-Image Proxy", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","),
allow_origins=["http://106.120.52.146:37001"], # Explicitly allow the frontend origin
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)
async def startup(): app.state.http = httpx.AsyncClient(timeout=httpx.Timeout(REQUEST_TIMEOUT_SECONDS, connect=5.0))
@app.on_event("shutdown")
async def shutdown() -> None:
await app.state.http.aclose()
async def shutdown(): await app.state.http.aclose()
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
async def health(): return {"status": "ok"}
# --- Endpoints ---
@app.post("/auth/login")
async def login(user_id: str = Query(..., alias="userId")) -> dict:
if whitelist_store.is_allowed(user_id):
return {"status": "ok", "userId": user_id}
async def login(user_id: str = Query(..., alias="userId")):
if whitelist_store.is_allowed(user_id): return {"status": "ok", "userId": user_id}
raise HTTPException(status_code=403, detail="User not whitelisted")
@app.post("/likes/{item_id}")
async def toggle_like(item_id: str, user_id: str = Query(..., alias="userId")):
updated_item = image_store.toggle_like(item_id, user_id)
if updated_item: return updated_item
updated_item = video_store.toggle_like(item_id, user_id)
if updated_item: return updated_item
raise HTTPException(status_code=404, detail="Item not found")
@app.get("/gallery/images")
async def gallery_images(limit: int = Query(200, ge=1, le=1000), author_id: Optional[str] = Query(None, alias="authorId")):
items = image_store.list_items()
if author_id: items = [item for item in items if item.get("authorId") == author_id]
return {"images": items[:limit]}
@app.get("/gallery/videos")
async def gallery_videos(limit: int = Query(200, ge=1, le=1000), author_id: Optional[str] = Query(None, alias="authorId")):
items = video_store.list_items()
if author_id: items = [item for item in items if item.get("authorId") == author_id]
return {"videos": items[:limit]}
@app.post("/gallery/videos")
async def add_video(video: GalleryVideo):
try:
return video_store.add_item(video)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to store video metadata: {exc}")
@app.post("/generate", response_model=ImageGenerationResponse)
async def generate_image(payload: ImageGenerationPayload):
request_params_data = payload.model_dump(); body = {k: v for k, v in request_params_data.items() if v is not None and k != "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)
resp.raise_for_status()
data = resp.json()
image_url = data.get("url") or f"data:image/png;base64,{data.get('image')}"
stored_item_data = {
"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,
"author_id": payload.author_id,
"negative_prompt": payload.negative_prompt,
}
stored = image_store.add_item(GalleryImage(**stored_item_data))
return ImageGenerationResponse(image=data.get("image"), url=data.get("url"), time_taken=data.get("time_taken", 0.0), request_params=request_params, gallery_item=GalleryImage.model_validate(stored))
except httpx.RequestError as exc: raise HTTPException(status_code=502, detail=f"Z-Image service unreachable: {exc}")
except Exception as exc: raise HTTPException(status_code=500, detail=f"An error occurred: {exc}")
@app.get("/admin/whitelist")
async def get_whitelist() -> dict:
return {"whitelist": whitelist_store.get_all()}
@app.post("/admin/whitelist")
async def add_whitelist(user_ids: List[str]) -> dict:
whitelist_store.add_users(user_ids)
return {"status": "ok", "whitelist": whitelist_store.get_all()}
@app.delete("/admin/whitelist/{user_id}")
async def remove_whitelist(user_id: str) -> dict:
whitelist_store.remove_user(user_id)
return {"status": "ok", "whitelist": whitelist_store.get_all()}
@app.post("/likes/{image_id}")
async def toggle_like(
image_id: str,
user_id: str = Query(..., alias="userId")
) -> dict:
"""Toggle like status for an image by a user."""
updated_image = gallery_store.toggle_like(image_id, user_id)
if not updated_image:
raise HTTPException(status_code=404, detail="Image not found")
return updated_image
# Redirect old /gallery to /gallery/images for backward compatibility
@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,
)
async def gallery(limit: int = Query(200, ge=1, le=1000), author_id: Optional[str] = Query(None, alias="authorId")):
return await gallery_images(limit=limit, author_id=author_id)
\ No newline at end of file
... ...
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { ImageItem, ImageGenerationParams, UserProfile, VideoStatus } from './types';
import { SHOWCASE_IMAGES, ADMIN_ID } from './constants';
import { SHOWCASE_IMAGES, ADMIN_ID, VIDEO_OSS_BASE_URL } from './constants';
import { generateImage } from './services/imageService';
import { submitVideoJob, pollVideoStatus, getVideoResultUrl } from './services/videoService';
import { fetchGallery, toggleLike } from './services/galleryService';
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';
... ... @@ -13,93 +13,86 @@ 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_DATA = 'z-image-gallery-data-v2';
const STORAGE_KEY_VIDEO_DATA = 'z-video-gallery-data-v1';
const STORAGE_KEY_USER = 'z-image-user-profile';
const MIN_GALLERY_ITEMS = 8;
// --- Enums and Types ---
enum GalleryMode {
Image,
Video,
}
const App: React.FC = () => {
// --- State: User ---
// State
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
const [isWhitelistModalOpen, setIsWhitelistModalOpen] = useState(false);
// --- State: UI ---
const [galleryMode, setGalleryMode] = useState<GalleryMode>(GalleryMode.Image);
// --- 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 [videos, setVideos] = useState<ImageItem[]>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_VIDEO_DATA);
if (saved) return JSON.parse(saved);
} catch(e) { console.error(e); }
return [];
});
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);
// --- State: Admin/Edit ---
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;
// 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 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);
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);
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);
}, []);
useEffect(() => {
try { localStorage.setItem(STORAGE_KEY_DATA, JSON.stringify(images)); }
catch (e) { console.error("Storage full", e); }
}, [images]);
// --- 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(() => {
try { localStorage.setItem(STORAGE_KEY_VIDEO_DATA, JSON.stringify(videos)); }
catch (e) { console.error("Storage full for videos", e); }
}, [videos]);
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);
... ... @@ -108,73 +101,29 @@ const App: React.FC = () => {
};
const handleLogout = () => {
if(confirm("确定要退出登录吗?")) {
if (confirm("确定要退出登录吗?")) {
localStorage.removeItem(STORAGE_KEY_USER);
setCurrentUser(null);
setIsAuthModalOpen(true);
}
};
const syncGallery = useCallback(async () => {
if (galleryMode === GalleryMode.Video) {
// We are not syncing videos from a central gallery in this version.
return;
}
try {
const remoteImages = await fetchGallery();
setImages(prev => {
const normalized = remoteImages.map(img => {
const isLiked = img.likedBy && currentUser
? img.likedBy.includes(currentUser.employeeId)
: false;
return {
...img,
likes: img.likes, // Trust server
isLikedByCurrentUser: isLiked,
};
});
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);
}
}, [currentUser, galleryMode]);
useEffect(() => {
syncGallery();
const interval = setInterval(syncGallery, 30000);
return () => clearInterval(interval);
}, [syncGallery]);
const handleGenerateVideo = async (params: ImageGenerationParams, imageFile: File) => {
if (!currentUser) {
setIsAuthModalOpen(true);
return;
}
if (!currentUser) { setIsAuthModalOpen(true); return; }
setError(null);
setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' });
try {
const taskId = await submitVideoJob(params.prompt, imageFile);
const finalStatus = await pollVideoStatus(taskId, (statusUpdate) => {
setVideoStatus(statusUpdate);
});
const taskId = await submitVideoJob(params.prompt, imageFile, currentUser.employeeId);
const finalStatus = await pollVideoStatus(taskId, setVideoStatus);
const newVideo: ImageItem = {
if (!finalStatus.video_filename) {
throw new Error("视频生成完成,但未找到有效的视频文件名。");
}
const newVideoData: ImageItem = {
id: `vid-${Date.now()}`,
url: getVideoResultUrl(taskId),
url: `${VIDEO_OSS_BASE_URL}/${finalStatus.video_filename}`,
prompt: params.prompt,
authorId: currentUser.employeeId,
createdAt: Date.now(),
... ... @@ -182,63 +131,34 @@ const App: React.FC = () => {
isLikedByCurrentUser: false,
generationTime: finalStatus.processing_time,
};
setVideos(prev => [newVideo, ...prev]);
// Keep the "complete" status visible for a few seconds before clearing
setTimeout(() => {
setVideoStatus(null);
}, 3000); // Display for 3 seconds
const savedVideo = await saveVideo(newVideoData);
setVideos(prev => [savedVideo, ...prev]);
setTimeout(() => setVideoStatus(null), 3000);
} catch (err: any) {
console.error(err);
setError("视频生成失败。请确保视频生成服务正常运行。");
setVideoStatus(null); // Clear immediately on error
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);
if (!imageFile) { alert("请上传一张图片以生成视频。"); return; }
handleGenerateVideo(uiParams, imageFile);
return;
}
if (!currentUser) { setIsAuthModalOpen(true); return; }
setIsGenerating(true);
setError(null);
setIncomingParams(null); // Reset syncing state once action starts
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 => {
const existing = prev.filter(img => img.id !== newImage.id);
return [newImage, ...existing];
});
if (!serverImage) {
await syncGallery();
}
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("生成失败。请确保服务器正常运行。");
... ... @@ -247,77 +167,55 @@ const App: React.FC = () => {
}
};
const handleLike = async (image: ImageItem) => {
if (!currentUser) {
setIsAuthModalOpen(true);
return;
}
const handleLike = async (item: ImageItem) => {
if (!currentUser) { setIsAuthModalOpen(true); return; }
const isVideo = item.id.startsWith('vid-');
const stateSetter = isVideo ? setVideos : setImages;
// Optimistic update
const previousImages = [...images];
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
};
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 img;
return i;
}));
try {
await toggleLike(image.id, currentUser.employeeId);
await toggleLike(item.id, currentUser.employeeId);
} catch (e) {
console.error("Like failed", e);
setImages(previousImages); // Revert
alert("操作失败");
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);
}
if (banner) { banner.style.display = 'flex'; setTimeout(() => { banner.style.display = 'none'; }, 3000); }
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
};
// --- Management (ADMIN) ---
// 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 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 = `...`; // Omitted for brevity
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 = 'constants.ts';
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} />
<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>
... ... @@ -348,52 +246,27 @@ const App: React.FC = () => {
</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>
<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 && ( // This is for image generation only
<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>
{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={videos} onImageClick={setSelectedImage} onLike={() => {}} currentUser={currentUser?.employeeId} isVideoGallery={true} />
<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}
/>
)}
{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>
... ...
... ... @@ -23,12 +23,22 @@ const HistoryBar: React.FC<HistoryBarProps> = ({ images, onSelect }) => {
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"
/>
{img.id.startsWith('vid-') ? (
<video
src={img.url}
className="w-full h-full object-cover"
muted
playsInline
preload="metadata"
/>
) : (
<img
src={img.url}
alt="History"
className="w-full h-full object-cover"
loading="lazy"
/>
)}
</button>
))}
</div>
... ...
import { ImageItem } from './types';
export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009";
export const TURBO_DIFFUSION_VIDEO_BASE_URL = "http://106.120.52.146:38000";
// Base URL for the TurboDiffusion AI service (submit job, poll status)
export const TURBO_DIFFUSION_API_URL = "http://106.120.52.146:38000";
// Base URL for the OSS service that serves the final video files
export const VIDEO_OSS_BASE_URL = "http://106.120.52.146:39997";
const ENV_PROXY_URL = import.meta.env?.VITE_API_BASE_URL?.trim();
const DEFAULT_PROXY_URL = ENV_PROXY_URL && ENV_PROXY_URL.length > 0
... ...
... ... @@ -10,22 +10,65 @@ export const fetchGallery = async (authorId?: string): Promise<ImageItem[]> => {
params.set('authorId', authorId);
}
const query = params.toString();
const response = await fetch(`${API_BASE_URL}/gallery${query ? `?${query}` : ''}`);
// Note: The backend endpoint was changed from /gallery to /gallery/images
const response = await fetch(`${API_BASE_URL}/gallery/images${query ? `?${query}` : ''}`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gallery fetch failed (${response.status}): ${errorText}`);
throw new Error(`Image gallery fetch failed (${response.status}): ${errorText}`);
}
const data: GalleryResponse = await response.json();
return data.images ?? [];
};
export const toggleLike = async (imageId: string, userId: string): Promise<ImageItem> => {
export const fetchVideoGallery = async (authorId?: string): Promise<ImageItem[]> => {
if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) {
throw new Error("Cannot like images in direct mode");
return [];
}
const params = new URLSearchParams();
if (authorId) {
params.set('authorId', authorId);
}
const query = params.toString();
const response = await fetch(`${API_BASE_URL}/gallery/videos${query ? `?${query}` : ''}`);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Video gallery fetch failed (${response.status}): ${errorText}`);
}
// Assuming the response for videos is { videos: ImageItem[] }
const data: { videos: ImageItem[] } = await response.json();
return data.videos ?? [];
};
export const saveVideo = async (videoData: ImageItem): Promise<ImageItem> => {
if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) {
throw new Error("Cannot save videos in direct mode");
}
const response = await fetch(`${API_BASE_URL}/gallery/videos`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(videoData),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Save video failed (${response.status}): ${errorText}`);
}
const response = await fetch(`${API_BASE_URL}/likes/${imageId}?userId=${userId}`, {
return await response.json();
};
export const toggleLike = async (itemId: string, userId: string): Promise<ImageItem> => {
if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) {
throw new Error("Cannot like items in direct mode");
}
const response = await fetch(`${API_BASE_URL}/likes/${itemId}?userId=${userId}`, {
method: 'POST',
});
... ... @@ -35,4 +78,4 @@ export const toggleLike = async (imageId: string, userId: string): Promise<Image
}
return await response.json();
};
};
\ No newline at end of file
... ...
import { TURBO_DIFFUSION_VIDEO_BASE_URL } from '../constants';
import { TURBO_DIFFUSION_API_URL } from '../constants';
import { VideoStatus } from '../types';
/**
* Submits a video generation job to the backend.
* @returns The task ID for the submitted job.
*/
export const submitVideoJob = async (prompt: string, image: File): Promise<string> => {
export const submitVideoJob = async (prompt: string, image: File, authorId: string): Promise<string> => {
const formData = new FormData();
formData.append('prompt', prompt);
formData.append('image', image, image.name);
formData.append('author_id', authorId);
const submitRes = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/submit-job/`, {
const submitRes = await fetch(`${TURBO_DIFFUSION_API_URL}/submit-job/`, {
method: 'POST',
body: formData,
});
... ... @@ -37,7 +38,7 @@ export const pollVideoStatus = (
return new Promise((resolve, reject) => {
const interval = setInterval(async () => {
try {
const res = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/status/${taskId}`);
const res = await fetch(`${TURBO_DIFFUSION_API_URL}/status/${taskId}`);
if (!res.ok) {
// Stop polling on HTTP error
clearInterval(interval);
... ... @@ -63,12 +64,4 @@ export const pollVideoStatus = (
}
}, 2000); // Poll every 2 seconds
});
};
/**
* Gets the final URL for a completed video task.
*/
export const getVideoResultUrl = (taskId: string): string => {
return `${TURBO_DIFFUSION_VIDEO_BASE_URL}/result/${taskId}`;
};
\ No newline at end of file
... ...
... ... @@ -48,4 +48,5 @@ export interface VideoStatus {
message: string;
queue_position?: number;
processing_time?: number;
video_filename?: string; // The final filename of the video
}
... ...