ly0303521

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

@@ -13,19 +13,20 @@ from fastapi.middleware.cors import CORSMiddleware @@ -13,19 +13,20 @@ from fastapi.middleware.cors import CORSMiddleware
13 from pydantic import BaseModel, Field, ConfigDict 13 from pydantic import BaseModel, Field, ConfigDict
14 import logging 14 import logging
15 15
  16 +# --- Constants ---
16 logger = logging.getLogger("uvicorn.error") 17 logger = logging.getLogger("uvicorn.error")
17 -logging.basicConfig(level=logging.INFO)  
18 -logger.info("your message %s", "hello")  
19 Z_IMAGE_BASE_URL = os.getenv("Z_IMAGE_BASE_URL", "http://106.120.52.146:39009").rstrip("/") 18 Z_IMAGE_BASE_URL = os.getenv("Z_IMAGE_BASE_URL", "http://106.120.52.146:39009").rstrip("/")
20 REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "120")) 19 REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "120"))
21 -GALLERY_DATA_PATH = Path(os.getenv("GALLERY_DATA_PATH", Path(__file__).with_name("gallery_data.json"))) 20 +GALLERY_IMAGES_PATH = Path(os.getenv("GALLERY_IMAGES_PATH", Path(__file__).with_name("gallery_images.json")))
  21 +GALLERY_VIDEOS_PATH = Path(os.getenv("GALLERY_VIDEOS_PATH", Path(__file__).with_name("gallery_videos.json")))
22 GALLERY_MAX_ITEMS = int(os.getenv("GALLERY_MAX_ITEMS", "500")) 22 GALLERY_MAX_ITEMS = int(os.getenv("GALLERY_MAX_ITEMS", "500"))
23 WHITELIST_PATH = Path(os.getenv("WHITELIST_PATH", Path(__file__).with_name("whitelist.txt"))) 23 WHITELIST_PATH = Path(os.getenv("WHITELIST_PATH", Path(__file__).with_name("whitelist.txt")))
24 24
  25 +# --- Pydantic Models ---
  26 +# Define dependent models first to avoid forward reference issues.
25 27
26 class ImageGenerationPayload(BaseModel): 28 class ImageGenerationPayload(BaseModel):
27 model_config = ConfigDict(populate_by_name=True) 29 model_config = ConfigDict(populate_by_name=True)
28 -  
29 prompt: str = Field(..., min_length=1, max_length=2048) 30 prompt: str = Field(..., min_length=1, max_length=2048)
30 height: int = Field(1024, ge=64, le=2048) 31 height: int = Field(1024, ge=64, le=2048)
31 width: int = Field(1024, ge=64, le=2048) 32 width: int = Field(1024, ge=64, le=2048)
@@ -36,309 +37,210 @@ class ImageGenerationPayload(BaseModel): @@ -36,309 +37,210 @@ class ImageGenerationPayload(BaseModel):
36 output_format: Literal["base64", "url"] = "base64" 37 output_format: Literal["base64", "url"] = "base64"
37 author_id: Optional[str] = Field(default=None, alias="authorId", min_length=1, max_length=64) 38 author_id: Optional[str] = Field(default=None, alias="authorId", min_length=1, max_length=64)
38 39
39 -  
40 -class ImageGenerationResponse(BaseModel):  
41 - image: Optional[str] = None  
42 - url: Optional[str] = None  
43 - time_taken: float = 0.0  
44 - error: Optional[str] = None  
45 - request_params: ImageGenerationPayload  
46 - gallery_item: Optional["GalleryImage"] = None  
47 -  
48 -  
49 -class GalleryImage(BaseModel): 40 +class GalleryItem(BaseModel):
50 model_config = ConfigDict(populate_by_name=True) 41 model_config = ConfigDict(populate_by_name=True)
51 -  
52 id: str 42 id: str
53 prompt: str = Field(..., min_length=1, max_length=2048) 43 prompt: str = Field(..., min_length=1, max_length=2048)
54 - height: int = Field(..., ge=64, le=2048)  
55 - width: int = Field(..., ge=64, le=2048)  
56 - num_inference_steps: int = Field(..., ge=1, le=200)  
57 - guidance_scale: float = Field(..., ge=0.0, le=20.0)  
58 - seed: int = Field(..., ge=0)  
59 url: str 44 url: str
60 created_at: float = Field(default_factory=lambda: time.time() * 1000, alias="createdAt") 45 created_at: float = Field(default_factory=lambda: time.time() * 1000, alias="createdAt")
61 author_id: Optional[str] = Field(default=None, alias="authorId") 46 author_id: Optional[str] = Field(default=None, alias="authorId")
62 likes: int = 0 47 likes: int = 0
63 is_mock: bool = Field(default=False, alias="isMock") 48 is_mock: bool = Field(default=False, alias="isMock")
64 - negative_prompt: Optional[str] = None  
65 liked_by: List[str] = Field(default_factory=list, alias="likedBy") 49 liked_by: List[str] = Field(default_factory=list, alias="likedBy")
66 50
  51 +class GalleryImage(GalleryItem):
  52 + height: int = Field(..., ge=64, le=2048)
  53 + width: int = Field(..., ge=64, le=2048)
  54 + num_inference_steps: int = Field(..., ge=1, le=200)
  55 + guidance_scale: float = Field(..., ge=0.0, le=20.0)
  56 + seed: int = Field(..., ge=0)
  57 + negative_prompt: Optional[str] = None
67 58
68 -ImageGenerationResponse.model_rebuild() 59 +class GalleryVideo(GalleryItem):
  60 + generation_time: Optional[float] = Field(default=None, alias="generationTime")
69 61
  62 +class ImageGenerationResponse(BaseModel):
  63 + image: Optional[str] = None
  64 + url: Optional[str] = None
  65 + time_taken: float = 0.0
  66 + error: Optional[str] = None
  67 + request_params: ImageGenerationPayload
  68 + gallery_item: Optional[GalleryImage] = None # No forward ref needed now
70 69
71 -class WhitelistStore:  
72 - """Simple text file backed whitelist.""" 70 +# --- Data Stores ---
73 71
  72 +class WhitelistStore:
74 def __init__(self, path: Path) -> None: 73 def __init__(self, path: Path) -> None:
75 self.path = path 74 self.path = path
76 self.lock = RLock() 75 self.lock = RLock()
77 - if not self.path.exists():  
78 - # Default admin ID if file doesn't exist  
79 - self._write(["86427531"])  
80 - 76 + if not self.path.exists(): self._write(["86427531"])
81 def _read(self) -> List[str]: 77 def _read(self) -> List[str]:
82 - if not self.path.exists():  
83 - return [] 78 + if not self.path.exists(): return []
84 try: 79 try:
85 with self.path.open("r", encoding="utf-8") as f: 80 with self.path.open("r", encoding="utf-8") as f:
86 - lines = f.read().splitlines()  
87 - return [line.strip() for line in lines if line.strip()]  
88 - except OSError:  
89 - return []  
90 - 81 + return [line.strip() for line in f.read().splitlines() if line.strip()]
  82 + except OSError: return []
91 def _write(self, ids: List[str]) -> None: 83 def _write(self, ids: List[str]) -> None:
92 with self.lock: 84 with self.lock:
93 try: 85 try:
94 - with self.path.open("w", encoding="utf-8") as f:  
95 - f.write("\n".join(ids))  
96 - except OSError as exc:  
97 - print(f"[WARN] Failed to write whitelist: {exc}")  
98 -  
99 - def is_allowed(self, user_id: str) -> bool:  
100 - allowed = self._read()  
101 - return user_id in allowed  
102 - 86 + with self.path.open("w", encoding="utf-8") as f: f.write("\n".join(ids))
  87 + except OSError as exc: print(f"[WARN] Failed to write whitelist: {exc}")
  88 + def is_allowed(self, user_id: str) -> bool: return user_id in self._read()
