样式优化
This commit is contained in:
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
Generated
+10
@@ -12,6 +12,7 @@
|
||||
"antd": "^6.3.7",
|
||||
"dayjs": "^1.11.20",
|
||||
"plyr-react": "^6.0.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
@@ -3997,6 +3998,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"antd": "^6.3.7",
|
||||
"dayjs": "^1.11.20",
|
||||
"plyr-react": "^6.0.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -8,10 +8,24 @@ import ProjectsPage from './pages/ProjectsPage';
|
||||
import GeneratePage from './pages/GeneratePage';
|
||||
import RecordsPage from './pages/RecordsPage';
|
||||
import CreditsPage from './pages/CreditsPage';
|
||||
import GenerateConver from './pages/GenerateConver';
|
||||
import InitialReplication from './pages/InitialReplication';
|
||||
import RemoveLens from './pages/RemoveLens';
|
||||
|
||||
|
||||
|
||||
|
||||
import { useAuthStore } from './store/useAuthStore';
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { user, loading } = useAuthStore();
|
||||
const { user, loading, checkAuth } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token && !loading && !user) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}, [user, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -20,11 +34,10 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
window.location.href = '/login';
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -75,6 +88,12 @@ const App = () => {
|
||||
<Route path="projects/:projectId/generate" element={<GeneratePage />} />
|
||||
<Route path="records" element={<RecordsPage />} />
|
||||
<Route path="credits" element={<CreditsPage />} />
|
||||
<Route path="conversation" element={<GenerateConver />} />
|
||||
<Route path="initial" element={<InitialReplication />} />
|
||||
<Route path="removelens" element={<RemoveLens />} />
|
||||
|
||||
|
||||
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -110,6 +110,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
const msg = parsed?.detail || `请求失败 (${res.status})`;
|
||||
if (res.status === 401) {
|
||||
clearToken();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
@@ -77,11 +77,15 @@ export async function optimizePrompt(
|
||||
): Promise<OptimizeResult> {
|
||||
if (USE_MOCK) return mock.mockOptimizePrompt(projectId, params as any);
|
||||
return api.post('/generation-records/optimize', {
|
||||
project_id: projectId,
|
||||
project_id: projectId,//项目id
|
||||
gen_type:params.genType,//生成类型
|
||||
prompt: params.prompt,
|
||||
duration: params.duration,
|
||||
references: params.references || null,
|
||||
idempotency_key: params.idempotencyKey || null,
|
||||
image_size: params.image_size || null,
|
||||
image_proportion: params.image_proportion || null,
|
||||
image_px: params.image_px || null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,8 +164,13 @@ export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: strin
|
||||
|
||||
export async function getVideoEngines(): Promise<{ items: { id: string; name: string; provider: string; supportedRatios: string[]; supportedResolutions: string[]; supportedDurations: number[] }[] }> {
|
||||
if (USE_MOCK) return { items: [{ id: 'mock', name: 'Seedance', provider: 'seedance', supportedRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], supportedResolutions: ['480p', '720p', '1080p'], supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] }] };
|
||||
return api.get('/video-engines', false);
|
||||
return api.get('/video-engines');
|
||||
}
|
||||
// 参数选择(图片)Parameters
|
||||
export async function getParameters(): Promise<any[]> {
|
||||
return api.get('/image-engines');
|
||||
}
|
||||
|
||||
|
||||
// ── SMS ───────────────────────────────────────────────────
|
||||
|
||||
@@ -266,3 +275,11 @@ export async function getMenuConfigs(): Promise<any[]> {
|
||||
export async function getRechargePackages(): Promise<any[]> {
|
||||
return api.get('/recharge-packages');
|
||||
}
|
||||
|
||||
|
||||
export async function getCreditRatios(): Promise<any[]> {
|
||||
return api.get('/credits/ratios');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CreditRecord,
|
||||
Project,
|
||||
GenerationRecord,
|
||||
GenerateParams,
|
||||
OptimizeParams,
|
||||
OptimizeResult,
|
||||
LoginParams,
|
||||
@@ -55,10 +56,14 @@ let MOCK_RECORDS: GenerationRecord[] = [
|
||||
resolution: '1080p',
|
||||
status: 'completed',
|
||||
videoUrl: 'https://example.com/video1.mp4',
|
||||
textCreditsCost: 10,
|
||||
textTokensUsed: 850,
|
||||
creditsCost: 120,
|
||||
videoTokensUsed: 0,
|
||||
textCreditsCost: 0,
|
||||
textTokensUsed: 0,
|
||||
videoTokensUsed: 120,
|
||||
imageSize: '1080p',
|
||||
imageProportion: '9:16',
|
||||
imagePx: '1080x1920',
|
||||
imageUrl: '',
|
||||
createdAt: '2026-04-29 14:22:00',
|
||||
generatedAt: '2026-04-29 14:25:00',
|
||||
},
|
||||
@@ -73,10 +78,14 @@ let MOCK_RECORDS: GenerationRecord[] = [
|
||||
resolution: '1080p',
|
||||
status: 'completed',
|
||||
videoUrl: 'https://example.com/video2.mp4',
|
||||
textCreditsCost: 8,
|
||||
textTokensUsed: 720,
|
||||
creditsCost: 80,
|
||||
videoTokensUsed: 0,
|
||||
textCreditsCost: 0,
|
||||
textTokensUsed: 0,
|
||||
videoTokensUsed: 80,
|
||||
imageSize: '1080p',
|
||||
imageProportion: '16:9',
|
||||
imagePx: '1920x1080',
|
||||
imageUrl: '',
|
||||
createdAt: '2026-04-30 09:15:00',
|
||||
generatedAt: '2026-04-30 09:18:00',
|
||||
},
|
||||
@@ -90,10 +99,14 @@ let MOCK_RECORDS: GenerationRecord[] = [
|
||||
aspectRatio: '16:9',
|
||||
resolution: '4K',
|
||||
status: 'prompt_optimized',
|
||||
textCreditsCost: 12,
|
||||
textTokensUsed: 960,
|
||||
creditsCost: 120,
|
||||
videoTokensUsed: 0,
|
||||
textCreditsCost: 0,
|
||||
textTokensUsed: 0,
|
||||
videoTokensUsed: 120,
|
||||
imageSize: '4K',
|
||||
imageProportion: '16:9',
|
||||
imagePx: '3840x2160',
|
||||
imageUrl: '',
|
||||
createdAt: '2026-05-01 16:40:00',
|
||||
},
|
||||
{
|
||||
@@ -106,10 +119,14 @@ let MOCK_RECORDS: GenerationRecord[] = [
|
||||
aspectRatio: '16:9',
|
||||
resolution: '4K',
|
||||
status: 'prompt_optimized',
|
||||
textCreditsCost: 10,
|
||||
textTokensUsed: 880,
|
||||
creditsCost: 100,
|
||||
videoTokensUsed: 0,
|
||||
textCreditsCost: 0,
|
||||
textTokensUsed: 0,
|
||||
videoTokensUsed: 100,
|
||||
imageSize: '4K',
|
||||
imageProportion: '16:9',
|
||||
imagePx: '3840x2160',
|
||||
imageUrl: '',
|
||||
createdAt: '2026-05-03 08:30:00',
|
||||
},
|
||||
];
|
||||
@@ -169,15 +186,14 @@ export async function mockDeleteProject(id: string): Promise<void> {
|
||||
export async function mockOptimizePrompt(
|
||||
projectId: string,
|
||||
params: OptimizeParams
|
||||
): Promise<OptimizeResult> {
|
||||
): Promise<OptimizeResult & { record: GenerationRecord }> {
|
||||
await delay(1500);
|
||||
|
||||
const project = MOCK_PROJECTS.find((p) => p.id === projectId);
|
||||
const textCredits = Math.max(1, Math.ceil(params.prompt.length * 0.3));
|
||||
const textTokens = Math.round(params.prompt.length * 1.2);
|
||||
const cost = Math.round(80 + params.prompt.length * 0.5 + params.duration * 2);
|
||||
|
||||
if (currentUser) {
|
||||
currentUser.credits -= textCredits;
|
||||
currentUser.credits -= cost;
|
||||
}
|
||||
|
||||
const optimizedPromptMap: Record<string, string> = {
|
||||
@@ -195,7 +211,7 @@ export async function mockOptimizePrompt(
|
||||
if (matched) {
|
||||
optimizedPrompt = optimizedPromptMap[matched];
|
||||
} else {
|
||||
optimizedPrompt = `精心构图的画面中,${params.prompt}。采用电影级镜头语言,自然光线与人工光源完美结合,营造出沉浸式视觉体验。画面色彩饱满而真实,细节丰富。运镜流畅自然,节奏张弛有度,完美适配${params.duration}秒时长。`;
|
||||
optimizedPrompt = `精心构图的画面中,${params.prompt}。采用电影级镜头语言,自然光线与人工光源完美结合,营造出沉浸式视觉体验。画面色彩饱满而真实,细节丰富。运镜流畅自然,节奏张弛有度,完美适配${params.duration}秒时长。${params.aspectRatio}比例构图,${params.resolution}高清画质呈现。`;
|
||||
}
|
||||
|
||||
const record: GenerationRecord = {
|
||||
@@ -205,17 +221,23 @@ export async function mockOptimizePrompt(
|
||||
originalPrompt: params.prompt,
|
||||
optimizedPrompt,
|
||||
duration: params.duration,
|
||||
aspectRatio: params.aspectRatio as any,
|
||||
resolution: params.resolution as any,
|
||||
status: 'prompt_optimized',
|
||||
textCreditsCost: textCredits,
|
||||
textTokensUsed: textTokens,
|
||||
creditsCost: 0,
|
||||
creditsCost: cost,
|
||||
textCreditsCost: cost,
|
||||
textTokensUsed: 0,
|
||||
videoTokensUsed: 0,
|
||||
imageSize: params.resolution || '1080p',
|
||||
imageProportion: params.aspectRatio || '16:9',
|
||||
imagePx: '1920x1080',
|
||||
imageUrl: '',
|
||||
createdAt: new Date().toLocaleString('zh-CN'),
|
||||
};
|
||||
|
||||
MOCK_RECORDS.unshift(record);
|
||||
|
||||
return { optimizedPrompt, textCreditsCost: textCredits, textTokensUsed: textTokens, record };
|
||||
return { optimizedPrompt, textCreditsCost: cost, textTokensUsed: 0, record };
|
||||
}
|
||||
|
||||
export async function mockGenerateVideo(recordId: string): Promise<GenerationRecord> {
|
||||
@@ -241,10 +263,10 @@ export async function mockGetGenerationRecords(projectId?: string): Promise<Gene
|
||||
// ── Admin Mock Data ───────────────────────────────────
|
||||
|
||||
const MOCK_ADMIN_USERS: AdminUser[] = [
|
||||
{ id: 'u-001', username: 'videomaker', email: 'demo@videogen.ai', credits: 2680, isActive: true, isAdmin: false, userType: 'normal', createdAt: '2026-04-15', lastLoginAt: '2026-05-06 14:30' },
|
||||
{ id: 'u-002', username: 'designer', email: 'designer@example.com', credits: 520, isActive: true, isAdmin: false, userType: 'normal', createdAt: '2026-04-20', lastLoginAt: '2026-05-05 09:12' },
|
||||
{ id: 'u-003', username: 'marketer', email: 'mkt@company.com', phone: '13800138000', credits: 0, isActive: true, isAdmin: false, userType: 'normal', createdAt: '2026-04-25', lastLoginAt: '2026-05-04 16:45' },
|
||||
{ id: 'u-004', username: 'editor', email: 'editor@studio.com', credits: 1500, isActive: false, isAdmin: false, userType: 'normal', createdAt: '2026-04-10', lastLoginAt: '2026-04-28 11:20' },
|
||||
{ id: 'u-001', username: 'videomaker', email: 'demo@videogen.ai', credits: 2680, isActive: true, isAdmin: false, userType: 'regular', createdAt: '2026-04-15', lastLoginAt: '2026-05-06 14:30' },
|
||||
{ id: 'u-002', username: 'designer', email: 'designer@example.com', credits: 520, isActive: true, isAdmin: false, userType: 'regular', createdAt: '2026-04-20', lastLoginAt: '2026-05-05 09:12' },
|
||||
{ id: 'u-003', username: 'marketer', email: 'mkt@company.com', phone: '13800138000', credits: 0, isActive: true, isAdmin: false, userType: 'regular', createdAt: '2026-04-25', lastLoginAt: '2026-05-04 16:45' },
|
||||
{ id: 'u-004', username: 'editor', email: 'editor@studio.com', credits: 1500, isActive: false, isAdmin: false, userType: 'regular', createdAt: '2026-04-10', lastLoginAt: '2026-04-28 11:20' },
|
||||
{ id: 'u-005', username: 'admin', email: 'admin@videogen.ai', credits: 10000, isActive: true, isAdmin: true, userType: 'admin', createdAt: '2026-04-01', lastLoginAt: '2026-05-07 08:00' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography } from 'antd';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
WalletOutlined,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
FireFilled,
|
||||
CrownFilled,
|
||||
BankFilled,
|
||||
QrcodeOutlined,
|
||||
CloseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
@@ -81,6 +83,8 @@ const AppLayout: React.FC = () => {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [siteName, setSiteName] = useState('VideoGen.AI');
|
||||
const [siteLogo, setSiteLogo] = useState('');
|
||||
const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false);
|
||||
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSiteInfo().then(info => {
|
||||
@@ -240,7 +244,7 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<div style={{ flex: 1, padding: '8px 8px', overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
|
||||
{!collapsed && (
|
||||
<div style={{ color: 'rgba(148,163,184,0.4)', fontSize: 11, fontWeight: 600, padding: '8px 12px 6px', letterSpacing: 1 }}>
|
||||
导航
|
||||
@@ -500,7 +504,27 @@ const AppLayout: React.FC = () => {
|
||||
<div style={{ marginTop: 20, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}>取消</Button>
|
||||
<Button type="primary" size="large" disabled={!selectedPlan}
|
||||
onClick={() => { message.success('支付功能对接后端后开放'); setRechargeModalOpen(false); setSelectedPlan(null); }}
|
||||
onClick={() => {
|
||||
const plan = rechargeOptions.find((opt: any) => opt.id === selectedPlan);
|
||||
if (plan) {
|
||||
const totalCredits = (plan.credits || 0) + (plan.bonus_credits || plan.bonusCredits || 0);
|
||||
// 生成随机支付内容(模拟微信支付订单号)
|
||||
const orderId = `WX${Date.now()}${Math.random().toString(36).substr(2, 6).toUpperCase()}`;
|
||||
const paymentContent = JSON.stringify({
|
||||
orderId,
|
||||
amount: plan.price,
|
||||
credits: totalCredits,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
setCurrentPaymentInfo({
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
qrCode: paymentContent
|
||||
});
|
||||
setRechargeModalOpen(false);
|
||||
setQrCodeModalOpen(true);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 10, fontWeight: 600,
|
||||
background: selectedPlan ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : '#d1d5db',
|
||||
@@ -512,6 +536,125 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* QR Code Payment Modal */}
|
||||
<Modal
|
||||
open={qrCodeModalOpen}
|
||||
onCancel={() => { setQrCodeModalOpen(false); setCurrentPaymentInfo(null); }}
|
||||
footer={null}
|
||||
width={400}
|
||||
closable={false}
|
||||
styles={{
|
||||
body: { padding: 0, borderRadius: 16, overflow: 'hidden' },
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '24px' }}>
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
borderRadius: 16,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
margin: '0 auto 12px',
|
||||
}}>
|
||||
<QrcodeOutlined style={{ fontSize: 24, color: '#fff' }} />
|
||||
</div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>微信支付</Typography.Title>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 13 }}>请使用微信扫描二维码完成支付</Typography.Text>
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 180,
|
||||
height: 180,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{currentPaymentInfo && (
|
||||
<QRCodeSVG
|
||||
value={currentPaymentInfo.qrCode}
|
||||
size={160}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<div style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: '#1a1a2e',
|
||||
}}>
|
||||
¥{currentPaymentInfo?.price || 0}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 13,
|
||||
color: '#64748b',
|
||||
marginTop: 4,
|
||||
}}>
|
||||
购买 {currentPaymentInfo?.credits || 0} 积分
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div style={{ marginTop: 20, padding: 16, background: '#fef3c7', borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{ fontSize: 16, marginTop: -2 }}>💡</div>
|
||||
<div style={{ fontSize: 13, color: '#92400e' }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>支付提示</div>
|
||||
<ul style={{ margin: 0, paddingLeft: 16 }}>
|
||||
<li style={{ marginBottom: 2 }}>请在支付后等待页面自动跳转</li>
|
||||
<li>如支付成功但未到账,请联系客服</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Buttons */}
|
||||
<div style={{ marginTop: 20, display: 'flex', gap: 12 }}>
|
||||
<Button
|
||||
size="large"
|
||||
onClick={() => { setQrCodeModalOpen(false); setCurrentPaymentInfo(null); }}
|
||||
style={{ flex: 1, borderRadius: 10 }}
|
||||
>
|
||||
取消支付
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
message.success('支付成功!积分已到账');
|
||||
setQrCodeModalOpen(false);
|
||||
setCurrentPaymentInfo(null);
|
||||
setSelectedPlan(null);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 10,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
已完成支付
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Message Center Modal */}
|
||||
<Modal
|
||||
open={msgModalOpen}
|
||||
|
||||
@@ -210,11 +210,133 @@ html, body {
|
||||
|
||||
/* Video player fits mobile */
|
||||
video { max-width: 100% !important; }
|
||||
|
||||
/* InitialReplication page */
|
||||
.replication-container {
|
||||
flex-direction: column !important;
|
||||
gap: 16px !important;
|
||||
padding: 16px !important;
|
||||
height: calc(100vh - 64px) !important;
|
||||
}
|
||||
.replication-preview {
|
||||
width: 100% !important;
|
||||
height: 30vh !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
.replication-form {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
max-height: calc(70vh - 32px) !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
.replication-form-content {
|
||||
padding: 16px !important;
|
||||
max-height: calc(70vh - 64px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Date Display ──────────────────────── */
|
||||
.date-display { font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; }
|
||||
|
||||
/* ── Small Mobile Breakpoint (max-width: 480px) ────────────── */
|
||||
@media (max-width: 480px) {
|
||||
/* Base styles */
|
||||
html, body { font-size: 14px !important; }
|
||||
|
||||
/* Login page */
|
||||
.login-card { width: 100% !important; }
|
||||
.login-right { padding: 16px !important; }
|
||||
|
||||
/* Header adjustments */
|
||||
.gen-header { padding: 12px 14px !important; }
|
||||
|
||||
/* Form elements */
|
||||
.gen-form-row { gap: 8px !important; }
|
||||
|
||||
/* Button adjustments */
|
||||
.ant-btn { height: 36px !important; font-size: 13px !important; padding: 0 12px !important; }
|
||||
|
||||
/* Input adjustments */
|
||||
.ant-input, .ant-input-affix-wrapper, .ant-select-selector {
|
||||
height: 36px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* Card padding */
|
||||
.ant-card { padding: 12px !important; }
|
||||
|
||||
/* Mobile bottom nav adjustments */
|
||||
.mobile-bottom-nav { height: 56px !important; }
|
||||
.mobile-bottom-nav .nav-item .nav-icon { font-size: 18px !important; }
|
||||
.mobile-bottom-nav .nav-item { font-size: 9px !important; gap: 2px !important; }
|
||||
|
||||
/* InitialReplication page - Small Mobile */
|
||||
.replication-container {
|
||||
padding: 12px !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
.replication-preview {
|
||||
height: 25vh !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
.replication-form-content {
|
||||
padding: 12px !important;
|
||||
}
|
||||
.replication-form-content .ant-btn {
|
||||
height: 36px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
.replication-form-content .ant-input,
|
||||
.replication-form-content .ant-input-affix-wrapper {
|
||||
height: 36px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* Video and image containers */
|
||||
video, img {
|
||||
max-height: 160px !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
/* Upload areas */
|
||||
.upload-area { padding: 12px !important; }
|
||||
|
||||
/* Spacing adjustments */
|
||||
.mobile-p-16 { padding: 12px !important; }
|
||||
.mobile-gap-12 { gap: 8px !important; }
|
||||
|
||||
/* Font size adjustments */
|
||||
h1 { font-size: 18px !important; }
|
||||
h2 { font-size: 16px !important; }
|
||||
h3 { font-size: 14px !important; }
|
||||
p { font-size: 12px !important; }
|
||||
|
||||
/* Button text */
|
||||
.btn-text-sm { font-size: 12px !important; }
|
||||
|
||||
/* Margin adjustments */
|
||||
.mobile-mb-8 { margin-bottom: 8px !important; }
|
||||
.mobile-mb-12 { margin-bottom: 12px !important; }
|
||||
}
|
||||
|
||||
/* ── Tablet Breakpoint (768px to 1024px) ────────────── */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
/* Desktop sidebar adjustments */
|
||||
.desktop-sidebar { width: 180px !important; }
|
||||
.desktop-content { margin-left: 180px !important; }
|
||||
|
||||
/* Card max-width */
|
||||
.ant-card { max-width: calc(50% - 8px) !important; }
|
||||
|
||||
/* Font adjustments */
|
||||
html, body { font-size: 15px !important; }
|
||||
}
|
||||
|
||||
/* ── Large Desktop Breakpoint (min-width: 1200px) ────────────── */
|
||||
@media (min-width: 1200px) {
|
||||
/* Max width container */
|
||||
.max-w-container { max-width: 1400px; margin: 0 auto; }
|
||||
|
||||
/* Card grid */
|
||||
.card-grid { grid-template-columns: repeat(3, 1fr) !important; }
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value,
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setDisplay(Math.round(start + diff * eased));
|
||||
setDisplay(start + diff * eased);
|
||||
if (progress < 1) ref.current = requestAnimationFrame(animate);
|
||||
};
|
||||
ref.current = requestAnimationFrame(animate);
|
||||
|
||||
@@ -0,0 +1,914 @@
|
||||
/**
|
||||
* AI对话页面组件
|
||||
*
|
||||
* 功能说明:
|
||||
* - 提供AI聊天界面,支持创建对话、发送消息、上传图片/视频、删除对话等功能
|
||||
* - 支持媒体类型选择(图片/视频)和生成数量选择(1-10)
|
||||
* - 消息列表自动滚动到底部
|
||||
* - 响应式侧边栏(可收起/展开)
|
||||
*
|
||||
* 技术栈:React + TypeScript + Ant Design
|
||||
*
|
||||
* 页面结构:
|
||||
* - 左侧:对话列表侧边栏
|
||||
* - 右侧:消息展示区 + 输入区域
|
||||
*
|
||||
* @component AIChatPage
|
||||
* @returns {React.ReactElement} AI聊天页面组件
|
||||
*/
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
// Ant Design组件导入
|
||||
import {
|
||||
Layout, // 布局组件
|
||||
Button, // 按钮组件
|
||||
Input, // 输入框组件
|
||||
Select, // 选择器组件
|
||||
Upload, // 文件上传组件
|
||||
message, // 消息提示组件
|
||||
Space, // 间距组件
|
||||
Typography, // 排版组件
|
||||
Tooltip, // 提示组件
|
||||
Popconfirm, // 确认弹窗组件
|
||||
} from 'antd';
|
||||
|
||||
// Ant Design图标导入
|
||||
import {
|
||||
PlusOutlined, // 加号图标(新建对话)
|
||||
MenuUnfoldOutlined, // 展开菜单图标
|
||||
MenuFoldOutlined, // 收起菜单图标
|
||||
SendOutlined, // 发送图标
|
||||
DeleteOutlined, // 删除图标
|
||||
RobotOutlined, // 机器人图标(AI头像)
|
||||
LoadingOutlined, // 加载图标
|
||||
PictureOutlined, // 图片图标
|
||||
VideoCameraOutlined, // 视频图标
|
||||
} from '@ant-design/icons';
|
||||
|
||||
// 解构Layout组件
|
||||
const { Header, Sider, Content } = Layout;
|
||||
// 解构Input组件
|
||||
const { TextArea } = Input;
|
||||
// 解构Select组件
|
||||
const { Option } = Select;
|
||||
// 解构Typography组件
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* 消息类型定义
|
||||
* @interface Message
|
||||
* @property {string} id - 消息唯一标识
|
||||
* @property {'user' | 'bot'} type - 消息发送者类型
|
||||
* @property {string} content - 消息文本内容
|
||||
* @property {string} timestamp - 消息发送时间
|
||||
* @property {string[]} [images] - 消息附带的图片URL列表(可选)
|
||||
*/
|
||||
interface Message {
|
||||
id: string;
|
||||
type: 'user' | 'bot';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话类型定义
|
||||
* @interface Conversation
|
||||
* @property {string} id - 对话唯一标识
|
||||
* @property {string} title - 对话标题
|
||||
* @property {string} lastMessage - 最后一条消息预览
|
||||
* @property {string} timestamp - 最后消息时间
|
||||
* @property {Message[]} messages - 消息列表
|
||||
*/
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
lastMessage: string;
|
||||
timestamp: string;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟文件上传函数
|
||||
* @param {File} file - 要上传的文件对象
|
||||
* @returns {Promise<{ url: string }>} 返回包含文件URL的Promise
|
||||
* @description 模拟真实的文件上传过程,延迟500ms后返回文件的Object URL
|
||||
*/
|
||||
const mockUpload = (file: File): Promise<{ url: string }> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
// 使用URL.createObjectURL创建本地文件预览URL
|
||||
const url = URL.createObjectURL(file);
|
||||
resolve({ url });
|
||||
}, 500);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 模拟AI回复函数
|
||||
* @param {string} content - 用户输入的内容
|
||||
* @returns {Promise<string>} 返回AI回复内容的Promise
|
||||
* @description 模拟AI响应过程,延迟1500ms后随机返回一条预设回复
|
||||
*/
|
||||
const mockAIResponse = (content: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const responses = [
|
||||
`好的,我来帮您创作关于"${content}"的内容...`,
|
||||
`您的想法很有趣!关于"${content}",我有以下建议:`,
|
||||
`收到!正在为您生成"${content}"相关的内容...`,
|
||||
`太棒了!"${content}"是一个很棒的主题,让我来帮您实现。`,
|
||||
];
|
||||
// 随机选择一条回复
|
||||
resolve(responses[Math.floor(Math.random() * responses.length)]);
|
||||
}, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* AI聊天页面主组件
|
||||
* @function AIChatPage
|
||||
* @returns {React.ReactElement} AI聊天页面
|
||||
*/
|
||||
const AIChatPage: React.FC = () => {
|
||||
// ==================== 状态定义 ====================
|
||||
|
||||
/**
|
||||
* 侧边栏收起/展开状态
|
||||
* @state {boolean} collapsed - true表示收起,false表示展开
|
||||
*/
|
||||
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* 当前选中的对话ID
|
||||
* @state {string | null} currentConversationId - 当前活跃对话的ID,null表示未选中任何对话
|
||||
*/
|
||||
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* 对话列表
|
||||
* @state {Conversation[]} conversations - 存储所有对话数据,包含初始模拟数据
|
||||
*/
|
||||
const [conversations, setConversations] = useState<Conversation[]>([
|
||||
{
|
||||
id: '1',
|
||||
title: '女生产品文案',
|
||||
lastMessage: '女生拿着这个产品',
|
||||
timestamp: '2026-05-26 09:41:07',
|
||||
messages: [
|
||||
{
|
||||
id: 'm1',
|
||||
type: 'user',
|
||||
content: '女生拿着这个产品',
|
||||
timestamp: '2026-05-26 09:41:07',
|
||||
images: ['https://neeko-copilot.bytedance.net/api/text_to_image?prompt=woman%20holding%20luxury%20cream%20jar%20elegant%20bathroom&image_size=portrait_4_3'],
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
type: 'bot',
|
||||
content: '好的,我来为您生成关于这个产品的创意内容...',
|
||||
timestamp: '2026-05-26 09:41:10',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '旅行视频脚本',
|
||||
lastMessage: '帮我写一个旅行vlog脚本',
|
||||
timestamp: '2026-05-25 14:30:22',
|
||||
messages: [
|
||||
{
|
||||
id: 'm3',
|
||||
type: 'user',
|
||||
content: '帮我写一个旅行vlog脚本',
|
||||
timestamp: '2026-05-25 14:30:22',
|
||||
},
|
||||
{
|
||||
id: 'm4',
|
||||
type: 'bot',
|
||||
content: '当然!我来帮您构思一个精彩的旅行vlog脚本...',
|
||||
timestamp: '2026-05-25 14:30:25',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
/**
|
||||
* 输入框内容
|
||||
* @state {string} inputValue - 用户在文本输入框中输入的内容
|
||||
*/
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
|
||||
/**
|
||||
* 媒体类型选择(图片/视频)
|
||||
* @state {string} mediaType - 'image'表示图片,'video'表示视频
|
||||
*/
|
||||
const [mediaType, setMediaType] = useState<string>('image');
|
||||
|
||||
/**
|
||||
* 生成数量选择(1-10)
|
||||
* @state {string} countType - 生成内容的数量,值为'1'到'10'
|
||||
*/
|
||||
const [countType, setCountType] = useState<string>('1');
|
||||
|
||||
/**
|
||||
* 文件上传状态
|
||||
* @state {boolean} uploading - true表示正在上传文件
|
||||
*/
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* AI响应加载状态
|
||||
* @state {boolean} loading - true表示AI正在生成回复
|
||||
*/
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* 当前上传的图片URL列表
|
||||
* @state {string[]} currentImages - 存储当前会话中待发送的图片URL
|
||||
*/
|
||||
const [currentImages, setCurrentImages] = useState<string[]>([]);
|
||||
|
||||
// ==================== 引用定义 ====================
|
||||
|
||||
/**
|
||||
* 消息列表底部引用,用于自动滚动
|
||||
* @ref {HTMLDivElement | null} messagesEndRef - 指向消息列表最后一个元素
|
||||
*/
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ==================== 派生数据 ====================
|
||||
|
||||
/**
|
||||
* 获取当前选中的对话对象
|
||||
* @const {Conversation | undefined} currentConversation - 当前活跃对话数据
|
||||
*/
|
||||
const currentConversation = conversations.find((c) => c.id === currentConversationId);
|
||||
|
||||
// ==================== 副作用 ====================
|
||||
|
||||
/**
|
||||
* 自动滚动到底部
|
||||
* @effect 当消息列表更新时,自动滚动到最新消息位置
|
||||
* @dependency {currentConversation?.messages} - 监听消息列表变化
|
||||
*/
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [currentConversation?.messages]);
|
||||
|
||||
// ==================== 事件处理函数 ====================
|
||||
|
||||
/**
|
||||
* 创建新对话
|
||||
* @function handleNewChat
|
||||
* @description 创建一个新的空对话,并切换到该对话
|
||||
*/
|
||||
const handleNewChat = () => {
|
||||
// 生成唯一ID(使用时间戳)
|
||||
const newId = Date.now().toString();
|
||||
const newConversation: Conversation = {
|
||||
id: newId,
|
||||
title: '新对话',
|
||||
lastMessage: '',
|
||||
timestamp: new Date().toLocaleString('zh-CN'),
|
||||
messages: [],
|
||||
};
|
||||
// 将新对话插入到列表顶部
|
||||
setConversations((prev) => [newConversation, ...prev]);
|
||||
// 切换到新对话
|
||||
setCurrentConversationId(newId);
|
||||
// 清空输入框和已上传图片
|
||||
setInputValue('');
|
||||
setCurrentImages([]);
|
||||
// 显示提示消息
|
||||
message.info('已开启新对话');
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除对话
|
||||
* @function handleDeleteChat
|
||||
* @param {string} conversationId - 要删除的对话ID
|
||||
* @description 删除指定对话,并自动切换到其他对话
|
||||
*/
|
||||
const handleDeleteChat = (conversationId: string) => {
|
||||
// 过滤掉要删除的对话
|
||||
setConversations((prev) => prev.filter((c) => c.id !== conversationId));
|
||||
// 如果删除的是当前选中的对话,切换到其他对话
|
||||
if (currentConversationId === conversationId) {
|
||||
setCurrentConversationId(
|
||||
conversations[0]?.id === conversationId
|
||||
? conversations[1]?.id || null
|
||||
: conversations[0]?.id || null
|
||||
);
|
||||
}
|
||||
// 显示成功提示
|
||||
message.success('对话已删除');
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择对话
|
||||
* @function handleSelectChat
|
||||
* @param {string} conversationId - 要选择的对话ID
|
||||
* @description 切换到指定对话,清空已上传图片
|
||||
*/
|
||||
const handleSelectChat = (conversationId: string) => {
|
||||
setCurrentConversationId(conversationId);
|
||||
setCurrentImages([]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* @function handleSend
|
||||
* @async
|
||||
* @description 发送用户消息,等待AI回复,并更新对话状态
|
||||
*/
|
||||
const handleSend = async () => {
|
||||
// 验证:必须有内容或图片
|
||||
if (!inputValue.trim() && currentImages.length === 0) {
|
||||
message.warning('请输入内容或上传图片');
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建用户消息对象
|
||||
const newMessage: Message = {
|
||||
id: `m${Date.now()}`,
|
||||
type: 'user',
|
||||
content: inputValue.trim(),
|
||||
timestamp: new Date().toLocaleString('zh-CN'),
|
||||
images: currentImages.length > 0 ? [...currentImages] : undefined,
|
||||
};
|
||||
|
||||
// 更新对话列表,添加用户消息
|
||||
setConversations((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === currentConversationId
|
||||
? {
|
||||
...c,
|
||||
messages: [...c.messages, newMessage],
|
||||
lastMessage: inputValue.trim() || '[图片]',
|
||||
timestamp: new Date().toLocaleString('zh-CN'),
|
||||
// 如果是新对话,使用第一条消息作为标题
|
||||
title: c.title === '新对话' && inputValue.trim()
|
||||
? inputValue.trim().substring(0, 20)
|
||||
: c.title,
|
||||
}
|
||||
: c
|
||||
)
|
||||
);
|
||||
|
||||
// 清空输入框和已上传图片
|
||||
setInputValue('');
|
||||
setCurrentImages([]);
|
||||
// 设置加载状态
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// 调用模拟AI回复
|
||||
const response = await mockAIResponse(inputValue.trim() || '图片请求');
|
||||
// 创建AI回复消息对象
|
||||
const botMessage: Message = {
|
||||
id: `m${Date.now() + 1}`,
|
||||
type: 'bot',
|
||||
content: response,
|
||||
timestamp: new Date().toLocaleString('zh-CN'),
|
||||
};
|
||||
|
||||
// 更新对话列表,添加AI回复
|
||||
setConversations((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === currentConversationId
|
||||
? {
|
||||
...c,
|
||||
messages: [...c.messages, botMessage],
|
||||
lastMessage: response.substring(0, 30) + (response.length > 30 ? '...' : ''),
|
||||
timestamp: new Date().toLocaleString('zh-CN'),
|
||||
}
|
||||
: c
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
// 处理错误
|
||||
message.error('发送失败,请重试');
|
||||
} finally {
|
||||
// 无论成功与否,取消加载状态
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 文件上传处理
|
||||
* @function handleUpload
|
||||
* @async
|
||||
* @param {File} file - 要上传的文件
|
||||
* @returns {false} - 返回false阻止自动上传,由自定义逻辑处理
|
||||
* @description 验证文件类型和大小,上传文件并添加到当前图片列表
|
||||
*/
|
||||
const handleUpload = async (file: File) => {
|
||||
// 验证文件类型
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
if (!isImage && !isVideo) {
|
||||
message.error('仅支持图片或视频文件');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
const maxMB = isVideo ? 100 : 10;
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
message.error(`${isVideo ? '视频' : '图片'}大小不能超过${maxMB}MB`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证图片数量(最多4张)
|
||||
const imageCount = currentImages.filter((_, i) => i < 4).length;
|
||||
if (imageCount >= 4) {
|
||||
message.error('最多上传4张图片');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置上传状态
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// 调用模拟上传
|
||||
const res = await mockUpload(file);
|
||||
// 添加到图片列表
|
||||
setCurrentImages((prev) => [...prev, res.url]);
|
||||
message.success(`${isImage ? '图片' : '视频'}上传成功`);
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
// 返回false阻止Ant Design的自动上传行为
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除已上传的图片
|
||||
* @function handleRemoveImage
|
||||
* @param {number} index - 要移除的图片索引
|
||||
* @description 从当前图片列表中移除指定索引的图片
|
||||
*/
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setCurrentImages((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
/**
|
||||
* 按回车键发送
|
||||
* @function handleKeyPress
|
||||
* @param {React.KeyboardEvent} e - 键盘事件对象
|
||||
* @description 监听回车键,非Shift+Enter时发送消息
|
||||
*/
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 渲染 ====================
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh', background: '#fafafa' }}>
|
||||
{/* 左侧边栏 - 对话列表 */}
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
width={220}
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRight: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: 12 }}>
|
||||
{/* 收起/展开按钮 */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{ marginBottom: 12, width: '100%' }}
|
||||
/>
|
||||
|
||||
{/* 新对话按钮 - 展开状态显示 */}
|
||||
{!collapsed && (
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleNewChat}
|
||||
style={{ marginBottom: 16, borderRadius: 8 }}
|
||||
>
|
||||
新对话
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 对话列表 - 展开状态显示 */}
|
||||
{!collapsed && conversations.length > 0 && (
|
||||
<div style={{ maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
|
||||
{conversations.map((conversation) => (
|
||||
<div
|
||||
key={conversation.id}
|
||||
onClick={() => handleSelectChat(conversation.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px',
|
||||
marginBottom: 4,
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
background: currentConversationId === conversation.id ? '#f0f5ff' : 'transparent',
|
||||
border: currentConversationId === conversation.id ? '1px solid #e0e8ff' : 'none',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = currentConversationId === conversation.id ? '#f0f5ff' : '#fafafa';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = currentConversationId === conversation.id ? '#f0f5ff' : 'transparent';
|
||||
}}
|
||||
>
|
||||
{/* 对话信息区域 */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#333', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{conversation.title}
|
||||
</p>
|
||||
<p style={{ margin: 2, fontSize: 11, color: '#999', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{conversation.lastMessage || '暂无消息'}
|
||||
</p>
|
||||
</div>
|
||||
{/* 删除按钮 */}
|
||||
<Popconfirm
|
||||
title="确定删除此对话?"
|
||||
onConfirm={() => handleDeleteChat(conversation.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ color: '#999', padding: 4 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<Layout style={{ flex: 1 }}>
|
||||
{/* 头部 - 显示对话标题和模型信息 */}
|
||||
<Header
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: '0 24px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{currentConversation?.title || '开启创作'}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: '#999' }}>
|
||||
<span>模型: Gemini 3 Pro</span>
|
||||
<span>比例: 9:16</span>
|
||||
<span style={{ color: '#6366f1', fontWeight: 500 }}>消耗 15 积分</span>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
{/* 消息区域 */}
|
||||
<Content
|
||||
style={{
|
||||
margin: 0,
|
||||
background: '#fafafa',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: 24,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 空状态 - 未选择对话时显示 */}
|
||||
{!currentConversation && (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 50,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #a78bfa 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<RobotOutlined style={{ fontSize: 40, color: '#fff' }} />
|
||||
</div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 500, color: '#333' }}>
|
||||
你好,想创作什么?
|
||||
</h2>
|
||||
<p style={{ margin: 8, fontSize: 13, color: '#999' }}>
|
||||
输入想法、剧本或上传参考,开始你的创作之旅
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息列表 - 有对话时显示 */}
|
||||
{currentConversation && (
|
||||
<div
|
||||
style={{
|
||||
height: 'calc(100vh - 280px)', // 固定高度,减去头部和输入区域
|
||||
overflowY: 'auto', // 垂直滚动
|
||||
paddingBottom: 24,
|
||||
paddingRight: 8, // 预留滚动条空间
|
||||
}}
|
||||
>
|
||||
{/* 遍历消息列表 */}
|
||||
{currentConversation.messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start', // 所有消息左对齐
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 10, maxWidth: '70%' }}>
|
||||
{/* 头像 */}
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 50,
|
||||
background: '#e0e0e0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<RobotOutlined style={{ color: '#666', fontSize: 16 }} />
|
||||
</div>
|
||||
|
||||
{/* 消息内容 */}
|
||||
<div>
|
||||
{/* 消息气泡 */}
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: '16px 16px 16px 4px',
|
||||
padding: '12px 16px',
|
||||
maxWidth: '100%',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
{/* 图片内容 */}
|
||||
{message.images && message.images.length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 8, marginBottom: 10 }}>
|
||||
{message.images.map((img, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={img}
|
||||
alt={`图片${idx + 1}`}
|
||||
style={{ maxWidth: '100%', borderRadius: 8, objectFit: 'cover' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 文本内容 */}
|
||||
<p style={{ margin: 0, fontSize: 14, color: '#333' }}>
|
||||
{message.content}
|
||||
</p>
|
||||
</div>
|
||||
{/* 时间戳 */}
|
||||
<p style={{ margin: 4, fontSize: 11, color: '#999', textAlign: 'left' }}>
|
||||
{message.timestamp}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载中动画 - AI回复时显示 */}
|
||||
{loading && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 50,
|
||||
background: '#e0e0e0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<RobotOutlined style={{ color: '#666', fontSize: 16 }} />
|
||||
</div>
|
||||
<div style={{ background: '#fff', borderRadius: '16px 16px 16px 4px', padding: '12px 16px', boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 50, background: '#6366f1', animation: 'blink 1s infinite' }} />
|
||||
<span style={{ width: 8, height: 8, borderRadius: 50, background: '#6366f1', animation: 'blink 1s infinite 0.2s' }} />
|
||||
<span style={{ width: 8, height: 8, borderRadius: 50, background: '#6366f1', animation: 'blink 1s infinite 0.4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息列表底部标记 - 用于自动滚动 */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入区域 - 有对话时显示 */}
|
||||
{currentConversation && (
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: 16,
|
||||
border: '1px solid #e8e8e8',
|
||||
padding: 12,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
||||
}}
|
||||
>
|
||||
{/* 已上传图片预览 */}
|
||||
{currentImages.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, overflowX: 'auto' }}>
|
||||
{currentImages.map((img, idx) => (
|
||||
<div key={idx} style={{ position: 'relative', width: 80, flexShrink: 0 }}>
|
||||
<img
|
||||
src={img}
|
||||
alt={`已上传${idx + 1}`}
|
||||
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8 }}
|
||||
/>
|
||||
{/* 删除已上传图片按钮 */}
|
||||
<button
|
||||
onClick={() => handleRemoveImage(idx)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
right: -6,
|
||||
width: 20,
|
||||
height: 20,
|
||||
border: 'none',
|
||||
background: '#ff4d4f',
|
||||
borderRadius: 50,
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入框区域 */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{/* 上传按钮 */}
|
||||
<Upload
|
||||
accept="image/*,video/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
>
|
||||
<Tooltip title={`参考内容(${currentImages.length}/4)`}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
border: '1.5px dashed #d9d9d9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.background = 'rgba(99,102,241,0.04)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#d9d9d9';
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<LoadingOutlined style={{ fontSize: 16, color: '#6366f1' }} />
|
||||
) : (
|
||||
<PlusOutlined style={{ fontSize: 16, color: '#94a3b8' }} />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Upload>
|
||||
|
||||
{/* 文本输入框 */}
|
||||
<TextArea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="上传最多4张参考图,输入提示词描述您想生成的画面..."
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{ flex: 1, borderRadius: 12 }}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSend}
|
||||
disabled={!inputValue.trim() && currentImages.length === 0}
|
||||
loading={loading}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 底部选择器 - 媒体类型和数量 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 12, paddingTop: 12, borderTop: '1px solid #f0f0f0' }}>
|
||||
<Space size="middle">
|
||||
{/* 文件类型选择器 */}
|
||||
<Select
|
||||
value={mediaType}
|
||||
onChange={(val) => setMediaType(val)}
|
||||
style={{ width: 100 }}
|
||||
size="small"
|
||||
>
|
||||
<Option value="image">
|
||||
<PictureOutlined style={{ marginRight: 4 }} />
|
||||
图片
|
||||
</Option>
|
||||
<Option value="video">
|
||||
<VideoCameraOutlined style={{ marginRight: 4 }} />
|
||||
视频
|
||||
</Option>
|
||||
</Select>
|
||||
|
||||
{/* 数量选择器 */}
|
||||
<Select
|
||||
value={countType}
|
||||
onChange={(val) => setCountType(val)}
|
||||
style={{ width: 80 }}
|
||||
size="small"
|
||||
>
|
||||
<Option value="1">1</Option>
|
||||
<Option value="2">2</Option>
|
||||
<Option value="3">3</Option>
|
||||
<Option value="4">4</Option>
|
||||
<Option value="5">5</Option>
|
||||
<Option value="6">6</Option>
|
||||
<Option value="7">7</Option>
|
||||
<Option value="8">8</Option>
|
||||
<Option value="9">9</Option>
|
||||
<Option value="10">10</Option>
|
||||
</Select>
|
||||
</Space>
|
||||
|
||||
{/* 底部信息 */}
|
||||
<div style={{ display: 'flex', gap: 8, fontSize: 12, color: '#999' }}>
|
||||
<span>GPT Image 2</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ width: 16, height: 16, borderRadius: 4, background: '#f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
9:16
|
||||
</span>
|
||||
<span>10</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
{/* 自定义CSS动画 - 加载中闪烁效果 */}
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0.3; }
|
||||
}
|
||||
`}</style>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIChatPage;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,538 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Layout,
|
||||
Button,
|
||||
Input,
|
||||
Upload,
|
||||
message,
|
||||
Card,
|
||||
Modal,
|
||||
Table,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
VideoCameraOutlined,
|
||||
PictureOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const GenerateConver: React.FC = () => {
|
||||
|
||||
const [tableData, setTableData] = useState<any[]>(
|
||||
// [
|
||||
// {
|
||||
// id: 1,
|
||||
// image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=product%20image%20red%20gift%20box%20with%20hearts&image_size=square',
|
||||
// originalName: '进圈',
|
||||
// productName: '他趣',
|
||||
// status: '视频成功',
|
||||
// createTime: '2026-05-14 17:49:20',
|
||||
// },
|
||||
// {
|
||||
// id: 2,
|
||||
// image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=luxury%20perfume%20bottle%20golden%20elegant&image_size=square',
|
||||
// originalName: '香水',
|
||||
// productName: '面霜',
|
||||
// status: '视频提示词成功',
|
||||
// createTime: '2026-05-08 08:57:46',
|
||||
// },
|
||||
// ]
|
||||
);
|
||||
|
||||
|
||||
// 表单数据
|
||||
const [originalProductName, setOriginalProductName] = useState<string>('');
|
||||
const [ownProductName, setOwnProductName] = useState<string>('');
|
||||
const [productSellingPoints, setProductSellingPoints] = useState<string>('');
|
||||
|
||||
// 文件状态
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imageUrl, setImageUrl] = useState<string>('');
|
||||
|
||||
// 弹窗状态
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// 文件上传前校验
|
||||
const beforeVideoUpload = (file: File) => {
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
if (!isVideo) {
|
||||
message.error('只能上传视频文件');
|
||||
return false;
|
||||
}
|
||||
const isLt50M = file.size / 1024 / 1024 < 50;
|
||||
if (!isLt50M) {
|
||||
message.error('视频大小不能超过50MB');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查视频时长
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
if (video.duration > 15) {
|
||||
message.error('视频时长不能超过15秒');
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
setVideoFile(null);
|
||||
setVideoUrl('');
|
||||
return;
|
||||
}
|
||||
};
|
||||
video.src = URL.createObjectURL(file);
|
||||
|
||||
setVideoFile(file);
|
||||
setVideoUrl(URL.createObjectURL(file));
|
||||
return false;
|
||||
};
|
||||
|
||||
const beforeImageUpload = (file: File) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
message.error('只能上传图片文件');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查图片比例
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const ratio = img.width / img.height;
|
||||
const isRatioValid = Math.abs(ratio - 0.75) < 0.1 || Math.abs(ratio - 0.5625) < 0.1;
|
||||
// if (!isRatioValid) {
|
||||
// message.warning('建议使用3:4或9:16比例的图片以获得最佳效果');
|
||||
// }
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
|
||||
setImageFile(file);
|
||||
setImageUrl(URL.createObjectURL(file));
|
||||
return false;
|
||||
};
|
||||
|
||||
// 删除视频
|
||||
const handleRemoveVideo = () => {
|
||||
if (videoUrl) {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
}
|
||||
setVideoFile(null);
|
||||
setVideoUrl('');
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = () => {
|
||||
if (imageUrl) {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
}
|
||||
setImageFile(null);
|
||||
setImageUrl('');
|
||||
};
|
||||
|
||||
// 处理生成
|
||||
const handleGenerate = () => {
|
||||
if (!originalProductName.trim()) {
|
||||
message.warning('请输入原视频产品名称');
|
||||
return;
|
||||
}
|
||||
if (!ownProductName.trim()) {
|
||||
message.warning('请输入自有产品名称');
|
||||
return;
|
||||
}
|
||||
if (!productSellingPoints.trim()) {
|
||||
message.warning('请输入产品卖点');
|
||||
return;
|
||||
}
|
||||
message.success('正在生成爆款开头复刻视频...');
|
||||
console.log('生成参数:', {
|
||||
originalProductName,
|
||||
ownProductName,
|
||||
productSellingPoints,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="replication-container" style={{ overflow: 'auto', height: '94vh', display: 'flex', justifyContent: 'space-between', gap: '2%' }}>
|
||||
|
||||
<div className="replication-preview" style={{ width: '70%', height: '100%', background: '#fff', display: 'flex', flexDirection: 'column', borderRadius: 15, overflow: 'hidden' }}>
|
||||
|
||||
|
||||
|
||||
{/* 顶部标题栏 */}
|
||||
<Header
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: '0 24px',
|
||||
borderBottom: '1px solid #e8e8e8',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 500, color: '#333' }}>
|
||||
爆款开头复刻
|
||||
</h2>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
style={{ borderRadius: 6, padding: '4px 12px', fontSize: 12 }}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
创作记录
|
||||
</Button>
|
||||
</Header>
|
||||
|
||||
{/* 左侧预览区域 */}
|
||||
<div style={{ flex: 1, background: '#fff', borderRight: '1px solid #e8e8e8', }}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{/* 占位图标 */}
|
||||
<div
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 20,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* 底层卡片 */}
|
||||
<div
|
||||
style={{
|
||||
width: 55,
|
||||
height: 65,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #a78bfa 100%)',
|
||||
borderRadius: 10,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 5,
|
||||
}}
|
||||
/>
|
||||
{/* 上层卡片 */}
|
||||
<div
|
||||
style={{
|
||||
width: 45,
|
||||
height: 52,
|
||||
border: '2px solid #c7d2fe',
|
||||
borderRadius: 8,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 5,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 500, color: '#333', marginBottom: 8 }}>
|
||||
暂无生成内容
|
||||
</h3>
|
||||
<p style={{ margin: 0, fontSize: 12, color: '#999', textAlign: 'center', padding: '0 20px' }}>
|
||||
请完善素材与卖点,生成专属爆款开头复刻视频
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="replication-form" style={{ width: '28%', height: '100%', background: '#fff', borderRadius: 15, overflow: 'hidden' }}>
|
||||
{/* 右侧表单区域 */}
|
||||
<div className="replication-form-content" style={{ width: '100%', height: '100%', background: '#fff', padding: 20, overflowY: 'auto' }}>
|
||||
{/* 上传视频 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#333', marginBottom: 8 }}>
|
||||
上传视频
|
||||
</p>
|
||||
{videoUrl ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
style={{ width: '100%', borderRadius: 8, maxHeight: 200 }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRemoveVideo}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 24,
|
||||
height: 24,
|
||||
border: 'none',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#999' }}>
|
||||
{videoFile?.name} ({(videoFile?.size ? (videoFile.size / 1024 / 1024).toFixed(2) : 0)}MB)
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
beforeUpload={beforeVideoUpload}
|
||||
showUploadList={false}
|
||||
accept="video/mp4,video/quicktime,.mp4,.mov"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed #d9d9d9',
|
||||
borderRadius: 8,
|
||||
padding: 20,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor = '#6366f1';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor = '#d9d9d9';
|
||||
}}
|
||||
>
|
||||
<VideoCameraOutlined style={{ fontSize: 18, color: '#999', marginBottom: 6 }} />
|
||||
<p style={{ margin: 0, fontSize: 12, color: '#666' }}>
|
||||
您可以通过点击或拖拽上传视频
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: 11, color: '#bbb', marginTop: 4 }}>
|
||||
支持的文件类型:MP4、MOV|视频最大时长:15 秒 | 最大大小:50M
|
||||
</p>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 上传产品图片 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, fontWeight: 500, color: '#333', marginBottom: 8 }}>
|
||||
上传产品图片
|
||||
</p>
|
||||
{imageUrl ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="产品图片"
|
||||
style={{ width: '100%', borderRadius: 8, maxHeight: 200, objectFit: 'contain' }}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRemoveImage}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 24,
|
||||
height: 24,
|
||||
border: 'none',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#999' }}>
|
||||
{imageFile?.name} ({(imageFile?.size ? (imageFile.size / 1024 / 1024).toFixed(2) : 0)}MB)
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
beforeUpload={beforeImageUpload}
|
||||
showUploadList={false}
|
||||
accept="image/jpeg,image/jpg,image/png,.jpg,.jpeg,.png"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
border: '1px dashed #d9d9d9',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor = '#6366f1';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor = '#d9d9d9';
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||
<PictureOutlined style={{ fontSize: 14, color: '#999' }} />
|
||||
<span style={{ fontSize: 12, color: '#666' }}>+ 点击上传</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 11, color: '#bbb', marginTop: 4 }}>
|
||||
支持 JPG,JPEG,PNG 格式,图片比例为 3:4 或 9:16 效果最佳
|
||||
</p>
|
||||
</div>
|
||||
</Upload>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 原视频产品名称 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<p style={{ margin: 0, fontSize: 12, color: '#666', marginBottom: 6 }}>
|
||||
原视频产品名称
|
||||
</p>
|
||||
<Input
|
||||
value={originalProductName}
|
||||
onChange={(e) => setOriginalProductName(e.target.value)}
|
||||
placeholder="请输入原视频产品名称"
|
||||
style={{ borderRadius: 6, height: 32, fontSize: 12 }}
|
||||
maxLength={10}
|
||||
suffix={<span style={{ color: '#ccc', fontSize: 11 }}>{originalProductName.length}/10</span>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 自有产品名称 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<p style={{ margin: 0, fontSize: 12, color: '#666', marginBottom: 6 }}>
|
||||
自有产品名称
|
||||
</p>
|
||||
<Input
|
||||
value={ownProductName}
|
||||
onChange={(e) => setOwnProductName(e.target.value)}
|
||||
placeholder="请输入自有产品名称"
|
||||
style={{ borderRadius: 6, height: 32, fontSize: 12 }}
|
||||
maxLength={10}
|
||||
suffix={<span style={{ color: '#ccc', fontSize: 11 }}>{ownProductName.length}/10</span>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 产品卖点 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<p style={{ margin: 0, fontSize: 12, color: '#666', marginBottom: 6 }}>
|
||||
产品卖点
|
||||
</p>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<TextArea
|
||||
value={productSellingPoints}
|
||||
onChange={(e) => setProductSellingPoints(e.target.value)}
|
||||
placeholder="请输入产品卖点"
|
||||
style={{ borderRadius: 6, fontSize: 12 }}
|
||||
rows={2}
|
||||
maxLength={30}
|
||||
/>
|
||||
<span style={{ position: 'absolute', right: 8, bottom: 6, color: '#ccc', fontSize: 11 }}>
|
||||
{productSellingPoints.length}/30
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 立即生成按钮 */}
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
onClick={handleGenerate}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
height: 40,
|
||||
fontWeight: 500,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #a78bfa 100%)',
|
||||
border: 'none',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
立即生成+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 创作记录弹窗 */}
|
||||
<Modal
|
||||
title="创作记录"
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
width={800}
|
||||
footer={null}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
{/* 搜索区域 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="搜索产品名称"
|
||||
style={{ width: 200, borderRadius: 6, marginRight: 8 }}
|
||||
/>
|
||||
<Button type="primary" style={{ borderRadius: 6 }}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: '产品图片',
|
||||
dataIndex: 'image',
|
||||
key: 'image',
|
||||
width: 90,
|
||||
render: (image: string) => (
|
||||
<img
|
||||
src={image}
|
||||
alt="产品图片"
|
||||
style={{ width: 50, height: 50, objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '原产品名称',
|
||||
dataIndex: 'originalName',
|
||||
key: 'originalName',
|
||||
},
|
||||
{
|
||||
title: '产品名称',
|
||||
dataIndex: 'productName',
|
||||
key: 'productName',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: () => (
|
||||
<a href="#" style={{ color: '#6366f1', textDecoration: 'none', fontSize: 12 }}>
|
||||
查看详情
|
||||
</a>
|
||||
),
|
||||
},
|
||||
]}
|
||||
dataSource={tableData}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerateConver;
|
||||
@@ -156,7 +156,7 @@ const ProjectsPage: React.FC = () => {
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', border: 'none',
|
||||
borderRadius: 10, fontWeight: 600, height: 36,
|
||||
boxShadow: '0 4px 12px rgba(99,102,241,0.25)',
|
||||
}}>生成视频</Button>
|
||||
}}>生成</Button>
|
||||
<Popconfirm title="确定删除该项目?" description="项目下的生成记录将一并删除"
|
||||
onConfirm={async (e) => { e?.stopPropagation(); await deleteProject(project.id); message.success('项目已删除'); }}
|
||||
onCancel={(e) => e?.stopPropagation()}>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
VideoCameraOutlined,
|
||||
DownloadOutlined,
|
||||
RocketOutlined,
|
||||
PictureOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import type { GenerationStatus, AspectRatio, Resolution } from '../types';
|
||||
@@ -68,6 +69,23 @@ const RecordsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -124,6 +142,9 @@ const RecordsPage: React.FC = () => {
|
||||
const prompt = editablePrompts[record.id] ?? record.optimizedPrompt;
|
||||
const isGenerating = generating[record.id];
|
||||
const isExpanded = expandedId === record.id;
|
||||
const type: any = record.genType || 'image';
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div key={record.id} className="animate-slideInCard" style={{ animationDelay: `${i * 0.04}s` }}>
|
||||
@@ -138,6 +159,12 @@ const RecordsPage: React.FC = () => {
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* <div style={{ width: 28, height: 28, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, }}>
|
||||
{type === 'image' ? <span style={{ color: '#d97706', fontSize: 12 }} >图片</span> : <span style={{ color: '#2563eb', fontSize: 12 }} >视频</span>}
|
||||
</div> */}
|
||||
<div style={{ width: 28, height: 28, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, background: type === 'image' ? '#fef3c7' : '#dbeafe' }}>
|
||||
{type === 'image' ? <PictureOutlined style={{ color: '#d97706', fontSize: 12 }} /> : <VideoCameraOutlined style={{ color: '#2563eb', fontSize: 12 }} />}
|
||||
</div>
|
||||
{/* Expand icon */}
|
||||
{isExpanded
|
||||
? <CaretDownOutlined style={{ color: '#6366f1', fontSize: 12, flexShrink: 0 }} />
|
||||
@@ -162,9 +189,11 @@ const RecordsPage: React.FC = () => {
|
||||
</Typography.Text>
|
||||
|
||||
{/* Meta */}
|
||||
<span className="mobile-meta" translate="no" style={{ fontSize: 12, color: '#94a3b8', flexShrink: 0 }}>
|
||||
{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>
|
||||
</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>
|
||||
</span>}
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="record-actions" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -291,7 +320,17 @@ const RecordsPage: React.FC = () => {
|
||||
display: 'flex', gap: 16, padding: '12px 16px',
|
||||
borderRadius: 10, background: '#f8f9fc',
|
||||
}}>
|
||||
{[
|
||||
{type === 'image' ? [
|
||||
{ label: '分辨率', value: record.imageSize ? `${record.imageSize} ` : '-' },
|
||||
{ label: '画面比例', value: record.imageProportion ? `${record.imageProportion} ` : '-' },
|
||||
{ label: '画面尺寸', value: record.imageProportion ? `${record.imageProportion} ` : '-' },
|
||||
{ label: '消耗积分', value: record.creditsCost ? `${record.creditsCost} ` : '-', highlight: !!record.creditsCost },
|
||||
].map((item, j) => (
|
||||
<div key={j} style={{ flex: 1 }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>{item.label}</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: item.highlight ? '#6366f1' : '#1a1a2e' }}>{item.value}</Typography.Text>
|
||||
</div>
|
||||
)) : [
|
||||
{ label: '时长', value: record.duration ? `${record.duration} 秒` : '-' },
|
||||
{ label: '画面比例', value: record.aspectRatio || (record.status === 'prompt_optimized' ? '待选择' : '-') },
|
||||
{ label: '分辨率', value: record.resolution || (record.status === 'prompt_optimized' ? '待选择' : '-') },
|
||||
@@ -315,7 +354,7 @@ const RecordsPage: React.FC = () => {
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.3)',
|
||||
}}>
|
||||
选择参数生成视频
|
||||
{type === 'video' ? '选择参数生成视频' : '生成图片'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -327,7 +366,7 @@ const RecordsPage: React.FC = () => {
|
||||
loading={isGenerating}
|
||||
onClick={() => openGenModal(record)}
|
||||
style={{ borderRadius: 12, fontWeight: 600, height: 44 }}>
|
||||
重新生成视频
|
||||
重新生成{type === 'video' ? '视频' : '图片'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -343,34 +382,42 @@ const RecordsPage: React.FC = () => {
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12,
|
||||
}}>
|
||||
<LoadingOutlined style={{ color: '#6366f1', fontSize: 32 }} spin />
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>视频生成中...</Typography.Text>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>{type === 'video' ? '视频' : '图片'}生成中...</Typography.Text>
|
||||
<Typography.Text style={{ color: '#cbd5e1', fontSize: 12 }}>请耐心等待,生成完成后将自动展示</Typography.Text>
|
||||
</div>
|
||||
) : record.status === 'completed' && record.videoUrl ? (
|
||||
) : record.status === 'completed' && (record.videoUrl || record.imageUrl) ? (
|
||||
<div style={{ borderRadius: 14, overflow: 'hidden', border: '1px solid #f0f0f5', position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 10, right: 10, zIndex: 10 }}>
|
||||
<Tooltip title="下载视频">
|
||||
<Tooltip title={`下载${type === 'video' ? '视频' : '图片'}`}>
|
||||
<Button size="small" icon={<DownloadOutlined />}
|
||||
onClick={() => { const a = document.createElement('a'); a.href = `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${record.videoUrl}`; a.download = `${record.projectName}.mp4`; a.click(); }}
|
||||
onClick={() => { const a = document.createElement('a'); a.href = `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${type === 'video' ? record.videoUrl : record.imageUrl}`; a.download = `${record.projectName}${type === 'video' ? '.mp4' : '.png'}`; a.click(); }}
|
||||
style={{ background: 'rgba(0,0,0,0.5)', border: 'none', color: '#fff', backdropFilter: 'blur(4px)', borderRadius: 8 }}>
|
||||
下载
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div style={{ minHeight: 280, overflow: 'hidden' }}>
|
||||
{type === 'video' ? (
|
||||
<video
|
||||
src={`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${record.videoUrl}`}
|
||||
controls
|
||||
preload="metadata"
|
||||
style={{ width: '100%', display: 'block' }}
|
||||
style={{ width: '100%', height: '400px', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${record.imageUrl}`}
|
||||
alt={record.projectName}
|
||||
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
background: '#fafbff',
|
||||
}}>
|
||||
<Space>
|
||||
<VideoCameraOutlined style={{ color: '#6366f1' }} />
|
||||
{type === 'video' ? <VideoCameraOutlined style={{ color: '#6366f1' }} /> : <PictureOutlined style={{ color: '#6366f1' }} />}
|
||||
<Typography.Text style={{ fontSize: 13, color: '#475569' }}>
|
||||
{record.projectName}
|
||||
</Typography.Text>
|
||||
@@ -390,7 +437,7 @@ const RecordsPage: React.FC = () => {
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12,
|
||||
}}>
|
||||
<CloseCircleOutlined style={{ color: '#ef4444', fontSize: 32 }} />
|
||||
<Typography.Text style={{ color: '#ef4444', fontSize: 14 }}>视频生成失败</Typography.Text>
|
||||
<Typography.Text style={{ color: '#ef4444', fontSize: 14 }}>{type === 'video' ? '视频' : '图片'}生成失败</Typography.Text>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}>请在左侧点击重新生成</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
@@ -400,7 +447,7 @@ const RecordsPage: React.FC = () => {
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12,
|
||||
}}>
|
||||
<ClockCircleOutlined style={{ color: '#94a3b8', fontSize: 32 }} />
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>待生成视频</Typography.Text>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>待生成{type === 'video' ? '视频' : '图片'}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#cbd5e1', fontSize: 12 }}>点击左侧生成按钮开始生成</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
interface FrameData {
|
||||
url: string;
|
||||
time: number;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export default function VideoFrameExtractor() {
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [frames, setFrames] = useState<FrameData[]>([]);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
const [extractProgress, setExtractProgress] = useState(0);
|
||||
const [selectedFrames, setSelectedFrames] = useState<number[]>([]);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const cleanupResources = useCallback(() => {
|
||||
if (videoUrl) {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
}
|
||||
frames.forEach(frame => {
|
||||
if (frame.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(frame.url);
|
||||
}
|
||||
});
|
||||
setFrames([]);
|
||||
setVideoUrl('');
|
||||
setVideoFile(null);
|
||||
setError('');
|
||||
setSelectedFrames([]);
|
||||
}, [videoUrl, frames]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('video/')) {
|
||||
setError('请选择视频文件');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
setError('视频文件大小不能超过 100MB');
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupResources();
|
||||
|
||||
setVideoFile(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
setVideoUrl(url);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('video/')) {
|
||||
setError('请选择视频文件');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
setError('视频文件大小不能超过 100MB');
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupResources();
|
||||
|
||||
setVideoFile(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
setVideoUrl(url);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
/**
|
||||
* 视频帧提取核心函数 - 拆镜功能的主要实现
|
||||
*
|
||||
* 功能说明:
|
||||
* - 从上传的视频中按时间间隔提取帧画面
|
||||
* - 每秒提取一帧,确保覆盖整个视频时长
|
||||
* - 默认选中前4秒的帧(4帧),用于后续创作
|
||||
*
|
||||
* 技术实现:
|
||||
* - 使用 HTML5 Video API 进行视频帧捕获
|
||||
* - 通过 Canvas API 将视频帧转换为图片
|
||||
* - 使用 Promise + 事件监听确保帧数据就绪
|
||||
* - 添加超时机制防止死锁
|
||||
*/
|
||||
const extractFrames = useCallback(async () => {
|
||||
// 前置检查:确保视频和画布元素已就绪
|
||||
if (!videoRef.current || !canvasRef.current) return;
|
||||
|
||||
// 初始化提取状态
|
||||
setIsExtracting(true); // 标记正在提取中
|
||||
setExtractProgress(1); // 重置进度为0%
|
||||
setSelectedFrames([]); // 清空已选帧列表
|
||||
|
||||
// 获取视频和画布引用
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return; // Canvas 2D上下文获取失败则退出
|
||||
|
||||
// 计算提取参数
|
||||
const duration = video.duration; // 视频总时长(秒)
|
||||
const frameList: FrameData[] = []; // 存储提取的帧数据
|
||||
const extractInterval = 1; // 提取间隔:每1秒抽一帧
|
||||
const totalFrames = Math.max(1, Math.floor(duration / extractInterval)); // 计算总帧数
|
||||
|
||||
try {
|
||||
// 遍历视频时间轴,按间隔提取帧
|
||||
for (let time = 0; time < duration; time += extractInterval) {
|
||||
// 设置视频当前播放位置到目标时间点
|
||||
video.currentTime = time;
|
||||
|
||||
// 等待帧数据就绪(异步等待)
|
||||
await new Promise<void>((resolve) => {
|
||||
/**
|
||||
* 检查视频就绪状态
|
||||
* readyState >= 2 表示当前帧数据已加载完成
|
||||
* readyState 值说明:
|
||||
* - 0 = HAVE_NOTHING: 无数据
|
||||
* - 1 = HAVE_METADATA: 仅元数据
|
||||
* - 2 = HAVE_CURRENT_DATA: 当前帧数据可用
|
||||
* - 3 = HAVE_FUTURE_DATA: 当前帧和后续帧可用
|
||||
* - 4 = HAVE_ENOUGH_DATA: 所有数据可用
|
||||
*/
|
||||
const checkReadyState = () => {
|
||||
if (video.readyState >= 2) {
|
||||
processFrame();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理单帧提取的核心函数
|
||||
*/
|
||||
const processFrame = () => {
|
||||
// 清理事件监听器,防止内存泄漏
|
||||
video.removeEventListener('seeked', onSeeked);
|
||||
video.removeEventListener('loadeddata', onLoadedData);
|
||||
clearTimeout(timeoutId); // 清除超时计时器
|
||||
|
||||
// 设置画布尺寸为视频帧尺寸
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
// 将视频当前帧绘制到画布
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 将画布转换为 Base64 格式的图片 URL(JPEG格式,质量0.9)
|
||||
const frameUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
|
||||
// 将帧数据添加到列表
|
||||
frameList.push({
|
||||
url: frameUrl, // 帧图片URL
|
||||
time: Math.round(time * 10) / 10, // 帧对应的时间点(保留一位小数)
|
||||
selected: false, // 默认未选中
|
||||
});
|
||||
|
||||
// 更新提取进度
|
||||
setExtractProgress(Math.round((frameList.length / totalFrames) * 100));
|
||||
|
||||
// 完成当前帧提取,继续下一轮
|
||||
resolve();
|
||||
};
|
||||
|
||||
/**
|
||||
* seeked 事件处理:视频定位完成后触发
|
||||
*/
|
||||
const onSeeked = () => {
|
||||
checkReadyState();
|
||||
};
|
||||
|
||||
/**
|
||||
* loadeddata 事件处理:帧数据加载完成后触发
|
||||
* 作为 seeked 的备选方案,确保兼容性
|
||||
*/
|
||||
const onLoadedData = () => {
|
||||
processFrame();
|
||||
};
|
||||
|
||||
/**
|
||||
* 超时机制:3秒超时防止无限等待
|
||||
* 如果3秒内帧数据仍未就绪,跳过当前帧继续下一帧
|
||||
*/
|
||||
const timeoutId = setTimeout(() => {
|
||||
video.removeEventListener('seeked', onSeeked);
|
||||
video.removeEventListener('loadeddata', onLoadedData);
|
||||
console.warn(`Timeout waiting for frame at ${time}s`);
|
||||
resolve(); // 跳过当前帧,继续处理下一帧
|
||||
}, 3000);
|
||||
|
||||
// 先同步检查状态,如果未就绪则注册事件监听器等待
|
||||
if (!checkReadyState()) {
|
||||
video.addEventListener('seeked', onSeeked);
|
||||
video.addEventListener('loadeddata', onLoadedData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 提取完成后,默认选中前4秒的帧(约8帧)
|
||||
const defaultSelected = Math.min(8, frameList.length);
|
||||
const defaultIndices: number[] = [];
|
||||
const updatedFrames = frameList.map((frame, index) => {
|
||||
if (index < defaultSelected) {
|
||||
defaultIndices.push(index);
|
||||
return { ...frame, selected: true }; // 标记为已选中
|
||||
}
|
||||
return frame;
|
||||
});
|
||||
|
||||
// 更新状态:保存提取的帧列表和默认选中的帧索引
|
||||
setFrames(updatedFrames);
|
||||
setSelectedFrames(defaultIndices);
|
||||
} catch (err) {
|
||||
// 提取过程中发生错误
|
||||
setError('拆镜过程中发生错误');
|
||||
console.error('Extract frames error:', err);
|
||||
} finally {
|
||||
// 无论成功或失败,都标记提取完成
|
||||
setIsExtracting(false);
|
||||
setExtractProgress(100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleFrameSelection = (index: number) => {
|
||||
setSelectedFrames(prev => {
|
||||
if (prev.includes(index)) {
|
||||
return prev.filter(i => i !== index);
|
||||
}
|
||||
return [...prev, index];
|
||||
});
|
||||
|
||||
setFrames(prev =>
|
||||
prev.map((frame, i) =>
|
||||
i === index ? { ...frame, selected: !frame.selected } : frame
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleRemoveVideo = () => {
|
||||
cleanupResources();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(180deg, #f8f9ff 0%, #f0f1ff 100%)',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}>
|
||||
<div style={{ padding: '60px 20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
{/* 标题区域 */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 40 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
marginBottom: 12
|
||||
}}>
|
||||
<div style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #a78bfa 100%)',
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 18
|
||||
}}>
|
||||
🎬
|
||||
</div>
|
||||
<h1 style={{
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
color: '#1a1a1a',
|
||||
margin: 0
|
||||
}}>
|
||||
拆镜复刻
|
||||
</h1>
|
||||
</div>
|
||||
<p style={{ fontSize: 14, color: '#666', margin: 0 }}>
|
||||
一键拆解画面分镜,助力仿拍或创作
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
backgroundColor: '#fff5f5',
|
||||
border: '1px solid #ffccc7',
|
||||
borderRadius: 8,
|
||||
padding: '12px 16px',
|
||||
marginBottom: 20,
|
||||
color: '#d93026',
|
||||
fontSize: 14,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传/视频区域 */}
|
||||
<div
|
||||
style={{
|
||||
border: '2px dashed #c7d2fe',
|
||||
borderRadius: 20,
|
||||
padding: 40,
|
||||
background: '#fff',
|
||||
position: 'relative'
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{/* 删除按钮 */}
|
||||
{videoUrl && (
|
||||
<button
|
||||
onClick={handleRemoveVideo}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: 'none',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 14,
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 未上传状态 */}
|
||||
{!videoUrl && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
margin: '0 auto 20px',
|
||||
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(167, 139, 250, 0.1) 100%)',
|
||||
borderRadius: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #a78bfa 100%)',
|
||||
borderRadius: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 20
|
||||
}}>
|
||||
☁️
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: '14px 32px',
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #a78bfa 100%)',
|
||||
color: 'white',
|
||||
borderRadius: 30,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
marginBottom: 16,
|
||||
transition: 'opacity 0.2s'
|
||||
}}
|
||||
>
|
||||
点击或拖拽到此上传视频文件
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
<p style={{ fontSize: 12, color: '#999', margin: 0 }}>
|
||||
支持 MP4、MOV 格式视频,最大支持 100MB 的文件,支持上传 3 分钟内的视频,可处理 4-15 秒片段
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已上传状态 - 视频预览 */}
|
||||
{videoUrl && !frames.length && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
controls
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 300,
|
||||
borderRadius: 12,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
<p style={{ fontSize: 12, color: '#999', marginTop: 12 }}>
|
||||
时长: {formatTime(videoRef.current?.duration || 0)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拆镜结果 */}
|
||||
{frames.length > 0 && (
|
||||
<div>
|
||||
{/* 视频预览 */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 20 }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
controls
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 200,
|
||||
borderRadius: 12,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 帧选择区域 */}
|
||||
<div style={{
|
||||
background: '#f8f9ff',
|
||||
borderRadius: 12,
|
||||
padding: 16
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 12 }}>
|
||||
<button
|
||||
onClick={() => {}}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: 'none',
|
||||
background: '#e0e7ff',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
color: '#6366f1'
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginLeft: 12,
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
00:00:00 / 00:00:{String(Math.round(videoRef.current?.duration || 0)).padStart(2, '0')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 帧缩略图 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
overflowX: 'auto',
|
||||
paddingBottom: 8,
|
||||
scrollbarWidth: 'thin'
|
||||
}}>
|
||||
{frames.map((frame, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => toggleFrameSelection(index)}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 60,
|
||||
height: 45,
|
||||
border: frame.selected ? '2px solid #6366f1' : '1px solid #e5e7eb',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={frame.url}
|
||||
alt={`帧 ${index + 1}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
{frame.selected && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 2,
|
||||
right: 2,
|
||||
width: 14,
|
||||
height: 14,
|
||||
background: '#6366f1',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
color: 'white'
|
||||
}}>
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
<span style={{ fontSize: 12, color: '#999' }}>
|
||||
已选取 {selectedFrames.length} 帧
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 进度条 */}
|
||||
{isExtracting && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 12, color: '#666' }}>正在拆解...</span>
|
||||
<span style={{ fontSize: 12, color: '#666' }}>{extractProgress}%</span>
|
||||
</div>
|
||||
<div style={{
|
||||
height: 4,
|
||||
backgroundColor: '#e5e7eb',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, #6366f1 0%, #a78bfa 100%)',
|
||||
width: `${extractProgress}%`,
|
||||
transition: 'width 0.3s'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div style={{ textAlign: 'center', marginTop: 32 }}>
|
||||
<button
|
||||
onClick={extractFrames}
|
||||
disabled={!videoUrl || isExtracting}
|
||||
style={{
|
||||
padding: '14px 48px',
|
||||
background: isExtracting
|
||||
? '#ccc'
|
||||
: 'linear-gradient(135deg, #6366f1 0%, #a78bfa 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 30,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: isExtracting ? 'not-allowed' : 'pointer',
|
||||
transition: 'opacity 0.2s'
|
||||
}}
|
||||
>
|
||||
{isExtracting ? '⏳ 拆解中...' : '⚡ 一键拆解'}
|
||||
<span style={{ marginLeft: 8, opacity: 0.8 }}>
|
||||
{/* 预计消耗10个积分 */}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 创作记录 */}
|
||||
{/* <div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
<a
|
||||
href="/records"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: '#6366f1',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
📋 创作记录
|
||||
</a>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* 隐藏画布 */}
|
||||
<canvas ref={canvasRef} style={{ display: 'none' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,9 +27,14 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
|
||||
checkAuth: async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) { set({ user: null, loading: false }); return; }
|
||||
const user = await api.getUser();
|
||||
set({ user, loading: false });
|
||||
} catch {
|
||||
} catch (error: any) {
|
||||
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
set({ user: null, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface GenerationRecord {
|
||||
originalPrompt: string;
|
||||
optimizedPrompt?: string;
|
||||
duration?: number;
|
||||
genType?: number;
|
||||
aspectRatio?: AspectRatio;
|
||||
resolution?: Resolution;
|
||||
status: GenerationStatus;
|
||||
@@ -77,18 +78,29 @@ export interface GenerationRecord {
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
generatedAt?: string;
|
||||
imageSize: string;
|
||||
imageProportion: string;
|
||||
imagePx: string;
|
||||
imageUrl: string;
|
||||
|
||||
}
|
||||
|
||||
export interface OptimizeParams {
|
||||
prompt: string;
|
||||
duration: number;
|
||||
genType?: any;
|
||||
resolution?: string;
|
||||
aspectRatio?: string;
|
||||
references?: MediaReference[];
|
||||
idempotencyKey?: string;
|
||||
image_size:any;
|
||||
image_proportion:any;
|
||||
image_px:any
|
||||
}
|
||||
|
||||
export interface GenerateParams {
|
||||
aspectRatio: AspectRatio;
|
||||
resolution: Resolution;
|
||||
aspectRatio?: AspectRatio;
|
||||
resolution?: Resolution;
|
||||
}
|
||||
|
||||
export interface OptimizeResult {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export function generateUUID(): string {
|
||||
// 优先使用现代浏览器的 crypto.randomUUID
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// 降级方案:使用 crypto.getRandomValues 生成 UUIDv4
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
// 设置版本为 4(UUIDv4)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
// 设置变体为 RFC 4122
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
|
||||
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
// 最后降级:使用时间戳和随机数
|
||||
const timestamp = Date.now().toString(16).padStart(12, '0');
|
||||
const random = Math.random().toString(16).slice(2, 10).padStart(8, '0');
|
||||
return `${timestamp}-${random}-4xxx-yxxx-${Math.random().toString(16).slice(2, 12)}`.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
Reference in New Issue
Block a user