ly0303521

添加视频生成限制次数,通过集赞给予视频生成次数奖励

... ... @@ -3,6 +3,7 @@ import json
import os
import secrets
import time
import fcntl
from pathlib import Path
from threading import Lock, RLock
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").
REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "120"))
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")))
USAGE_PATH = Path(os.getenv("USAGE_PATH", Path(__file__).with_name("usage.json")))
GALLERY_MAX_ITEMS = int(os.getenv("GALLERY_MAX_ITEMS", "500"))
WHITELIST_PATH = Path(os.getenv("WHITELIST_PATH", Path(__file__).with_name("whitelist.txt")))
ADMIN_ID = "86427531"
# --- Usage Store ---
class UsageStore:
def __init__(self, path: Path):
self.path = path
if not self.path.exists():
self._write({})
def _read(self) -> dict:
try:
if not self.path.exists(): return {}
with self.path.open("r", encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _write(self, data: dict):
try:
payload = json.dumps(data, ensure_ascii=False, indent=2)
temp_path = self.path.with_suffix(".tmp_proxy")
with temp_path.open("w", encoding="utf-8") as f:
f.write(payload)
temp_path.replace(self.path)
except Exception as e:
logger.error(f"Failed to write usage: {e}")
def get_usage(self, user_id: str) -> dict:
data = self._read()
import datetime
today = datetime.date.today().isoformat()
user_data = data.get(user_id, {"daily_used": 0, "bonus_count": 0, "last_reset": today})
if user_data.get("last_reset") != today:
user_data["daily_used"] = 0
user_data["last_reset"] = today
return user_data
def update_bonus(self, user_id: str, delta: int):
data = self._read()
import datetime
today = datetime.date.today().isoformat()
user_data = data.get(user_id, {"daily_used": 0, "bonus_count": 0, "last_reset": today})
if user_data.get("last_reset") != today:
user_data["daily_used"] = 0
user_data["last_reset"] = today
user_data["bonus_count"] = max(0, user_data.get("bonus_count", 0) + delta)
data[user_id] = user_data
self._write(data)
# --- Pydantic Models ---
# Define dependent models first to avoid forward reference issues.
... ... @@ -163,17 +216,20 @@ class JsonStore:
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)
usage_store = UsageStore(USAGE_PATH)
# --- App Setup ---
app = FastAPI(title="Z-Image Proxy", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://106.120.52.146:37001"], # Explicitly allow the frontend origin
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"]
)
@app.on_event("startup")
async def startup(): app.state.http = httpx.AsyncClient(timeout=httpx.Timeout(REQUEST_TIMEOUT_SECONDS, connect=5.0))
@app.on_event("shutdown")
async def shutdown(): await app.state.http.aclose()
... ... @@ -188,12 +244,52 @@ async def login(user_id: str = Query(..., alias="userId")):
@app.post("/likes/{item_id}")
async def toggle_like(item_id: str, user_id: str = Query(..., alias="userId")):
is_liked_before = False
items = image_store.list_items()
target_item = next((i for i in items if i.get("id") == item_id), None)
if target_item:
is_liked_before = user_id in target_item.get("likedBy", [])
updated_item = image_store.toggle_like(item_id, user_id)
if updated_item:
is_liked_after = user_id in updated_item.get("likedBy", [])
if is_liked_after and not is_liked_before:
usage_store.update_bonus(user_id, 1)
elif not is_liked_after and is_liked_before:
usage_store.update_bonus(user_id, -1)
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")
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("/usage/{user_id}")
async def get_user_usage(user_id: str):
try:
usage = usage_store.get_usage(user_id)
is_admin = user_id == ADMIN_ID
remaining = (2 - usage["daily_used"]) + usage["bonus_count"] if not is_admin else 999999
return {
"daily_used": usage["daily_used"],
"bonus_count": usage["bonus_count"],
"base_limit": 2,
"remaining": max(0, remaining),
"is_admin": is_admin
}
except Exception as e:
logger.error(f"Error getting usage for {user_id}: {e}")
return {
"daily_used": 0,
"bonus_count": 0,
"base_limit": 2,
"remaining": 2,
"is_admin": user_id == ADMIN_ID
}
@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()
... ...
{
"10773758": {
"daily_used": 4,
"bonus_count": 7,
"last_reset": "2026-01-20"
},
"11110000": {
"daily_used": 2,
"bonus_count": 0,
"last_reset": "2026-01-20"
}
}
\ No newline at end of file
... ...
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { ImageItem, ImageGenerationParams, UserProfile, VideoStatus } from './types';
import { ImageItem, ImageGenerationParams, UserProfile, VideoStatus, UserUsage } from './types';
import { SHOWCASE_IMAGES, ADMIN_ID, VIDEO_OSS_BASE_URL } from './constants';
import { generateImage } from './services/imageService';
import { submitVideoJob, pollVideoStatus } from './services/videoService';
import { fetchGallery, fetchVideoGallery, saveVideo, toggleLike, deleteVideo } from './services/galleryService';
import { fetchGallery, fetchVideoGallery, saveVideo, toggleLike, deleteVideo, fetchUsage } from './services/galleryService';
import MasonryGrid from './components/MasonryGrid';
import InputBar from './components/InputBar';
import HistoryBar from './components/HistoryBar';
... ... @@ -29,8 +29,10 @@ const App: React.FC = () => {
const [images, setImages] = useState<ImageItem[]>(SHOWCASE_IMAGES);
const [videos, setVideos] = useState<ImageItem[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [videoStatus, setVideoStatus] = useState<VideoStatus | null>(null);
const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null);
const [userUsage, setUserUsage] = useState<UserUsage | null>(null);
const [error, setError] = useState<string | null>(null);
const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null);
const [isAdminModalOpen, setIsAdminModalOpen] = useState(false);
... ... @@ -38,7 +40,6 @@ const App: React.FC = () => {
const [editingImage, setEditingImage] = useState<ImageItem | null>(null);
const isAdmin = currentUser?.employeeId === ADMIN_ID;
const isGeneratingVideo = videoStatus !== null;
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]);
... ... @@ -81,15 +82,25 @@ const App: React.FC = () => {
} catch (err) { console.error("Failed to sync video gallery", err); }
}, [currentUser]);
const syncUsage = useCallback(async () => {
if (!currentUser) return;
try {
const usage = await fetchUsage(currentUser.employeeId);
setUserUsage(usage);
} catch (err) { console.error("Failed to sync usage", err); }
}, [currentUser]);
useEffect(() => {
syncImageGallery();
syncVideoGallery();
syncUsage();
const interval = setInterval(() => {
syncImageGallery();
syncVideoGallery();
syncUsage();
}, 30000);
return () => clearInterval(interval);
}, [syncImageGallery, syncVideoGallery]);
}, [syncImageGallery, syncVideoGallery, syncUsage]);
// --- Handlers ---
... ... @@ -110,7 +121,14 @@ const App: React.FC = () => {
const handleGenerateVideo = async (params: ImageGenerationParams, imageFile: File) => {
if (!currentUser) { setIsAuthModalOpen(true); return; }
if (!isAdmin && userUsage && userUsage.remaining <= 0) {
alert("今日剩余次数不足,请前往灵感图库点赞获取生成次数");
return;
}
setError(null);
setIsGeneratingVideo(true);
setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' });
try {
... ... @@ -137,12 +155,17 @@ const App: React.FC = () => {
const savedVideo = await saveVideo(newVideoData);
setVideos(prev => [savedVideo, ...prev]);
syncUsage();
setTimeout(() => setVideoStatus(null), 3000);
setTimeout(() => {
setVideoStatus(null);
setIsGeneratingVideo(false);
}, 3000);
} catch (err: any) {
console.error(err);
setError("视频生成失败。请确保视频生成服务正常运行。");
setVideoStatus(null);
setIsGeneratingVideo(false);
}
};
... ... @@ -185,6 +208,7 @@ const App: React.FC = () => {
try {
await toggleLike(item.id, currentUser.employeeId);
if (!isVideo) syncUsage();
} catch (e) {
console.error("Like failed", e);
if (isVideo) syncVideoGallery(); else syncImageGallery();
... ... @@ -246,7 +270,14 @@ const App: React.FC = () => {
<span className={`text-xs uppercase tracking-wider px-2 py-0.5 rounded font-bold ${isAdmin ? 'text-purple-600 bg-purple-50' : 'text-gray-400 bg-gray-50'}`}>
{isAdmin ? 'Administrator' : '设计师'}
</span>
<span className="font-mono font-bold text-gray-800">{currentUser.employeeId}</span>
<div className="flex items-center gap-2">
{userUsage && !isAdmin && (
<span className="text-[10px] bg-purple-100 text-purple-600 px-1.5 py-0.5 rounded font-bold">
视频剩余: {userUsage.remaining}
</span>
)}
<span className="font-mono font-bold text-gray-800">{currentUser.employeeId}</span>
</div>
</div>
)}
... ... @@ -283,7 +314,7 @@ const App: React.FC = () => {
</main>
<HistoryBar images={galleryMode === GalleryMode.Image ? userHistory : userVideoHistory} onSelect={setSelectedImage} />
<InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} videoStatus={videoStatus} />
<InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} videoStatus={videoStatus} userUsage={userUsage} />
{selectedImage && <DetailModal image={selectedImage} onClose={() => setSelectedImage(null)} onEdit={isAdmin ? handleOpenEditModal : undefined} onGenerateSimilar={handleGenerateSimilar} onDelete={handleDeleteVideo} currentUser={currentUser} />}
<AdminModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} onSave={handleSaveImage} onDelete={handleDeleteImage} initialData={editingImage} />
<WhitelistModal isOpen={isWhitelistModalOpen} onClose={() => setIsWhitelistModalOpen(false)} />
... ...
import React, { useState, useEffect, KeyboardEvent, useRef } from 'react';
import { Loader2, ArrowUp, Sliders, Dices, X, RefreshCw, Image as ImageIcon, Hourglass } from 'lucide-react';
import { ImageGenerationParams, VideoStatus } from '../types';
import { ImageGenerationParams, VideoStatus, UserUsage } from '../types';
interface InputBarProps {
onGenerate: (params: ImageGenerationParams, imageFile?: File) => void;
isGenerating: boolean;
incomingParams?: ImageGenerationParams | null;
isVideoMode: boolean;
videoStatus?: VideoStatus | null; // New prop
videoStatus?: VideoStatus | null;
userUsage?: UserUsage | null;
}
const ASPECT_RATIOS = [
... ... @@ -19,7 +20,7 @@ const ASPECT_RATIOS = [
{ label: 'Custom', w: 0, h: 0 },
];
const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams, isVideoMode, videoStatus }) => {
const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams, isVideoMode, videoStatus, userUsage }) => {
const [prompt, setPrompt] = useState('');
const [showSettings, setShowSettings] = useState(false);
const [isSubmittingLocal, setIsSubmittingLocal] = useState(false); // New state for local submission status
... ... @@ -252,7 +253,11 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
<div className="pointer-events-auto w-full max-w-2xl transition-all duration-300 ease-in-out transform hover:scale-[1.01]">
{videoStatus && ( // Display video status above the input bar
<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">
<Hourglass size={20} className="text-purple-500 animate-spin" />
{videoStatus.status === 'complete' ? (
<div className="text-green-500 font-bold">✓</div>
) : (
<Hourglass size={20} className="text-purple-500 animate-spin" />
)}
<span className="text-gray-800 dark:text-white font-medium text-sm">
{videoStatus.status === 'submitting' && '提交请求中...'}
{videoStatus.status === 'queued' && `排队中... (第 ${videoStatus.queue_position || '?'} 位)`}
... ... @@ -262,6 +267,12 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
</div>
)}
<div className="relative group">
{isVideoMode && userUsage && !userUsage.is_admin && (
<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">
<RefreshCw size={10} className="animate-spin-slow" />
今日剩余次数: {userUsage.remaining}
</div>
)}
<div className="absolute inset-0 bg-white/80 dark:bg-black/80 backdrop-blur-2xl rounded-full shadow-2xl border border-white/20 dark:border-white/10" />
<div className="relative flex items-center p-2 pr-2">
... ...
... ... @@ -11,13 +11,12 @@ 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
? ENV_PROXY_URL
: "http://localhost:9009";
: "http://106.120.52.146:37000";
export const API_BASE_URL = DEFAULT_PROXY_URL;
export const API_ENDPOINTS = API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL
? [Z_IMAGE_DIRECT_BASE_URL]
: [API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL];
export const API_ENDPOINTS = [API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL];
// This is the specific Administrator ID requested
export const ADMIN_ID = '86427531';
... ...
import { API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL } from '../constants';
import { GalleryResponse, ImageItem } from '../types';
import { GalleryResponse, ImageItem, UserUsage } from '../types';
export const fetchUsage = async (userId: string): Promise<UserUsage> => {
const response = await fetch(`${API_BASE_URL}/usage/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch usage');
}
return await response.json();
};
export const fetchGallery = async (authorId?: string): Promise<ImageItem[]> => {
if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) {
... ...
... ... @@ -50,3 +50,12 @@ export interface VideoStatus {
processing_time?: number;
video_filename?: string; // The final filename of the video
}
export interface UserUsage {
daily_used: number;
bonus_count: number;
base_limit: number;
total_limit: number;
remaining: number;
is_admin: boolean;
}
... ...