103 def add_users(self, user_ids: List[str]) -> None: 89 def add_users(self, user_ids: List[str]) -> None:
104 with self.lock: 90 with self.lock:
105 - current = set(self._read())  
106 - current.update(user_ids)  
107 - self._write(sorted(list(current)))  
108 - 91 + current = set(self._read()); current.update(user_ids); self._write(sorted(list(current)))
109 def remove_user(self, user_id: str) -> None: 92 def remove_user(self, user_id: str) -> None:
110 with self.lock: 93 with self.lock:
111 current = self._read() 94 current = self._read()
112 - if user_id in current:  
113 - current = [uid for uid in current if uid != user_id]  
114 - self._write(current)  
115 -  
116 - def get_all(self) -> List[str]:  
117 - return self._read()  
118 - 95 + if user_id in current: self._write([uid for uid in current if uid != user_id])
  96 + def get_all(self) -> List[str]: return self._read()
119 97
120 -class GalleryStore:  
121 - """Simple JSON file backed store for generated images."""  
122 -  
123 - def __init__(self, path: Path, max_items: int = 500) -> None: 98 +class JsonStore:
  99 + """Generic JSON file backed store for a list of items."""
  100 + def __init__(self, path: Path, item_key: str, max_items: int = 500) -> None:
124 self.path = path 101 self.path = path
  102 + self.item_key = item_key
125 self.max_items = max_items 103 self.max_items = max_items
126 self.lock = Lock() 104 self.lock = Lock()
127 - self.enabled = True  
128 - self._memory_cache: List[dict] = []  
129 try: 105 try:
130 self.path.parent.mkdir(parents=True, exist_ok=True) 106 self.path.parent.mkdir(parents=True, exist_ok=True)
131 - if self.path.exists():  
132 - self._memory_cache = self._read().get("images", [])  
133 - else:  
134 - self._write({"images": []})  
135 - except OSError as exc: # pragma: no cover - filesystem guards  
136 - self.enabled = False  
137 - print(f"[WARN] Gallery store disabled due to filesystem error: {exc}")  
138 - 107 + if not self.path.exists(): self._write({self.item_key: []})
  108 + except OSError as exc: print(f"[WARN] JSON store at {path} disabled: {exc}")
139 def _read(self) -> dict: 109 def _read(self) -> dict:
140 - if not self.enabled:  
141 - return {"images": list(self._memory_cache)}  
142 try: 110 try:
143 - with self.path.open("r", encoding="utf-8") as file:  
144 - return json.load(file)  
145 - except (FileNotFoundError, json.JSONDecodeError):  
146 - return {"images": []}  
147 - 111 + with self.path.open("r", encoding="utf-8") as file: return json.load(file)
  112 + except (FileNotFoundError, json.JSONDecodeError): return {self.item_key: []}
148 def _write(self, data: dict) -> None: 113 def _write(self, data: dict) -> None:
149 - if not self.enabled:  
150 - self._memory_cache = list(data.get("images", []))  
151 - return  
152 payload = json.dumps(data, ensure_ascii=False, indent=2) 114 payload = json.dumps(data, ensure_ascii=False, indent=2)
153 temp_path = self.path.with_suffix(".tmp") 115 temp_path = self.path.with_suffix(".tmp")
154 try: 116 try:
155 - with temp_path.open("w", encoding="utf-8") as file:  
156 - file.write(payload) 117 + with temp_path.open("w", encoding="utf-8") as file: file.write(payload)
157 temp_path.replace(self.path) 118 temp_path.replace(self.path)
158 - except OSError as exc:  
159 - # Some filesystems (or permissions) may block atomic replace; fall back to direct write  
160 - print(f"[WARN] Atomic gallery write failed, attempting direct write: {exc}")  
161 - try:  
162 - with self.path.open("w", encoding="utf-8") as file:  
163 - file.write(payload)  
164 - except OSError as direct_exc:  
165 - raise direct_exc  
166 - self._memory_cache = list(data.get("images", []))  
167 -  
168 - def list_images(self) -> List[dict]:  
169 - with self.lock:  
170 - data = self._read()  
171 - return list(data.get("images", []))  
172 -  
173 - def add_image(self, image: GalleryImage) -> dict:  
174 - payload = image.model_dump(by_alias=True) 119 + except OSError:
  120 + with self.path.open("w", encoding="utf-8") as file: file.write(payload)
  121 + def list_items(self) -> List[dict]:
  122 + with self.lock: return self._read().get(self.item_key, [])
  123 + def add_item(self, item: BaseModel) -> dict:
  124 + payload = item.model_dump(by_alias=True)
175 with self.lock: 125 with self.lock:
176 data = self._read() 126 data = self._read()
177 - images = data.get("images", [])  
178 - images.insert(0, payload)  
179 - data["images"] = images[: self.max_items] 127 + items = data.get(self.item_key, [])
  128 + items.insert(0, payload)
  129 + data[self.item_key] = items[:self.max_items]
180 self._write(data) 130 self._write(data)
181 return payload 131 return payload
182 -  
183 - def toggle_like(self, image_id: str, user_id: str) -> Optional[dict]: 132 + def toggle_like(self, item_id: str, user_id: str) -> Optional[dict]:
184 with self.lock: 133 with self.lock:
185 data = self._read() 134 data = self._read()
186 - images = data.get("images", [])  
187 - target_image = next((img for img in images if img.get("id") == image_id), None)  
188 -  
189 - if not target_image:  
190 - return None  
191 -  
192 - liked_by = target_image.get("likedBy", [])  
193 - # Handle legacy data where likedBy might be missing  
194 - if not isinstance(liked_by, list):  
195 - liked_by = []  
196 - 135 + items = data.get(self.item_key, [])
  136 + target_item = next((i for i in items if i.get("id") == item_id), None)
  137 + if not target_item: return None
  138 + liked_by = target_item.get("likedBy", [])
  139 + if not isinstance(liked_by, list): liked_by = []
197 if user_id in liked_by: 140 if user_id in liked_by:
198 liked_by.remove(user_id) 141 liked_by.remove(user_id)
199 - target_image["likes"] = max(0, target_image.get("likes", 0) - 1) 142 + target_item["likes"] = max(0, target_item.get("likes", 0) - 1)
200 else: 143 else:
201 liked_by.append(user_id) 144 liked_by.append(user_id)
202 - target_image["likes"] = target_image.get("likes", 0) + 1  
203 -  
204 - target_image["likedBy"] = liked_by 145 + target_item["likes"] = target_item.get("likes", 0) + 1
  146 + target_item["likedBy"] = liked_by
205 self._write(data) 147 self._write(data)
206 - return target_image  
207 - 148 + return target_item
208 149
209 -gallery_store = GalleryStore(GALLERY_DATA_PATH, GALLERY_MAX_ITEMS) 150 +image_store = JsonStore(GALLERY_IMAGES_PATH, item_key="images", max_items=GALLERY_MAX_ITEMS)
  151 +video_store = JsonStore(GALLERY_VIDEOS_PATH, item_key="videos", max_items=GALLERY_MAX_ITEMS)
