Showing
7 changed files
with
181 additions
and
15 deletions
| @@ -3,6 +3,7 @@ import json | @@ -3,6 +3,7 @@ import json | ||
| 3 | import os | 3 | import os |
| 4 | import secrets | 4 | import secrets |
| 5 | import time | 5 | import time |
| 6 | +import fcntl | ||
| 6 | from pathlib import Path | 7 | from pathlib import Path |
| 7 | from threading import Lock, RLock | 8 | from threading import Lock, RLock |
| 8 | from typing import List, Literal, Optional | 9 | from typing import List, Literal, Optional |
| @@ -19,8 +20,60 @@ Z_IMAGE_BASE_URL = os.getenv("Z_IMAGE_BASE_URL", "http://106.120.52.146:39009"). | @@ -19,8 +20,60 @@ Z_IMAGE_BASE_URL = os.getenv("Z_IMAGE_BASE_URL", "http://106.120.52.146:39009"). | ||
| 19 | REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "120")) | 20 | REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "120")) |
| 20 | GALLERY_IMAGES_PATH = Path(os.getenv("GALLERY_IMAGES_PATH", Path(__file__).with_name("gallery_images.json"))) | 21 | 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_VIDEOS_PATH = Path(os.getenv("GALLERY_VIDEOS_PATH", Path(__file__).with_name("gallery_videos.json"))) |
| 23 | +USAGE_PATH = Path(os.getenv("USAGE_PATH", Path(__file__).with_name("usage.json"))) | ||
| 22 | GALLERY_MAX_ITEMS = int(os.getenv("GALLERY_MAX_ITEMS", "500")) | 24 | GALLERY_MAX_ITEMS = int(os.getenv("GALLERY_MAX_ITEMS", "500")) |
| 23 | WHITELIST_PATH = Path(os.getenv("WHITELIST_PATH", Path(__file__).with_name("whitelist.txt"))) | 25 | WHITELIST_PATH = Path(os.getenv("WHITELIST_PATH", Path(__file__).with_name("whitelist.txt"))) |
| 26 | +ADMIN_ID = "86427531" | ||
| 27 | + | ||
| 28 | +# --- Usage Store --- | ||
| 29 | +class UsageStore: | ||
| 30 | + def __init__(self, path: Path): | ||
| 31 | + self.path = path | ||
| 32 | + if not self.path.exists(): | ||
| 33 | + self._write({}) | ||
| 34 | + | ||
| 35 | + def _read(self) -> dict: | ||
| 36 | + try: | ||
| 37 | + if not self.path.exists(): return {} | ||
| 38 | + with self.path.open("r", encoding="utf-8") as f: | ||
| 39 | + return json.load(f) | ||
| 40 | + except (FileNotFoundError, json.JSONDecodeError): | ||
| 41 | + return {} | ||
| 42 | + | ||
| 43 | + def _write(self, data: dict): | ||
| 44 | + try: | ||
| 45 | + payload = json.dumps(data, ensure_ascii=False, indent=2) | ||
| 46 | + temp_path = self.path.with_suffix(".tmp_proxy") | ||
| 47 | + with temp_path.open("w", encoding="utf-8") as f: | ||
| 48 | + f.write(payload) | ||
| 49 | + temp_path.replace(self.path) | ||
| 50 | + except Exception as e: | ||
| 51 | + logger.error(f"Failed to write usage: {e}") | ||
| 52 | + | ||
| 53 | + def get_usage(self, user_id: str) -> dict: | ||
| 54 | + data = self._read() | ||
| 55 | + import datetime | ||
| 56 | + today = datetime.date.today().isoformat() | ||
| 57 | + user_data = data.get(user_id, {"daily_used": 0, "bonus_count": 0, "last_reset": today}) | ||
| 58 | + | ||
| 59 | + if user_data.get("last_reset") != today: | ||
| 60 | + user_data["daily_used"] = 0 | ||
| 61 | + user_data["last_reset"] = today | ||
| 62 | + return user_data | ||
| 63 | + | ||
| 64 | + def update_bonus(self, user_id: str, delta: int): | ||
| 65 | + data = self._read() | ||
| 66 | + import datetime | ||
| 67 | + today = datetime.date.today().isoformat() | ||
| 68 | + user_data = data.get(user_id, {"daily_used": 0, "bonus_count": 0, "last_reset": today}) | ||
| 69 | + | ||
| 70 | + if user_data.get("last_reset") != today: | ||
| 71 | + user_data["daily_used"] = 0 | ||
| 72 | + user_data["last_reset"] = today | ||
| 73 | + | ||
| 74 | + user_data["bonus_count"] = max(0, user_data.get("bonus_count", 0) + delta) | ||
| 75 | + data[user_id] = user_data | ||
| 76 | + self._write(data) | ||
| 24 | 77 | ||
| 25 | # --- Pydantic Models --- | 78 | # --- Pydantic Models --- |
| 26 | # Define dependent models first to avoid forward reference issues. | 79 | # Define dependent models first to avoid forward reference issues. |
| @@ -163,17 +216,20 @@ class JsonStore: | @@ -163,17 +216,20 @@ class JsonStore: | ||
| 163 | image_store = JsonStore(GALLERY_IMAGES_PATH, item_key="images", max_items=GALLERY_MAX_ITEMS) | 216 | image_store = JsonStore(GALLERY_IMAGES_PATH, item_key="images", max_items=GALLERY_MAX_ITEMS) |
| 164 | video_store = JsonStore(GALLERY_VIDEOS_PATH, item_key="videos", max_items=GALLERY_MAX_ITEMS) | 217 | video_store = JsonStore(GALLERY_VIDEOS_PATH, item_key="videos", max_items=GALLERY_MAX_ITEMS) |
| 165 | whitelist_store = WhitelistStore(WHITELIST_PATH) | 218 | whitelist_store = WhitelistStore(WHITELIST_PATH) |
| 219 | +usage_store = UsageStore(USAGE_PATH) | ||
| 166 | 220 | ||
| 167 | # --- App Setup --- | 221 | # --- App Setup --- |
| 168 | app = FastAPI(title="Z-Image Proxy", version="1.0.0") | 222 | app = FastAPI(title="Z-Image Proxy", version="1.0.0") |
| 169 | app.add_middleware( | 223 | app.add_middleware( |
| 170 | CORSMiddleware, | 224 | CORSMiddleware, |
| 171 | - allow_origins=["http://106.120.52.146:37001"], # Explicitly allow the frontend origin | 225 | + allow_origins=["*"], |
| 172 | allow_credentials=True, | 226 | allow_credentials=True, |
| 173 | allow_methods=["*"], | 227 | allow_methods=["*"], |
| 174 | allow_headers=["*"], | 228 | allow_headers=["*"], |
| 229 | + expose_headers=["*"] | ||
| 175 | ) | 230 | ) |
| 176 | @app.on_event("startup") | 231 | @app.on_event("startup") |
| 232 | + | ||
| 177 | async def startup(): app.state.http = httpx.AsyncClient(timeout=httpx.Timeout(REQUEST_TIMEOUT_SECONDS, connect=5.0)) | 233 | async def startup(): app.state.http = httpx.AsyncClient(timeout=httpx.Timeout(REQUEST_TIMEOUT_SECONDS, connect=5.0)) |
| 178 | @app.on_event("shutdown") | 234 | @app.on_event("shutdown") |
| 179 | async def shutdown(): await app.state.http.aclose() | 235 | async def shutdown(): await app.state.http.aclose() |
| @@ -188,12 +244,52 @@ async def login(user_id: str = Query(..., alias="userId")): | @@ -188,12 +244,52 @@ async def login(user_id: str = Query(..., alias="userId")): | ||
| 188 | 244 | ||
| 189 | @app.post("/likes/{item_id}") | 245 | @app.post("/likes/{item_id}") |
| 190 | async def toggle_like(item_id: str, user_id: str = Query(..., alias="userId")): | 246 | async def toggle_like(item_id: str, user_id: str = Query(..., alias="userId")): |
| 247 | + is_liked_before = False | ||
| 248 | + items = image_store.list_items() | ||
| 249 | + target_item = next((i for i in items if i.get("id") == item_id), None) | ||
| 250 | + if target_item: | ||
| 251 | + is_liked_before = user_id in target_item.get("likedBy", []) | ||
| 252 | + | ||
| 191 | updated_item = image_store.toggle_like(item_id, user_id) | 253 | updated_item = image_store.toggle_like(item_id, user_id) |
| 254 | + if updated_item: | ||
| 255 | + is_liked_after = user_id in updated_item.get("likedBy", []) | ||
| 256 | + if is_liked_after and not is_liked_before: | ||
| 257 | + usage_store.update_bonus(user_id, 1) | ||
| 258 | + elif not is_liked_after and is_liked_before: | ||
| 259 | + usage_store.update_bonus(user_id, -1) | ||
| 260 | + return updated_item | ||
| 261 | + | ||
| 262 | + updated_item = video_store.toggle_like(item_id, user_id) | ||
| 192 | if updated_item: return updated_item | 263 | if updated_item: return updated_item |
| 264 | + raise HTTPException(status_code=404, detail="Item not found") | ||
| 265 | + | ||
| 193 | updated_item = video_store.toggle_like(item_id, user_id) | 266 | updated_item = video_store.toggle_like(item_id, user_id) |
| 194 | if updated_item: return updated_item | 267 | if updated_item: return updated_item |
| 195 | raise HTTPException(status_code=404, detail="Item not found") | 268 | raise HTTPException(status_code=404, detail="Item not found") |
| 196 | 269 | ||
| 270 | +@app.get("/usage/{user_id}") | ||
| 271 | +async def get_user_usage(user_id: str): | ||
| 272 | + try: | ||
| 273 | + usage = usage_store.get_usage(user_id) | ||
| 274 | + is_admin = user_id == ADMIN_ID | ||
| 275 | + remaining = (2 - usage["daily_used"]) + usage["bonus_count"] if not is_admin else 999999 | ||
| 276 | + return { | ||
| 277 | + "daily_used": usage["daily_used"], | ||
| 278 | + "bonus_count": usage["bonus_count"], | ||
| 279 | + "base_limit": 2, | ||
| 280 | + "remaining": max(0, remaining), | ||
| 281 | + "is_admin": is_admin | ||
| 282 | + } | ||
| 283 | + except Exception as e: | ||
| 284 | + logger.error(f"Error getting usage for {user_id}: {e}") | ||
| 285 | + return { | ||
| 286 | + "daily_used": 0, | ||
| 287 | + "bonus_count": 0, | ||
| 288 | + "base_limit": 2, | ||
| 289 | + "remaining": 2, | ||
| 290 | + "is_admin": user_id == ADMIN_ID | ||
| 291 | + } | ||
| 292 | + | ||
| 197 | @app.get("/gallery/images") | 293 | @app.get("/gallery/images") |
| 198 | async def gallery_images(limit: int = Query(200, ge=1, le=1000), author_id: Optional[str] = Query(None, alias="authorId")): | 294 | async def gallery_images(limit: int = Query(200, ge=1, le=1000), author_id: Optional[str] = Query(None, alias="authorId")): |
| 199 | items = image_store.list_items() | 295 | items = image_store.list_items() |
backend/usage.json
0 → 100644
| 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, UserUsage } from './types'; |
| 3 | import { SHOWCASE_IMAGES, ADMIN_ID, VIDEO_OSS_BASE_URL } 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 } from './services/videoService'; | 5 | import { submitVideoJob, pollVideoStatus } from './services/videoService'; |
| 6 | -import { fetchGallery, fetchVideoGallery, saveVideo, toggleLike, deleteVideo } from './services/galleryService'; | 6 | +import { fetchGallery, fetchVideoGallery, saveVideo, toggleLike, deleteVideo, fetchUsage } 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'; |
| @@ -29,8 +29,10 @@ const App: React.FC = () => { | @@ -29,8 +29,10 @@ const App: React.FC = () => { | ||
| 29 | const [images, setImages] = useState<ImageItem[]>(SHOWCASE_IMAGES); | 29 | const [images, setImages] = useState<ImageItem[]>(SHOWCASE_IMAGES); |
| 30 | const [videos, setVideos] = useState<ImageItem[]>([]); | 30 | const [videos, setVideos] = useState<ImageItem[]>([]); |
| 31 | const [isGenerating, setIsGenerating] = useState(false); | 31 | const [isGenerating, setIsGenerating] = useState(false); |
| 32 | + const [isGeneratingVideo, setIsGeneratingVideo] = useState(false); | ||
| 32 | const [videoStatus, setVideoStatus] = useState<VideoStatus | null>(null); | 33 | const [videoStatus, setVideoStatus] = useState<VideoStatus | null>(null); |
| 33 | const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null); | 34 | const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null); |
| 35 | + const [userUsage, setUserUsage] = useState<UserUsage | null>(null); | ||
| 34 | const [error, setError] = useState<string | null>(null); | 36 | const [error, setError] = useState<string | null>(null); |
| 35 | const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null); | 37 | const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null); |
| 36 | const [isAdminModalOpen, setIsAdminModalOpen] = useState(false); | 38 | const [isAdminModalOpen, setIsAdminModalOpen] = useState(false); |
| @@ -38,7 +40,6 @@ const App: React.FC = () => { | @@ -38,7 +40,6 @@ const App: React.FC = () => { | ||
| 38 | const [editingImage, setEditingImage] = useState<ImageItem | null>(null); | 40 | const [editingImage, setEditingImage] = useState<ImageItem | null>(null); |
| 39 | 41 | ||
| 40 | const isAdmin = currentUser?.employeeId === ADMIN_ID; | 42 | const isAdmin = currentUser?.employeeId === ADMIN_ID; |
| 41 | - const isGeneratingVideo = videoStatus !== null; | ||
| 42 | 43 | ||
| 43 | const sortedImages = useMemo(() => [...images].sort((a, b) => (b.likes || 0) - (a.likes || 0)), [images]); | 44 | 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]); | 45 | const sortedVideos = useMemo(() => [...videos].sort((a, b) => b.createdAt - a.createdAt), [videos]); |
| @@ -81,15 +82,25 @@ const App: React.FC = () => { | @@ -81,15 +82,25 @@ const App: React.FC = () => { | ||
| 81 | } catch (err) { console.error("Failed to sync video gallery", err); } | 82 | } catch (err) { console.error("Failed to sync video gallery", err); } |
| 82 | }, [currentUser]); | 83 | }, [currentUser]); |
| 83 | 84 | ||
| 85 | + const syncUsage = useCallback(async () => { | ||
| 86 | + if (!currentUser) return; | ||
| 87 | + try { | ||
| 88 | + const usage = await fetchUsage(currentUser.employeeId); | ||
| 89 | + setUserUsage(usage); | ||
| 90 | + } catch (err) { console.error("Failed to sync usage", err); } | ||
| 91 | + }, [currentUser]); | ||
| 92 | + | ||
| 84 | useEffect(() => { | 93 | useEffect(() => { |
| 85 | syncImageGallery(); | 94 | syncImageGallery(); |
| 86 | syncVideoGallery(); | 95 | syncVideoGallery(); |
| 96 | + syncUsage(); | ||
| 87 | const interval = setInterval(() => { | 97 | const interval = setInterval(() => { |
| 88 | syncImageGallery(); | 98 | syncImageGallery(); |
| 89 | syncVideoGallery(); | 99 | syncVideoGallery(); |
| 100 | + syncUsage(); | ||
| 90 | }, 30000); | 101 | }, 30000); |
| 91 | return () => clearInterval(interval); | 102 | return () => clearInterval(interval); |
| 92 | - }, [syncImageGallery, syncVideoGallery]); | 103 | + }, [syncImageGallery, syncVideoGallery, syncUsage]); |
| 93 | 104 | ||
| 94 | 105 | ||
| 95 | // --- Handlers --- | 106 | // --- Handlers --- |
| @@ -110,7 +121,14 @@ const App: React.FC = () => { | @@ -110,7 +121,14 @@ const App: React.FC = () => { | ||
| 110 | 121 | ||
| 111 | const handleGenerateVideo = async (params: ImageGenerationParams, imageFile: File) => { | 122 | const handleGenerateVideo = async (params: ImageGenerationParams, imageFile: File) => { |
| 112 | if (!currentUser) { setIsAuthModalOpen(true); return; } | 123 | if (!currentUser) { setIsAuthModalOpen(true); return; } |
| 124 | + | ||
| 125 | + if (!isAdmin && userUsage && userUsage.remaining <= 0) { | ||
| 126 | + alert("今日剩余次数不足,请前往灵感图库点赞获取生成次数"); | ||
| 127 | + return; | ||
| 128 | + } | ||
| 129 | + | ||
| 113 | setError(null); | 130 | setError(null); |
| 131 | + setIsGeneratingVideo(true); | ||
| 114 | setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' }); | 132 | setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' }); |
| 115 | 133 | ||
| 116 | try { | 134 | try { |
| @@ -137,12 +155,17 @@ const App: React.FC = () => { | @@ -137,12 +155,17 @@ const App: React.FC = () => { | ||
| 137 | 155 | ||
| 138 | const savedVideo = await saveVideo(newVideoData); | 156 | const savedVideo = await saveVideo(newVideoData); |
| 139 | setVideos(prev => [savedVideo, ...prev]); | 157 | setVideos(prev => [savedVideo, ...prev]); |
| 158 | + syncUsage(); | ||
| 140 | 159 | ||
| 141 | - setTimeout(() => setVideoStatus(null), 3000); | 160 | + setTimeout(() => { |
| 161 | + setVideoStatus(null); | ||
| 162 | + setIsGeneratingVideo(false); | ||
| 163 | + }, 3000); | ||
| 142 | } catch (err: any) { | 164 | } catch (err: any) { |
| 143 | console.error(err); | 165 | console.error(err); |
| 144 | setError("视频生成失败。请确保视频生成服务正常运行。"); | 166 | setError("视频生成失败。请确保视频生成服务正常运行。"); |
| 145 | setVideoStatus(null); | 167 | setVideoStatus(null); |
| 168 | + setIsGeneratingVideo(false); | ||
| 146 | } | 169 | } |
| 147 | }; | 170 | }; |
| 148 | 171 | ||
| @@ -185,6 +208,7 @@ const App: React.FC = () => { | @@ -185,6 +208,7 @@ const App: React.FC = () => { | ||
| 185 | 208 | ||
| 186 | try { | 209 | try { |
| 187 | await toggleLike(item.id, currentUser.employeeId); | 210 | await toggleLike(item.id, currentUser.employeeId); |
| 211 | + if (!isVideo) syncUsage(); | ||
| 188 | } catch (e) { | 212 | } catch (e) { |
| 189 | console.error("Like failed", e); | 213 | console.error("Like failed", e); |
| 190 | if (isVideo) syncVideoGallery(); else syncImageGallery(); | 214 | if (isVideo) syncVideoGallery(); else syncImageGallery(); |
| @@ -246,8 +270,15 @@ const App: React.FC = () => { | @@ -246,8 +270,15 @@ const App: React.FC = () => { | ||
| 246 | <span className={`text-xs uppercase tracking-wider px-2 py-0.5 rounded font-bold ${isAdmin ? 'text-purple-600 bg-purple-50' : 'text-gray-400 bg-gray-50'}`}> | 270 | <span className={`text-xs uppercase tracking-wider px-2 py-0.5 rounded font-bold ${isAdmin ? 'text-purple-600 bg-purple-50' : 'text-gray-400 bg-gray-50'}`}> |
| 247 | {isAdmin ? 'Administrator' : '设计师'} | 271 | {isAdmin ? 'Administrator' : '设计师'} |
| 248 | </span> | 272 | </span> |
| 273 | + <div className="flex items-center gap-2"> | ||
| 274 | + {userUsage && !isAdmin && ( | ||
| 275 | + <span className="text-[10px] bg-purple-100 text-purple-600 px-1.5 py-0.5 rounded font-bold"> | ||
| 276 | + 视频剩余: {userUsage.remaining} | ||
| 277 | + </span> | ||
| 278 | + )} | ||
| 249 | <span className="font-mono font-bold text-gray-800">{currentUser.employeeId}</span> | 279 | <span className="font-mono font-bold text-gray-800">{currentUser.employeeId}</span> |
| 250 | </div> | 280 | </div> |
| 281 | + </div> | ||
| 251 | )} | 282 | )} |
| 252 | 283 | ||
| 253 | {isAdmin && ( | 284 | {isAdmin && ( |
| @@ -283,7 +314,7 @@ const App: React.FC = () => { | @@ -283,7 +314,7 @@ const App: React.FC = () => { | ||
| 283 | </main> | 314 | </main> |
| 284 | 315 | ||
| 285 | <HistoryBar images={galleryMode === GalleryMode.Image ? userHistory : userVideoHistory} onSelect={setSelectedImage} /> | 316 | <HistoryBar images={galleryMode === GalleryMode.Image ? userHistory : userVideoHistory} onSelect={setSelectedImage} /> |
| 286 | - <InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} videoStatus={videoStatus} /> | 317 | + <InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} videoStatus={videoStatus} userUsage={userUsage} /> |
| 287 | {selectedImage && <DetailModal image={selectedImage} onClose={() => setSelectedImage(null)} onEdit={isAdmin ? handleOpenEditModal : undefined} onGenerateSimilar={handleGenerateSimilar} onDelete={handleDeleteVideo} currentUser={currentUser} />} | 318 | {selectedImage && <DetailModal image={selectedImage} onClose={() => setSelectedImage(null)} onEdit={isAdmin ? handleOpenEditModal : undefined} onGenerateSimilar={handleGenerateSimilar} onDelete={handleDeleteVideo} currentUser={currentUser} />} |
| 288 | <AdminModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} onSave={handleSaveImage} onDelete={handleDeleteImage} initialData={editingImage} /> | 319 | <AdminModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} onSave={handleSaveImage} onDelete={handleDeleteImage} initialData={editingImage} /> |
| 289 | <WhitelistModal isOpen={isWhitelistModalOpen} onClose={() => setIsWhitelistModalOpen(false)} /> | 320 | <WhitelistModal isOpen={isWhitelistModalOpen} onClose={() => setIsWhitelistModalOpen(false)} /> |
| 1 | import React, { useState, useEffect, KeyboardEvent, useRef } from 'react'; | 1 | import React, { useState, useEffect, KeyboardEvent, useRef } from 'react'; |
| 2 | import { Loader2, ArrowUp, Sliders, Dices, X, RefreshCw, Image as ImageIcon, Hourglass } from 'lucide-react'; | 2 | import { Loader2, ArrowUp, Sliders, Dices, X, RefreshCw, Image as ImageIcon, Hourglass } from 'lucide-react'; |
| 3 | -import { ImageGenerationParams, VideoStatus } from '../types'; | 3 | +import { ImageGenerationParams, VideoStatus, UserUsage } from '../types'; |
| 4 | 4 | ||
| 5 | interface InputBarProps { | 5 | interface InputBarProps { |
| 6 | onGenerate: (params: ImageGenerationParams, imageFile?: File) => void; | 6 | onGenerate: (params: ImageGenerationParams, imageFile?: File) => void; |
| 7 | isGenerating: boolean; | 7 | isGenerating: boolean; |
| 8 | incomingParams?: ImageGenerationParams | null; | 8 | incomingParams?: ImageGenerationParams | null; |
| 9 | isVideoMode: boolean; | 9 | isVideoMode: boolean; |
| 10 | - videoStatus?: VideoStatus | null; // New prop | 10 | + videoStatus?: VideoStatus | null; |
| 11 | + userUsage?: UserUsage | null; | ||
| 11 | } | 12 | } |
| 12 | 13 | ||
| 13 | const ASPECT_RATIOS = [ | 14 | const ASPECT_RATIOS = [ |
| @@ -19,7 +20,7 @@ const ASPECT_RATIOS = [ | @@ -19,7 +20,7 @@ const ASPECT_RATIOS = [ | ||
| 19 | { label: 'Custom', w: 0, h: 0 }, | 20 | { label: 'Custom', w: 0, h: 0 }, |
| 20 | ]; | 21 | ]; |
| 21 | 22 | ||
| 22 | -const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams, isVideoMode, videoStatus }) => { | 23 | +const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams, isVideoMode, videoStatus, userUsage }) => { |
| 23 | const [prompt, setPrompt] = useState(''); | 24 | const [prompt, setPrompt] = useState(''); |
| 24 | const [showSettings, setShowSettings] = useState(false); | 25 | const [showSettings, setShowSettings] = useState(false); |
| 25 | const [isSubmittingLocal, setIsSubmittingLocal] = useState(false); // New state for local submission status | 26 | const [isSubmittingLocal, setIsSubmittingLocal] = useState(false); // New state for local submission status |
| @@ -252,7 +253,11 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP | @@ -252,7 +253,11 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP | ||
| 252 | <div className="pointer-events-auto w-full max-w-2xl transition-all duration-300 ease-in-out transform hover:scale-[1.01]"> | 253 | <div className="pointer-events-auto w-full max-w-2xl transition-all duration-300 ease-in-out transform hover:scale-[1.01]"> |
| 253 | {videoStatus && ( // Display video status above the input bar | 254 | {videoStatus && ( // Display video status above the input bar |
| 254 | <div className="absolute -top-16 left-1/2 -translate-x-1/2 bg-white/90 dark:bg-gray-800/90 backdrop-blur-md px-4 py-2 rounded-xl shadow-lg flex items-center gap-2 z-10 animate-fade-in-up"> | 255 | <div className="absolute -top-16 left-1/2 -translate-x-1/2 bg-white/90 dark:bg-gray-800/90 backdrop-blur-md px-4 py-2 rounded-xl shadow-lg flex items-center gap-2 z-10 animate-fade-in-up"> |
| 256 | + {videoStatus.status === 'complete' ? ( | ||
| 257 | + <div className="text-green-500 font-bold">✓</div> | ||
| 258 | + ) : ( | ||
| 255 | <Hourglass size={20} className="text-purple-500 animate-spin" /> | 259 | <Hourglass size={20} className="text-purple-500 animate-spin" /> |
| 260 | + )} | ||
| 256 | <span className="text-gray-800 dark:text-white font-medium text-sm"> | 261 | <span className="text-gray-800 dark:text-white font-medium text-sm"> |
| 257 | {videoStatus.status === 'submitting' && '提交请求中...'} | 262 | {videoStatus.status === 'submitting' && '提交请求中...'} |
| 258 | {videoStatus.status === 'queued' && `排队中... (第 ${videoStatus.queue_position || '?'} 位)`} | 263 | {videoStatus.status === 'queued' && `排队中... (第 ${videoStatus.queue_position || '?'} 位)`} |
| @@ -262,6 +267,12 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP | @@ -262,6 +267,12 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP | ||
| 262 | </div> | 267 | </div> |
| 263 | )} | 268 | )} |
| 264 | <div className="relative group"> | 269 | <div className="relative group"> |
| 270 | + {isVideoMode && userUsage && !userUsage.is_admin && ( | ||
| 271 | + <div className="absolute -top-8 right-6 bg-purple-600/90 backdrop-blur-md text-white text-[10px] px-2 py-1 rounded-lg font-bold shadow-lg animate-fade-in flex items-center gap-1 border border-white/20"> | ||
| 272 | + <RefreshCw size={10} className="animate-spin-slow" /> | ||
| 273 | + 今日剩余次数: {userUsage.remaining} | ||
| 274 | + </div> | ||
| 275 | + )} | ||
| 265 | <div className="absolute inset-0 bg-white/80 dark:bg-black/80 backdrop-blur-2xl rounded-full shadow-2xl border border-white/20 dark:border-white/10" /> | 276 | <div className="absolute inset-0 bg-white/80 dark:bg-black/80 backdrop-blur-2xl rounded-full shadow-2xl border border-white/20 dark:border-white/10" /> |
| 266 | 277 | ||
| 267 | <div className="relative flex items-center p-2 pr-2"> | 278 | <div className="relative flex items-center p-2 pr-2"> |
| @@ -11,13 +11,12 @@ export const VIDEO_OSS_BASE_URL = "http://106.120.52.146:39997"; | @@ -11,13 +11,12 @@ export const VIDEO_OSS_BASE_URL = "http://106.120.52.146:39997"; | ||
| 11 | 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(); |
| 12 | 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 |
| 13 | ? ENV_PROXY_URL | 13 | ? ENV_PROXY_URL |
| 14 | - : "http://localhost:9009"; | 14 | + : "http://106.120.52.146:37000"; |
| 15 | 15 | ||
| 16 | export const API_BASE_URL = DEFAULT_PROXY_URL; | 16 | export const API_BASE_URL = DEFAULT_PROXY_URL; |
| 17 | 17 | ||
| 18 | -export const API_ENDPOINTS = API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL | ||
| 19 | - ? [Z_IMAGE_DIRECT_BASE_URL] | ||
| 20 | - : [API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL]; | 18 | +export const API_ENDPOINTS = [API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL]; |
| 19 | + | ||
| 21 | 20 | ||
| 22 | // This is the specific Administrator ID requested | 21 | // This is the specific Administrator ID requested |
| 23 | export const ADMIN_ID = '86427531'; | 22 | export const ADMIN_ID = '86427531'; |
| 1 | import { API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL } from '../constants'; | 1 | import { API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL } from '../constants'; |
| 2 | -import { GalleryResponse, ImageItem } from '../types'; | 2 | +import { GalleryResponse, ImageItem, UserUsage } from '../types'; |
| 3 | + | ||
| 4 | +export const fetchUsage = async (userId: string): Promise<UserUsage> => { | ||
| 5 | + const response = await fetch(`${API_BASE_URL}/usage/${userId}`); | ||
| 6 | + if (!response.ok) { | ||
| 7 | + throw new Error('Failed to fetch usage'); | ||
| 8 | + } | ||
| 9 | + return await response.json(); | ||
| 10 | +}; | ||
| 3 | 11 | ||
| 4 | export const fetchGallery = async (authorId?: string): Promise<ImageItem[]> => { | 12 | export const fetchGallery = async (authorId?: string): Promise<ImageItem[]> => { |
| 5 | if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) { | 13 | if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) { |
| @@ -50,3 +50,12 @@ export interface VideoStatus { | @@ -50,3 +50,12 @@ export interface VideoStatus { | ||
| 50 | processing_time?: number; | 50 | processing_time?: number; |
| 51 | video_filename?: string; // The final filename of the video | 51 | video_filename?: string; // The final filename of the video |
| 52 | } | 52 | } |
| 53 | + | ||
| 54 | +export interface UserUsage { | ||
| 55 | + daily_used: number; | ||
| 56 | + bonus_count: number; | ||
| 57 | + base_limit: number; | ||
| 58 | + total_limit: number; | ||
| 59 | + remaining: number; | ||
| 60 | + is_admin: boolean; | ||
| 61 | +} |
-
Please register or login to post a comment