Merge branch 'sun'

This commit is contained in:
sjy
2026-08-10 10:42:09 +08:00
16 changed files with 3848 additions and 815 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

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

Before

Width:  |  Height:  |  Size: 1.2 MiB

-14
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 897 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

-36
View File
@@ -1,36 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" id="favicon" />
<script>
// 立即从 localStorage 设置 favicon,避免闪烁
(function() {
try {
var cached = localStorage.getItem('siteInfo');
if (cached) {
var info = JSON.parse(cached);
if (info.siteLogo) {
var link = document.getElementById('favicon');
link.href = info.siteLogo;
link.type = 'image/png';
}
if (info.siteName) {
document.title = info.siteName;
}
}
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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" />
<title>民众智创</title>
<script type="module" crossorigin src="/assets/index-MRCff8OF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KEztuQj6.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+5
View File
@@ -1,5 +1,10 @@
{ {
<<<<<<< HEAD
"src/pages/CreativePlazaPage.tsx": { "src/pages/CreativePlazaPage.tsx": {
"description": "创意广场" "description": "创意广场"
=======
"src/pages/InvoicePage.tsx": {
"description": "申请开票"
>>>>>>> sun
} }
} }
+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>
@@ -62,6 +62,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';
@@ -692,6 +693,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 },
@@ -708,6 +710,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