210 whitelist_store = WhitelistStore(WHITELIST_PATH) 152 whitelist_store = WhitelistStore(WHITELIST_PATH)
211 153
212 - 154 +# --- App Setup ---
213 app = FastAPI(title="Z-Image Proxy", version="1.0.0") 155 app = FastAPI(title="Z-Image Proxy", version="1.0.0")
214 -  
215 app.add_middleware( 156 app.add_middleware(
216 CORSMiddleware, 157 CORSMiddleware,
217 - allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","), 158 + allow_origins=["http://106.120.52.146:37001"], # Explicitly allow the frontend origin
218 allow_credentials=True, 159 allow_credentials=True,
219 allow_methods=["*"], 160 allow_methods=["*"],
220 allow_headers=["*"], 161 allow_headers=["*"],
221 ) 162 )
222 -  
223 -  
224 @app.on_event("startup") 163 @app.on_event("startup")
225 -async def startup() -> None:  
226 - timeout = httpx.Timeout(REQUEST_TIMEOUT_SECONDS, connect=5.0)  
227 - app.state.http = httpx.AsyncClient(timeout=timeout)  
228 -  
229 - 164 +async def startup(): app.state.http = httpx.AsyncClient(timeout=httpx.Timeout(REQUEST_TIMEOUT_SECONDS, connect=5.0))
230 @app.on_event("shutdown") 165 @app.on_event("shutdown")
231 -async def shutdown() -> None:  
232 - await app.state.http.aclose()  
233 -  
234 - 166 +async def shutdown(): await app.state.http.aclose()
235 @app.get("/health") 167 @app.get("/health")
236 -async def health() -> dict:  
237 - return {"status": "ok"}  
238 - 168 +async def health(): return {"status": "ok"}
239 169
  170 +# --- Endpoints ---
240 @app.post("/auth/login") 171 @app.post("/auth/login")
241 -async def login(user_id: str = Query(..., alias="userId")) -> dict:  
242 - if whitelist_store.is_allowed(user_id):  
243 - return {"status": "ok", "userId": user_id} 172 +async def login(user_id: str = Query(..., alias="userId")):
  173 + if whitelist_store.is_allowed(user_id): return {"status": "ok", "userId": user_id}
244 raise HTTPException(status_code=403, detail="User not whitelisted") 174 raise HTTPException(status_code=403, detail="User not whitelisted")
245 175
  176 +@app.post("/likes/{item_id}")
  177 +async def toggle_like(item_id: str, user_id: str = Query(..., alias="userId")):
  178 + updated_item = image_store.toggle_like(item_id, user_id)
  179 + if updated_item: return updated_item
  180 + updated_item = video_store.toggle_like(item_id, user_id)
  181 + if updated_item: return updated_item
  182 + raise HTTPException(status_code=404, detail="Item not found")
  183 +
  184 +@app.get("/gallery/images")
  185 +async def gallery_images(limit: int = Query(200, ge=1, le=1000), author_id: Optional[str] = Query(None, alias="authorId")):
  186 + items = image_store.list_items()
  187 + if author_id: items = [item for item in items if item.get("authorId") == author_id]
  188 + return {"images": items[:limit]}
  189 +
  190 +@app.get("/gallery/videos")
  191 +async def gallery_videos(limit: int = Query(200, ge=1, le=1000), author_id: Optional[str] = Query(None, alias="authorId")):
  192 + items = video_store.list_items()
  193 + if author_id: items = [item for item in items if item.get("authorId") == author_id]
  194 + return {"videos": items[:limit]}
  195 +
  196 +@app.post("/gallery/videos")
  197 +async def add_video(video: GalleryVideo):
  198 + try:
  199 + return video_store.add_item(video)
  200 + except Exception as exc:
  201 + raise HTTPException(status_code=500, detail=f"Failed to store video metadata: {exc}")
  202 +
  203 +@app.post("/generate", response_model=ImageGenerationResponse)
  204 +async def generate_image(payload: ImageGenerationPayload):
  205 + 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"}
  206 + if "seed" not in body: body["seed"] = secrets.randbelow(1_000_000_000)
  207 + request_params_data["seed"] = body["seed"]; request_params = ImageGenerationPayload(**request_params_data)
  208 + url = f"{Z_IMAGE_BASE_URL}/generate"
  209 + try:
  210 + resp = await app.state.http.post(url, json=body)
  211 + resp.raise_for_status()
  212 + data = resp.json()
  213 + image_url = data.get("url") or f"data:image/png;base64,{data.get('image')}"
  214 + stored_item_data = {
  215 + "id": data.get("id") or secrets.token_hex(16),
  216 + "prompt": payload.prompt,
  217 + "width": payload.width,
  218 + "height": payload.height,
  219 + "num_inference_steps": payload.num_inference_steps,
  220 + "guidance_scale": payload.guidance_scale,
  221 + "seed": request_params.seed,
  222 + "url": image_url,
  223 + "author_id": payload.author_id,
  224 + "negative_prompt": payload.negative_prompt,
  225 + }
  226 + stored = image_store.add_item(GalleryImage(**stored_item_data))
  227 + 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))
  228 + except httpx.RequestError as exc: raise HTTPException(status_code=502, detail=f"Z-Image service unreachable: {exc}")
  229 + except Exception as exc: raise HTTPException(status_code=500, detail=f"An error occurred: {exc}")
246 230
247 @app.get("/admin/whitelist") 231 @app.get("/admin/whitelist")
248 async def get_whitelist() -> dict: 232 async def get_whitelist() -> dict:
249 return {"whitelist": whitelist_store.get_all()} 233 return {"whitelist": whitelist_store.get_all()}
250 -  
251 -  
252 @app.post("/admin/whitelist") 234 @app.post("/admin/whitelist")
253 async def add_whitelist(user_ids: List[str]) -> dict: 235 async def add_whitelist(user_ids: List[str]) -> dict:
254 whitelist_store.add_users(user_ids) 236 whitelist_store.add_users(user_ids)
255 return {"status": "ok", "whitelist": whitelist_store.get_all()} 237 return {"status": "ok", "whitelist": whitelist_store.get_all()}
256 -  
257 -  
258 @app.delete("/admin/whitelist/{user_id}") 238 @app.delete("/admin/whitelist/{user_id}")
259 async def remove_whitelist(user_id: str) -> dict: 239 async def remove_whitelist(user_id: str) -> dict:
260 whitelist_store.remove_user(user_id) 240 whitelist_store.remove_user(user_id)
261 return {"status": "ok", "whitelist": whitelist_store.get_all()} 241 return {"status": "ok", "whitelist": whitelist_store.get_all()}
262 242
263 -  
264 -@app.post("/likes/{image_id}")  
265 -async def toggle_like(  
266 - image_id: str,  
267 - user_id: str = Query(..., alias="userId")  
268 -) -> dict:  
269 - """Toggle like status for an image by a user."""  
270 - updated_image = gallery_store.toggle_like(image_id, user_id)  
271 - if not updated_image:  
272 - raise HTTPException(status_code=404, detail="Image not found")  
273 - return updated_image  
274 -  
275 - 243 +# Redirect old /gallery to /gallery/images for backward compatibility
276 @app.get("/gallery") 244 @app.get("/gallery")
277 -async def gallery(  
278 - limit: int = Query(200, ge=1, le=1000),  
279 - author_id: Optional[str] = Query(default=None, alias="authorId"),  
280 -) -> dict:  
281 - """Return the persisted gallery images, optionally filtered by author."""  
282 - images = gallery_store.list_images()  
283 - if author_id:  
284 - images = [item for item in images if item.get("authorId") == author_id]  
285 - return {"images": images[:limit]}  
286 -  
287 -  
288 -@app.post("/generate", response_model=ImageGenerationResponse)  
289 -async def generate_image(payload: ImageGenerationPayload) -> ImageGenerationResponse:  
290 - request_params_data = payload.model_dump()  
291 - body = {  
292 - key: value  
293 - for key, value in request_params_data.items()  
294 - if value is not None and key != "author_id"  
295 - }  
296 - if "seed" not in body:  
297 - body["seed"] = secrets.randbelow(1_000_000_000)  
298 - request_params_data["seed"] = body["seed"]  
299 - request_params = ImageGenerationPayload(**request_params_data)  
300 - url = f"{Z_IMAGE_BASE_URL}/generate"  
301 -  
302 - try:  
303 - resp = await app.state.http.post(url, json=body)  
304 - except httpx.RequestError as exc: # pragma: no cover - network errors only  
305 - raise HTTPException(status_code=502, detail=f"Z-Image service unreachable: {exc}") from exc  
306 -  
307 - if resp.status_code != 200:  
308 - raise HTTPException(status_code=resp.status_code, detail=f"Z-Image error: {resp.text}")  
309 -  
310 - data = resp.json()  
311 - image = data.get("image")  
312 - image_url = data.get("url")  
313 - if not image and not image_url:  
314 - raise HTTPException(status_code=502, detail=f"Malformed response from Z-Image: {data}")  
315 -  
316 - stored_image: Optional[GalleryImage] = None  
317 - try:  
318 - stored = gallery_store.add_image(  
319 - GalleryImage(  
320 - id=data.get("id") or secrets.token_hex(16),  
321 - prompt=payload.prompt,  
322 - width=payload.width,  
323 - height=payload.height,  
324 - num_inference_steps=payload.num_inference_steps,  
325 - guidance_scale=payload.guidance_scale,  
326 - seed=request_params.seed,  
327 - url=image_url or f"data:image/png;base64,{image}",  
328 - author_id=payload.author_id,  
329 - negative_prompt=payload.negative_prompt,  
330 - )  
331 - )  
332 - stored_image = GalleryImage.model_validate(stored)  
333 - except Exception as exc: # pragma: no cover - diagnostics only  
334 - # Persisting gallery data should not block the response  
335 - print(f"[WARN] Failed to store gallery image: {exc}")  
336 -  
337 - return ImageGenerationResponse(  
338 - image=image,  
339 - url=image_url,  
340 - time_taken=float(data.get("time_taken", 0.0)),  
341 - error=data.get("error"),  
342 - request_params=request_params,  
343 - gallery_item=stored_image,  
344 - ) 245 +async def gallery(limit: int = Query(200, ge=1, le=1000), author_id: Optional[str] = Query(None, alias="authorId")):
  246 + return await gallery_images(limit=limit, author_id=author_id)
