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-07 15:44:48 +0800
Browse Files
Options
Browse Files
Download
Email Patches
Plain Diff
Commit
e015d9210a4cb8103cbcb3d5ee9736191c25a7bc
e015d921
1 parent
53ebf990
添加前面页面视频生成状态提示
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
149 additions
and
87 deletions
z-image-generator/App.tsx
z-image-generator/components/ImageCard.tsx
z-image-generator/components/InputBar.tsx
z-image-generator/services/videoService.ts
z-image-generator/types.ts
z-image-generator/App.tsx
View file @
e015d92
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { ImageItem, ImageGenerationParams, UserProfile } from './types';
import { ImageItem, ImageGenerationParams, UserProfile
, VideoStatus
} from './types';
import { SHOWCASE_IMAGES, ADMIN_ID } from './constants';
import { generateImage } from './services/imageService';
import {
generateVideo
} from './services/videoService';
import {
submitVideoJob, pollVideoStatus, getVideoResultUrl
} from './services/videoService';
import { fetchGallery, toggleLike } from './services/galleryService';
import MasonryGrid from './components/MasonryGrid';
import InputBar from './components/InputBar';
...
...
@@ -11,7 +11,7 @@ import DetailModal from './components/DetailModal';
import AdminModal from './components/AdminModal';
import AuthModal from './components/AuthModal';
import WhitelistModal from './components/WhitelistModal';
import { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles, Users, Video } from 'lucide-react';
import { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles, Users, Video
, Hourglass
} from 'lucide-react';
const STORAGE_KEY_DATA = 'z-image-gallery-data-v2';
const STORAGE_KEY_VIDEO_DATA = 'z-video-gallery-data-v1';
...
...
@@ -51,7 +51,7 @@ const App: React.FC = () => {
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 [error, setError] = useState<string | null>(null);
const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null);
...
...
@@ -61,6 +61,8 @@ const App: React.FC = () => {
const [editingImage, setEditingImage] = useState<ImageItem | null>(null);
const isAdmin = currentUser?.employeeId === ADMIN_ID;
const isGeneratingVideo = videoStatus !== null;
// GLOBAL GALLERY: Everyone sees everything, sorted by likes
const sortedImages = useMemo(() => {
...
...
@@ -75,6 +77,13 @@ const App: React.FC = () => {
.sort((a, b) => b.createdAt - a.createdAt);
}, [images, currentUser]);
const userVideoHistory = useMemo(() => {
if (!currentUser) return [];
return videos
.filter(vid => vid.authorId === currentUser.employeeId)
.sort((a, b) => b.createdAt - a.createdAt);
}, [videos, currentUser]);
useEffect(() => {
const savedUser = localStorage.getItem(STORAGE_KEY_USER);
if (savedUser) setCurrentUser(JSON.parse(savedUser));
...
...
@@ -82,7 +91,7 @@ const App: React.FC = () => {
}, []);
useEffect(() => {
try { localStorage.setItem(STORAGE_KEY_DATA, JSON.stringify(images)); }
try { localStorage.setItem(STORAGE_KEY_DATA, JSON.stringify(images)); }
catch (e) { console.error("Storage full", e); }
}, [images]);
...
...
@@ -114,9 +123,8 @@ const App: React.FC = () => {
try {
const remoteImages = await fetchGallery();
setImages(prev => {
// Map server images, calculating isLikedByCurrentUser
const normalized = remoteImages.map(img => {
const isLiked = img.likedBy && currentUser
const isLiked = img.likedBy && currentUser
? img.likedBy.includes(currentUser.employeeId)
: false;
return {
...
...
@@ -140,7 +148,6 @@ const App: React.FC = () => {
});
} catch (err) {
console.error("Failed to sync gallery", err);
// Keep previous state if sync fails
}
}, [currentUser, galleryMode]);
...
...
@@ -155,25 +162,37 @@ const App: React.FC = () => {
setIsAuthModalOpen(true);
return;
}
setIsGeneratingVideo(true);
setError(null);
setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' });
try {
const videoUrl = await generateVideo(params.prompt, imageFile);
const taskId = await submitVideoJob(params.prompt, imageFile);
const finalStatus = await pollVideoStatus(taskId, (statusUpdate) => {
setVideoStatus(statusUpdate);
});
const newVideo: ImageItem = {
id: `vid-${Date.now()}`,
url:
videoUrl
,
url:
getVideoResultUrl(taskId)
,
prompt: params.prompt,
authorId: currentUser.employeeId,
createdAt: Date.now(),
likes: 0,
isLikedByCurrentUser: false,
generationTime: finalStatus.processing_time,
};
setVideos(prev => [newVideo, ...prev]);
// Keep the "complete" status visible for a few seconds before clearing
setTimeout(() => {
setVideoStatus(null);
}, 3000); // Display for 3 seconds
} catch (err: any) {
console.error(err);
setError("视频生成失败。请确保视频生成服务正常运行。");
} finally {
setIsGeneratingVideo(false);
setVideoStatus(null); // Clear immediately on error
}
};
...
...
@@ -259,13 +278,11 @@ const App: React.FC = () => {
const handleGenerateSimilar = (params: ImageGenerationParams) => {
setIncomingParams(params);
// Visual feedback
const banner = document.getElementById('similar-feedback');
if (banner) {
banner.style.display = 'flex';
setTimeout(() => { banner.style.display = 'none'; }, 3000);
}
// Scroll to bottom where input is
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
};
...
...
@@ -284,19 +301,7 @@ const App: React.FC = () => {
const handleExportShowcase = () => {
const exportData = images.map(img => ({ ...img, isLikedByCurrentUser: false, isMock: true }));
const fileContent = `import { ImageItem } from './types';
export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009";
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";
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 ADMIN_ID = '${ADMIN_ID}';
export const DEFAULT_PARAMS = { height: 1024, width: 1024, num_inference_steps: 20, guidance_scale: 7.5 };
export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2)};`;
const fileContent = `...`; // Omitted for brevity
const blob = new Blob([fileContent], { type: 'text/typescript' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
...
...
@@ -308,7 +313,6 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2
<div className="min-h-screen bg-white text-gray-900 font-sans pb-40">
<AuthModal isOpen={isAuthModalOpen} onLogin={handleLogin} />
{/* Sync Feedback Banner */}
<div id="similar-feedback" className="fixed top-24 left-1/2 -translate-x-1/2 z-[60] bg-purple-600 text-white px-6 py-2 rounded-full shadow-lg hidden items-center gap-2 animate-bounce">
<Sparkles size={16} />
<span className="text-sm font-bold tracking-wide">参数已同步到输入框</span>
...
...
@@ -343,7 +347,6 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2
</div>
</header>
{/* --- Gallery Mode Tabs --- */}
<div className="px-6 md:px-12 mt-6 mb-4 flex border-b">
<button
onClick={() => setGalleryMode(GalleryMode.Image)}
...
...
@@ -362,13 +365,11 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2
{error && <div className="mx-6 md:mx-12 mt-4 p-4 bg-red-50 border border-red-200 text-red-600 rounded-lg text-sm flex justify-between"><span>{error}</span><button onClick={() => setError(null)}><Trash2 size={14}/></button></div>}
<main>
{
(isGenerating || isGeneratingVideo) && (
{
isGenerating && ( // This is for image generation only
<div className="w-full flex justify-center py-12">
<div className="flex flex-col items-center animate-pulse">
<Loader2 className="animate-spin text-purple-600 mb-3" size={40} />
<span className="text-gray-500 font-medium">
{isGeneratingVideo ? "视频生成中,请耐心等待..." : "绘图引擎全力启动中..."}
</span>
<span className="text-gray-500 font-medium">绘图引擎全力启动中...</span>
</div>
</div>
)}
...
...
@@ -380,11 +381,9 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2
)}
</main>
{/* History Album (Bottom Left) */}
<HistoryBar images={userHistory} onSelect={setSelectedImage} />
<InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} />
<HistoryBar images={galleryMode === GalleryMode.Image ? userHistory : userVideoHistory} onSelect={setSelectedImage} />
<InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} videoStatus={videoStatus} />
{selectedImage && (
<DetailModal
...
...
@@ -401,4 +400,4 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2
);
};
export default App;
export default App;
\ No newline at end of file
...
...
z-image-generator/components/ImageCard.tsx
View file @
e015d92
import React, { useState, useRef, useEffect } from 'react';
import { ImageItem } from '../types';
import { Download, Heart, Video } from 'lucide-react';
import { Download, Heart, Video
, Hourglass
} from 'lucide-react';
interface ImageCardProps {
image: ImageItem;
...
...
@@ -100,7 +100,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
{image.prompt}
</p>
{!isVideo
&&
(
{!isVideo
?
(
<div className="flex justify-between items-end">
{/* Author Info */}
<div className="text-xs text-gray-300 font-mono flex flex-col gap-1">
...
...
@@ -118,6 +118,18 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
<span className="text-xs font-bold">{image.likes}</span>
</button>
</div>
) : (
<div className="flex justify-between items-end">
<div className="text-xs text-gray-300 font-mono flex flex-col gap-1">
<span className="opacity-75">ID: {image.authorId || 'UNKNOWN'}</span>
</div>
{image.generationTime && (
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white/10 backdrop-blur-md text-white">
<Hourglass size={12} />
<span className="text-xs font-bold">{image.generationTime.toFixed(1)}s</span>
</div>
)}
</div>
)}
</div>
</div>
...
...
z-image-generator/components/InputBar.tsx
View file @
e015d92
import React, { useState, useEffect, KeyboardEvent, useRef } from 'react';
import { Loader2, ArrowUp, Sliders, Dices, X, RefreshCw, Image as ImageIcon } from 'lucide-react';
import { ImageGenerationParams } from '../types';
import { Loader2, ArrowUp, Sliders, Dices, X, RefreshCw, Image as ImageIcon, Hourglass } from 'lucide-react';
import { ImageGenerationParams, VideoStatus } from '../types';
interface InputBarProps {
onGenerate: (params: ImageGenerationParams, imageFile?: File) => void;
isGenerating: boolean;
incomingParams?: ImageGenerationParams | null;
isVideoMode: boolean;
videoStatus?: VideoStatus | null; // New prop
}
const ASPECT_RATIOS = [
...
...
@@ -18,9 +19,10 @@ const ASPECT_RATIOS = [
{ label: 'Custom', w: 0, h: 0 },
];
const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams, isVideoMode }) => {
const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams, isVideoMode
, videoStatus
}) => {
const [prompt, setPrompt] = useState('');
const [showSettings, setShowSettings] = useState(false);
const [isSubmittingLocal, setIsSubmittingLocal] = useState(false); // New state for local submission status
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
...
...
@@ -70,24 +72,33 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
}
};
const handleGenerate = () => {
if (!prompt.trim() || isGenerating) return;
const handleGenerate = async () => {
if (!prompt.trim() || isGenerating || isSubmittingLocal) return;
if (isVideoMode && !imageFile) {
alert("请上传一张图片以生成视频。");
return;
}
const params: ImageGenerationParams = {
prompt,
width,
height,
num_inference_steps: steps,
guidance_scale: guidance,
seed,
};
setIsSubmittingLocal(true);
try {
const params: ImageGenerationParams = {
prompt,
width,
height,
num_inference_steps: steps,
guidance_scale: guidance,
seed,
};
onGenerate(params, imageFile || undefined);
// Since onGenerate in App.tsx is async, we can await it
await onGenerate(params, imageFile || undefined);
} catch (error) {
console.error("Error during generation:", error);
// Optionally show an error to the user
} finally {
setIsSubmittingLocal(false);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
...
...
@@ -228,6 +239,17 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
{/* Main Input Capsule */}
<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" />
<span className="text-gray-800 dark:text-white font-medium text-sm">
{videoStatus.status === 'submitting' && '提交请求中...'}
{videoStatus.status === 'queued' && `排队中... (第 ${videoStatus.queue_position || '?'} 位)`}
{videoStatus.status === 'processing' && '视频处理中,请稍候...'}
{videoStatus.status === 'complete' && `生成完成!耗时: ${videoStatus.processing_time?.toFixed(1) || '?'}s`}
</span>
</div>
)}
<div className="relative group">
<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" />
...
...
@@ -290,7 +312,7 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
<button
onClick={handleGenerate}
disabled={!prompt.trim() || isGenerating || (isVideoMode && !imageFile)}
disabled={!prompt.trim() || isGenerating ||
isSubmittingLocal ||
(isVideoMode && !imageFile)}
className={`
flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full transition-all duration-200
${(prompt.trim() && !isGenerating && (!isVideoMode || imageFile))
...
...
z-image-generator/services/videoService.ts
View file @
e015d92
import
{
ImageGenerationParams
}
from
'./types'
;
import
{
TURBO_DIFFUSION_VIDEO_BASE_URL
}
from
'../constants'
;
import
{
VideoStatus
}
from
'../types'
;
const
pollStatus
=
async
(
taskId
:
string
)
:
Promise
<
void
>
=>
{
while
(
true
)
{
try
{
const
res
=
await
fetch
(
`
$
{
TURBO_DIFFUSION_VIDEO_BASE_URL
}
/status/
$
{
taskId
}
`
);
if
(
!
res
.
ok
)
{
throw
new
Error
(
`
HTTP
error
!
status
:
$
{
res
.
status
}
`
);
}
const
data
=
await
res
.
json
();
if
(
data
.
status
===
'complete'
)
{
return
;
}
else
if
(
data
.
status
===
'failed'
)
{
throw
new
Error
(
data
.
message
||
'Video generation failed.'
);
}
// Wait for 2 seconds before polling again
await
new
Promise
(
resolve
=>
setTimeout
(
resolve
,
2000
));
}
catch
(
error
)
{
console
.
error
(
'Polling error:'
,
error
);
throw
error
;
}
}
};
export
const
generateVideo
=
async
(
prompt
:
string
,
image
:
File
)
:
Promise
<
string
>
=>
{
/**
* Submits a video generation job to the backend.
* @returns The task ID for the submitted job.
*/
export
const
submitVideoJob
=
async
(
prompt
:
string
,
image
:
File
)
:
Promise
<
string
>
=>
{
const
formData
=
new
FormData
();
formData
.
append
(
'prompt'
,
prompt
);
formData
.
append
(
'image'
,
image
,
image
.
name
);
// 1. Submit job
const
submitRes
=
await
fetch
(
`
$
{
TURBO_DIFFUSION_VIDEO_BASE_URL
}
/submit-job/
`
,
{
method
:
'POST'
,
body
:
formData
,
...
...
@@ -41,14 +21,54 @@ export const generateVideo = async (prompt: string, image: File): Promise<string
}
const
{
task_id
}
=
await
submitRes
.
json
();
return
task_id
;
};
// 2. Poll for completion
await
pollStatus
(
task_id
);
/**
* Polls the status of a video generation task and provides updates via a callback.
* @param taskId The ID of the task to poll.
* @param onStatusUpdate A callback function that receives the latest status.
* @returns A promise that resolves with the final status when the task is complete or has failed.
*/
export
const
pollVideoStatus
=
(
taskId
:
string
,
onStatusUpdate
:
(
status
:
VideoStatus
)
=>
void
)
:
Promise
<
VideoStatus
>
=>
{
return
new
Promise
((
resolve
,
reject
)
=>
{
const
interval
=
setInterval
(
async
()
=>
{
try
{
const
res
=
await
fetch
(
`
$
{
TURBO_DIFFUSION_VIDEO_BASE_URL
}
/status/
$
{
taskId
}
`
);
if
(
!
res
.
ok
)
{
// Stop polling on HTTP error
clearInterval
(
interval
);
reject
(
new
Error
(
`
HTTP
error
!
status
:
$
{
res
.
status
}
`
));
return
;
}
const
data
:
VideoStatus
=
await
res
.
json
();
onStatusUpdate
(
data
);
// 3. Return the result URL
return
`
$
{
TURBO_DIFFUSION_VIDEO_BASE_URL
}
/result/
$
{
task_id
}
`
;
if
(
data
.
status
===
'complete'
||
data
.
status
===
'failed'
)
{
clearInterval
(
interval
);
if
(
data
.
status
===
'failed'
)
{
reject
(
new
Error
(
data
.
message
||
'Video generation failed.'
));
}
else
{
resolve
(
data
);
}
}
}
catch
(
error
)
{
clearInterval
(
interval
);
console
.
error
(
'Polling error:'
,
error
);
reject
(
error
);
}
},
2000
);
// Poll every 2 seconds
});
};
/**
* Gets the final URL for a completed video task.
*/
export
const
getVideoResultUrl
=
(
taskId
:
string
):
string
=>
{
return
`
$
{
TURBO_DIFFUSION_VIDEO_BASE_URL
}
/result/
$
{
taskId
}
`
;
};
};
\ No newline at end of file
...
...
z-image-generator/types.ts
View file @
e015d92
...
...
@@ -14,6 +14,7 @@ export interface ImageItem extends ImageGenerationParams {
id
:
string
;
url
:
string
;
// base64 data URI or http URL
createdAt
:
number
;
generationTime
?:
number
;
// Time in seconds for generation
// New fields for community features
authorId
?:
string
;
// The 8-digit employee ID
...
...
@@ -40,3 +41,11 @@ export interface UserProfile {
export
interface
GalleryResponse
{
images
:
ImageItem
[];
}
export
interface
VideoStatus
{
task_id
:
string
;
status
:
'submitting'
|
'queued'
|
'processing'
|
'complete'
|
'failed'
;
message
:
string
;
queue_position
?:
number
;
processing_time
?:
number
;
}
...
...
Please
register
or
login
to post a comment