暂存修改

This commit is contained in:
sjy
2026-07-23 13:07:33 +08:00
55 changed files with 4363 additions and 1920 deletions
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -1,3 +1,4 @@
<!doctype html>
<html lang="zh-CN">
<head>
@@ -28,10 +29,11 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-M8UKiCMs.js"></script>
<script type="module" crossorigin src="/assets/index-BhfopyFy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bsz_Xon-.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+82
View File
@@ -17,6 +17,7 @@
"qrcode.react": "^4.2.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-quill-new": "^3.8.3",
"react-router-dom": "^7.15.0",
"three": "^0.184.0",
"uuid": "^14.0.0",
@@ -2826,6 +2827,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2833,6 +2840,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -3615,6 +3628,25 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3822,6 +3854,12 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4138,6 +4176,35 @@
],
"license": "MIT"
},
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
}
},
"node_modules/quill-delta": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"dependencies": {
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/rangetouch": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz",
@@ -4189,6 +4256,21 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-quill-new": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
"integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21",
"quill": "~2.0.3"
},
"peerDependencies": {
"quill-delta": "^5.1.0",
"react": "^16 || ^17 || ^18 || ^19",
"react-dom": "^16 || ^17 || ^18 || ^19"
}
},
"node_modules/react-router": {
"version": "7.15.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
+1
View File
@@ -19,6 +19,7 @@
"qrcode.react": "^4.2.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-quill-new": "^3.8.3",
"react-router-dom": "^7.15.0",
"three": "^0.184.0",
"uuid": "^14.0.0",
+12 -9
View File
@@ -5,7 +5,7 @@
import { api, setToken, clearToken } from './client';
import * as mock from './mock';
import type {
User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult,
User, CreditRecord, Project, GenerationRecord, OptimizeParams, OptimizeResult,
Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitProject, PrivatePortraitValidateSession,
PrivatePortraitProjectCreateWithValidateOut, PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut,
@@ -112,7 +112,11 @@ export async function optimizePrompt(
project_id: projectId,//项目id
gen_type:params.genType,//生成类型
prompt: params.prompt,
engine_id: params.engineId,
include_media_references: params.includeMediaReferences ?? false,
duration: params.duration,
aspect_ratio: params.aspectRatio || null,
resolution: params.resolution || null,
references: params.references || null,
idempotency_key: params.idempotencyKey || null,
image_size: params.image_size || null,
@@ -266,15 +270,14 @@ export async function deleteUpload(url: string): Promise<void> {
export async function updateRecordPrompt(recordId: string, optimizedPrompt: string): Promise<void> {
await api.put(`/generation-records/${recordId}/prompt`, { optimized_prompt: optimizedPrompt });
}
export async function generateVideo(recordId: string, params: GenerateParams): Promise<GenerationRecord> {
export async function generateVideo(recordId: string): Promise<GenerationRecord> {
if (USE_MOCK) return mock.mockGenerateVideo(recordId);
return api.post<GenerationRecord>(`/generation-records/${recordId}/generate`, {
engine_id: params.engineId,
include_media_references: params.includeMediaReferences ?? false,
aspect_ratio: params.aspectRatio,
resolution: params.resolution,
image_size: params.imageSize,
});
return api.post<GenerationRecord>(`/generation-records/${recordId}/generate`);
}
export async function retryGeneration(recordId: string): Promise<GenerationRecord> {
if (USE_MOCK) return mock.mockGenerateVideo(recordId);
return api.post<GenerationRecord>(`/generation-records/${recordId}/retry`);
}
// ── Credits ───────────────────────────────────────────────
export async function getCredits(page = 1, pageSize = 20): Promise<{ credits: number; records: CreditRecord[]; total: number }> {
+29 -9
View File
@@ -3,7 +3,6 @@ import type {
CreditRecord,
Project,
GenerationRecord,
GenerateParams,
OptimizeParams,
OptimizeResult,
LoginParams,
@@ -208,7 +207,7 @@ export async function mockOptimizePrompt(
await delay(1500);
const project = MOCK_PROJECTS.find((p) => p.id === projectId);
const cost = Math.round(80 + params.prompt.length * 0.5 + params.duration * 2);
const cost = Math.round(80 + params.prompt.length * 0.5 + (params.duration || 0) * 2);
if (currentUser) {
currentUser.credits -= cost;
@@ -239,18 +238,28 @@ export async function mockOptimizePrompt(
projectName: project?.name ?? '未知项目',
originalPrompt: params.prompt,
optimizedPrompt,
duration: params.duration,
aspectRatio: params.aspectRatio as any,
resolution: params.resolution as any,
duration: params.genType === 'video' ? params.duration : undefined,
aspectRatio: params.genType === 'video' ? params.aspectRatio as any : undefined,
resolution: params.genType === 'video' ? params.resolution as any : undefined,
status: 'prompt_optimized',
creditsCost: cost,
textCreditsCost: cost,
textTokensUsed: 0,
videoTokensUsed: 0,
imageSize: params.resolution || '1080p',
imageProportion: params.aspectRatio || '16:9',
imagePx: '1920x1080',
imageSize: params.genType === 'image' ? params.image_size : undefined,
imageProportion: params.genType === 'image' ? params.image_proportion : undefined,
imagePx: params.genType === 'image' ? params.image_px : undefined,
imageUrl: '',
engineId: params.engineId,
engineName: params.engineId,
engineSnapshot: { id: params.engineId, name: params.engineId },
includeMediaReferences: Boolean(params.includeMediaReferences),
configComplete: true,
canGenerate: true,
canRetry: false,
shouldPoll: false,
clientStatus: 'ready',
operationPhase: 'prompt',
createdAt: new Date().toLocaleString('zh-CN'),
};
@@ -265,7 +274,18 @@ export async function mockGenerateVideo(recordId: string): Promise<GenerationRec
if (!record) throw new Error('记录不存在');
record.status = 'completed';
record.videoUrl = `https://example.com/video-${recordId}.mp4`;
record.canGenerate = false;
record.canRetry = false;
record.shouldPoll = false;
record.clientStatus = 'success';
record.operationPhase = 'resource';
if (record.genType === 'image') {
record.imageUrl = `https://example.com/image-${recordId}.png`;
record.videoUrl = undefined;
} else {
record.videoUrl = `https://example.com/video-${recordId}.mp4`;
record.imageUrl = undefined;
}
record.generatedAt = new Date().toLocaleString('zh-CN');
return record;
@@ -1,6 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Tag, Button } from 'antd';
import { BellOutlined, ThunderboltOutlined, GiftOutlined, StarOutlined, CloseOutlined } from '@ant-design/icons';
import { BellOutlined, ThunderboltOutlined, GiftOutlined, StarOutlined, CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import { getNotifications, markNotificationRead } from '../api';
import { useAuthStore } from '../store/useAuthStore';
@@ -105,94 +105,61 @@ const NotificationPopup: React.FC = () => {
background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(6px)',
}} onClick={handleClose}>
<div onClick={e => e.stopPropagation()} style={{
width: 420,
borderRadius: 24,
width: 520,
borderRadius: 24,
overflow: 'hidden',
boxShadow: '0 25px 80px rgba(0,0,0,0.15)',
boxShadow: '0 30px 100px rgba(0,0,0,0.2)',
animation: 'notificationPop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
background: '#ffffff',
}}>
{/* Header */}
<div style={{
background: tc.gradient,
padding: '24px 28px 20px',
position: 'relative',
padding: '16px 28px 14px',
position: 'relative',
overflow: 'hidden',
}}>
<div style={{
position: 'absolute',
right: -50,
top: -50,
width: 200,
height: 200,
borderRadius: '50%',
<div style={{
position: 'absolute',
right: -50,
top: -50,
width: 200,
height: 200,
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(255,255,255,0.15) 0%, transparent 60%)',
filter: 'blur(30px)',
}} />
<div style={{
position: 'absolute',
right: 30,
top: 10,
width: 80,
height: 80,
borderRadius: '50%',
background: 'rgba(255,255,255,0.06)',
filter: 'blur(15px)',
}} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44,
height: 44,
borderRadius: 14,
background: 'rgba(255,255,255,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
color: '#fff',
}}>{tc.icon}</div>
<div>
<div style={{
color: 'rgba(255,255,255,0.7)',
fontSize: 12,
marginBottom: 4,
fontWeight: 500,
}}></div>
<div style={{
color: '#fff',
fontSize: 28,
fontWeight: 700,
letterSpacing: '-1px',
}}>{current.title}</div>
</div>
</div>
<button
onClick={handleClose}
{/* Top row: type badge + close */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Tag style={{ background: 'rgba(255,255,255,0.2)', border: 'none', color: '#fff', borderRadius: 6, padding: '2px 10px', fontSize: 11, fontWeight: 500 }}>
{tc.icon} {tc.label}
</Tag>
<button
onClick={handleClose}
style={{
width: 30,
height: 30,
borderRadius: '50%',
background: 'rgba(255,255,255,0.1)',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
outline: 'none',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
width: 28, height: 28, borderRadius: '50%',
background: 'rgba(255,255,255,0.1)',
border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.2s ease', outline: 'none',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.2)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.1)'; }}
>
<CloseOutlined style={{ color: '#fff', fontSize: 14 }} />
<CloseOutlined style={{ color: '#fff', fontSize: 12 }} />
</button>
</div>
{/* Title + time row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12 }}>
<div style={{ color: '#fff', fontSize: 22, fontWeight: 700, letterSpacing: '-0.5px' }}>
{current.title}
</div>
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, whiteSpace: 'nowrap', flexShrink: 0 }}>
{current.createdAt ? new Date(current.createdAt).toLocaleString('zh-CN') : ''}
</div>
</div>
</div>
{/* Body */}
@@ -201,48 +168,67 @@ const NotificationPopup: React.FC = () => {
padding: '24px 28px 28px',
}}>
<div style={{
padding: '18px 20px',
background: '#f8fafc',
padding: '18px 20px',
background: '#f8fafc',
borderRadius: 16,
marginBottom: 20,
marginBottom: 20,
border: '1px solid #f1f5f9',
}}>
<div style={{
fontSize: 14,
color: '#475569',
<div style={{
fontSize: 14,
color: '#475569',
lineHeight: 1.7,
}}>
{current.content}
</div>
}} dangerouslySetInnerHTML={{ __html: current.content }} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{/* Left: navigation with left/right buttons */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tag
color={tc.gradient.includes('#6366f1') ? '#6366f1' : tc.gradient.includes('#f59e0b') ? '#f59e0b' : '#8b5cf6'}
style={{
borderRadius: 6,
border: 'none',
padding: '3px 10px',
fontSize: 11,
fontWeight: 500,
background: '#f1f5f9',
color: '#64748b',
}}
>
{tc.label}
</Tag>
{notifications.length > 1 && (
<>
<button
onClick={(e) => { e.stopPropagation(); setCurrentIndex(currentIndex > 0 ? currentIndex - 1 : notifications.length - 1); }}
style={{
width: 30, height: 30, borderRadius: '50%',
background: '#f1f5f9', border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.2s', outline: 'none',
}}
onMouseEnter={e => { e.currentTarget.style.background = '#e2e8f0'; }}
onMouseLeave={e => { e.currentTarget.style.background = '#f1f5f9'; }}
>
<LeftOutlined style={{ color: '#64748b', fontSize: 12 }} />
</button>
<span style={{ fontSize: 12, color: '#64748b', fontWeight: 500, minWidth: 36, textAlign: 'center' }}>
{currentIndex + 1}/{notifications.length}
</span>
<button
onClick={(e) => { e.stopPropagation(); setCurrentIndex(currentIndex < notifications.length - 1 ? currentIndex + 1 : 0); }}
style={{
width: 30, height: 30, borderRadius: '50%',
background: '#f1f5f9', border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.2s', outline: 'none',
}}
onMouseEnter={e => { e.currentTarget.style.background = '#e2e8f0'; }}
onMouseLeave={e => { e.currentTarget.style.background = '#f1f5f9'; }}
>
<RightOutlined style={{ color: '#64748b', fontSize: 12 }} />
</button>
</>
)}
</div>
<Button
type="primary"
onClick={handleAcknowledge}
{/* Right: acknowledge button */}
<Button
type="primary"
onClick={handleAcknowledge}
style={{
borderRadius: 12,
fontWeight: 600,
borderRadius: 12,
fontWeight: 600,
height: 40,
background: tc.gradient,
background: tc.gradient,
border: 'none',
paddingLeft: 28,
paddingLeft: 28,
paddingRight: 28,
boxShadow: `0 6px 16px ${tc.glowColor}`,
transition: 'all 0.2s ease',
@@ -261,28 +247,6 @@ const NotificationPopup: React.FC = () => {
</Button>
</div>
{notifications.length > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 6, marginTop: 20 }}>
{notifications.map((_, i) => (
<button
key={i}
onClick={() => setCurrentIndex(i)}
style={{
width: i === currentIndex ? 20 : 5,
height: 5,
borderRadius: 3,
background: i === currentIndex
? (tc.gradient.includes('#6366f1') ? '#6366f1' : tc.gradient.includes('#f59e0b') ? '#f59e0b' : '#8b5cf6')
: '#e2e8f0',
border: 'none',
cursor: 'pointer',
transition: 'all 0.3s ease',
outline: 'none',
}}
/>
))}
</div>
)}
</div>
</div>
<style>{`
+181 -440
View File
@@ -502,6 +502,7 @@ const GeneratePage: React.FC = () => {
fetchRecords,
optimizePrompt,
generateVideo,
retryGeneration,
} = useAppStore();
const recordItems = records.items;
const { user } = useAuthStore();
@@ -635,11 +636,6 @@ const GeneratePage: React.FC = () => {
Record<string, string>
>({});
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
// Per-record param selections for history prompt_optimized records
const [historyParams, setHistoryParams] = useState<
Record<string, { aspectRatio?: AspectRatio; resolution?: Resolution; engineId?: string; includeMediaReferences?: boolean }>
>({});
// Video preview modal
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
@@ -1301,7 +1297,11 @@ const GeneratePage: React.FC = () => {
const pending = localStorage.getItem("pending_optimize");
if (!pending) return;
try {
const { key, projectId: pId, prompt, duration, ts } = JSON.parse(pending);
const {
key, projectId: pId, prompt, duration, engineId, genType, aspectRatio,
resolution, imageSize, imageProportion, imagePx, includeMediaReferences: pendingIncludeReferences,
referenceIds, ts,
} = JSON.parse(pending);
if (Date.now() - ts > 5 * 60 * 1000 || pId !== projectId) {
localStorage.removeItem("pending_optimize");
return;
@@ -1314,6 +1314,15 @@ const GeneratePage: React.FC = () => {
r.projectId === pId &&
r.originalPrompt === prompt &&
r.duration === duration &&
r.engineId === engineId &&
String(r.genType || "video") === String(genType || "video") &&
String(r.aspectRatio || "") === String(aspectRatio || "") &&
String(r.resolution || "") === String(resolution || "") &&
String(r.imageSize || "") === String(imageSize || "") &&
String(r.imageProportion || "") === String(imageProportion || "") &&
String(r.imagePx || "") === String(imagePx || "") &&
Boolean(r.includeMediaReferences) === Boolean(pendingIncludeReferences) &&
JSON.stringify((r.references || []).map((item) => item.upload_resource_id || item.url).filter(Boolean).sort()) === JSON.stringify((referenceIds || []).slice().sort()) &&
r.status === "prompt_optimized" &&
new Date(r.createdAt).getTime() > ts - 10000,
);
@@ -1370,8 +1379,19 @@ const GeneratePage: React.FC = () => {
// Media credits estimate for step 2 (video or image)
const estimatedVideoCredits = mediaType === "image"
? getImageCreditsFromCimage(selectedResolution)
: calcVideoCredits(videoDuration, videoResolution);
? getImageCreditsFromCimage(
currentRecord?.imageSize || selectedResolution,
currentRecord?.engineId || selectedEngineId,
currentRecord?.includeMediaReferences ?? includeMediaReferences,
currentRecord?.references || references,
)
: calcVideoCredits(
currentRecord?.duration || videoDuration,
(currentRecord?.resolution || videoResolution) as Resolution,
currentRecord?.engineId || selectedEngineId,
currentRecord?.includeMediaReferences ?? includeMediaReferences,
currentRecord?.references || references,
);
const canAffordVideo = userCredits >= estimatedVideoCredits;
// Step 1: Optimize prompt (text credits)
@@ -1386,6 +1406,14 @@ const GeneratePage: React.FC = () => {
message.error("请输入视频/图片描述");
return;
}
if (!selectedEngineId) {
message.error("请选择生成引擎");
return;
}
if (mediaType === "video" && (!videoAspectRatio || !videoResolution)) {
message.error("请选择视频比例和分辨率");
return;
}
setOptimizing(true);
const optionEntries = Object.entries(selectedOptions).map(
@@ -1404,20 +1432,32 @@ const GeneratePage: React.FC = () => {
projectId,
prompt: fullPrompt,
duration: videoDuration,
engineId: selectedEngineId,
genType: mediaType,
aspectRatio: mediaType === "video" ? videoAspectRatio : "",
resolution: mediaType === "video" ? videoResolution : "",
imageSize: mediaType === "image" ? selectedResolution : "",
imageProportion: mediaType === "image" ? selectedRatio : "",
imagePx: mediaType === "image" ? `${width}x${height}` : "",
includeMediaReferences,
referenceIds: references.map((item) => item.upload_resource_id || item.url).filter(Boolean).sort(),
ts: Date.now(),
}),
);
inFlightOptimizeKey.current = idempotencyKey;
const result = await optimizePrompt(projectId, {
prompt: fullPrompt,
duration: videoDuration,
duration: mediaType === "video" ? videoDuration : undefined,
genType: mediaType,
resolution: selectedResolution,
engineId: selectedEngineId,
includeMediaReferences,
aspectRatio: mediaType === "video" ? videoAspectRatio : undefined,
resolution: mediaType === "video" ? videoResolution : undefined,
references: references.length > 0 ? references : undefined,
idempotencyKey,
image_size: selectedResolution,
image_proportion: selectedRatio,
image_px: width + "x" + height,
image_size: mediaType === "image" ? selectedResolution : undefined,
image_proportion: mediaType === "image" ? selectedRatio : undefined,
image_px: mediaType === "image" ? width + "x" + height : undefined,
});
// console.log("按钮触发", result);
@@ -1542,7 +1582,7 @@ const GeneratePage: React.FC = () => {
const frontendState = recordStates[item.id];
// 如果后端状态不是generating,说明任务已经完成(成功或失败),需要处理
// 不管前端当前是什么状态,都要处理完成的任务
if (latest.status !== "generating") {
if (!(latest.shouldPoll ?? latest.status === "generating")) {
completedIds.push(item.id);
} else if (frontendState !== "generating") {
// 如果后端状态是generating,但前端不是,更新为generating(处理页面刷新后状态丢失的情况)
@@ -1617,7 +1657,7 @@ const GeneratePage: React.FC = () => {
}, []);
useEffect(() => {
const generatingRecords = recordItems.filter((r) => r.status === "generating");
const generatingRecords = recordItems.filter((r) => r.shouldPoll ?? r.status === "generating");
generatingRecords.forEach((record) => {
setRecordStates((p) => ({ ...p, [record.id]: "generating" }));
startPolling(record.id);
@@ -1625,10 +1665,6 @@ const GeneratePage: React.FC = () => {
}, [recordItems]);
const handleGenerate = async (recordId: string) => {
if (!canAffordVideo) {
message.error("积分不足,请先充值");
return;
}
setGenerating((p) => ({ ...p, [recordId]: true }));
setRecordStates((p) => ({ ...p, [recordId]: "generating" }));
message.loading({
@@ -1637,13 +1673,15 @@ const GeneratePage: React.FC = () => {
key: recordId,
});
try {
const result = await generateVideo(recordId, {
engineId: selectedEngineId || undefined,
includeMediaReferences,
aspectRatio: videoAspectRatio,
resolution: videoResolution,
imageSize: currentRecord?.imageSize || selectedResolution,
});
const record = projectRecords.find((item) => item.id === recordId) || currentRecord;
if (!record || record.canGenerate === false) {
throw new Error(record?.configFallbackHint || "该记录当前不可生成,请重新生成提词");
}
const nextPrompt = record.id === currentRecord?.id ? editedPrompt : editablePrompts[recordId];
if (nextPrompt && nextPrompt !== record.optimizedPrompt) {
await updateRecordPrompt(recordId, nextPrompt);
}
const result = await generateVideo(recordId);
if (result.status === "failed") {
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
message.error({
@@ -1698,18 +1736,10 @@ const GeneratePage: React.FC = () => {
key: recordId,
});
try {
// Save edited prompt first if changed
const editedPrompt = editablePrompts[recordId];
if (editedPrompt && editedPrompt !== record.optimizedPrompt) {
await updateRecordPrompt(recordId, editedPrompt);
if (!record.configComplete || record.canRetry === false) {
throw new Error("该失败记录不能直接重试,请重新生成提词");
}
const result = await generateVideo(recordId, {
engineId: record.engineId || selectedEngineId || undefined,
includeMediaReferences: Boolean(record.includeMediaReferences),
aspectRatio: record.aspectRatio || "16:9",
resolution: record.resolution || "720p",
imageSize: record.imageSize,
});
const result = await retryGeneration(recordId);
if (result.status === "failed") {
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
@@ -1758,6 +1788,10 @@ const GeneratePage: React.FC = () => {
return resolveGenerationUiState({
status: localStatus || record.status,
pipelineStage: localState ? null : record.pipelineStage,
clientStatus: localState ? null : record.clientStatus,
shouldPoll: localState ? localState === "generating" : record.shouldPoll,
canGenerate: record.canGenerate,
canRetry: record.canRetry,
});
};
@@ -2792,6 +2826,36 @@ const GeneratePage: React.FC = () => {
)}
</div>
)}
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
flexWrap: "wrap",
marginRight: 16,
}}
>
<Select
value={selectedEngineId || undefined}
onChange={handleGenerationEngineChange}
placeholder="选择生成引擎"
style={{ minWidth: 180 }}
options={(mediaType === "image" ? imageEngines : videoEngines).map((item: any) => ({ value: item.id, label: item.name }))}
/>
{mediaType === "video" && (
<>
<Select value={videoAspectRatio} onChange={(value) => setVideoAspectRatio(value as AspectRatio)} style={{ width: 110 }} options={engineOptions.ratios.map((value) => ({ value, label: value }))} />
<Select value={videoResolution} onChange={(value) => setVideoResolution(value as Resolution)} style={{ width: 110 }} options={engineOptions.resolutions.map((value) => ({ value, label: value }))} />
</>
)}
{references.length > 0 && (
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<Switch size="small" checked={includeMediaReferences} onChange={setIncludeMediaReferences} />
<Typography.Text style={{ fontSize: 12 }}></Typography.Text>
</div>
)}
</div>
<div
style={{
display: "flex",
@@ -3273,57 +3337,6 @@ const GeneratePage: React.FC = () => {
</div>
)}
{/* Video params selection */}
{/* {mediaType !== 'image' && (
<div style={{ marginTop: 24 }}>
<Typography.Text style={{ fontSize: 13, color: '#1a1a2e', fontWeight: 600, display: 'block', marginBottom: 12 }}>选择视频参数</Typography.Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 12px', borderRadius: 8, background: '#f8f9fc', border: '1px solid #e2e8f0' }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>时长</Typography.Text>
<Typography.Text strong style={{ fontSize: 13, color: '#6366f1' }}>{videoDuration}s</Typography.Text>
</div>
<PortalDropdown label="比例" value={videoAspectRatio}
options={engineOptions.ratios}
expanded={expandedEngine === 'ratio'}
onToggle={() => setExpandedEngine(expandedEngine === 'ratio' ? null : 'ratio')}
onSelect={(v) => setVideoAspectRatio(v as AspectRatio)}
onClose={() => setExpandedEngine(null)}
/>
<PortalDropdown label="分辨率" value={videoResolution}
options={engineOptions.resolutions}
expanded={expandedEngine === 'resolution'}
onToggle={() => setExpandedEngine(expandedEngine === 'resolution' ? null : 'resolution')}
onSelect={(v) => setVideoResolution(v as Resolution)}
onClose={() => setExpandedEngine(null)}
/>
</div>
</div>
)} */}
{/* Image params selection
{mediaType === 'image' && currentRecord && (
<div style={{ marginTop: 24, padding: 16, borderRadius: 12, background: '#f8f9fc' }}>
<div style={{ display: 'flex', justifyContent: 'space-around' }}>
<div style={{ textAlign: 'center' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>分辨率</Typography.Text>
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>{currentRecord.imageSize || '-'}</Typography.Text>
</div>
<div style={{ textAlign: 'center' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>画面比例</Typography.Text>
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>{currentRecord.imageProportion || '-'}</Typography.Text>
</div>
<div style={{ textAlign: 'center' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>画面尺寸</Typography.Text>
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>{currentRecord.imagePx || '-'}</Typography.Text>
</div>
<div style={{ textAlign: 'center' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>消耗积分</Typography.Text>
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>-</Typography.Text>
</div>
</div>
</div>
)} */}
{/* Video credits + generate button */}
<div
style={{
@@ -3340,17 +3353,27 @@ const GeneratePage: React.FC = () => {
<div style={{ minWidth: 220, marginRight: 16 }}>
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}></Typography.Text>
<Select
value={selectedEngineId || undefined}
onChange={handleGenerationEngineChange}
value={currentRecord.engineId || undefined}
disabled
style={{ width: '100%' }}
options={(mediaType === 'image' ? imageEngines : videoEngines).map((item: any) => ({ value: item.id, label: item.name }))}
/>
{currentRecord?.references?.length ? (
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
<Switch size="small" checked={includeMediaReferences} onChange={setIncludeMediaReferences} />
<Typography.Text style={{ fontSize: 12, color: '#64748b' }}></Typography.Text>
<Switch size="small" checked={Boolean(currentRecord.includeMediaReferences)} disabled />
<Typography.Text style={{ fontSize: 12, color: '#64748b' }}></Typography.Text>
</div>
) : null}
{currentRecord.configComplete === false && (
<Typography.Text
type={currentRecord.canGenerate === false ? "danger" : "warning"}
style={{ fontSize: 12, display: "block", marginTop: 8 }}
>
{currentRecord.configFallbackHint || (currentRecord.canGenerate === false
? "旧版本配置不完整,请重新生成提词"
: "旧版本配置缺失,提交生成时将由后端自动补齐")}
</Typography.Text>
)}
</div>
{/* Video params selection */}
@@ -3372,7 +3395,7 @@ const GeneratePage: React.FC = () => {
marginBottom: 12,
}}
>
</Typography.Text>
<div
style={{
@@ -3400,33 +3423,25 @@ const GeneratePage: React.FC = () => {
strong
style={{ fontSize: 13, color: "#6366f1" }}
>
{videoDuration}s
{currentRecord.duration || videoDuration}s
</Typography.Text>
</div>
<PortalDropdown
label="比例"
value={videoAspectRatio}
options={engineOptions.ratios}
expanded={expandedEngine === "ratio"}
onToggle={() =>
setExpandedEngine(
expandedEngine === "ratio" ? null : "ratio",
)
}
onSelect={(v) => setVideoAspectRatio(v as AspectRatio)}
value={(currentRecord.aspectRatio || videoAspectRatio) as string}
options={[String(currentRecord.aspectRatio || videoAspectRatio)]}
expanded={false}
onToggle={() => undefined}
onSelect={() => undefined}
onClose={() => setExpandedEngine(null)}
/>
<PortalDropdown
label="分辨率"
value={videoResolution}
options={engineOptions.resolutions}
expanded={expandedEngine === "resolution"}
onToggle={() =>
setExpandedEngine(
expandedEngine === "resolution" ? null : "resolution",
)
}
onSelect={(v) => setVideoResolution(v as Resolution)}
value={(currentRecord.resolution || videoResolution) as string}
options={[String(currentRecord.resolution || videoResolution)]}
expanded={false}
onToggle={() => undefined}
onSelect={() => undefined}
onClose={() => setExpandedEngine(null)}
/>
</div>
@@ -3533,9 +3548,7 @@ const GeneratePage: React.FC = () => {
strong
style={{ fontSize: 14, color: "#10b981" }}
>
{mediaType === "image" && currentRecord.imageSize
? getImageCreditsFromCimage(currentRecord.imageSize) || estimatedVideoCredits
: calcVideoCredits(videoDuration, videoResolution)}
{estimatedVideoCredits}
</Typography.Text>
</div>
<Typography.Text style={{ color: "#cbd5e1", fontSize: 18 }}>
@@ -3551,7 +3564,7 @@ const GeneratePage: React.FC = () => {
strong
style={{ fontSize: 16, color: "#1a1a2e" }}
>
{(lastTextCredits + (mediaType === "image" ? getImageCreditsFromCimage(currentRecord.imageSize) : calcVideoCredits(videoDuration, videoResolution))).toFixed(2)}
{(lastTextCredits + estimatedVideoCredits).toFixed(2)}
</Typography.Text>
</div>
</div>
@@ -3587,6 +3600,7 @@ const GeneratePage: React.FC = () => {
loading={recordStates[currentRecord.id] === "generating"}
disabled={
!canAffordVideo ||
currentRecord.canGenerate === false ||
recordStates[currentRecord.id] === "generating" ||
recordStates[currentRecord.id] === "done"
}
@@ -3611,7 +3625,20 @@ const GeneratePage: React.FC = () => {
? "生成完成"
: recordStates[currentRecord.id] === "generating"
? "生成中..."
: `生成${mediaType === "image" ? "图片" : "视频"} (${ (mediaType === "image" ? getImageCreditsFromCimage(currentRecord.imageSize) : calcVideoCredits(videoDuration, videoResolution))}积分)`}
: `生成${mediaType === "image" ? "图片" : "视频"} (${(mediaType === "image"
? getImageCreditsFromCimage(
currentRecord.imageSize || selectedResolution,
currentRecord.engineId,
currentRecord.includeMediaReferences,
currentRecord.references,
)
: calcVideoCredits(
currentRecord.duration || videoDuration,
(currentRecord.resolution || videoResolution) as Resolution,
currentRecord.engineId,
currentRecord.includeMediaReferences,
currentRecord.references,
))}积分)`}
</Button>
</Tooltip>
</div>
@@ -3810,7 +3837,7 @@ const GeneratePage: React.FC = () => {
flexShrink: 0,
}}
>
</Tag>
)}
@@ -3824,7 +3851,7 @@ const GeneratePage: React.FC = () => {
flexShrink: 0,
}}
>
{record.duration ? `${record.imageSize}` : "-"} ·{" "}
{record.imageSize || "-"} ·{" "}
{record.imageProportion || "-"} ·{" "}
{record.imagePx || "-"} ·{" "}
<span className="date-display" translate="no">
@@ -4429,279 +4456,54 @@ const GeneratePage: React.FC = () => {
position: "relative",
}}
>
{/* 将参数选择和视频生成按钮放到视频视频框 */}
{/* 待生成记录只能读取提词阶段冻结的配置 */}
{status === "prompt_optimized" && (
<div
style={{
marginTop: 16,
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
backgroundColor: "transparent",
inset: 0,
padding: 16,
borderRadius: 12,
background: "#f8f9fc",
border: "1px solid #e2e8f0",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<Select
size="small"
value={historyParams[record.id]?.engineId || record.engineId || (type === 'image' ? imageEngines[0]?.id : videoEngines[0]?.id)}
onChange={(value) => {
const options = type === 'video' ? getVideoEngineOptions(value) : null;
setHistoryParams((prev) => ({
...prev,
[record.id]: {
...prev[record.id],
engineId: value,
aspectRatio: type === 'video' ? options!.ratios[0] as AspectRatio : prev[record.id]?.aspectRatio,
resolution: type === 'video' ? options!.resolutions[0] as Resolution : prev[record.id]?.resolution,
},
}));
}}
options={(type === 'image' ? imageEngines : videoEngines).map((item: any) => ({ value: item.id, label: item.name }))}
style={{ minWidth: 160 }}
/>
{record.references?.length ? (
<><Switch size="small" checked={Boolean(historyParams[record.id]?.includeMediaReferences ?? record.includeMediaReferences)} onChange={(checked) => setHistoryParams((prev) => ({ ...prev, [record.id]: { ...prev[record.id], includeMediaReferences: checked, aspectRatio: prev[record.id]?.aspectRatio || '' as AspectRatio, resolution: prev[record.id]?.resolution || '' as Resolution } }))} /><Typography.Text style={{ fontSize: 12 }}></Typography.Text></>
) : null}
<div>
<Typography.Text strong style={{ display: "block", marginBottom: 10 }}></Typography.Text>
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>{record.engineName || record.engineId || "配置缺失"}</Typography.Text>
{type === "video" ? (
<>
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>{record.duration || "-"}</Typography.Text>
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>{record.aspectRatio || "-"}</Typography.Text>
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>{record.resolution || "-"}</Typography.Text>
</>
) : (
<>
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>{record.imageSize || "-"}</Typography.Text>
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>{record.imageProportion || "-"}</Typography.Text>
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>{record.imagePx || "-"}</Typography.Text>
</>
)}
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>{record.includeMediaReferences ? "生成时携带" : "仅用于提词或不携带"}</Typography.Text>
{record.configComplete === false && (
<Typography.Text type={record.canGenerate === false ? "danger" : "warning"} style={{ display: "block", marginTop: 8 }}>
{record.configFallbackHint || (record.canGenerate === false ? "旧版本配置不完整,请重新生成提词" : "旧版本配置缺失,提交生成时将由后端自动补齐")}
</Typography.Text>
)}
</div>
{type === "video" && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
flexWrap: "wrap",
marginBottom: 12,
}}
>
<div
translate="no"
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 12px",
borderRadius: 8,
background: "#f8f9fc",
border: "1px solid #e2e8f0",
marginLeft: 12,
}}
>
<Typography.Text
style={{ fontSize: 12, color: "#94a3b8" }}
>
</Typography.Text>
<Typography.Text
strong
style={{ fontSize: 13, color: "#6366f1" }}
>
{record.duration}
</Typography.Text>
</div>
<PortalDropdown
label="比例"
value={
historyParams[record.id]?.aspectRatio ||
"选择比例"
}
options={getVideoEngineOptions(historyParams[record.id]?.engineId || record.engineId || videoEngines[0]?.id).ratios}
expanded={
expandedEngine === `ratio-${record.id}`
}
onToggle={() =>
setExpandedEngine(
expandedEngine === `ratio-${record.id}`
? null
: `ratio-${record.id}`,
)
}
onSelect={(v) =>
setHistoryParams((prev) => ({
...prev,
[record.id]: {
...prev[record.id],
aspectRatio: v as AspectRatio,
resolution:
prev[record.id]?.resolution || "",
},
}))
}
onClose={() => setExpandedEngine(null)}
/>
<PortalDropdown
label="分辨率"
value={
historyParams[record.id]?.resolution ||
"选择分辨率"
}
options={getVideoEngineOptions(historyParams[record.id]?.engineId || record.engineId || videoEngines[0]?.id).resolutions}
expanded={
expandedEngine === `res-${record.id}`
}
onToggle={() =>
setExpandedEngine(
expandedEngine === `res-${record.id}`
? null
: `res-${record.id}`,
)
}
onSelect={(v) =>
setHistoryParams((prev) => ({
...prev,
[record.id]: {
...prev[record.id],
aspectRatio:
prev[record.id]?.aspectRatio || "",
resolution: v as Resolution,
},
}))
}
onClose={() => setExpandedEngine(null)}
/>
</div>
)}
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
block
loading={generating[record.id]}
disabled={
type === "video" &&
(!historyParams[record.id]?.aspectRatio ||
!historyParams[record.id]?.resolution)
}
onClick={async () => {
const params = historyParams[record.id];
// 视频类型需要检查比例和分辨率
if (type === "video") {
if (
!params?.aspectRatio ||
!params?.resolution
) {
message.error("请选择比例和分辨率");
return;
}
if (
userCredits <
calcVideoCredits(
record.duration || 5,
params.resolution,
params.engineId || record.engineId || videoEngines[0]?.id,
Boolean(params.includeMediaReferences ?? record.includeMediaReferences),
record.references,
)
) {
message.error("积分不足,请先充值");
return;
}
}
setGenerating((p) => ({
...p,
[record.id]: true,
}));
setRecordStates((p) => ({
...p,
[record.id]: "generating",
}));
const mediaText =
type === "video" ? "视频" : "图片";
message.loading({
content: `${projectName}」正在生成${mediaText}...`,
duration: 0,
key: record.id,
});
try {
await generateVideo(record.id, {
engineId: params?.engineId || record.engineId || (type === 'image' ? imageEngines[0]?.id : videoEngines[0]?.id),
includeMediaReferences: Boolean(params?.includeMediaReferences ?? record.includeMediaReferences),
aspectRatio: params?.aspectRatio,
resolution: params?.resolution,
imageSize: record.imageSize,
});
startPolling(record.id);
message.success({
content: `${projectName}${mediaText}已提交,正在生成中...`,
key: record.id,
duration: 3,
});
} catch (error: any) {
setRecordStates((p) => ({
...p,
[record.id]: "failed",
}));
const errorMsg = error?.response?.data?.message || error?.message || `${mediaText}生成失败`;
message.error({
content: `${projectName}${errorMsg}`,
key: record.id,
duration: 3,
});
} finally {
setGenerating((p) => ({
...p,
[record.id]: false,
}));
}
}}
style={{
borderRadius: 12,
fontWeight: 600,
color: "#fff",
height: 48,
background:
"linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
border: "none",
position: "absolute",
bottom: 18,
left: 0,
}}
disabled={record.canGenerate === false}
onClick={() => handleGenerate(record.id)}
style={{ borderRadius: 12, fontWeight: 600, height: 48 }}
>
{type === "video" ? "视频" : "图片"}
{type === "video" ? (
<span
style={{
color: "#d2d2d2",
fontSize: "14px",
marginLeft: "6px",
}}
>
()
</span>
) : (
""
)}{" "}
{/* {type === "video" &&
historyParams[record.id]?.resolution
? `(${calcVideoCredits(
record.duration || 5,
historyParams[record.id].resolution as Resolution,
historyParams[record.id].engineId || record.engineId || videoEngines[0]?.id,
Boolean(historyParams[record.id].includeMediaReferences ?? record.includeMediaReferences),
record.references,
)}积分)`
: ""} */}
{type === "video" &&
historyParams[record.id]?.resolution
? `(${calcVideoCredits(
record.duration || 5,
historyParams[record.id].resolution as Resolution,
historyParams[record.id].engineId || record.engineId || videoEngines[0]?.id,
Boolean(historyParams[record.id].includeMediaReferences ?? record.includeMediaReferences),
record.references,
)}积分)`
: ""}
{type === "image" && record.imageSize
? `(${getImageCreditsFromCimage(
record.imageSize,
historyParams[record.id]?.engineId || record.engineId || imageEngines[0]?.id,
Boolean(historyParams[record.id]?.includeMediaReferences ?? record.includeMediaReferences),
record.references,
)}积分)`
: type === "image"
? `(积分)`
: ""}
</Button>
</div>
)}
@@ -4925,73 +4727,12 @@ const GeneratePage: React.FC = () => {
<Typography.Text
style={{ color: "#94a3b8", fontSize: 13 }}
>
{type === "image"
? "待生成图片"
: status === "prompt_optimized"
? "待配置视频参数"
: "待生成视频"}
{type === "image" ? "待生成图片" : "待生成视频"}
</Typography.Text>
</div>
)}
</div>
{/* Generate buttons for history records */}
{/* {status === 'prompt_optimized' && (
<div style={{ marginTop: 16, width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
<div translate="no" style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 12px', borderRadius: 8, background: '#f8f9fc', border: '1px solid #e2e8f0' }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>时长</Typography.Text>
<Typography.Text strong style={{ fontSize: 13, color: '#6366f1' }}>{record.duration}秒</Typography.Text>
</div>
<PortalDropdown label="比例" value={historyParams[record.id]?.aspectRatio || '选择比例'}
options={engineOptions.ratios}
expanded={expandedEngine === `ratio-${record.id}`}
onToggle={() => setExpandedEngine(expandedEngine === `ratio-${record.id}` ? null : `ratio-${record.id}`)}
onSelect={(v) => setHistoryParams(prev => ({ ...prev, [record.id]: { ...prev[record.id], aspectRatio: v as AspectRatio, resolution: prev[record.id]?.resolution || '' } }))}
onClose={() => setExpandedEngine(null)}
/>
<PortalDropdown label="分辨率" value={historyParams[record.id]?.resolution || '选择分辨率'}
options={engineOptions.resolutions}
expanded={expandedEngine === `res-${record.id}`}
onToggle={() => setExpandedEngine(expandedEngine === `res-${record.id}` ? null : `res-${record.id}`)}
onSelect={(v) => setHistoryParams(prev => ({ ...prev, [record.id]: { ...prev[record.id], aspectRatio: prev[record.id]?.aspectRatio || '', resolution: v as Resolution } }))}
onClose={() => setExpandedEngine(null)}
/>
</div>
<Button type="primary" size="large" icon={<RocketOutlined />} block
loading={generating[record.id]}
disabled={!historyParams[record.id]?.aspectRatio || !historyParams[record.id]?.resolution}
onClick={async () => {
const params = historyParams[record.id];
if (!params?.aspectRatio || !params?.resolution) { message.error('请选择比例和分辨率'); return; }
if (userCredits < calcVideoCredits(record.duration || 5, params.resolution)) { message.error('积分不足,请先充值'); return; }
setGenerating((p) => ({ ...p, [record.id]: true }));
setRecordStates((p) => ({ ...p, [record.id]: 'generating' }));
message.loading({ content: `「${projectName}」正在生成视频...`, duration: 0, key: record.id });
try {
await generateVideo(record.id, { engineId: params.engineId || record.engineId || videoEngines[0]?.id, includeMediaReferences: Boolean(params.includeMediaReferences ?? record.includeMediaReferences), aspectRatio: params.aspectRatio, resolution: params.resolution, imageSize: record.imageSize });
setRecordStates((p) => ({ ...p, [record.id]: 'done' }));
message.success({ content: `「${projectName}」视频生成成功!`, key: record.id, duration: 3 });
} catch {
setRecordStates((p) => ({ ...p, [record.id]: 'failed' }));
message.error({ content: `「${projectName}」视频生成失败`, key: record.id, duration: 3 });
} finally {
setGenerating((p) => ({ ...p, [record.id]: false }));
}
}}
style={{ borderRadius: 12, fontWeight: 600, height: 48, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}>
生成视频 {historyParams[record.id]?.resolution ? `(${calcVideoCredits(record.duration || 5, historyParams[record.id].resolution)}积分)` : ''}
</Button>
</div>
)}
{statusState.isFailure && (
<div style={{ marginTop: 16 }}>
<Button type="primary" danger size="large" icon={<PlayCircleOutlined />} block
loading={generating[record.id]}
onClick={() => handleRetryGeneration(record.id)}
style={{ borderRadius: 12, fontWeight: 600, height: 48 }}>重新生成视频</Button>
</div>
)} */}
</div>
</div>
)}
+1
View File
@@ -81,6 +81,7 @@ const MessagesPage: React.FC = () => {
dataIndex: 'content',
key: 'content',
ellipsis: true,
render: (v: string) => <div style={{ maxWidth: 300 }} dangerouslySetInnerHTML={{ __html: v }} />,
},
{
title: '状态',
+42 -96
View File
@@ -4,7 +4,6 @@ import {
Empty,
Input,
message,
Modal,
Pagination,
Select,
Space,
@@ -31,14 +30,14 @@ import {
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/useAppStore';
import type { AspectRatio, Resolution } from '../types';
import { updateRecordPrompt } from '../api';
import { formatDate } from '../utils/formatDate';
import { copyToClipboard } from '../utils/clipboard';
import { resolveGenerationUiState } from '../utils/generationTaskStatus';
const RecordsPage: React.FC = () => {
const navigate = useNavigate();
const { records, projects, fetchRecords, fetchProjects, generateVideo } = useAppStore();
const { records, projects, fetchRecords, fetchProjects, generateVideo, retryGeneration } = useAppStore();
const recordItems = records.items;
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
@@ -48,8 +47,33 @@ const RecordsPage: React.FC = () => {
const [editablePrompts, setEditablePrompts] = useState<Record<string, string>>({});
const [editingRecordId, setEditingRecordId] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
// Generate modal for param selection
const [genModal, setGenModal] = useState<{ recordId: string; projectName: string; ratio: AspectRatio; resolution: Resolution } | null>(null);
const submitFrozenGeneration = async (record: any) => {
const type = record.genType || 'image';
const retry = record.canRetry === true;
if ((!retry && (record.canGenerate === false || !record.configComplete)) || (retry && !record.configComplete)) {
message.error('该记录生成配置不完整,请重新生成提词');
return;
}
setGenerating((prev) => ({ ...prev, [record.id]: true }));
message.loading({ content: `${record.projectName}」正在提交${type === 'video' ? '视频' : '图片'}生成...`, duration: 0, key: record.id });
try {
if (retry) {
await retryGeneration(record.id);
} else {
const editedPrompt = editablePrompts[record.id];
if (editedPrompt && editedPrompt !== record.optimizedPrompt) {
await updateRecordPrompt(record.id, editedPrompt);
}
await generateVideo(record.id);
}
message.success({ content: `${record.projectName}」已提交,正在生成中...`, key: record.id, duration: 3 });
} catch (error: any) {
message.error({ content: error?.response?.data?.detail || error?.message || '提交生成失败', key: record.id, duration: 3 });
} finally {
setGenerating((prev) => ({ ...prev, [record.id]: false }));
}
};
useEffect(() => {
fetchProjects();
@@ -64,48 +88,6 @@ const RecordsPage: React.FC = () => {
});
}, [fetchRecords, filterProject, filterStatus, currentPage, pageSize]);
const handleGenerate = async () => {
if (!genModal) return;
const { recordId, projectName, ratio, resolution } = genModal;
setGenerating((p) => ({ ...p, [recordId]: true }));
setGenModal(null);
message.loading({ content: `${projectName}」正在生成视频...`, duration: 0, key: recordId });
try {
await generateVideo(recordId, { aspectRatio: ratio, resolution });
message.success({ content: `${projectName}」视频生成成功!`, key: recordId, duration: 3 });
} catch {
message.error({ content: `${projectName}」视频生成失败`, key: recordId, duration: 3 });
} finally {
setGenerating((p) => ({ ...p, [recordId]: false }));
}
};
const openGenModal = (record: any) => {
const type: any = record.genType || 'image';
// 如果是图片类型,直接生成,不需要弹窗
if (type === 'image') {
setGenerating((p) => ({ ...p, [record.id]: true }));
message.loading({ content: `${record.projectName}」正在生成图片...`, duration: 0, key: record.id });
generateVideo(record.id, {}).then(() => {
message.success({ content: `${record.projectName}」图片生成成功!`, key: record.id, duration: 3 });
}).catch(() => {
message.error({ content: `${record.projectName}」图片生成失败`, key: record.id, duration: 3 });
}).finally(() => {
setGenerating((p) => ({ ...p, [record.id]: false }));
});
return;
}
// 如果是视频类型,显示参数选择弹窗
setGenModal({
recordId: record.id,
projectName: record.projectName,
ratio: (record.aspectRatio as AspectRatio) || '16:9',
resolution: (record.resolution as Resolution) || '720p',
});
};
const filtered = recordItems;
return (
@@ -222,7 +204,7 @@ const RecordsPage: React.FC = () => {
{/* Meta */}
{type === 'image' ? <span className="mobile-meta" translate="no" style={{ fontSize: 12, color: '#94a3b8', flexShrink: 0 }}>
{record.duration ? `${record.imageSize}` : '-'} · {record.imageProportion || '-'} · {record.imagePx || '-'} · <span className="date-display" translate="no">{formatDate(record.createdAt)}</span>
{record.imageSize || '-'} · {record.imageProportion || '-'} · {record.imagePx || '-'} · <span className="date-display" translate="no">{formatDate(record.createdAt)}</span>
</span> : <span className="mobile-meta" translate="no" style={{ fontSize: 12, color: '#94a3b8', flexShrink: 0 }}>
{record.duration ? `${record.duration}` : '-'} · {record.aspectRatio || '-'} · {record.resolution || '-'} · <span className="date-display" translate="no">{formatDate(record.createdAt)}</span>
</span>}
@@ -233,7 +215,8 @@ const RecordsPage: React.FC = () => {
{record.status === 'prompt_optimized' && (
<Button type="primary" size="small" icon={<PlayCircleOutlined />}
loading={isGenerating}
onClick={() => openGenModal(record)}
disabled={!record.configComplete || record.canGenerate === false}
onClick={() => submitFrozenGeneration(record)}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
@@ -250,7 +233,8 @@ const RecordsPage: React.FC = () => {
{uiState.isFailure && (
<Button type="primary" danger size="small" icon={<PlayCircleOutlined />}
loading={isGenerating}
onClick={() => openGenModal(record)}
disabled={!record.configComplete || record.canRetry === false}
onClick={() => submitFrozenGeneration(record)}
style={{ borderRadius: 8 }}>
</Button>
@@ -355,7 +339,7 @@ const RecordsPage: React.FC = () => {
{type === 'image' ? [
{ label: '分辨率', value: record.imageSize ? `${record.imageSize} ` : '-' },
{ label: '画面比例', value: record.imageProportion ? `${record.imageProportion} ` : '-' },
{ label: '画面尺寸', value: record.imageProportion ? `${record.imageProportion} ` : '-' },
{ label: '画面尺寸', value: record.imagePx ? `${record.imagePx} ` : '-' },
{ label: '消耗积分', value: record.creditsCost ? `${record.creditsCost} ` : '-', highlight: !!record.creditsCost },
].map((item, j) => (
<div key={j} style={{ flex: 1 }}>
@@ -364,8 +348,8 @@ const RecordsPage: React.FC = () => {
</div>
)) : [
{ label: '时长', value: record.duration ? `${record.duration}` : '-' },
{ label: '画面比例', value: record.aspectRatio || (record.status === 'prompt_optimized' ? '待选择' : '-') },
{ label: '分辨率', value: record.resolution || (record.status === 'prompt_optimized' ? '待选择' : '-') },
{ label: '画面比例', value: record.aspectRatio || (record.status === 'prompt_optimized' ? '配置缺失' : '-') },
{ label: '分辨率', value: record.resolution || (record.status === 'prompt_optimized' ? '配置缺失' : '-') },
{ label: '消耗积分', value: record.creditsCost ? `${record.creditsCost}` : '-', highlight: !!record.creditsCost },
].map((item, j) => (
<div key={j} style={{ flex: 1 }}>
@@ -380,13 +364,14 @@ const RecordsPage: React.FC = () => {
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" size="large" icon={<RocketOutlined />}
loading={isGenerating}
onClick={() => openGenModal(record)}
disabled={!record.configComplete || record.canGenerate === false}
onClick={() => submitFrozenGeneration(record)}
style={{
borderRadius: 12, fontWeight: 600, height: 44,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.3)',
}}>
{type === 'video' ? '选择参数生成视频' : '生成图片'}
{type === 'video' ? '生成视频' : '生成图片'}
</Button>
</div>
)}
@@ -396,7 +381,8 @@ const RecordsPage: React.FC = () => {
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" danger size="large" icon={<PlayCircleOutlined />}
loading={isGenerating}
onClick={() => openGenModal(record)}
disabled={!record.configComplete || record.canRetry === false}
onClick={() => submitFrozenGeneration(record)}
style={{ borderRadius: 12, fontWeight: 600, height: 44 }}>
{type === 'video' ? '视频' : '图片'}
</Button>
@@ -509,46 +495,6 @@ const RecordsPage: React.FC = () => {
</div>
)}
{/* Generate modal */}
<Modal
title={<Space><RocketOutlined /></Space>}
open={!!genModal}
onCancel={() => setGenModal(null)}
onOk={handleGenerate}
okText="提交生成"
cancelText="取消"
width={420}
>
{genModal && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
{(() => {
const rec = recordItems.find(r => r.id === genModal.recordId);
return (
<>
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}></Typography.Text>
<Typography.Text strong>{rec?.duration || 5}</Typography.Text>
</div>
<div>
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}></Typography.Text>
<Select value={genModal.ratio} onChange={(v) => setGenModal(prev => prev ? { ...prev, ratio: v } : null)}
style={{ width: '100%' }}
options={['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'].map(r => ({ value: r, label: r }))}
/>
</div>
<div>
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}></Typography.Text>
<Select value={genModal.resolution} onChange={(v) => setGenModal(prev => prev ? { ...prev, resolution: v } : null)}
style={{ width: '100%' }}
options={['480p', '720p', '1080p'].map(r => ({ value: r, label: r }))}
/>
</div>
</>
);
})()}
</div>
)}
</Modal>
</div>
);
};
+25 -6
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult, Industry, MediaReference } from '../types';
import type { Project, GenerationRecord, OptimizeParams, OptimizeResult, Industry, MediaReference } from '../types';
import * as api from '../api';
import { useAuthStore } from './useAuthStore';
@@ -41,7 +41,8 @@ interface AppState {
fetchRecords: (params?: api.GetRecordsPageParams) => Promise<void>;
optimizePrompt: (projectId: string, params: OptimizeParams) => Promise<OptimizeResult>;
generateVideo: (recordId: string, params: GenerateParams) => Promise<GenerationRecord>;
generateVideo: (recordId: string) => Promise<GenerationRecord>;
retryGeneration: (recordId: string) => Promise<GenerationRecord>;
updateRecordReferences: (recordId: string, references: MediaReference[]) => void;
// 生成配置状态更新方法
@@ -126,18 +127,36 @@ export const useAppStore = create<AppState>((set, get) => ({
try { await useAuthStore.getState().checkAuth(); } catch { /* */ }
const currentRecords = get().records;
const existingIndex = currentRecords.items.findIndex((item) => item.id === result.record.id);
const nextItems = existingIndex >= 0
? currentRecords.items.map((item) => item.id === result.record.id ? result.record : item)
: [result.record, ...currentRecords.items];
set({
records: {
...currentRecords,
total: currentRecords.total + 1,
items: [result.record, ...currentRecords.items],
total: existingIndex >= 0 ? currentRecords.total : currentRecords.total + 1,
items: nextItems,
},
});
return result;
},
generateVideo: async (recordId, params) => {
const record = await api.generateVideo(recordId, params);
generateVideo: async (recordId) => {
const record = await api.generateVideo(recordId);
try { await useAuthStore.getState().checkAuth(); } catch { /* */ }
const currentRecords = get().records;
set({
records: {
...currentRecords,
items: currentRecords.items.map((r) => (r.id === recordId ? record : r)),
},
});
return record;
},
retryGeneration: async (recordId) => {
const record = await api.retryGeneration(recordId);
try { await useAuthStore.getState().checkAuth(); } catch { /* */ }
const currentRecords = get().records;
+20 -21
View File
@@ -181,7 +181,7 @@ export interface GenerationRecord {
originalPrompt: string;
optimizedPrompt?: string;
duration?: number;
genType?: number;
genType?: 'video' | 'image';
aspectRatio?: AspectRatio;
resolution?: Resolution;
status: GenerationStatus;
@@ -197,38 +197,37 @@ export interface GenerationRecord {
errorMessage?: string;
createdAt: string;
generatedAt?: string;
imageSize: string;
imageProportion: string;
imagePx: string;
imageUrl: string;
imageSize?: string;
imageProportion?: string;
imagePx?: string;
imageUrl?: string;
engineId?: string;
engineName?: string;
engineSnapshot?: Record<string, any>;
includeMediaReferences?: boolean;
configComplete?: boolean;
configRecoverable?: boolean;
configFallbackHint?: string;
canGenerate?: boolean;
canRetry?: boolean;
shouldPoll?: boolean;
clientStatus?: 'prompt_processing' | 'ready' | 'generating' | 'success' | 'failure';
operationPhase?: 'prompt' | 'resource';
}
export interface OptimizeParams {
prompt: string;
duration: number;
genType?: any;
duration?: number;
genType: 'video' | 'image';
engineId: string;
includeMediaReferences?: boolean;
resolution?: string;
aspectRatio?: string;
references?: MediaReference[];
idempotencyKey?: string;
image_size?: any;
image_proportion?: any;
image_px?: any;
video_duration?: number;
video_ratio?: string;
video_resolution?: string;
}
export interface GenerateParams {
engineId?: string;
includeMediaReferences?: boolean;
aspectRatio?: AspectRatio;
resolution?: Resolution;
imageSize?: string;
image_size?: string;
image_proportion?: string;
image_px?: string;
}
export type ModuleGenerationFlowVersion = 'v1' | 'v2';
+50 -85
View File
@@ -2,6 +2,10 @@ export interface GenerationStatusLike {
status?: string | null;
displayStatus?: string | null;
pipelineStage?: string | null;
clientStatus?: string | null;
shouldPoll?: boolean | null;
canGenerate?: boolean | null;
canRetry?: boolean | null;
}
export type GenerationUiColor = 'default' | 'processing' | 'warning' | 'success' | 'error' | 'blue' | 'orange' | 'purple';
@@ -17,42 +21,34 @@ export interface GenerationUiState {
isSuccess: boolean;
isFailure: boolean;
isTerminal: boolean;
canGenerate: boolean;
canRetry: boolean;
shouldPoll: boolean;
}
const ACTIVE_STATUS_KEYS = new Set([
'pending',
'optimizing',
'prompt_optimized',
'generating',
]);
const CLIENT_STATUS_MAP: Record<string, string> = {
prompt_processing: 'optimizing',
ready: 'prompt_optimized',
generating: 'generating',
success: 'completed',
failure: 'failed',
};
const ACTIVE_STATUS_KEYS = new Set(['pending', 'optimizing', 'generating']);
const ACTIVE_PIPELINE_STAGES = new Set([
'queued',
'preparing',
'creating_provider_task',
'provider_result_staged',
'waiting_remote',
'polling',
'result_ready',
'download_queued',
'downloading',
'retry_waiting',
'upscale_queued',
'upscale_processing',
'upscale_polling',
'upscale_downloading',
'upscale_finalizing',
'upscale_retry_waiting',
'queued', 'preparing', 'creating_provider_task', 'provider_result_staged',
'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading',
'retry_waiting', 'recovery_inconsistent', 'upscale_queued', 'upscale_processing',
'upscale_polling', 'upscale_downloading', 'upscale_finalizing', 'upscale_retry_waiting',
]);
const SUCCESS_KEYS = new Set(['completed', 'done']);
const FAILURE_KEYS = new Set(['failed', 'timeout', 'download_failed', 'upscale_failed']);
const TERMINAL_KEYS = new Set([...SUCCESS_KEYS, ...FAILURE_KEYS, 'deleted']);
const LABELS: Record<string, string> = {
pending: '生成中',
optimizing: '生成中',
prompt_optimized: '生成',
pending: '待处理',
optimizing: '提词处理中',
prompt_optimized: '生成',
generating: '生成中',
queued: '生成中',
preparing: '生成中',
@@ -64,15 +60,16 @@ const LABELS: Record<string, string> = {
download_queued: '生成中',
downloading: '生成中',
retry_waiting: '生成中',
recovery_inconsistent: '生成中',
upscale_queued: '生成中',
upscale_processing: '生成中',
upscale_polling: '生成中',
upscale_downloading: '生成中',
upscale_finalizing: '生成中',
upscale_retry_waiting: '生成中',
completed: '已完成',
done: '已完成',
timeout: '生成超时',
completed: '成',
done: '成',
timeout: '失败',
download_failed: '失败',
upscale_failed: '失败',
failed: '失败',
@@ -80,33 +77,15 @@ const LABELS: Record<string, string> = {
};
const COLOR_MAP: Record<string, GenerationUiColor> = {
pending: 'default',
optimizing: 'processing',
prompt_optimized: 'processing',
generating: 'warning',
queued: 'processing',
preparing: 'processing',
creating_provider_task: 'processing',
provider_result_staged: 'processing',
waiting_remote: 'processing',
polling: 'processing',
result_ready: 'processing',
download_queued: 'processing',
downloading: 'processing',
retry_waiting: 'orange',
upscale_queued: 'purple',
upscale_processing: 'purple',
upscale_polling: 'purple',
upscale_downloading: 'purple',
upscale_finalizing: 'purple',
upscale_retry_waiting: 'orange',
completed: 'success',
done: 'success',
failed: 'error',
timeout: 'error',
download_failed: 'error',
upscale_failed: 'error',
deleted: 'default',
pending: 'default', optimizing: 'processing', prompt_optimized: 'blue', generating: 'processing',
queued: 'processing', preparing: 'processing', creating_provider_task: 'processing',
provider_result_staged: 'processing', waiting_remote: 'processing', polling: 'processing',
result_ready: 'processing', download_queued: 'processing', downloading: 'processing',
retry_waiting: 'orange', recovery_inconsistent: 'orange', upscale_queued: 'purple',
upscale_processing: 'purple', upscale_polling: 'purple', upscale_downloading: 'purple',
upscale_finalizing: 'purple', upscale_retry_waiting: 'orange', completed: 'success',
done: 'success', failed: 'error', timeout: 'error', download_failed: 'error',
upscale_failed: 'error', deleted: 'default',
};
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
@@ -121,45 +100,28 @@ export const getGenerationStatusColor = (key?: string | null): GenerationUiColor
return COLOR_MAP[normalized] || 'default';
};
const firstMatching = (values: string[], keys: Set<string>): string => (
values.find((value) => keys.has(value)) || ''
);
export const resolveGenerationUiState = (value: GenerationStatusLike): GenerationUiState => {
const status = normalize(value.status);
const displayStatus = normalize(value.displayStatus);
const pipelineStage = normalize(value.pipelineStage);
const values = [displayStatus, status, pipelineStage].filter(Boolean);
const clientStatus = normalize(value.clientStatus);
const mappedClientStatus = CLIENT_STATUS_MAP[clientStatus] || '';
const failureKey = FAILURE_KEYS.has(pipelineStage)
? pipelineStage
: firstMatching([displayStatus, status], FAILURE_KEYS);
const deletedKey = firstMatching(values, new Set(['deleted']));
const successKey = firstMatching(values, SUCCESS_KEYS);
let effectiveKey = '';
if (failureKey) {
effectiveKey = failureKey;
} else if (deletedKey) {
effectiveKey = deletedKey;
} else if (ACTIVE_PIPELINE_STAGES.has(pipelineStage)) {
let effectiveKey = mappedClientStatus || displayStatus || status || pipelineStage || 'pending';
if (FAILURE_KEYS.has(pipelineStage) || FAILURE_KEYS.has(status) || FAILURE_KEYS.has(displayStatus)) {
effectiveKey = FAILURE_KEYS.has(pipelineStage) ? pipelineStage : (FAILURE_KEYS.has(status) ? status : displayStatus);
} else if (SUCCESS_KEYS.has(status) || SUCCESS_KEYS.has(displayStatus)) {
effectiveKey = SUCCESS_KEYS.has(status) ? status : displayStatus;
} else if (!mappedClientStatus && pipelineStage && ACTIVE_PIPELINE_STAGES.has(pipelineStage)) {
effectiveKey = pipelineStage;
} else if (successKey) {
effectiveKey = successKey;
} else if (pipelineStage) {
effectiveKey = pipelineStage;
} else {
effectiveKey = displayStatus || status || 'pending';
}
const isFailure = FAILURE_KEYS.has(effectiveKey);
const isSuccess = SUCCESS_KEYS.has(effectiveKey);
const isActive = !isFailure && !isSuccess && effectiveKey !== 'deleted' && (
ACTIVE_PIPELINE_STAGES.has(pipelineStage)
|| ACTIVE_STATUS_KEYS.has(displayStatus)
|| ACTIVE_STATUS_KEYS.has(status)
|| ACTIVE_PIPELINE_STAGES.has(effectiveKey)
);
const shouldPoll = typeof value.shouldPoll === 'boolean'
? value.shouldPoll
: (!isFailure && !isSuccess && (ACTIVE_STATUS_KEYS.has(status) || ACTIVE_PIPELINE_STAGES.has(pipelineStage)));
const isActive = shouldPoll;
return {
status,
@@ -172,6 +134,9 @@ export const resolveGenerationUiState = (value: GenerationStatusLike): Generatio
isSuccess,
isFailure,
isTerminal: TERMINAL_KEYS.has(effectiveKey),
canGenerate: typeof value.canGenerate === 'boolean' ? value.canGenerate : status === 'prompt_optimized',
canRetry: typeof value.canRetry === 'boolean' ? value.canRetry : isFailure,
shouldPoll,
};
};