1 import React, { useState, useEffect, useMemo, useCallback } from 'react'; 1 import React, { useState, useEffect, useMemo, useCallback } from 'react';
2 import { ImageItem, ImageGenerationParams, UserProfile, VideoStatus } from './types'; 2 import { ImageItem, ImageGenerationParams, UserProfile, VideoStatus } from './types';
3 -import { SHOWCASE_IMAGES, ADMIN_ID } from './constants'; 3 +import { SHOWCASE_IMAGES, ADMIN_ID, VIDEO_OSS_BASE_URL } from './constants';
4 import { generateImage } from './services/imageService'; 4 import { generateImage } from './services/imageService';
5 -import { submitVideoJob, pollVideoStatus, getVideoResultUrl } from './services/videoService';  
6 -import { fetchGallery, toggleLike } from './services/galleryService'; 5 +import { submitVideoJob, pollVideoStatus } from './services/videoService';
  6 +import { fetchGallery, fetchVideoGallery, saveVideo, toggleLike } from './services/galleryService';
7 import MasonryGrid from './components/MasonryGrid'; 7 import MasonryGrid from './components/MasonryGrid';
8 import InputBar from './components/InputBar'; 8 import InputBar from './components/InputBar';
9 import HistoryBar from './components/HistoryBar'; 9 import HistoryBar from './components/HistoryBar';
@@ -13,93 +13,86 @@ import AuthModal from './components/AuthModal'; @@ -13,93 +13,86 @@ import AuthModal from './components/AuthModal';
13 import WhitelistModal from './components/WhitelistModal'; 13 import WhitelistModal from './components/WhitelistModal';
14 import { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles, Users, Video, Hourglass } from 'lucide-react'; 14 import { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles, Users, Video, Hourglass } from 'lucide-react';
15 15
16 -const STORAGE_KEY_DATA = 'z-image-gallery-data-v2';  
17 -const STORAGE_KEY_VIDEO_DATA = 'z-video-gallery-data-v1';  
18 const STORAGE_KEY_USER = 'z-image-user-profile'; 16 const STORAGE_KEY_USER = 'z-image-user-profile';
19 const MIN_GALLERY_ITEMS = 8; 17 const MIN_GALLERY_ITEMS = 8;
20 18
21 -// --- Enums and Types ---  
22 enum GalleryMode { 19 enum GalleryMode {
23 Image, 20 Image,
24 Video, 21 Video,
25 } 22 }
26 23
27 const App: React.FC = () => { 24 const App: React.FC = () => {
28 - // --- State: User --- 25 + // State
29 const [currentUser, setCurrentUser] = useState<UserProfile | null>(null); 26 const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
30 const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); 27 const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
31 - const [isWhitelistModalOpen, setIsWhitelistModalOpen] = useState(false);  
32 -  
33 - // --- State: UI ---  
34 const [galleryMode, setGalleryMode] = useState<GalleryMode>(GalleryMode.Image); 28 const [galleryMode, setGalleryMode] = useState<GalleryMode>(GalleryMode.Image);
35 -  
36 - // --- State: Data ---  
37 - const [images, setImages] = useState<ImageItem[]>(() => {  
38 - try {  
39 - const saved = localStorage.getItem(STORAGE_KEY_DATA);  
40 - if (saved) return JSON.parse(saved);  
41 - } catch (e) { console.error(e); }  
42 - return SHOWCASE_IMAGES;  
43 - });  
44 - const [videos, setVideos] = useState<ImageItem[]>(() => {  
45 - try {  
46 - const saved = localStorage.getItem(STORAGE_KEY_VIDEO_DATA);  
47 - if (saved) return JSON.parse(saved);  
48 - } catch(e) { console.error(e); }  
49 - return [];  
50 - });  
51 -  
52 - 29 + const [images, setImages] = useState<ImageItem[]>(SHOWCASE_IMAGES);
  30 + const [videos, setVideos] = useState<ImageItem[]>([]);
53 const [isGenerating, setIsGenerating] = useState(false); 31 const [isGenerating, setIsGenerating] = useState(false);
54 const [videoStatus, setVideoStatus] = useState<VideoStatus | null>(null); 32 const [videoStatus, setVideoStatus] = useState<VideoStatus | null>(null);
55 const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null); 33 const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null);
56 const [error, setError] = useState<string | null>(null); 34 const [error, setError] = useState<string | null>(null);
57 const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null); 35 const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null);
58 -  
59 - // --- State: Admin/Edit ---  
60 const [isAdminModalOpen, setIsAdminModalOpen] = useState(false); 36 const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
  37 + const [isWhitelistModalOpen, setIsWhitelistModalOpen] = useState(false);
61 const [editingImage, setEditingImage] = useState<ImageItem | null>(null); 38 const [editingImage, setEditingImage] = useState<ImageItem | null>(null);
62 39
63 const isAdmin = currentUser?.employeeId === ADMIN_ID; 40 const isAdmin = currentUser?.employeeId === ADMIN_ID;
64 const isGeneratingVideo = videoStatus !== null; 41 const isGeneratingVideo = videoStatus !== null;
65 42
  43 + const sortedImages = useMemo(() => [...images].sort((a, b) => (b.likes || 0) - (a.likes || 0)), [images]);
  44 + const sortedVideos = useMemo(() => [...videos].sort((a, b) => b.createdAt - a.createdAt), [videos]);
