合并代码解决冲突

This commit is contained in:
Lrd
2026-06-18 09:11:43 +08:00
60 changed files with 30905 additions and 3711 deletions
+3 -1
View File
@@ -24,4 +24,6 @@ dist-ssr
*.sw?
*.bak
*.zip
*.testbak
*.testbak
.env.production
+1
View File
@@ -17,6 +17,7 @@
}
}
.hero {
position: relative;
+6 -1
View File
@@ -20,9 +20,10 @@ import GeneratedRecord from './pages/GeneratedRecord';
import PreTest from './pages/PreTest';
import AuthorizationPage from './pages/AuthorizationPage';
import RemoveInfo from './pages/RemoveInfo';
import RemoveRw from './pages/RemoveRw';
// import RemoveFenbu from './pages/RemoveFenbu';
import ConsumePage from './pages/ConsumePage';
import { useAuthStore } from './store/useAuthStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading, checkAuth } = useAuthStore();
useEffect(() => {
@@ -102,6 +103,10 @@ const App = () => {
<Route path="initial/:creatID/initialinfo" element={<InitialInfo />} />
<Route path="removelens" element={<RemoveLens />} />
<Route path="removelens/:creatID/removeinfo" element={<RemoveInfo />} />
<Route path="removelens/:creatID/removefenbu" element={<RemoveRw />} />
{/* <Route path="removelens/:creatID/removefenbu" element={<RemoveFenbu />} /> */}
<Route path="generated" element={<GeneratedRecord />} />
<Route path="pretest" element={<PreTest />} />
<Route path="authorization" element={<AuthorizationPage />} />
+71 -2
View File
@@ -378,7 +378,7 @@ export async function getReplicationDetail(id: string): Promise<any> {
export async function getone(projectId: string, stepId: string): Promise<any> {
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-image-prompt`);
}
// 第二步,生成图片
export async function gettwo(projectId: string, stepId: string ,params: any): Promise<any> {
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-image`, params);
@@ -481,4 +481,73 @@ export async function deletePreTest(templateId: string): Promise<any> {
// /api/pre-test-template/default
export async function getDefaultPreTest(): Promise<any> {
return api.get(`/pre-test-template/default`);
}
}
// 修改第四步视频 AI 提词 JSON schema
export async function updateHotOpeningVideoPromptSchema(projectId: string, stepId: string, params: { prompt_schema: Record<string, any> }): Promise<any> {
return api.put(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/video-prompt-schema`, params);
}
// 镜头复刻
export async function createShotReplication(params: any): Promise<any> {
return api.post('/shot-replications/task-sets', params);
}
// 获取镜头复刻任务列表
export async function getShotReplicationList(page: number, page_size: number): Promise<any> {
return api.get(`/shot-replications/task-sets?page=${page}&page_size=${page_size}`);
}
// 获取镜头复刻任务详情
export async function getShotReplicationDetail(taskSetId: string): Promise<any> {
return api.get(`/shot-replications/task-sets/${taskSetId}`);
}
// ai拆镜
export async function createRemoveLens(taskSetId: string, params: any): Promise<any> {
return api.post(`/shot-replications/task-sets/${taskSetId}/split-by-ai`, params);
}
// 手动分割
export async function splitCustom(taskSetId: string, params: any): Promise<any> {
return api.post(`/shot-replications/task-sets/${taskSetId}/split-custom`, params);
}
// 拆镜列表
export async function Removelist(taskSetId: string): Promise<any> {
return api.get(`/shot-replications/task-sets/${taskSetId}/segments`);
}
// 生成视频
export async function removeCreate(recordId: string, params: any): Promise<any> {
return api.post(`/shot-replications/segments/${recordId}/replication-projects`, params);
}
// 获取爆款开头复刻任务详情
export async function removeDetail(id: string): Promise<any> {
return api.get(`/shot-replications/projects/${id}`);
}
// 第一步,生成提示词
export async function removeone(projectId: string, stepId: string): Promise<any> {
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-image-prompt`);
}
// 第二步,生成图片
export async function removetwo(projectId: string, stepId: string ,params: any): Promise<any> {
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-image`, params);
}
// 第三步,生成视频提示词
export async function removethree(projectId: string, stepId: string ,params: any): Promise<any> {
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-video-prompt`, params);
}
// 第四步,生成视频
export async function removefour(projectId: string, stepId: string ,params: any): Promise<any> {
return api.post(`/shot-replications/projects/${projectId}/steps/${stepId}/generate-video`, params);
}
//
@@ -167,7 +167,7 @@ const AppLayout: React.FC = () => {
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const currentOrderNoRef = useRef<string | null>(null);
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
// LocalStorage keys
const PENDING_ORDER_KEY = 'pending_payment_order';
@@ -177,12 +177,12 @@ const AppLayout: React.FC = () => {
getSiteInfo().then(info => {
const name = info.siteName || '民众智创';
const logo = info.siteLogo || '';
if (name !== siteName) {
setSiteName(name);
document.title = name;
}
if (logo && logo !== siteLogo) {
setSiteLogo(logo);
let faviconLink = document.querySelector('link[rel="icon"]') as HTMLLinkElement;
@@ -194,15 +194,15 @@ const AppLayout: React.FC = () => {
faviconLink.href = logo;
faviconLink.type = 'image/png';
}
localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo }));
}).catch(() => {});
}).catch(() => { });
}, []);
const loadUnreadCount = () => {
getUnreadCount().then(count => {
setUnreadCount(count);
}).catch(() => {});
}).catch(() => { });
};
// 检查并恢复待处理的支付订单
@@ -229,7 +229,7 @@ const AppLayout: React.FC = () => {
const timeoutSeconds = savedOrder.timeoutSeconds || 180;
const elapsedSeconds = Math.floor((now - createdAt) / 1000);
const remainingSeconds = Math.max(0, timeoutSeconds - elapsedSeconds);
if (remainingSeconds > 0) {
setQrCodeModalOpen(true);
startPolling(savedOrder.orderNo, remainingSeconds);
@@ -252,7 +252,7 @@ const AppLayout: React.FC = () => {
}
}
};
checkPendingOrder();
}, []);
@@ -274,16 +274,16 @@ const AppLayout: React.FC = () => {
});
}
setMenuItems(items);
}).catch(() => {});
}).catch(() => { });
getRechargePackages().then(data => {
setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false));
}).catch(() => {});
}).catch(() => { });
getPaymentMethods().then(data => {
setEnabledMethods(data);
// Auto-select the first enabled method
if (data.alipay) setPaymentMethod('alipay');
else if (data.wechat) setPaymentMethod('wechat');
}).catch(() => {});
}).catch(() => { });
loadUnreadCount();
}, [user]);
@@ -344,7 +344,7 @@ const AppLayout: React.FC = () => {
const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => {
stopPolling();
setCountdown(timeoutSeconds);
// 订单状态轮询(每2秒查询一次,只查询当前订单
const pollingTimer = setInterval(async () => {
try {
@@ -368,7 +368,7 @@ const AppLayout: React.FC = () => {
}
}, 2000);
pollingTimerRef.current = pollingTimer;
// 倒计时
const countdownTimer = setInterval(() => {
setCountdown(prev => {
@@ -376,7 +376,7 @@ const AppLayout: React.FC = () => {
// 超时自动取消
stopPolling();
if (currentOrderNoRef.current) {
cancelPaymentOrder(currentOrderNoRef.current).catch(() => {});
cancelPaymentOrder(currentOrderNoRef.current).catch(() => { });
currentOrderNoRef.current = null;
}
localStorage.removeItem(PENDING_ORDER_KEY);
@@ -474,8 +474,8 @@ const AppLayout: React.FC = () => {
background: isActive ? 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)' : 'transparent',
transition: 'all 0.2s ease',
}}>
<span style={{
fontSize: depth > 0 ? 14 : 16,
<span style={{
fontSize: depth > 0 ? 14 : 16,
flexShrink: 0,
color: isActive ? '#6366f1' : '#64748b',
}}>{menuIcon}</span>
@@ -521,13 +521,13 @@ const AppLayout: React.FC = () => {
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.4)',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)';
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(99, 102, 241, 0.5)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)';
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(99, 102, 241, 0.4)';
}}
@@ -544,7 +544,7 @@ const AppLayout: React.FC = () => {
justifyContent: 'flex-start',
gap: 12,
padding: '10px 14px',
borderRadius: 14, cursor: 'pointer',
borderRadius: 14, cursor: 'pointer',
transition: 'all 0.25s ease',
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)',
@@ -559,8 +559,8 @@ const AppLayout: React.FC = () => {
}}
>
<Avatar size={36} icon={<UserOutlined />}
style={{
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
style={{
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
flexShrink: 0,
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
}} />
@@ -568,9 +568,9 @@ const AppLayout: React.FC = () => {
<div style={{ color: '#1e293b', fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: -0.01 }}>
{user?.username}
</div>
<div style={{
color: '#6366f1',
fontSize: 12,
<div style={{
color: '#6366f1',
fontSize: 12,
fontWeight: 500,
letterSpacing: 0,
background: 'rgba(99, 102, 241, 0.08)',
@@ -586,18 +586,21 @@ const AppLayout: React.FC = () => {
{/* Main Content */}
<div className="desktop-content" style={{
marginLeft: sidebarW + 48,
marginLeft: sidebarW + 48,
marginRight: 32,
marginTop: 16,
marginBottom: 16,
flex: 1,
minHeight: 'calc(100vh - 32px)',
flex: 1,
// height: '100%',
minHeight: 'calc(100vh - 32px)',
background: 'transparent',
padding: 0,
padding: 0,
transition: 'margin-left 0.25s ease',
}}>
<div style={{
background: '#ffffff',
boxSizing: 'border-box',
height: '100%',
background: '#ffffffff',
borderRadius: '20px',
boxShadow: '0 4px 32px rgba(0, 0, 0, 0.06), 0 1px 8px rgba(0, 0, 0, 0.04)',
minHeight: '100%',
@@ -672,33 +675,33 @@ const AppLayout: React.FC = () => {
const g = GRADIENTS[idx % GRADIENTS.length];
const totalCredits = (opt.credits || 0) + (opt.bonus_credits || opt.bonusCredits || 0);
return (
<div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{
flex: '1 1 45%', minWidth: 200, borderRadius: 16, padding: '20px 16px',
background: selectedPlan === opt.id ? 'rgba(99,102,241,0.04)' : '#fafbff',
border: selectedPlan === opt.id ? '2px solid #6366f1' : '1px solid #f0f0f5',
cursor: 'pointer', position: 'relative', transition: 'all 0.2s',
}}>
{opt.description && (
<Tag color="purple" style={{ position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)', borderRadius: 8, fontSize: 11 }}>{opt.description}</Tag>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: g.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, color: '#fff', boxShadow: `0 6px 16px ${g.shadow}`,
}}>{g.icon}</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<Typography.Text strong style={{ fontSize: 15 }}>{opt.name}</Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>{totalCredits.toLocaleString()} </Typography.Text>
</div>
<div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{
flex: '1 1 45%', minWidth: 200, borderRadius: 16, padding: '20px 16px',
background: selectedPlan === opt.id ? 'rgba(99,102,241,0.04)' : '#fafbff',
border: selectedPlan === opt.id ? '2px solid #6366f1' : '1px solid #f0f0f5',
cursor: 'pointer', position: 'relative', transition: 'all 0.2s',
}}>
{opt.description && (
<Tag color="purple" style={{ position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)', borderRadius: 8, fontSize: 11 }}>{opt.description}</Tag>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
fontSize: 22, fontWeight: 800, marginTop: 4,
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
}}>¥{opt.price}</div>
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: g.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, color: '#fff', boxShadow: `0 6px 16px ${g.shadow}`,
}}>{g.icon}</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<Typography.Text strong style={{ fontSize: 15 }}>{opt.name}</Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>{totalCredits.toLocaleString()} </Typography.Text>
</div>
<div style={{
fontSize: 22, fontWeight: 800, marginTop: 4,
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
}}>¥{opt.price}</div>
</div>
</div>
</div>
</div>
);
})}
</div>
@@ -763,7 +766,7 @@ const AppLayout: React.FC = () => {
setRechargeModalOpen(false);
setQrCodeModalOpen(true);
currentOrderNoRef.current = order.orderNo;
// 保存到 localStorage
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
orderNo: order.orderNo,
@@ -774,7 +777,7 @@ const AppLayout: React.FC = () => {
createdAt: order.createdAt || new Date().toISOString(),
timeoutSeconds: 180,
}));
// Start polling for payment status
startPolling(order.orderNo);
} else {
@@ -808,7 +811,7 @@ const AppLayout: React.FC = () => {
stopPolling();
// Mark order as cancelled if it's still pending
if (currentOrderNoRef.current) {
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {}
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { }
currentOrderNoRef.current = null;
}
localStorage.removeItem(PENDING_ORDER_KEY);
@@ -934,7 +937,7 @@ const AppLayout: React.FC = () => {
onClick={async () => {
stopPolling();
if (currentOrderNoRef.current) {
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {}
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { }
currentOrderNoRef.current = null;
}
localStorage.removeItem(PENDING_ORDER_KEY);
@@ -0,0 +1,274 @@
import React from 'react';
import { Button, Input, Space, Tag, Typography } from 'antd';
const { Text } = Typography;
const { TextArea } = Input;
type JsonValue = any;
type VideoPromptSchemaEditorProps = {
value: Record<string, JsonValue>;
onChange: (nextValue: Record<string, JsonValue>) => void;
};
const LOCKED_TOP_LEVEL_KEYS = new Set([
'schema_version',
'schema_usage',
'动态时间规划',
'输出规格限制',
'合规控制',
'质量控制',
]);
const LOCKED_FRAME_KEYS = new Set([
'视频时长',
'视频比例',
'清晰度',
'帧率',
'推荐分辨率',
]);
const EDITABLE_FRAME_KEYS = new Set([
'主体描述',
'主体数量',
'主体位置',
'主体占比',
'场景描述',
'构图方式',
'画面风格',
'光影色彩',
]);
const EDITABLE_FINAL_PROMPT_KEYS = new Set([
'主提示词',
'动作提示词',
'镜头提示词',
'字幕提示词',
'音频提示词',
'风格提示词',
'负面提示词',
]);
function clonePlain<T>(value: T): T {
return value === undefined ? value : JSON.parse(JSON.stringify(value));
}
function pathIncludes(path: Array<string | number>, key: string): boolean {
return path.some((item) => String(item) === key);
}
function getPathValue(root: JsonValue, path: Array<string | number>): JsonValue {
let current = root;
for (const key of path) {
if (current === undefined || current === null) return undefined;
current = current[key as keyof typeof current];
}
return current;
}
function setPathValue(root: JsonValue, path: Array<string | number>, value: JsonValue): JsonValue {
const next = clonePlain(root);
let current = next;
for (let index = 0; index < path.length - 1; index += 1) {
current = current[path[index] as keyof typeof current];
}
current[path[path.length - 1] as keyof typeof current] = value;
return next;
}
function removeArrayItem(root: JsonValue, path: Array<string | number>, index: number): JsonValue {
const arrayValue = getPathValue(root, path);
if (!Array.isArray(arrayValue)) return root;
return setPathValue(root, path, arrayValue.filter((_, itemIndex) => itemIndex !== index));
}
function addArrayItem(root: JsonValue, path: Array<string | number>, sampleValue: JsonValue): JsonValue {
const arrayValue = getPathValue(root, path);
if (!Array.isArray(arrayValue)) return root;
const nextItem = typeof sampleValue === 'object' && sampleValue !== null ? clonePlain(sampleValue) : '';
return setPathValue(root, path, [...arrayValue, nextItem]);
}
function stringifyReadonly(value: JsonValue): string {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
}
function shouldUseTextArea(value: JsonValue): boolean {
const text = stringifyReadonly(value);
return text.length > 40 || text.includes('\n') || text.includes('') || text.includes('。');
}
function isLockedPath(path: Array<string | number>): boolean {
const rootKey = String(path[0] ?? '');
const currentKey = String(path[path.length - 1] ?? '');
if (LOCKED_TOP_LEVEL_KEYS.has(rootKey)) return true;
if (rootKey === '画面属性') {
if (LOCKED_FRAME_KEYS.has(currentKey)) return true;
if (!EDITABLE_FRAME_KEYS.has(currentKey)) return false;
}
if (rootKey === '最终提示词' && path.length === 2) {
return !EDITABLE_FINAL_PROMPT_KEYS.has(currentKey);
}
if ((rootKey === '动作流程' || rootKey === '镜头流程') && currentKey === '时间段') {
return true;
}
return false;
}
function canAddOrRemoveArray(path: Array<string | number>): boolean {
const rootKey = String(path[0] ?? '');
if (LOCKED_TOP_LEVEL_KEYS.has(rootKey)) return false;
if (rootKey === '动作流程' || rootKey === '镜头流程') return false;
return true;
}
function fieldTitle(key: string | number): string {
return typeof key === 'number' ? `${key + 1}` : key;
}
function ReadonlyBlock({ value }: { value: JsonValue }) {
const text = stringifyReadonly(value);
return shouldUseTextArea(value) ? (
<TextArea value={text} rows={Math.min(6, Math.max(2, Math.ceil(text.length / 42)))} disabled style={{ borderRadius: 8, color: '#64748b' }} />
) : (
<Input value={text} disabled style={{ borderRadius: 8, color: '#64748b' }} />
);
}
function EditableInput({ value, onChange }: { value: JsonValue; onChange: (nextValue: JsonValue) => void }) {
const text = stringifyReadonly(value);
if (shouldUseTextArea(value)) {
return (
<TextArea
value={text}
onChange={(event) => onChange(event.target.value)}
rows={Math.min(8, Math.max(3, Math.ceil(text.length / 42)))}
style={{ borderRadius: 8 }}
/>
);
}
return <Input value={text} onChange={(event) => onChange(event.target.value)} style={{ borderRadius: 8 }} />;
}
const VideoPromptSchemaEditor: React.FC<VideoPromptSchemaEditorProps> = ({ value, onChange }) => {
const safeValue = value && typeof value === 'object' ? value : {};
const updatePath = (path: Array<string | number>, nextValue: JsonValue) => {
onChange(setPathValue(safeValue, path, nextValue));
};
const renderNode = (key: string | number, nodeValue: JsonValue, path: Array<string | number>, depth = 0): React.ReactNode => {
const locked = isLockedPath(path);
const rootKey = String(path[0] ?? '');
if (Array.isArray(nodeValue)) {
const editableArray = !locked && canAddOrRemoveArray(path);
const sample = nodeValue.find((item) => item !== undefined) ?? '';
return (
<div key={path.join('.')} style={{ marginBottom: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<Space>
<Text strong style={{ color: '#334155', fontSize: 13 }}>{fieldTitle(key)}</Text>
{!editableArray && <Tag color="default"></Tag>}
</Space>
{editableArray && (
<Button size="small" type="link" onClick={() => onChange(addArrayItem(safeValue, path, sample))}>
+
</Button>
)}
</div>
<div style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: 12, background: locked ? '#f8fafc' : '#fff' }}>
{nodeValue.length === 0 ? (
<Text style={{ color: '#94a3b8', fontSize: 12 }}></Text>
) : (
nodeValue.map((item, index) => (
<div key={`${path.join('.')}.${index}`} style={{ marginBottom: index === nodeValue.length - 1 ? 0 : 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<Text style={{ color: '#94a3b8', fontSize: 12, marginTop: 7, minWidth: 28 }}>{index + 1}.</Text>
<div style={{ flex: 1 }}>
{typeof item === 'object' && item !== null ? (
renderObjectFields(item, [...path, index], depth + 1)
) : locked ? (
<ReadonlyBlock value={item} />
) : (
<EditableInput value={item} onChange={(nextText) => updatePath([...path, index], nextText)} />
)}
</div>
{editableArray && (
<Button size="small" type="text" danger onClick={() => onChange(removeArrayItem(safeValue, path, index))}>
</Button>
)}
</div>
</div>
))
)}
</div>
{(rootKey === '动作流程' || rootKey === '镜头流程') && (
<Text style={{ display: 'block', marginTop: 6, color: '#94a3b8', fontSize: 12 }}>
</Text>
)}
</div>
);
}
if (typeof nodeValue === 'object' && nodeValue !== null) {
const lockedSection = locked || LOCKED_TOP_LEVEL_KEYS.has(String(key));
return (
<div key={path.join('.')} style={{ marginBottom: 18 }}>
<Space style={{ marginBottom: 8 }}>
<Text strong style={{ color: '#334155', fontSize: 13 }}>{fieldTitle(key)}</Text>
{lockedSection && <Tag color="default"></Tag>}
</Space>
<div
style={{
borderLeft: depth === 0 ? '3px solid #6366f1' : '2px solid #e2e8f0',
paddingLeft: 12,
marginLeft: 4,
background: lockedSection ? '#f8fafc' : 'transparent',
}}
>
{renderObjectFields(nodeValue, path, depth + 1)}
</div>
</div>
);
}
return (
<div key={path.join('.')} style={{ marginBottom: 12 }}>
<Space style={{ marginBottom: 4 }}>
<Text style={{ color: '#64748b', fontSize: 12 }}>{fieldTitle(key)}</Text>
{locked && <Tag color="default"></Tag>}
</Space>
{locked ? (
<ReadonlyBlock value={nodeValue} />
) : (
<EditableInput value={nodeValue} onChange={(nextText) => updatePath(path, nextText)} />
)}
</div>
);
};
const renderObjectFields = (objectValue: Record<string, JsonValue>, parentPath: Array<string | number>, depth = 0): React.ReactNode => {
return Object.entries(objectValue).map(([childKey, childValue]) => renderNode(childKey, childValue, [...parentPath, childKey], depth));
};
return (
<div>
<div style={{ marginBottom: 14, padding: 12, borderRadius: 10, background: '#f8fafc', color: '#64748b', fontSize: 13, lineHeight: 1.7 }}>
schema /
</div>
{Object.entries(safeValue).map(([key, childValue]) => renderNode(key, childValue, [key]))}
</div>
);
};
export default VideoPromptSchemaEditor;
@@ -0,0 +1,624 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Modal, Slider, Spin, Typography } from 'antd';
import { PauseOutlined, PlayCircleFilled } from '@ant-design/icons';
const { Text } = Typography;
const MIN_TRIM_SECONDS = 2;
const MAX_TRIM_SECONDS = 15;
const DEFAULT_TRIM_SECONDS = 7;
const FRAME_WIDTH = 160;
const FRAME_HEIGHT = 90;
type VideoTrimPickerProps = {
open: boolean;
videoUrl: string;
title?: string;
loading?: boolean;
minDuration?: number;
maxDuration?: number;
onCancel: () => void;
onConfirm: (range: { startSecond: number; endSecond: number; durationSecond: number }) => void | Promise<void>;
};
type FrameItem = {
second: number;
captureSecond?: number;
image: string;
status: 'loading' | 'success' | 'failed';
fallback?: boolean;
};
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function toIntegerSecond(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.round(value));
}
function formatTime(secondValue: number): string {
const total = toIntegerSecond(secondValue);
const minutes = Math.floor(total / 60);
const seconds = total % 60;
return `${pad2(minutes)}:${pad2(seconds)}`;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function sameRange(a: [number, number], b: [number, number]): boolean {
return a[0] === b[0] && a[1] === b[1];
}
function waitForEvent(target: EventTarget, eventName: string, timeout = 8000): Promise<void> {
return new Promise((resolve, reject) => {
let timer: number | undefined;
const cleanup = () => {
if (timer) window.clearTimeout(timer);
target.removeEventListener(eventName, onOk);
target.removeEventListener('error', onError);
};
const onOk = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error(`视频${eventName}失败`));
};
target.addEventListener(eventName, onOk, { once: true });
target.addEventListener('error', onError, { once: true });
timer = window.setTimeout(() => {
cleanup();
reject(new Error(`视频${eventName}超时`));
}, timeout);
});
}
function waitNextFrame(): Promise<void> {
return new Promise((resolve) => {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => resolve());
});
});
}
async function seekVideo(video: HTMLVideoElement, targetSecond: number): Promise<void> {
const safeTarget = Math.max(0, targetSecond);
if (Math.abs(video.currentTime - safeTarget) > 0.01) {
const seeked = waitForEvent(video, 'seeked');
video.currentTime = safeTarget;
await seeked;
}
await waitNextFrame();
}
function isMostlyBlackFrame(ctx: CanvasRenderingContext2D, width: number, height: number): boolean {
let data: Uint8ClampedArray;
try {
data = ctx.getImageData(0, 0, width, height).data;
} catch {
return false;
}
let sampled = 0;
let dark = 0;
const pixelStride = 8;
for (let y = 0; y < height; y += pixelStride) {
for (let x = 0; x < width; x += pixelStride) {
const index = (y * width + x) * 4;
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
const a = data[index + 3];
if (a < 20) continue;
const luma = 0.299 * r + 0.587 * g + 0.114 * b;
sampled += 1;
if (luma < 18) dark += 1;
}
}
return sampled > 0 && dark / sampled >= 0.85;
}
async function captureOneFrame(
video: HTMLVideoElement,
ctx: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
displaySecond: number,
realDuration: number,
): Promise<FrameItem> {
const lastSafeSecond = Math.max(0, realDuration - 0.05);
const candidates = Array.from(
new Set(
[displaySecond, displaySecond + 0.2, displaySecond + 0.5, displaySecond + 1, displaySecond + 2]
.map((value) => Math.min(value, lastSafeSecond))
.filter((value) => value >= 0 && value <= lastSafeSecond),
),
);
for (const captureSecond of candidates) {
try {
await seekVideo(video, captureSecond);
ctx.clearRect(0, 0, FRAME_WIDTH, FRAME_HEIGHT);
ctx.drawImage(video, 0, 0, FRAME_WIDTH, FRAME_HEIGHT);
if (isMostlyBlackFrame(ctx, FRAME_WIDTH, FRAME_HEIGHT)) {
continue;
}
return {
second: displaySecond,
captureSecond,
image: canvas.toDataURL('image/jpeg', 0.76),
status: 'success',
fallback: Math.abs(captureSecond - displaySecond) > 0.01,
};
} catch {
// 当前候选时间失败时继续尝试后面的候选时间。
}
}
return {
second: displaySecond,
image: '',
status: 'failed',
};
}
function normalizeRange(
rawRange: [number, number],
prevRange: [number, number],
integerDuration: number,
minDuration: number,
maxDuration: number,
): [number, number] {
if (!integerDuration || integerDuration <= 0) {
return [0, 0];
}
const maxSelectableDuration = Math.min(maxDuration, integerDuration);
const minSelectableDuration = Math.min(minDuration, maxSelectableDuration);
let [start, end] = rawRange;
start = toIntegerSecond(clamp(start, 0, integerDuration));
end = toIntegerSecond(clamp(end, 0, integerDuration));
if (end < start) {
[start, end] = [end, start];
}
const movedStart = Math.abs(start - prevRange[0]) >= Math.abs(end - prevRange[1]);
let selectedDuration = end - start;
if (selectedDuration < minSelectableDuration) {
if (movedStart) {
start = toIntegerSecond(clamp(end - minSelectableDuration, 0, Math.max(0, integerDuration - minSelectableDuration)));
end = start + minSelectableDuration;
} else {
end = toIntegerSecond(clamp(start + minSelectableDuration, minSelectableDuration, integerDuration));
start = end - minSelectableDuration;
}
}
selectedDuration = end - start;
if (selectedDuration > maxSelectableDuration) {
if (movedStart) {
start = toIntegerSecond(clamp(end - maxSelectableDuration, 0, Math.max(0, integerDuration - maxSelectableDuration)));
} else {
end = toIntegerSecond(clamp(start + maxSelectableDuration, maxSelectableDuration, integerDuration));
}
}
start = toIntegerSecond(clamp(start, 0, integerDuration));
end = toIntegerSecond(clamp(end, start, integerDuration));
return [start, end];
}
function buildInitialRange(integerDuration: number, minDuration: number, maxDuration: number): [number, number] {
if (!integerDuration || integerDuration <= 0) return [0, minDuration];
const initialEnd = Math.min(integerDuration, Math.max(minDuration, Math.min(DEFAULT_TRIM_SECONDS, maxDuration)));
return [0, initialEnd];
}
const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
open,
videoUrl,
title = '手动拆镜',
loading = false,
minDuration = MIN_TRIM_SECONDS,
maxDuration = MAX_TRIM_SECONDS,
onCancel,
onConfirm,
}) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const abortRef = useRef(false);
const rangeRef = useRef<[number, number]>([0, minDuration]);
const currentTimeRef = useRef(0);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [range, setRangeState] = useState<[number, number]>([0, minDuration]);
const [frames, setFrames] = useState<FrameItem[]>([]);
const [frameLoading, setFrameLoading] = useState(false);
const [playing, setPlaying] = useState(false);
const [localError, setLocalError] = useState('');
const integerDuration = useMemo(() => Math.max(0, Math.floor(duration || 0)), [duration]);
const selectedDuration = useMemo(() => Math.max(0, range[1] - range[0]), [range]);
const disabledByDuration = integerDuration > 0 && integerDuration < minDuration;
const setRange = useCallback((next: [number, number] | ((prev: [number, number]) => [number, number])) => {
setRangeState((prev) => {
const resolved = typeof next === 'function' ? next(prev) : next;
if (sameRange(prev, resolved)) return prev;
rangeRef.current = resolved;
return resolved;
});
}, []);
const setDisplayedTime = useCallback((second: number) => {
const next = toIntegerSecond(second);
if (currentTimeRef.current === next) return;
currentTimeRef.current = next;
setCurrentTime(next);
}, []);
const seekPreview = useCallback((second: number) => {
const video = videoRef.current;
if (!video || !integerDuration) return;
const next = toIntegerSecond(clamp(second, 0, integerDuration));
try {
video.currentTime = next;
} catch {
// ignore seek error
}
setDisplayedTime(next);
}, [integerDuration, setDisplayedTime]);
useEffect(() => {
if (!open) {
abortRef.current = true;
setPlaying(false);
setFrames([]);
setFrameLoading(false);
setDuration(0);
setDisplayedTime(0);
setLocalError('');
setRange([0, minDuration]);
if (videoRef.current) {
videoRef.current.pause();
}
return;
}
abortRef.current = false;
setPlaying(false);
setFrameLoading(true);
setFrames([]);
setDuration(0);
setDisplayedTime(0);
setLocalError('');
setRange([0, minDuration]);
const extractor = document.createElement('video');
extractor.crossOrigin = 'anonymous';
extractor.muted = true;
extractor.playsInline = true;
extractor.preload = 'auto';
extractor.src = videoUrl;
const buildFrames = async () => {
try {
await waitForEvent(extractor, 'loadedmetadata');
await waitForEvent(extractor, 'loadeddata').catch(() => undefined);
if (abortRef.current) return;
const realDuration = Number.isFinite(extractor.duration) ? extractor.duration : 0;
const nextIntegerDuration = Math.max(0, Math.floor(realDuration));
setDuration(realDuration);
setRange(buildInitialRange(nextIntegerDuration, minDuration, maxDuration));
if (!realDuration || nextIntegerDuration <= 0) {
setLocalError('视频时长异常,无法抽取帧');
return;
}
const canvas = document.createElement('canvas');
canvas.width = FRAME_WIDTH;
canvas.height = FRAME_HEIGHT;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
setLocalError('当前浏览器不支持 Canvas 抽帧');
return;
}
const initialFrames: FrameItem[] = Array.from({ length: nextIntegerDuration }, (_, index) => ({
second: index,
image: '',
status: 'loading',
}));
setFrames(initialFrames);
const collected = [...initialFrames];
for (let second = 0; second < nextIntegerDuration; second += 1) {
if (abortRef.current) return;
const frame = await captureOneFrame(extractor, ctx, canvas, second, realDuration);
if (abortRef.current) return;
collected[second] = frame;
setFrames([...collected]);
}
} catch (error: any) {
if (!abortRef.current) {
setLocalError(error?.message || '视频帧抽取失败,请确认视频资源允许跨域访问');
}
} finally {
if (!abortRef.current) {
setFrameLoading(false);
}
}
};
buildFrames();
return () => {
abortRef.current = true;
extractor.pause();
extractor.removeAttribute('src');
extractor.load();
};
}, [open, videoUrl, minDuration, maxDuration, setDisplayedTime, setRange]);
const handleRangeChange = useCallback((value: number[]) => {
if (!integerDuration) return;
setLocalError('');
setRange((prev) => normalizeRange([Number(value[0]), Number(value[1])], prev, integerDuration, minDuration, maxDuration));
}, [integerDuration, maxDuration, minDuration, setRange]);
const handleRangeChangeComplete = useCallback(() => {
seekPreview(rangeRef.current[0]);
}, [seekPreview]);
const handleFrameClick = useCallback((second: number) => {
if (!integerDuration || disabledByDuration) return;
const keepDuration = clamp(selectedDuration || Math.min(DEFAULT_TRIM_SECONDS, maxDuration), minDuration, Math.min(maxDuration, integerDuration));
let start = clamp(second, 0, Math.max(0, integerDuration - keepDuration));
start = toIntegerSecond(start);
const next: [number, number] = [start, start + keepDuration];
setRange(next);
seekPreview(next[0]);
}, [disabledByDuration, integerDuration, maxDuration, minDuration, seekPreview, selectedDuration, setRange]);
const handlePlaySelected = async () => {
const video = videoRef.current;
if (!video || !integerDuration) return;
if (playing) {
video.pause();
setPlaying(false);
return;
}
if (video.currentTime < range[0] || video.currentTime >= range[1]) {
video.currentTime = range[0];
setDisplayedTime(range[0]);
}
try {
await video.play();
setPlaying(true);
setLocalError('');
} catch {
setLocalError('视频播放失败,请检查视频地址');
}
};
const handleTimeUpdate = () => {
const video = videoRef.current;
if (!video) return;
const current = toIntegerSecond(video.currentTime || 0);
setDisplayedTime(current);
if (playing && video.currentTime >= rangeRef.current[1]) {
video.pause();
video.currentTime = rangeRef.current[1];
setDisplayedTime(rangeRef.current[1]);
setPlaying(false);
}
};
const handleConfirm = async () => {
if (!integerDuration || integerDuration <= 0) {
setLocalError('请等待视频加载完成');
return;
}
if (disabledByDuration) {
setLocalError(`视频总时长不足 ${minDuration} 秒,无法手动拆镜`);
return;
}
if (selectedDuration < minDuration) {
setLocalError(`拆镜片段不能低于 ${minDuration}`);
return;
}
if (selectedDuration > maxDuration) {
setLocalError(`拆镜片段不能超过 ${maxDuration}`);
return;
}
setLocalError('');
await onConfirm({
startSecond: range[0],
endSecond: range[1],
durationSecond: selectedDuration,
});
};
return (
<Modal
title={title}
open={open}
onCancel={onCancel}
width={980}
destroyOnHidden
footer={[
<Button key="cancel" onClick={onCancel} disabled={loading}>
</Button>,
<Button key="confirm" type="primary" onClick={handleConfirm} loading={loading} disabled={loading || disabledByDuration || !integerDuration}>
</Button>,
]}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ display: 'flex', justifyContent: 'center', background: '#fff' }}>
<video
ref={videoRef}
src={videoUrl}
crossOrigin="anonymous"
preload="metadata"
playsInline
onLoadedMetadata={(event) => {
const realDuration = Number.isFinite(event.currentTarget.duration) ? event.currentTarget.duration : 0;
if (!realDuration) return;
const nextIntegerDuration = Math.max(0, Math.floor(realDuration));
setDuration((prev) => (Math.floor(prev || 0) === nextIntegerDuration ? prev : realDuration));
setRange((prev) => {
if (prev[1] > minDuration || prev[0] !== 0) return prev;
return buildInitialRange(nextIntegerDuration, minDuration, maxDuration);
});
}}
onTimeUpdate={handleTimeUpdate}
onPause={() => setPlaying(false)}
style={{ width: '100%', maxHeight: 420, objectFit: 'contain', background: '#111', borderRadius: 8 }}
/>
</div>
<div
style={{
borderRadius: 24,
background: '#f3f6ff',
padding: '26px 34px 18px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 28, marginBottom: 22 }}>
<Button
type="text"
icon={playing ? <PauseOutlined /> : <PlayCircleFilled />}
onClick={handlePlaySelected}
disabled={!integerDuration || disabledByDuration}
style={{ fontSize: 22, color: '#111' }}
/>
<span style={{ fontSize: 26, color: '#111', letterSpacing: 1 }}>
{formatTime(currentTime)} / {formatTime(integerDuration)}
</span>
</div>
<div style={{ position: 'relative', padding: '0 6px' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${Math.max(frames.length, 1)}, minmax(42px, 1fr))`,
height: 86,
overflow: 'hidden',
borderRadius: 10,
background: '#dbe2ff',
}}
>
{frameLoading && frames.length === 0 ? (
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="small" />
<Text style={{ marginLeft: 8, color: '#64748b' }}>...</Text>
</div>
) : frames.length > 0 ? (
frames.map((frame) => (
<button
key={frame.second}
type="button"
onClick={() => handleFrameClick(frame.second)}
title={`${frame.second}s${frame.fallback ? `,实际取帧 ${frame.captureSecond?.toFixed(1)}s` : ''}`}
style={{
minWidth: 0,
height: 86,
border: 'none',
padding: 0,
background: '#eef2ff',
overflow: 'hidden',
cursor: disabledByDuration ? 'not-allowed' : 'pointer',
position: 'relative',
}}
>
{frame.status === 'success' && frame.image ? (
<img src={frame.image} alt={`${frame.second}s`} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
) : frame.status === 'loading' ? (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="small" />
</div>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#64748b', fontSize: 12, background: '#eef2ff' }}>
<span>{frame.second}s</span>
<span style={{ fontSize: 11 }}></span>
</div>
)}
<span
style={{
position: 'absolute',
left: 4,
bottom: 3,
color: '#fff',
fontSize: 11,
textShadow: '0 1px 3px rgba(0,0,0,.7)',
}}
>
{frame.second}s
</span>
</button>
))
) : (
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748b' }}>
</div>
)}
</div>
<div style={{ marginTop: -54, padding: '0 8px 0' }}>
<Slider
range
min={0}
max={integerDuration || maxDuration}
step={1}
value={range}
onChange={handleRangeChange}
onChangeComplete={handleRangeChangeComplete}
tooltip={{ formatter: (value) => `${toIntegerSecond(Number(value || 0))}s` }}
disabled={!integerDuration || disabledByDuration}
/>
</div>
</div>
<div style={{ marginTop: 30, textAlign: 'center', color: '#64748b', fontSize: 16 }}>
{selectedDuration}s
<span style={{ marginLeft: 12, fontSize: 13, color: '#94a3b8' }}>
{minDuration}s {maxDuration}s/
</span>
</div>
{localError && (
<div style={{ marginTop: 12, textAlign: 'center', color: '#ef4444', fontSize: 13 }}>
{localError}
</div>
)}
</div>
</div>
</Modal>
);
};
export default VideoTrimPicker;
+15 -11
View File
@@ -830,7 +830,7 @@ const AIChatPage: React.FC = () => {
// ==================== 渲染 ====================
return (
<Layout style={{ height: '94vh', background: '#fafafa', overflow: 'hidden' }}>
<Layout style={{ height: '90vh', background: '#fafafa', overflow: 'auto' }}>
{/* 左侧边栏 - 对话列表(已隐藏,保留代码) */}
{false && (
<Sider
@@ -2336,11 +2336,13 @@ const AIChatPage: React.FC = () => {
×
</button>
}
bodyStyle={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
styles={{
body: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
}
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
@@ -2423,11 +2425,13 @@ const AIChatPage: React.FC = () => {
×
</button>
}
bodyStyle={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
styles={{
body: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '400px',
}
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
+60 -147
View File
@@ -2,12 +2,17 @@ import React, { useState, useEffect } from 'react';
import { Button, Typography, Collapse, Space, Modal, Input, Table, message } from 'antd';
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import { getReplicationList, getReplicationDetail, gettwo,getthree, getfour,getEngine } from '../api/index';
import { getReplicationList, getReplicationDetail, gettwo, getthree, getfour, getEngine, updateHotOpeningVideoPromptSchema } from '../api/index';
import VideoPromptSchemaEditor from '../components/VideoPromptSchemaEditor';
import './css/InitialInfo.css';
const { Title, Text } = Typography;
const { TextArea } = Input;
function clonePlain<T>(value: T): T {
return value === undefined ? value : JSON.parse(JSON.stringify(value));
}
function InitialInfo() {
const navigate = useNavigate();
const { creatID } = useParams<{ creatID: string }>();
@@ -17,6 +22,8 @@ function InitialInfo() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [currentType, setCurrentType] = useState<string>('image');
const [formData, setFormData] = useState<any>({});
const [editingPromptStepId, setEditingPromptStepId] = useState<string>('');
const [promptSaving, setPromptSaving] = useState(false);
const [pollingTimer, setPollingTimer] = useState<any>(null);
// 引擎和视频参数相关状态
@@ -180,23 +187,57 @@ function InitialInfo() {
}
}, [steps]);
const handleOpenModal = (prompt?: any, type?: string) => {
const handleOpenModal = (prompt?: any, type?: string, stepId?: string | number) => {
setCurrentType(type || 'image');
if (type === 'video' && typeof prompt === 'object') {
setFormData(prompt);
setEditingPromptStepId(stepId ? String(stepId) : '');
if (type === 'video' && typeof prompt === 'object' && prompt) {
setFormData(clonePlain(prompt));
setPromptText('');
} else {
setPromptText(prompt || '');
setFormData({});
}
setModalVisible(true);
};
const handleConfirm = () => {
setModalVisible(false);
const handleConfirm = async () => {
if (currentType !== 'video') {
setModalVisible(false);
return;
}
if (!taskDetail?.id || !editingPromptStepId) {
message.warning('缺少任务或步骤 ID,无法保存视频提示词');
return;
}
if (!formData || typeof formData !== 'object' || Object.keys(formData).length === 0) {
message.warning('视频提示词不能为空');
return;
}
setPromptSaving(true);
try {
const res: any = await updateHotOpeningVideoPromptSchema(taskDetail.id, editingPromptStepId, {
prompt_schema: formData,
});
if (res?.detail) {
setTaskDetail(res.detail);
setApiSteps(res.detail.steps || []);
} else {
refreshTaskDetail();
}
message.success(res?.message || '视频提示词已保存');
setModalVisible(false);
setEditingPromptStepId('');
} catch (error: any) {
message.error(error?.message || '保存视频提示词失败');
} finally {
setPromptSaving(false);
}
};
// 轮询任务详情
@@ -905,7 +946,7 @@ function InitialInfo() {
<Button
type="default"
icon={<EditOutlined />}
onClick={() => handleOpenModal(step?.output?.payload?.promptSchema,'video')}
onClick={() => handleOpenModal(step?.output?.payload?.promptSchema, 'video', step.id)}
style={{ flex: 1, borderRadius: 8, borderColor: '#6366f1', color: '#6366f1', height: 36 }}
disabled={step.status !== 'completed'}
>
@@ -967,12 +1008,12 @@ function InitialInfo() {
</div>
</div>
<Modal
title="修改提示词"
title={currentType === 'video' ? '修改视频提示词' : '修改提示词'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onCancel={() => { if (!promptSaving) setModalVisible(false); }}
footer={[
<Button key="cancel" onClick={() => setModalVisible(false)}></Button>,
<Button key="confirm" type="primary" onClick={handleConfirm}></Button>,
<Button key="cancel" onClick={() => setModalVisible(false)} disabled={promptSaving}></Button>,
<Button key="confirm" type="primary" onClick={handleConfirm} loading={promptSaving} disabled={promptSaving}></Button>,
]}
width={800}
>
@@ -986,7 +1027,7 @@ function InitialInfo() {
/>
) : (
<div style={{ maxHeight: 500, overflowY: 'auto', paddingRight: 10 }}>
<FormRenderer data={formData} onChange={setFormData} />
<VideoPromptSchemaEditor value={formData} onChange={setFormData} />
</div>
)}
</Modal>
@@ -1055,7 +1096,10 @@ function InitialInfo() {
key: 'action',
render: (_, record) => (
<button
onClick={() => navigate(`/initial/${record.id}/initialinfo`)}
onClick={() => {
setIsModalOpen(false);
navigate(`/initial/${record.id}/initialinfo`);
}}
style={{ color: '#6366f1', textDecoration: 'none', fontSize: 12, border: 'none', background: 'none', cursor: 'pointer' }}
>
@@ -1081,135 +1125,4 @@ function InitialInfo() {
);
}
const FormRenderer = ({ data, onChange }: { data: any; onChange: (data: any) => void }) => {
const handleFieldChange = (path: string[], value: any) => {
const newData = { ...data };
let current = newData;
for (let i = 0; i < path.length - 1; i++) {
current = current[path[i]];
}
current[path[path.length - 1]] = value;
onChange(newData);
};
const handleArrayItemChange = (path: string[], index: number, value: any) => {
const newData = { ...data };
let current = newData;
for (let i = 0; i < path.length; i++) {
if (i === path.length - 1) {
current[path[i]] = [...current[path[i]]];
current[path[i]][index] = value;
} else {
current = current[path[i]];
}
}
onChange(newData);
};
const handleArrayAdd = (path: string[]) => {
const newData = { ...data };
let current = newData;
for (let i = 0; i < path.length; i++) {
if (i === path.length - 1) {
current[path[i]] = [...current[path[i]], ''];
} else {
current = current[path[i]];
}
}
onChange(newData);
};
const handleArrayRemove = (path: string[], index: number) => {
const newData = { ...data };
let current = newData;
for (let i = 0; i < path.length; i++) {
if (i === path.length - 1) {
current[path[i]] = current[path[i]].filter((_: any, i: number) => i !== index);
} else {
current = current[path[i]];
}
}
onChange(newData);
};
const renderField = (key: string, value: any, path: string[]) => {
if (Array.isArray(value)) {
return (
<div key={key} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
<Text strong style={{ color: '#374151', fontSize: 13 }}>{key}</Text>
<Button
type="text"
size="small"
onClick={() => handleArrayAdd(path)}
style={{ marginLeft: 8, color: '#6366f1', fontSize: 12 }}
>
+
</Button>
</div>
<div style={{ border: '1px solid #e5e7eb', borderRadius: 8, padding: 12, background: '#f9fafb' }}>
{value.map((item: any, index: number) => (
<div key={index} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
<span style={{ color: '#9ca3af', fontSize: 12, marginTop: 6 }}>{index + 1}.</span>
<div style={{ flex: 1 }}>
{typeof item === 'object' ? (
<FormRenderer
data={item}
onChange={(newItem) => handleArrayItemChange(path, index, newItem)}
/>
) : (
<Input
value={item}
onChange={(e) => handleArrayItemChange(path, index, e.target.value)}
style={{ width: '100%', borderRadius: 6 }}
/>
)}
</div>
<Button
type="text"
danger
onClick={() => handleArrayRemove(path, index)}
style={{ marginTop: 4 }}
>
</Button>
</div>
))}
</div>
</div>
);
}
if (typeof value === 'object' && value !== null) {
return (
<div key={key} style={{ marginBottom: 16 }}>
<Text strong style={{ color: '#374151', fontSize: 13, marginBottom: 8, display: 'block' }}>
{key}
</Text>
<div style={{ borderLeft: '3px solid #6366f1', paddingLeft: 12, marginLeft: 4 }}>
<FormRenderer data={value} onChange={(newValue) => handleFieldChange(path, newValue)} />
</div>
</div>
);
}
return (
<div key={key} style={{ marginBottom: 12 }}>
<Text style={{ color: '#6b7280', fontSize: 12, marginBottom: 4, display: 'block' }}>{key}</Text>
<Input
value={value}
onChange={(e) => handleFieldChange(path, e.target.value)}
style={{ width: '100%', borderRadius: 6 }}
/>
</div>
);
};
return (
<div>
{Object.entries(data).map(([key, value]) => renderField(key, value, [key]))}
</div>
);
};
export default InitialInfo;
+432 -336
View File
@@ -1,22 +1,68 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button, Table, Tag, Drawer, Input, Upload, message } from 'antd';
import { ArrowLeftOutlined, PlayCircleOutlined, XOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Button, Drawer, Input, Table, Tag, Upload, message } from 'antd';
import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadImage } from '../api';
import VideoTrimPicker from '../components/VideoTrimPicker';
const { TextArea } = Input;
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const MIN_TRIM_SECONDS = 2;
const MAX_TRIM_SECONDS = 15;
function buildAssetUrl(url?: string): string {
if (!url) return '';
if (/^https?:\/\//i.test(url)) return url;
return `${API_BASE}${url}`;
}
function RemoveInfo() {
const { creatID } = useParams<{ creatID: string }>();
const navigate = useNavigate();
const [drawerVisible, setDrawerVisible] = useState(false);
const [currentSegment, setCurrentSegment] = useState<number | null>(null);
const [trimModalVisible, setTrimModalVisible] = useState(false);
const [currentSegment, setCurrentSegment] = useState<string | null>(null);
const [productName, setProductName] = useState('');
const [productSellingPoint, setProductSellingPoint] = useState('');
const [productImage, setProductImage] = useState('');
const [detailImage, setDetailImage] = useState('');
const [taskDetail, setTaskDetail] = useState<any>(null);
const [tableData, setTableData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [splitLoading, setSplitLoading] = useState(false);
const [autoSplitLoading, setAutoSplitLoading] = useState(false);
const handleGenerate = (segmentId: number) => {
const videoUrl = useMemo(() => buildAssetUrl(taskDetail?.videoUrl), [taskDetail?.videoUrl]);
const fetchTaskDetail = useCallback(async () => {
if (!creatID) return;
try {
const res = await getShotReplicationDetail(creatID);
setTaskDetail(res);
} catch {
message.error('获取任务详情失败');
}
}, [creatID]);
const fetchSegments = useCallback(async () => {
if (!creatID) return;
try {
const res = await Removelist(creatID);
setTableData(res.items || []);
} catch {
message.error('获取拆镜列表失败');
}
}, [creatID]);
const refreshPageData = useCallback(async () => {
await Promise.all([fetchTaskDetail(), fetchSegments()]);
}, [fetchTaskDetail, fetchSegments]);
useEffect(() => {
refreshPageData();
}, [refreshPageData]);
const handleGenerate = (segmentId: string) => {
setCurrentSegment(segmentId);
setDrawerVisible(true);
};
@@ -27,41 +73,30 @@ function RemoveInfo() {
setProductName('');
setProductSellingPoint('');
setProductImage('');
setDetailImage('');
};
const handleProductImageChange: any = (info: any) => {
if (info.fileList.length > 0) {
const file = info.fileList[0];
if (file.originFileObj) {
const reader = new FileReader();
reader.onload = (e) => {
setProductImage(e.target?.result as string);
};
reader.readAsDataURL(file.originFileObj);
}
} else {
if (info.fileList.length === 0) {
setProductImage('');
}
};
const handleDetailImageChange: any = (info: any) => {
if (info.fileList.length > 0) {
const file = info.fileList[0];
if (file.originFileObj) {
const reader = new FileReader();
reader.onload = (e) => {
setDetailImage(e.target?.result as string);
};
reader.readAsDataURL(file.originFileObj);
}
} else {
setDetailImage('');
const beforeUploadProductImage = async (file: File) => {
try {
const uploadResult = await uploadImage(file);
setProductImage(uploadResult.url);
message.success('图片上传成功');
} catch {
message.error('图片上传失败,请重试');
}
return false;
};
const handleManualGenerate = () => {
// 必填校验
const handleManualGenerate = async () => {
if (!currentSegment) {
message.warning('请先选择拆镜片段');
return;
}
if (!productImage) {
message.warning('请上传产品图');
return;
@@ -72,347 +107,408 @@ function RemoveInfo() {
}
if (!productSellingPoint.trim()) {
message.warning('请输入产品卖点');
return;
return;
}
// 输出内容
console.log('手动生成 - 片段', currentSegment);
console.log('产品图:', productImage);
console.log('细节图:', detailImage);
console.log('产品名称:', productName);
console.log('产品卖点:', productSellingPoint);
setLoading(true);
try {
const params = {
target_project_name: productName.trim(),
core_content_point: productSellingPoint.trim(),
material_image_url: buildAssetUrl(productImage),
idempotency_key: `replication_${Date.now()}`,
};
message.success(`手动生成成功!片段: ${currentSegment}`);
await removeCreate(currentSegment, params);
message.success('视频生成任务创建成功');
handleCloseDrawer();
await fetchSegments();
} catch (err: any) {
message.error(err?.message || '创建失败,请重试');
} finally {
setLoading(false);
}
};
const mockData = {
productName: '返回',
uploadTime: '2026-06-09 09:01:16',
sellingPoints: ['一键匹配', '连麦聊天'],
audience: '123123',
audienceAnalysis: '123123',
videoUrl: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=320&h=180&fit=crop',
segments: [
{
key: '1',
id: 1,
timeRange: '00:00 - 00:03',
thumbnail: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=120&h=80&fit=crop',
content: '11111',
lines: 'qqqqqqqqqqq',
contentStrategy: '展示礼盒'
},
{
key: '2',
id: 2,
timeRange: '00:03 - 00:06',
thumbnail: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=120&h=80&fit=crop',
content: '1231231231',
lines: 'qqqqqqqqqqq',
contentStrategy: '开箱展示'
},
{
key: '3',
id: 3,
timeRange: '00:06 - 00:09',
thumbnail: 'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=120&h=80&fit=crop',
content: '123123',
lines: 'qqqqqqqqqqq',
contentStrategy: '取出产品'
},
]
const handleAutoGenerate = async () => {
if (!creatID) return;
setAutoSplitLoading(true);
try {
await createRemoveLens(creatID, {
selected_indices: [],
replace_existing: false,
});
message.success('AI 拆镜任务已提交');
await refreshPageData();
} catch (error: any) {
message.error(error?.message || '拆镜失败');
} finally {
setAutoSplitLoading(false);
}
};
const handleOpenTrimModal = () => {
if (!videoUrl) {
message.warning('原视频地址不存在');
return;
}
setTrimModalVisible(true);
};
const handleCustomSplit = async (range: { startSecond: number; endSecond: number; durationSecond: number }) => {
if (!creatID) return;
if (range.durationSecond < MIN_TRIM_SECONDS) {
message.warning(`拆镜片段不能低于 ${MIN_TRIM_SECONDS}`);
return;
}
if (range.durationSecond > MAX_TRIM_SECONDS) {
message.warning(`拆镜片段不能超过 ${MAX_TRIM_SECONDS}`);
return;
}
setSplitLoading(true);
try {
await splitCustom(creatID, {
start_second: range.startSecond,
end_second: range.endSecond,
});
message.success('手动拆镜任务已提交');
setTrimModalVisible(false);
await refreshPageData();
} catch (error: any) {
message.error(error?.message || '手动拆镜失败');
} finally {
setSplitLoading(false);
}
};
const canCreateReplication = (record: any) => {
return record?.splitStatus === 'completed' && !!record?.segmentVideoUrl;
};
const columns = [
{
title: '片段',
width: 100,
render: (text: any, record: any) => (
align: 'center' as const,
render: (_: any, record: any) => (
<div>
<div style={{ fontSize: 16, fontWeight: 600, color: '#333' }}>{record.id}</div>
<div style={{ fontSize: 12, color: '#999' }}>{record.timeRange}</div>
<div style={{ fontSize: 16, fontWeight: 600, color: '#333' }}>{record.segmentName || `片段${record.segmentIndex || ''}`}</div>
<div style={{ fontSize: 12, color: '#999' }}>{record.timeNode}</div>
{record.sourceMode === 'custom' && <Tag color="blue" style={{ marginTop: 6 }}></Tag>}
</div>
)
),
},
{
title: '片段视频',
width: 120,
render: (text: any, record: any) => (
<div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden' }}>
<img
src={record.thumbnail}
alt={`片段${record.id}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 28,
height: 28,
background: 'rgba(0,0,0,0.6)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<PlayCircleOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
width: 150,
align: 'center' as const,
render: (_: any, record: any) => (
<div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden', background: '#f1f5f9' }}>
{record.segmentVideoUrl ? (
<video
controls
src={buildAssetUrl(record.segmentVideoUrl)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 12 }}>
{record.splitStatus === 'failed' ? '切割失败' : '切割中'}
</div>
)}
</div>
)
),
},
{
title: '画面内容',
title: '视频内容',
width: 250,
render: (text: any, record: any) => (
<div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>
{record.content}
</div>
)
},
{
title: '台词',
width: 250,
render: (text: any, record: any) => (
<div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>
{record.lines}
</div>
)
},
{
title: '内容策略',
width: 120,
align: 'left' as const,
render: (text: any, record: any) => (
<div style={{ fontSize: 14, color: '#333', fontWeight: 500 }}>
{record.contentStrategy || '-'}
render: (_: any, record: any) => (
<div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>
{record.segmentContent || record.lastError || '-'}
</div>
)
),
},
{
title: '视频类型',
width: 120,
align: 'center' as const,
render: (_: any, record: any) => (
<div style={{ fontSize: 14, color: '#333', fontWeight: 500 }}>
{record.segmentCategory || '-'}
</div>
),
},
{
title: '状态',
width: 120,
align: 'center' as const,
render: (_: any, record: any) => {
const statusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待切割', color: 'default' },
processing: { text: '切割中', color: 'processing' },
retry_waiting: { text: '等待重试', color: 'warning' },
completed: { text: '已完成', color: 'success' },
failed: { text: '失败', color: 'error' },
};
const item = statusMap[record.splitStatus] || { text: record.splitStatus || '-', color: 'default' };
return <Tag color={item.color}>{item.text}</Tag>;
},
},
{
title: '素材',
width: 140,
align: 'center' as const,
render: (text: any, record: any) => (
render: (_: any, record: any) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<div style={{ fontSize: 12, color: '#999', padding: '12px 24px', border: '1px dashed #ddd', borderRadius: 4 }}>
</div>
<Button
type="text"
onClick={() => handleGenerate(record.id)}
style={{ color: '#656efa', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
>
</Button>
{record.moduleProjectId ? (
<Button
type="text"
onClick={() => navigate(`/removelens/${record.moduleProjectId}/removefenbu`)}
style={{ color: '#656efa', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
>
</Button>
) : (
<Button
type="text"
onClick={() => handleGenerate(String(record.id))}
disabled={!canCreateReplication(record)}
style={{ color: canCreateReplication(record) ? '#656efa' : '#94a3b8', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
>
{canCreateReplication(record) ? '视频生成' : '待切割完成'}
</Button>
)}
</div>
)
}
),
},
];
return (
<React.Fragment>
<div style={{ minHeight: '94vh', background: '#f5f5f5' }}>
<div style={{ background: '#fff', padding: '16px 24px 0 24px', boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
style={{ fontSize: 16, color: '#666' }}
/>
<span style={{ fontSize: 18, fontWeight: 600 }}>{mockData.productName}</span>
<>
<div
style={{
minHeight: 'calc(100vh - 90px)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div style={{ background: '#fff' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate(-1)}
style={{ fontSize: 16, color: '#666' }}
/>
</div>
</div>
</div>
<div style={{ }}>
<div style={{ background: '#fff', borderRadius: 12, padding: '10px 20px 20px 20px', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 30, marginBottom: 12 }}>
<div style={{ position: 'relative', width: 280, height: 160, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}>
<img
src={mockData.videoUrl}
alt="视频缩略图"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
{taskDetail ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden' }}>
<div style={{ flex: 0.4, background: '#fff', borderRadius: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 30, marginBottom: 12 }}>
<div style={{ position: 'relative', width: 280, height: 160, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}>
<video
controls
src={videoUrl}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h2 style={{ fontSize: 20, fontWeight: 600, marginBottom: 16 }}></h2>
<div style={{ textAlign: 'right' }}>
<span style={{ color: '#999', fontSize: 12 }}>: {taskDetail.createdAt}</span>
</div>
</div>
<div style={{ display: 'flex', marginBottom: 12 }}>
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>:</span>
<span style={{ color: '#333', fontWeight: 500 }}>{taskDetail.title}</span>
</div>
<div style={{ display: 'flex', marginBottom: 12 }}>
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>:</span>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{(taskDetail.originalVideoAudience?.split('、') || []).map((point: string, index: number) => (
<Tag key={index} color="purple" style={{ fontSize: 12 }}>
{point}
</Tag>
))}
</div>
</div>
<div style={{ display: 'flex' }}>
<span style={{ color: '#999', fontSize: 14, marginRight: 12, width: 80, flexShrink: 0 }}>:</span>
<span style={{ color: '#333' }}>{taskDetail.originalVideoContent}</span>
</div>
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 16, marginBottom: 20, marginTop: 20 }}>
<Button
type="default"
onClick={handleOpenTrimModal}
disabled={!videoUrl || splitLoading}
style={{
flex: 1,
height: 48,
borderRadius: 8,
borderColor: '#6366f1',
color: '#6366f1',
fontWeight: 500,
}}
>
</Button>
<Button
type="primary"
onClick={handleAutoGenerate}
loading={autoSplitLoading}
disabled={autoSplitLoading}
style={{
flex: 1,
height: 48,
borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
fontWeight: 500,
border: 'none',
}}
>
</Button>
</div>
<div style={{ flex: 0.6, background: '#fff', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Table
columns={columns}
dataSource={tableData}
pagination={false}
bordered={false}
rowKey="id"
scroll={{ y: '1005' }}
/>
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 48,
</div>
</div>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: 16, color: '#999' }}>...</span>
</div>
)}
</div>
<VideoTrimPicker
open={trimModalVisible}
videoUrl={videoUrl}
title="手动视频切片"
loading={splitLoading}
minDuration={MIN_TRIM_SECONDS}
maxDuration={MAX_TRIM_SECONDS}
onCancel={() => setTrimModalVisible(false)}
onConfirm={handleCustomSplit}
/>
<Drawer
title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<span>
<span style={{ color: '#6366f1' }}></span>
<span style={{ color: '#6366f1' }}>-{currentSegment}</span>
</span>
<Button
type="text"
icon={<XOutlined />}
onClick={handleCloseDrawer}
style={{ padding: 0 }}
/>
</div>
}
placement="right"
closable={false}
onClose={handleCloseDrawer}
open={drawerVisible}
size={480}
styles={{
body: { padding: '24px' },
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<label style={{ fontWeight: 400, color: '#333', marginBottom: 12, display: 'block' }}>
<span style={{ color: '#ff4d4f' }}>*</span>
</label>
<div style={{ display: 'flex', gap: 16 }}>
<Upload
listType="picture-card"
onChange={handleProductImageChange}
beforeUpload={beforeUploadProductImage}
maxCount={1}
accept="image/*"
style={{ width: 140, height: 140 }}
>
{!productImage && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<PlusOutlined style={{ fontSize: 24, color: '#999' }} />
<span style={{ fontSize: 12, color: '#999' }}> *</span>
</div>
)}
</Upload>
</div>
</div>
<div>
<label style={{ fontWeight: 500, color: '#333', marginBottom: 12, display: 'block' }}>
</label>
<Input
value={productName}
onChange={(e) => setProductName(e.target.value)}
placeholder="请输入产品名称"
style={{ height: 48, borderRadius: 8 }}
maxLength={10}
showCount
/>
</div>
<div>
<label style={{ fontWeight: 500, color: '#333', marginBottom: 12, display: 'block' }}>
</label>
<TextArea
value={productSellingPoint}
onChange={(e) => setProductSellingPoint(e.target.value)}
placeholder="请输入产品卖点"
style={{ borderRadius: 8 }}
maxLength={100}
showCount
rows={3}
/>
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 24 }}>
<Button
type="default"
onClick={handleManualGenerate}
loading={loading}
disabled={loading}
style={{
flex: 1,
height: 48,
background: 'rgba(0,0,0,0.6)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<PlayCircleOutlined style={{ fontSize: 28, color: '#fff' }} />
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h2 style={{ fontSize: 20, fontWeight: 600, marginBottom: 16 }}></h2>
<div style={{ textAlign: 'right' }}>
<span style={{ color: '#999', fontSize: 12 }}>: {mockData.uploadTime}</span>
</div>
</div>
<div style={{ display: 'flex', marginBottom: 12 }}>
<span style={{ color: '#999', fontSize: 14, marginRight: 12 }}>:</span>
<span style={{ color: '#333', fontWeight: 500 }}>{mockData.productName}</span>
</div>
<div style={{ display: 'flex', marginBottom: 12 }}>
<span style={{ color: '#999', fontSize: 14, marginRight: 12 }}>:</span>
<div style={{ display: 'flex', gap: 8 }}>
{mockData.sellingPoints.map((point, index) => (
<Tag key={index} color="purple" style={{ fontSize: 12 }}>
{point}
</Tag>
))}
</div>
</div>
<div style={{ display: 'flex', marginBottom: 12 }}>
<span style={{ color: '#999', fontSize: 14, marginRight: 12 }}>:</span>
<span style={{ color: '#333' }}>{mockData.audience}</span>
</div>
<div style={{ display: 'flex' }}>
<span style={{ color: '#999', fontSize: 14, marginRight: 12 }}>:</span>
<span style={{ color: '#333' }}>{mockData.audienceAnalysis}</span>
</div>
</div>
</div>
borderRadius: 8,
borderColor: '#6366f1',
color: '#6366f1',
fontWeight: 500,
}}
>
{loading ? '生成中...' : '手动生成'}
</Button>
</div>
</div>
<div style={{ background: '#fff', borderRadius: 12, overflow: 'hidden' }}>
<Table
columns={columns}
dataSource={mockData.segments}
pagination={false}
bordered={false}
rowKey="key"
scroll={{ y: 520 }}
/>
</div>
</div>
</div>
<Drawer
title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<span>
<span style={{ color: '#6366f1' }}></span>
<span style={{ color: '#6366f1' }}>-{currentSegment}</span>
</span>
<Button
type="text"
icon={<XOutlined />}
onClick={handleCloseDrawer}
style={{ padding: 0 }}
/>
</div>
}
placement="right"
closable={false}
onClose={handleCloseDrawer}
open={drawerVisible}
width={480}
bodyStyle={{ padding: '24px' }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<label style={{ fontWeight: 400, color: '#333', marginBottom: 12, display: 'block' }}>
<span style={{ color: '#ff4d4f' }}>*</span>
</label>
<div style={{ display: 'flex', gap: 16 }}>
<Upload
listType="picture-card"
onChange={handleProductImageChange}
maxCount={1}
accept="image/*"
style={{ width: 140, height: 140 }}
>
{!productImage && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<PlusOutlined style={{ fontSize: 24, color: '#999' }} />
<span style={{ fontSize: 12, color: '#999' }}> *</span>
</div>
)}
</Upload>
<Upload
listType="picture-card"
onChange={handleDetailImageChange}
maxCount={1}
accept="image/*"
style={{ width: 140, height: 140 }}
>
{!detailImage && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<PlusOutlined style={{ fontSize: 24, color: '#999' }} />
<span style={{ fontSize: 12, color: '#999' }}></span>
</div>
)}
</Upload>
</div>
</div>
<div>
<label style={{ fontWeight: 500, color: '#333', marginBottom: 12, display: 'block' }}>
</label>
<Input
value={productName}
onChange={(e) => setProductName(e.target.value)}
placeholder="请输入产品名称"
style={{ height: 48, borderRadius: 8 }}
maxLength={10}
showCount
/>
</div>
<div>
<label style={{ fontWeight: 500, color: '#333', marginBottom: 12, display: 'block' }}>
</label>
<TextArea
value={productSellingPoint}
onChange={(e) => setProductSellingPoint(e.target.value)}
placeholder="请输入产品卖点"
style={{ borderRadius: 8 }}
maxLength={100}
showCount
rows={3}
/>
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 24 }}>
<Button
type="default"
onClick={handleManualGenerate}
style={{
flex: 1,
height: 48,
borderRadius: 8,
borderColor: '#6366f1',
color: '#6366f1',
fontWeight: 500
}}
>
</Button>
</div>
</div>
</Drawer>
</React.Fragment>
</Drawer>
</>
);
}
+119 -57
View File
@@ -1,7 +1,8 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Button, Modal, Input, Table, Upload, Popconfirm } from 'antd';
import { useState, useRef, useCallback } from 'react';
import { Button, Modal, Input, Table, Upload, Popconfirm, message } from 'antd';
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { uploadVideo, createShotReplication, getShotReplicationList } from '../api';
export default function VideoFrameExtractor() {
const navigate = useNavigate();
@@ -10,8 +11,12 @@ export default function VideoFrameExtractor() {
const [error, setError] = useState<string>('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [productName, setProductName] = useState<string>('');
const [videoDuration, setVideoDuration] = useState<number>(0);
const [loading, setLoading] = useState(false);
const [tableData, setTableData] = useState<any[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -26,7 +31,7 @@ export default function VideoFrameExtractor() {
setProductName('');
}, [videoUrl]);
const handleFileChange = (file: File) => {
const handleFileChange = async (file: File) => {
if (!file.type.startsWith('video/')) {
setError('请选择视频文件');
return false;
@@ -38,10 +43,20 @@ export default function VideoFrameExtractor() {
}
cleanupResources();
setLoading(true);
try {
const uploadResult = await uploadVideo(file);
setVideoUrl(uploadResult.url);
setError('');
message.success('视频上传成功');
} catch (err) {
setError('视频上传失败,请重试');
message.error('视频上传失败');
} finally {
setLoading(false);
}
const url = URL.createObjectURL(file);
setVideoUrl(url);
setError('');
return false;
};
@@ -51,27 +66,57 @@ export default function VideoFrameExtractor() {
}
}, []);
const tableData = [
{
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 handleCreate = async () => {
if (!videoUrl || !productName.trim()) {
message.warning('请先上传视频并输入产品名称');
return;
}
setLoading(true);
try {
const params = {
video_url: videoUrl,
video_duration_seconds: videoDuration,
title: productName.trim(),
idempotency_key: `shot_${Date.now()}`,
};
await createShotReplication(params);
message.success('任务创建成功');
// 清空上传内容和输入框
cleanupResources();
navigate('/');
} catch (err) {
message.error('任务创建失败,请重试');
} finally {
setLoading(false);
}
};
const fetchList = async (page: number, size: number) => {
try {
const res = await getShotReplicationList(page, size);
setTableData(res.items || []);
setTotal(res.total || 0);
setCurrentPage(page);
setPageSize(size);
} catch (err) {
message.error('获取列表失败');
}
};
const handlePageChange = (page: number, size: number) => {
fetchList(page, size);
};
// 打开弹窗时获取列表数据
const handleOpenModal = () => {
setIsModalOpen(true);
fetchList(1, 10);
};
return (
<div style={{ minHeight: '100vh', background: 'linear-gradient(135deg, #f5f3ff 0%, #fdf2f8 100%)', padding: '20px' }}>
<div style={{ minHeight: 'calc(100vh - 90px)', overflow: 'auto', background: 'linear-gradient(135deg, #f5f3ff 0%, #fdf2f8 100%)', padding: '20px' }}>
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@@ -107,7 +152,7 @@ export default function VideoFrameExtractor() {
</div>
<button
onClick={() => setIsModalOpen(true)}
onClick={handleOpenModal}
style={{
padding: '10px 20px',
background: 'white',
@@ -254,7 +299,7 @@ export default function VideoFrameExtractor() {
}}>
<video
ref={videoRef}
src={videoUrl}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${videoUrl}`}
controls
onLoadedMetadata={handleVideoLoaded}
style={{
@@ -292,10 +337,11 @@ export default function VideoFrameExtractor() {
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button
disabled={!videoUrl || !productName.trim()}
onClick={handleCreate}
disabled={!videoUrl || !productName.trim() || loading}
style={{
padding: '14px 56px',
background: videoUrl && productName.trim()
background: videoUrl && productName.trim() && !loading
? 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)'
: '#e2e8f0',
color: 'white',
@@ -303,14 +349,14 @@ export default function VideoFrameExtractor() {
borderRadius: 14,
fontSize: 15,
fontWeight: 600,
cursor: videoUrl && productName.trim() ? 'pointer' : 'not-allowed',
boxShadow: videoUrl && productName.trim() ? '0 6px 20px rgba(99, 102, 241, 0.35)' : 'none',
cursor: videoUrl && productName.trim() && !loading ? 'pointer' : 'not-allowed',
boxShadow: videoUrl && productName.trim() && !loading ? '0 6px 20px rgba(99, 102, 241, 0.35)' : 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 10
}}
>
<span></span>
<span>{loading ? '创建中...' : '创建'}</span>
<span style={{
fontSize: 12,
opacity: 0.85,
@@ -345,28 +391,23 @@ export default function VideoFrameExtractor() {
<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: 'image',
// key: 'image',
// width: 90,
// render: (image: string) => (
// <img
// src={image}
// alt="产品图片"
// style={{ width: 50, height: 50, objectFit: 'cover', borderRadius: 6 }}
// />
// ),
// },
{
title: '产品名称',
dataIndex: 'productName',
key: 'productName',
dataIndex: 'title',
key: 'title',
},
{
title: '状态',
@@ -375,13 +416,13 @@ export default function VideoFrameExtractor() {
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
dataIndex: 'createdAt',
key: 'createdAt',
},
{
title: '操作',
key: 'action',
render: (_, record) => (
render: (record) => (
<button
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
style={{ color: '#6366f1', textDecoration: 'none', fontSize: 13, border: 'none', background: 'none', cursor: 'pointer' }}
@@ -393,7 +434,28 @@ export default function VideoFrameExtractor() {
]}
dataSource={tableData}
rowKey="id"
pagination={false}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
showSizeChanger: false,
showQuickJumper: false,
showTotal: (total) => `${total}`,
placement: ['bottomCenter'],
itemRender: (_, type, originalElement) => {
if (type === 'page') {
return <span style={{ borderRadius: 4, margin: '0 4px' }}>{originalElement}</span>;
}
if (type === 'prev') {
return <span style={{ borderRadius: 4, margin: '0 4px' }}></span>;
}
if (type === 'next') {
return <span style={{ borderRadius: 4, margin: '0 4px' }}></span>;
}
return originalElement;
},
}}
/>
</Modal>
</div>
File diff suppressed because it is too large Load Diff