完成sun分支功能

This commit is contained in:
sjy
2026-08-10 10:39:01 +08:00
parent 5d2c63ced1
commit e4fe42991d
14 changed files with 3967 additions and 247 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

+1 -1
View File
@@ -27,7 +27,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title> <title>民众智创</title>
<script type="module" crossorigin src="/assets/index-BioNCQJL.js"></script> <script type="module" crossorigin src="/assets/index-BuKny7Fe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-IrbjYl3V.css"> <link rel="stylesheet" crossorigin href="/assets/index-IrbjYl3V.css">
</head> </head>
<body> <body>
+5 -1
View File
@@ -1 +1,5 @@
{} {
"src/pages/InvoicePage.tsx": {
"description": "申请开票"
}
}
+14 -2
View File
@@ -27,7 +27,6 @@ import MaterialListPage from './pages/MaterialListPage';
import RemoveInfo from './pages/RemoveInfo'; import RemoveInfo from './pages/RemoveInfo';
import RemoveRw from './pages/RemoveRw'; import RemoveRw from './pages/RemoveRw';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
// import RemoveFenbu from './pages/RemoveFenbu';
import ConsumePage from './pages/ConsumePage'; import ConsumePage from './pages/ConsumePage';
import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage'; import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage';
import PopularPage from './pages/PopularPage'; import PopularPage from './pages/PopularPage';
@@ -35,6 +34,9 @@ import CreativePlazaPage from './pages/CreativePlazaPage';
import TeamManagementPage from './pages/TeamManagementPage'; import TeamManagementPage from './pages/TeamManagementPage';
import JoinTeamPage from './pages/JoinTeamPage'; import JoinTeamPage from './pages/JoinTeamPage';
import PrivatePortraitAuthorizeResult from './pages/PrivatePortraitAuthorizeResult'; import PrivatePortraitAuthorizeResult from './pages/PrivatePortraitAuthorizeResult';
import InvoicePage from './pages/InvoicePage';
import VideovEditing from './pages/VideovEditing';
import VideoProjectList from './pages/VideoProjectList';
import { useAuthStore } from './store/useAuthStore'; import { useAuthStore } from './store/useAuthStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading, checkAuth } = useAuthStore(); const { user, loading, checkAuth } = useAuthStore();
@@ -119,7 +121,6 @@ const App = () => {
<Route path="removelens" element={<RemoveLens />} /> <Route path="removelens" element={<RemoveLens />} />
<Route path="removelens/:creatID/removeinfo" element={<RemoveInfo />} /> <Route path="removelens/:creatID/removeinfo" element={<RemoveInfo />} />
<Route path="removelens/:creatID/removefenbu" element={<RemoveRw />} /> <Route path="removelens/:creatID/removefenbu" element={<RemoveRw />} />
{/* <Route path="removelens/:creatID/removefenbu" element={<RemoveFenbu />} /> */}
<Route path="generated" element={<GeneratedRecord />} /> <Route path="generated" element={<GeneratedRecord />} />
<Route path="pretest" element={<PreTest />} /> <Route path="pretest" element={<PreTest />} />
<Route path="authorization" element={<AuthorizationPage />} /> <Route path="authorization" element={<AuthorizationPage />} />
@@ -131,7 +132,18 @@ const App = () => {
<Route path="authacc" element={<AuthAccountPage />} /> <Route path="authacc" element={<AuthAccountPage />} />
<Route path="creativeplaza" element={<CreativePlazaPage />} /> <Route path="creativeplaza" element={<CreativePlazaPage />} />
<Route path="team-management" element={<TeamManagementPage />} /> <Route path="team-management" element={<TeamManagementPage />} />
<Route path="invoice" element={<InvoicePage />} />
<Route path="videoprojects" element={<VideoProjectList />} />
</Route> </Route>
{/* 视频剪辑页面:独立全屏路由,不使用 AppLayout */}
<Route
path="/videoediting"
element={
<ProtectedRoute>
<VideovEditing />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/projects" replace />} /> <Route path="*" element={<Navigate to="/projects" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
@@ -60,6 +60,7 @@ import {
MenuOutlined, MenuOutlined,
ArrowLeftOutlined, ArrowLeftOutlined,
RobotOutlined, RobotOutlined,
ProfileOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore'; import { useAuthStore } from '../../store/useAuthStore';
@@ -619,6 +620,7 @@ const AppLayout: React.FC = () => {
...(user?.isTeamManager ? [{ key: 'teamManagement' as const, icon: <TeamOutlined style={{ color: '#6366f1' }} />, label: '团队管理' }] : []), ...(user?.isTeamManager ? [{ key: 'teamManagement' as const, icon: <TeamOutlined style={{ color: '#6366f1' }} />, label: '团队管理' }] : []),
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' }, { key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' }, { key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
{ key: 'invoice', icon: <ProfileOutlined />, label: '申请开票' },
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` }, { key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
...(operationManualUrl ? [{ key: 'manual' as const, icon: <FileTextOutlined />, label: '操作手册' }] : []), ...(operationManualUrl ? [{ key: 'manual' as const, icon: <FileTextOutlined />, label: '操作手册' }] : []),
{ type: 'divider' as const }, { type: 'divider' as const },
@@ -635,6 +637,7 @@ const AppLayout: React.FC = () => {
else if (key === 'teamManagement') { navigate('/team-management'); } else if (key === 'teamManagement') { navigate('/team-management'); }
else if (key === 'myCredits') { navigate('/user-center?tab=credits'); } else if (key === 'myCredits') { navigate('/user-center?tab=credits'); }
else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); } else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); }
else if (key === 'invoice') { navigate('/invoice'); }
else if (key === 'manual') { window.open(operationManualUrl, '_blank'); } else if (key === 'manual') { window.open(operationManualUrl, '_blank'); }
}; };
+1 -1
View File
@@ -87,7 +87,7 @@ body {
.content_box { .content_box {
margin: -24px -32px -32px; margin: -24px -32px -32px;
border-radius: 0 0 16px 16px; border-radius: 16px;
height: calc(100vh - 34px); height: calc(100vh - 34px);
overflow: auto; overflow: auto;
scrollbar-width: none; scrollbar-width: none;
+103 -33
View File
@@ -120,15 +120,64 @@ const GenerateConver: React.FC = () => {
setVideoEngines(engines); setVideoEngines(engines);
if (engines.length > 0) { if (engines.length > 0) {
const first = engines[0]; const first = engines[0];
const firstMaxDur = (() => {
const raw = first.maxDuration ?? first.max_duration;
if (raw != null) return Number(raw);
const durs = first.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = first.supportedDurations;
const generatedDurs = Array.from({ length: firstMaxDur - 4 }, (_, i) => i + 5);
const mergedDurs = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setEngineId(first.id); setEngineId(first.id);
setVideoAspectRatio(first.supportedRatios?.includes('9:16') ? '9:16' : first.supportedRatios?.[0] || '9:16'); setVideoAspectRatio(first.supportedRatios?.includes('9:16') ? '9:16' : first.supportedRatios?.[0] || '9:16');
setVideoResolution(first.supportedResolutions?.[0] || '480p'); setVideoResolution(first.supportedResolutions?.[0] || '480p');
setVideoDuration(first.supportedDurations?.[0] || 5); const defaultDur = (rawDurs || []).find((d: number) => d <= firstMaxDur) ?? generatedDurs[0] ?? 5;
setVideoDuration(defaultDur);
setEngineOptions({
ratios: first.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: first.supportedResolutions || ['480p', '720p', '1080p'],
durations: mergedDurs.length > 0 ? mergedDurs : generatedDurs,
});
} }
}).catch(() => message.error('视频引擎加载失败')); }).catch(() => message.error('视频引擎加载失败'));
calculateCredits().then((rules: any) => setCreditRules(Array.isArray(rules) ? rules : [])).catch(() => setCreditRules([])); calculateCredits().then((rules: any) => setCreditRules(Array.isArray(rules) ? rules : [])).catch(() => setCreditRules([]));
}, []); }, []);
// 当引擎变化时,同步 engineOptions 的 durations 以匹配 maxDuration
useEffect(() => {
if (!selectedEngine) return;
const engineMaxDur = (() => {
const raw = selectedEngine.maxDuration ?? selectedEngine.max_duration;
if (raw != null) return Number(raw);
const durs = selectedEngine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = selectedEngine.supportedDurations;
const generatedDurs = Array.from({ length: Math.max(engineMaxDur - 4, 1) }, (_, i) => i + 5);
const mergedDurs = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setEngineOptions(prev => {
const prevDurs = prev.durations;
const maxDur = prevDurs.length > 0 ? Math.max(...prevDurs) : 0;
if (maxDur === engineMaxDur) return prev;
return {
ratios: selectedEngine.supportedRatios || prev.ratios,
resolutions: selectedEngine.supportedResolutions || prev.resolutions,
durations: mergedDurs.length > 0 ? mergedDurs : generatedDurs,
};
});
setVideoDuration(prev => {
if (prev <= engineMaxDur) return prev;
return (rawDurs || []).find((d: number) => d <= engineMaxDur) ?? generatedDurs[0] ?? 5;
});
}, [engineId]);
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@@ -142,6 +191,14 @@ const GenerateConver: React.FC = () => {
}, []); }, []);
const selectedEngine = videoEngines.find((item: any) => item.id === engineId); const selectedEngine = videoEngines.find((item: any) => item.id === engineId);
const currentMaxDuration = (() => {
if (!selectedEngine) return 15;
const raw = selectedEngine.maxDuration ?? selectedEngine.max_duration;
if (raw != null) return Number(raw);
const durs = selectedEngine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const selectedEngineSupportsImage = !imageUrl || supportsReferenceImage(selectedEngine); const selectedEngineSupportsImage = !imageUrl || supportsReferenceImage(selectedEngine);
const estimatedCredits = (() => { const estimatedCredits = (() => {
const rule = creditRules.find((item: any) => item.modelConfigId === engineId && item.genType === 'video' && item.resolution === videoResolution); const rule = creditRules.find((item: any) => item.modelConfigId === engineId && item.genType === 'video' && item.resolution === videoResolution);
@@ -301,8 +358,8 @@ const GenerateConver: React.FC = () => {
resolve(false); resolve(false);
return; return;
} }
if (video.duration >= 16) { if (video.duration > currentMaxDuration) {
message.error('视频时长不能超过15秒'); message.error(`视频时长不能超过${currentMaxDuration}`);
URL.revokeObjectURL(video.src); URL.revokeObjectURL(video.src);
resolve(false); resolve(false);
return; return;
@@ -888,6 +945,7 @@ const GenerateConver: React.FC = () => {
</div> </div>
<div className="replication-form" style={{ <div className="replication-form" style={{
overflow: 'auto', width: '30%', overflow: 'auto', width: '30%',
background: 'transparent', background: 'transparent',
@@ -903,11 +961,15 @@ const GenerateConver: React.FC = () => {
background: 'rgba(258, 250, 252, 0.2)', background: 'rgba(258, 250, 252, 0.2)',
padding: 24, overflowY: 'auto' padding: 24, overflowY: 'auto'
}}> }}>
{/* 上传视频 */} {/* 上传视频 */}
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10 }}> <p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10 }}>
<span style={{ color: '#f94444' }}></span> <span style={{ color: '#f94444' }}></span>
<p style={{ fontSize: 12, color: '#64748b', fontWeight: 400, }}>
{currentMaxDuration}
</p>
</p> </p>
{videoUrl ? ( {videoUrl ? (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
@@ -982,7 +1044,7 @@ const GenerateConver: React.FC = () => {
... ...
</p> </p>
<p style={{ margin: 0, fontSize: 11, color: '#94a3b8', marginTop: 4 }}> <p style={{ margin: 0, fontSize: 11, color: '#94a3b8', marginTop: 4 }}>
MP4MOV | 15 | 50M MP4MOV | {currentMaxDuration} | 50M
</p> </p>
</div> </div>
) : ( ) : (
@@ -1003,7 +1065,7 @@ const GenerateConver: React.FC = () => {
</p> </p>
<p style={{ margin: 0, fontSize: 11, color: '#94a3b8', marginTop: 4 }}> <p style={{ margin: 0, fontSize: 11, color: '#94a3b8', marginTop: 4 }}>
MP4MOV | 15 | 50M MP4MOV | {currentMaxDuration} | 50M
</p> </p>
</> </>
)} )}
@@ -1048,7 +1110,7 @@ const GenerateConver: React.FC = () => {
> >
× ×
</button> </button>
</div> </div>
) : ( ) : (
<UploadSelector <UploadSelector
@@ -1149,8 +1211,8 @@ const GenerateConver: React.FC = () => {
border: '1px solid rgba(99, 102, 241, 0.15)', border: '1px solid rgba(99, 102, 241, 0.15)',
background: 'rgba(255,255,255,0.8)', background: 'rgba(255,255,255,0.8)',
}} }}
maxLength={10} maxLength={50}
suffix={<span style={{ color: '#94a3b8', fontSize: 12 }}>{originalProductName.length}/10</span>} suffix={<span style={{ color: '#94a3b8', fontSize: 12 }}>{originalProductName.length}/50</span>}
/> />
</div> </div>
@@ -1171,8 +1233,8 @@ const GenerateConver: React.FC = () => {
border: '1px solid rgba(99, 102, 241, 0.15)', border: '1px solid rgba(99, 102, 241, 0.15)',
background: 'rgba(255,255,255,0.8)', background: 'rgba(255,255,255,0.8)',
}} }}
maxLength={10} maxLength={50}
suffix={<span style={{ color: '#94a3b8', fontSize: 12 }}>{ownProductName.length}/10</span>} suffix={<span style={{ color: '#94a3b8', fontSize: 12 }}>{ownProductName.length}/50</span>}
/> />
</div> </div>
@@ -1196,16 +1258,16 @@ const GenerateConver: React.FC = () => {
background: 'rgba(255,255,255,0.8)', background: 'rgba(255,255,255,0.8)',
}} }}
rows={3} rows={3}
maxLength={30} maxLength={200}
/> />
<span style={{ position: 'absolute', right: 10, bottom: 8, color: '#94a3b8', fontSize: 12 }}> <span style={{ position: 'absolute', right: 10, bottom: 8, color: '#94a3b8', fontSize: 12 }}>
{productSellingPoints.length}/30 {productSellingPoints.length}/200
</span> </span>
</div> </div>
</div> </div>
<div style={{ marginBottom: 12, display: 'grid', gap: 10 }}> <div style={{ marginBottom: 12, display: 'grid', gap: 10 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 600, color: '#475569' }}> <p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b' }}>
<span style={{ color: '#f94444' }}></span> <span style={{ color: '#f94444' }}></span>
</p> </p>
<div style={{ width: '100%', display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ width: '100%', display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
@@ -1246,7 +1308,7 @@ const GenerateConver: React.FC = () => {
style={{ style={{
position: 'absolute', position: 'absolute',
bottom: 'calc(100% + 8px)', bottom: 'calc(100% + 8px)',
left: -10, left: -19,
width: 300, width: 300,
backgroundColor: '#fff', backgroundColor: '#fff',
borderRadius: 16, borderRadius: 16,
@@ -1278,10 +1340,20 @@ const GenerateConver: React.FC = () => {
type="button" type="button"
onClick={() => { onClick={() => {
setEngineId(engine.id); setEngineId(engine.id);
const engineMaxDur = (() => {
const raw = engine.maxDuration ?? engine.max_duration;
if (raw != null) return Number(raw);
const durs = engine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = engine.supportedDurations;
const generatedDurs = Array.from({ length: engineMaxDur - 4 }, (_, i) => i + 5);
const newDurations = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setEngineOptions({ setEngineOptions({
ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'], ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'], resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'],
durations: engine.supportedDurations || [5, 8, 10, 12, 15], durations: newDurations,
}); });
if (!engine.supportedRatios?.includes(videoAspectRatio)) { if (!engine.supportedRatios?.includes(videoAspectRatio)) {
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9'); setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
@@ -1289,8 +1361,9 @@ const GenerateConver: React.FC = () => {
if (!engine.supportedResolutions?.includes(videoResolution)) { if (!engine.supportedResolutions?.includes(videoResolution)) {
setVideoResolution(engine.supportedResolutions?.[0] || '720p'); setVideoResolution(engine.supportedResolutions?.[0] || '720p');
} }
if (!engine.supportedDurations?.includes(videoDuration)) { if (!newDurations.includes(videoDuration) || videoDuration > engineMaxDur) {
setVideoDuration(engine.supportedDurations?.[0] || 5); const validDur = newDurations.find(d => d <= engineMaxDur) ?? newDurations[0] ?? 5;
setVideoDuration(validDur);
} }
setShowEngineModal(false); setShowEngineModal(false);
}} }}
@@ -1322,11 +1395,6 @@ const GenerateConver: React.FC = () => {
}}> }}>
{engine.name} {engine.name}
</span> </span>
<span style={{
fontSize: 11,
color: '#9ca3af',
}}>
</span>
</button> </button>
))} ))}
</div> </div>
@@ -1436,13 +1504,13 @@ const GenerateConver: React.FC = () => {
<span style={{ <span style={{
fontSize: 9, fontSize: 9,
color: videoAspectRatio === ratio color: videoAspectRatio === ratio
? '#6366f1' ? '#6366f1'
: '#6b7280', : '#6b7280',
fontWeight: videoAspectRatio === ratio ? 600 : 400, fontWeight: videoAspectRatio === ratio ? 600 : 400,
}}> }}>
{ratio} {ratio}
</span> </span>
</button> </button>
); );
})} })}
</div> </div>
@@ -1477,15 +1545,15 @@ const GenerateConver: React.FC = () => {
height: 6, height: 6,
borderRadius: 3, borderRadius: 3,
background: '#6366f1', background: '#6366f1',
width: `${((videoDuration - Math.min(...engineOptions.durations)) / (Math.max(...engineOptions.durations) - Math.min(...engineOptions.durations))) * 100}%`, width: `${((videoDuration - Math.min(...engineOptions.durations)) / (currentMaxDuration - Math.min(...engineOptions.durations))) * 100}%`,
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
}} /> }} />
<input <input
type="range" type="range"
min={Math.min(...engineOptions.durations)} min={Math.min(...engineOptions.durations)}
max={Math.max(...engineOptions.durations)} max={currentMaxDuration}
value={videoDuration} value={videoDuration}
onChange={(e) => setVideoDuration(Number(e.target.value))} onChange={(e) => setVideoDuration(Math.min(Number(e.target.value), currentMaxDuration))}
style={{ style={{
position: 'relative', position: 'relative',
width: '100%', width: '100%',
@@ -1567,6 +1635,8 @@ const GenerateConver: React.FC = () => {
</div> </div>
{/* 立即生成按钮 */} {/* 立即生成按钮 */}
<Tooltip <Tooltip
title={((user?.credits || 0) < (estimatedCredits + optimizeHoldCredits)) ? '积分不足,请更换参数/充值积分' : ''} title={((user?.credits || 0) < (estimatedCredits + optimizeHoldCredits)) ? '积分不足,请更换参数/充值积分' : ''}
File diff suppressed because it is too large Load Diff
+174 -91
View File
@@ -115,18 +115,76 @@ function RemoveInfo() {
setVideoEngines(engines); setVideoEngines(engines);
if (engines.length > 0) { if (engines.length > 0) {
const first = engines[0]; const first = engines[0];
const firstMaxDur = (() => {
const raw = first.maxDuration ?? first.max_duration;
if (raw != null) return Number(raw);
const durs = first.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = first.supportedDurations;
const generatedDurs = Array.from({ length: Math.max(firstMaxDur - 4, 1) }, (_, i) => i + 5);
const mergedDurs = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setEngineId(first.id); setEngineId(first.id);
setVideoAspectRatio(first.supportedRatios?.includes('9:16') ? '9:16' : first.supportedRatios?.[0] || '9:16'); setVideoAspectRatio(first.supportedRatios?.includes('9:16') ? '9:16' : first.supportedRatios?.[0] || '9:16');
setVideoResolution(first.supportedResolutions?.[0] || '480p'); setVideoResolution(first.supportedResolutions?.[0] || '480p');
setVideoDuration(first.supportedDurations?.[0] || 5); const defaultDur = (rawDurs || []).find((d: number) => d <= firstMaxDur) ?? generatedDurs[0] ?? 5;
setVideoDuration(defaultDur);
setEngineOptions({
ratios: first.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: first.supportedResolutions || ['480p', '720p', '1080p'],
durations: mergedDurs.length > 0 ? mergedDurs : generatedDurs,
});
} }
}).catch(() => message.error('视频引擎加载失败')); }).catch(() => message.error('视频引擎加载失败'));
calculateCredits().then((rules: any) => setCreditRules(Array.isArray(rules) ? rules : [])).catch(() => setCreditRules([])); calculateCredits().then((rules: any) => setCreditRules(Array.isArray(rules) ? rules : [])).catch(() => setCreditRules([]));
}, []); }, []);
// 当引擎变化时,同步 engineOptions 的 durations 以匹配 maxDuration
useEffect(() => {
if (!selectedEngine) return;
const engineMaxDur = (() => {
const raw = selectedEngine.maxDuration ?? selectedEngine.max_duration;
if (raw != null) return Number(raw);
const durs = selectedEngine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = selectedEngine.supportedDurations;
const generatedDurs = Array.from({ length: Math.max(engineMaxDur - 4, 1) }, (_, i) => i + 5);
const mergedDurs = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setEngineOptions(prev => {
const prevDurs = prev.durations;
const maxDur = prevDurs.length > 0 ? Math.max(...prevDurs) : 0;
if (maxDur === engineMaxDur) return prev;
return {
ratios: selectedEngine.supportedRatios || prev.ratios,
resolutions: selectedEngine.supportedResolutions || prev.resolutions,
durations: mergedDurs.length > 0 ? mergedDurs : generatedDurs,
};
});
setVideoDuration(prev => {
if (prev <= engineMaxDur) return prev;
return (rawDurs || []).find((d: number) => d <= engineMaxDur) ?? generatedDurs[0] ?? 5;
});
}, [engineId]);
const createIdempotencyRef = useRef<{ segmentId: string; key: string } | null>(null); const createIdempotencyRef = useRef<{ segmentId: string; key: string } | null>(null);
const selectedEngine = videoEngines.find((item: any) => item.id === engineId); const selectedEngine = videoEngines.find((item: any) => item.id === engineId);
// 当前选中引擎支持的最大时长
const currentMaxDuration = (() => {
if (!selectedEngine) return 15;
const raw = selectedEngine.maxDuration ?? selectedEngine.max_duration;
if (raw != null) return Number(raw);
const durs = selectedEngine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const selectedEngineSupportsImage = !productImage || supportsReferenceImage(selectedEngine); const selectedEngineSupportsImage = !productImage || supportsReferenceImage(selectedEngine);
const estimatedCredits = useMemo(() => { const estimatedCredits = useMemo(() => {
const rule = creditRules.find((item: any) => item.modelConfigId === engineId && item.genType === 'video' && item.resolution === videoResolution); const rule = creditRules.find((item: any) => item.modelConfigId === engineId && item.genType === 'video' && item.resolution === videoResolution);
@@ -145,7 +203,17 @@ function RemoveInfo() {
if (!engine) return; if (!engine) return;
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9'); setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
setVideoResolution(engine.supportedResolutions?.[0] || '480p'); setVideoResolution(engine.supportedResolutions?.[0] || '480p');
setVideoDuration(engine.supportedDurations?.[0] || 5); const engineMaxDur = (() => {
const raw = engine.maxDuration ?? engine.max_duration;
if (raw != null) return Number(raw);
const durs = engine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = engine.supportedDurations;
const generatedDurs = Array.from({ length: Math.max(engineMaxDur - 4, 1) }, (_, i) => i + 5);
const mergedDurs = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setVideoDuration(mergedDurs.find((d: number) => d <= engineMaxDur) ?? mergedDurs[0] ?? 5);
}; };
const autoSplitButtonText = useMemo(() => { const autoSplitButtonText = useMemo(() => {
@@ -744,6 +812,90 @@ function RemoveInfo() {
}, },
], []); ], []);
// 将表格部分用 useMemo 缓存,避免输入字段变化时触发 VideoCell 重新挂载
const tableSection = useMemo(() => (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: '#fff',
borderRadius: 16,
overflow: 'hidden',
border: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08)',
}}
>
<div style={{ padding: '16px 24px', borderBottom: '1px solid rgba(99, 102, 241, 0.1)', background: 'linear-gradient(135deg, rgba(99,102,241,0.02) 0%, transparent 100%)' }}>
<h3 style={{ fontSize: 16, fontWeight: 600, color: '#1e293b', margin: 0 }}></h3>
<span style={{ fontSize: 12, color: '#64748b', marginLeft: 12 }}> {tableData.length} </span>
</div>
<Table
columns={columns}
dataSource={tableData}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
},
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
bordered={false}
rowKey="id"
scroll={{ y: 300 }}
style={{ flex: 1 }}
components={{
body: {
row: ({ className, style, ...rest }) => (
<tr
{...rest}
className={className}
style={{
...style,
transition: 'all 0.2s ease',
borderBottom: '1px solid rgba(99, 102, 241, 0.05)',
height: 80,
overflow: 'auto',
}}
/>
),
cell: ({ className, style, ...rest }) => (
<td
{...rest}
className={className}
style={{
...style,
padding: '16px 24px',
}}
/>
),
},
header: {
cell: ({ className, style, ...rest }) => (
<th
{...rest}
className={className}
style={{
...style,
background: 'rgba(255, 255, 255, 1)',
color: '#64748b',
fontWeight: 500,
fontSize: 13,
padding: '16px 24px',
borderBottom: 'none',
}}
/>
),
},
}}
/>
</div>
), [tableData, columns, currentPage, pageSize, total]);
return ( return (
<> <>
<div <div
@@ -1028,87 +1180,7 @@ function RemoveInfo() {
</Popconfirm> </Popconfirm>
</div> </div>
<div {tableSection}
style={{
flex: 2,
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%)',
backdropFilter: 'blur(20px)',
borderRadius: 20,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
border: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08)',
}}
>
<div style={{ padding: '16px 24px', borderBottom: '1px solid rgba(99, 102, 241, 0.1)', background: 'linear-gradient(135deg, rgba(99,102,241,0.02) 0%, transparent 100%)' }}>
<h3 style={{ fontSize: 16, fontWeight: 600, color: '#1e293b', margin: 0 }}></h3>
<span style={{ fontSize: 12, color: '#64748b', marginLeft: 12 }}> {tableData.length} </span>
</div>
<Table
columns={columns}
dataSource={tableData}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
},
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
bordered={false}
rowKey="id"
scroll={{ y: 300 }}
style={{ flex: 1 }}
components={{
body: {
row: ({ className, style, ...rest }) => (
<tr
{...rest}
className={className}
style={{
...style,
transition: 'all 0.2s ease',
borderBottom: '1px solid rgba(99, 102, 241, 0.05)',
height: 80,
overflow: 'auto',
}}
/>
),
cell: ({ className, style, ...rest }) => (
<td
{...rest}
className={className}
style={{
...style,
padding: '16px 24px',
}}
/>
),
},
header: {
cell: ({ className, style, ...rest }) => (
<th
{...rest}
className={className}
style={{
...style,
background: 'rgba(255, 255, 255, 1)',
color: '#64748b',
fontWeight: 500,
fontSize: 13,
padding: '16px 24px',
borderBottom: 'none',
}}
/>
),
},
}}
/>
</div>
</div> </div>
) : ( ) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
@@ -1211,7 +1283,7 @@ function RemoveInfo() {
onChange={(e) => setProductName(e.target.value)} onChange={(e) => setProductName(e.target.value)}
placeholder="请输入项目名称" placeholder="请输入项目名称"
style={{ height: 44, borderRadius: 8, border: '1px solid rgba(99, 102, 241, 0.2)' }} style={{ height: 44, borderRadius: 8, border: '1px solid rgba(99, 102, 241, 0.2)' }}
maxLength={10} maxLength={50}
showCount showCount
/> />
</div> </div>
@@ -1225,7 +1297,7 @@ function RemoveInfo() {
onChange={(e) => setProductSellingPoint(e.target.value)} onChange={(e) => setProductSellingPoint(e.target.value)}
placeholder="请输入项目描述" placeholder="请输入项目描述"
style={{ borderRadius: 8, border: '1px solid rgba(99, 102, 241, 0.2)' }} style={{ borderRadius: 8, border: '1px solid rgba(99, 102, 241, 0.2)' }}
maxLength={100} maxLength={200}
showCount showCount
rows={3} rows={3}
/> />
@@ -1305,10 +1377,20 @@ function RemoveInfo() {
type="button" type="button"
onClick={() => { onClick={() => {
setEngineId(engine.id); setEngineId(engine.id);
const engineMaxDur = (() => {
const raw = engine.maxDuration ?? engine.max_duration;
if (raw != null) return Number(raw);
const durs = engine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = engine.supportedDurations;
const generatedDurs = Array.from({ length: Math.max(engineMaxDur - 4, 1) }, (_, i) => i + 5);
const newDurations = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setEngineOptions({ setEngineOptions({
ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'], ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'], resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'],
durations: engine.supportedDurations || [5, 8, 10, 12, 15], durations: newDurations,
}); });
if (!engine.supportedRatios?.includes(videoAspectRatio)) { if (!engine.supportedRatios?.includes(videoAspectRatio)) {
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9'); setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
@@ -1316,8 +1398,9 @@ function RemoveInfo() {
if (!engine.supportedResolutions?.includes(videoResolution)) { if (!engine.supportedResolutions?.includes(videoResolution)) {
setVideoResolution(engine.supportedResolutions?.[0] || '720p'); setVideoResolution(engine.supportedResolutions?.[0] || '720p');
} }
if (!engine.supportedDurations?.includes(videoDuration)) { if (!newDurations.includes(videoDuration) || videoDuration > engineMaxDur) {
setVideoDuration(engine.supportedDurations?.[0] || 5); const validDur = newDurations.find(d => d <= engineMaxDur) ?? newDurations[0] ?? 5;
setVideoDuration(validDur);
} }
setShowEngineModal(false); setShowEngineModal(false);
}} }}
@@ -1504,15 +1587,15 @@ function RemoveInfo() {
height: 6, height: 6,
borderRadius: 3, borderRadius: 3,
background: '#6366f1', background: '#6366f1',
width: `${((videoDuration - Math.min(...engineOptions.durations)) / (Math.max(...engineOptions.durations) - Math.min(...engineOptions.durations))) * 100}%`, width: `${((videoDuration - Math.min(...engineOptions.durations)) / (currentMaxDuration - Math.min(...engineOptions.durations))) * 100}%`,
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
}} /> }} />
<input <input
type="range" type="range"
min={Math.min(...engineOptions.durations)} min={Math.min(...engineOptions.durations)}
max={Math.max(...engineOptions.durations)} max={currentMaxDuration}
value={videoDuration} value={videoDuration}
onChange={(e) => setVideoDuration(Number(e.target.value))} onChange={(e) => setVideoDuration(Math.min(Number(e.target.value), currentMaxDuration))}
style={{ style={{
position: 'relative', position: 'relative',
width: '100%', width: '100%',
@@ -0,0 +1,333 @@
import React, { useState } from 'react';
import { Button, Card, Empty, Modal, Input, message, Dropdown } from 'antd';
import {
PlusOutlined,
VideoCameraOutlined,
EllipsisOutlined,
DeleteOutlined,
EditOutlined,
FolderOpenOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
const { TextArea } = Input;
interface VideoProject {
id: string;
name: string;
description: string;
createdAt: string;
updatedAt: string;
}
const VideoProjectList: React.FC = () => {
const navigate = useNavigate();
const [projects, setProjects] = useState<VideoProject[]>([
{
id: 'demo-1',
name: '示例项目',
description: '这是一个视频剪辑示例项目',
createdAt: dayjs().subtract(2, 'day').format('YYYY-MM-DD HH:mm'),
updatedAt: dayjs().subtract(1, 'day').format('YYYY-MM-DD HH:mm'),
},
]);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<VideoProject | null>(null);
const [formValues, setFormValues] = useState({ name: '', description: '' });
// 创建项目
const handleCreate = () => {
if (!formValues.name.trim()) {
message.warning('请输入项目名称');
return;
}
const newProject: VideoProject = {
id: `proj-${Date.now()}`,
name: formValues.name.trim(),
description: formValues.description.trim(),
createdAt: dayjs().format('YYYY-MM-DD HH:mm'),
updatedAt: dayjs().format('YYYY-MM-DD HH:mm'),
};
setProjects(prev => [newProject, ...prev]);
setCreateModalOpen(false);
setFormValues({ name: '', description: '' });
message.success('项目创建成功');
// 跳转到剪辑页面
navigate(`/videoediting?id=${newProject.id}`);
};
// 打开重命名弹窗
const handleRenameClick = (project: VideoProject) => {
setEditingProject(project);
setFormValues({ name: project.name, description: project.description });
setRenameModalOpen(true);
};
// 确认重命名
const handleRename = () => {
if (!editingProject) return;
if (!formValues.name.trim()) {
message.warning('请输入项目名称');
return;
}
setProjects(prev => prev.map(p =>
p.id === editingProject.id
? { ...p, name: formValues.name.trim(), description: formValues.description.trim(), updatedAt: dayjs().format('YYYY-MM-DD HH:mm') }
: p
));
setRenameModalOpen(false);
setEditingProject(null);
setFormValues({ name: '', description: '' });
message.success('项目已更新');
};
// 删除项目
const handleDelete = (id: string) => {
setProjects(prev => prev.filter(p => p.id !== id));
message.success('项目已删除');
};
// 打开项目
const handleOpenProject = (project: VideoProject) => {
navigate(`/videoediting?id=${project.id}`);
};
// 右上角操作菜单
const getDropdownItems = (project: VideoProject) => ({
items: [
{
key: 'rename',
icon: <EditOutlined />,
label: '重命名',
onClick: () => handleRenameClick(project),
},
{
key: 'delete',
icon: <DeleteOutlined />,
label: '删除',
danger: true,
onClick: () => {
Modal.confirm({
title: '确认删除',
content: `确定要删除项目「${project.name}」吗?`,
okText: '删除',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => handleDelete(project.id),
});
},
},
],
});
return (
<div className="content_box" style={{ background: '#fff',}}>
{/* 顶部标题栏 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 600, color: '#1e293b' }}>
</h2>
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#64748b' }}>
</p>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
size="large"
onClick={() => {
setFormValues({ name: '', description: '' });
setCreateModalOpen(true);
}}
>
</Button>
</div>
{/* 项目列表 */}
{projects.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<span style={{ color: '#64748b' }}>
</span>
}
>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setFormValues({ name: '', description: '' });
setCreateModalOpen(true);
}}
>
</Button>
</Empty>
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 16,
}}>
{projects.map(project => (
<Card
key={project.id}
hoverable
style={{
borderRadius: 12,
overflow: 'hidden',
transition: 'all 0.2s ease',
border: '1px solid #e8ecf2',
}}
bodyStyle={{ padding: 0 }}
>
{/* 缩略图区域 */}
<div
onClick={() => handleOpenProject(project)}
style={{
height: 160,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
position: 'relative',
}}
>
<VideoCameraOutlined style={{ fontSize: 48, color: 'rgba(255,255,255,0.8)' }} />
<div style={{
position: 'absolute',
top: 8,
right: 8,
}}>
<Dropdown
menu={getDropdownItems(project)}
trigger={['click']}
placement="bottomRight"
>
<Button
type="text"
size="small"
icon={<EllipsisOutlined style={{ color: '#fff', fontSize: 18 }} />}
style={{ background: 'rgba(0,0,0,0.3)', borderRadius: 6 }}
onClick={(e) => e.stopPropagation()}
/>
</Dropdown>
</div>
</div>
{/* 信息区域 */}
<div
onClick={() => handleOpenProject(project)}
style={{ padding: '14px 16px', cursor: 'pointer' }}
>
<div style={{
fontSize: 15,
fontWeight: 600,
color: '#1e293b',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginBottom: 4,
}}>
{project.name}
</div>
<div style={{
fontSize: 12,
color: '#94a3b8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginBottom: 8,
}}>
{project.description || '暂无描述'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 12, color: '#94a3b8' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<ClockCircleOutlined />
{project.updatedAt}
</span>
</div>
</div>
</Card>
))}
</div>
)}
{/* 创建项目弹窗 */}
<Modal
title="创建视频剪辑项目"
open={createModalOpen}
onOk={handleCreate}
onCancel={() => setCreateModalOpen(false)}
okText="创建并开始剪辑"
cancelText="取消"
width={480}
>
<div style={{ paddingTop: 8 }}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 6, fontSize: 13, fontWeight: 500, color: '#475569' }}></label>
<Input
placeholder="请输入项目名称"
value={formValues.name}
onChange={e => setFormValues(prev => ({ ...prev, name: e.target.value }))}
maxLength={30}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontSize: 13, fontWeight: 500, color: '#475569' }}></label>
<TextArea
placeholder="请输入项目描述"
value={formValues.description}
onChange={e => setFormValues(prev => ({ ...prev, description: e.target.value }))}
rows={3}
maxLength={100}
/>
</div>
</div>
</Modal>
{/* 重命名弹窗 */}
<Modal
title="编辑项目"
open={renameModalOpen}
onOk={handleRename}
onCancel={() => { setRenameModalOpen(false); setEditingProject(null); }}
okText="保存"
cancelText="取消"
width={480}
>
<div style={{ paddingTop: 8 }}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 6, fontSize: 13, fontWeight: 500, color: '#475569' }}></label>
<Input
placeholder="请输入项目名称"
value={formValues.name}
onChange={e => setFormValues(prev => ({ ...prev, name: e.target.value }))}
maxLength={30}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontSize: 13, fontWeight: 500, color: '#475569' }}></label>
<TextArea
placeholder="请输入项目描述"
value={formValues.description}
onChange={e => setFormValues(prev => ({ ...prev, description: e.target.value }))}
rows={3}
maxLength={100}
/>
</div>
</div>
</Modal>
</div>
);
};
export default VideoProjectList;
File diff suppressed because it is too large Load Diff