66 45
67 - // GLOBAL GALLERY: Everyone sees everything, sorted by likes  
68 - const sortedImages = useMemo(() => {  
69 - return [...images].sort((a, b) => (b.likes || 0) - (a.likes || 0));  
70 - }, [images]);  
71 -  
72 - // USER HISTORY: Only current user's generations  
73 const userHistory = useMemo(() => { 46 const userHistory = useMemo(() => {
74 if (!currentUser) return []; 47 if (!currentUser) return [];
75 - return images  
76 - .filter(img => img.authorId === currentUser.employeeId)  
77 - .sort((a, b) => b.createdAt - a.createdAt); 48 + return images.filter(img => img.authorId === currentUser.employeeId).sort((a, b) => b.createdAt - a.createdAt);
78 }, [images, currentUser]); 49 }, [images, currentUser]);
79 50
80 const userVideoHistory = useMemo(() => { 51 const userVideoHistory = useMemo(() => {
81 if (!currentUser) return []; 52 if (!currentUser) return [];
82 - return videos  
83 - .filter(vid => vid.authorId === currentUser.employeeId)  
84 - .sort((a, b) => b.createdAt - a.createdAt); 53 + return videos.filter(vid => vid.authorId === currentUser.employeeId).sort((a, b) => b.createdAt - a.createdAt);
85 }, [videos, currentUser]); 54 }, [videos, currentUser]);
86 55
  56 + // --- Auth Effect ---
87 useEffect(() => { 57 useEffect(() => {
88 const savedUser = localStorage.getItem(STORAGE_KEY_USER); 58 const savedUser = localStorage.getItem(STORAGE_KEY_USER);
89 if (savedUser) setCurrentUser(JSON.parse(savedUser)); 59 if (savedUser) setCurrentUser(JSON.parse(savedUser));
90 else setIsAuthModalOpen(true); 60 else setIsAuthModalOpen(true);
91 }, []); 61 }, []);
92 62
93 - useEffect(() => {  
94 - try { localStorage.setItem(STORAGE_KEY_DATA, JSON.stringify(images)); }  
95 - catch (e) { console.error("Storage full", e); }  
96 - }, [images]); 63 + // --- Data Sync ---
  64 + const syncImageGallery = useCallback(async () => {
  65 + try {
  66 + const remoteImages = await fetchGallery();
  67 + setImages(prev => {
  68 + const normalized = remoteImages.map(img => ({ ...img, isLikedByCurrentUser: currentUser ? (img.likedBy || []).includes(currentUser.employeeId) : false }));
  69 + if (normalized.length >= MIN_GALLERY_ITEMS) return normalized;
  70 + const existingIds = new Set(normalized.map(img => img.id));
  71 + const filler = SHOWCASE_IMAGES.filter(img => !existingIds.has(img.id)).slice(0, MIN_GALLERY_ITEMS - normalized.length);
  72 + return [...normalized, ...filler];
  73 + });
  74 + } catch (err) { console.error("Failed to sync image gallery", err); }
  75 + }, [currentUser]);
  76 +
  77 + const syncVideoGallery = useCallback(async () => {
  78 + try {
  79 + const remoteVideos = await fetchVideoGallery();
  80 + setVideos(remoteVideos.map(vid => ({ ...vid, isLikedByCurrentUser: currentUser ? (vid.likedBy || []).includes(currentUser.employeeId) : false })));
  81 + } catch (err) { console.error("Failed to sync video gallery", err); }
  82 + }, [currentUser]);
97 83
98 useEffect(() => { 84 useEffect(() => {
99 - try { localStorage.setItem(STORAGE_KEY_VIDEO_DATA, JSON.stringify(videos)); }  
100 - catch (e) { console.error("Storage full for videos", e); }  
101 - }, [videos]); 85 + syncImageGallery();
  86 + syncVideoGallery();
  87 + const interval = setInterval(() => {
  88 + syncImageGallery();
  89 + syncVideoGallery();
  90 + }, 30000);
  91 + return () => clearInterval(interval);
  92 + }, [syncImageGallery, syncVideoGallery]);
102 93
  94 +
  95 + // --- Handlers ---
