Toggle navigation
Toggle navigation
This project
Loading...
Sign in
卢阳
/
front_backend_zImage
Go to a project
Toggle navigation
Projects
Groups
Snippets
Help
Toggle navigation pinning
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Builds
Commits
Authored by
ly0303521
2026-01-20 14:23:41 +0800
Browse Files
Options
Browse Files
Download
Email Patches
Plain Diff
Commit
ace3d9541b37f60d1d2d5bbb6a7aadd39df9caca
ace3d954
1 parent
dd5dcc55
添加视频生成限制次数,通过集赞给予视频生成次数奖励
Hide whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
183 additions
and
17 deletions
backend/main.py
backend/usage.json
z-image-generator/App.tsx
z-image-generator/components/InputBar.tsx
z-image-generator/constants.ts
z-image-generator/services/galleryService.ts
z-image-generator/types.ts
backend/main.py
View file @
ace3d95
...
...
@@ -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
()
...
...
backend/usage.json
0 → 100644
View file @
ace3d95
{
"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
...
...
z-image-generator/App.tsx
View file @
ace3d95
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)} />
...
...
z-image-generator/components/InputBar.tsx
View file @
ace3d95
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">
...
...
z-image-generator/constants.ts
View file @
ace3d95
...
...
@@ -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'
;
...
...
z-image-generator/services/galleryService.ts
View file @
ace3d95
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
)
{
...
...
z-image-generator/types.ts
View file @
ace3d95
...
...
@@ -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
;
}
...
...
Please
register
or
login
to post a comment