样式优化

This commit is contained in:
孙佳艺
2026-05-26 13:47:24 +08:00
parent d61dcdc8db
commit 01d15276ba
23 changed files with 6265 additions and 747 deletions
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+10
View File
@@ -12,6 +12,7 @@
"antd": "^6.3.7", "antd": "^6.3.7",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"plyr-react": "^6.0.0", "plyr-react": "^6.0.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-router-dom": "^7.15.0", "react-router-dom": "^7.15.0",
@@ -3997,6 +3998,15 @@
"node": ">=6" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+1
View File
@@ -14,6 +14,7 @@
"antd": "^6.3.7", "antd": "^6.3.7",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"plyr-react": "^6.0.0", "plyr-react": "^6.0.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-router-dom": "^7.15.0", "react-router-dom": "^7.15.0",
+1
View File
@@ -0,0 +1 @@
{}
+23 -4
View File
@@ -8,10 +8,24 @@ import ProjectsPage from './pages/ProjectsPage';
import GeneratePage from './pages/GeneratePage'; import GeneratePage from './pages/GeneratePage';
import RecordsPage from './pages/RecordsPage'; import RecordsPage from './pages/RecordsPage';
import CreditsPage from './pages/CreditsPage'; 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'; import { useAuthStore } from './store/useAuthStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { 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) { if (loading) {
return ( return (
@@ -20,11 +34,10 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
</div> </div>
); );
} }
if (!user) { if (!user) {
return <Navigate to="/login" replace />; window.location.href = '/login';
return null;
} }
return <>{children}</>; return <>{children}</>;
}; };
@@ -75,6 +88,12 @@ const App = () => {
<Route path="projects/:projectId/generate" element={<GeneratePage />} /> <Route path="projects/:projectId/generate" element={<GeneratePage />} />
<Route path="records" element={<RecordsPage />} /> <Route path="records" element={<RecordsPage />} />
<Route path="credits" element={<CreditsPage />} /> <Route path="credits" element={<CreditsPage />} />
<Route path="conversation" element={<GenerateConver />} />
<Route path="initial" element={<InitialReplication />} />
<Route path="removelens" element={<RemoveLens />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/projects" replace />} /> <Route path="*" element={<Navigate to="/projects" replace />} />
</Routes> </Routes>
+2 -1
View File
@@ -106,10 +106,11 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
} }
// Handle error responses // Handle error responses
if (!res.ok) { if (!res.ok) {
const msg = parsed?.detail || `请求失败 (${res.status})`; const msg = parsed?.detail || `请求失败 (${res.status})`;
if (res.status === 401) { if (res.status === 401) {
clearToken(); clearToken();
window.location.href = '/login';
} }
throw new Error(msg); throw new Error(msg);
} }
+19 -2
View File
@@ -77,11 +77,15 @@ export async function optimizePrompt(
): Promise<OptimizeResult> { ): Promise<OptimizeResult> {
if (USE_MOCK) return mock.mockOptimizePrompt(projectId, params as any); if (USE_MOCK) return mock.mockOptimizePrompt(projectId, params as any);
return api.post('/generation-records/optimize', { return api.post('/generation-records/optimize', {
project_id: projectId, project_id: projectId,//项目id
gen_type:params.genType,//生成类型
prompt: params.prompt, prompt: params.prompt,
duration: params.duration, duration: params.duration,
references: params.references || null, references: params.references || null,
idempotency_key: params.idempotencyKey || 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[] }[] }> { 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] }] }; 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 ─────────────────────────────────────────────────── // ── SMS ───────────────────────────────────────────────────
@@ -266,3 +275,11 @@ export async function getMenuConfigs(): Promise<any[]> {
export async function getRechargePackages(): Promise<any[]> { export async function getRechargePackages(): Promise<any[]> {
return api.get('/recharge-packages'); return api.get('/recharge-packages');
} }
export async function getCreditRatios(): Promise<any[]> {
return api.get('/credits/ratios');
}
+47 -25
View File
@@ -3,6 +3,7 @@ import type {
CreditRecord, CreditRecord,
Project, Project,
GenerationRecord, GenerationRecord,
GenerateParams,
OptimizeParams, OptimizeParams,
OptimizeResult, OptimizeResult,
LoginParams, LoginParams,
@@ -55,10 +56,14 @@ let MOCK_RECORDS: GenerationRecord[] = [
resolution: '1080p', resolution: '1080p',
status: 'completed', status: 'completed',
videoUrl: 'https://example.com/video1.mp4', videoUrl: 'https://example.com/video1.mp4',
textCreditsCost: 10,
textTokensUsed: 850,
creditsCost: 120, 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', createdAt: '2026-04-29 14:22:00',
generatedAt: '2026-04-29 14:25:00', generatedAt: '2026-04-29 14:25:00',
}, },
@@ -73,10 +78,14 @@ let MOCK_RECORDS: GenerationRecord[] = [
resolution: '1080p', resolution: '1080p',
status: 'completed', status: 'completed',
videoUrl: 'https://example.com/video2.mp4', videoUrl: 'https://example.com/video2.mp4',
textCreditsCost: 8,
textTokensUsed: 720,
creditsCost: 80, 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', createdAt: '2026-04-30 09:15:00',
generatedAt: '2026-04-30 09:18:00', generatedAt: '2026-04-30 09:18:00',
}, },
@@ -90,10 +99,14 @@ let MOCK_RECORDS: GenerationRecord[] = [
aspectRatio: '16:9', aspectRatio: '16:9',
resolution: '4K', resolution: '4K',
status: 'prompt_optimized', status: 'prompt_optimized',
textCreditsCost: 12,
textTokensUsed: 960,
creditsCost: 120, 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', createdAt: '2026-05-01 16:40:00',
}, },
{ {
@@ -106,10 +119,14 @@ let MOCK_RECORDS: GenerationRecord[] = [
aspectRatio: '16:9', aspectRatio: '16:9',
resolution: '4K', resolution: '4K',
status: 'prompt_optimized', status: 'prompt_optimized',
textCreditsCost: 10,
textTokensUsed: 880,
creditsCost: 100, 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', createdAt: '2026-05-03 08:30:00',
}, },
]; ];
@@ -169,15 +186,14 @@ export async function mockDeleteProject(id: string): Promise<void> {
export async function mockOptimizePrompt( export async function mockOptimizePrompt(
projectId: string, projectId: string,
params: OptimizeParams params: OptimizeParams
): Promise<OptimizeResult> { ): Promise<OptimizeResult & { record: GenerationRecord }> {
await delay(1500); await delay(1500);
const project = MOCK_PROJECTS.find((p) => p.id === projectId); const project = MOCK_PROJECTS.find((p) => p.id === projectId);
const textCredits = Math.max(1, Math.ceil(params.prompt.length * 0.3)); const cost = Math.round(80 + params.prompt.length * 0.5 + params.duration * 2);
const textTokens = Math.round(params.prompt.length * 1.2);
if (currentUser) { if (currentUser) {
currentUser.credits -= textCredits; currentUser.credits -= cost;
} }
const optimizedPromptMap: Record<string, string> = { const optimizedPromptMap: Record<string, string> = {
@@ -195,7 +211,7 @@ export async function mockOptimizePrompt(
if (matched) { if (matched) {
optimizedPrompt = optimizedPromptMap[matched]; optimizedPrompt = optimizedPromptMap[matched];
} else { } else {
optimizedPrompt = `精心构图的画面中,${params.prompt}。采用电影级镜头语言,自然光线与人工光源完美结合,营造出沉浸式视觉体验。画面色彩饱满而真实,细节丰富。运镜流畅自然,节奏张弛有度,完美适配${params.duration}秒时长。`; optimizedPrompt = `精心构图的画面中,${params.prompt}。采用电影级镜头语言,自然光线与人工光源完美结合,营造出沉浸式视觉体验。画面色彩饱满而真实,细节丰富。运镜流畅自然,节奏张弛有度,完美适配${params.duration}秒时长。${params.aspectRatio}比例构图,${params.resolution}高清画质呈现。`;
} }
const record: GenerationRecord = { const record: GenerationRecord = {
@@ -205,17 +221,23 @@ export async function mockOptimizePrompt(
originalPrompt: params.prompt, originalPrompt: params.prompt,
optimizedPrompt, optimizedPrompt,
duration: params.duration, duration: params.duration,
aspectRatio: params.aspectRatio as any,
resolution: params.resolution as any,
status: 'prompt_optimized', status: 'prompt_optimized',
textCreditsCost: textCredits, creditsCost: cost,
textTokensUsed: textTokens, textCreditsCost: cost,
creditsCost: 0, textTokensUsed: 0,
videoTokensUsed: 0, videoTokensUsed: 0,
imageSize: params.resolution || '1080p',
imageProportion: params.aspectRatio || '16:9',
imagePx: '1920x1080',
imageUrl: '',
createdAt: new Date().toLocaleString('zh-CN'), createdAt: new Date().toLocaleString('zh-CN'),
}; };
MOCK_RECORDS.unshift(record); 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> { export async function mockGenerateVideo(recordId: string): Promise<GenerationRecord> {
@@ -241,10 +263,10 @@ export async function mockGetGenerationRecords(projectId?: string): Promise<Gene
// ── Admin Mock Data ─────────────────────────────────── // ── Admin Mock Data ───────────────────────────────────
const MOCK_ADMIN_USERS: AdminUser[] = [ 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-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: 'normal', createdAt: '2026-04-20', lastLoginAt: '2026-05-05 09:12' }, { 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: 'normal', createdAt: '2026-04-25', lastLoginAt: '2026-05-04 16:45' }, { 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: 'normal', createdAt: '2026-04-10', lastLoginAt: '2026-04-28 11:20' }, { 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' }, { 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 React, { useEffect, useState, useMemo } from 'react';
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography } from 'antd'; import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography } from 'antd';
import { QRCodeSVG } from 'qrcode.react';
import { import {
PlayCircleOutlined, PlayCircleOutlined,
WalletOutlined, WalletOutlined,
@@ -18,6 +19,7 @@ import {
FireFilled, FireFilled,
CrownFilled, CrownFilled,
BankFilled, BankFilled,
QrcodeOutlined,
CloseOutlined, CloseOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
@@ -81,6 +83,8 @@ const AppLayout: React.FC = () => {
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [siteName, setSiteName] = useState('VideoGen.AI'); const [siteName, setSiteName] = useState('VideoGen.AI');
const [siteLogo, setSiteLogo] = useState(''); const [siteLogo, setSiteLogo] = useState('');
const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false);
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string } | null>(null);
useEffect(() => { useEffect(() => {
getSiteInfo().then(info => { getSiteInfo().then(info => {
@@ -240,7 +244,7 @@ const AppLayout: React.FC = () => {
</div> </div>
{/* Menu */} {/* Menu */}
<div style={{ flex: 1, padding: '8px 8px', overflow: 'hidden' }}> <div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
{!collapsed && ( {!collapsed && (
<div style={{ color: 'rgba(148,163,184,0.4)', fontSize: 11, fontWeight: 600, padding: '8px 12px 6px', letterSpacing: 1 }}> <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' }}> <div style={{ marginTop: 20, display: 'flex', justifyContent: 'flex-end' }}>
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}></Button> <Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}></Button>
<Button type="primary" size="large" disabled={!selectedPlan} <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={{ style={{
borderRadius: 10, fontWeight: 600, borderRadius: 10, fontWeight: 600,
background: selectedPlan ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : '#d1d5db', background: selectedPlan ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : '#d1d5db',
@@ -512,6 +536,125 @@ const AppLayout: React.FC = () => {
</div> </div>
</Modal> </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 */} {/* Message Center Modal */}
<Modal <Modal
open={msgModalOpen} open={msgModalOpen}
+122
View File
@@ -210,11 +210,133 @@ html, body {
/* Video player fits mobile */ /* Video player fits mobile */
video { max-width: 100% !important; } 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 ──────────────────────── */
.date-display { font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; } .date-display { font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; }
/* ── Small Mobile Breakpoint (max-width: 480px) ────────────── */
@media (max-width: 480px) { @media (max-width: 480px) {
/* Base styles */
html, body { font-size: 14px !important; }
/* Login page */
.login-card { width: 100% !important; } .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; }
} }
+1 -1
View File
@@ -23,7 +23,7 @@ const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value,
const elapsed = now - startTime; const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1); const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3); 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); if (progress < 1) ref.current = requestAnimationFrame(animate);
}; };
ref.current = requestAnimationFrame(animate); ref.current = requestAnimationFrame(animate);
+914
View File
@@ -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 }}>
MP4MOV|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;
+1 -1
View File
@@ -156,7 +156,7 @@ const ProjectsPage: React.FC = () => {
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', border: 'none', background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', border: 'none',
borderRadius: 10, fontWeight: 600, height: 36, borderRadius: 10, fontWeight: 600, height: 36,
boxShadow: '0 4px 12px rgba(99,102,241,0.25)', boxShadow: '0 4px 12px rgba(99,102,241,0.25)',
}}></Button> }}></Button>
<Popconfirm title="确定删除该项目?" description="项目下的生成记录将一并删除" <Popconfirm title="确定删除该项目?" description="项目下的生成记录将一并删除"
onConfirm={async (e) => { e?.stopPropagation(); await deleteProject(project.id); message.success('项目已删除'); }} onConfirm={async (e) => { e?.stopPropagation(); await deleteProject(project.id); message.success('项目已删除'); }}
onCancel={(e) => e?.stopPropagation()}> onCancel={(e) => e?.stopPropagation()}>
+60 -13
View File
@@ -25,6 +25,7 @@ import {
VideoCameraOutlined, VideoCameraOutlined,
DownloadOutlined, DownloadOutlined,
RocketOutlined, RocketOutlined,
PictureOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useAppStore } from '../store/useAppStore'; import { useAppStore } from '../store/useAppStore';
import type { GenerationStatus, AspectRatio, Resolution } from '../types'; import type { GenerationStatus, AspectRatio, Resolution } from '../types';
@@ -68,6 +69,23 @@ const RecordsPage: React.FC = () => {
}; };
const openGenModal = (record: any) => { 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({ setGenModal({
recordId: record.id, recordId: record.id,
projectName: record.projectName, projectName: record.projectName,
@@ -124,6 +142,9 @@ const RecordsPage: React.FC = () => {
const prompt = editablePrompts[record.id] ?? record.optimizedPrompt; const prompt = editablePrompts[record.id] ?? record.optimizedPrompt;
const isGenerating = generating[record.id]; const isGenerating = generating[record.id];
const isExpanded = expandedId === record.id; const isExpanded = expandedId === record.id;
const type: any = record.genType || 'image';
return ( return (
<div key={record.id} className="animate-slideInCard" style={{ animationDelay: `${i * 0.04}s` }}> <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, 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 */} {/* Expand icon */}
{isExpanded {isExpanded
? <CaretDownOutlined style={{ color: '#6366f1', fontSize: 12, flexShrink: 0 }} /> ? <CaretDownOutlined style={{ color: '#6366f1', fontSize: 12, flexShrink: 0 }} />
@@ -162,9 +189,11 @@ const RecordsPage: React.FC = () => {
</Typography.Text> </Typography.Text>
{/* Meta */} {/* 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> {record.duration ? `${record.duration}` : '-'} · {record.aspectRatio || '-'} · {record.resolution || '-'} · <span className="date-display" translate="no">{formatDate(record.createdAt)}</span>
</span> </span>}
{/* Quick actions */} {/* Quick actions */}
<div className="record-actions" onClick={(e) => e.stopPropagation()}> <div className="record-actions" onClick={(e) => e.stopPropagation()}>
@@ -291,7 +320,17 @@ const RecordsPage: React.FC = () => {
display: 'flex', gap: 16, padding: '12px 16px', display: 'flex', gap: 16, padding: '12px 16px',
borderRadius: 10, background: '#f8f9fc', 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.duration ? `${record.duration}` : '-' },
{ label: '画面比例', value: record.aspectRatio || (record.status === 'prompt_optimized' ? '待选择' : '-') }, { label: '画面比例', value: record.aspectRatio || (record.status === 'prompt_optimized' ? '待选择' : '-') },
{ label: '分辨率', value: record.resolution || (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%)', background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.3)', border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.3)',
}}> }}>
{type === 'video' ? '选择参数生成视频' : '生成图片'}
</Button> </Button>
</div> </div>
)} )}
@@ -327,7 +366,7 @@ const RecordsPage: React.FC = () => {
loading={isGenerating} loading={isGenerating}
onClick={() => openGenModal(record)} onClick={() => openGenModal(record)}
style={{ borderRadius: 12, fontWeight: 600, height: 44 }}> style={{ borderRadius: 12, fontWeight: 600, height: 44 }}>
{type === 'video' ? '视频' : '图片'}
</Button> </Button>
</div> </div>
)} )}
@@ -343,34 +382,42 @@ const RecordsPage: React.FC = () => {
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12,
}}> }}>
<LoadingOutlined style={{ color: '#6366f1', fontSize: 32 }} spin /> <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> <Typography.Text style={{ color: '#cbd5e1', fontSize: 12 }}></Typography.Text>
</div> </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={{ borderRadius: 14, overflow: 'hidden', border: '1px solid #f0f0f5', position: 'relative' }}>
<div style={{ position: 'absolute', top: 10, right: 10, zIndex: 10 }}> <div style={{ position: 'absolute', top: 10, right: 10, zIndex: 10 }}>
<Tooltip title="下载视频"> <Tooltip title={`下载${type === 'video' ? '视频' : '图片'}`}>
<Button size="small" icon={<DownloadOutlined />} <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 }}> style={{ background: 'rgba(0,0,0,0.5)', border: 'none', color: '#fff', backdropFilter: 'blur(4px)', borderRadius: 8 }}>
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
<div style={{ minHeight: 280, overflow: 'hidden' }}> <div style={{ minHeight: 280, overflow: 'hidden' }}>
{type === 'video' ? (
<video <video
src={`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${record.videoUrl}`} src={`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${record.videoUrl}`}
controls controls
preload="metadata" 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>
<div style={{ <div style={{
padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: '#fafbff', background: '#fafbff',
}}> }}>
<Space> <Space>
<VideoCameraOutlined style={{ color: '#6366f1' }} /> {type === 'video' ? <VideoCameraOutlined style={{ color: '#6366f1' }} /> : <PictureOutlined style={{ color: '#6366f1' }} />}
<Typography.Text style={{ fontSize: 13, color: '#475569' }}> <Typography.Text style={{ fontSize: 13, color: '#475569' }}>
{record.projectName} {record.projectName}
</Typography.Text> </Typography.Text>
@@ -390,7 +437,7 @@ const RecordsPage: React.FC = () => {
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12,
}}> }}>
<CloseCircleOutlined style={{ color: '#ef4444', fontSize: 32 }} /> <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> <Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}></Typography.Text>
</div> </div>
) : ( ) : (
@@ -400,7 +447,7 @@ const RecordsPage: React.FC = () => {
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12,
}}> }}>
<ClockCircleOutlined style={{ color: '#94a3b8', fontSize: 32 }} /> <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> <Typography.Text style={{ color: '#cbd5e1', fontSize: 12 }}></Typography.Text>
</div> </div>
)} )}
+613
View File
@@ -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 格式的图片 URLJPEG格式,质量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 }}>
MP4MOV 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>
);
}
+6 -1
View File
@@ -27,9 +27,14 @@ export const useAuthStore = create<AuthState>((set) => ({
checkAuth: async () => { checkAuth: async () => {
try { try {
const token = localStorage.getItem('auth_token');
if (!token) { set({ user: null, loading: false }); return; }
const user = await api.getUser(); const user = await api.getUser();
set({ user, loading: false }); 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 }); set({ user: null, loading: false });
} }
}, },
+14 -2
View File
@@ -65,6 +65,7 @@ export interface GenerationRecord {
originalPrompt: string; originalPrompt: string;
optimizedPrompt?: string; optimizedPrompt?: string;
duration?: number; duration?: number;
genType?: number;
aspectRatio?: AspectRatio; aspectRatio?: AspectRatio;
resolution?: Resolution; resolution?: Resolution;
status: GenerationStatus; status: GenerationStatus;
@@ -77,18 +78,29 @@ export interface GenerationRecord {
errorMessage?: string; errorMessage?: string;
createdAt: string; createdAt: string;
generatedAt?: string; generatedAt?: string;
imageSize: string;
imageProportion: string;
imagePx: string;
imageUrl: string;
} }
export interface OptimizeParams { export interface OptimizeParams {
prompt: string; prompt: string;
duration: number; duration: number;
genType?: any;
resolution?: string;
aspectRatio?: string;
references?: MediaReference[]; references?: MediaReference[];
idempotencyKey?: string; idempotencyKey?: string;
image_size:any;
image_proportion:any;
image_px:any
} }
export interface GenerateParams { export interface GenerateParams {
aspectRatio: AspectRatio; aspectRatio?: AspectRatio;
resolution: Resolution; resolution?: Resolution;
} }
export interface OptimizeResult { export interface OptimizeResult {
+26
View File
@@ -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));
// 设置版本为 4UUIDv4
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);
});
}
+2
View File
@@ -6,6 +6,8 @@
"module": "esnext", "module": "esnext",
"types": ["vite/client"], "types": ["vite/client"],
"skipLibCheck": true, "skipLibCheck": true,
"strict": false,
"noImplicitAny": false,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",