103 const handleLogin = (employeeId: string) => { 96 const handleLogin = (employeeId: string) => {
104 const user: UserProfile = { employeeId, hasAccess: true }; 97 const user: UserProfile = { employeeId, hasAccess: true };
105 setCurrentUser(user); 98 setCurrentUser(user);
@@ -108,73 +101,29 @@ const App: React.FC = () => { @@ -108,73 +101,29 @@ const App: React.FC = () => {
108 }; 101 };
109 102
110 const handleLogout = () => { 103 const handleLogout = () => {
111 - if(confirm("确定要退出登录吗?")) { 104 + if (confirm("确定要退出登录吗?")) {
112 localStorage.removeItem(STORAGE_KEY_USER); 105 localStorage.removeItem(STORAGE_KEY_USER);
113 setCurrentUser(null); 106 setCurrentUser(null);
114 setIsAuthModalOpen(true); 107 setIsAuthModalOpen(true);
115 } 108 }
116 }; 109 };
117 110
118 - const syncGallery = useCallback(async () => {  
119 - if (galleryMode === GalleryMode.Video) {  
120 - // We are not syncing videos from a central gallery in this version.  
121 - return;  
122 - }  
123 - try {  
124 - const remoteImages = await fetchGallery();  
125 - setImages(prev => {  
126 - const normalized = remoteImages.map(img => {  
127 - const isLiked = img.likedBy && currentUser  
128 - ? img.likedBy.includes(currentUser.employeeId)  
129 - : false;  
130 - return {  
131 - ...img,  
132 - likes: img.likes, // Trust server  
133 - isLikedByCurrentUser: isLiked,  
134 - };  
135 - });  
136 -  
137 - if (normalized.length >= MIN_GALLERY_ITEMS) {  
138 - return normalized;  
139 - }  
140 -  
141 - const existingIds = new Set(normalized.map(img => img.id));  
142 - const filler = SHOWCASE_IMAGES.filter(img => !existingIds.has(img.id)).slice(  
143 - 0,  
144 - Math.max(0, MIN_GALLERY_ITEMS - normalized.length)  
145 - );  
146 -  
147 - return [...normalized, ...filler];  
148 - });  
149 - } catch (err) {  
150 - console.error("Failed to sync gallery", err);  
151 - }  
152 - }, [currentUser, galleryMode]);  
153 -  
154 - useEffect(() => {  
155 - syncGallery();  
156 - const interval = setInterval(syncGallery, 30000);  
157 - return () => clearInterval(interval);  
158 - }, [syncGallery]);  
159 -  
160 const handleGenerateVideo = async (params: ImageGenerationParams, imageFile: File) => { 111 const handleGenerateVideo = async (params: ImageGenerationParams, imageFile: File) => {
161 - if (!currentUser) {  
162 - setIsAuthModalOpen(true);  
163 - return;  
164 - } 112 + if (!currentUser) { setIsAuthModalOpen(true); return; }
165 setError(null); 113 setError(null);
166 setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' }); 114 setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' });
167 115
168 try { 116 try {
169 - const taskId = await submitVideoJob(params.prompt, imageFile); 117 + const taskId = await submitVideoJob(params.prompt, imageFile, currentUser.employeeId);
  118 + const finalStatus = await pollVideoStatus(taskId, setVideoStatus);
170 119
171 - const finalStatus = await pollVideoStatus(taskId, (statusUpdate) => {  
172 - setVideoStatus(statusUpdate);  
173 - }); 120 + if (!finalStatus.video_filename) {
  121 + throw new Error("视频生成完成,但未找到有效的视频文件名。");
  122 + }
174 123
175 - const newVideo: ImageItem = { 124 + const newVideoData: ImageItem = {
176 id: `vid-${Date.now()}`, 125 id: `vid-${Date.now()}`,
177 - url: getVideoResultUrl(taskId), 126 + url: `${VIDEO_OSS_BASE_URL}/${finalStatus.video_filename}`,
178 prompt: params.prompt, 127 prompt: params.prompt,
179 authorId: currentUser.employeeId, 128 authorId: currentUser.employeeId,
180 createdAt: Date.now(), 129 createdAt: Date.now(),
@@ -182,63 +131,34 @@ const App: React.FC = () => { @@ -182,63 +131,34 @@ const App: React.FC = () => {
182 isLikedByCurrentUser: false, 131 isLikedByCurrentUser: false,
183 generationTime: finalStatus.processing_time, 132 generationTime: finalStatus.processing_time,
184 }; 133 };
185 - setVideos(prev => [newVideo, ...prev]);  
186 134
187 - // Keep the "complete" status visible for a few seconds before clearing  
188 - setTimeout(() => {  
189 - setVideoStatus(null);  
190 - }, 3000); // Display for 3 seconds 135 + const savedVideo = await saveVideo(newVideoData);
  136 + setVideos(prev => [savedVideo, ...prev]);
191 137
  138 + setTimeout(() => setVideoStatus(null), 3000);
192 } catch (err: any) { 139 } catch (err: any) {
193 console.error(err); 140 console.error(err);
194 setError("视频生成失败。请确保视频生成服务正常运行。"); 141 setError("视频生成失败。请确保视频生成服务正常运行。");
195 - setVideoStatus(null); // Clear immediately on error 142 + setVideoStatus(null);
196 } 143 }
197 }; 144 };
198 145
199 const handleGenerate = async (uiParams: ImageGenerationParams, imageFile?: File) => { 146 const handleGenerate = async (uiParams: ImageGenerationParams, imageFile?: File) => {
200 if (galleryMode === GalleryMode.Video) { 147 if (galleryMode === GalleryMode.Video) {
201 - if (!imageFile) {  
202 - alert("请上传一张图片以生成视频。");  
203 - return;  
204 - } 148 + if (!imageFile) { alert("请上传一张图片以生成视频。"); return; }
205 handleGenerateVideo(uiParams, imageFile); 149 handleGenerateVideo(uiParams, imageFile);
206 return; 150 return;
207 } 151 }
208 -  
209 - if (!currentUser) {  
210 - setIsAuthModalOpen(true);  
211 - return;  
212 - }  
213 - 152 + if (!currentUser) { setIsAuthModalOpen(true); return; }
214 setIsGenerating(true); 153 setIsGenerating(true);
215 setError(null); 154 setError(null);
216 - setIncomingParams(null); // Reset syncing state once action starts  
217 - 155 + setIncomingParams(null);
218 try { 156 try {
219 const result = await generateImage(uiParams, currentUser.employeeId); 157 const result = await generateImage(uiParams, currentUser.employeeId);
220 - const serverImage = result.galleryItem  
221 - ? { ...result.galleryItem, isLikedByCurrentUser: false }  
222 - : null;  
223 -  
224 - const newImage: ImageItem = serverImage || {  
225 - id: Date.now().toString(),  
226 - url: result.imageUrl,  
227 - createdAt: Date.now(),  
228 - authorId: currentUser.employeeId,  
229 - likes: 0,  
230 - isLikedByCurrentUser: false,  
231 - ...uiParams  
232 - };  
233 -  
234 - setImages(prev => {  
235 - const existing = prev.filter(img => img.id !== newImage.id);  
236 - return [newImage, ...existing];  
237 - });  
238 -  
239 - if (!serverImage) {  
240 - await syncGallery();  
241 - } 158 + const serverImage = result.galleryItem ? { ...result.galleryItem, isLikedByCurrentUser: false } : null;
  159 + const newImage: ImageItem = serverImage || { id: Date.now().toString(), url: result.imageUrl, createdAt: Date.now(), authorId: currentUser.employeeId, likes: 0, isLikedByCurrentUser: false, ...uiParams };
  160 + setImages(prev => [newImage, ...prev.filter(img => img.id !== newImage.id)]);
  161 + if (!serverImage) await syncImageGallery();
242 } catch (err: any) { 162 } catch (err: any) {
243 console.error(err); 163 console.error(err);
244 setError("生成失败。请确保服务器正常运行。"); 164 setError("生成失败。请确保服务器正常运行。");
@@ -247,31 +167,24 @@ const App: React.FC = () => { @@ -247,31 +167,24 @@ const App: React.FC = () => {
247 } 167 }
248 }; 168 };
249 169
250 - const handleLike = async (image: ImageItem) => {  
251 - if (!currentUser) {  
252 - setIsAuthModalOpen(true);  
253 - return;  
254 - } 170 + const handleLike = async (item: ImageItem) => {
  171 + if (!currentUser) { setIsAuthModalOpen(true); return; }
  172 + const isVideo = item.id.startsWith('vid-');
  173 + const stateSetter = isVideo ? setVideos : setImages;
255 174
256 - // Optimistic update  
257 - const previousImages = [...images];  
258 - setImages(prev => prev.map(img => {  
259 - if (img.id === image.id) {  
260 - const isLiked = !!img.isLikedByCurrentUser;  
261 - return {  
262 - ...img,  
263 - isLikedByCurrentUser: !isLiked,  
264 - likes: isLiked ? Math.max(0, (img.likes || 0) - 1) : (img.likes || 0) + 1  
265 - }; 175 + stateSetter(prev => prev.map(i => {
  176 + if (i.id === item.id) {
  177 + const isLiked = !!i.isLikedByCurrentUser;
  178 + return { ...i, isLikedByCurrentUser: !isLiked, likes: (i.likes || 0) + (isLiked ? -1 : 1) };
266 } 179 }
267 - return img; 180 + return i;
268 })); 181 }));
269 182
270 try { 183 try {
271 - await toggleLike(image.id, currentUser.employeeId); 184 + await toggleLike(item.id, currentUser.employeeId);
272 } catch (e) { 185 } catch (e) {
273 console.error("Like failed", e); 186 console.error("Like failed", e);
274 - setImages(previousImages); // Revert 187 + if (isVideo) syncVideoGallery(); else syncImageGallery();
275 alert("操作失败"); 188 alert("操作失败");
276 } 189 }
277 }; 190 };
@@ -279,45 +192,30 @@ const App: React.FC = () => { @@ -279,45 +192,30 @@ const App: React.FC = () => {
279 const handleGenerateSimilar = (params: ImageGenerationParams) => { 192 const handleGenerateSimilar = (params: ImageGenerationParams) => {
280 setIncomingParams(params); 193 setIncomingParams(params);
281 const banner = document.getElementById('similar-feedback'); 194 const banner = document.getElementById('similar-feedback');
282 - if (banner) {  
283 - banner.style.display = 'flex';  
284 - setTimeout(() => { banner.style.display = 'none'; }, 3000);  
285 - } 195 + if (banner) { banner.style.display = 'flex'; setTimeout(() => { banner.style.display = 'none'; }, 3000); }
286 window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); 196 window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
287 }; 197 };
288 198
289 - // --- Management (ADMIN) --- 199 + // Admin handlers...
290 const handleOpenCreateModal = () => { if (isAdmin) { setEditingImage(null); setIsAdminModalOpen(true); } }; 200 const handleOpenCreateModal = () => { if (isAdmin) { setEditingImage(null); setIsAdminModalOpen(true); } };
291 const handleOpenEditModal = (image: ImageItem) => { if (isAdmin) { setEditingImage(image); setSelectedImage(null); setIsAdminModalOpen(true); } }; 201 const handleOpenEditModal = (image: ImageItem) => { if (isAdmin) { setEditingImage(image); setSelectedImage(null); setIsAdminModalOpen(true); } };
292 - const handleSaveImage = (savedImage: ImageItem) => {  
293 - setImages(prev => {  
294 - const exists = prev.some(img => img.id === savedImage.id);  
295 - if (exists) return prev.map(img => img.id === savedImage.id ? savedImage : img);  
296 - return [{ ...savedImage, authorId: currentUser?.employeeId || 'ADMIN', likes: savedImage.likes || 0 }, ...prev];  
297 - });  
298 - }; 202 + 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]; });
299 const handleDeleteImage = (id: string) => { if (isAdmin) setImages(prev => prev.filter(img => img.id !== id)); }; 203 const handleDeleteImage = (id: string) => { if (isAdmin) setImages(prev => prev.filter(img => img.id !== id)); };
300 const handleResetData = () => { if (isAdmin && confirm('警告:确定要重置为初始演示数据吗?')) { setImages(SHOWCASE_IMAGES); } }; 204 const handleResetData = () => { if (isAdmin && confirm('警告:确定要重置为初始演示数据吗?')) { setImages(SHOWCASE_IMAGES); } };
301 205
302 const handleExportShowcase = () => { 206 const handleExportShowcase = () => {
303 const exportData = images.map(img => ({ ...img, isLikedByCurrentUser: false, isMock: true })); 207 const exportData = images.map(img => ({ ...img, isLikedByCurrentUser: false, isMock: true }));
304 - const fileContent = `...`; // Omitted for brevity 208 + const fileContent = `import { ImageItem } from './types';\n\nexport const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2)};`;
305 const blob = new Blob([fileContent], { type: 'text/typescript' }); 209 const blob = new Blob([fileContent], { type: 'text/typescript' });
306 const a = document.createElement('a'); 210 const a = document.createElement('a');
307 a.href = URL.createObjectURL(blob); 211 a.href = URL.createObjectURL(blob);
308 - a.download = 'constants.ts'; 212 + a.download = 'showcase_images.ts';
309 a.click(); 213 a.click();
310 }; 214 };
311 215
312 return ( 216 return (
313 <div className="min-h-screen bg-white text-gray-900 font-sans pb-40"> 217 <div className="min-h-screen bg-white text-gray-900 font-sans pb-40">
314 <AuthModal isOpen={isAuthModalOpen} onLogin={handleLogin} /> 218 <AuthModal isOpen={isAuthModalOpen} onLogin={handleLogin} />
315 -  
316 - <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">  
317 - <Sparkles size={16} />  
318 - <span className="text-sm font-bold tracking-wide">参数已同步到输入框</span>  
319 - </div>  
320 -  
321 <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"> 219 <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">
322 <div> 220 <div>
323 <h1 className="text-3xl md:text-4xl font-extrabold tracking-tight mb-2">艺云-DESIGN</h1> 221 <h1 className="text-3xl md:text-4xl font-extrabold tracking-tight mb-2">艺云-DESIGN</h1>
@@ -348,52 +246,27 @@ const App: React.FC = () => { @@ -348,52 +246,27 @@ const App: React.FC = () => {
348 </header> 246 </header>
349 247
350 <div className="px-6 md:px-12 mt-6 mb-4 flex border-b"> 248 <div className="px-6 md:px-12 mt-6 mb-4 flex border-b">
351 - <button  
352 - onClick={() => setGalleryMode(GalleryMode.Image)}  
353 - 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'}`}  
354 - >  
355 - 灵感图库  
356 - </button>  
357 - <button  
358 - onClick={() => setGalleryMode(GalleryMode.Video)}  
359 - 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'}`}  
360 - >  
361 - 视频素材  
362 - </button> 249 + <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>
  250 + <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>
363 </div> 251 </div>
364 252
365 {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>} 253 {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>}
366 254
367 <main> 255 <main>
368 - {isGenerating && ( // This is for image generation only  
369 - <div className="w-full flex justify-center py-12">  
370 - <div className="flex flex-col items-center animate-pulse">  
371 - <Loader2 className="animate-spin text-purple-600 mb-3" size={40} />  
372 - <span className="text-gray-500 font-medium">绘图引擎全力启动中...</span>  
373 - </div>  
374 - </div> 256 + {isGenerating && (
  257 + <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>
375 )} 258 )}
376 259
377 {galleryMode === GalleryMode.Image ? ( 260 {galleryMode === GalleryMode.Image ? (
378 <MasonryGrid images={sortedImages} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId}/> 261 <MasonryGrid images={sortedImages} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId}/>
379 ) : ( 262 ) : (
380 - <MasonryGrid images={videos} onImageClick={setSelectedImage} onLike={() => {}} currentUser={currentUser?.employeeId} isVideoGallery={true} /> 263 + <MasonryGrid images={sortedVideos} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId} isVideoGallery={true} />
381 )} 264 )}
382 </main> 265 </main>
383 266
384 <HistoryBar images={galleryMode === GalleryMode.Image ? userHistory : userVideoHistory} onSelect={setSelectedImage} /> 267 <HistoryBar images={galleryMode === GalleryMode.Image ? userHistory : userVideoHistory} onSelect={setSelectedImage} />
385 -  
386 <InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} videoStatus={videoStatus} /> 268 <InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} videoStatus={videoStatus} />
387 -  
388 - {selectedImage && (  
389 - <DetailModal  
390 - image={selectedImage}  
391 - onClose={() => setSelectedImage(null)}  
392 - onEdit={isAdmin ? handleOpenEditModal : undefined}  
393 - onGenerateSimilar={handleGenerateSimilar}  
394 - />  
395 - )}  
396 - 269 + {selectedImage && <DetailModal image={selectedImage} onClose={() => setSelectedImage(null)} onEdit={isAdmin ? handleOpenEditModal : undefined} onGenerateSimilar={handleGenerateSimilar}/>}
397 <AdminModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} onSave={handleSaveImage} onDelete={handleDeleteImage} initialData={editingImage} /> 270 <AdminModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} onSave={handleSaveImage} onDelete={handleDeleteImage} initialData={editingImage} />
398 <WhitelistModal isOpen={isWhitelistModalOpen} onClose={() => setIsWhitelistModalOpen(false)} /> 271 <WhitelistModal isOpen={isWhitelistModalOpen} onClose={() => setIsWhitelistModalOpen(false)} />
399 </div> 272 </div>
@@ -23,12 +23,22 @@ const HistoryBar: React.FC<HistoryBarProps> = ({ images, onSelect }) => { @@ -23,12 +23,22 @@ const HistoryBar: React.FC<HistoryBarProps> = ({ images, onSelect }) => {
23 onClick={() => onSelect(img)} 23 onClick={() => onSelect(img)}
24 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" 24 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"
25 > 25 >
  26 + {img.id.startsWith('vid-') ? (
  27 + <video
  28 + src={img.url}
  29 + className="w-full h-full object-cover"
  30 + muted
  31 + playsInline
  32 + preload="metadata"
  33 + />
  34 + ) : (
26 <img 35 <img
27 src={img.url} 36 src={img.url}
28 alt="History" 37 alt="History"
29 className="w-full h-full object-cover" 38 className="w-full h-full object-cover"
30 loading="lazy" 39 loading="lazy"
31 /> 40 />
  41 + )}
32 </button> 42 </button>
33 ))} 43 ))}
34 </div> 44 </div>
1 import { ImageItem } from './types'; 1 import { ImageItem } from './types';
2 2
3 export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009"; 3 export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009";
4 -export const TURBO_DIFFUSION_VIDEO_BASE_URL = "http://106.120.52.146:38000"; 4 +
  5 +// Base URL for the TurboDiffusion AI service (submit job, poll status)
  6 +export const TURBO_DIFFUSION_API_URL = "http://106.120.52.146:38000";
  7 +
  8 +// Base URL for the OSS service that serves the final video files
  9 +export const VIDEO_OSS_BASE_URL = "http://106.120.52.146:39997";
5 10
6 const ENV_PROXY_URL = import.meta.env?.VITE_API_BASE_URL?.trim(); 11 const ENV_PROXY_URL = import.meta.env?.VITE_API_BASE_URL?.trim();
7 const DEFAULT_PROXY_URL = ENV_PROXY_URL && ENV_PROXY_URL.length > 0 12 const DEFAULT_PROXY_URL = ENV_PROXY_URL && ENV_PROXY_URL.length > 0
@@ -10,22 +10,65 @@ export const fetchGallery = async (authorId?: string): Promise<ImageItem[]> => { @@ -10,22 +10,65 @@ export const fetchGallery = async (authorId?: string): Promise<ImageItem[]> => {
10 params.set('authorId', authorId); 10 params.set('authorId', authorId);
11 } 11 }
12 const query = params.toString(); 12 const query = params.toString();
13 - const response = await fetch(`${API_BASE_URL}/gallery${query ? `?${query}` : ''}`); 13 + // Note: The backend endpoint was changed from /gallery to /gallery/images
  14 + const response = await fetch(`${API_BASE_URL}/gallery/images${query ? `?${query}` : ''}`);
14 15
15 if (!response.ok) { 16 if (!response.ok) {
16 const errorText = await response.text(); 17 const errorText = await response.text();
17 - throw new Error(`Gallery fetch failed (${response.status}): ${errorText}`); 18 + throw new Error(`Image gallery fetch failed (${response.status}): ${errorText}`);
18 } 19 }
19 20
20 const data: GalleryResponse = await response.json(); 21 const data: GalleryResponse = await response.json();
21 return data.images ?? []; 22 return data.images ?? [];
22 }; 23 };
23 24
24 -export const toggleLike = async (imageId: string, userId: string): Promise<ImageItem> => { 25 +export const fetchVideoGallery = async (authorId?: string): Promise<ImageItem[]> => {
25 if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) { 26 if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) {
26 - throw new Error("Cannot like images in direct mode"); 27 + return [];
  28 + }
  29 + const params = new URLSearchParams();
  30 + if (authorId) {
  31 + params.set('authorId', authorId);
  32 + }
  33 + const query = params.toString();
  34 + const response = await fetch(`${API_BASE_URL}/gallery/videos${query ? `?${query}` : ''}`);
  35 +
  36 + if (!response.ok) {
  37 + const errorText = await response.text();
  38 + throw new Error(`Video gallery fetch failed (${response.status}): ${errorText}`);
  39 + }
  40 +
  41 + // Assuming the response for videos is { videos: ImageItem[] }
  42 + const data: { videos: ImageItem[] } = await response.json();
  43 + return data.videos ?? [];
  44 +};
  45 +
  46 +
  47 +export const saveVideo = async (videoData: ImageItem): Promise<ImageItem> => {
  48 + if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) {
  49 + throw new Error("Cannot save videos in direct mode");
  50 + }
  51 + const response = await fetch(`${API_BASE_URL}/gallery/videos`, {
  52 + method: 'POST',
  53 + headers: {
  54 + 'Content-Type': 'application/json',
  55 + },
  56 + body: JSON.stringify(videoData),
  57 + });
  58 +
  59 + if (!response.ok) {
  60 + const errorText = await response.text();
  61 + throw new Error(`Save video failed (${response.status}): ${errorText}`);
  62 + }
  63 + return await response.json();
  64 +};
  65 +
  66 +
  67 +export const toggleLike = async (itemId: string, userId: string): Promise<ImageItem> => {
  68 + if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) {
  69 + throw new Error("Cannot like items in direct mode");
27 } 70 }
28 - const response = await fetch(`${API_BASE_URL}/likes/${imageId}?userId=${userId}`, { 71 + const response = await fetch(`${API_BASE_URL}/likes/${itemId}?userId=${userId}`, {
29 method: 'POST', 72 method: 'POST',
30 }); 73 });
31 74
1 -import { TURBO_DIFFUSION_VIDEO_BASE_URL } from '../constants'; 1 +import { TURBO_DIFFUSION_API_URL } from '../constants';
2 import { VideoStatus } from '../types'; 2 import { VideoStatus } from '../types';
3 3
4 /** 4 /**
5 * Submits a video generation job to the backend. 5 * Submits a video generation job to the backend.
6 * @returns The task ID for the submitted job. 6 * @returns The task ID for the submitted job.
7 */ 7 */
8 -export const submitVideoJob = async (prompt: string, image: File): Promise<string> => { 8 +export const submitVideoJob = async (prompt: string, image: File, authorId: string): Promise<string> => {
9 const formData = new FormData(); 9 const formData = new FormData();
10 formData.append('prompt', prompt); 10 formData.append('prompt', prompt);
11 formData.append('image', image, image.name); 11 formData.append('image', image, image.name);
  12 + formData.append('author_id', authorId);
12 13
13 - const submitRes = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/submit-job/`, { 14 + const submitRes = await fetch(`${TURBO_DIFFUSION_API_URL}/submit-job/`, {
14 method: 'POST', 15 method: 'POST',
15 body: formData, 16 body: formData,
16 }); 17 });
@@ -37,7 +38,7 @@ export const pollVideoStatus = ( @@ -37,7 +38,7 @@ export const pollVideoStatus = (
37 return new Promise((resolve, reject) => { 38 return new Promise((resolve, reject) => {
38 const interval = setInterval(async () => { 39 const interval = setInterval(async () => {
39 try { 40 try {
40 - const res = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/status/${taskId}`); 41 + const res = await fetch(`${TURBO_DIFFUSION_API_URL}/status/${taskId}`);
41 if (!res.ok) { 42 if (!res.ok) {
42 // Stop polling on HTTP error 43 // Stop polling on HTTP error
43 clearInterval(interval); 44 clearInterval(interval);
@@ -64,11 +65,3 @@ export const pollVideoStatus = ( @@ -64,11 +65,3 @@ export const pollVideoStatus = (
64 }, 2000); // Poll every 2 seconds 65 }, 2000); // Poll every 2 seconds
65 }); 66 });
66 }; 67 };
67 -  
68 -  
69 -/**  
70 - * Gets the final URL for a completed video task.  
71 - */  
72 -export const getVideoResultUrl = (taskId: string): string => {  
73 - return `${TURBO_DIFFUSION_VIDEO_BASE_URL}/result/${taskId}`;  
74 -};  
@@ -48,4 +48,5 @@ export interface VideoStatus { @@ -48,4 +48,5 @@ export interface VideoStatus {
48 message: string; 48 message: string;
49 queue_position?: number; 49 queue_position?: number;
50 processing_time?: number; 50 processing_time?: number;
  51 + video_filename?: string; // The final filename of the video
51 } 52 }