合并代码解决冲突

This commit is contained in:
Lrd
2026-06-18 09:11:43 +08:00
60 changed files with 30905 additions and 3711 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
VITE_USE_MOCK=false VITE_USE_MOCK=false
# Encryption disabled for dev — enable in production # Encryption disabled for dev — enable in production
VITE_ENCRYPTION_KEY= VITE_ENCRYPTION_KEY=
+1
View File
@@ -47,6 +47,7 @@ lerna-debug.log*
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.production
# 如果你有示例环境变量文件,可以提交 # 如果你有示例环境变量文件,可以提交
!.env.example !.env.example
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-DFBUW3T1.js"></script> <script type="module" crossorigin src="/assets/index-ApLUbX7J.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css"> <link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head> </head>
<body> <body>
+10
View File
@@ -22,6 +22,11 @@ import AdminOperationLogs from './pages/AdminOperationLogs';
import AdminOauthAppList from './pages/AdminOauthAppList'; import AdminOauthAppList from './pages/AdminOauthAppList';
import AdminGenerationRecords from './pages/AdminGenerationRecords'; import AdminGenerationRecords from './pages/AdminGenerationRecords';
import AdminGenerationAiRecords from './pages/AdminGenerationAiRecords'; import AdminGenerationAiRecords from './pages/AdminGenerationAiRecords';
import AdminHotOpeningReplications from './pages/AdminHotOpeningReplications';
import AdminHotOpeningReplicationDetail from './pages/AdminHotOpeningReplicationDetail';
import AdminShotReplications from './pages/AdminShotReplications';
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
import { useAdminStore } from './store'; import { useAdminStore } from './store';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
@@ -84,6 +89,11 @@ const App = () => {
<Route path="operation-logs" element={<AdminOperationLogs />} /> <Route path="operation-logs" element={<AdminOperationLogs />} />
<Route path="generation-records" element={<AdminGenerationRecords />} /> <Route path="generation-records" element={<AdminGenerationRecords />} />
<Route path="generation-ai" element={<AdminGenerationAiRecords />} /> <Route path="generation-ai" element={<AdminGenerationAiRecords />} />
<Route path="hot-opening-replications" element={<AdminHotOpeningReplications />} />
<Route path="hot-opening-replications/:projectId" element={<AdminHotOpeningReplicationDetail />} />
<Route path="shot-replications" element={<AdminShotReplications />} />
<Route path="shot-replications/task-sets/:taskSetId" element={<AdminShotTaskSetDetail />} />
<Route path="shot-replications/projects/:projectId" element={<AdminReplicationProjectDetail moduleType="shot_replicate" />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
@@ -1,78 +0,0 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider, App as AntApp, Spin } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import AdminLayout from './pages/AdminLayout';
import AdminLoginPage from './pages/AdminLoginPage';
import AdminDashboard from './pages/AdminDashboard';
import AdminUsers from './pages/AdminUsers';
import AdminModels from './pages/AdminModels';
import AdminSettings from './pages/AdminSettings';
import AdminNotificationManager from './pages/AdminNotificationManager';
import AdminCreditRecords from './pages/AdminCreditRecords';
import AdminPaymentConfig from './pages/AdminPaymentConfig';
import AdminIndustries from './pages/AdminIndustries';
import AdminVideoEngines from './pages/AdminVideoEngines';
import AdminImageEngines from './pages/AdminImageEngines';
import AdminCreditRatios from './pages/AdminCreditRatios';
import AdminMenuConfig from './pages/AdminMenuConfig';
import AdminRechargePackages from './pages/AdminRechargePackages';
import AdminOperationLogs from './pages/AdminOperationLogs';
import AdminGenerationRecords from './pages/AdminGenerationRecords';
import { useAdminStore } from './store';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading } = useAdminStore();
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
};
const App = () => {
const { checkAuth } = useAdminStore();
useEffect(() => { checkAuth(); }, []);
return (
<ConfigProvider locale={zhCN} theme={{
token: { colorPrimary: '#6366f1', borderRadius: 8 },
components: {
Button: { controlHeight: 36, controlHeightLG: 44 },
Card: { boxShadow: '0 1px 3px rgba(0,0,0,0.04)' },
Table: { headerBg: '#fafbfc' },
},
}}>
<AntApp>
<BrowserRouter>
<Routes>
<Route path="/login" element={<AdminLoginPage />} />
<Route path="/" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
<Route index element={<AdminDashboard />} />
<Route path="users" element={<AdminUsers />} />
<Route path="credit-records" element={<AdminCreditRecords />} />
<Route path="models" element={<AdminModels />} />
<Route path="credit-ratios" element={<AdminCreditRatios />} />
<Route path="video-engines" element={<AdminVideoEngines />} />
<Route path="industries" element={<AdminIndustries />} />
<Route path="menu-configs" element={<AdminMenuConfig />} />
<Route path="recharge-packages" element={<AdminRechargePackages />} />
<Route path="payment" element={<AdminPaymentConfig />} />
<Route path="settings" element={<AdminSettings />} />
<Route path="notifications" element={<AdminNotificationManager />} />
<Route path="operation-logs" element={<AdminOperationLogs />} />
<Route path="generation-records" element={<AdminGenerationRecords />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AntApp>
</ConfigProvider>
);
};
export default App;
+69
View File
@@ -7,6 +7,9 @@ import type {
User, CreditRecord, Project, GenerationRecord, GenerationParams, User, CreditRecord, Project, GenerationRecord, GenerationParams,
Industry, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification, Industry, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
GenerationAiEnginesResponse, GenerationAITaskListOut, GenerationAITaskQueryParams, GenerationAiEnginesResponse, GenerationAITaskListOut, GenerationAITaskQueryParams,
AdminHotOpeningTaskQueryParams, HotOpeningTaskListOut, ReplicationProjectDetailOut,
AdminShotTaskSetQueryParams, ShotTaskSetListOut, ShotTaskSetDetailOut,
AdminShotSegmentQueryParams, ShotSegmentListOut, ShotSegmentDetailOut,
} from '../types'; } from '../types';
// ── Auth ────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────
@@ -422,3 +425,69 @@ export async function getAdminGenerationAiTasks(params?: GenerationAITaskQueryPa
return api.get<GenerationAITaskListOut>(`/generation-ai/tasks${qs ? `?${qs}` : ''}`); return api.get<GenerationAITaskListOut>(`/generation-ai/tasks${qs ? `?${qs}` : ''}`);
} }
// ── Replication Modules (Admin readonly pages reuse original APIs) ───────────
function appendParam(params: URLSearchParams, key: string, value?: string | number | null): void {
if (value === undefined || value === null || String(value).trim() === '') return;
params.set(key, String(value));
}
export async function getAdminHotOpeningTasks(params?: AdminHotOpeningTaskQueryParams): Promise<HotOpeningTaskListOut> {
const q = new URLSearchParams();
appendParam(q, 'status', params?.status);
appendParam(q, 'keyword', params?.keyword);
appendParam(q, 'user_id', params?.userId);
appendParam(q, 'user_name', params?.userName);
appendParam(q, 'created_start', params?.createdStart);
appendParam(q, 'created_end', params?.createdEnd);
appendParam(q, 'page', params?.page);
appendParam(q, 'page_size', params?.pageSize);
const qs = q.toString();
return api.get<HotOpeningTaskListOut>(`/hot-opening-replications/tasks${qs ? `?${qs}` : ''}`);
}
export async function getAdminHotOpeningTaskDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
return api.get<ReplicationProjectDetailOut>(`/hot-opening-replications/tasks/${projectId}`);
}
export async function getAdminShotTaskSets(params?: AdminShotTaskSetQueryParams): Promise<ShotTaskSetListOut> {
const q = new URLSearchParams();
appendParam(q, 'status', params?.status);
appendParam(q, 'analysis_status', params?.analysisStatus);
appendParam(q, 'split_status', params?.splitStatus);
appendParam(q, 'keyword', params?.keyword);
appendParam(q, 'user_id', params?.userId);
appendParam(q, 'user_name', params?.userName);
appendParam(q, 'created_start', params?.createdStart);
appendParam(q, 'created_end', params?.createdEnd);
appendParam(q, 'page', params?.page);
appendParam(q, 'page_size', params?.pageSize);
const qs = q.toString();
return api.get<ShotTaskSetListOut>(`/shot-replications/task-sets${qs ? `?${qs}` : ''}`);
}
export async function getAdminShotTaskSetDetail(taskSetId: string): Promise<ShotTaskSetDetailOut> {
return api.get<ShotTaskSetDetailOut>(`/shot-replications/task-sets/${taskSetId}`);
}
export async function getAdminShotSegments(taskSetId: string, params?: AdminShotSegmentQueryParams): Promise<ShotSegmentListOut> {
const q = new URLSearchParams();
appendParam(q, 'source_mode', params?.sourceMode);
appendParam(q, 'split_status', params?.splitStatus);
appendParam(q, 'analysis_status', params?.analysisStatus);
appendParam(q, 'replicate_status', params?.replicateStatus);
appendParam(q, 'page', params?.page);
appendParam(q, 'page_size', params?.pageSize);
const qs = q.toString();
return api.get<ShotSegmentListOut>(`/shot-replications/task-sets/${taskSetId}/segments${qs ? `?${qs}` : ''}`);
}
export async function getAdminShotSegmentDetail(segmentId: string): Promise<ShotSegmentDetailOut> {
return api.get<ShotSegmentDetailOut>(`/shot-replications/segments/${segmentId}`);
}
export async function getAdminShotProjectDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
return api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
}
@@ -0,0 +1,8 @@
import React from 'react';
import AdminReplicationProjectDetail from './AdminReplicationProjectDetail';
const AdminHotOpeningReplicationDetail: React.FC = () => (
<AdminReplicationProjectDetail moduleType="hot_opening_replicate" />
);
export default AdminHotOpeningReplicationDetail;
@@ -0,0 +1,233 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getAdminHotOpeningTasks } from '../api';
import type { HotOpeningTaskListItemOut } from '../types';
import { formatDate } from '../utils/formatDate';
const PAGE_SIZE = 20;
const STATUS_OPTIONS = [
{ value: 'pending', label: '已创建' },
{ value: 'waiting_user', label: '等待用户操作' },
{ value: 'processing', label: '处理中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
];
const STATUS_MAP: Record<string, { color: string; text: string }> = {
pending: { color: 'default', text: '已创建' },
waiting_user: { color: 'processing', text: '等待用户操作' },
processing: { color: 'warning', text: '处理中' },
completed: { color: 'success', text: '已完成' },
failed: { color: 'error', text: '失败' },
cancelled: { color: 'default', text: '已取消' },
};
const STEP_MAP: Record<string, string> = {
material_input: '素材输入',
image_prompt_optimize: '图片 AI 提词',
image_generate: '图片生成',
video_prompt_optimize: '视频 AI 提词',
video_generate: '视频生成',
};
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
if (!status) return <Tag>-</Tag>;
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
return <Tag color={meta.color}>{meta.text}</Tag>;
};
const AdminHotOpeningReplications: React.FC = () => {
const navigate = useNavigate();
const [items, setItems] = useState<HotOpeningTaskListItemOut[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>('');
const [inputKeyword, setInputKeyword] = useState('');
const [inputUserId, setInputUserId] = useState('');
const [inputUserName, setInputUserName] = useState('');
const [createdRange, setCreatedRange] = useState<any>(null);
const [queryKeyword, setQueryKeyword] = useState('');
const [queryUserId, setQueryUserId] = useState('');
const [queryUserName, setQueryUserName] = useState('');
const [queryCreatedRange, setQueryCreatedRange] = useState<any>(null);
const [reloadKey, setReloadKey] = useState(0);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getAdminHotOpeningTasks({
status: status || undefined,
keyword: queryKeyword || undefined,
userId: queryUserId || undefined,
userName: queryUserName || undefined,
createdStart: queryCreatedRange?.[0]?.toISOString?.(),
createdEnd: queryCreatedRange?.[1]?.toISOString?.(),
page,
pageSize: PAGE_SIZE,
});
setItems(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载爆款开头复刻任务失败');
} finally {
setLoading(false);
}
}, [page, queryCreatedRange, queryKeyword, queryUserId, queryUserName, status]);
useEffect(() => {
load();
}, [load, reloadKey]);
const doSearch = () => {
setQueryKeyword(inputKeyword.trim());
setQueryUserId(inputUserId.trim());
setQueryUserName(inputUserName.trim());
setQueryCreatedRange(createdRange);
setPage(1);
};
const resetSearch = () => {
setStatus('');
setInputKeyword('');
setInputUserId('');
setInputUserName('');
setCreatedRange(null);
setQueryKeyword('');
setQueryUserId('');
setQueryUserName('');
setQueryCreatedRange(null);
setPage(1);
setReloadKey(v => v + 1);
};
return (
<div style={{ padding: 24 }}>
<Card>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space>
<Space wrap>
<Select
allowClear
placeholder="任务状态"
style={{ width: 160 }}
value={status || undefined}
onChange={value => { setStatus(value || ''); setPage(1); }}
options={STATUS_OPTIONS}
/>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="关键词:标题/项目名/核心点"
style={{ width: 260 }}
value={inputKeyword}
onChange={e => setInputKeyword(e.target.value)}
onPressEnter={doSearch}
/>
<Input
allowClear
placeholder="用户ID(管理员)"
style={{ width: 190 }}
value={inputUserId}
onChange={e => setInputUserId(e.target.value)}
onPressEnter={doSearch}
/>
<Input
allowClear
placeholder="用户名(管理员)"
style={{ width: 230 }}
value={inputUserName}
onChange={e => setInputUserName(e.target.value)}
onPressEnter={doSearch}
/>
<DatePicker.RangePicker
showTime
value={createdRange}
onChange={setCreatedRange}
placeholder={['创建开始', '创建结束']}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={doSearch}></Button>
<Button onClick={resetSearch}></Button>
</Space>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
showSizeChanger: false,
showTotal: value => `${value}`,
onChange: setPage,
}}
scroll={{ x: 1380 }}
columns={[
{
title: '项目ID',
dataIndex: 'id',
width: 150,
render: (value: string) => <Tooltip title={value}>{shortId(value)}</Tooltip>,
},
{
title: '用户',
width: 180,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Typography.Text>{record.userName || '-'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{shortId(record.userId)}</Typography.Text>
</Space>
),
},
{
title: '项目信息',
width: 260,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Typography.Text strong>{record.title || record.targetProjectName || '-'}</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ maxWidth: 240 }}>{record.sourceProjectName || '-'}</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ maxWidth: 240 }}>{record.coreContentPoint || '-'}</Typography.Text>
</Space>
),
},
{ title: '状态', dataIndex: 'status', width: 130, render: (v: string) => <StatusTag status={v} /> },
{ title: '当前步骤', dataIndex: 'currentStepCode', width: 140, render: (v: string) => STEP_MAP[v] || v || '-' },
{ title: '图片结果', dataIndex: 'finalImageUrl', width: 90, render: (v: string) => v ? <Tag color="success"></Tag> : <Tag></Tag> },
{ title: '视频结果', dataIndex: 'finalVideoUrl', width: 90, render: (v: string) => v ? <Tag color="success"></Tag> : <Tag></Tag> },
{ title: '错误信息', dataIndex: 'errorMessage', width: 220, ellipsis: true, render: (v: string) => v || '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: safeDate },
{
title: '操作',
fixed: 'right',
width: 110,
render: (_, record) => (
<Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/hot-opening-replications/${record.id}`)}></Button>
),
},
]}
/>
</Space>
</Card>
</div>
);
};
export default AdminHotOpeningReplications;
@@ -1,250 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getImageEngines, saveImageEngine, deleteImageEngine } from '../api';
interface ImageEngine {
id: string;
name: string;
provider: string;
apiBase: string;
apiKey: string;
modelName: string;
supportedSizes: string[];
supportedStyles: string[];
generateUrl: string;
isActive: boolean;
priority: number;
}
function parseJsonArray(val: unknown): any[] {
if (Array.isArray(val)) return val;
if (typeof val === 'string') {
try { return JSON.parse(val); } catch { return []; }
}
return [];
}
const AdminImageEngines: React.FC = () => {
const [engines, setEngines] = useState<ImageEngine[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getImageEngines();
setEngines(data.map((e: any) => ({
...e,
supportedSizes: parseJsonArray(e.supportedSizes),
supportedStyles: parseJsonArray(e.supportedStyles),
})));
} catch {
message.error('加载图片引擎失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
const payload = {
name: values.name,
provider: values.provider,
api_base: values.apiBase,
api_key: values.apiKey,
model_name: values.modelName,
supported_sizes: JSON.stringify(values.supportedSizes || []),
supported_styles: JSON.stringify(values.supportedStyles || []),
generate_url: values.generateUrl || '',
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
};
if (modal.engine) {
await saveImageEngine({ id: modal.engine.id, ...payload });
message.success('已更新');
} else {
await saveImageEngine(payload);
message.success('已添加');
}
setModal({ open: false, engine: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
}
};
const handleDelete = async (id: string) => {
try {
await deleteImageEngine(id);
message.success('已删除');
load();
} catch {
message.error('删除失败');
}
};
const openEdit = (engine?: ImageEngine) => {
setModal({ open: true, engine: engine || null });
if (engine) {
form.setFieldsValue(engine);
} else {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
supportedSizes: ['1024x1024', '1024x1792', '1792x1024'],
supportedStyles: ['realistic', 'anime', 'oil_painting'],
});
}
};
const columns = [
{
title: '引擎名称', key: 'name', width: 180,
render: (_: any, r: ImageEngine) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: r.isActive
? 'linear-gradient(135deg, #10b981, #059669)'
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 16,
}}><PictureOutlined /></div>
<div>
<Typography.Text strong>{r.name}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider}</div>
</div>
</div>
),
},
{
title: '支持尺寸', dataIndex: 'supportedSizes', width: 280,
render: (sizes: string[]) => <Space size={2} wrap>{sizes.map(s => <Tag key={s}>{s}</Tag>)}</Space>,
},
{
title: '支持风格', dataIndex: 'supportedStyles', width: 280,
render: (styles: string[]) => <Space size={2} wrap>{styles.map(s => <Tag key={s} color="blue">{s}</Tag>)}</Space>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: ImageEngine) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<PictureOutlined style={{ fontSize: 18, color: '#10b981' }} />
<Typography.Text strong style={{ fontSize: 16 }}>图片引擎配置</Typography.Text>
<Tag color="green">{engines.length} 个引擎</Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
添加引擎
</Button>
</div>
<Table
columns={columns}
dataSource={engines}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><PictureOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={620}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="name" label="引擎名称" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Input placeholder="豆包文生图" size="large" />
</Form.Item>
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'ark', label: '火山引擎 (Ark)' },
]} />
</Form.Item>
</div>
<Form.Item name="apiBase" label="API基础地址" rules={[{ required: true }]}>
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3" size="large" />
</Form.Item>
<Form.Item name="apiKey" label="API Key">
<Input.Password placeholder="sk-****" size="large" />
</Form.Item>
<Form.Item name="modelName" label="模型名称">
<Input placeholder="doubao-seedream-3-0-t2i-250415" size="large" />
</Form.Item>
<Form.Item name="generateUrl" label="生成接口地址">
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
</Form.Item>
<Form.Item name="supportedSizes" label="支持尺寸">
<Select mode="tags" size="large" placeholder="输入尺寸后回车添加" tokenSeparators={[',', '']}
options={[
{ value: '1024x1024' }, { value: '1024x1792' }, { value: '1792x1024' },
{ value: '512x512' }, { value: '768x768' },
]} />
</Form.Item>
<Form.Item name="supportedStyles" label="支持风格">
<Select mode="tags" size="large" placeholder="输入风格后回车添加" tokenSeparators={[',', '']}
options={[
{ value: 'realistic', label: '写实' },
{ value: 'anime', label: '动漫' },
{ value: 'oil_painting', label: '油画' },
{ value: 'watercolor', label: '水彩' },
{ value: 'sketch', label: '素描' },
]} />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级">
<Select size="large" options={[
{ value: 0, label: '0 (默认)' },
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
{ value: 5, label: '5' },
{ value: 10, label: '10 (最高)' },
]} />
</Form.Item>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminImageEngines;
@@ -1,339 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Checkbox, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getImageEngines, saveImageEngine, deleteImageEngine } from '../api';
interface ImageEngine {
id: string;
name: string;
provider: string;
apiBase: string;
apiKey: string;
modelName: string;
supportedModels: string[];
supportedSizes: Record<string, Record<string, string>>;
defaultSize: string;
generateUrl: string;
isActive: boolean;
priority: number;
}
function parseJsonArray(val: unknown): any[] {
if (Array.isArray(val)) return val;
if (typeof val === 'string') {
try { return JSON.parse(val); } catch { return []; }
}
return [];
}
function parseSizes(val: unknown): Record<string, Record<string, string>> {
if (val && typeof val === 'object' && !Array.isArray(val)) return val as Record<string, Record<string, string>>;
if (typeof val === 'string') {
try { const p = JSON.parse(val); return (p && typeof p === 'object' && !Array.isArray(p)) ? p : {}; } catch { return {}; }
}
return {};
}
// Default size options with pixel mappings
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
"2K": {
"1:1": "2048×2048",
"4:3": "2304×1728",
"3:4": "1728×2304",
"16:9": "2560×1440",
"9:16": "1600×2848",
"3:2": "2496×1664",
"2:3": "1664×2496",
"21:9": "3024×1296",
},
"4K": {
"1:1": "4096×4096",
"4:3": "4608×3456",
"3:4": "3520×4704",
"16:9": "5404×3040",
"9:16": "3040×5504",
"3:2": "4992×3328",
"2:3": "3328×4992",
"21:9": "6197×2656",
},
};
const ALL_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9"];
const AdminImageEngines: React.FC = () => {
const [engines, setEngines] = useState<ImageEngine[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getImageEngines();
setEngines(data.map((e: any) => ({
...e,
supportedModels: parseJsonArray(e.supportedModels),
supportedSizes: parseSizes(e.supportedSizes),
})));
} catch {
message.error('加载图片引擎失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
// Build supportedSizes from form values
const sizes: Record<string, Record<string, string>> = {};
for (const tier of ["2K", "4K"]) {
const selected: string[] = values[`size_${tier}`] || [];
if (selected.length > 0) {
sizes[tier] = {};
for (const ratio of selected) {
sizes[tier][ratio] = SIZE_OPTIONS[tier]?.[ratio] || ratio;
}
}
}
const payload = {
name: values.name,
provider: values.provider,
api_base: values.apiBase,
api_key: values.apiKey,
model_name: values.modelName,
supported_models: JSON.stringify(values.supportedModels || []),
supported_sizes: JSON.stringify(sizes),
default_size: values.defaultSize || '2K',
generate_url: values.generateUrl || '',
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
};
if (modal.engine) {
await saveImageEngine({ id: modal.engine.id, ...payload });
message.success('已更新');
} else {
await saveImageEngine(payload);
message.success('已添加');
}
setModal({ open: false, engine: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
}
};
const handleDelete = async (id: string) => {
try {
await deleteImageEngine(id);
message.success('已删除');
load();
} catch {
message.error('删除失败');
}
};
const openEdit = (engine?: ImageEngine) => {
setModal({ open: true, engine: engine || null });
if (engine) {
const sizeFields: Record<string, string[]> = {};
for (const tier of ["2K", "4K"]) {
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
}
form.setFieldsValue({
...engine,
...sizeFields,
});
} else {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
supportedModels: ['doubao-seedream-5-0-260128'],
defaultSize: '2K',
size_2K: ALL_RATIOS,
size_4K: ALL_RATIOS,
});
}
};
const columns = [
{
title: '引擎名称', key: 'name', width: 180,
render: (_: any, r: ImageEngine) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: r.isActive
? 'linear-gradient(135deg, #10b981, #059669)'
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 16,
}}><PictureOutlined /></div>
<div>
<Typography.Text strong>{r.name}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.modelName}</div>
</div>
</div>
),
},
{
title: '2K 支持比例', key: 'sizes_2k', width: 260,
render: (_: any, r: ImageEngine) => {
const ratios = Object.keys(r.supportedSizes?.["2K"] || {});
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
return <Space size={2} wrap>{ratios.map(ratio => (
<Tag key={ratio} color="blue">{ratio} {r.supportedSizes["2K"][ratio]}</Tag>
))}</Space>;
},
},
{
title: '4K 支持比例', key: 'sizes_4k', width: 260,
render: (_: any, r: ImageEngine) => {
const ratios = Object.keys(r.supportedSizes?.["4K"] || {});
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
return <Space size={2} wrap>{ratios.map(ratio => (
<Tag key={ratio} color="purple">{ratio} {r.supportedSizes["4K"][ratio]}</Tag>
))}</Space>;
},
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: ImageEngine) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<PictureOutlined style={{ fontSize: 18, color: '#10b981' }} />
<Typography.Text strong style={{ fontSize: 16 }}>图片引擎配置</Typography.Text>
<Tag color="green">{engines.length} 个引擎</Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
添加引擎
</Button>
</div>
<Table
columns={columns}
dataSource={engines}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 1000 }}
/>
</Card>
<Modal
title={<Space><PictureOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={680}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="name" label="引擎名称" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Input placeholder="豆包文生图" size="large" />
</Form.Item>
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'ark', label: '火山引擎 (Ark)' },
]} />
</Form.Item>
</div>
<Form.Item name="apiBase" label="API基础地址" rules={[{ required: true }]}>
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3" size="large" />
</Form.Item>
<Form.Item name="apiKey" label="API Key">
<Input.Password placeholder="sk-****" size="large" />
</Form.Item>
<Form.Item name="modelName" label="默认模型">
<Input placeholder="doubao-seedream-5-0-260128" size="large" />
</Form.Item>
<Form.Item name="supportedModels" label="支持模型列表">
<Select mode="tags" size="large" placeholder="输入模型ID后回车添加" tokenSeparators={[',', '']}
options={[{ value: 'doubao-seedream-5-0-260128' }]} />
</Form.Item>
{/* Size config */}
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 14 }}>尺寸配置</Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
勾选每个档位支持的比例,前台选择后传对应像素值给SDK
</Typography.Text>
</div>
{["2K", "4K"].map(tier => (
<div key={tier} style={{
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
marginBottom: 12, border: '1px solid #f0f0f5',
}}>
<Typography.Text strong style={{ fontSize: 13, color: tier === '2K' ? '#3b82f6' : '#8b5cf6' }}>
{tier}
</Typography.Text>
<Form.Item name={`size_${tier}`} style={{ marginTop: 8, marginBottom: 0 }}>
<Checkbox.Group style={{ width: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6px 0' }}>
{ALL_RATIOS.map(ratio => (
<Checkbox key={ratio} value={ratio} style={{ fontSize: 12 }}>
{ratio} <span style={{ color: '#94a3b8', fontSize: 11 }}>{SIZE_OPTIONS[tier]?.[ratio]}</span>
</Checkbox>
))}
</div>
</Checkbox.Group>
</Form.Item>
</div>
))}
<Form.Item name="defaultSize" label="默认尺寸档位">
<Select size="large" options={[
{ value: '2K', label: '2K' },
{ value: '4K', label: '4K' },
]} />
</Form.Item>
<Form.Item name="generateUrl" label="生成接口地址">
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级">
<Select size="large" options={[
{ value: 0, label: '0 (默认)' },
{ value: 1, label: '1' }, { value: 2, label: '2' }, { value: 3, label: '3' },
{ value: 5, label: '5' }, { value: 10, label: '10 (最高)' },
]} />
</Form.Item>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminImageEngines;
@@ -1,487 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Modal, Popconfirm, Space, Switch, Table, Tag, Typography, Dropdown, Select,
} from 'antd';
import {
AppstoreOutlined, PlusOutlined, EditOutlined, DeleteOutlined, DownOutlined, MinusCircleOutlined,
ShoppingCartOutlined, BookOutlined, HomeOutlined, FireOutlined, RocketOutlined, SkinOutlined,
CompassOutlined, HeartOutlined, CarOutlined, CameraOutlined, CloudOutlined, StarOutlined,
TrophyOutlined, ThunderboltOutlined, BulbOutlined, CoffeeOutlined, CrownOutlined, DashboardOutlined,
FlagOutlined, GlobalOutlined, GiftOutlined, LaptopOutlined, MobileOutlined, MonitorOutlined,
PayCircleOutlined, PictureOutlined, PlayCircleOutlined, SafetyOutlined,
ShoppingOutlined, SmileOutlined, SoundOutlined, TagOutlined, TeamOutlined,
ToolOutlined, TruckOutlined, VideoCameraOutlined, WalletOutlined, BankOutlined,
BuildOutlined, ExperimentOutlined, HighlightOutlined, IdcardOutlined,
MedicineBoxOutlined, ReadOutlined, RestOutlined, SketchOutlined, SolutionOutlined,
} from '@ant-design/icons';
import { getIndustryConfigs, saveIndustryConfig, deleteIndustryConfig } from '../api';
interface OptionGroup {
name: string;
options: string[];
}
interface IndustryItem {
id: string;
key: string;
label: string;
icon: string;
description: string;
skills: string;
optionGroups: OptionGroup[];
isActive: boolean;
sortOrder: number;
}
// Icon library for industries
const ICON_OPTIONS: { name: string; label: string; component: React.ReactNode }[] = [
{ name: 'ShoppingCartOutlined', label: '购物', component: <ShoppingCartOutlined /> },
{ name: 'BookOutlined', label: '书籍', component: <BookOutlined /> },
{ name: 'HomeOutlined', label: '房产', component: <HomeOutlined /> },
{ name: 'FireOutlined', label: '火', component: <FireOutlined /> },
{ name: 'RocketOutlined', label: '科技', component: <RocketOutlined /> },
{ name: 'SkinOutlined', label: '时尚', component: <SkinOutlined /> },
{ name: 'CompassOutlined', label: '指南', component: <CompassOutlined /> },
{ name: 'HeartOutlined', label: '健康', component: <HeartOutlined /> },
{ name: 'CarOutlined', label: '汽车', component: <CarOutlined /> },
{ name: 'CameraOutlined', label: '相机', component: <CameraOutlined /> },
{ name: 'CloudOutlined', label: '云', component: <CloudOutlined /> },
{ name: 'StarOutlined', label: '星', component: <StarOutlined /> },
{ name: 'TrophyOutlined', label: '奖杯', component: <TrophyOutlined /> },
{ name: 'ThunderboltOutlined', label: '闪电', component: <ThunderboltOutlined /> },
{ name: 'BulbOutlined', label: '灯泡', component: <BulbOutlined /> },
{ name: 'CoffeeOutlined', label: '咖啡', component: <CoffeeOutlined /> },
{ name: 'CrownOutlined', label: '皇冠', component: <CrownOutlined /> },
{ name: 'DashboardOutlined', label: '仪表', component: <DashboardOutlined /> },
{ name: 'FlagOutlined', label: '旗帜', component: <FlagOutlined /> },
{ name: 'GlobalOutlined', label: '全球', component: <GlobalOutlined /> },
{ name: 'GiftOutlined', label: '礼物', component: <GiftOutlined /> },
{ name: 'LaptopOutlined', label: '笔记本', component: <LaptopOutlined /> },
{ name: 'MobileOutlined', label: '手机', component: <MobileOutlined /> },
{ name: 'MonitorOutlined', label: '显示器', component: <MonitorOutlined /> },
{ name: 'PayCircleOutlined', label: '支付', component: <PayCircleOutlined /> },
{ name: 'PictureOutlined', label: '图片', component: <PictureOutlined /> },
{ name: 'PlayCircleOutlined', label: '播放', component: <PlayCircleOutlined /> },
{ name: 'SafetyOutlined', label: '安全', component: <SafetyOutlined /> },
{ name: 'ShoppingOutlined', label: '商店', component: <ShoppingOutlined /> },
{ name: 'SmileOutlined', label: '笑脸', component: <SmileOutlined /> },
{ name: 'SoundOutlined', label: '声音', component: <SoundOutlined /> },
{ name: 'TagOutlined', label: '标签', component: <TagOutlined /> },
{ name: 'TeamOutlined', label: '团队', component: <TeamOutlined /> },
{ name: 'ToolOutlined', label: '工具', component: <ToolOutlined /> },
{ name: 'TruckOutlined', label: '物流', component: <TruckOutlined /> },
{ name: 'VideoCameraOutlined', label: '视频', component: <VideoCameraOutlined /> },
{ name: 'WalletOutlined', label: '钱包', component: <WalletOutlined /> },
{ name: 'BankOutlined', label: '银行', component: <BankOutlined /> },
{ name: 'BuildOutlined', label: '建筑', component: <BuildOutlined /> },
{ name: 'ExperimentOutlined', label: '实验', component: <ExperimentOutlined /> },
{ name: 'HighlightOutlined', label: '高亮', component: <HighlightOutlined /> },
{ name: 'IdcardOutlined', label: '名片', component: <IdcardOutlined /> },
{ name: 'MedicineBoxOutlined', label: '医药', component: <MedicineBoxOutlined /> },
{ name: 'ReadOutlined', label: '阅读', component: <ReadOutlined /> },
{ name: 'RestOutlined', label: '休息', component: <RestOutlined /> },
{ name: 'SketchOutlined', label: '钻石', component: <SketchOutlined /> },
{ name: 'SolutionOutlined', label: '方案', component: <SolutionOutlined /> },
];
const iconMap: Record<string, React.ReactNode> = Object.fromEntries(
ICON_OPTIONS.map(o => [o.name, o.component])
);
function getIconComponent(name: string): React.ReactNode {
return iconMap[name] || <AppstoreOutlined />;
}
// Parse skills JSON into { skillItems, optionGroups }
function parseSkills(raw: string | any[]): { skillItems: { key: string; label: string }[]; optionGroups: OptionGroup[] } {
try {
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!Array.isArray(arr)) return { skillItems: [], optionGroups: [] };
const skillItems: { key: string; label: string }[] = [];
const optionGroups: OptionGroup[] = [];
for (const item of arr) {
if (item && item.type === 'option_group' && item.name && Array.isArray(item.options)) {
optionGroups.push({ name: item.name, options: item.options });
} else if (item && item.key && item.label) {
skillItems.push({ key: item.key, label: item.label });
}
}
return { skillItems, optionGroups };
} catch {
return { skillItems: [], optionGroups: [] };
}
}
// Icon Picker Component
const IconPicker: React.FC<{ value?: string; onChange?: (v: string) => void }> = ({ value, onChange }) => {
const selected = ICON_OPTIONS.find(o => o.name === value);
return (
<Dropdown
trigger={['click']}
dropdownRender={() => (
<div style={{
background: '#fff', borderRadius: 12, padding: 12, width: 360,
boxShadow: '0 12px 40px rgba(0,0,0,0.15)', border: '1px solid #f0f0f5',
}}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
{ICON_OPTIONS.map(opt => (
<div key={opt.name} onMouseDown={e => { e.preventDefault(); e.stopPropagation(); onChange?.(opt.name); }} title={opt.label} style={{
width: 40, height: 40, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 18, transition: 'all 0.15s',
background: value === opt.name ? '#6366f1' : 'transparent',
color: value === opt.name ? '#fff' : '#64748b',
border: value === opt.name ? 'none' : '1px solid transparent',
}}
onMouseEnter={e => { if (value !== opt.name) e.currentTarget.style.background = '#f1f5f9'; }}
onMouseLeave={e => { if (value !== opt.name) e.currentTarget.style.background = 'transparent'; }}
>
{opt.component}
</div>
))}
</div>
</div>
)}
>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px',
borderRadius: 8, border: '1px solid #d9d9d9', cursor: 'pointer', height: 40,
}}>
{value ? (
<>
<span style={{ fontSize: 18, color: '#6366f1' }}>{getIconComponent(value)}</span>
<span style={{ color: '#64748b', fontSize: 13 }}>{selected?.label || value}</span>
</>
) : (
<span style={{ color: '#bfbfbf', fontSize: 13 }}>选择图标</span>
)}
<DownOutlined style={{ fontSize: 10, color: '#bfbfbf', marginLeft: 'auto' }} />
</div>
</Dropdown>
);
};
const AdminIndustries: React.FC = () => {
const [industries, setIndustries] = useState<IndustryItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [modal, setModal] = useState<{ open: boolean; item: IndustryItem | null }>({ open: false, item: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getIndustryConfigs();
setIndustries(data.map((item: any) => {
const { skillItems, optionGroups } = parseSkills(item.skills);
return {
id: item.id,
key: item.key,
label: item.label,
icon: item.icon || '',
description: item.description || '',
skills: JSON.stringify(skillItems),
optionGroups,
isActive: item.is_active ?? item.isActive ?? true,
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
};
}));
} catch {
message.error('加载行业配置失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
// Build skills array: skill items + option groups
const skillsArr: any[] = [];
if (values.skills_wentutujie?.trim()) {
skillsArr.push({ type: 'skill', key: '文图理解', label: values.skills_wentutujie.trim() });
}
// Add option groups
const groups: OptionGroup[] = (values.optionGroups || [])
.filter((g: any) => g?.name?.trim())
.map((g: any) => ({
name: g.name.trim(),
options: (g.options || []).filter((o: string) => o?.trim()),
}))
.filter((g: OptionGroup) => g.options.length > 0);
for (const g of groups) {
skillsArr.push({ type: 'option_group', name: g.name, options: g.options });
}
const payload: any = {
key: values.key,
label: values.label,
icon: values.icon || '',
description: values.description || '',
skills: skillsArr,
is_active: values.is_active ?? true,
sort_order: values.sort_order ?? 0,
};
if (modal.item?.id) {
await saveIndustryConfig({ id: modal.item.id, ...payload });
message.success('已更新');
} else {
await saveIndustryConfig(payload);
message.success('已添加');
}
setModal({ open: false, item: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
await deleteIndustryConfig(id);
message.success('已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openEdit = (item?: IndustryItem) => {
setModal({ open: true, item: item || null });
let wentutujie = '';
let optionGroups: OptionGroup[] = [];
if (item) {
// Parse skills for 文图理解
try {
const arr = JSON.parse(item.skills);
if (Array.isArray(arr)) {
for (const s of arr) {
if (s.key === '文图理解') { wentutujie = s.label || ''; break; }
}
}
} catch { /* ignore */ }
optionGroups = item.optionGroups.length > 0 ? item.optionGroups : [{ name: '', options: [] }];
}
if (item) {
form.setFieldsValue({
key: item.key,
label: item.label,
icon: item.icon || '',
description: item.description,
skills_wentutujie: wentutujie,
optionGroups,
is_active: item.isActive,
sort_order: item.sortOrder,
});
} else {
form.resetFields();
form.setFieldsValue({ is_active: true, sort_order: 0, optionGroups: [{ name: '', options: [] }] });
}
};
const columns = [
{
title: '行业', key: 'industry', width: 180,
render: (_: any, r: IndustryItem) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 10, background: '#f1f5f9',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, color: '#6366f1', flexShrink: 0,
}}>
{getIconComponent(r.icon)}
</div>
<div>
<Typography.Text strong>{r.label}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.key}</div>
</div>
</div>
),
},
{ title: '描述', dataIndex: 'description', ellipsis: true },
{
title: '选项配置', key: 'optionGroups', width: 260,
render: (_: any, r: IndustryItem) => {
if (!r.optionGroups || r.optionGroups.length === 0) {
return <Typography.Text style={{ fontSize: 12, color: '#cbd5e1' }}>未配置</Typography.Text>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{r.optionGroups.map((g, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>{g.name}</Tag>
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
{g.options.slice(0, 3).join('、')}{g.options.length > 3 ? `...${g.options.length}项` : ''}
</Typography.Text>
</div>
))}
</div>
);
},
},
{
title: '文图理解生成视频提示词', dataIndex: 'skills', ellipsis: true,
render: (v: string) => {
try {
const arr = JSON.parse(v);
if (Array.isArray(arr)) {
const wtj = arr.find((s: any) => s.key === '文图理解生成视频提示词' || s.key === '文图理解');
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
}
} catch { /* ignore */ }
return <span style={{ color: '#bfbfbf' }}>-</span>;
},
},
{
title: '文图理解生成图片提示词', dataIndex: 'skills', ellipsis: true,
render: (v: string) => {
try {
const arr = JSON.parse(v);
if (Array.isArray(arr)) {
const wtj = arr.find((s: any) => s.key === '文图理解生成图片提示词');
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
}
} catch { /* ignore */ }
return <span style={{ color: '#bfbfbf' }}>-</span>;
},
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: IndustryItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
<Popconfirm title="确定删除该行业?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<AppstoreOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}>行业与技能配置</Typography.Text>
<Tag color="purple">{industries.length} 个行业</Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
添加行业
</Button>
</div>
<Table
columns={columns}
dataSource={industries}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><AppstoreOutlined />{modal.item ? '编辑行业' : '添加行业'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={640}
confirmLoading={saving}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="key" label="行业标识" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入标识' }]}>
<Input placeholder="例如:ecommerce" size="large" />
</Form.Item>
<Form.Item name="label" label="行业名称" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如:电商" size="large" />
</Form.Item>
</div>
<Form.Item name="icon" label="行业图标">
<IconPicker />
</Form.Item>
<Form.Item name="description" label="行业描述">
<Input.TextArea rows={2} placeholder="行业描述" size="large" />
</Form.Item>
<Form.Item name="skills_wentutujie" label="文图理解提示词" extra="用于LLM优化提示词的系统指令,根据行业特点引导AI理解文案与画面的关系">
<Input.TextArea rows={3} placeholder="请输入文图理解提示词,例如:&#10;你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" size="large" />
</Form.Item>
{/* Option Groups */}
<div style={{ marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 13 }}>行业选项配置</Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
添加选项组,每组包含名称和多个选项,前台将显示为下拉选择
</Typography.Text>
</div>
<Form.List name="optionGroups">
{(fields, { add, remove }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{
display: 'flex', gap: 8, alignItems: 'flex-start',
padding: '10px 12px', borderRadius: 10,
background: '#f8f9fc', border: '1px solid #f0f0f5',
}}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Form.Item {...restField} name={[name, 'name']} label="选项名称" style={{ marginBottom: 0 }}
rules={[{ required: true, message: '请输入选项名称' }]}>
<Input placeholder="例如:视频风格" size="middle" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item {...restField} name={[name, 'options']} label="选项内容" style={{ marginBottom: 0 }}>
<Select
mode="tags"
size="middle"
placeholder="输入选项后回车添加"
style={{ borderRadius: 8 }}
tokenSeparators={[',', '', '、']}
/>
</Form.Item>
</div>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: '#ef4444', fontSize: 16, marginTop: 34, cursor: 'pointer', flexShrink: 0 }}
/>
</div>
))}
<Button
type="dashed" onClick={() => add()} block
icon={<PlusOutlined />}
style={{ borderRadius: 8, height: 36 }}
>
添加选项组
</Button>
</div>
)}
</Form.List>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="is_active" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0} style={{ flex: 1 }}>
<Input type="number" size="large" />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminIndustries;
@@ -1,490 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Modal, Popconfirm, Space, Switch, Table, Tag, Typography, Dropdown, Select,
} from 'antd';
import {
AppstoreOutlined, PlusOutlined, EditOutlined, DeleteOutlined, DownOutlined, MinusCircleOutlined,
ShoppingCartOutlined, BookOutlined, HomeOutlined, FireOutlined, RocketOutlined, SkinOutlined,
CompassOutlined, HeartOutlined, CarOutlined, CameraOutlined, CloudOutlined, StarOutlined,
TrophyOutlined, ThunderboltOutlined, BulbOutlined, CoffeeOutlined, CrownOutlined, DashboardOutlined,
FlagOutlined, GlobalOutlined, GiftOutlined, LaptopOutlined, MobileOutlined, MonitorOutlined,
PayCircleOutlined, PictureOutlined, PlayCircleOutlined, SafetyOutlined,
ShoppingOutlined, SmileOutlined, SoundOutlined, TagOutlined, TeamOutlined,
ToolOutlined, TruckOutlined, VideoCameraOutlined, WalletOutlined, BankOutlined,
BuildOutlined, ExperimentOutlined, HighlightOutlined, IdcardOutlined,
MedicineBoxOutlined, ReadOutlined, RestOutlined, SketchOutlined, SolutionOutlined,
} from '@ant-design/icons';
import { getIndustryConfigs, saveIndustryConfig, deleteIndustryConfig } from '../api';
interface OptionGroup {
name: string;
options: string[];
}
interface IndustryItem {
id: string;
key: string;
label: string;
icon: string;
description: string;
skills: string;
optionGroups: OptionGroup[];
isActive: boolean;
sortOrder: number;
}
// Icon library for industries
const ICON_OPTIONS: { name: string; label: string; component: React.ReactNode }[] = [
{ name: 'ShoppingCartOutlined', label: '购物', component: <ShoppingCartOutlined /> },
{ name: 'BookOutlined', label: '书籍', component: <BookOutlined /> },
{ name: 'HomeOutlined', label: '房产', component: <HomeOutlined /> },
{ name: 'FireOutlined', label: '火', component: <FireOutlined /> },
{ name: 'RocketOutlined', label: '科技', component: <RocketOutlined /> },
{ name: 'SkinOutlined', label: '时尚', component: <SkinOutlined /> },
{ name: 'CompassOutlined', label: '指南', component: <CompassOutlined /> },
{ name: 'HeartOutlined', label: '健康', component: <HeartOutlined /> },
{ name: 'CarOutlined', label: '汽车', component: <CarOutlined /> },
{ name: 'CameraOutlined', label: '相机', component: <CameraOutlined /> },
{ name: 'CloudOutlined', label: '云', component: <CloudOutlined /> },
{ name: 'StarOutlined', label: '星', component: <StarOutlined /> },
{ name: 'TrophyOutlined', label: '奖杯', component: <TrophyOutlined /> },
{ name: 'ThunderboltOutlined', label: '闪电', component: <ThunderboltOutlined /> },
{ name: 'BulbOutlined', label: '灯泡', component: <BulbOutlined /> },
{ name: 'CoffeeOutlined', label: '咖啡', component: <CoffeeOutlined /> },
{ name: 'CrownOutlined', label: '皇冠', component: <CrownOutlined /> },
{ name: 'DashboardOutlined', label: '仪表', component: <DashboardOutlined /> },
{ name: 'FlagOutlined', label: '旗帜', component: <FlagOutlined /> },
{ name: 'GlobalOutlined', label: '全球', component: <GlobalOutlined /> },
{ name: 'GiftOutlined', label: '礼物', component: <GiftOutlined /> },
{ name: 'LaptopOutlined', label: '笔记本', component: <LaptopOutlined /> },
{ name: 'MobileOutlined', label: '手机', component: <MobileOutlined /> },
{ name: 'MonitorOutlined', label: '显示器', component: <MonitorOutlined /> },
{ name: 'PayCircleOutlined', label: '支付', component: <PayCircleOutlined /> },
{ name: 'PictureOutlined', label: '图片', component: <PictureOutlined /> },
{ name: 'PlayCircleOutlined', label: '播放', component: <PlayCircleOutlined /> },
{ name: 'SafetyOutlined', label: '安全', component: <SafetyOutlined /> },
{ name: 'ShoppingOutlined', label: '商店', component: <ShoppingOutlined /> },
{ name: 'SmileOutlined', label: '笑脸', component: <SmileOutlined /> },
{ name: 'SoundOutlined', label: '声音', component: <SoundOutlined /> },
{ name: 'TagOutlined', label: '标签', component: <TagOutlined /> },
{ name: 'TeamOutlined', label: '团队', component: <TeamOutlined /> },
{ name: 'ToolOutlined', label: '工具', component: <ToolOutlined /> },
{ name: 'TruckOutlined', label: '物流', component: <TruckOutlined /> },
{ name: 'VideoCameraOutlined', label: '视频', component: <VideoCameraOutlined /> },
{ name: 'WalletOutlined', label: '钱包', component: <WalletOutlined /> },
{ name: 'BankOutlined', label: '银行', component: <BankOutlined /> },
{ name: 'BuildOutlined', label: '建筑', component: <BuildOutlined /> },
{ name: 'ExperimentOutlined', label: '实验', component: <ExperimentOutlined /> },
{ name: 'HighlightOutlined', label: '高亮', component: <HighlightOutlined /> },
{ name: 'IdcardOutlined', label: '名片', component: <IdcardOutlined /> },
{ name: 'MedicineBoxOutlined', label: '医药', component: <MedicineBoxOutlined /> },
{ name: 'ReadOutlined', label: '阅读', component: <ReadOutlined /> },
{ name: 'RestOutlined', label: '休息', component: <RestOutlined /> },
{ name: 'SketchOutlined', label: '钻石', component: <SketchOutlined /> },
{ name: 'SolutionOutlined', label: '方案', component: <SolutionOutlined /> },
];
const iconMap: Record<string, React.ReactNode> = Object.fromEntries(
ICON_OPTIONS.map(o => [o.name, o.component])
);
function getIconComponent(name: string): React.ReactNode {
return iconMap[name] || <AppstoreOutlined />;
}
// Parse skills JSON into { skillItems, optionGroups }
function parseSkills(raw: string | any[]): { skillItems: { key: string; label: string }[]; optionGroups: OptionGroup[] } {
try {
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!Array.isArray(arr)) return { skillItems: [], optionGroups: [] };
const skillItems: { key: string; label: string }[] = [];
const optionGroups: OptionGroup[] = [];
for (const item of arr) {
if (item && item.type === 'option_group' && item.name && Array.isArray(item.options)) {
optionGroups.push({ name: item.name, options: item.options });
} else if (item && item.key && item.label) {
skillItems.push({ key: item.key, label: item.label });
}
}
return { skillItems, optionGroups };
} catch {
return { skillItems: [], optionGroups: [] };
}
}
// Icon Picker Component
const IconPicker: React.FC<{ value?: string; onChange?: (v: string) => void }> = ({ value, onChange }) => {
const selected = ICON_OPTIONS.find(o => o.name === value);
return (
<Dropdown
trigger={['click']}
dropdownRender={() => (
<div style={{
background: '#fff', borderRadius: 12, padding: 12, width: 360,
boxShadow: '0 12px 40px rgba(0,0,0,0.15)', border: '1px solid #f0f0f5',
}}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
{ICON_OPTIONS.map(opt => (
<div key={opt.name} onMouseDown={e => { e.preventDefault(); e.stopPropagation(); onChange?.(opt.name); }} title={opt.label} style={{
width: 40, height: 40, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 18, transition: 'all 0.15s',
background: value === opt.name ? '#6366f1' : 'transparent',
color: value === opt.name ? '#fff' : '#64748b',
border: value === opt.name ? 'none' : '1px solid transparent',
}}
onMouseEnter={e => { if (value !== opt.name) e.currentTarget.style.background = '#f1f5f9'; }}
onMouseLeave={e => { if (value !== opt.name) e.currentTarget.style.background = 'transparent'; }}
>
{opt.component}
</div>
))}
</div>
</div>
)}
>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px',
borderRadius: 8, border: '1px solid #d9d9d9', cursor: 'pointer', height: 40,
}}>
{value ? (
<>
<span style={{ fontSize: 18, color: '#6366f1' }}>{getIconComponent(value)}</span>
<span style={{ color: '#64748b', fontSize: 13 }}>{selected?.label || value}</span>
</>
) : (
<span style={{ color: '#bfbfbf', fontSize: 13 }}>选择图标</span>
)}
<DownOutlined style={{ fontSize: 10, color: '#bfbfbf', marginLeft: 'auto' }} />
</div>
</Dropdown>
);
};
const AdminIndustries: React.FC = () => {
const [industries, setIndustries] = useState<IndustryItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [modal, setModal] = useState<{ open: boolean; item: IndustryItem | null }>({ open: false, item: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getIndustryConfigs();
setIndustries(data.map((item: any) => {
const { skillItems, optionGroups } = parseSkills(item.skills);
return {
id: item.id,
key: item.key,
label: item.label,
icon: item.icon || '',
description: item.description || '',
skills: JSON.stringify(skillItems),
optionGroups,
isActive: item.is_active ?? item.isActive ?? true,
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
};
}));
} catch {
message.error('加载行业配置失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
// Build skills array: skill items + option groups
const skillsArr: any[] = [];
if (values.skills_wentutujie?.trim()) {
skillsArr.push({ type: 'skill', key: '文图理解', label: values.skills_wentutujie.trim() });
}
// Add option groups
const groups: OptionGroup[] = (values.optionGroups || [])
.filter((g: any) => g?.name?.trim())
.map((g: any) => ({
name: g.name.trim(),
options: (g.options || []).filter((o: string) => o?.trim()),
}))
.filter((g: OptionGroup) => g.options.length > 0);
for (const g of groups) {
skillsArr.push({ type: 'option_group', name: g.name, options: g.options });
}
const payload: any = {
key: values.key,
label: values.label,
icon: values.icon || '',
description: values.description || '',
skills: skillsArr,
is_active: values.is_active ?? true,
sort_order: values.sort_order ?? 0,
};
if (modal.item?.id) {
await saveIndustryConfig({ id: modal.item.id, ...payload });
message.success('已更新');
} else {
await saveIndustryConfig(payload);
message.success('已添加');
}
setModal({ open: false, item: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
await deleteIndustryConfig(id);
message.success('已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openEdit = (item?: IndustryItem) => {
setModal({ open: true, item: item || null });
let wentutujie = '';
let wentutujieImage = '';
let optionGroups: OptionGroup[] = [];
if (item) {
// Parse skills for 文图理解 video and image prompts
try {
const arr = JSON.parse(item.skills);
if (Array.isArray(arr)) {
for (const s of arr) {
if (s.key === '文图理解生成视频提示词' || s.key === '文图理解') { wentutujie = s.label || ''; }
if (s.key === '文图理解生成图片提示词') { wentutujieImage = s.label || ''; }
}
}
} catch { /* ignore */ }
optionGroups = item.optionGroups.length > 0 ? item.optionGroups : [{ name: '', options: [] }];
}
if (item) {
form.setFieldsValue({
key: item.key,
label: item.label,
icon: item.icon || '',
description: item.description,
skills_wentutujie: wentutujie,
skills_wentutujie_image: wentutujieImage,
optionGroups,
is_active: item.isActive,
sort_order: item.sortOrder,
});
} else {
form.resetFields();
form.setFieldsValue({ is_active: true, sort_order: 0, optionGroups: [{ name: '', options: [] }] });
}
};
const columns = [
{
title: '行业', key: 'industry', width: 180,
render: (_: any, r: IndustryItem) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 10, background: '#f1f5f9',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, color: '#6366f1', flexShrink: 0,
}}>
{getIconComponent(r.icon)}
</div>
<div>
<Typography.Text strong>{r.label}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.key}</div>
</div>
</div>
),
},
{ title: '描述', dataIndex: 'description', ellipsis: true },
{
title: '选项配置', key: 'optionGroups', width: 260,
render: (_: any, r: IndustryItem) => {
if (!r.optionGroups || r.optionGroups.length === 0) {
return <Typography.Text style={{ fontSize: 12, color: '#cbd5e1' }}>未配置</Typography.Text>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{r.optionGroups.map((g, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>{g.name}</Tag>
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
{g.options.slice(0, 3).join('、')}{g.options.length > 3 ? `...${g.options.length}项` : ''}
</Typography.Text>
</div>
))}
</div>
);
},
},
{
title: '文图理解生成视频提示词', dataIndex: 'skills', ellipsis: true,
render: (v: string) => {
try {
const arr = JSON.parse(v);
if (Array.isArray(arr)) {
const wtj = arr.find((s: any) => s.key === '文图理解生成视频提示词' || s.key === '文图理解');
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
}
} catch { /* ignore */ }
return <span style={{ color: '#bfbfbf' }}>-</span>;
},
},
{
title: '文图理解生成图片提示词', dataIndex: 'skills', ellipsis: true,
render: (v: string) => {
try {
const arr = JSON.parse(v);
if (Array.isArray(arr)) {
const wtj = arr.find((s: any) => s.key === '文图理解生成图片提示词');
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
}
} catch { /* ignore */ }
return <span style={{ color: '#bfbfbf' }}>-</span>;
},
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: IndustryItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
<Popconfirm title="确定删除该行业?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<AppstoreOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}>行业与技能配置</Typography.Text>
<Tag color="purple">{industries.length} 个行业</Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
添加行业
</Button>
</div>
<Table
columns={columns}
dataSource={industries}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><AppstoreOutlined />{modal.item ? '编辑行业' : '添加行业'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={640}
confirmLoading={saving}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="key" label="行业标识" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入标识' }]}>
<Input placeholder="例如:ecommerce" size="large" />
</Form.Item>
<Form.Item name="label" label="行业名称" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如:电商" size="large" />
</Form.Item>
</div>
<Form.Item name="icon" label="行业图标">
<IconPicker />
</Form.Item>
<Form.Item name="description" label="行业描述">
<Input.TextArea rows={2} placeholder="行业描述" size="large" />
</Form.Item>
<Form.Item name="skills_wentutujie" label="文图理解提示词" extra="用于LLM优化提示词的系统指令,根据行业特点引导AI理解文案与画面的关系">
<Input.TextArea rows={3} placeholder="请输入文图理解提示词,例如:&#10;你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" size="large" />
</Form.Item>
{/* Option Groups */}
<div style={{ marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 13 }}>行业选项配置</Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
添加选项组,每组包含名称和多个选项,前台将显示为下拉选择
</Typography.Text>
</div>
<Form.List name="optionGroups">
{(fields, { add, remove }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{
display: 'flex', gap: 8, alignItems: 'flex-start',
padding: '10px 12px', borderRadius: 10,
background: '#f8f9fc', border: '1px solid #f0f0f5',
}}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Form.Item {...restField} name={[name, 'name']} label="选项名称" style={{ marginBottom: 0 }}
rules={[{ required: true, message: '请输入选项名称' }]}>
<Input placeholder="例如:视频风格" size="middle" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item {...restField} name={[name, 'options']} label="选项内容" style={{ marginBottom: 0 }}>
<Select
mode="tags"
size="middle"
placeholder="输入选项后回车添加"
style={{ borderRadius: 8 }}
tokenSeparators={[',', '', '、']}
/>
</Form.Item>
</div>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: '#ef4444', fontSize: 16, marginTop: 34, cursor: 'pointer', flexShrink: 0 }}
/>
</div>
))}
<Button
type="dashed" onClick={() => add()} block
icon={<PlusOutlined />}
style={{ borderRadius: 8, height: 36 }}
>
添加选项组
</Button>
</div>
)}
</Form.List>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="is_active" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0} style={{ flex: 1 }}>
<Input type="number" size="large" />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminIndustries;
+19 -17
View File
@@ -261,23 +261,25 @@ const AdminLayout: React.FC = () => {
</div> </div>
{/* Menu */} {/* Menu */}
<Menu <div style={{ overflowY: 'auto', maxHeight: 'calc(100vh - 120px)', paddingRight: 8 }}>
mode="inline" <Menu
selectedKeys={[activeKey]} mode="inline"
defaultOpenKeys={openKeys} selectedKeys={[activeKey]}
items={antMenuItems} defaultOpenKeys={openKeys}
style={{ items={antMenuItems}
background: 'transparent', style={{
border: 'none', background: 'transparent',
paddingTop: 8, border: 'none',
marginTop: 8, paddingTop: 8,
}} marginTop: 8,
onClick={({ key }) => { }}
if (key.startsWith('group-')) return; onClick={({ key }) => {
navigate(key); if (key.startsWith('group-')) return;
}} navigate(key);
theme="light" }}
/> theme="light"
/>
</div>
</Sider> </Sider>
@@ -1,318 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd';
import {
DashboardOutlined,
UserOutlined,
RobotOutlined,
SettingOutlined,
BellOutlined,
ThunderboltOutlined,
LogoutOutlined,
LockOutlined,
WalletOutlined,
CalculatorOutlined,
DollarOutlined,
AppstoreOutlined,
PlayCircleOutlined,
GiftOutlined,
HomeOutlined,
StarOutlined,
HeartOutlined,
CameraOutlined,
FileTextOutlined,
HistoryOutlined,
VideoCameraOutlined,
PictureOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
import { useAdminStore } from '../store';
import { getMenuConfigs, adminChangePassword } from '../api';
const { Sider, Content } = Layout;
const iconMap: Record<string, React.ReactNode> = {
DashboardOutlined: <DashboardOutlined />,
UserOutlined: <UserOutlined />,
RobotOutlined: <RobotOutlined />,
SettingOutlined: <SettingOutlined />,
BellOutlined: <BellOutlined />,
LogoutOutlined: <LogoutOutlined />,
WalletOutlined: <WalletOutlined />,
CalculatorOutlined: <CalculatorOutlined />,
DollarOutlined: <DollarOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
PlayCircleOutlined: <PlayCircleOutlined />,
GiftOutlined: <GiftOutlined />,
HomeOutlined: <HomeOutlined />,
StarOutlined: <StarOutlined />,
HeartOutlined: <HeartOutlined />,
CameraOutlined: <CameraOutlined />,
FileTextOutlined: <FileTextOutlined />,
HistoryOutlined: <HistoryOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
};
const AdminLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, loading, logout } = useAdminStore();
const [collapsed, setCollapsed] = useState(false);
const [menuItems, setMenuItems] = useState<any[]>([]);
const [pwdModal, setPwdModal] = useState(false);
const [pwdForm] = Form.useForm();
useEffect(() => {
getMenuConfigs().then(data => {
let menus = data.filter((m: any) => {
const target = m.menu_target ?? m.menuTarget ?? 'admin';
const active = m.is_active ?? m.isActive ?? true;
return active && (target === 'admin' || target === 'both');
});
// Non-admin backend users: filter by allowedMenus
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
if (user && !isAdminUser) {
const allowed = user.allowedMenus ?? (user as any)?.allowed_menus;
if (allowed && Array.isArray(allowed) && allowed.length > 0) {
const allowedSet = new Set(allowed);
menus = menus.filter((m: any) => {
const menuType = m.menu_type ?? m.menuType;
if (menuType === 'group') {
return menus.some((c: any) => {
const pid = c.parent_id ?? c.parentId;
return pid === m.id && allowedSet.has(c.path);
});
}
return allowedSet.has(m.path);
});
} else {
// No allowed menus set and not admin = show nothing
menus = [];
}
}
setMenuItems(menus);
}).catch(() => {});
}, [user]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!user) {
return <Navigate to="/admin/login" replace />;
}
// Build menu items for Ant Design Menu — single sorted list interleaving groups and pages
const childMap: Record<string, any[]> = {};
menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group' && (m.parent_id ?? m.parentId)).forEach(m => {
const pid = m.parent_id ?? m.parentId;
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(m);
});
// Sort all menu items by sortOrder, then build ant menu items
const sorted = [...menuItems].sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0));
const antMenuItems: any[] = [];
sorted.forEach(m => {
const menuType = m.menu_type ?? m.menuType;
const parentId = m.parent_id ?? m.parentId;
if (menuType === 'group') {
const children = (childMap[m.id] || [])
.sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0))
.map(c => ({
key: c.path,
icon: iconMap[c.icon] || undefined,
label: c.label,
}));
if (children.length > 0) {
antMenuItems.push({
key: `group-${m.id}`,
icon: iconMap[m.icon] || undefined,
label: m.label,
children,
});
}
} else if (!parentId) {
antMenuItems.push({
key: m.path,
icon: iconMap[m.icon] || undefined,
label: m.label,
});
}
});
// Fallback only for admin users — non-admin users with no permissions see nothing
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
if (antMenuItems.length === 0 && isAdminUser) {
antMenuItems.push(
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
);
}
const selectedKey = location.pathname;
// Find the leaf menu key that matches (for sub-menus, need to find the right key)
let activeKey = selectedKey;
const allLeafKeys: string[] = [];
antMenuItems.forEach(item => {
if (item.children) {
item.children.forEach((c: any) => allLeafKeys.push(c.key));
} else {
allLeafKeys.push(item.key);
}
});
// Exact match or prefix match
if (!allLeafKeys.includes(activeKey)) {
activeKey = allLeafKeys.find(k => activeKey.startsWith(k)) || '/';
}
// Find open keys for sub-menus
const openKeys: string[] = [];
antMenuItems.forEach(item => {
if (item.children) {
if (item.children.some((c: any) => c.key === activeKey)) {
openKeys.push(item.key);
}
}
});
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
theme="dark"
style={{
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
}}
>
{/* Logo */}
<div style={{
height: 64, display: 'flex', alignItems: 'center',
justifyContent: 'center', gap: 10,
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
{!collapsed && (
<span style={{ color: '#f1f5f9', fontSize: 15, fontWeight: 700 }}>
管理后台
</span>
)}
</div>
{/* Menu */}
<Menu
mode="inline"
selectedKeys={[activeKey]}
defaultOpenKeys={openKeys}
items={antMenuItems}
onClick={({ key }) => {
if (key.startsWith('group-')) return;
navigate(key);
}}
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
theme="dark"
/>
</Sider>
<Layout>
{/* Header */}
<div style={{
height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px',
}}>
<Typography.Text strong style={{ fontSize: 16 }}>
{antMenuItems.find(m => m.key === activeKey)?.label
|| antMenuItems.flatMap(m => m.children || []).find((c: any) => c.key === activeKey)?.label
|| '管理后台'}
</Typography.Text>
<Dropdown menu={{
items: [
{ key: 'user', icon: <UserOutlined />, label: user?.username, disabled: true },
{ type: 'divider' as const },
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
],
onClick: ({ key }) => {
if (key === 'logout') { logout(); navigate('/login'); }
if (key === 'changePwd') { setPwdModal(true); pwdForm.resetFields(); }
},
}} placement="bottomRight" arrow>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', padding: '4px 8px', borderRadius: 8, transition: 'background 0.2s' }}>
<Avatar size={28} icon={<UserOutlined />}
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>
{user?.username}
</Typography.Text>
</div>
</Dropdown>
</div>
{/* Content */}
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
{antMenuItems.length === 0 && !isAdminUser ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', color: '#94a3b8' }}>
<LockOutlined style={{ fontSize: 48, marginBottom: 16, color: '#cbd5e1' }} />
<div style={{ fontSize: 16, fontWeight: 600, color: '#64748b' }}>暂无任何权限</div>
<div style={{ fontSize: 13, marginTop: 8 }}>请联系管理员配置菜单权限</div>
</div>
) : (
<Outlet />
)}
</Content>
</Layout>
<Modal
title={<Space><LockOutlined />修改密码</Space>}
open={pwdModal}
onOk={async () => {
try {
const values = await pwdForm.validateFields();
if (values.newPassword !== values.confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
await adminChangePassword(values.oldPassword, values.newPassword);
message.success('密码修改成功');
setPwdModal(false);
pwdForm.resetFields();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '修改失败');
}
}}
onCancel={() => { setPwdModal(false); pwdForm.resetFields(); }}
okText="确认修改" cancelText="取消" width={420}
>
<Form form={pwdForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
<Input.Password placeholder="请输入原密码" size="large" />
</Form.Item>
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
<Input.Password placeholder="请输入新密码(至少6位)" size="large" />
</Form.Item>
<Form.Item name="confirmPassword" label="确认新密码" rules={[{ required: true, message: '请再次输入新密码' }]}>
<Input.Password placeholder="请再次输入新密码" size="large" />
</Form.Item>
</Form>
</Modal>
</Layout>
);
};
export default AdminLayout;
@@ -1,319 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd';
import {
DashboardOutlined,
UserOutlined,
RobotOutlined,
SettingOutlined,
BellOutlined,
ThunderboltOutlined,
LogoutOutlined,
LockOutlined,
WalletOutlined,
CalculatorOutlined,
DollarOutlined,
AppstoreOutlined,
PlayCircleOutlined,
GiftOutlined,
HomeOutlined,
StarOutlined,
HeartOutlined,
CameraOutlined,
FileTextOutlined,
HistoryOutlined,
VideoCameraOutlined,
PictureOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
import { useAdminStore } from '../store';
import { getMenuConfigs, adminChangePassword } from '../api';
const { Sider, Content } = Layout;
const iconMap: Record<string, React.ReactNode> = {
DashboardOutlined: <DashboardOutlined />,
UserOutlined: <UserOutlined />,
RobotOutlined: <RobotOutlined />,
SettingOutlined: <SettingOutlined />,
BellOutlined: <BellOutlined />,
LogoutOutlined: <LogoutOutlined />,
WalletOutlined: <WalletOutlined />,
CalculatorOutlined: <CalculatorOutlined />,
DollarOutlined: <DollarOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
PlayCircleOutlined: <PlayCircleOutlined />,
GiftOutlined: <GiftOutlined />,
HomeOutlined: <HomeOutlined />,
StarOutlined: <StarOutlined />,
HeartOutlined: <HeartOutlined />,
CameraOutlined: <CameraOutlined />,
FileTextOutlined: <FileTextOutlined />,
HistoryOutlined: <HistoryOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
PictureOutlined: <PictureOutlined />,
};
const AdminLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, loading, logout } = useAdminStore();
const [collapsed, setCollapsed] = useState(false);
const [menuItems, setMenuItems] = useState<any[]>([]);
const [pwdModal, setPwdModal] = useState(false);
const [pwdForm] = Form.useForm();
useEffect(() => {
getMenuConfigs().then(data => {
let menus = data.filter((m: any) => {
const target = m.menu_target ?? m.menuTarget ?? 'admin';
const active = m.is_active ?? m.isActive ?? true;
return active && (target === 'admin' || target === 'both');
});
// Non-admin backend users: filter by allowedMenus
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
if (user && !isAdminUser) {
const allowed = user.allowedMenus ?? (user as any)?.allowed_menus;
if (allowed && Array.isArray(allowed) && allowed.length > 0) {
const allowedSet = new Set(allowed);
menus = menus.filter((m: any) => {
const menuType = m.menu_type ?? m.menuType;
if (menuType === 'group') {
return menus.some((c: any) => {
const pid = c.parent_id ?? c.parentId;
return pid === m.id && allowedSet.has(c.path);
});
}
return allowedSet.has(m.path);
});
} else {
// No allowed menus set and not admin = show nothing
menus = [];
}
}
setMenuItems(menus);
}).catch(() => {});
}, [user]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!user) {
return <Navigate to="/admin/login" replace />;
}
// Build menu items for Ant Design Menu — single sorted list interleaving groups and pages
const childMap: Record<string, any[]> = {};
menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group' && (m.parent_id ?? m.parentId)).forEach(m => {
const pid = m.parent_id ?? m.parentId;
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(m);
});
// Sort all menu items by sortOrder, then build ant menu items
const sorted = [...menuItems].sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0));
const antMenuItems: any[] = [];
sorted.forEach(m => {
const menuType = m.menu_type ?? m.menuType;
const parentId = m.parent_id ?? m.parentId;
if (menuType === 'group') {
const children = (childMap[m.id] || [])
.sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0))
.map(c => ({
key: c.path,
icon: iconMap[c.icon] || undefined,
label: c.label,
}));
if (children.length > 0) {
antMenuItems.push({
key: `group-${m.id}`,
icon: iconMap[m.icon] || undefined,
label: m.label,
children,
});
}
} else if (!parentId) {
antMenuItems.push({
key: m.path,
icon: iconMap[m.icon] || undefined,
label: m.label,
});
}
});
// Fallback only for admin users — non-admin users with no permissions see nothing
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
if (antMenuItems.length === 0 && isAdminUser) {
antMenuItems.push(
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
);
}
const selectedKey = location.pathname;
// Find the leaf menu key that matches (for sub-menus, need to find the right key)
let activeKey = selectedKey;
const allLeafKeys: string[] = [];
antMenuItems.forEach(item => {
if (item.children) {
item.children.forEach((c: any) => allLeafKeys.push(c.key));
} else {
allLeafKeys.push(item.key);
}
});
// Exact match or prefix match
if (!allLeafKeys.includes(activeKey)) {
activeKey = allLeafKeys.find(k => activeKey.startsWith(k)) || '/';
}
// Find open keys for sub-menus
const openKeys: string[] = [];
antMenuItems.forEach(item => {
if (item.children) {
if (item.children.some((c: any) => c.key === activeKey)) {
openKeys.push(item.key);
}
}
});
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
theme="dark"
style={{
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
}}
>
{/* Logo */}
<div style={{
height: 64, display: 'flex', alignItems: 'center',
justifyContent: 'center', gap: 10,
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
{!collapsed && (
<span style={{ color: '#f1f5f9', fontSize: 15, fontWeight: 700 }}>
管理后台
</span>
)}
</div>
{/* Menu */}
<Menu
mode="inline"
selectedKeys={[activeKey]}
defaultOpenKeys={openKeys}
items={antMenuItems}
onClick={({ key }) => {
if (key.startsWith('group-')) return;
navigate(key);
}}
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
theme="dark"
/>
</Sider>
<Layout>
{/* Header */}
<div style={{
height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px',
}}>
<Typography.Text strong style={{ fontSize: 16 }}>
{antMenuItems.find(m => m.key === activeKey)?.label
|| antMenuItems.flatMap(m => m.children || []).find((c: any) => c.key === activeKey)?.label
|| '管理后台'}
</Typography.Text>
<Dropdown menu={{
items: [
{ key: 'user', icon: <UserOutlined />, label: user?.username, disabled: true },
{ type: 'divider' as const },
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
],
onClick: ({ key }) => {
if (key === 'logout') { logout(); navigate('/login'); }
if (key === 'changePwd') { setPwdModal(true); pwdForm.resetFields(); }
},
}} placement="bottomRight" arrow>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', padding: '4px 8px', borderRadius: 8, transition: 'background 0.2s' }}>
<Avatar size={28} icon={<UserOutlined />}
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>
{user?.username}
</Typography.Text>
</div>
</Dropdown>
</div>
{/* Content */}
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
{antMenuItems.length === 0 && !isAdminUser ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', color: '#94a3b8' }}>
<LockOutlined style={{ fontSize: 48, marginBottom: 16, color: '#cbd5e1' }} />
<div style={{ fontSize: 16, fontWeight: 600, color: '#64748b' }}>暂无任何权限</div>
<div style={{ fontSize: 13, marginTop: 8 }}>请联系管理员配置菜单权限</div>
</div>
) : (
<Outlet />
)}
</Content>
</Layout>
<Modal
title={<Space><LockOutlined />修改密码</Space>}
open={pwdModal}
onOk={async () => {
try {
const values = await pwdForm.validateFields();
if (values.newPassword !== values.confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
await adminChangePassword(values.oldPassword, values.newPassword);
message.success('密码修改成功');
setPwdModal(false);
pwdForm.resetFields();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '修改失败');
}
}}
onCancel={() => { setPwdModal(false); pwdForm.resetFields(); }}
okText="确认修改" cancelText="取消" width={420}
>
<Form form={pwdForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
<Input.Password placeholder="请输入原密码" size="large" />
</Form.Item>
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
<Input.Password placeholder="请输入新密码(至少6位)" size="large" />
</Form.Item>
<Form.Item name="confirmPassword" label="确认新密码" rules={[{ required: true, message: '请再次输入新密码' }]}>
<Input.Password placeholder="请再次输入新密码" size="large" />
</Form.Item>
</Form>
</Modal>
</Layout>
);
};
export default AdminLayout;
@@ -0,0 +1,384 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Card,
Collapse,
Descriptions,
Empty,
Space,
Spin,
Steps,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import {
ArrowLeftOutlined,
FileImageOutlined,
PlayCircleOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import { getAdminHotOpeningTaskDetail, getAdminShotProjectDetail } from '../api';
import type { ReplicationProjectDetailOut, ReplicationStepOut } from '../types';
import { formatDate } from '../utils/formatDate';
import JsonCollapse, { JsonBlock } from './adminReplication/components/JsonCollapse';
import MediaPreview from './adminReplication/components/MediaPreview';
import StatusTag, { getModuleLabel, getStatusLabel, getStepCodeLabel } from './adminReplication/components/StatusTag';
import VideoPromptSchemaViewer from './adminReplication/components/VideoPromptSchemaViewer';
type ReplicationModuleType = 'hot_opening_replicate' | 'shot_replicate';
interface AdminReplicationProjectDetailProps {
moduleType?: ReplicationModuleType;
}
const STEP_ORDER = [
'material_input',
'image_prompt_optimize',
'image_generate',
'video_prompt_optimize',
'video_generate',
];
const STEP_DESCRIPTIONS: Record<string, string> = {
material_input: '参考素材、项目名称和核心内容点',
image_prompt_optimize: '图片 AI 提词优化结果',
image_generate: '图片引擎、生成参数和结果图',
video_prompt_optimize: '视频 JSON schema、动作流程和最终提词',
video_generate: '视频引擎、生成参数、封面和结果视频',
};
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => {
if (!value) return '-';
return value.length > 18 ? `${value.slice(0, 10)}...${value.slice(-4)}` : value;
};
const EmptyText: React.FC<{ text?: string }> = ({ text = '-' }) => (
<Typography.Text type="secondary">{text}</Typography.Text>
);
const StepRawJson: React.FC<{ step?: ReplicationStepOut | null }> = ({ step }) => {
if (!step) return null;
return <JsonCollapse input={step.input} output={step.output} />;
};
const StepHeader: React.FC<{ index: number; stepCode: string; step?: ReplicationStepOut | null; current?: boolean }> = ({ index, stepCode, step, current }) => (
<Space wrap size={8}>
<Typography.Text strong>{`${index}步:${getStepCodeLabel(stepCode)}`}</Typography.Text>
<StatusTag status={step?.status || 'not_started'} />
{current ? <Tag color="blue"></Tag> : null}
<Typography.Text type="secondary">{STEP_DESCRIPTIONS[stepCode] || stepCode}</Typography.Text>
{step?.updatedAt ? <Typography.Text type="secondary">{safeDate(step.updatedAt)}</Typography.Text> : null}
{step?.chatTaskId ? <Tooltip title={step.chatTaskId}><Tag>Chat{shortId(step.chatTaskId)}</Tag></Tooltip> : null}
</Space>
);
const buildDefaultActiveKeys = (detail: ReplicationProjectDetailOut | null, stepsByCode: Record<string, ReplicationStepOut>): string[] => {
if (!detail) return [];
const keys = new Set<string>();
keys.add('material_input');
if (detail.currentStepCode) keys.add(detail.currentStepCode);
Object.values(stepsByCode).forEach(step => {
if (step.status === 'failed' || step.errorMessage) keys.add(step.stepCode);
});
if (detail.errorMessage) keys.add(detail.currentStepCode || 'material_input');
return Array.from(keys);
};
const renderErrorAlert = (messageText?: string | null, title = '错误信息') => {
if (!messageText) return null;
return <Alert type="error" showIcon message={title} description={messageText} style={{ marginTop: 12, marginBottom: 12 }} />;
};
const renderPromptText = (value?: string | null, empty = '暂无提词') => {
if (!value) return <EmptyText text={empty} />;
return <Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{value}</Typography.Paragraph>;
};
const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps> = ({ moduleType = 'shot_replicate' }) => {
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const [detail, setDetail] = useState<ReplicationProjectDetailOut | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!projectId) return;
setLoading(true);
try {
const res = moduleType === 'hot_opening_replicate'
? await getAdminHotOpeningTaskDetail(projectId)
: await getAdminShotProjectDetail(projectId);
setDetail(res);
} catch (e: any) {
message.error(e?.message || '加载复刻项目详情失败');
} finally {
setLoading(false);
}
}, [moduleType, projectId]);
useEffect(() => {
load();
}, [load]);
const stepsByCode = useMemo(() => {
const map: Record<string, ReplicationStepOut> = {};
(detail?.steps || []).forEach(step => { map[step.stepCode] = step; });
return map;
}, [detail]);
const moduleValue = detail?.module || moduleType;
const moduleName = getModuleLabel(moduleValue);
const stepItems = useMemo(() => STEP_ORDER.map(code => {
const step = stepsByCode[code];
let status: 'wait' | 'process' | 'finish' | 'error' = 'wait';
if (step?.status === 'completed') status = 'finish';
else if (step?.status === 'processing') status = 'process';
else if (step?.status === 'failed') status = 'error';
return {
title: getStepCodeLabel(code),
description: step ? <StatusTag status={step.status} /> : '未创建',
status,
};
}), [stepsByCode]);
const defaultActiveKeys = useMemo(() => buildDefaultActiveKeys(detail, stepsByCode), [detail, stepsByCode]);
if (loading && !detail) {
return <div style={{ padding: 64, textAlign: 'center' }}><Spin size="large" /></div>;
}
if (!detail) {
return (
<Card style={{ margin: 24 }}>
<Empty description="未找到复刻项目详情" />
</Card>
);
}
const materialStep = stepsByCode.material_input;
const imagePromptStep = stepsByCode.image_prompt_optimize;
const imageGenerateStep = stepsByCode.image_generate;
const videoPromptStep = stepsByCode.video_prompt_optimize;
const videoGenerateStep = stepsByCode.video_generate;
const imageResultUrl = detail.imageGeneration?.resultImageUrl || detail.finalImageUrl;
const videoCoverUrl = detail.videoGeneration?.resultVideoCoverUrl || detail.finalVideoCoverUrl;
const videoResultUrl = detail.videoGeneration?.resultVideoUrl || detail.finalVideoUrl;
const collapseItems = [
{
key: 'material_input',
label: <StepHeader index={1} stepCode="material_input" step={materialStep} current={detail.currentStepCode === 'material_input'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="模块类型"><StatusTag status={moduleValue} /></Descriptions.Item>
<Descriptions.Item label="素材步骤ID">{detail.material?.materialStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="参考素材项目名">{detail.material?.sourceProjectName || '-'}</Descriptions.Item>
<Descriptions.Item label="生成项目名称">{detail.material?.targetProjectName || '-'}</Descriptions.Item>
<Descriptions.Item label="核心内容点" span={2}>{detail.material?.coreContentPoint || '-'}</Descriptions.Item>
</Descriptions>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
<Card size="small" title={<Space><PlayCircleOutlined /></Space>}>
<MediaPreview type="video" url={detail.material?.materialVideoUrl} emptyDescription="暂无素材视频" />
</Card>
<Card size="small" title={<Space><FileImageOutlined /></Space>}>
<MediaPreview type="image" url={detail.material?.materialImageUrl} emptyDescription="暂无素材图片" />
</Card>
</div>
{renderErrorAlert(materialStep?.errorMessage)}
<StepRawJson step={materialStep} />
</Space>
),
},
{
key: 'image_prompt_optimize',
label: <StepHeader index={2} stepCode="image_prompt_optimize" step={imagePromptStep} current={detail.currentStepCode === 'image_prompt_optimize'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="提词步骤ID">{detail.imageGeneration?.promptStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="步骤状态"><StatusTag status={imagePromptStep?.status || detail.imageGeneration?.status} /></Descriptions.Item>
<Descriptions.Item label="图片提示词" span={2}>{renderPromptText(detail.imageGeneration?.prompt, '暂无图片提示词')}</Descriptions.Item>
</Descriptions>
{renderErrorAlert(imagePromptStep?.errorMessage || detail.imageGeneration?.errorMessage)}
<StepRawJson step={imagePromptStep} />
</Space>
),
},
{
key: 'image_generate',
label: <StepHeader index={3} stepCode="image_generate" step={imageGenerateStep} current={detail.currentStepCode === 'image_generate'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="生成步骤ID">{detail.imageGeneration?.generateStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="Chat任务ID">{detail.imageGeneration?.chatTaskId || '-'}</Descriptions.Item>
<Descriptions.Item label="状态"><StatusTag status={detail.imageGeneration?.status || imageGenerateStep?.status} /></Descriptions.Item>
<Descriptions.Item label="图片引擎ID">{detail.imageGeneration?.engineId || '-'}</Descriptions.Item>
<Descriptions.Item label="图片引擎名称">{detail.imageGeneration?.engineName || '-'}</Descriptions.Item>
<Descriptions.Item label="生成参数" span={3}><JsonBlock value={detail.imageGeneration?.params || {}} maxHeight={160} /></Descriptions.Item>
</Descriptions>
{renderErrorAlert(detail.imageGeneration?.errorMessage || imageGenerateStep?.errorMessage)}
<Card size="small" title={<Space><FileImageOutlined /></Space>}>
<MediaPreview
type="image"
url={imageResultUrl}
height={360}
emptyDescription={imageGenerateStep?.status === 'failed' || detail.imageGeneration?.errorMessage ? '图片生成失败,未返回图片地址' : '暂无图片结果'}
errorDescription="图片资源加载失败,可能图片文件不存在、签名过期或访问权限受限。"
/>
</Card>
<StepRawJson step={imageGenerateStep} />
</Space>
),
},
{
key: 'video_prompt_optimize',
label: <StepHeader index={4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="提词步骤ID">{detail.videoGeneration?.promptStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="步骤状态"><StatusTag status={videoPromptStep?.status} /></Descriptions.Item>
<Descriptions.Item label="提词参数" span={3}><JsonBlock value={detail.videoGeneration?.promptParams || {}} maxHeight={160} /></Descriptions.Item>
</Descriptions>
{renderErrorAlert(videoPromptStep?.errorMessage || detail.videoGeneration?.errorMessage)}
<VideoPromptSchemaViewer schema={detail.videoGeneration?.promptSchema} finalPrompt={detail.videoGeneration?.finalPrompt} />
<StepRawJson step={videoPromptStep} />
</Space>
),
},
{
key: 'video_generate',
label: <StepHeader index={5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="生成步骤ID">{detail.videoGeneration?.generateStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="Chat任务ID">{detail.videoGeneration?.chatTaskId || '-'}</Descriptions.Item>
<Descriptions.Item label="状态"><StatusTag status={detail.videoGeneration?.status || videoGenerateStep?.status} /></Descriptions.Item>
<Descriptions.Item label="视频引擎ID">{detail.videoGeneration?.engineId || '-'}</Descriptions.Item>
<Descriptions.Item label="视频引擎名称">{detail.videoGeneration?.engineName || '-'}</Descriptions.Item>
<Descriptions.Item label="生成参数" span={3}><JsonBlock value={detail.videoGeneration?.params || {}} maxHeight={160} /></Descriptions.Item>
</Descriptions>
{renderErrorAlert(detail.videoGeneration?.errorMessage || videoGenerateStep?.errorMessage)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
<Card size="small" title={<Space><FileImageOutlined /></Space>}>
<MediaPreview
type="image"
url={videoCoverUrl}
emptyDescription={videoGenerateStep?.status === 'failed' || detail.videoGeneration?.errorMessage ? '视频生成失败,未返回封面' : '暂无视频封面'}
errorDescription="视频封面加载失败,可能文件不存在、签名过期或访问权限受限。"
/>
</Card>
<Card size="small" title={<Space><VideoCameraOutlined /></Space>}>
<MediaPreview
type="video"
url={videoResultUrl}
emptyDescription={videoGenerateStep?.status === 'failed' || detail.videoGeneration?.errorMessage ? '视频生成失败,未返回视频地址' : '暂无最终视频'}
errorDescription="视频资源加载失败,可能文件不存在、签名过期或访问权限受限。"
/>
</Card>
</div>
<StepRawJson step={videoGenerateStep} />
</Space>
),
},
];
return (
<div style={{ padding: 24 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space wrap style={{ justifyContent: 'space-between', width: '100%' }}>
<Space wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)}></Button>
<Typography.Title level={3} style={{ margin: 0 }}>{moduleName}</Typography.Title>
<StatusTag status={detail.status} />
{detail.module && detail.module !== moduleType ? <Tag color="gold">{getModuleLabel(detail.module)}</Tag> : null}
</Space>
<Button onClick={load} loading={loading}></Button>
</Space>
<Card>
<Descriptions title="基础信息" column={3} bordered size="small">
<Descriptions.Item label="项目ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="模块类型"><StatusTag status={moduleValue} /></Descriptions.Item>
<Descriptions.Item label="用户ID">{detail.userId || '-'}</Descriptions.Item>
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
<Descriptions.Item label="当前步骤"><Tooltip title={detail.currentStepCode || ''}>{getStepCodeLabel(detail.currentStepCode)}</Tooltip></Descriptions.Item>
<Descriptions.Item label="状态"><StatusTag status={detail.status} /></Descriptions.Item>
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
<Descriptions.Item label="完成时间">{safeDate(detail.completedAt)}</Descriptions.Item>
{detail.errorMessage ? <Descriptions.Item label="错误信息" span={3}><Alert type="error" showIcon message={detail.errorMessage} /></Descriptions.Item> : null}
</Descriptions>
</Card>
<Card title="步骤进度">
<Steps items={stepItems} />
</Card>
<Card title="最终结果预览">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
<Card size="small" title="最终图片">
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
</Card>
<Card size="small" title="最终视频封面">
<MediaPreview type="image" url={detail.finalVideoCoverUrl || detail.videoGeneration?.resultVideoCoverUrl} height={220} emptyDescription="暂无最终视频封面" />
</Card>
<Card size="small" title="最终视频">
<MediaPreview type="video" url={detail.finalVideoUrl || detail.videoGeneration?.resultVideoUrl} height={220} emptyDescription="暂无最终视频" />
</Card>
</div>
</Card>
<Collapse defaultActiveKey={defaultActiveKeys} items={collapseItems} />
<Collapse
items={[
{
key: 'steps-table',
label: '完整步骤列表',
children: (
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.steps || []}
scroll={{ x: 1100 }}
columns={[
{ title: '序号', dataIndex: 'stepIndex', width: 70 },
{ title: '步骤', dataIndex: 'stepCode', width: 160, render: (v: string) => <Tooltip title={v}>{getStepCodeLabel(v)}</Tooltip> },
{ title: '状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} /> },
{ title: '版本', dataIndex: 'version', width: 70 },
{ title: '当前有效', dataIndex: 'isCurrent', width: 90, render: (v: boolean) => v ? <Tag color="success"></Tag> : <Tag></Tag> },
{ title: 'Chat任务', dataIndex: 'chatTaskId', width: 160, render: (v: string) => <Tooltip title={v || ''}>{shortId(v)}</Tooltip> },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
{ title: '完成时间', dataIndex: 'completedAt', width: 170, render: safeDate },
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true, render: (v: string) => v || '-' },
]}
/>
),
},
{
key: 'raw-detail',
label: '完整详情原始 JSON',
children: <JsonBlock value={detail} maxHeight={520} />,
},
]}
/>
</Space>
</div>
);
};
export default AdminReplicationProjectDetail;
@@ -0,0 +1,220 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getAdminShotTaskSets } from '../api';
import type { ShotTaskSetOut } from '../types';
import { formatDate } from '../utils/formatDate';
const PAGE_SIZE = 20;
const TASK_STATUS_OPTIONS = [
{ value: 'pending_analysis', label: '等待分析' },
{ value: 'analyzing', label: '分析中' },
{ value: 'analysis_completed', label: '分析完成' },
{ value: 'analysis_failed', label: '分析失败' },
{ value: 'splitting', label: '拆镜中' },
{ value: 'split_completed', label: '拆镜完成' },
{ value: 'partial_failed', label: '部分失败' },
{ value: 'failed', label: '失败' },
];
const ANALYSIS_STATUS_OPTIONS = [
{ value: 'pending', label: '待分析' },
{ value: 'processing', label: '分析中' },
{ value: 'completed', label: '分析完成' },
{ value: 'failed', label: '分析失败' },
];
const SPLIT_STATUS_OPTIONS = [
{ value: 'none', label: '未拆镜' },
{ value: 'pending', label: '待拆镜' },
{ value: 'processing', label: '拆镜中' },
{ value: 'completed', label: '拆镜完成' },
{ value: 'failed', label: '拆镜失败' },
{ value: 'retry_waiting', label: '等待重试' },
];
const STATUS_MAP: Record<string, { color: string; text: string }> = {
pending_analysis: { color: 'default', text: '等待分析' },
analyzing: { color: 'processing', text: '分析中' },
analysis_completed: { color: 'success', text: '分析完成' },
analysis_failed: { color: 'error', text: '分析失败' },
splitting: { color: 'warning', text: '拆镜中' },
split_completed: { color: 'success', text: '拆镜完成' },
partial_failed: { color: 'orange', text: '部分失败' },
failed: { color: 'error', text: '失败' },
none: { color: 'default', text: '未拆镜' },
pending: { color: 'default', text: '待处理' },
processing: { color: 'warning', text: '处理中' },
completed: { color: 'success', text: '完成' },
retry_waiting: { color: 'orange', text: '等待重试' },
};
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
if (!status) return <Tag>-</Tag>;
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
return <Tag color={meta.color}>{meta.text}</Tag>;
};
const AdminShotReplications: React.FC = () => {
const navigate = useNavigate();
const [items, setItems] = useState<ShotTaskSetOut[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [status, setStatus] = useState('');
const [analysisStatus, setAnalysisStatus] = useState('');
const [splitStatus, setSplitStatus] = useState('');
const [inputKeyword, setInputKeyword] = useState('');
const [inputUserId, setInputUserId] = useState('');
const [inputUserName, setInputUserName] = useState('');
const [createdRange, setCreatedRange] = useState<any>(null);
const [queryKeyword, setQueryKeyword] = useState('');
const [queryUserId, setQueryUserId] = useState('');
const [queryUserName, setQueryUserName] = useState('');
const [queryCreatedRange, setQueryCreatedRange] = useState<any>(null);
const [reloadKey, setReloadKey] = useState(0);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getAdminShotTaskSets({
status: status || undefined,
analysisStatus: analysisStatus || undefined,
splitStatus: splitStatus || undefined,
keyword: queryKeyword || undefined,
userId: queryUserId || undefined,
userName: queryUserName || undefined,
createdStart: queryCreatedRange?.[0]?.toISOString?.(),
createdEnd: queryCreatedRange?.[1]?.toISOString?.(),
page,
pageSize: PAGE_SIZE,
});
setItems(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载拆镜复刻任务失败');
} finally {
setLoading(false);
}
}, [analysisStatus, page, queryCreatedRange, queryKeyword, queryUserId, queryUserName, splitStatus, status]);
useEffect(() => {
load();
}, [load, reloadKey]);
const doSearch = () => {
setQueryKeyword(inputKeyword.trim());
setQueryUserId(inputUserId.trim());
setQueryUserName(inputUserName.trim());
setQueryCreatedRange(createdRange);
setPage(1);
};
const resetSearch = () => {
setStatus('');
setAnalysisStatus('');
setSplitStatus('');
setInputKeyword('');
setInputUserId('');
setInputUserName('');
setCreatedRange(null);
setQueryKeyword('');
setQueryUserId('');
setQueryUserName('');
setQueryCreatedRange(null);
setPage(1);
setReloadKey(v => v + 1);
};
return (
<div style={{ padding: 24 }}>
<Card>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title>
<Typography.Text type="secondary">AI </Typography.Text>
</div>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space>
<Space wrap>
<Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} />
<Select allowClear placeholder="分析状态" style={{ width: 140 }} value={analysisStatus || undefined} onChange={v => { setAnalysisStatus(v || ''); setPage(1); }} options={ANALYSIS_STATUS_OPTIONS} />
<Select allowClear placeholder="拆镜状态" style={{ width: 140 }} value={splitStatus || undefined} onChange={v => { setSplitStatus(v || ''); setPage(1); }} options={SPLIT_STATUS_OPTIONS} />
<Input allowClear prefix={<SearchOutlined />} placeholder="关键词:标题/内容/分类/受众" style={{ width: 260 }} value={inputKeyword} onChange={e => setInputKeyword(e.target.value)} onPressEnter={doSearch} />
<Input allowClear placeholder="用户ID(管理员)" style={{ width: 190 }} value={inputUserId} onChange={e => setInputUserId(e.target.value)} onPressEnter={doSearch} />
<Input allowClear placeholder="用户名(管理员)" style={{ width: 230 }} value={inputUserName} onChange={e => setInputUserName(e.target.value)} onPressEnter={doSearch} />
<DatePicker.RangePicker showTime value={createdRange} onChange={setCreatedRange} placeholder={['创建开始', '创建结束']} />
<Button type="primary" icon={<SearchOutlined />} onClick={doSearch}></Button>
<Button onClick={resetSearch}></Button>
</Space>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, showTotal: value => `${value}`, onChange: setPage }}
scroll={{ x: 1500 }}
columns={[
{ title: '任务集ID', dataIndex: 'id', width: 150, render: (v: string) => <Tooltip title={v}>{shortId(v)}</Tooltip> },
{
title: '用户',
width: 180,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Typography.Text>{record.userName || '-'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{shortId(record.userId)}</Typography.Text>
</Space>
),
},
{
title: '任务信息',
width: 300,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Typography.Text strong>{record.title || '-'}</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ maxWidth: 280 }}>{record.originalVideoCategory || '-'}</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ maxWidth: 280 }}>{record.originalVideoAudience || '-'}</Typography.Text>
</Space>
),
},
{ title: '总状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} /> },
{ title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
{
title: '切片进度',
width: 180,
render: (_, record) => {
const totalSegments = record.segmentCount || 0;
const done = record.completedSegmentCount || 0;
const percent = totalSegments ? Math.round((done / totalSegments) * 100) : 0;
return <Progress size="small" percent={percent} format={() => `${done}/${totalSegments}`} />;
},
},
{ title: '失败片段', dataIndex: 'failedSegmentCount', width: 90, render: (v: number) => v ? <Tag color="error">{v}</Tag> : <Tag>0</Tag> },
{ title: '视频时长', dataIndex: 'videoDurationSeconds', width: 100, render: (v: number) => `${Number(v || 0).toFixed(1)}s` },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: safeDate },
{
title: '操作',
fixed: 'right',
width: 120,
render: (_, record) => <Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/shot-replications/task-sets/${record.id}`)}></Button>,
},
]}
/>
</Space>
</Card>
</div>
);
};
export default AdminShotReplications;
@@ -0,0 +1,340 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
Button,
Card,
Collapse,
Descriptions,
Drawer,
Empty,
Input,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import { ArrowLeftOutlined, EyeOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import { getAdminShotSegmentDetail, getAdminShotSegments, getAdminShotTaskSetDetail } from '../api';
import type { ShotAiSuggestionOut, ShotSegmentDetailOut, ShotSegmentOut, ShotTaskSetDetailOut } from '../types';
import { formatDate } from '../utils/formatDate';
import { getStepCodeLabel } from './adminReplication/components/StatusTag';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
const PAGE_SIZE = 20;
const SOURCE_OPTIONS = [
{ value: 'ai_suggestion', label: 'AI 建议' },
{ value: 'custom', label: '自定义' },
];
const SPLIT_STATUS_OPTIONS = [
{ value: 'none', label: '未拆镜' },
{ value: 'pending', label: '待拆镜' },
{ value: 'processing', label: '拆镜中' },
{ value: 'completed', label: '拆镜完成' },
{ value: 'failed', label: '拆镜失败' },
{ value: 'retry_waiting', label: '等待重试' },
];
const ANALYSIS_STATUS_OPTIONS = [
{ value: 'not_required', label: '无需分析' },
{ value: 'pending', label: '待分析' },
{ value: 'processing', label: '分析中' },
{ value: 'completed', label: '分析完成' },
{ value: 'failed', label: '分析失败' },
];
const REPLICATE_STATUS_OPTIONS = [
{ value: 'not_started', label: '未复刻' },
{ value: 'project_created', label: '已创建项目' },
{ value: 'processing', label: '复刻中' },
{ value: 'completed', label: '复刻完成' },
{ value: 'failed', label: '复刻失败' },
];
const STATUS_MAP: Record<string, { color: string; text: string }> = {
pending_analysis: { color: 'default', text: '等待分析' },
analyzing: { color: 'processing', text: '分析中' },
analysis_completed: { color: 'success', text: '分析完成' },
analysis_failed: { color: 'error', text: '分析失败' },
splitting: { color: 'warning', text: '拆镜中' },
split_completed: { color: 'success', text: '拆镜完成' },
partial_failed: { color: 'orange', text: '部分失败' },
failed: { color: 'error', text: '失败' },
none: { color: 'default', text: '未拆镜' },
pending: { color: 'default', text: '待处理' },
processing: { color: 'warning', text: '处理中' },
completed: { color: 'success', text: '完成' },
retry_waiting: { color: 'orange', text: '等待重试' },
not_required: { color: 'default', text: '无需分析' },
not_started: { color: 'default', text: '未复刻' },
project_created: { color: 'processing', text: '已创建项目' },
};
const apiUrl = (url?: string | null): string => {
if (!url) return '';
const value = String(url).trim();
if (!value) return '';
if (/^(https?:)?\/\//i.test(value) || /^(blob|data):/i.test(value)) return value;
return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
};
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
if (!status) return <Tag>-</Tag>;
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
return <Tag color={meta.color}>{meta.text}</Tag>;
};
const JsonBlock: React.FC<{ value: unknown; maxHeight?: number }> = ({ value, maxHeight = 420 }) => (
<pre style={{ margin: 0, padding: 12, maxHeight, overflow: 'auto', background: '#0f172a', color: '#e2e8f0', borderRadius: 8, fontSize: 12, lineHeight: 1.6 }}>
{JSON.stringify(value ?? {}, null, 2)}
</pre>
);
const VideoPreview: React.FC<{ url?: string | null; height?: number }> = ({ url, height = 280 }) => {
if (!url) return <Empty description="暂无视频" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
return <video src={apiUrl(url)} controls preload="metadata" style={{ width: '100%', maxHeight: height, borderRadius: 12, background: '#0f172a' }} />;
};
const SuggestionTable: React.FC<{ items?: ShotAiSuggestionOut[] }> = ({ items = [] }) => (
<Table
rowKey="index"
size="small"
pagination={false}
dataSource={items}
scroll={{ x: true }}
columns={[
{ title: '序号', dataIndex: 'index', width: 70 },
{ title: '时间节点', dataIndex: 'timeNode', width: 120 },
{ title: '开始', dataIndex: 'startSecond', width: 80, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
{ title: '结束', dataIndex: 'endSecond', width: 80, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
{ title: '内容', dataIndex: 'content', ellipsis: true },
{ title: '分类', dataIndex: 'category', width: 140 },
{ title: '受众', dataIndex: 'audience', ellipsis: true },
]}
/>
);
const AdminShotTaskSetDetail: React.FC = () => {
const { taskSetId } = useParams<{ taskSetId: string }>();
const navigate = useNavigate();
const [detail, setDetail] = useState<ShotTaskSetDetailOut | null>(null);
const [segments, setSegments] = useState<ShotSegmentOut[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [segmentLoading, setSegmentLoading] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [sourceMode, setSourceMode] = useState('');
const [splitStatus, setSplitStatus] = useState('');
const [analysisStatus, setAnalysisStatus] = useState('');
const [replicateStatus, setReplicateStatus] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [segmentDetail, setSegmentDetail] = useState<ShotSegmentDetailOut | null>(null);
const loadDetail = useCallback(async () => {
if (!taskSetId) return;
setLoading(true);
try {
const res = await getAdminShotTaskSetDetail(taskSetId);
setDetail(res);
} catch (e: any) {
message.error(e?.message || '加载拆镜任务详情失败');
} finally {
setLoading(false);
}
}, [taskSetId]);
const loadSegments = useCallback(async () => {
if (!taskSetId) return;
setSegmentLoading(true);
try {
const res = await getAdminShotSegments(taskSetId, {
sourceMode: sourceMode || undefined,
splitStatus: splitStatus || undefined,
analysisStatus: analysisStatus || undefined,
replicateStatus: replicateStatus || undefined,
page,
pageSize: PAGE_SIZE,
});
setSegments(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载拆镜片段失败');
} finally {
setSegmentLoading(false);
}
}, [analysisStatus, page, replicateStatus, sourceMode, splitStatus, taskSetId]);
useEffect(() => { loadDetail(); }, [loadDetail, reloadKey]);
useEffect(() => { loadSegments(); }, [loadSegments, reloadKey]);
const openSegmentDetail = async (segmentId: string) => {
setDrawerOpen(true);
setSegmentDetail(null);
try {
const res = await getAdminShotSegmentDetail(segmentId);
setSegmentDetail(res);
} catch (e: any) {
message.error(e?.message || '加载片段详情失败');
}
};
const resetFilters = () => {
setSourceMode('');
setSplitStatus('');
setAnalysisStatus('');
setReplicateStatus('');
setPage(1);
setReloadKey(v => v + 1);
};
if (loading && !detail) {
return <div style={{ padding: 64, textAlign: 'center' }}><Spin size="large" /></div>;
}
if (!detail) {
return <Card style={{ margin: 24 }}><Empty description="未找到拆镜任务详情" /></Card>;
}
return (
<div style={{ padding: 24 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/shot-replications')}></Button>
<Typography.Title level={3} style={{ margin: 0 }}></Typography.Title>
<StatusTag status={detail.status} />
</Space>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space>
<Card>
<Descriptions title="总任务信息" column={3} bordered size="small">
<Descriptions.Item label="任务集ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户ID">{detail.userId || '-'}</Descriptions.Item>
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
<Descriptions.Item label="视频时长">{Number(detail.videoDurationSeconds || 0).toFixed(2)}s</Descriptions.Item>
<Descriptions.Item label="总状态"><StatusTag status={detail.status} /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} /></Descriptions.Item>
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} /></Descriptions.Item>
<Descriptions.Item label="片段数量">{detail.completedSegmentCount}/{detail.segmentCount} {detail.failedSegmentCount}</Descriptions.Item>
<Descriptions.Item label="原视频分类">{detail.originalVideoCategory || '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
<Descriptions.Item label="原视频内容" span={3}>{detail.originalVideoContent || '-'}</Descriptions.Item>
<Descriptions.Item label="原视频受众" span={3}>{detail.originalVideoAudience || '-'}</Descriptions.Item>
{detail.analysisErrorMessage ? <Descriptions.Item label="分析错误" span={3}><Alert type="error" message={detail.analysisErrorMessage} /></Descriptions.Item> : null}
{detail.splitErrorMessage ? <Descriptions.Item label="拆镜错误" span={3}><Alert type="error" message={detail.splitErrorMessage} /></Descriptions.Item> : null}
</Descriptions>
</Card>
<Card title={<Space><PlayCircleOutlined /></Space>}>
<VideoPreview url={detail.videoUrl} height={360} />
</Card>
<Card title="AI 建议拆镜列表">
{detail.aiSuggestions?.length ? <SuggestionTable items={detail.aiSuggestions} /> : <Empty description="暂无 AI 建议拆镜" />}
<Collapse
size="small"
ghost
style={{ marginTop: 12 }}
items={[{ key: 'raw', label: '查看原视频分析完整 JSON', children: <JsonBlock value={detail.analysisResultJson} /> }]}
/>
</Card>
<Card title="片段列表">
<Space wrap style={{ marginBottom: 16 }}>
<Select allowClear placeholder="片段来源" style={{ width: 140 }} value={sourceMode || undefined} options={SOURCE_OPTIONS} onChange={v => { setSourceMode(v || ''); setPage(1); }} />
<Select allowClear placeholder="切割状态" style={{ width: 140 }} value={splitStatus || undefined} options={SPLIT_STATUS_OPTIONS} onChange={v => { setSplitStatus(v || ''); setPage(1); }} />
<Select allowClear placeholder="分析状态" style={{ width: 140 }} value={analysisStatus || undefined} options={ANALYSIS_STATUS_OPTIONS} onChange={v => { setAnalysisStatus(v || ''); setPage(1); }} />
<Select allowClear placeholder="复刻状态" style={{ width: 150 }} value={replicateStatus || undefined} options={REPLICATE_STATUS_OPTIONS} onChange={v => { setReplicateStatus(v || ''); setPage(1); }} />
<Button icon={<SearchOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
<Button onClick={resetFilters}></Button>
</Space>
<Table
rowKey="id"
loading={segmentLoading}
dataSource={segments}
pagination={{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, showTotal: value => `${value}`, onChange: setPage }}
scroll={{ x: 1550 }}
columns={[
{ title: '片段ID', dataIndex: 'id', width: 150, render: (v: string) => <Tooltip title={v}>{shortId(v)}</Tooltip> },
{ title: '序号', dataIndex: 'segmentIndex', width: 70 },
{ title: '来源', dataIndex: 'sourceMode', width: 100, render: (v: string) => v === 'ai_suggestion' ? <Tag color="purple">AI建议</Tag> : <Tag color="cyan"></Tag> },
{ title: '时间节点', dataIndex: 'timeNode', width: 130 },
{ title: '时长', dataIndex: 'durationSeconds', width: 90, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
{ title: '切割', dataIndex: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
{ title: '片段内容', dataIndex: 'segmentContent', width: 260, ellipsis: true, render: (v: string) => v || '-' },
{ title: '分类', dataIndex: 'segmentCategory', width: 120, render: (v: string) => v || '-' },
{
title: '关联项目',
width: 220,
render: (_, record) => record.moduleProjectId ? (
<Space direction="vertical" size={0}>
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}`)}>{shortId(record.moduleProjectId)}</Button>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{record.moduleProjectTitle || getStepCodeLabel(record.moduleProjectCurrentStepCode)}</Typography.Text>
<StatusTag status={record.moduleProjectStatus} />
</Space>
) : <Tag></Tag>,
},
{ title: '错误', dataIndex: 'splitLastError', width: 220, ellipsis: true, render: (v: string, record) => v || record.analysisErrorMessage || '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
{
title: '操作',
fixed: 'right',
width: 100,
render: (_, record) => <Button type="link" icon={<EyeOutlined />} onClick={() => openSegmentDetail(record.id)}></Button>,
},
]}
/>
</Card>
</Space>
<Drawer title="片段详情" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)} destroyOnClose>
{!segmentDetail ? <Spin /> : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="片段ID" span={2}>{segmentDetail.id}</Descriptions.Item>
<Descriptions.Item label="时间节点">{segmentDetail.timeNode}</Descriptions.Item>
<Descriptions.Item label="时长">{Number(segmentDetail.durationSeconds || 0).toFixed(2)}s</Descriptions.Item>
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} /></Descriptions.Item>
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} /></Descriptions.Item>
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
<Descriptions.Item label="片段内容" span={2}>{segmentDetail.segmentContent || '-'}</Descriptions.Item>
<Descriptions.Item label="片段分类">{segmentDetail.segmentCategory || '-'}</Descriptions.Item>
<Descriptions.Item label="片段受众">{segmentDetail.segmentAudience || '-'}</Descriptions.Item>
</Descriptions>
<Card size="small" title="片段视频">
<VideoPreview url={segmentDetail.segmentVideoUrl} height={300} />
</Card>
{segmentDetail.splitLastError ? <Alert type="error" message="切割错误" description={segmentDetail.splitLastError} /> : null}
{segmentDetail.analysisErrorMessage ? <Alert type="error" message="分析错误" description={segmentDetail.analysisErrorMessage} /> : null}
<Collapse
size="small"
items={[
{ key: 'analysis', label: '片段分析 JSON', children: <JsonBlock value={segmentDetail.analysisJson} /> },
{ key: 'suggestion', label: 'AI 建议原始 JSON', children: <JsonBlock value={segmentDetail.aiSuggestionJson} /> },
]}
/>
</Space>
)}
</Drawer>
</div>
);
};
export default AdminShotTaskSetDetail;
@@ -0,0 +1,59 @@
import React from 'react';
import { Collapse, Empty } from 'antd';
interface JsonBlockProps {
value: unknown;
maxHeight?: number;
}
export const JsonBlock: React.FC<JsonBlockProps> = ({ value, maxHeight = 420 }) => {
if (value === undefined || value === null || value === '') {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无 JSON 数据" />;
}
return (
<pre
style={{
margin: 0,
padding: 12,
maxHeight,
overflow: 'auto',
background: '#0f172a',
color: '#e2e8f0',
borderRadius: 8,
fontSize: 12,
lineHeight: 1.6,
}}
>
{JSON.stringify(value, null, 2)}
</pre>
);
};
interface JsonCollapseProps {
input?: unknown;
output?: unknown;
raw?: unknown;
inputLabel?: string;
outputLabel?: string;
rawLabel?: string;
}
const JsonCollapse: React.FC<JsonCollapseProps> = ({
input,
output,
raw,
inputLabel = '查看 input_json',
outputLabel = '查看 output_json',
rawLabel = '查看原始 JSON',
}) => {
const items = [];
if (input !== undefined) items.push({ key: 'input', label: inputLabel, children: <JsonBlock value={input} /> });
if (output !== undefined) items.push({ key: 'output', label: outputLabel, children: <JsonBlock value={output} /> });
if (raw !== undefined) items.push({ key: 'raw', label: rawLabel, children: <JsonBlock value={raw} /> });
if (!items.length) return null;
return <Collapse size="small" style={{ marginTop: 12 }} items={items} />;
};
export default JsonCollapse;
@@ -0,0 +1,126 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd';
import { CopyOutlined, LinkOutlined } from '@ant-design/icons';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
export function resolveResourceUrl(url?: string | null): string {
if (!url) return '';
const value = String(url).trim();
if (!value) return '';
if (/^(https?:)?\/\//i.test(value) || /^(blob|data):/i.test(value)) return value;
return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
}
interface MediaPreviewProps {
url?: string | null;
type: 'image' | 'video';
height?: number;
emptyDescription?: string;
errorDescription?: string;
}
const MediaPreview: React.FC<MediaPreviewProps> = ({
url,
type,
height = 260,
emptyDescription = '暂无资源',
errorDescription = '资源加载失败,可能文件不存在、签名过期或访问权限受限。',
}) => {
const resolvedUrl = useMemo(() => resolveResourceUrl(url), [url]);
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
setLoadFailed(false);
}, [resolvedUrl]);
const copyUrl = async () => {
if (!resolvedUrl) return;
try {
await navigator.clipboard.writeText(resolvedUrl);
message.success('资源地址已复制');
} catch {
message.error('复制失败,请手动复制');
}
};
const tools = resolvedUrl ? (
<Space wrap style={{ marginTop: 8 }}>
<Button size="small" icon={<CopyOutlined />} onClick={copyUrl}></Button>
<Button size="small" icon={<LinkOutlined />} onClick={() => window.open(resolvedUrl, '_blank', 'noopener,noreferrer')}></Button>
<Typography.Text copyable={{ text: resolvedUrl }} type="secondary" style={{ maxWidth: 460 }} ellipsis>
{resolvedUrl}
</Typography.Text>
</Space>
) : null;
if (!resolvedUrl) {
return (
<div>
<div
style={{
height,
borderRadius: 12,
border: '1px dashed #cbd5e1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f8fafc',
}}
>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} />
</div>
</div>
);
}
return (
<div>
{loadFailed ? (
<Alert
type="warning"
showIcon
message="资源预览失败"
description={errorDescription}
style={{ marginBottom: 8 }}
/>
) : null}
{type === 'image' ? (
<Image
src={resolvedUrl}
onError={() => setLoadFailed(true)}
style={{
width: '100%',
maxHeight: height,
objectFit: 'contain',
borderRadius: 12,
background: '#f8fafc',
border: '1px solid #f1f5f9',
}}
fallback=""
preview={!loadFailed}
/>
) : (
<video
src={resolvedUrl}
controls
preload="metadata"
onError={() => setLoadFailed(true)}
style={{
width: '100%',
maxHeight: height,
borderRadius: 12,
background: '#0f172a',
border: '1px solid #f1f5f9',
}}
/>
)}
{tools}
</div>
);
};
export default MediaPreview;
@@ -0,0 +1,105 @@
import React from 'react';
import { Tag, Tooltip } from 'antd';
interface LabelMeta {
text: string;
color?: string;
}
const STATUS_LABELS: Record<string, LabelMeta> = {
// 通用任务状态
pending: { text: '待处理', color: 'default' },
waiting_user: { text: '等待用户操作', color: 'processing' },
processing: { text: '处理中', color: 'warning' },
completed: { text: '已完成', color: 'success' },
failed: { text: '失败', color: 'error' },
cancelled: { text: '已取消', color: 'default' },
canceled: { text: '已取消', color: 'default' },
timeout: { text: '已超时', color: 'error' },
queued: { text: '已入队', color: 'processing' },
preparing: { text: '准备中', color: 'processing' },
// 生成任务 pipeline / download stage
creating_provider_task: { text: '创建远端任务', color: 'processing' },
waiting_remote: { text: '等待远端结果', color: 'processing' },
polling: { text: '轮询远端结果', color: 'processing' },
result_ready: { text: '结果已就绪', color: 'success' },
downloading: { text: '下载中', color: 'processing' },
done: { text: '已完成', color: 'success' },
download_failed: { text: '下载失败', color: 'error' },
retry_waiting: { text: '等待重试', color: 'orange' },
// 拆镜总任务状态
pending_analysis: { text: '等待分析', color: 'default' },
analyzing: { text: '分析中', color: 'processing' },
analysis_completed: { text: '分析完成', color: 'success' },
analysis_failed: { text: '分析失败', color: 'error' },
splitting: { text: '拆镜中', color: 'warning' },
split_completed: { text: '拆镜完成', color: 'success' },
partial_failed: { text: '部分失败', color: 'orange' },
// 拆镜片段状态
none: { text: '未拆镜', color: 'default' },
not_required: { text: '无需处理', color: 'default' },
not_started: { text: '未开始', color: 'default' },
project_created: { text: '已创建项目', color: 'processing' },
// 布尔/兜底类
true: { text: '是', color: 'success' },
false: { text: '否', color: 'default' },
};
const STEP_LABELS: Record<string, string> = {
material_input: '素材输入',
image_prompt_optimize: '图片 AI 提词',
image_generate: '图片生成',
video_prompt_optimize: '视频 AI 提词',
video_generate: '视频生成',
};
const MODULE_LABELS: Record<string, string> = {
hot_opening_replicate: '爆款开头复刻',
shot_replicate: '拆镜复刻',
};
const SOURCE_MODE_LABELS: Record<string, LabelMeta> = {
ai_suggestion: { text: 'AI 建议', color: 'purple' },
custom: { text: '自定义', color: 'cyan' },
original: { text: '原视频', color: 'blue' },
};
export function getModuleLabel(value?: string | null): string {
if (!value) return '-';
return MODULE_LABELS[value] || value;
}
export function getStepCodeLabel(value?: string | null): string {
if (!value) return '-';
return STEP_LABELS[value] || value;
}
export function getStatusLabel(value?: string | null): string {
if (!value) return '-';
return STATUS_LABELS[value]?.text || STEP_LABELS[value] || MODULE_LABELS[value] || SOURCE_MODE_LABELS[value]?.text || value;
}
export function getStatusColor(value?: string | null): string {
if (!value) return 'default';
return STATUS_LABELS[value]?.color || SOURCE_MODE_LABELS[value]?.color || 'blue';
}
interface StatusTagProps {
status?: string | boolean | null;
tooltipRaw?: boolean;
}
const StatusTag: React.FC<StatusTagProps> = ({ status, tooltipRaw = true }) => {
if (status === undefined || status === null || status === '') return <Tag>-</Tag>;
const raw = String(status);
const label = getStatusLabel(raw);
const tag = <Tag color={getStatusColor(raw)}>{label}</Tag>;
if (!tooltipRaw || label === raw) return tag;
return <Tooltip title={raw}>{tag}</Tooltip>;
};
export default StatusTag;
@@ -0,0 +1,314 @@
import React from 'react';
import { Card, Collapse, Descriptions, Empty, Space, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { JsonBlock } from './JsonCollapse';
const FIELD_LABELS: Record<string, string> = {
schema_version: 'Schema 版本',
generation_type: '生成类型',
business_type: '业务类型',
usage: '用途',
product_type: '产品类型',
target_audience: '目标受众',
style: '风格',
aspect_ratio: '画面比例',
resolution: '分辨率',
duration: '时长',
fps: '帧率',
final_prompt: '最终提示词',
prompt: '提示词',
description: '说明',
content: '内容',
time: '时间',
time_range: '时间段',
start_time: '开始时间',
end_time: '结束时间',
stage: '阶段',
scene: '场景',
action: '动作',
camera: '镜头',
shot: '镜头',
lens: '镜头',
subtitle: '字幕',
voiceover: '口播',
audio: '音频',
rhythm: '节奏',
notes: '备注',
};
const SECTION_LABELS: Record<string, string> = {
basic: '基础信息',
basic_info: '基础信息',
material: '素材理解',
material_understanding: '素材理解',
business: '业务属性',
business_info: '业务属性',
visual: '画面与风格',
visual_style: '画面与风格',
time_plan: '时间规划',
timeline: '时间规划',
scene_timeline: '场景时间线',
action_flow: '动作流程',
motion_flow: '动作流程',
character_action_flow: '角色动作流程',
camera_flow: '镜头流程',
shot_flow: '镜头流程',
lens_flow: '镜头流程',
subtitle: '字幕',
subtitles: '字幕',
voiceover: '口播',
audio: '音频',
rhythm: '节奏',
compliance: '合规控制',
final_prompt: '最终提示词',
};
const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
const labelOf = (key: string): string => SECTION_LABELS[key] || FIELD_LABELS[key] || key;
const isLongText = (value: unknown): boolean => typeof value === 'string' && value.length > 80;
const EMPTY_TEXTS = new Set(['', '无', 'null', 'None', 'none', '未提及', '不适用']);
const PLACEHOLDER_FLOW_TEXTS = new Set([
'展示主体动作、核心卖点或主要视觉内容',
'展示主要动作、核心卖点或主要视觉内容',
'展示核心卖点或主要视觉内容',
'展示主体动作',
'无',
]);
const ACTION_CONTENT_KEYS = ['动作内容', '动作', '动作说明', '内容', '说明', '主体动作', '动作变化'];
const CAMERA_CONTENT_KEYS = ['镜头内容', '镜头', '镜头说明', '运镜', '运镜说明', '内容', '说明'];
const toText = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const isEmptyText = (value: unknown): boolean => EMPTY_TEXTS.has(toText(value));
const isActionFlowSection = (sectionKey: string): boolean => {
const lower = sectionKey.toLowerCase();
return lower.includes('action') || lower.includes('motion') || lower.includes('动作');
};
const isCameraFlowSection = (sectionKey: string): boolean => {
const lower = sectionKey.toLowerCase();
return lower.includes('camera') || lower.includes('shot') || lower.includes('lens') || lower.includes('镜头');
};
const isTimePlanSection = (sectionKey: string): boolean => {
const lower = sectionKey.toLowerCase();
return lower.includes('time') || lower.includes('timeline') || lower.includes('时间规划') || lower.includes('动态时间规划');
};
const pickFirstContent = (item: Record<string, unknown>, keys: string[]): string => {
for (const key of keys) {
if (key in item && !isEmptyText(item[key])) return toText(item[key]);
}
return '';
};
const normalizeFlowContent = (baseContent: string, extras: string[]): string => {
const cleanBase = baseContent.trim();
const uniqueExtras = extras.filter((item, index) => item && extras.indexOf(item) === index);
if (uniqueExtras.length && (!cleanBase || PLACEHOLDER_FLOW_TEXTS.has(cleanBase))) {
return uniqueExtras.join('');
}
const parts = cleanBase && !EMPTY_TEXTS.has(cleanBase) ? [cleanBase] : [];
uniqueExtras.forEach((item) => {
if (item && !parts.includes(item)) parts.push(item);
});
return parts.join('') || '-';
};
const normalizeFlowItemForDisplay = (
item: Record<string, unknown>,
contentKey: '动作内容' | '镜头内容',
contentKeys: string[],
): Record<string, unknown> => {
const allowed = new Set(['时间段', contentKey, ...contentKeys]);
const extras: string[] = [];
Object.entries(item).forEach(([field, value]) => {
if (allowed.has(field)) return;
const fieldText = toText(field);
const valueText = toText(value);
if (fieldText && !EMPTY_TEXTS.has(fieldText)) extras.push(fieldText);
if (valueText && !EMPTY_TEXTS.has(valueText) && valueText !== fieldText) extras.push(valueText);
});
return {
时间段: toText(item['时间段']) || '-',
[contentKey]: normalizeFlowContent(pickFirstContent(item, contentKeys), extras),
};
};
const normalizeRecordForSection = (sectionKey: string, item: Record<string, unknown>): Record<string, unknown> => {
if (isActionFlowSection(sectionKey)) {
return normalizeFlowItemForDisplay(item, '动作内容', ACTION_CONTENT_KEYS);
}
if (isCameraFlowSection(sectionKey)) {
return normalizeFlowItemForDisplay(item, '镜头内容', CAMERA_CONTENT_KEYS);
}
if (isTimePlanSection(sectionKey)) {
return {
时间段: toText(item['时间段']) || '-',
阶段: toText(item['阶段']) || '-',
说明: toText(item['说明']) || '-',
};
}
return item;
};
const renderValue = (value: unknown): React.ReactNode => {
if (value === null || value === undefined || value === '') return <Typography.Text type="secondary">-</Typography.Text>;
if (typeof value === 'boolean') return value ? '是' : '否';
if (typeof value === 'number') return value;
if (typeof value === 'string') {
return <Typography.Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>{value}</Typography.Paragraph>;
}
return <JsonBlock value={value} maxHeight={220} />;
};
const getArrayMode = (key: string): 'card' | 'table' => {
const lower = key.toLowerCase();
if (
lower.includes('action') ||
lower.includes('motion') ||
lower.includes('camera') ||
lower.includes('shot') ||
lower.includes('lens') ||
lower.includes('flow') ||
lower.includes('动作') ||
lower.includes('镜头')
) {
return 'card';
}
return 'table';
};
const renderCardArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{items.map((item, index) => {
const displayItem = normalizeRecordForSection(sectionKey, item);
return (
<Card
key={`${sectionKey}-${index}`}
size="small"
title={`${labelOf(sectionKey)} ${index + 1}`}
styles={{ body: { padding: 12 } }}
>
<Descriptions size="small" column={1} bordered>
{Object.entries(displayItem).map(([field, value]) => (
<Descriptions.Item key={field} label={labelOf(field)}>
{renderValue(value)}
</Descriptions.Item>
))}
</Descriptions>
</Card>
);
})}
</Space>
);
const renderTableArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => {
const displayItems = items.map(item => normalizeRecordForSection(sectionKey, item));
const fields = Array.from(new Set(displayItems.flatMap(item => Object.keys(item))));
const columns: ColumnsType<Record<string, unknown>> = fields.map(field => ({
title: labelOf(field),
dataIndex: field,
key: field,
width: isLongText(displayItems.find(item => item[field])?.[field]) ? 280 : 160,
render: (value: unknown) => renderValue(value),
}));
return (
<Table
size="small"
rowKey={(_, index) => `${sectionKey}-${index}`}
columns={columns}
dataSource={displayItems}
pagination={false}
scroll={{ x: Math.max(900, fields.length * 180) }}
tableLayout="fixed"
/>
);
};
const renderArray = (sectionKey: string, value: unknown[]): React.ReactNode => {
if (!value.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />;
if (value.every(isRecord)) {
return getArrayMode(sectionKey) === 'card'
? renderCardArray(sectionKey, value)
: renderTableArray(sectionKey, value);
}
return <JsonBlock value={value} maxHeight={260} />;
};
const renderRecord = (value: Record<string, unknown>): React.ReactNode => (
<Descriptions size="small" column={2} bordered>
{Object.entries(value).map(([field, fieldValue]) => (
<Descriptions.Item key={field} label={labelOf(field)} span={Array.isArray(fieldValue) || isRecord(fieldValue) || isLongText(fieldValue) ? 2 : 1}>
{Array.isArray(fieldValue)
? renderArray(field, fieldValue)
: isRecord(fieldValue)
? renderRecord(fieldValue)
: renderValue(fieldValue)}
</Descriptions.Item>
))}
</Descriptions>
);
const renderSection = (key: string, value: unknown): React.ReactNode => {
if (Array.isArray(value)) return renderArray(key, value);
if (isRecord(value)) return renderRecord(value);
return renderValue(value);
};
interface VideoPromptSchemaViewerProps {
schema?: Record<string, any> | null;
finalPrompt?: string | null;
}
const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({ schema, finalPrompt }) => {
const hasSchema = !!schema && Object.keys(schema).length > 0;
if (!hasSchema && !finalPrompt) {
return <Empty description="暂无视频提词 schema" />;
}
const schemaItems = hasSchema
? Object.entries(schema || {}).map(([key, value]) => ({
key,
label: labelOf(key),
children: renderSection(key, value),
}))
: [];
const defaultKeys = finalPrompt ? ['final_prompt'] : [];
return (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{finalPrompt ? (
<Card size="small" title="最终视频提示词">
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{finalPrompt}</Typography.Paragraph>
</Card>
) : null}
{schemaItems.length ? (
<Collapse
size="small"
defaultActiveKey={defaultKeys}
items={schemaItems}
/>
) : null}
</Space>
);
};
export default VideoPromptSchemaViewer;
+239
View File
@@ -342,3 +342,242 @@ export interface GenerationAITaskQueryParams {
userName?: string; userName?: string;
} }
// ── Admin Replication Readonly Types ──────────────────────────────────────
export type ModuleReplicationStatus =
| 'pending'
| 'waiting_user'
| 'processing'
| 'completed'
| 'failed'
| 'cancelled'
| string;
export interface ReplicationStepOut {
id: string;
projectId: string;
module: string;
stepIndex: number;
stepCode: string;
status: string;
version: number;
isCurrent: boolean;
parentStepId?: string | null;
sourceStepId?: string | null;
chatTaskId?: string | null;
input?: Record<string, any> | null;
output?: Record<string, any> | null;
errorMessage?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
completedAt?: string | null;
}
export interface ReplicationMaterialOut {
materialStepId?: string | null;
materialVideoUrl?: string | null;
materialImageUrl?: string | null;
sourceProjectName?: string | null;
targetProjectName?: string | null;
coreContentPoint?: string | null;
}
export interface ReplicationImageGenerationOut {
promptStepId?: string | null;
generateStepId?: string | null;
prompt?: string | null;
engineId?: string | null;
engineName?: string | null;
params?: Record<string, any> | null;
chatTaskId?: string | null;
status?: string | null;
resultImageUrl?: string | null;
errorMessage?: string | null;
}
export interface ReplicationVideoGenerationOut {
promptStepId?: string | null;
generateStepId?: string | null;
promptSchema?: Record<string, any> | null;
finalPrompt?: string | null;
promptParams?: Record<string, any> | null;
engineId?: string | null;
engineName?: string | null;
params?: Record<string, any> | null;
chatTaskId?: string | null;
status?: string | null;
resultVideoUrl?: string | null;
resultVideoCoverUrl?: string | null;
errorMessage?: string | null;
}
export interface ReplicationProjectDetailOut {
id: string;
projectId: string;
userId?: string | null;
userName?: string | null;
module: string;
title?: string | null;
status: ModuleReplicationStatus;
currentStepCode?: string | null;
finalImageUrl?: string | null;
finalVideoUrl?: string | null;
finalVideoCoverUrl?: string | null;
errorMessage?: string | null;
material: ReplicationMaterialOut;
imageGeneration: ReplicationImageGenerationOut;
videoGeneration: ReplicationVideoGenerationOut;
steps: ReplicationStepOut[];
createdAt?: string | null;
updatedAt?: string | null;
completedAt?: string | null;
}
export interface HotOpeningTaskListItemOut {
id: string;
projectId: string;
userId?: string | null;
userName?: string | null;
module: string;
title?: string | null;
status: ModuleReplicationStatus;
currentStepCode?: string | null;
sourceProjectName?: string | null;
targetProjectName?: string | null;
coreContentPoint?: string | null;
finalImageUrl?: string | null;
finalVideoUrl?: string | null;
finalVideoCoverUrl?: string | null;
errorMessage?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
completedAt?: string | null;
}
export interface HotOpeningTaskListOut {
total: number;
items: HotOpeningTaskListItemOut[];
}
export interface AdminHotOpeningTaskQueryParams {
status?: string;
keyword?: string;
userId?: string;
userName?: string;
createdStart?: string;
createdEnd?: string;
page?: number;
pageSize?: number;
}
export interface ShotTaskSetOut {
id: string;
userId?: string | null;
userName?: string | null;
title?: string | null;
videoUrl: string;
videoDurationSeconds: number;
status: string;
analysisStatus: string;
splitStatus: string;
originalVideoContent?: string | null;
originalVideoCategory?: string | null;
originalVideoAudience?: string | null;
segmentCount: number;
completedSegmentCount: number;
failedSegmentCount: number;
analysisErrorMessage?: string | null;
splitErrorMessage?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface ShotAiSuggestionOut {
index: number;
startSecond: number;
endSecond: number;
durationSeconds: number;
timeNode: string;
content?: string | null;
category?: string | null;
audience?: string | null;
}
export interface ShotTaskSetDetailOut extends ShotTaskSetOut {
aiSuggestions: ShotAiSuggestionOut[];
analysisResultJson?: Record<string, any> | any[] | null;
}
export interface ShotTaskSetListOut {
total: number;
page: number;
pageSize: number;
items: ShotTaskSetOut[];
}
export interface AdminShotTaskSetQueryParams {
status?: string;
analysisStatus?: string;
splitStatus?: string;
keyword?: string;
userId?: string;
userName?: string;
createdStart?: string;
createdEnd?: string;
page?: number;
pageSize?: number;
}
export interface ShotSegmentOut {
id: string;
taskSetId: string;
segmentIndex: number;
segmentName?: string | null;
sourceMode: string;
startSecond: number;
endSecond: number;
durationSeconds: number;
timeNode: string;
splitStatus: string;
analysisStatus: string;
replicateStatus: string;
segmentVideoUrl?: string | null;
originalVideoContent?: string | null;
originalVideoCategory?: string | null;
originalVideoAudience?: string | null;
segmentContent?: string | null;
segmentCategory?: string | null;
segmentAudience?: string | null;
splitRetryCount: number;
splitLastError?: string | null;
analysisErrorMessage?: string | null;
moduleProjectId?: string | null;
moduleProjectTitle?: string | null;
moduleProjectStatus?: string | null;
moduleProjectCurrentStepCode?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface ShotSegmentDetailOut extends ShotSegmentOut {
analysisJson?: Record<string, any> | any[] | null;
aiSuggestionJson?: Record<string, any> | any[] | null;
}
export interface ShotSegmentListOut {
total: number;
page: number;
pageSize: number;
items: ShotSegmentOut[];
}
export interface AdminShotSegmentQueryParams {
sourceMode?: string;
splitStatus?: string;
analysisStatus?: string;
replicateStatus?: string;
page?: number;
pageSize?: number;
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminsettings.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts"],"version":"6.0.3"} {"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts"],"version":"6.0.3"}
@@ -8,122 +8,262 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '287c6c064c5d' revision: str = "287c6c064c5d"
down_revision: Union[str, None] = '9216bca75ccf' down_revision: Union[str, None] = "9216bca75ccf"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def _inspector():
return inspect(op.get_bind())
def _table_exists(table_name: str) -> bool:
return table_name in _inspector().get_table_names(schema="public")
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
columns = _inspector().get_columns(table_name, schema="public")
return any(column["name"] == column_name for column in columns)
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
indexes = _inspector().get_indexes(table_name, schema="public")
return any(index["name"] == index_name for index in indexes)
def _create_pre_test_template_table() -> None:
op.create_table(
"pre_test_template",
sa.Column("id", sa.String(length=32), nullable=False, comment="主键"),
sa.Column("name", sa.String(length=128), nullable=False, comment="模板名称"),
sa.Column("user_id", sa.String(length=32), nullable=False, comment="用户id"),
sa.Column("note", sa.Text(), nullable=True, comment="模板备注"),
sa.Column("platform", sa.String(length=32), nullable=True, comment="投放平台(AD/QIANCHUAN/LOCAL"),
sa.Column("external_action", sa.String(length=64), nullable=True, comment="转化目标"),
sa.Column("cpa_bid", sa.Float(), nullable=True, comment="目标转化成本:[1, 10000]"),
sa.Column("audience_gender", sa.String(length=16), nullable=True, comment="性别(ALL/MALE/FEMALE"),
sa.Column("audience_age", sa.Text(), nullable=True, comment="受众年龄,JSON数组, 格式:[ALL,18-23, 24-30, 31-40, 41-49, 50+]"),
sa.Column("audience_region", sa.Text(), nullable=True, comment="受众地区,JSON数组(二级行政区域code)"),
sa.Column("audience_network", sa.Text(), nullable=True, comment="网络类型,JSON数组, 格式:[ALL,5G,4G,3G,2G,WIFI]"),
sa.Column("cus_name", sa.String(length=256), nullable=True, comment="客户主体名称"),
sa.Column("pricing_type", sa.String(length=16), nullable=True, comment="出价类型(OCPC/CPA/OCPM"),
sa.Column("cost_cap", sa.Boolean(), nullable=True, comment="是否最优成本出价(仅AD支持)"),
sa.Column("target_cost", sa.Boolean(), nullable=True, comment="是否稳定成本出价(仅AD支持)"),
sa.Column("nobid", sa.Boolean(), nullable=True, comment="是否最大转化出价(仅AD支持)"),
sa.Column("cpc_bid", sa.Float(), nullable=True, comment="目标点击成本:[1, 10000]"),
sa.Column("budget", sa.Float(), nullable=True, comment="预算金额:[1, 10000]"),
sa.Column("is_default", sa.Boolean(), nullable=True, comment="是否默认模板"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id", name="pre_test_template_pkey"),
)
if not _index_exists("pre_test_template", "ix_pre_test_template_user_id"):
op.create_index(
"ix_pre_test_template_user_id",
"pre_test_template",
["user_id"],
unique=False,
)
def _upgrade_existing_pre_test_template_table() -> None:
if not _column_exists("pre_test_template", "note"):
op.add_column(
"pre_test_template",
sa.Column("note", sa.Text(), nullable=True, comment="模板备注"),
)
if _column_exists("pre_test_template", "platform"):
op.alter_column(
"pre_test_template",
"platform",
existing_type=sa.VARCHAR(length=32),
nullable=True,
existing_comment="投放平台(AD/QIANCHUAN/LOCAL",
)
if _column_exists("pre_test_template", "external_action"):
op.alter_column(
"pre_test_template",
"external_action",
existing_type=sa.VARCHAR(length=64),
nullable=True,
existing_comment="转化目标",
)
if _column_exists("pre_test_template", "audience_gender"):
op.alter_column(
"pre_test_template",
"audience_gender",
existing_type=sa.VARCHAR(length=16),
nullable=True,
existing_comment="性别(ALL/MALE/FEMALE",
)
if _column_exists("pre_test_template", "audience_age"):
op.alter_column(
"pre_test_template",
"audience_age",
existing_type=sa.TEXT(),
comment="受众年龄,JSON数组, 格式:[ALL,18-23, 24-30, 31-40, 41-49, 50+]",
existing_comment="受众年龄,JSON数组",
existing_nullable=True,
)
if _column_exists("pre_test_template", "audience_network"):
op.alter_column(
"pre_test_template",
"audience_network",
existing_type=sa.TEXT(),
comment="网络类型,JSON数组, 格式:[ALL,5G,4G,3G,2G,WIFI]",
existing_comment="网络类型,JSON数组",
existing_nullable=True,
)
if _column_exists("pre_test_template", "pricing_type"):
op.alter_column(
"pre_test_template",
"pricing_type",
existing_type=sa.VARCHAR(length=16),
nullable=True,
existing_comment="出价类型(OCPC/CPA/OCPM",
)
if _column_exists("pre_test_template", "cost_cap"):
op.alter_column(
"pre_test_template",
"cost_cap",
existing_type=sa.BOOLEAN(),
nullable=True,
existing_comment="是否最优成本出价(仅AD支持)",
)
if _column_exists("pre_test_template", "target_cost"):
op.alter_column(
"pre_test_template",
"target_cost",
existing_type=sa.BOOLEAN(),
nullable=True,
existing_comment="是否稳定成本出价(仅AD支持)",
)
if _column_exists("pre_test_template", "nobid"):
op.alter_column(
"pre_test_template",
"nobid",
existing_type=sa.BOOLEAN(),
nullable=True,
existing_comment="是否最大转化出价(仅AD支持)",
)
if _column_exists("pre_test_template", "is_default"):
op.alter_column(
"pre_test_template",
"is_default",
existing_type=sa.BOOLEAN(),
nullable=True,
existing_comment="是否默认模板",
)
if _column_exists("pre_test_template", "status"):
op.drop_column("pre_test_template", "status")
if _column_exists("pre_test_template", "description"):
op.drop_column("pre_test_template", "description")
if not _index_exists("pre_test_template", "ix_pre_test_template_user_id"):
op.create_index(
"ix_pre_test_template_user_id",
"pre_test_template",
["user_id"],
unique=False,
)
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # pre_test_template 是这版迁移缺失的核心表。
op.add_column('pre_test_template', sa.Column('note', sa.Text(), nullable=True, comment='模板备注')) # 如果不存在,直接按当前模型源码创建完整表。
op.alter_column('pre_test_template', 'platform', # 如果已存在,则按原迁移逻辑补 note、调整 nullable/comment、删除旧字段。
existing_type=sa.VARCHAR(length=32), if not _table_exists("pre_test_template"):
nullable=True, _create_pre_test_template_table()
existing_comment='投放平台(AD/QIANCHUAN/LOCAL') else:
op.alter_column('pre_test_template', 'external_action', _upgrade_existing_pre_test_template_table()
existing_type=sa.VARCHAR(length=64),
nullable=True, # resources_material 追加字段,按源码迁移逻辑保留,同时增加字段存在判断,避免重复执行报错。
existing_comment='转化目标') if _table_exists("resources_material"):
op.alter_column('pre_test_template', 'audience_gender', if not _column_exists("resources_material", "task_id"):
existing_type=sa.VARCHAR(length=16), op.add_column(
nullable=True, "resources_material",
existing_comment='性别(ALL/MALE/FEMALE') sa.Column("task_id", sa.String(length=32), nullable=True, comment="前测任务id"),
op.alter_column('pre_test_template', 'audience_age', )
existing_type=sa.TEXT(),
comment='受众年龄,JSON数组, 格式:[ALL,18-23, 24-30, 31-40, 41-49, 50+]', if not _column_exists("resources_material", "note"):
existing_comment='受众年龄,JSON数组', op.add_column(
existing_nullable=True) "resources_material",
op.alter_column('pre_test_template', 'audience_network', sa.Column("note", sa.Text(), nullable=True, comment="前测失败备注或者其他备注"),
existing_type=sa.TEXT(), )
comment='网络类型,JSON数组, 格式:[ALL,5G,4G,3G,2G,WIFI]',
existing_comment='网络类型,JSON数组', if not _column_exists("resources_material", "status"):
existing_nullable=True) op.add_column(
op.alter_column('pre_test_template', 'pricing_type', "resources_material",
existing_type=sa.VARCHAR(length=16), sa.Column("status", sa.String(length=16), nullable=True, comment="前测状态(FAILED/PENDING/SUCCESS"),
nullable=True, )
existing_comment='出价类型(OCPC/CPA/OCPM')
op.alter_column('pre_test_template', 'cost_cap', if not _column_exists("resources_material", "pre_result"):
existing_type=sa.BOOLEAN(), op.add_column(
nullable=True, "resources_material",
existing_comment='是否最优成本出价(仅AD支持)') sa.Column("pre_result", sa.Text(), nullable=True, comment="前测结果,JSON数组对象"),
op.alter_column('pre_test_template', 'target_cost', )
existing_type=sa.BOOLEAN(),
nullable=True, if not _column_exists("resources_material", "pre_test_template_id"):
existing_comment='是否稳定成本出价(仅AD支持)') op.add_column(
op.alter_column('pre_test_template', 'nobid', "resources_material",
existing_type=sa.BOOLEAN(), sa.Column("pre_test_template_id", sa.String(length=32), nullable=True, comment="前测模板id"),
nullable=True, )
existing_comment='是否最大转化出价(仅AD支持)')
op.alter_column('pre_test_template', 'is_default', if not _index_exists("resources_material", "ix_resources_material_task_id"):
existing_type=sa.BOOLEAN(), op.create_index(
nullable=True, "ix_resources_material_task_id",
existing_comment='是否默认模板') "resources_material",
op.drop_column('pre_test_template', 'status') ["task_id"],
op.drop_column('pre_test_template', 'description') unique=False,
op.add_column('resources_material', sa.Column('task_id', sa.String(length=32), nullable=True, comment='前测任务id')) )
op.add_column('resources_material', sa.Column('note', sa.Text(), nullable=True, comment='前测失败备注或者其他备注'))
op.add_column('resources_material', sa.Column('status', sa.String(length=16), nullable=True, comment='前测状态(FAILED/PENDING/SUCCESS'))
op.add_column('resources_material', sa.Column('pre_result', sa.Text(), nullable=True, comment='前测结果,JSON数组对象'))
op.add_column('resources_material', sa.Column('pre_test_template_id', sa.String(length=32), nullable=True, comment='前测模板id'))
op.create_index(op.f('ix_resources_material_task_id'), 'resources_material', ['task_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### if _table_exists("resources_material"):
op.drop_index(op.f('ix_resources_material_task_id'), table_name='resources_material') if _index_exists("resources_material", "ix_resources_material_task_id"):
op.drop_column('resources_material', 'pre_test_template_id') op.drop_index("ix_resources_material_task_id", table_name="resources_material")
op.drop_column('resources_material', 'pre_result')
op.drop_column('resources_material', 'status') if _column_exists("resources_material", "pre_test_template_id"):
op.drop_column('resources_material', 'note') op.drop_column("resources_material", "pre_test_template_id")
op.drop_column('resources_material', 'task_id')
op.add_column('pre_test_template', sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True, comment='模板描述')) if _column_exists("resources_material", "pre_result"):
op.add_column('pre_test_template', sa.Column('status', sa.INTEGER(), autoincrement=False, nullable=False, comment='状态(0=禁用,1=启用)')) op.drop_column("resources_material", "pre_result")
op.alter_column('pre_test_template', 'is_default',
existing_type=sa.BOOLEAN(), if _column_exists("resources_material", "status"):
nullable=False, op.drop_column("resources_material", "status")
existing_comment='是否默认模板')
op.alter_column('pre_test_template', 'nobid', if _column_exists("resources_material", "note"):
existing_type=sa.BOOLEAN(), op.drop_column("resources_material", "note")
nullable=False,
existing_comment='是否最大转化出价(仅AD支持)') if _column_exists("resources_material", "task_id"):
op.alter_column('pre_test_template', 'target_cost', op.drop_column("resources_material", "task_id")
existing_type=sa.BOOLEAN(),
nullable=False, # 这一版迁移负责新增 pre_test_template 表,所以回滚时删除该表。
existing_comment='是否稳定成本出价(仅AD支持)') if _table_exists("pre_test_template"):
op.alter_column('pre_test_template', 'cost_cap', if _index_exists("pre_test_template", "ix_pre_test_template_user_id"):
existing_type=sa.BOOLEAN(), op.drop_index("ix_pre_test_template_user_id", table_name="pre_test_template")
nullable=False,
existing_comment='是否最优成本出价(仅AD支持)') op.drop_table("pre_test_template")
op.alter_column('pre_test_template', 'pricing_type',
existing_type=sa.VARCHAR(length=16),
nullable=False,
existing_comment='出价类型(OCPC/CPA/OCPM')
op.alter_column('pre_test_template', 'audience_network',
existing_type=sa.TEXT(),
comment='网络类型,JSON数组',
existing_comment='网络类型,JSON数组, 格式:[ALL,5G,4G,3G,2G,WIFI]',
existing_nullable=True)
op.alter_column('pre_test_template', 'audience_age',
existing_type=sa.TEXT(),
comment='受众年龄,JSON数组',
existing_comment='受众年龄,JSON数组, 格式:[ALL,18-23, 24-30, 31-40, 41-49, 50+]',
existing_nullable=True)
op.alter_column('pre_test_template', 'audience_gender',
existing_type=sa.VARCHAR(length=16),
nullable=False,
existing_comment='性别(ALL/MALE/FEMALE')
op.alter_column('pre_test_template', 'external_action',
existing_type=sa.VARCHAR(length=64),
nullable=False,
existing_comment='转化目标')
op.alter_column('pre_test_template', 'platform',
existing_type=sa.VARCHAR(length=32),
nullable=False,
existing_comment='投放平台(AD/QIANCHUAN/LOCAL')
op.drop_column('pre_test_template', 'note')
# ### end Alembic commands ###
+2
View File
@@ -21,6 +21,7 @@ from app.api.v1.test import router as test_router
from app.api.v1.user_oauth import router as user_oauth_router from app.api.v1.user_oauth import router as user_oauth_router
from app.api.v1.user_oauth_app import router as user_oauth_app_router from app.api.v1.user_oauth_app import router as user_oauth_app_router
from app.api.v1.upload_material import router as upload_material_router from app.api.v1.upload_material import router as upload_material_router
from app.api.v1.pre_test_template import router as pre_test_template_router
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth_router) api_router.include_router(auth_router)
@@ -44,3 +45,4 @@ api_router.include_router(test_router)
api_router.include_router(user_oauth_router) api_router.include_router(user_oauth_router)
api_router.include_router(user_oauth_app_router) api_router.include_router(user_oauth_app_router)
api_router.include_router(upload_material_router) api_router.include_router(upload_material_router)
api_router.include_router(pre_test_template_router)
+2 -2
View File
@@ -1081,8 +1081,8 @@ async def update_system_config(
config = result.scalar_one_or_none() config = result.scalar_one_or_none()
if not config: if not config:
raise HTTPException(status_code=404, detail="配置不存在") raise HTTPException(status_code=404, detail="配置不存在")
config.value = req.value config.value = str(req.value)
await db.flush() await db.commit()
return config return config
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace from types import SimpleNamespace
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
@@ -8,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db
from app.models.user import User from app.models.user import User
from app.enums.common import ModuleProjectStatusEnum
from app.enums.hot_opening_replicate import HotOpeningStepCodeEnum, ModuleCodeEnum from app.enums.hot_opening_replicate import HotOpeningStepCodeEnum, ModuleCodeEnum
from app.schemas.hot_opening_replicate import ( from app.schemas.hot_opening_replicate import (
HotOpeningActionOut, HotOpeningActionOut,
@@ -195,7 +197,7 @@ async def _mark_dispatch_failed_and_raise(
"/spec", "/spec",
response_model=HotOpeningSpecOut, response_model=HotOpeningSpecOut,
summary="查询爆款开头复刻模块状态枚举和步骤 JSON 结构说明", summary="查询爆款开头复刻模块状态枚举和步骤 JSON 结构说明",
description="返回总任务状态、子任务状态、5个固定步骤编码以及每个步骤 input_json/output_json 的统一结构示例,方便前端和排查人员对照", description="保留给调试和前端兜底读取。原业务接口已经在 Path、Query、Body 和响应模型字段上直接展示参数说明与枚举值",
) )
async def get_spec(): async def get_spec():
return HotOpeningSpecOut() return HotOpeningSpecOut()
@@ -239,13 +241,38 @@ async def create_task(
description="分页查询爆款开头复刻总任务项目列表。普通用户只能查看自己的项目,管理员可查看全部。", description="分页查询爆款开头复刻总任务项目列表。普通用户只能查看自己的项目,管理员可查看全部。",
) )
async def list_tasks( async def list_tasks(
status: str | None = Query(None, description="总任务状态筛选,例如 waiting_userprocessingcompletedfailed;为空不过滤"), status: ModuleProjectStatusEnum | None = Query(None, description="总任务状态筛选pending=已创建waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消;为空不过滤"),
keyword: str | None = Query(None, description="关键词搜索:项目ID、标题、生成项目名称、素材项目名称、核心内容点;普通用户只在自己的数据内搜索"),
user_id: str | None = Query(None, description="管理员专用:按用户ID筛选;普通用户不可使用"),
user_name: str | None = Query(None, description="管理员专用:按用户名模糊筛选;普通用户不可使用"),
created_start: datetime | None = Query(None, description="管理员专用:创建时间开始,ISO datetime;普通用户不可使用"),
created_end: datetime | None = Query(None, description="管理员专用:创建时间结束,ISO datetime;普通用户不可使用"),
page: int = Query(1, ge=1, description="分页页码,从1开始"), page: int = Query(1, ge=1, description="分页页码,从1开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"), page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
return await list_hot_opening_projects(db, current_user=current_user, status=status, page=page, page_size=page_size) admin_only_params = {
"user_id": user_id,
"user_name": user_name,
"created_start": created_start,
"created_end": created_end,
}
if not _safe_user_is_admin(current_user) and any(value is not None and str(value).strip() != "" for value in admin_only_params.values()):
raise HTTPException(status_code=403, detail="当前搜索条件仅管理员可用")
return await list_hot_opening_projects(
db,
current_user=current_user,
status=status.value if status else None,
keyword=keyword,
user_id=user_id,
user_name=user_name,
created_start=created_start,
created_end=created_end,
page=page,
page_size=page_size,
)
@router.get( @router.get(
@@ -0,0 +1,358 @@
import json
from typing import Any, Optional, Dict
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.pre_test_template import PreTestTemplate
from app.utils.douyinApi import DouyinApi
from app.utils.area import parse_district_data, get_area_by_level, get_cached_area_data, fetch_and_cache_area_data
from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.schemas.pre_test_template import (
PreTestTemplateCreate,
PreTestTemplateListResponse,
PreTestTemplateOut,
PreTestTemplateUpdate,
)
from app.services.pre_test_template_service import (
create_pre_test_template,
delete_pre_test_template,
get_default_template,
get_pre_test_template,
get_pre_test_template_list,
update_pre_test_template,
)
router = APIRouter(prefix="/pre-test-template", tags=["前测模板"])
def _template_to_dict(template):
return {
"id": template.id,
"name": template.name,
"note": template.note,
"platform": template.platform,
"external_action": template.external_action,
"cpa_bid": template.cpa_bid,
"audience_gender": template.audience_gender,
"audience_age": json.loads(template.audience_age) if template.audience_age else None,
"audience_region": json.loads(template.audience_region) if template.audience_region else None,
"audience_network": json.loads(template.audience_network) if template.audience_network else None,
"cus_name": template.cus_name,
"pricing_type": template.pricing_type,
"cost_cap": template.cost_cap,
"target_cost": template.target_cost,
"nobid": template.nobid,
"cpc_bid": template.cpc_bid,
"budget": template.budget,
"is_default": template.is_default,
"user_id": template.user_id,
"created_at": template.created_at,
"updated_at": template.updated_at,
}
@router.post(
"/create",
summary="创建前测模板",
description="创建一个新的前测模板",
)
async def create_template(
req: PreTestTemplateCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
try:
#新增一个判断,如果模板名称重复,提示用户修改
result = await db.execute(
select(PreTestTemplate).where(
PreTestTemplate.name == req.name,
PreTestTemplate.user_id == current_user.id,
PreTestTemplate.deleted_at.is_(None),
)
)
existing_template = result.scalar()
if existing_template:
raise ValueError("模板名称已存在")
template = await create_pre_test_template(
user_id=current_user.id,
db=db,
name=req.name,
note=req.note,
platform=req.platform,
external_action=req.external_action,
cpa_bid=req.cpa_bid,
audience_gender=req.audience_gender,
audience_age=req.audience_age,
audience_region=req.audience_region,
audience_network=req.audience_network,
cus_name=req.cus_name,
pricing_type=req.pricing_type,
cost_cap=req.cost_cap,
target_cost=req.target_cost,
nobid=req.nobid,
cpc_bid=req.cpc_bid,
budget=req.budget,
is_default=req.is_default,
)
return {
"code": 0,
"message": "创建成功",
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"创建失败: {str(e)}",
)
@router.get(
"/list",
summary="获取前测模板列表",
description="获取当前用户的前测模板列表,支持按平台筛选和分页",
response_model=PreTestTemplateListResponse,
)
async def list_templates(
platform: Optional[str] = Query(None, description="投放平台筛选(AD/QIANCHUAN/LOCAL"),
page: int = Query(1, description="页码,默认1"),
page_size: int = Query(10, description="每页数量,默认10,最大100"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
try:
result = await get_pre_test_template_list(
user_id=current_user.id,
db=db,
platform=platform,
page=page,
page_size=page_size,
)
return {
"code": 0,
"message": "查询成功",
"data": [_template_to_dict(t) for t in result["data"]],
"pagination": {
"page": result["page"],
"page_size": result["page_size"],
"total": result["total"],
"total_pages": (result["total"] + result["page_size"] - 1) // result["page_size"],
},
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"查询失败: {str(e)}",
)
@router.get(
"/default",
summary="获取默认前测模板",
description="获取当前用户的默认前测模板",
)
async def get_default(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
try:
template = await get_default_template(current_user.id, db)
if not template:
return {
"code": 0,
"message": "未设置默认模板",
"data": None,
}
return {
"code": 0,
"message": "查询成功",
"data": _template_to_dict(template),
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"查询失败: {str(e)}",
)
@router.get(
"/select/{template_id}",
summary="获取前测模板详情",
description="根据模板id获取前测模板详情",
)
async def get_template(
template_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
try:
template = await get_pre_test_template(template_id, current_user.id, db)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在",
)
return {
"code": 0,
"message": "查询成功",
"data": _template_to_dict(template),
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"查询失败: {str(e)}",
)
@router.post(
"/update/{template_id}",
summary="更新前测模板",
description="更新指定的前测模板",
)
async def update_template(
template_id: str,
req: PreTestTemplateUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
try:
update_data = req.dict(exclude_none=True)
template = await update_pre_test_template(template_id, current_user.id, db, **update_data)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在",
)
return {
"code": 0,
"message": "更新成功",
"data": _template_to_dict(template),
}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"更新失败: {str(e)}",
)
@router.get(
"/delete/{template_id}",
summary="删除前测模板",
description="软删除指定的前测模板",
)
async def delete_template(
template_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Any:
try:
success = await delete_pre_test_template(template_id, current_user.id, db)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="模板不存在",
)
return {
"code": 0,
"message": "删除成功",
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"删除失败: {str(e)}",
)
@router.get(
"/getArea",
summary="获取行政区域信息",
description="获取指定级别的行政区域信息,支持一级、二级、三级区域,如果需要更新地区,执行:/api/pre-test-template/getArea?oauth_id=0019ecab9b8bc57d964&advertiser_id=1836693172153543",
)
async def get_template_area(
oauth_id: str = Query(None, description="授权ID选填,更新地区必填"),
advertiser_id: str = Query(default="1836693172153543", description="授权ID选填,更新地区必填"),
code: Optional[str] = Query("CN", description="行政区域编码,默认中国CN,选填"),
level: Optional[str] = Query("ONE_LEVEL", description="行政区域层级,可选值:ONE_LEVEL(获取省份)、TWO_LEVEL(市级)、THREE_LEVEL(区级)"),
parent_code: Optional[str] = Query(None, description="父级区域编码,获取二级时传一级编码,获取三级时传二级编码"),
) -> Any:
try:
# 1. 先检查缓存是否存在
area_list = get_cached_area_data()
# 2. 如果缓存不存在,调用接口获取数据并保存到缓存
if not area_list:
area_list = await fetch_and_cache_area_data(oauth_id, advertiser_id, code)
# 3. 根据 level 参数过滤区域
if level == "ONE_LEVEL":
result = get_area_by_level(area_list, "ONE_LEVEL")
elif level == "TWO_LEVEL":
result = get_area_by_level(area_list, "TWO_LEVEL", parent_code)
elif level == "THREE_LEVEL":
if not parent_code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="获取三级区域需要提供二级区域编码(parent_code)",
)
result = get_area_by_level(area_list, "THREE_LEVEL", parent_code)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"不支持的级别: {level}",
)
# 4. 转换为字典格式返回
result_dict = [area.to_dict() for area in result]
return {
"code": 0,
"message": "成功",
"data": result_dict,
}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取区域信息失败: {str(e)}",
)
+147 -66
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace from types import SimpleNamespace
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
@@ -8,7 +9,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db
from app.models.user import User from app.models.user import User
from app.enums.shot_replicate import ModuleCodeEnum, ShotReplicateStepCodeEnum from app.enums.shot_replicate import (
ModuleCodeEnum,
ShotAnalysisStatusEnum,
ShotReplicateStepCodeEnum,
ShotSegmentAnalysisStatusEnum,
ShotSegmentReplicateStatusEnum,
ShotSegmentSourceModeEnum,
ShotSplitStatusEnum,
ShotTaskSetStatusEnum,
)
from app.schemas.shot_replicate import ( from app.schemas.shot_replicate import (
ShotReplicateActionOut, ShotReplicateActionOut,
ShotReplicateDeleteOut, ShotReplicateDeleteOut,
@@ -222,6 +232,7 @@ async def _mark_dispatch_failed_and_raise(
"/spec", "/spec",
response_model=ShotReplicateSpecOut, response_model=ShotReplicateSpecOut,
summary="查询拆镜复刻模块状态枚举和步骤 JSON 结构说明", summary="查询拆镜复刻模块状态枚举和步骤 JSON 结构说明",
description="保留给调试和前端兜底读取。原业务接口已经在 Path、Query、Body 和响应模型字段上直接展示参数说明与枚举值。",
) )
async def get_spec(): async def get_spec():
return ShotReplicateSpecOut() return ShotReplicateSpecOut()
@@ -231,9 +242,14 @@ async def get_spec():
"/task-sets", "/task-sets",
response_model=ShotTaskSetDetailOut, response_model=ShotTaskSetDetailOut,
summary="创建拆镜总任务集并异步分析原视频", summary="创建拆镜总任务集并异步分析原视频",
description=(
"创建拆镜总任务集,保存原视频地址和时长,随后异步投递原视频 AI 分析任务。"
"分析完成后会写入原视频内容、分类、受众和 AI 建议拆镜时间段。"
"状态枚举直接见本接口响应字段:status、analysis_status、split_status。"
),
) )
async def create_shot_task_set( async def create_shot_task_set(
req: ShotTaskSetCreate = Body(...), req: ShotTaskSetCreate = Body(..., description="创建拆镜总任务集参数:原视频 URL、视频时长、标题和可选幂等键"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -275,22 +291,39 @@ async def create_shot_task_set(
summary="查询拆镜总任务集列表", summary="查询拆镜总任务集列表",
) )
async def list_shot_task_sets( async def list_shot_task_sets(
status: str | None = Query(None, description="总任务状态,见 ShotTaskSetStatusEnum"), status: ShotTaskSetStatusEnum | None = Query(None, description="总任务状态筛选:pending_analysis=等待分析,analyzing=分析中,analysis_completed=分析完成,analysis_failed=分析失败,splitting=拆镜中,split_completed=拆镜完成,partial_failed=部分失败,failed=失败,deleted=已软删"),
analysis_status: str | None = Query(None, description="分析状态,见 ShotAnalysisStatusEnum"), analysis_status: ShotAnalysisStatusEnum | None = Query(None, description="原视频分析状态筛选:pending=待分析,processing=分析中,completed=分析完成,failed=分析失败"),
split_status: str | None = Query(None, description="拆镜状态,见 ShotSplitStatusEnum"), split_status: ShotSplitStatusEnum | None = Query(None, description="拆镜状态筛选:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试"),
keyword: str | None = Query(None, description="标题/内容关键词"), keyword: str | None = Query(None, description="标题/原视频内容/分类/受众关键词,模糊搜索;普通用户只在自己的数据内搜索"),
page: int = Query(1, ge=1), user_id: str | None = Query(None, description="管理员专用:按用户ID筛选;普通用户不可使用"),
page_size: int = Query(20, ge=1, le=100), user_name: str | None = Query(None, description="管理员专用:按用户名模糊筛选;普通用户不可使用"),
created_start: datetime | None = Query(None, description="管理员专用:创建时间开始,ISO datetime;普通用户不可使用"),
created_end: datetime | None = Query(None, description="管理员专用:创建时间结束,ISO datetime;普通用户不可使用"),
page: int = Query(1, ge=1, description="分页页码,从1开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
admin_only_params = {
"user_id": user_id,
"user_name": user_name,
"created_start": created_start,
"created_end": created_end,
}
if not _safe_user_is_admin(current_user) and any(value is not None and str(value).strip() != "" for value in admin_only_params.values()):
raise HTTPException(status_code=403, detail="当前搜索条件仅管理员可用")
return await list_task_sets( return await list_task_sets(
db, db,
current_user=current_user, current_user=current_user,
status=status, status=status.value if status else None,
analysis_status=analysis_status, analysis_status=analysis_status.value if analysis_status else None,
split_status=split_status, split_status=split_status.value if split_status else None,
keyword=keyword, keyword=keyword,
user_id=user_id,
user_name=user_name,
created_start=created_start,
created_end=created_end,
page=page, page=page,
page_size=page_size, page_size=page_size,
) )
@@ -300,9 +333,10 @@ async def list_shot_task_sets(
"/task-sets/{task_set_id}", "/task-sets/{task_set_id}",
response_model=ShotTaskSetDetailOut, response_model=ShotTaskSetDetailOut,
summary="获取拆镜总任务集详情", summary="获取拆镜总任务集详情",
description="根据拆镜总任务集ID查询详情,包含原视频分析结果、AI 建议拆镜列表和当前总任务状态。",
) )
async def get_shot_task_set( async def get_shot_task_set(
task_set_id: str = Path(...), task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -313,10 +347,14 @@ async def get_shot_task_set(
"/task-sets/{task_set_id}/split-by-ai", "/task-sets/{task_set_id}/split-by-ai",
response_model=ShotSplitByAIOut, response_model=ShotSplitByAIOut,
summary="按 AI 建议方案异步拆镜", summary="按 AI 建议方案异步拆镜",
description=(
"基于原视频 AI 分析生成的建议时间段创建拆镜片段,并异步投递 ffmpeg 切割任务。"
"selected_indices 不传时默认按全部 AI 建议拆镜;replace_existing=true 时会软删旧 AI 建议片段后重新创建。"
),
) )
async def split_by_ai( async def split_by_ai(
task_set_id: str = Path(...), task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
req: ShotSplitByAIRequest = Body(default_factory=ShotSplitByAIRequest), req: ShotSplitByAIRequest = Body(default_factory=ShotSplitByAIRequest, description="AI 建议拆镜参数:可选择建议序号,也可选择是否覆盖旧 AI 片段"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -345,10 +383,11 @@ async def split_by_ai(
"/task-sets/{task_set_id}/split-custom", "/task-sets/{task_set_id}/split-custom",
response_model=ShotSplitCustomOut, response_model=ShotSplitCustomOut,
summary="按用户自定义开始/结束秒异步拆单条片段", summary="按用户自定义开始/结束秒异步拆单条片段",
description="按用户传入的 start_second/end_second 创建 custom 来源片段,并异步投递 ffmpeg 切割任务;切割完成后可进入复刻项目。",
) )
async def split_custom( async def split_custom(
task_set_id: str = Path(...), task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
req: ShotSplitCustomRequest = Body(...), req: ShotSplitCustomRequest = Body(..., description="自定义拆镜时间段参数,end_second 必须大于 start_second"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -376,15 +415,16 @@ async def split_custom(
"/task-sets/{task_set_id}/segments", "/task-sets/{task_set_id}/segments",
response_model=ShotSegmentListOut, response_model=ShotSegmentListOut,
summary="查询拆镜片段列表", summary="查询拆镜片段列表",
description="分页查询指定拆镜总任务集下的片段列表,可按来源、切割状态、片段分析状态和复刻状态筛选。",
) )
async def list_task_set_segments( async def list_task_set_segments(
task_set_id: str = Path(...), task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
source_mode: str | None = Query(None, description="ai_suggestion/custom"), source_mode: ShotSegmentSourceModeEnum | None = Query(None, description="片段来源:ai_suggestion=AI 建议拆镜,custom=用户自定义拆镜"),
split_status: str | None = Query(None, description="拆镜状态"), split_status: ShotSplitStatusEnum | None = Query(None, description="切割状态:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试"),
analysis_status: str | None = Query(None, description="片段分析状态"), analysis_status: ShotSegmentAnalysisStatusEnum | None = Query(None, description="片段分析状态not_required=无需单独分析,pending=等待分析,processing=分析中,completed=分析完成,failed=分析失败"),
replicate_status: str | None = Query(None, description="复刻状态"), replicate_status: ShotSegmentReplicateStatusEnum | None = Query(None, description="片段复刻状态not_started=未复刻,project_created=已创建复刻项目,processing=复刻处理中,completed=复刻完成,failed=复刻失败"),
page: int = Query(1, ge=1), page: int = Query(1, ge=1, description="分页页码,从1开始"),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -392,10 +432,10 @@ async def list_task_set_segments(
db, db,
current_user=current_user, current_user=current_user,
task_set_id=task_set_id, task_set_id=task_set_id,
source_mode=source_mode, source_mode=source_mode.value if source_mode else None,
split_status=split_status, split_status=split_status.value if split_status else None,
analysis_status=analysis_status, analysis_status=analysis_status.value if analysis_status else None,
replicate_status=replicate_status, replicate_status=replicate_status.value if replicate_status else None,
page=page, page=page,
page_size=page_size, page_size=page_size,
) )
@@ -405,9 +445,10 @@ async def list_task_set_segments(
"/segments/{segment_id}", "/segments/{segment_id}",
response_model=ShotSegmentDetailOut, response_model=ShotSegmentDetailOut,
summary="获取拆镜片段详情", summary="获取拆镜片段详情",
description="根据拆镜片段ID查询单个片段详情,包含切割结果、片段分析结果、复刻项目ID和状态。",
) )
async def get_segment( async def get_segment(
segment_id: str = Path(...), segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -418,10 +459,15 @@ async def get_segment(
"/segments/{segment_id}/replication-projects", "/segments/{segment_id}/replication-projects",
response_model=ShotReplicateActionOut, response_model=ShotReplicateActionOut,
summary="将拆镜片段创建为拆镜复刻项目", summary="将拆镜片段创建为拆镜复刻项目",
description=(
"以拆镜片段作为锁定素材视频创建 ModuleGenerationProject 复刻项目。"
"创建后只同步生成第1步 material_input,后续第2-5步需要调用 /projects/{project_id}/steps/{step_id}/... 系列接口手动推进。"
"素材视频来自片段 segment_video_url,不允许前端传入或后续修改。"
),
) )
async def create_replication_project_from_segment( async def create_replication_project_from_segment(
segment_id: str = Path(...), segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
req: ShotSegmentReplicationCreateRequest = Body(...), req: ShotSegmentReplicationCreateRequest = Body(..., description="从拆镜片段创建复刻项目参数:生成项目名称、核心内容点、新产品图片和可选幂等键"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -450,9 +496,10 @@ async def create_replication_project_from_segment(
"/projects/{project_id}", "/projects/{project_id}",
response_model=ShotReplicateTaskDetailOut, response_model=ShotReplicateTaskDetailOut,
summary="获取拆镜复刻项目详情", summary="获取拆镜复刻项目详情",
description="获取拆镜复刻 ModuleGenerationProject 项目详情,聚合返回素材、图片提词、图片生成、视频提词、视频生成和当前有效步骤列表。",
) )
async def get_project( async def get_project(
project_id: str = Path(...), project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -463,10 +510,15 @@ async def get_project(
"/projects/{project_id}/material", "/projects/{project_id}/material",
response_model=ShotReplicateActionOut, response_model=ShotReplicateActionOut,
summary="修改拆镜复刻素材信息,素材视频不允许修改", summary="修改拆镜复刻素材信息,素材视频不允许修改",
description=(
"修改拆镜复刻第1步素材输入,并重建第1步 material_input 新版本。"
"素材视频 material_video_url 锁定为拆镜片段视频,不允许修改;可修改新产品图片、参考素材项目名、生成项目名和核心内容点。"
"修改后会软删除第2、3、4、5步当前有效任务,并清空旧图片/视频结果。"
),
) )
async def update_material( async def update_material(
project_id: str = Path(...), project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
req: ShotReplicateMaterialUpdateRequest = Body(...), req: ShotReplicateMaterialUpdateRequest = Body(..., description="第1步素材输入修改参数;不接收 material_video_url,至少传一个允许修改字段"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -486,12 +538,13 @@ async def update_material(
@router.put( @router.put(
"/projects/{project_id}/steps/{step_id}/image-prompt", "/projects/{project_id}/steps/{step_id}/image-prompt",
response_model=ShotReplicateActionOut, response_model=ShotReplicateActionOut,
summary="直接修改图片 AI 优化提词", summary="直接修改第2步图片 AI 优化提词",
description="直接修改第2步 image_prompt_optimize 的图片提示词,不调用 AI、不扣积分;保存后软删除第3、4、5步当前有效任务。",
) )
async def update_image_prompt( async def update_image_prompt(
project_id: str = Path(...), project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
step_id: str = Path(...), step_id: str = Path(..., description="第2步图片 AI 提词子任务ID,即 module_generation_steps.idstep_code=image_prompt_optimize"),
req: ShotReplicateImagePromptUpdateRequest = Body(...), req: ShotReplicateImagePromptUpdateRequest = Body(..., description="图片 AI 优化提词修改参数"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -512,12 +565,16 @@ async def update_image_prompt(
@router.put( @router.put(
"/projects/{project_id}/steps/{step_id}/video-prompt-schema", "/projects/{project_id}/steps/{step_id}/video-prompt-schema",
response_model=ShotReplicateActionOut, response_model=ShotReplicateActionOut,
summary="修改视频 AI 提词 JSON schema", summary="修改第4步视频 AI 提词 JSON schema",
description=(
"修改第4步 video_prompt_optimize 的视频提词 JSON schema,不调用 AI、不扣积分。"
"服务端会锁定视频时长、比例、分辨率、帧率、动态时间规划等关键结构;保存后软删除第5步视频生成任务。"
),
) )
async def update_video_prompt_schema( async def update_video_prompt_schema(
project_id: str = Path(...), project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
step_id: str = Path(...), step_id: str = Path(..., description="第4步视频 AI 提词子任务ID,即 module_generation_steps.idstep_code=video_prompt_optimize"),
req: ShotReplicateVideoPromptSchemaUpdateRequest = Body(...), req: ShotReplicateVideoPromptSchemaUpdateRequest = Body(..., description="视频 AI 提词 JSON schema 修改参数"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -536,19 +593,24 @@ async def update_video_prompt_schema(
@router.post( @router.post(
"/projects/{project_id}/generate-image-prompt", "/projects/{project_id}/steps/{step_id}/generate-image-prompt",
response_model=ShotReplicateActionOut, response_model=ShotReplicateActionOut,
summary="生成图片 AI 提词", summary="基于第1步素材输入生成图片 AI 提词",
description=(
"基于第1步 material_input 子任务手动生成第2步 image_prompt_optimize。"
"如果已存在旧的第2、3、4、5步,会先软删除旧步骤,再创建新的第2步并投递 Celery 文本提词任务。"
),
) )
async def generate_image_prompt( async def generate_image_prompt(
project_id: str = Path(...), project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
req: ShotReplicateGenerateImagePromptRequest = Body(default_factory=ShotReplicateGenerateImagePromptRequest), step_id: str = Path(..., description="第1步素材输入子任务ID,即 module_generation_steps.idstep_code=material_input"),
req: ShotReplicateGenerateImagePromptRequest = Body(default_factory=ShotReplicateGenerateImagePromptRequest, description="图片提词生成参数,当前无需传参,额外字段会忽略"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id")) _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try: try:
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, req=req) project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id, req=req)
project_id_value, step_id_value = project.id, step.id project_id_value, step_id_value = project.id, step.id
await db.commit() await db.commit()
except HTTPException: except HTTPException:
@@ -577,19 +639,25 @@ async def generate_image_prompt(
@router.post( @router.post(
"/projects/{project_id}/generate-image", "/projects/{project_id}/steps/{step_id}/generate-image",
response_model=ShotReplicateActionOut, response_model=ShotReplicateActionOut,
summary="根据图片 AI 提词生成图片", summary="基于第2步图片 AI 提词生成图片",
description=(
"基于第2步 image_prompt_optimize 子任务生成第3步 image_generate。"
"请求体传入图片引擎和图片参数;ChatGenerationTask 幂等键由后端自动生成。"
"如果已存在旧的第3、4、5步,会先软删除旧步骤,再创建新的第3步。"
),
) )
async def generate_image( async def generate_image(
project_id: str = Path(...), project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
req: ShotReplicateGenerateImageRequest = Body(...), step_id: str = Path(..., description="第2步图片 AI 提词子任务ID,即 module_generation_steps.idstep_code=image_prompt_optimize"),
req: ShotReplicateGenerateImageRequest = Body(..., description="图片生成引擎和参数;可选值来自图片引擎配置接口"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id")) _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try: try:
project, step, chat_task = await generate_image_from_prompt(db, current_user=current_user, project_id=project_id, req=req) project, step, chat_task = await generate_image_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req)
project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id
await db.commit() await db.commit()
except HTTPException: except HTTPException:
@@ -611,19 +679,25 @@ async def generate_image(
@router.post( @router.post(
"/projects/{project_id}/generate-video-prompt", "/projects/{project_id}/steps/{step_id}/generate-video-prompt",
response_model=ShotReplicateActionOut, response_model=ShotReplicateActionOut,
summary="生成视频 AI 提词 JSON schema", summary="基于第3步图片结果生成视频 AI 提词 JSON schema",
description=(
"基于第3步 image_generate 子任务生成第4步 video_prompt_optimize。"
"视频时长、比例、分辨率在本步骤确定并写入第4步 output_json;第5步视频生成只选择视频引擎。"
"如果已存在旧的第4、5步,会先软删除旧步骤,再创建新的第4步并投递 Celery 文本提词任务。"
),
) )
async def generate_video_prompt( async def generate_video_prompt(
project_id: str = Path(...), project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
req: ShotReplicateGenerateVideoPromptRequest = Body(...), step_id: str = Path(..., description="第3步图片生成子任务ID,即 module_generation_steps.idstep_code=image_generate"),
req: ShotReplicateGenerateVideoPromptRequest = Body(..., description="视频提词生成参数:视频引擎、时长、比例、分辨率和目标平台;可选值来自视频引擎配置接口"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id")) _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try: try:
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, req=req) project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
project_id_value, step_id_value = project.id, step.id project_id_value, step_id_value = project.id, step.id
await db.commit() await db.commit()
except HTTPException: except HTTPException:
@@ -652,19 +726,25 @@ async def generate_video_prompt(
@router.post( @router.post(
"/projects/{project_id}/generate-video", "/projects/{project_id}/steps/{step_id}/generate-video",
response_model=ShotReplicateActionOut, response_model=ShotReplicateActionOut,
summary="根据视频 AI 提词生成视频", summary="基于第4步视频 AI 提词生成最终视频",
description=(
"基于第4步 video_prompt_optimize 子任务生成第5步 video_generate。"
"请求体只需要选择视频生成引擎 engine_idduration、aspect_ratio、resolution 从第4步视频提词结果继承。"
"如果已存在旧的第5步,会先软删除旧步骤,再创建新的第5步。"
),
) )
async def generate_video( async def generate_video(
project_id: str = Path(...), project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
req: ShotReplicateGenerateVideoRequest = Body(...), step_id: str = Path(..., description="第4步视频 AI 提词子任务ID,即 module_generation_steps.idstep_code=video_prompt_optimize"),
req: ShotReplicateGenerateVideoRequest = Body(..., description="视频生成参数:只传 engine_id,其它视频参数继承第4步视频提词结果"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id")) _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try: try:
project, step, chat_task = await generate_video_from_prompt(db, current_user=current_user, project_id=project_id, req=req) project, step, chat_task = await generate_video_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req)
project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id
await db.commit() await db.commit()
except HTTPException: except HTTPException:
@@ -689,9 +769,10 @@ async def generate_video(
"/projects/{project_id}", "/projects/{project_id}",
response_model=ShotReplicateDeleteOut, response_model=ShotReplicateDeleteOut,
summary="软删除拆镜复刻项目", summary="软删除拆镜复刻项目",
description="软删除拆镜复刻 ModuleGenerationProject,并联动软删除当前有效步骤和关联的 ChatGenerationTask。",
) )
async def delete_project( async def delete_project(
project_id: str = Path(...), project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
+1 -1
View File
@@ -123,7 +123,7 @@ async def send_sms_code(
description="校验指定手机号、场景下的短信验证码。业务接口一般会内部校验,本接口主要用于前端调试或单独校验。", description="校验指定手机号、场景下的短信验证码。业务接口一般会内部校验,本接口主要用于前端调试或单独校验。",
) )
async def verify_sms(req: SmsVerifyRequest): async def verify_sms(req: SmsVerifyRequest):
ok = await verify_sms_code(req.phone, req.code, req.scene.value) ok = await verify_sms_code(req.phone.strip(), req.code.strip(), req.scene.value)
if not ok: if not ok:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
+2 -2
View File
@@ -6,13 +6,12 @@ from app.dependencies import get_current_user, get_db
from app.models.user import User from app.models.user import User
from app.models.user_oauth import UserOAuth from app.models.user_oauth import UserOAuth
from app.models.user_oauth_app import UserOAuthApp from app.models.user_oauth_app import UserOAuthApp
from app.schemas.user_oauth import RequestOAuthRequest, RequestOAuthResponse, UserOAuthOut from app.schemas.user_oauth import RequestOAuthRequest, RequestOAuthResponse, UserOAuthOut, OAuthListResponse
from app.services.user_oauth_service import ( from app.services.user_oauth_service import (
build_oauth_url, build_oauth_url,
get_token, get_token,
get_oauth_list, get_oauth_list,
) )
from app.tasks.user_oauth_tasks import _update_oauth_accounts
router = APIRouter(prefix="/user-oauth", tags=["oauth"]) router = APIRouter(prefix="/user-oauth", tags=["oauth"])
@@ -108,6 +107,7 @@ async def juliang_callback(
"/oauth_list", "/oauth_list",
summary="获取账户下所有授权列表", summary="获取账户下所有授权列表",
description="获取当前用户下所有授权账户列表,支持按授权登录账号、开户方式、授权账户id筛选", description="获取当前用户下所有授权账户列表,支持按授权登录账号、开户方式、授权账户id筛选",
response_model=OAuthListResponse,
) )
async def oauth_list( async def oauth_list(
account_userid: str | None = Query(None, description="授权登录账号id"), account_userid: str | None = Query(None, description="授权登录账号id"),
+145 -1
View File
@@ -560,6 +560,150 @@ def create_app() -> FastAPI:
async def health(): async def health():
return {"status": "ok"} return {"status": "ok"}
@application.get("/internal/status", response_class=HTMLResponse)
async def status_page():
from app.tasks.celery_app import celery_app
from app.config import settings
celery_status = "unknown"
celery_error = ""
redis_status = "unknown"
redis_error = ""
try:
if celery_app:
inspect = celery_app.control.inspect()
try:
workers = inspect.stats()
if workers:
celery_status = "running"
else:
celery_status = "no_workers"
except Exception as e:
celery_status = "error"
celery_error = str(e)
else:
celery_status = "disabled"
except Exception as e:
celery_status = "error"
celery_error = str(e)
try:
if settings.REDIS_URL:
import redis
r = redis.from_url(settings.REDIS_URL)
r.ping()
redis_status = "connected"
else:
redis_status = "disabled"
except Exception as e:
redis_status = "disconnected"
redis_error = str(e)
def get_celery_text(status):
if status == 'running':
return '运行中'
elif status == 'no_workers':
return '无可用 Worker'
elif status == 'disabled':
return '已禁用'
elif status == 'error':
return '连接失败'
else:
return '未知'
def get_redis_text(status):
if status == 'connected':
return '已连接'
elif status == 'disabled':
return '已禁用'
elif status == 'disconnected':
return '未连接'
else:
return '未知'
celery_text = get_celery_text(celery_status)
redis_text = get_redis_text(redis_status)
celery_error_html = f'<div class="error-message">错误: {celery_error}</div>' if celery_error else ''
redis_error_html = f'<div class="error-message">错误: {redis_error}</div>' if redis_error else ''
html_content = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>服务状态监控</title>
<style>
body {{ font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; background: #f5f6fa; }}
h1 {{ color: #1a1a2e; font-size: 28px; margin-bottom: 8px; }}
h1 span {{ color: #6366f1; }}
.status-card {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; margin-bottom: 16px; }}
.status-card h3 {{ margin: 0 0 12px; font-size: 16px; color: #1e293b; }}
.status-indicator {{ display: inline-flex; align-items: center; gap: 8px; }}
.status-dot {{ width: 10px; height: 10px; border-radius: 50%; }}
.status-dot.running {{ background: #22c55e; box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); }}
.status-dot.connected {{ background: #22c55e; box-shadow: 0 0 8px rgba(34, 197, 94, 0.5); }}
.status-dot.error {{ background: #dc2626; box-shadow: 0 0 8px rgba(220, 38, 38, 0.5); }}
.status-dot.disabled {{ background: #94a3b8; }}
.status-dot.unknown {{ background: #f59e0b; }}
.status-dot.no_workers {{ background: #f59e0b; }}
.status-dot.disconnected {{ background: #dc2626; }}
.status-text {{ font-weight: 600; }}
.status-text.running, .status-text.connected {{ color: #16a34a; }}
.status-text.error, .status-text.disconnected {{ color: #dc2626; }}
.status-text.disabled {{ color: #64748b; }}
.status-text.unknown, .status-text.no_workers {{ color: #d97706; }}
.error-message {{ margin-top: 8px; padding: 8px 12px; background: #fef2f2; border-radius: 6px; font-size: 13px; color: #dc2626; word-break: break-all; }}
.info-box {{ margin-top: 12px; padding: 12px; background: #f1f5f9; border-radius: 8px; font-size: 13px; color: #64748b; }}
.back-link {{ display: inline-block; margin-top: 20px; color: #6366f1; text-decoration: none; font-weight: 600; }}
.back-link:hover {{ text-decoration: underline; }}
.version {{ font-size: 13px; color: #94a3b8; margin-top: 4px; }}
</style>
</head>
<body>
<h1>服务状态<span>.</span></h1>
<p class="version">版本 v{settings.APP_VERSION}</p>
<div class="status-card">
<h3>Celery 任务队列</h3>
<div class="status-indicator">
<div class="status-dot {celery_status}"></div>
<span class="status-text {celery_status}">{celery_text}</span>
</div>
{celery_error_html}
<div class="info-box">
<strong>说明:</strong> Celery 用于异步处理 AI 生成任务、轮询任务和下载任务。如果显示"无可用 Worker",请启动 Celery worker。
</div>
</div>
<div class="status-card">
<h3>Redis 缓存</h3>
<div class="status-indicator">
<div class="status-dot {redis_status}"></div>
<span class="status-text {redis_status}">{redis_text}</span>
</div>
{redis_error_html}
<div class="info-box">
<strong>说明:</strong> Redis 用于 Celery 消息队列、任务状态存储和缓存。
</div>
</div>
<div class="status-card">
<h3>数据库连接</h3>
<div class="status-indicator">
<div class="status-dot connected"></div>
<span class="status-text connected">已连接</span>
</div>
<div class="info-box">
<strong>说明:</strong> PostgreSQL 数据库已连接。
</div>
</div>
<a href="/internal/" class="back-link">← 返回首页</a>
</body>
</html>"""
return HTMLResponse(content=html_content)
@application.get("/internal/decrypt-data", response_class=HTMLResponse) @application.get("/internal/decrypt-data", response_class=HTMLResponse)
async def decrypt_data_page(): async def decrypt_data_page():
html_content = """ html_content = """
@@ -701,7 +845,7 @@ a:hover{text-decoration:underline}
<div class="cards"> <div class="cards">
<div class="card"><h3><a href="/internal/api-docs">API 文档</a></h3><p>Swagger UI 交互式文档</p></div> <div class="card"><h3><a href="/internal/api-docs">API 文档</a></h3><p>Swagger UI 交互式文档</p></div>
<div class="card"><h3><a href="/internal/api-redoc">ReDoc</a></h3><p>ReDoc 格式文档</p></div> <div class="card"><h3><a href="/internal/api-redoc">ReDoc</a></h3><p>ReDoc 格式文档</p></div>
<div class="card"><h3><a href="/internal/health">健康检查</a></h3><p>服务状态</p></div> <div class="card"><h3><a href="/internal/status">服务状态</a></h3><p>Celery/Redis 监控</p></div>
<div class="card"><h3><a href="/internal/decrypt-data">解密数据</a></h3><p>数据解密</p></div> <div class="card"><h3><a href="/internal/decrypt-data">解密数据</a></h3><p>数据解密</p></div>
</div> </div>
</body></html>""" </body></html>"""
+1 -1
View File
@@ -29,7 +29,7 @@ class ModelConfigOut(ModelConfigCreate):
class SystemConfigUpdate(BaseModel): class SystemConfigUpdate(BaseModel):
value: str value: str | int | float
class SystemConfigOut(BaseModel): class SystemConfigOut(BaseModel):
@@ -329,9 +329,9 @@ class HotOpeningGenerateImageRequest(BaseModel):
) )
engine_id: str | None = Field(None, description="图片生成引擎ID。为空则使用当前启用且优先级最高的图片引擎") engine_id: str | None = Field(None, description="图片生成引擎ID。为空则使用当前启用且优先级最高的图片引擎")
image_size: str | None = Field(None, description="图片分辨率档位,例如 1K、2K。为空使用引擎默认值") image_size: str | None = Field(None, description="图片分辨率档位,例如 1K、2K。具体可选值来自图片引擎配置接口;为空使用引擎默认值")
image_proportion: str | None = Field(None, description="图片比例,例如 1:1、16:9、9:16。为空使用默认值") image_proportion: str | None = Field(None, description="图片比例,例如 1:1、16:9、9:16。具体可选值来自图片引擎配置接口;为空使用默认值")
image_px: str | None = Field(None, description="图片像素尺寸,例如 2048x2048。为空时按引擎支持尺寸自动匹配") image_px: str | None = Field(None, description="图片像素尺寸,例如 1024x1024、2048x2048。具体可选值来自图片引擎配置接口;为空时按引擎支持尺寸自动匹配")
class HotOpeningGenerateVideoPromptRequest(BaseModel): class HotOpeningGenerateVideoPromptRequest(BaseModel):
@@ -346,9 +346,9 @@ class HotOpeningGenerateVideoPromptRequest(BaseModel):
) )
engine_id: str | None = Field(None, description="视频引擎ID。用于读取该引擎支持的视频时长、比例、分辨率配置;为空使用最高优先级启用引擎") engine_id: str | None = Field(None, description="视频引擎ID。用于读取该引擎支持的视频时长、比例、分辨率配置;为空使用最高优先级启用引擎")
duration: int | None = Field(None, ge=1, description="希望用于视频提词规划的视频时长,单位秒。为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_DURATION") duration: int | None = Field(None, ge=1, description="希望用于视频提词规划的视频时长,单位秒。具体可选值来自视频引擎 supported_durations为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_DURATION")
aspect_ratio: str | None = Field(None, description="希望用于视频提词规划的视频比例为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_RATIO") aspect_ratio: str | None = Field(None, description="希望用于视频提词规划的视频比例,例如 9:16、16:9、1:1。具体可选值来自视频引擎 supported_ratios为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_RATIO")
resolution: str | None = Field(None, description="希望用于视频提词规划的视频分辨率为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_RESOLUTION") resolution: str | None = Field(None, description="希望用于视频提词规划的视频分辨率,例如 480p、720p、1080p。具体可选值来自视频引擎 supported_resolutions为空时优先使用 HOT_OPENING_DEFAULT_VIDEO_RESOLUTION")
target_platform: str | None = Field(None, max_length=64, description="目标平台,例如抖音/快手/小红书。为空时使用 HOT_OPENING_DEFAULT_TARGET_PLATFORM") target_platform: str | None = Field(None, max_length=64, description="目标平台,例如抖音/快手/小红书。为空时使用 HOT_OPENING_DEFAULT_TARGET_PLATFORM")
@@ -370,7 +370,7 @@ class HotOpeningStepOut(BaseModel):
module: str = Field(..., description="模块标识,例如 hot_opening_replicate") module: str = Field(..., description="模块标识,例如 hot_opening_replicate")
step_index: int = Field(..., description="步骤序号:1素材输入、2图片提词、3图片生成、4视频提词、5视频生成") step_index: int = Field(..., description="步骤序号:1素材输入、2图片提词、3图片生成、4视频提词、5视频生成")
step_code: str = Field(..., description="步骤编码:material_input/image_prompt_optimize/image_generate/video_prompt_optimize/video_generate") step_code: str = Field(..., description="步骤编码:material_input/image_prompt_optimize/image_generate/video_prompt_optimize/video_generate")
status: str = Field(..., description="步骤状态:pending/waiting_user/processing/completed/failed/cancelled") status: str = Field(..., description="步骤状态:pending=待处理,waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消")
version: int = Field(..., description="步骤版本号。重新生成或修改上游步骤后 version+1") version: int = Field(..., description="步骤版本号。重新生成或修改上游步骤后 version+1")
is_current: bool = Field(..., description="是否当前有效步骤。旧步骤会软删除且 is_current=false") is_current: bool = Field(..., description="是否当前有效步骤。旧步骤会软删除且 is_current=false")
parent_step_id: str | None = Field(None, description="上一个步骤ID") parent_step_id: str | None = Field(None, description="上一个步骤ID")
@@ -425,9 +425,11 @@ class HotOpeningVideoGenerationOut(BaseModel):
class HotOpeningTaskDetailOut(BaseModel): class HotOpeningTaskDetailOut(BaseModel):
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID") id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID")
project_id: str = Field(..., description="兼容前端命名,等同于 id") project_id: str = Field(..., description="兼容前端命名,等同于 id")
user_id: str | None = Field(None, description="所属用户ID;管理员后台排查使用")
user_name: str | None = Field(None, description="所属用户名;管理员后台排查使用")
module: str = Field(..., description="模块标识,爆款开头复刻固定为 hot_opening_replicate") module: str = Field(..., description="模块标识,爆款开头复刻固定为 hot_opening_replicate")
title: str | None = Field(None, description="项目标题,默认取生成项目名称") title: str | None = Field(None, description="项目标题,默认取生成项目名称")
status: str = Field(..., description="总任务状态:pending/waiting_user/processing/completed/failed/cancelled") status: str = Field(..., description="总任务状态:pending=已创建,waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消")
current_step_code: str | None = Field(None, description="当前所处步骤编码") current_step_code: str | None = Field(None, description="当前所处步骤编码")
final_image_url: str | None = Field(None, description="最终新项目图片 URL") final_image_url: str | None = Field(None, description="最终新项目图片 URL")
final_video_url: str | None = Field(None, description="最终视频 URL") final_video_url: str | None = Field(None, description="最终视频 URL")
@@ -445,11 +447,15 @@ class HotOpeningTaskDetailOut(BaseModel):
class HotOpeningTaskListItemOut(BaseModel): class HotOpeningTaskListItemOut(BaseModel):
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID") id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID")
project_id: str = Field(..., description="兼容前端命名,等同于 id") project_id: str = Field(..., description="兼容前端命名,等同于 id")
user_id: str | None = Field(None, description="所属用户ID;管理员后台排查使用")
user_name: str | None = Field(None, description="所属用户名;管理员后台排查使用")
module: str = Field(..., description="模块标识") module: str = Field(..., description="模块标识")
title: str | None = Field(None, description="项目标题") title: str | None = Field(None, description="项目标题")
status: str = Field(..., description="总任务状态") status: str = Field(..., description="总任务状态pending=已创建,waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消")
current_step_code: str | None = Field(None, description="当前步骤") current_step_code: str | None = Field(None, description="当前步骤")
source_project_name: str | None = Field(None, description="视频素材内容项目名称,来源于第1步素材输入")
target_project_name: str | None = Field(None, description="生成项目名称,来源于第1步素材输入") target_project_name: str | None = Field(None, description="生成项目名称,来源于第1步素材输入")
core_content_point: str | None = Field(None, description="核心内容点,来源于第1步素材输入")
final_image_url: str | None = Field(None, description="最终图片 URL") final_image_url: str | None = Field(None, description="最终图片 URL")
final_video_url: str | None = Field(None, description="最终视频 URL") final_video_url: str | None = Field(None, description="最终视频 URL")
final_video_cover_url: str | None = Field(None, description="最终视频封面 URL") final_video_cover_url: str | None = Field(None, description="最终视频封面 URL")
@@ -0,0 +1,111 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
class PreTestTemplateCreate(BaseModel):
name: str = Field(..., description="模板名称")
note: Optional[str] = Field(None, description="模板备注")
platform: Optional[str] = Field(None, description="投放平台(AD/QIANCHUAN/LOCAL")
external_action: Optional[str] = Field(None, description="转化目标")
cpa_bid: Optional[float] = Field(None, description="目标转化成本:[1, 10000]")
audience_gender: Optional[str] = Field(None, description="性别(ALL/MALE/FEMALE")
audience_age: Optional[List[str]] = Field(None, description="受众年龄列表")
audience_region: Optional[List[int]] = Field(None, description="受众地区code列表")
audience_network: Optional[List[str]] = Field(None, description="网络类型列表")
cus_name: Optional[str] = Field(None, description="客户主体名称")
pricing_type: Optional[str] = Field(None, description="出价类型(OCPC/CPA/OCPM")
cost_cap: Optional[bool] = Field(None, description="是否最优成本出价(仅AD支持)")
target_cost: Optional[bool] = Field(None, description="是否稳定成本出价(仅AD支持)")
nobid: Optional[bool] = Field(None, description="是否最大转化出价(仅AD支持)")
cpc_bid: Optional[float] = Field(None, description="目标点击成本:[1, 10000]")
budget: Optional[float] = Field(None, description="预算金额:[1, 10000]")
is_default: Optional[bool] = Field(None, description="是否设为默认模板")
@field_validator('platform')
def validate_platform(cls, v):
if v is None:
return v
allowed = ["AD", "QIANCHUAN", "LOCAL"]
if v not in allowed:
raise ValueError(f"platform must be one of {allowed}")
return v
@field_validator('pricing_type')
def validate_pricing_type(cls, v):
if v is None:
return v
allowed = ["OCPC", "CPA", "OCPM"]
if v not in allowed:
raise ValueError(f"pricing_type must be one of {allowed}")
return v
@field_validator('audience_gender')
def validate_gender(cls, v):
if v is None:
return v
allowed = ["ALL", "MALE", "FEMALE"]
if v not in allowed:
raise ValueError(f"audience_gender must be one of {allowed}")
return v
class PreTestTemplateUpdate(BaseModel):
name: Optional[str] = Field(None, description="模板名称")
note: Optional[str] = Field(None, description="模板备注")
platform: Optional[str] = Field(None, description="投放平台")
external_action: Optional[str] = Field(None, description="转化目标")
cpa_bid: Optional[float] = Field(None, description="目标转化成本")
audience_gender: Optional[str] = Field(None, description="性别")
audience_age: Optional[List[str]] = Field(None, description="受众年龄列表")
audience_region: Optional[List[int]] = Field(None, description="受众地区code列表")
audience_network: Optional[List[str]] = Field(None, description="网络类型列表")
cus_name: Optional[str] = Field(None, description="客户主体名称")
pricing_type: Optional[str] = Field(None, description="出价类型")
cost_cap: Optional[bool] = Field(None, description="是否最优成本出价")
target_cost: Optional[bool] = Field(None, description="是否稳定成本出价")
nobid: Optional[bool] = Field(None, description="是否最大转化出价")
cpc_bid: Optional[float] = Field(None, description="目标点击成本")
budget: Optional[float] = Field(None, description="预算金额")
is_default: Optional[bool] = Field(None, description="是否设为默认模板")
class PreTestTemplateOut(BaseModel):
id: str = Field(..., description="主键")
name: str = Field(..., description="模板名称")
note: Optional[str] = Field(None, description="模板备注")
platform: Optional[str] = Field(None, description="投放平台")
external_action: Optional[str] = Field(None, description="转化目标")
cpa_bid: Optional[float] = Field(None, description="目标转化成本")
audience_gender: Optional[str] = Field(None, description="性别")
audience_age: Optional[List[str]] = Field(None, description="受众年龄列表")
audience_region: Optional[List[int]] = Field(None, description="受众地区code列表")
audience_network: Optional[List[str]] = Field(None, description="网络类型列表")
cus_name: Optional[str] = Field(None, description="客户主体名称")
pricing_type: Optional[str] = Field(None, description="出价类型")
cost_cap: Optional[bool] = Field(None, description="是否最优成本出价")
target_cost: Optional[bool] = Field(None, description="是否稳定成本出价")
nobid: Optional[bool] = Field(None, description="是否最大转化出价")
cpc_bid: Optional[float] = Field(None, description="目标点击成本")
budget: Optional[float] = Field(None, description="预算金额")
is_default: Optional[bool] = Field(None, description="是否默认模板")
user_id: str = Field(..., description="用户id")
created_at: datetime = Field(..., description="创建时间")
updated_at: datetime = Field(..., description="更新时间")
model_config = {"from_attributes": True}
class PaginationInfo(BaseModel):
page: int = Field(..., description="当前页码")
page_size: int = Field(..., description="每页数量")
total: int = Field(..., description="总记录数")
total_pages: int = Field(..., description="总页数")
class PreTestTemplateListResponse(BaseModel):
code: int = Field(0, description="返回码")
message: str = Field("查询成功", description="返回消息")
data: List[PreTestTemplateOut] = Field(..., description="模板列表")
pagination: PaginationInfo = Field(..., description="分页信息")
+111 -104
View File
@@ -208,27 +208,30 @@ class ShotReplicateTaskCreate(BaseModel):
class ShotReplicateMaterialUpdateRequest(BaseModel): class ShotReplicateMaterialUpdateRequest(BaseModel):
"""修改拆镜复刻第1步素材输入请求体。""" """修改拆镜复刻第1步素材输入请求体。
拆镜复刻的素材视频来自拆镜片段 segment_video_url,与片段强绑定,不允许修改。
本接口只允许修改新产品图片、参考素材项目名、生成项目名和核心内容点。
"""
model_config = ConfigDict( model_config = ConfigDict(
extra="forbid",
json_schema_extra={ json_schema_extra={
"example": { "example": {
"material_video_url": "https://example.com/new-source.mp4",
"material_image_url": "https://example.com/new-product.png", "material_image_url": "https://example.com/new-product.png",
"source_project_name": "新的参考素材项目名称", "source_project_name": "新的参考素材项目名称",
"target_project_name": "新的生成项目名称", "target_project_name": "新的生成项目名称",
"core_content_point": "新的50字以内核心内容点", "core_content_point": "新的50字以内核心内容点",
} }
} },
) )
material_video_url: str | None = Field(None, min_length=1, description="素材视频链接,未传则沿用旧值") material_image_url: str | None = Field(None, min_length=1, description="素材图片链接,未传则沿用旧值。用于新产品/目标素材图片,来自已有上传接口")
material_image_url: str | None = Field(None, min_length=1, description="素材图片链接,未传则沿用旧值")
source_project_name: str | None = Field(None, min_length=1, max_length=20, description="视频素材内容项目名称,未传则沿用旧值") source_project_name: str | None = Field(None, min_length=1, max_length=20, description="视频素材内容项目名称,未传则沿用旧值")
target_project_name: str | None = Field(None, min_length=1, max_length=20, description="生成项目名称,未传则沿用旧值") target_project_name: str | None = Field(None, min_length=1, max_length=20, description="生成项目名称,未传则沿用旧值")
core_content_point: str | None = Field(None, min_length=1, max_length=50, description="生成项目核心内容点,最多50字,未传则沿用旧值") core_content_point: str | None = Field(None, min_length=1, max_length=50, description="生成项目核心内容点,最多50字,未传则沿用旧值")
@field_validator("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point", mode="before") @field_validator("material_image_url", "source_project_name", "target_project_name", "core_content_point", mode="before")
@classmethod @classmethod
def _strip_optional(cls, value: str | None) -> str | None: def _strip_optional(cls, value: str | None) -> str | None:
if value is None: if value is None:
@@ -240,7 +243,7 @@ class ShotReplicateMaterialUpdateRequest(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def _require_at_least_one(self) -> "ShotReplicateMaterialUpdateRequest": def _require_at_least_one(self) -> "ShotReplicateMaterialUpdateRequest":
if not any(getattr(self, field) is not None for field in ("material_video_url", "material_image_url", "source_project_name", "target_project_name", "core_content_point")): if not any(getattr(self, field) is not None for field in ("material_image_url", "source_project_name", "target_project_name", "core_content_point")):
raise ValueError("至少需要传入一个需要修改的字段") raise ValueError("至少需要传入一个需要修改的字段")
return self return self
@@ -248,7 +251,7 @@ class ShotReplicateMaterialUpdateRequest(BaseModel):
class ShotReplicateStepUpdate(BaseModel): class ShotReplicateStepUpdate(BaseModel):
"""修改拆镜复刻子任务请求体。""" """修改拆镜复刻子任务请求体。"""
material_video_url: str | None = Field(None, description="修改第1步素材视频链接") material_video_url: str | None = Field(None, description="历史兼容字段:拆镜复刻项目素材视频与片段绑定,正式接口不允许修改")
material_image_url: str | None = Field(None, description="修改第1步素材图片链接") material_image_url: str | None = Field(None, description="修改第1步素材图片链接")
source_project_name: str | None = Field(None, max_length=20, description="修改第1步视频素材内容项目名称") source_project_name: str | None = Field(None, max_length=20, description="修改第1步视频素材内容项目名称")
target_project_name: str | None = Field(None, max_length=20, description="修改第1步生成项目名称") target_project_name: str | None = Field(None, max_length=20, description="修改第1步生成项目名称")
@@ -329,9 +332,9 @@ class ShotReplicateGenerateImageRequest(BaseModel):
) )
engine_id: str | None = Field(None, description="图片生成引擎ID。为空则使用当前启用且优先级最高的图片引擎") engine_id: str | None = Field(None, description="图片生成引擎ID。为空则使用当前启用且优先级最高的图片引擎")
image_size: str | None = Field(None, description="图片分辨率档位,例如 1K、2K。为空使用引擎默认值") image_size: str | None = Field(None, description="图片分辨率档位,例如 1K、2K。具体可选值来自图片引擎配置接口;为空使用引擎默认值")
image_proportion: str | None = Field(None, description="图片比例,例如 1:1、16:9、9:16。为空使用默认值") image_proportion: str | None = Field(None, description="图片比例,例如 1:1、16:9、9:16。具体可选值来自图片引擎配置接口;为空使用默认值")
image_px: str | None = Field(None, description="图片像素尺寸,例如 2048x2048。为空时按引擎支持尺寸自动匹配") image_px: str | None = Field(None, description="图片像素尺寸,例如 1024x1024、2048x2048。具体可选值来自图片引擎配置接口;为空时按引擎支持尺寸自动匹配")
class ShotReplicateGenerateVideoPromptRequest(BaseModel): class ShotReplicateGenerateVideoPromptRequest(BaseModel):
@@ -346,9 +349,9 @@ class ShotReplicateGenerateVideoPromptRequest(BaseModel):
) )
engine_id: str | None = Field(None, description="视频引擎ID。用于读取该引擎支持的视频时长、比例、分辨率配置;为空使用最高优先级启用引擎") engine_id: str | None = Field(None, description="视频引擎ID。用于读取该引擎支持的视频时长、比例、分辨率配置;为空使用最高优先级启用引擎")
duration: int | None = Field(None, ge=1, description="希望用于视频提词规划的视频时长,单位秒。为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_DURATION") duration: int | None = Field(None, ge=1, description="希望用于视频提词规划的视频时长,单位秒。具体可选值来自视频引擎 supported_durations为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_DURATION")
aspect_ratio: str | None = Field(None, description="希望用于视频提词规划的视频比例为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_RATIO") aspect_ratio: str | None = Field(None, description="希望用于视频提词规划的视频比例,例如 9:16、16:9、1:1。具体可选值来自视频引擎 supported_ratios为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_RATIO")
resolution: str | None = Field(None, description="希望用于视频提词规划的视频分辨率为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION") resolution: str | None = Field(None, description="希望用于视频提词规划的视频分辨率,例如 480p、720p、1080p。具体可选值来自视频引擎 supported_resolutions为空时优先使用 SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION")
target_platform: str | None = Field(None, max_length=64, description="目标平台,例如抖音/快手/小红书。为空时使用 SHOT_REPLICATE_DEFAULT_TARGET_PLATFORM") target_platform: str | None = Field(None, max_length=64, description="目标平台,例如抖音/快手/小红书。为空时使用 SHOT_REPLICATE_DEFAULT_TARGET_PLATFORM")
@@ -425,6 +428,8 @@ class ShotReplicateVideoGenerationOut(BaseModel):
class ShotReplicateTaskDetailOut(BaseModel): class ShotReplicateTaskDetailOut(BaseModel):
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID") id: str = Field(..., description="总任务项目ID。这个ID就是前端项目ID")
project_id: str = Field(..., description="兼容前端命名,等同于 id") project_id: str = Field(..., description="兼容前端命名,等同于 id")
user_id: str | None = Field(None, description="所属用户ID;管理员后台排查使用")
user_name: str | None = Field(None, description="所属用户名;管理员后台排查使用")
module: str = Field(..., description="模块标识,拆镜复刻固定为 shot_replicate") module: str = Field(..., description="模块标识,拆镜复刻固定为 shot_replicate")
title: str | None = Field(None, description="项目标题,默认取生成项目名称") title: str | None = Field(None, description="项目标题,默认取生成项目名称")
status: str = Field(..., description="总任务状态:pending/waiting_user/processing/completed/failed/cancelled") status: str = Field(..., description="总任务状态:pending/waiting_user/processing/completed/failed/cancelled")
@@ -477,13 +482,6 @@ class ShotReplicateDeleteOut(BaseModel):
deleted: bool = Field(..., description="是否已软删除") deleted: bool = Field(..., description="是否已软删除")
class ShotReplicateSpecOut(BaseModel):
project_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_PROJECT_STATUS_DESCRIPTIONS, description="总任务状态说明")
step_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_STATUS_DESCRIPTIONS, description="子任务状态说明")
steps: list[dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_DESCRIPTIONS, description="5个固定步骤说明")
step_io_schema_version: str = Field(default=SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, description="步骤 input_json/output_json 结构版本")
step_io_examples: dict[str, dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_IO_EXAMPLES, description="每个步骤 input_json/output_json 示例")
# ======================== # ========================
# 拆镜总任务集 / 片段 API Schema # 拆镜总任务集 / 片段 API Schema
@@ -579,10 +577,14 @@ class ShotTaskSetCreate(BaseModel):
class ShotTaskSetListQuery(BaseModel): class ShotTaskSetListQuery(BaseModel):
status: str | None = Field(None, description="总任务状态筛选,见 ShotTaskSetStatusEnum") status: str | None = Field(None, description="总任务状态筛选pending_analysis/analyzing/analysis_completed/analysis_failed/splitting/split_completed/partial_failed/failed/deleted")
analysis_status: str | None = Field(None, description="分析状态筛选,见 ShotAnalysisStatusEnum") analysis_status: str | None = Field(None, description="分析状态筛选pending/processing/completed/failed")
split_status: str | None = Field(None, description="拆镜状态筛选,见 ShotSplitStatusEnum") split_status: str | None = Field(None, description="拆镜状态筛选none/pending/processing/completed/failed/retry_waiting")
keyword: str | None = Field(None, description="标题/内容关键词") keyword: str | None = Field(None, description="标题/内容关键词,模糊搜索")
user_id: str | None = Field(None, description="管理员专用:用户ID筛选")
user_name: str | None = Field(None, description="管理员专用:用户名模糊筛选")
created_start: NaiveDatetimeOptional = Field(None, description="管理员专用:创建时间开始")
created_end: NaiveDatetimeOptional = Field(None, description="管理员专用:创建时间结束")
page: int = Field(1, ge=1, description="页码") page: int = Field(1, ge=1, description="页码")
page_size: int = Field(20, ge=1, le=100, description="每页数量") page_size: int = Field(20, ge=1, le=100, description="每页数量")
@@ -590,44 +592,46 @@ class ShotTaskSetListQuery(BaseModel):
class ShotTaskSetOut(BaseModel): class ShotTaskSetOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: str id: str = Field(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id")
title: str | None = None user_id: str | None = Field(None, description="所属用户ID;管理员后台排查使用")
video_url: str user_name: str | None = Field(None, description="所属用户名;管理员后台排查使用")
video_duration_seconds: float title: str | None = Field(None, description="拆镜总任务标题,可为空")
status: str video_url: str = Field(..., description="原视频 URL,来自已有上传接口")
analysis_status: str video_duration_seconds: float = Field(..., description="原视频时长,单位秒,允许浮点")
split_status: str status: str = Field(..., description="总任务状态:pending_analysis=等待分析,analyzing=分析中,analysis_completed=分析完成,analysis_failed=分析失败,splitting=拆镜中,split_completed=拆镜完成,partial_failed=部分失败,failed=失败,deleted=已软删")
original_video_content: str | None = None analysis_status: str = Field(..., description="原视频分析状态:pending=待分析,processing=分析中,completed=分析完成,failed=分析失败")
original_video_category: str | None = None split_status: str = Field(..., description="拆镜状态:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试")
original_video_audience: str | None = None original_video_content: str | None = Field(None, description="AI 分析出的原视频整体内容描述")
segment_count: int = 0 original_video_category: str | None = Field(None, description="AI 分析出的原视频分类,例如游戏视频、产品广告、教程等")
completed_segment_count: int = 0 original_video_audience: str | None = Field(None, description="AI 分析出的原视频受众人群")
failed_segment_count: int = 0 segment_count: int = Field(0, description="当前有效拆镜片段总数")
analysis_error_message: str | None = None completed_segment_count: int = Field(0, description="切割完成的片段数量")
split_error_message: str | None = None failed_segment_count: int = Field(0, description="切割失败的片段数量")
created_at: NaiveDatetimeOptional = None analysis_error_message: str | None = Field(None, description="原视频 AI 分析失败原因")
updated_at: NaiveDatetimeOptional = None split_error_message: str | None = Field(None, description="总任务级拆镜失败原因")
created_at: NaiveDatetimeOptional = Field(None, description="创建时间")
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间")
class ShotTaskSetListOut(BaseModel): class ShotTaskSetListOut(BaseModel):
total: int total: int = Field(..., description="符合筛选条件的总任务集总数")
page: int page: int = Field(..., description="当前页码")
page_size: int page_size: int = Field(..., description="每页数量")
items: list[ShotTaskSetOut] items: list[ShotTaskSetOut] = Field(default_factory=list, description="拆镜总任务集列表")
class ShotTaskSetDetailOut(ShotTaskSetOut): class ShotTaskSetDetailOut(ShotTaskSetOut):
ai_suggestions: list[ShotAISuggestionOut] = Field(default_factory=list) ai_suggestions: list[ShotAISuggestionOut] = Field(default_factory=list, description="AI 建议拆镜时间段列表,split-by-ai 接口可按 index 选择")
analysis_result_json: dict[str, Any] | list[Any] | None = None analysis_result_json: dict[str, Any] | list[Any] | None = Field(None, description="原视频 AI 分析完整 JSON 结果,结构由模型响应决定")
class ShotSplitByAIRequest(BaseModel): class ShotSplitByAIRequest(BaseModel):
selected_indices: list[int] | None = Field(None, description="指定 AI 建议序号不传则全部") selected_indices: list[int] | None = Field(None, description="指定 AI 建议序号列表,序号来自 ai_suggestions[].index不传则全部 AI 建议拆镜")
replace_existing: bool = Field(False, description="是否软删旧 AI 建议片段后重新拆") replace_existing: bool = Field(False, description="是否软删旧 AI 建议片段后重新拆;false 时保留旧片段并新增未存在片段")
class ShotSplitCustomRequest(BaseModel): class ShotSplitCustomRequest(BaseModel):
start_second: float = Field(..., ge=0, description="自定义拆镜开始秒,允许浮点") start_second: float = Field(..., ge=0, description="自定义拆镜开始秒,允许浮点,必须大于等于0")
end_second: float = Field(..., gt=0, description="自定义拆镜结束秒,允许浮点,必须大于 start_second") end_second: float = Field(..., gt=0, description="自定义拆镜结束秒,允许浮点,必须大于 start_second")
@model_validator(mode="after") @model_validator(mode="after")
@@ -640,56 +644,59 @@ class ShotSplitCustomRequest(BaseModel):
class ShotSegmentOut(BaseModel): class ShotSegmentOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: str id: str = Field(..., description="拆镜片段ID,即 shot_replicate_segments.id")
task_set_id: str task_set_id: str = Field(..., description="所属拆镜总任务集ID,即 shot_replicate_task_sets.id")
segment_index: int segment_index: int = Field(..., description="片段序号,从1开始")
segment_name: str | None = None segment_name: str | None = Field(None, description="片段名称,可为空")
source_mode: str source_mode: str = Field(..., description="片段来源:ai_suggestion=AI 建议拆镜,custom=用户自定义拆镜")
start_second: float start_second: float = Field(..., description="片段开始秒")
end_second: float end_second: float = Field(..., description="片段结束秒")
duration_seconds: float duration_seconds: float = Field(..., description="片段时长,单位秒")
time_node: str time_node: str = Field(..., description="片段时间节点展示文案,例如 0-5秒")
split_status: str split_status: str = Field(..., description="切割状态:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试")
analysis_status: str analysis_status: str = Field(..., description="片段分析状态:not_required=无需单独分析,pending=等待分析,processing=分析中,completed=分析完成,failed=分析失败")
replicate_status: str replicate_status: str = Field(..., description="片段复刻状态:not_started=未复刻,project_created=已创建复刻项目,processing=复刻处理中,completed=复刻完成,failed=复刻失败")
segment_video_url: str | None = None segment_video_url: str | None = Field(None, description="切割后的片段视频 URL。切割完成后有值,用作复刻项目锁定素材视频")
original_video_content: str | None = None original_video_content: str | None = Field(None, description="原视频整体内容描述,来自总任务 AI 分析")
original_video_category: str | None = None original_video_category: str | None = Field(None, description="原视频分类,来自总任务 AI 分析")
original_video_audience: str | None = None original_video_audience: str | None = Field(None, description="原视频受众,来自总任务 AI 分析")
segment_content: str | None = None segment_content: str | None = Field(None, description="当前片段内容描述")
segment_category: str | None = None segment_category: str | None = Field(None, description="当前片段分类")
segment_audience: str | None = None segment_audience: str | None = Field(None, description="当前片段受众人群")
split_retry_count: int = 0 split_retry_count: int = Field(0, description="切割失败后的恢复重试次数")
split_last_error: str | None = None split_last_error: str | None = Field(None, description="最近一次切割失败原因")
analysis_error_message: str | None = None analysis_error_message: str | None = Field(None, description="片段分析失败原因")
module_project_id: str | None = None module_project_id: str | None = Field(None, description="由该片段创建的拆镜复刻项目ID,即 module_generation_projects.id")
created_at: NaiveDatetimeOptional = None module_project_title: str | None = Field(None, description="关联拆镜复刻项目标题;后台片段列表展示使用")
updated_at: NaiveDatetimeOptional = None module_project_status: str | None = Field(None, description="关联拆镜复刻项目状态;后台片段列表展示使用")
module_project_current_step_code: str | None = Field(None, description="关联拆镜复刻项目当前步骤;后台片段列表展示使用")
created_at: NaiveDatetimeOptional = Field(None, description="创建时间")
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间")
class ShotSegmentDetailOut(ShotSegmentOut): class ShotSegmentDetailOut(ShotSegmentOut):
analysis_json: dict[str, Any] | list[Any] | None = None analysis_json: dict[str, Any] | list[Any] | None = Field(None, description="片段 AI 分析完整 JSON,custom 片段可能有值;AI 建议片段通常复用 ai_suggestion_json")
ai_suggestion_json: dict[str, Any] | list[Any] | None = None ai_suggestion_json: dict[str, Any] | list[Any] | None = Field(None, description="AI 建议拆镜原始 JSONsource_mode=ai_suggestion 时通常有值")
class ShotSegmentListOut(BaseModel): class ShotSegmentListOut(BaseModel):
total: int total: int = Field(..., description="符合筛选条件的片段总数")
page: int page: int = Field(..., description="当前页码")
page_size: int page_size: int = Field(..., description="每页数量")
items: list[ShotSegmentOut] items: list[ShotSegmentOut] = Field(default_factory=list, description="拆镜片段列表")
class ShotSplitByAIOut(BaseModel): class ShotSplitByAIOut(BaseModel):
task_set_id: str task_set_id: str = Field(..., description="拆镜总任务集ID")
status: str status: str = Field(..., description="总任务状态:pending_analysis/analyzing/analysis_completed/analysis_failed/splitting/split_completed/partial_failed/failed/deleted")
split_status: str split_status: str = Field(..., description="拆镜状态:none/pending/processing/completed/failed/retry_waiting")
created_segment_count: int created_segment_count: int = Field(..., description="本次创建的拆镜片段数量")
segments: list[ShotSegmentOut] segments: list[ShotSegmentOut] = Field(default_factory=list, description="本次创建或返回的拆镜片段列表")
class ShotSplitCustomOut(BaseModel): class ShotSplitCustomOut(BaseModel):
task_set_id: str task_set_id: str = Field(..., description="拆镜总任务集ID")
segment: ShotSegmentOut segment: ShotSegmentOut = Field(..., description="本次创建的自定义拆镜片段")
class ShotSegmentReplicationCreateRequest(BaseModel): class ShotSegmentReplicationCreateRequest(BaseModel):
@@ -704,10 +711,10 @@ class ShotSegmentReplicationCreateRequest(BaseModel):
} }
) )
target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称") target_project_name: str = Field(..., min_length=1, max_length=20, description="生成项目名称,最多20字;会写入 module_generation_projects.title 和第1步 material_input")
core_content_point: str = Field(..., min_length=1, max_length=50, description="生成项目核心内容点,最多50字") core_content_point: str = Field(..., min_length=1, max_length=50, description="生成项目核心内容点,最多50字;用于后续图片 AI 提词和视频 AI 提词")
material_image_url: str = Field(..., min_length=1, description="新产品/目标素材图片链接,来自已有上传接口") material_image_url: str = Field(..., min_length=1, description="新产品/目标素材图片链接,来自已有上传接口;作为图片生成参考素材")
idempotency_key: str | None = Field(None, max_length=64, description="创建 ModuleGenerationProject 幂等键") idempotency_key: str | None = Field(None, max_length=64, description="创建 ModuleGenerationProject 幂等键;为空时后端可按业务生成或不使用")
@field_validator("target_project_name", "core_content_point", "material_image_url", "idempotency_key", mode="before") @field_validator("target_project_name", "core_content_point", "material_image_url", "idempotency_key", mode="before")
@classmethod @classmethod
@@ -721,14 +728,14 @@ class ShotSegmentReplicationCreateRequest(BaseModel):
class ShotReplicateSpecOut(BaseModel): class ShotReplicateSpecOut(BaseModel):
project_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_PROJECT_STATUS_DESCRIPTIONS) project_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_PROJECT_STATUS_DESCRIPTIONS, description="ModuleGenerationProject 总任务状态说明:pending/waiting_user/processing/completed/failed/cancelled")
step_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_STATUS_DESCRIPTIONS) step_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_STATUS_DESCRIPTIONS, description="ModuleGenerationStep 子任务状态说明:pending/waiting_user/processing/completed/failed/cancelled")
steps: list[dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_DESCRIPTIONS) steps: list[dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_DESCRIPTIONS, description="5个固定步骤说明:material_input/image_prompt_optimize/image_generate/video_prompt_optimize/video_generate")
step_io_schema_version: str = SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION step_io_schema_version: str = Field(default=SHOT_REPLICATE_STEP_IO_SCHEMA_VERSION, description="步骤 input_json/output_json 结构版本")
step_io_examples: dict[str, dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_IO_EXAMPLES) step_io_examples: dict[str, dict[str, Any]] = Field(default_factory=lambda: SHOT_REPLICATE_STEP_IO_EXAMPLES, description="每个步骤 input_json/output_json 示例")
task_set_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_TASK_SET_STATUS_DESCRIPTIONS) task_set_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_TASK_SET_STATUS_DESCRIPTIONS, description="拆镜总任务集状态说明")
analysis_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_ANALYSIS_STATUS_DESCRIPTIONS) analysis_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_ANALYSIS_STATUS_DESCRIPTIONS, description="原视频/片段视频分析状态说明")
split_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SPLIT_STATUS_DESCRIPTIONS) split_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SPLIT_STATUS_DESCRIPTIONS, description="ffmpeg 拆镜状态说明")
segment_source_modes: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_SOURCE_MODE_DESCRIPTIONS) segment_source_modes: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_SOURCE_MODE_DESCRIPTIONS, description="拆镜片段来源说明")
segment_analysis_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_ANALYSIS_STATUS_DESCRIPTIONS) segment_analysis_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_ANALYSIS_STATUS_DESCRIPTIONS, description="拆镜片段分析状态说明")
segment_replicate_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_REPLICATE_STATUS_DESCRIPTIONS) segment_replicate_statuses: dict[str, str] = Field(default_factory=lambda: SHOT_SEGMENT_REPLICATE_STATUS_DESCRIPTIONS, description="拆镜片段进入复刻流程后的状态说明")
@@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
import json import json
from copy import deepcopy
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func, select from sqlalchemy import String, cast, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from app.config import settings from app.config import settings
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
@@ -184,6 +186,19 @@ def _merge_dict(old: dict[str, Any] | None, new: dict[str, Any] | None) -> dict[
return merged return merged
def _force_set_json(model_obj: Any, field_name: str, value: Any) -> None:
"""强制持久化 JSON / JSONB 字段。
SQLAlchemy 对 dict/list 的嵌套原地修改不会稳定触发 dirty 判定。
所有编辑类接口在写入 input_json / output_json 时统一走这里:
1. deepcopy 断开旧引用;
2. 整体重新赋值;
3. flag_modified 显式标记字段已变更。
"""
setattr(model_obj, field_name, deepcopy(value))
flag_modified(model_obj, field_name)
async def log_module_event( async def log_module_event(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -486,9 +501,16 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
video_url = video_generate_output.get("result_video_url") or (video_chat.video_url if video_chat else None) or project.final_video_url video_url = video_generate_output.get("result_video_url") or (video_chat.video_url if video_chat else None) or project.final_video_url
cover_url = video_generate_output.get("result_video_cover_url") or (video_chat.video_cover_url if video_chat else None) or project.final_video_cover_url cover_url = video_generate_output.get("result_video_cover_url") or (video_chat.video_cover_url if video_chat else None) or project.final_video_cover_url
user_name: str | None = None
if project.user_id:
user_result = await db.execute(select(User.username).where(User.id == project.user_id).limit(1))
user_name = user_result.scalar_one_or_none()
return HotOpeningTaskDetailOut( return HotOpeningTaskDetailOut(
id=project.id, id=project.id,
project_id=project.id, project_id=project.id,
user_id=project.user_id,
user_name=user_name,
module=project.module, module=project.module,
title=project.title, title=project.title,
status=project.status, status=project.status,
@@ -586,40 +608,141 @@ async def create_hot_opening_project(db: AsyncSession, current_user: User, req:
return project return project
def _user_name_filter_subquery(value: str):
"""后台按用户名筛选时使用的子查询。
只在传入 user_name 时查询 users 表;keyword 不再关联 users,避免后台关键词搜索扩大查询范围。
"""
like = f"%{value.strip()}%"
return select(User.id).where(User.username.ilike(like))
async def _user_name_map_by_ids(db: AsyncSession, user_ids: set[str]) -> dict[str, str | None]:
"""一次性查询当前页涉及的用户,避免列表逐条查询用户表。"""
if not user_ids:
return {}
result = await db.execute(select(User.id, User.username).where(User.id.in_(list(user_ids))))
return {user_id: username for user_id, username in result.all()}
async def _current_step_map_by_project_ids(
db: AsyncSession,
*,
project_ids: set[str],
step_code: str,
) -> dict[str, ModuleGenerationStep]:
"""一次性查询当前页项目的指定步骤,避免列表逐条查 material_input。"""
if not project_ids:
return {}
result = await db.execute(
select(ModuleGenerationStep).where(
ModuleGenerationStep.project_id.in_(list(project_ids)),
ModuleGenerationStep.step_code == step_code,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
)
)
return {step.project_id: step for step in result.scalars().all()}
async def list_hot_opening_projects( async def list_hot_opening_projects(
db: AsyncSession, db: AsyncSession,
*, *,
current_user: User, current_user: User,
status: str | None, status: str | None,
keyword: str | None = None,
user_id: str | None = None,
user_name: str | None = None,
created_start: datetime | None = None,
created_end: datetime | None = None,
page: int, page: int,
page_size: int, page_size: int,
) -> HotOpeningTaskListOut: ) -> HotOpeningTaskListOut:
"""
爆款开头列表查询。
性能策略:
1. 列表接口只查列表字段,不调用 project_to_detail_out,避免 steps/chat/user 多重 N+1。
2. 主表分页查询完成后,再按当前页 project_ids 批量查 material_input。
3. 管理员需要展示 user_name 时,按当前页 user_id 去重后一次性查 User。
4. user_name 使用 IN (SELECT users.id ...) 子查询筛选;keyword 不查询 users 表。
"""
query = select(ModuleGenerationProject).where( query = select(ModuleGenerationProject).where(
ModuleGenerationProject.module == MODULE, ModuleGenerationProject.module == MODULE,
ModuleGenerationProject.deleted_at.is_(None), ModuleGenerationProject.deleted_at.is_(None),
) )
if not current_user.is_admin: if not current_user.is_admin:
query = query.where(ModuleGenerationProject.user_id == current_user.id) query = query.where(ModuleGenerationProject.user_id == current_user.id)
else:
if user_id and user_id.strip():
query = query.where(ModuleGenerationProject.user_id == user_id.strip())
if user_name and user_name.strip():
query = query.where(ModuleGenerationProject.user_id.in_(_user_name_filter_subquery(user_name)))
if created_start:
query = query.where(ModuleGenerationProject.created_at >= created_start)
if created_end:
query = query.where(ModuleGenerationProject.created_at <= created_end)
if status: if status:
query = query.where(ModuleGenerationProject.status == status) query = query.where(ModuleGenerationProject.status == status)
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar_one() if keyword and keyword.strip():
result = await db.execute(query.order_by(ModuleGenerationProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size)) like = f"%{keyword.strip()}%"
projects = list(result.scalars().all()) material_step_subquery = (
select(ModuleGenerationStep.project_id).where(
ModuleGenerationStep.step_code == HotOpeningStepCodeEnum.MATERIAL_INPUT.value,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
cast(ModuleGenerationStep.input_json, String).ilike(like),
)
)
query = query.where(
or_(
ModuleGenerationProject.id.ilike(like),
ModuleGenerationProject.title.ilike(like),
ModuleGenerationProject.current_step_code.ilike(like),
ModuleGenerationProject.error_message.ilike(like),
ModuleGenerationProject.id.in_(material_step_subquery),
)
)
total = int((await db.execute(select(func.count()).select_from(query.subquery()))).scalar() or 0)
result = await db.execute(
query.order_by(ModuleGenerationProject.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
projects = list(result.scalars().unique().all())
project_ids = {project.id for project in projects if project.id}
material_step_map = await _current_step_map_by_project_ids(
db,
project_ids=project_ids,
step_code=HotOpeningStepCodeEnum.MATERIAL_INPUT.value,
)
user_name_map: dict[str, str | None] = {}
if current_user.is_admin:
user_ids = {project.user_id for project in projects if project.user_id}
user_name_map = await _user_name_map_by_ids(db, user_ids)
items: list[HotOpeningTaskListItemOut] = [] items: list[HotOpeningTaskListItemOut] = []
for project in projects: for project in projects:
material_step = await _get_current_step_by_code(db, project.id, HotOpeningStepCodeEnum.MATERIAL_INPUT.value) material_step = material_step_map.get(project.id)
material = _step_payload(material_step.input_json if material_step else None) material = _step_payload(material_step.input_json if material_step else None)
items.append( items.append(
HotOpeningTaskListItemOut( HotOpeningTaskListItemOut(
id=project.id, id=project.id,
project_id=project.id, project_id=project.id,
user_id=project.user_id,
user_name=user_name_map.get(project.user_id) if current_user.is_admin and project.user_id else None,
module=project.module, module=project.module,
title=project.title, title=project.title,
status=project.status, status=project.status,
current_step_code=project.current_step_code, current_step_code=project.current_step_code,
source_project_name=material.get("source_project_name"),
target_project_name=material.get("target_project_name"), target_project_name=material.get("target_project_name"),
core_content_point=material.get("core_content_point"),
final_image_url=build_resource_signed_url(project.final_image_url) if project.final_image_url else None, final_image_url=build_resource_signed_url(project.final_image_url) if project.final_image_url else None,
final_video_url=build_resource_signed_url(project.final_video_url) if project.final_video_url else None, final_video_url=build_resource_signed_url(project.final_video_url) if project.final_video_url else None,
final_video_cover_url=build_resource_signed_url(project.final_video_cover_url) if project.final_video_cover_url else None, final_video_cover_url=build_resource_signed_url(project.final_video_cover_url) if project.final_video_cover_url else None,
@@ -681,8 +804,21 @@ async def update_hot_opening_step(
if req.output_json: if req.output_json:
output_data = _merge_dict(output_data, req.output_json) output_data = _merge_dict(output_data, req.output_json)
step.input_json = _step_input(step_code=step.step_code, payload=input_data, source_step_id=step.source_step_id, parent_step_id=step.parent_step_id) _force_set_json(
step.output_json = _step_output(step_code=step.step_code, status=ModuleStepStatusEnum.COMPLETED.value, payload=output_data) step,
"input_json",
_step_input(
step_code=step.step_code,
payload=input_data,
source_step_id=step.source_step_id,
parent_step_id=step.parent_step_id,
),
)
_force_set_json(
step,
"output_json",
_step_output(step_code=step.step_code, status=ModuleStepStatusEnum.COMPLETED.value, payload=output_data),
)
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None step.error_message = None
step.completed_at = _now() step.completed_at = _now()
@@ -692,6 +828,7 @@ async def update_hot_opening_step(
await _soft_delete_steps_from_index(db, project=project, start_index=step.step_index + 1) await _soft_delete_steps_from_index(db, project=project, start_index=step.step_index + 1)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.STEP_UPDATED.value, message="用户修改子任务内容") await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.STEP_UPDATED.value, message="用户修改子任务内容")
await db.flush()
return project, step return project, step
@@ -784,11 +921,15 @@ async def update_hot_opening_image_prompt(
output_data["manual_edited"] = True output_data["manual_edited"] = True
output_data["manual_edited_at"] = _now().isoformat() output_data["manual_edited_at"] = _now().isoformat()
step.output_json = _step_output( _force_set_json(
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, step,
status=ModuleStepStatusEnum.COMPLETED.value, "output_json",
payload=output_data, _step_output(
usage=usage, step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
status=ModuleStepStatusEnum.COMPLETED.value,
payload=output_data,
usage=usage,
),
) )
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None step.error_message = None
@@ -812,6 +953,7 @@ async def update_hot_opening_image_prompt(
message="用户直接修改图片 AI 优化提词,已软删除后续步骤", message="用户直接修改图片 AI 优化提词,已软删除后续步骤",
detail={"start_deleted_step_index": STEP_INDEX_MAP[HotOpeningStepCodeEnum.IMAGE_GENERATE.value]}, detail={"start_deleted_step_index": STEP_INDEX_MAP[HotOpeningStepCodeEnum.IMAGE_GENERATE.value]},
) )
await db.flush()
return project, step return project, step
@@ -856,11 +998,15 @@ async def update_hot_opening_video_prompt_schema(
output_data["manual_edited"] = True output_data["manual_edited"] = True
output_data["manual_edited_at"] = _now().isoformat() output_data["manual_edited_at"] = _now().isoformat()
step.output_json = _step_output( _force_set_json(
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, step,
status=ModuleStepStatusEnum.COMPLETED.value, "output_json",
payload=output_data, _step_output(
usage=usage, step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
status=ModuleStepStatusEnum.COMPLETED.value,
payload=output_data,
usage=usage,
),
) )
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.error_message = None step.error_message = None
@@ -900,6 +1046,7 @@ async def update_hot_opening_video_prompt_schema(
], ],
}, },
) )
await db.flush()
return project, step return project, step
@@ -1030,16 +1177,20 @@ async def run_image_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
}) })
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.completed_at = _now() step.completed_at = _now()
step.output_json = _step_output( _force_set_json(
step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, step,
status=ModuleStepStatusEnum.COMPLETED.value, "output_json",
payload={ _step_output(
"optimized_prompt": optimized, step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
"prompt": optimized, status=ModuleStepStatusEnum.COMPLETED.value,
"original_prompt": prompt_text, payload={
"references": references, "optimized_prompt": optimized,
}, "prompt": optimized,
usage=usage, "original_prompt": prompt_text,
"references": references,
},
usage=usage,
),
) )
project.status = ModuleProjectStatusEnum.WAITING_USER.value project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value project.current_step_code = HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
@@ -1319,16 +1470,20 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
}) })
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.completed_at = _now() step.completed_at = _now()
step.output_json = _step_output( _force_set_json(
step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, step,
status=ModuleStepStatusEnum.COMPLETED.value, "output_json",
payload={ _step_output(
"prompt_schema": prompt_schema, step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
"final_prompt": final_prompt, status=ModuleStepStatusEnum.COMPLETED.value,
"params_used_for_prompt": video_config, payload={
"target_platform": target_platform, "prompt_schema": prompt_schema,
}, "final_prompt": final_prompt,
usage=usage, "params_used_for_prompt": video_config,
"target_platform": target_platform,
},
usage=usage,
),
) )
project.status = ModuleProjectStatusEnum.WAITING_USER.value project.status = ModuleProjectStatusEnum.WAITING_USER.value
project.current_step_code = HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value project.current_step_code = HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value
@@ -1477,10 +1632,14 @@ async def handle_chat_generation_task_completed(db: AsyncSession, task: ChatGene
if step.step_code == HotOpeningStepCodeEnum.IMAGE_GENERATE.value: if step.step_code == HotOpeningStepCodeEnum.IMAGE_GENERATE.value:
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.completed_at = _now() step.completed_at = _now()
step.output_json = _step_output( _force_set_json(
step_code=HotOpeningStepCodeEnum.IMAGE_GENERATE.value, step,
status=ModuleStepStatusEnum.COMPLETED.value, "output_json",
result={"result_image_url": task.image_url, "chat_task_id": task.id}, _step_output(
step_code=HotOpeningStepCodeEnum.IMAGE_GENERATE.value,
status=ModuleStepStatusEnum.COMPLETED.value,
result={"result_image_url": task.image_url, "chat_task_id": task.id},
),
) )
project.final_image_url = task.image_url project.final_image_url = task.image_url
project.status = ModuleProjectStatusEnum.WAITING_USER.value project.status = ModuleProjectStatusEnum.WAITING_USER.value
@@ -1489,10 +1648,14 @@ async def handle_chat_generation_task_completed(db: AsyncSession, task: ChatGene
elif step.step_code == HotOpeningStepCodeEnum.VIDEO_GENERATE.value: elif step.step_code == HotOpeningStepCodeEnum.VIDEO_GENERATE.value:
step.status = ModuleStepStatusEnum.COMPLETED.value step.status = ModuleStepStatusEnum.COMPLETED.value
step.completed_at = _now() step.completed_at = _now()
step.output_json = _step_output( _force_set_json(
step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value, step,
status=ModuleStepStatusEnum.COMPLETED.value, "output_json",
result={"result_video_url": task.video_url, "result_video_cover_url": task.video_cover_url, "chat_task_id": task.id}, _step_output(
step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value,
status=ModuleStepStatusEnum.COMPLETED.value,
result={"result_video_url": task.video_url, "result_video_cover_url": task.video_cover_url, "chat_task_id": task.id},
),
) )
project.final_video_url = task.video_url project.final_video_url = task.video_url
project.final_video_cover_url = task.video_cover_url project.final_video_cover_url = task.video_cover_url
@@ -341,33 +341,162 @@ def fill_none_with_wu(value: Any) -> Any:
return value return value
TOP_LEVEL_SCHEMA_KEYS = tuple(CLIENT_SCHEMA_V1.keys()) + ("动态时间规划", "输出规格限制")
OBJECT_FIELD_WHITELISTS: dict[str, set[str]] = {
key: set(value.keys())
for key, value in CLIENT_SCHEMA_V1.items()
if isinstance(value, dict)
}
OBJECT_FIELD_WHITELISTS.setdefault("画面属性", set()).add("推荐分辨率")
OBJECT_FIELD_WHITELISTS["输出规格限制"] = {"支持时长", "支持比例", "支持分辨率", "当前推荐分辨率"}
ACTION_FLOW_CONTENT_KEYS = ("动作内容", "动作", "动作说明", "内容", "说明", "主体动作", "动作变化")
CAMERA_FLOW_CONTENT_KEYS = ("镜头内容", "镜头", "镜头说明", "运镜", "运镜说明", "内容", "说明")
TIME_PLAN_ALLOWED_KEYS = ("时间段", "阶段", "说明")
PLACEHOLDER_FLOW_TEXTS = {
"展示主体动作、核心卖点或主要视觉内容",
"展示主要动作、核心卖点或主要视觉内容",
"展示核心卖点或主要视觉内容",
"展示主体动作",
"",
}
EMPTY_VALUE_TEXTS = {"", "", "null", "None", "none", "未提及", "不适用"}
def _clean_schema_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (dict, list)):
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return str(value)
return str(value).strip()
def _is_empty_schema_value(value: Any) -> bool:
return _clean_schema_text(value) in EMPTY_VALUE_TEXTS
def _normalize_schema_object(section_key: str, value: Any) -> dict[str, Any]:
default_value = CLIENT_SCHEMA_V1.get(section_key)
if not isinstance(default_value, dict):
default_value = {}
source = value if isinstance(value, dict) else {}
merged = copy.deepcopy(default_value)
allowed_keys = OBJECT_FIELD_WHITELISTS.get(section_key, set(default_value.keys()))
for field_key in allowed_keys:
if field_key in source:
merged[field_key] = fill_none_with_wu(source.get(field_key))
return merged
def normalize_top_level_schema_fields(result: dict[str, Any]) -> dict[str, Any]:
"""按客户端视频提词 schema 白名单清洗顶层和普通对象字段。
AI 偶尔会把解释性文本作为 JSON key 输出。普通对象中的非预设字段直接过滤,
动作流程/镜头流程这类列表字段在后续 flow normalize 中单独处理。
"""
source = result if isinstance(result, dict) else {}
normalized: dict[str, Any] = {}
for key in TOP_LEVEL_SCHEMA_KEYS:
if key in OBJECT_FIELD_WHITELISTS:
normalized[key] = _normalize_schema_object(key, source.get(key))
elif key in source:
normalized[key] = fill_none_with_wu(source.get(key))
elif key in CLIENT_SCHEMA_V1:
normalized[key] = copy.deepcopy(CLIENT_SCHEMA_V1[key])
for key, default_value in CLIENT_SCHEMA_V1.items():
if key not in normalized:
normalized[key] = copy.deepcopy(default_value)
return normalized
def ensure_top_keys(result: dict[str, Any]) -> dict[str, Any]: def ensure_top_keys(result: dict[str, Any]) -> dict[str, Any]:
schema = copy.deepcopy(CLIENT_SCHEMA_V1) return normalize_top_level_schema_fields(result)
for key, default_value in schema.items():
if key not in result:
result[key] = default_value def _pick_flow_content(item: dict[str, Any], content_keys: tuple[str, ...]) -> str:
elif isinstance(default_value, dict) and isinstance(result.get(key), dict): for key in content_keys:
merged = copy.deepcopy(default_value) if key in item and not _is_empty_schema_value(item.get(key)):
merged.update(result[key]) return _clean_schema_text(item.get(key))
result[key] = merged return ""
return result
def _collect_extra_flow_texts(
item: dict[str, Any],
*,
content_keys: tuple[str, ...],
allowed_keys: set[str],
) -> list[str]:
extras: list[str] = []
for key, value in item.items():
if key in allowed_keys or key in content_keys:
continue
key_text = _clean_schema_text(key)
value_text = _clean_schema_text(value)
if key_text and key_text not in EMPTY_VALUE_TEXTS:
extras.append(key_text)
if value_text and value_text not in EMPTY_VALUE_TEXTS and value_text != key_text:
extras.append(value_text)
# 去重但保留顺序,避免 AI 重复写入同一句。
deduped: list[str] = []
for text in extras:
if text not in deduped:
deduped.append(text)
return deduped
def _merge_flow_content(base_content: str, extra_texts: list[str], fallback: str) -> str:
base_content = _clean_schema_text(base_content)
if extra_texts and (not base_content or base_content in PLACEHOLDER_FLOW_TEXTS or base_content == fallback):
return "".join(extra_texts)
parts = [base_content] if base_content and base_content not in EMPTY_VALUE_TEXTS else []
for text in extra_texts:
if text and text not in parts:
parts.append(text)
return "".join(parts) if parts else fallback
def _normalize_flow_item(
raw_item: Any,
*,
plan_item: dict[str, str],
content_key: str,
content_keys: tuple[str, ...],
) -> dict[str, str]:
item = raw_item if isinstance(raw_item, dict) else {}
allowed_keys = {"时间段", content_key}
base_content = _pick_flow_content(item, content_keys)
extra_texts = _collect_extra_flow_texts(item, content_keys=content_keys, allowed_keys=allowed_keys)
fallback = plan_item.get("说明") or ""
return {
"时间段": plan_item.get("时间段") or _clean_schema_text(item.get("时间段")) or "",
content_key: _merge_flow_content(base_content, extra_texts, fallback),
}
def _normalize_time_plan(plan: list[dict[str, str]], value: Any) -> list[dict[str, str]]:
source = value if isinstance(value, list) else []
normalized: list[dict[str, str]] = []
for index, plan_item in enumerate(plan):
raw_item = source[index] if index < len(source) and isinstance(source[index], dict) else {}
item: dict[str, str] = {
"时间段": plan_item.get("时间段") or _clean_schema_text(raw_item.get("时间段")) or "",
"阶段": _clean_schema_text(raw_item.get("阶段")) or plan_item.get("阶段") or "",
"说明": _clean_schema_text(raw_item.get("说明")) or plan_item.get("说明") or "",
}
# 动态时间规划只保留 时间段/阶段/说明,多余字段不入库。
normalized.append(item)
return normalized
def ensure_flow_matches_time_plan(result: dict[str, Any], duration: int) -> dict[str, Any]: def ensure_flow_matches_time_plan(result: dict[str, Any], duration: int) -> dict[str, Any]:
plan = build_time_plan(duration) plan = build_time_plan(duration)
if not isinstance(result.get("动作流程"), list) or not result["动作流程"]: result["动作流程"] = _align_flow_time_ranges(result.get("动作流程"), plan, "动作内容")
result["动作流程"] = [ result["镜头流程"] = _align_flow_time_ranges(result.get("镜头流程"), plan, "镜头内容")
{"时间段": item["时间段"], "动作内容": item["说明"]} result["动态时间规划"] = _normalize_time_plan(plan, result.get("动态时间规划"))
for item in plan
]
if not isinstance(result.get("镜头流程"), list) or not result["镜头流程"]:
result["镜头流程"] = [
{"时间段": item["时间段"], "镜头内容": item["说明"]}
for item in plan
]
result["动作流程"] = _align_flow_time_ranges(result["动作流程"], plan, "动作内容")
result["镜头流程"] = _align_flow_time_ranges(result["镜头流程"], plan, "镜头内容")
result["动态时间规划"] = plan
return result return result
@@ -462,16 +591,20 @@ def clean_final_prompt_specs(schema: dict[str, Any], video_config: dict[str, Any
return schema return schema
def _align_flow_time_ranges(flow: Any, plan: list[dict[str, str]], default_content_key: str) -> list[dict[str, Any]]: def _align_flow_time_ranges(flow: Any, plan: list[dict[str, str]], default_content_key: str) -> list[dict[str, str]]:
source = flow if isinstance(flow, list) else [] source = flow if isinstance(flow, list) else []
aligned: list[dict[str, Any]] = [] content_keys = ACTION_FLOW_CONTENT_KEYS if default_content_key == "动作内容" else CAMERA_FLOW_CONTENT_KEYS
aligned: list[dict[str, str]] = []
for index, plan_item in enumerate(plan): for index, plan_item in enumerate(plan):
old_item = source[index] if index < len(source) and isinstance(source[index], dict) else {} raw_item = source[index] if index < len(source) else {}
item = dict(old_item) aligned.append(
item["时间段"] = plan_item["时间段"] _normalize_flow_item(
if not any(k in item and str(item.get(k)).strip() for k in (default_content_key, "动作", "镜头", "说明", "内容")): raw_item,
item[default_content_key] = plan_item["说明"] plan_item=plan_item,
aligned.append(item) content_key=default_content_key,
content_keys=content_keys,
)
)
return aligned return aligned
@@ -0,0 +1,258 @@
import json
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.pre_test_template import PreTestTemplate
from app.utils.id_gen import generate_id
async def create_pre_test_template(
user_id: str,
db: AsyncSession,
name: str,
note: Optional[str] = None,
platform: Optional[str] = None,
external_action: Optional[str] = None,
cpa_bid: Optional[float] = None,
audience_gender: Optional[str] = None,
audience_age: Optional[list] = None,
audience_region: Optional[list] = None,
audience_network: Optional[list] = None,
cus_name: Optional[str] = None,
pricing_type: Optional[str] = None,
cost_cap: Optional[bool] = None,
target_cost: Optional[bool] = None,
nobid: Optional[bool] = None,
cpc_bid: Optional[float] = None,
budget: Optional[float] = None,
is_default: Optional[bool] = None,
) -> PreTestTemplate:
if is_default:
await db.execute(
update(PreTestTemplate)
.where(
PreTestTemplate.user_id == user_id,
PreTestTemplate.is_default == True,
PreTestTemplate.deleted_at.is_(None),
)
.values(is_default=False)
)
template = PreTestTemplate(
id=generate_id(),
user_id=user_id,
name=name,
note=note,
platform=platform,
external_action=external_action,
cpa_bid=cpa_bid,
audience_gender=audience_gender,
audience_age=json.dumps(audience_age) if audience_age else None,
audience_region=json.dumps(audience_region) if audience_region else None,
audience_network=json.dumps(audience_network) if audience_network else None,
cus_name=cus_name,
pricing_type=pricing_type,
cost_cap=cost_cap,
target_cost=target_cost,
nobid=nobid,
cpc_bid=cpc_bid,
budget=budget,
is_default=is_default,
)
db.add(template)
await db.commit()
await db.refresh(template)
return template
async def update_pre_test_template(
template_id: str,
user_id: str,
db: AsyncSession,
**kwargs,
) -> Optional[PreTestTemplate]:
result = await db.execute(
select(PreTestTemplate).where(
PreTestTemplate.id == template_id,
PreTestTemplate.user_id == user_id,
PreTestTemplate.deleted_at.is_(None),
)
)
template = result.scalar_one_or_none()
if not template:
return None
if kwargs.get("is_default"):
await db.execute(
update(PreTestTemplate)
.where(
PreTestTemplate.user_id == user_id,
PreTestTemplate.is_default == True,
PreTestTemplate.id != template_id,
PreTestTemplate.deleted_at.is_(None),
)
.values(is_default=False)
)
update_data = {}
if "name" in kwargs:
update_data["name"] = kwargs["name"]
if "note" in kwargs:
update_data["note"] = kwargs["note"]
if "platform" in kwargs:
update_data["platform"] = kwargs["platform"]
if "external_action" in kwargs:
update_data["external_action"] = kwargs["external_action"]
if "cpa_bid" in kwargs:
update_data["cpa_bid"] = kwargs["cpa_bid"]
if "audience_gender" in kwargs:
update_data["audience_gender"] = kwargs["audience_gender"]
if "audience_age" in kwargs:
update_data["audience_age"] = json.dumps(kwargs["audience_age"]) if kwargs["audience_age"] else None
if "audience_region" in kwargs:
update_data["audience_region"] = json.dumps(kwargs["audience_region"]) if kwargs["audience_region"] else None
if "audience_network" in kwargs:
update_data["audience_network"] = json.dumps(kwargs["audience_network"]) if kwargs["audience_network"] else None
if "cus_name" in kwargs:
update_data["cus_name"] = kwargs["cus_name"]
if "pricing_type" in kwargs:
update_data["pricing_type"] = kwargs["pricing_type"]
if "cost_cap" in kwargs:
update_data["cost_cap"] = kwargs["cost_cap"]
if "target_cost" in kwargs:
update_data["target_cost"] = kwargs["target_cost"]
if "nobid" in kwargs:
update_data["nobid"] = kwargs["nobid"]
if "cpc_bid" in kwargs:
update_data["cpc_bid"] = kwargs["cpc_bid"]
if "budget" in kwargs:
update_data["budget"] = kwargs["budget"]
if "is_default" in kwargs:
update_data["is_default"] = kwargs["is_default"]
if update_data:
await db.execute(
update(PreTestTemplate)
.where(PreTestTemplate.id == template_id)
.values(**update_data)
)
await db.commit()
await db.refresh(template)
return template
async def delete_pre_test_template(
template_id: str,
user_id: str,
db: AsyncSession,
) -> bool:
result = await db.execute(
select(PreTestTemplate).where(
PreTestTemplate.id == template_id,
PreTestTemplate.user_id == user_id,
PreTestTemplate.deleted_at.is_(None),
)
)
template = result.scalar_one_or_none()
if not template:
return False
from datetime import datetime
template.deleted_at = datetime.now()
await db.commit()
return True
async def get_pre_test_template(
template_id: str,
user_id: str,
db: AsyncSession,
) -> Optional[PreTestTemplate]:
result = await db.execute(
select(PreTestTemplate).where(
PreTestTemplate.id == template_id,
PreTestTemplate.user_id == user_id,
PreTestTemplate.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()
async def get_pre_test_template_list(
user_id: str,
db: AsyncSession,
platform: Optional[str] = None,
page: int = 1,
page_size: int = 10,
) -> dict:
if page < 1:
page = 1
if page_size < 1:
page_size = 10
if page_size > 100:
page_size = 100
query = select(PreTestTemplate).where(
PreTestTemplate.user_id == user_id,
PreTestTemplate.deleted_at.is_(None),
)
if platform:
query = query.where(PreTestTemplate.platform == platform)
query = query.order_by(PreTestTemplate.is_default.desc(), PreTestTemplate.created_at.desc())
total_result = await db.execute(query.with_only_columns(PreTestTemplate.id))
total = len(total_result.scalars().all())
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
templates = result.scalars().all()
return {
"data": templates,
"total": total,
"page": page,
"page_size": page_size,
}
async def get_default_template(
user_id: str,
db: AsyncSession,
) -> Optional[PreTestTemplate]:
# 如果有默认模板,返回默认模板
# 如果有多条默认的模板,返回最新创建的模板
# 如果没有设置默认模板,返回最新创建的模板
result = await db.execute(
select(PreTestTemplate)
.where(
PreTestTemplate.user_id == user_id,
PreTestTemplate.is_default == True,
PreTestTemplate.deleted_at.is_(None),
)
.order_by(PreTestTemplate.created_at.desc())
)
template = result.scalar_one_or_none()
if template:
return template
# 如果没有默认模板,返回最新创建的模板
result = await db.execute(
select(PreTestTemplate)
.where(
PreTestTemplate.user_id == user_id,
PreTestTemplate.deleted_at.is_(None),
)
.order_by(PreTestTemplate.created_at.desc())
)
return result.scalar_one_or_none()
@@ -493,9 +493,16 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
video_url = video_generate_output.get("result_video_url") or (video_chat.video_url if video_chat else None) or project.final_video_url video_url = video_generate_output.get("result_video_url") or (video_chat.video_url if video_chat else None) or project.final_video_url
cover_url = video_generate_output.get("result_video_cover_url") or (video_chat.video_cover_url if video_chat else None) or project.final_video_cover_url cover_url = video_generate_output.get("result_video_cover_url") or (video_chat.video_cover_url if video_chat else None) or project.final_video_cover_url
user_name: str | None = None
if project.user_id:
user_result = await db.execute(select(User.username).where(User.id == project.user_id).limit(1))
user_name = user_result.scalar_one_or_none()
return ShotReplicateTaskDetailOut( return ShotReplicateTaskDetailOut(
id=project.id, id=project.id,
project_id=project.id, project_id=project.id,
user_id=project.user_id,
user_name=user_name,
module=project.module, module=project.module,
title=project.title, title=project.title,
status=project.status, status=project.status,
@@ -593,6 +600,26 @@ async def create_shot_replicate_project(db: AsyncSession, current_user: User, re
return project return project
async def _current_step_map_by_project_ids(
db: AsyncSession,
*,
project_ids: set[str],
step_code: str,
) -> dict[str, ModuleGenerationStep]:
"""一次性查询当前页项目的指定步骤,避免列表逐条查 material_input。"""
if not project_ids:
return {}
result = await db.execute(
select(ModuleGenerationStep).where(
ModuleGenerationStep.project_id.in_(list(project_ids)),
ModuleGenerationStep.step_code == step_code,
ModuleGenerationStep.is_current.is_(True),
ModuleGenerationStep.deleted_at.is_(None),
)
)
return {step.project_id: step for step in result.scalars().all()}
async def list_shot_replicate_projects( async def list_shot_replicate_projects(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -601,6 +628,11 @@ async def list_shot_replicate_projects(
page: int, page: int,
page_size: int, page_size: int,
) -> ShotReplicateTaskListOut: ) -> ShotReplicateTaskListOut:
"""
拆镜复刻项目列表查询。
这个列表目前不是后台主入口,但仍避免逐条查 material_input,保持和爆款开头列表一致的批量查询策略。
"""
query = select(ModuleGenerationProject).where( query = select(ModuleGenerationProject).where(
ModuleGenerationProject.module == MODULE, ModuleGenerationProject.module == MODULE,
ModuleGenerationProject.deleted_at.is_(None), ModuleGenerationProject.deleted_at.is_(None),
@@ -610,13 +642,23 @@ async def list_shot_replicate_projects(
if status: if status:
query = query.where(ModuleGenerationProject.status == status) query = query.where(ModuleGenerationProject.status == status)
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar_one() total = int((await db.execute(select(func.count()).select_from(query.subquery()))).scalar() or 0)
result = await db.execute(query.order_by(ModuleGenerationProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size)) result = await db.execute(
projects = list(result.scalars().all()) query.order_by(ModuleGenerationProject.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
projects = list(result.scalars().unique().all())
project_ids = {project.id for project in projects if project.id}
material_step_map = await _current_step_map_by_project_ids(
db,
project_ids=project_ids,
step_code=ShotReplicateStepCodeEnum.MATERIAL_INPUT.value,
)
items: list[ShotReplicateTaskListItemOut] = [] items: list[ShotReplicateTaskListItemOut] = []
for project in projects: for project in projects:
material_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.MATERIAL_INPUT.value) material_step = material_step_map.get(project.id)
material = _step_payload(material_step.input_json if material_step else None) material = _step_payload(material_step.input_json if material_step else None)
items.append( items.append(
ShotReplicateTaskListItemOut( ShotReplicateTaskListItemOut(
@@ -717,8 +759,10 @@ async def update_shot_replicate_material_input(
old_material_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.MATERIAL_INPUT.value) old_material_step = await _get_current_step_by_code(db, project.id, ShotReplicateStepCodeEnum.MATERIAL_INPUT.value)
old_material = _step_payload(old_material_step.input_json if old_material_step else None) old_material = _step_payload(old_material_step.input_json if old_material_step else None)
# 拆镜复刻的素材视频来自 shot_replicate_segments.segment_video_url
# 与片段强绑定,不允许前端在项目素材修改接口中覆盖。
material = { material = {
"material_video_url": req.material_video_url if req.material_video_url is not None else old_material.get("material_video_url"), "material_video_url": old_material.get("material_video_url"),
"material_image_url": req.material_image_url if req.material_image_url is not None else old_material.get("material_image_url"), "material_image_url": req.material_image_url if req.material_image_url is not None else old_material.get("material_image_url"),
"source_project_name": req.source_project_name if req.source_project_name is not None else old_material.get("source_project_name"), "source_project_name": req.source_project_name if req.source_project_name is not None else old_material.get("source_project_name"),
"target_project_name": req.target_project_name if req.target_project_name is not None else old_material.get("target_project_name"), "target_project_name": req.target_project_name if req.target_project_name is not None else old_material.get("target_project_name"),
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from app.config import settings from app.config import settings
from sqlalchemy import func, select from sqlalchemy import String, cast, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.shot_replicate import ( from app.enums.shot_replicate import (
@@ -19,6 +19,7 @@ from app.enums.shot_replicate import (
ShotSplitStatusEnum, ShotSplitStatusEnum,
ShotTaskSetStatusEnum, ShotTaskSetStatusEnum,
) )
from app.models.module_generation_project import ModuleGenerationProject
from app.models.shot_replicate_segment import ShotReplicateSegment from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet from app.models.shot_replicate_task_set import ShotReplicateTaskSet
from app.models.user import User from app.models.user import User
@@ -85,26 +86,37 @@ def _normalize_suggestions(value: Any) -> list[dict[str, Any]]:
return normalized return normalized
def _task_set_to_out(task_set: ShotReplicateTaskSet) -> ShotTaskSetOut: def _task_set_to_out(task_set: ShotReplicateTaskSet, user_name: str | None = None) -> ShotTaskSetOut:
return ShotTaskSetOut.model_validate(task_set) data = ShotTaskSetOut.model_validate(task_set)
data.user_name = user_name
return data
def _task_set_to_detail_out(task_set: ShotReplicateTaskSet) -> ShotTaskSetDetailOut: def _task_set_to_detail_out(task_set: ShotReplicateTaskSet, user_name: str | None = None) -> ShotTaskSetDetailOut:
suggestions = [ShotAISuggestionOut(**{k: v for k, v in item.items() if k != "raw"}) for item in _normalize_suggestions(task_set.ai_suggestion_json)] suggestions = [ShotAISuggestionOut(**{k: v for k, v in item.items() if k != "raw"}) for item in _normalize_suggestions(task_set.ai_suggestion_json)]
base = ShotTaskSetDetailOut.model_validate(task_set) base = ShotTaskSetDetailOut.model_validate(task_set)
base.user_name = user_name
base.ai_suggestions = suggestions base.ai_suggestions = suggestions
return base return base
def _segment_to_out(segment: ShotReplicateSegment) -> ShotSegmentOut: def _segment_to_out(segment: ShotReplicateSegment, project: ModuleGenerationProject | None = None) -> ShotSegmentOut:
data = ShotSegmentOut.model_validate(segment) data = ShotSegmentOut.model_validate(segment)
data.segment_name = f"片段{segment.segment_index}" data.segment_name = f"片段{segment.segment_index}"
if project:
data.module_project_title = project.title
data.module_project_status = project.status
data.module_project_current_step_code = project.current_step_code
return data return data
def _segment_to_detail_out(segment: ShotReplicateSegment) -> ShotSegmentDetailOut: def _segment_to_detail_out(segment: ShotReplicateSegment, project: ModuleGenerationProject | None = None) -> ShotSegmentDetailOut:
data = ShotSegmentDetailOut.model_validate(segment) data = ShotSegmentDetailOut.model_validate(segment)
data.segment_name = f"片段{segment.segment_index}" data.segment_name = f"片段{segment.segment_index}"
if project:
data.module_project_title = project.title
data.module_project_status = project.status
data.module_project_current_step_code = project.current_step_code
return data return data
@@ -201,6 +213,23 @@ async def create_task_set(db: AsyncSession, *, current_user: User, req: ShotTask
return task_set return task_set
def _user_name_filter_subquery(value: str):
"""后台按用户名筛选时使用的子查询。
只在传入 user_name 时查询 users 表;keyword 不再关联 users,避免后台关键词搜索扩大查询范围。
"""
like = f"%{value.strip()}%"
return select(User.id).where(User.username.ilike(like))
async def _user_name_map_by_ids(db: AsyncSession, user_ids: set[str]) -> dict[str, str | None]:
"""一次性查询当前页涉及的用户,避免列表逐条查询用户表。"""
if not user_ids:
return {}
result = await db.execute(select(User.id, User.username).where(User.id.in_(list(user_ids))))
return {user_id: username for user_id, username in result.all()}
async def list_task_sets( async def list_task_sets(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -209,39 +238,85 @@ async def list_task_sets(
analysis_status: str | None = None, analysis_status: str | None = None,
split_status: str | None = None, split_status: str | None = None,
keyword: str | None = None, keyword: str | None = None,
user_id: str | None = None,
user_name: str | None = None,
created_start: datetime | None = None,
created_end: datetime | None = None,
page: int = 1, page: int = 1,
page_size: int = 20, page_size: int = 20,
) -> ShotTaskSetListOut: ) -> ShotTaskSetListOut:
"""
拆镜总任务集列表查询。
性能策略:
1. 主列表不 join User,避免 count/list 复杂化。
2. 管理员按 user_name 搜索时使用 IN (SELECT users.id ...) 子查询;keyword 不查询 users 表。
3. 当前页数据取出后,再按 user_id 去重批量查 user_name,用于后台渲染。
"""
query = select(ShotReplicateTaskSet).where(ShotReplicateTaskSet.deleted_at.is_(None)) query = select(ShotReplicateTaskSet).where(ShotReplicateTaskSet.deleted_at.is_(None))
if not current_user.is_admin: if not current_user.is_admin:
query = query.where(ShotReplicateTaskSet.user_id == current_user.id) query = query.where(ShotReplicateTaskSet.user_id == current_user.id)
else:
if user_id and user_id.strip():
query = query.where(ShotReplicateTaskSet.user_id == user_id.strip())
if user_name and user_name.strip():
query = query.where(ShotReplicateTaskSet.user_id.in_(_user_name_filter_subquery(user_name)))
if created_start:
query = query.where(ShotReplicateTaskSet.created_at >= created_start)
if created_end:
query = query.where(ShotReplicateTaskSet.created_at <= created_end)
if status: if status:
query = query.where(ShotReplicateTaskSet.status == status) query = query.where(ShotReplicateTaskSet.status == status)
if analysis_status: if analysis_status:
query = query.where(ShotReplicateTaskSet.analysis_status == analysis_status) query = query.where(ShotReplicateTaskSet.analysis_status == analysis_status)
if split_status: if split_status:
query = query.where(ShotReplicateTaskSet.split_status == split_status) query = query.where(ShotReplicateTaskSet.split_status == split_status)
if keyword: if keyword and keyword.strip():
like = f"%{keyword.strip()}%" like = f"%{keyword.strip()}%"
query = query.where( conditions = [
(ShotReplicateTaskSet.title.ilike(like)) ShotReplicateTaskSet.id.ilike(like),
| (ShotReplicateTaskSet.original_video_content.ilike(like)) ShotReplicateTaskSet.title.ilike(like),
| (ShotReplicateTaskSet.original_video_category.ilike(like)) ShotReplicateTaskSet.original_video_content.ilike(like),
) ShotReplicateTaskSet.original_video_category.ilike(like),
ShotReplicateTaskSet.original_video_audience.ilike(like),
cast(ShotReplicateTaskSet.ai_suggestion_json, String).ilike(like),
]
query = query.where(or_(*conditions))
total_result = await db.execute(select(func.count()).select_from(query.subquery())) total_result = await db.execute(select(func.count()).select_from(query.subquery()))
total = int(total_result.scalar() or 0) total = int(total_result.scalar() or 0)
rows = await db.execute( result = await db.execute(
query.order_by(ShotReplicateTaskSet.created_at.desc()) query.order_by(ShotReplicateTaskSet.created_at.desc())
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
.limit(page_size) .limit(page_size)
) )
return ShotTaskSetListOut(total=total, page=page, page_size=page_size, items=[_task_set_to_out(item) for item in rows.scalars().all()]) task_sets = list(result.scalars().unique().all())
user_name_map: dict[str, str | None] = {}
if current_user.is_admin:
user_ids = {task_set.user_id for task_set in task_sets if task_set.user_id}
user_name_map = await _user_name_map_by_ids(db, user_ids)
return ShotTaskSetListOut(
total=total,
page=page,
page_size=page_size,
items=[
_task_set_to_out(
task_set,
user_name_map.get(task_set.user_id) if current_user.is_admin and task_set.user_id else None,
)
for task_set in task_sets
],
)
async def task_set_detail(db: AsyncSession, *, current_user: User, task_set_id: str) -> ShotTaskSetDetailOut: async def task_set_detail(db: AsyncSession, *, current_user: User, task_set_id: str) -> ShotTaskSetDetailOut:
task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user) task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user)
return _task_set_to_detail_out(task_set) user_result = await db.execute(select(User.username).where(User.id == task_set.user_id).limit(1))
return _task_set_to_detail_out(task_set, user_result.scalar_one_or_none())
async def _next_segment_index(db: AsyncSession, task_set_id: str) -> int: async def _next_segment_index(db: AsyncSession, task_set_id: str) -> int:
@@ -499,9 +574,13 @@ async def list_segments(
page_size: int = 20, page_size: int = 20,
) -> ShotSegmentListOut: ) -> ShotSegmentListOut:
await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user) await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user)
query = select(ShotReplicateSegment).where( query = (
ShotReplicateSegment.task_set_id == task_set_id, select(ShotReplicateSegment, ModuleGenerationProject)
ShotReplicateSegment.deleted_at.is_(None), .outerjoin(ModuleGenerationProject, ModuleGenerationProject.id == ShotReplicateSegment.module_project_id)
.where(
ShotReplicateSegment.task_set_id == task_set_id,
ShotReplicateSegment.deleted_at.is_(None),
)
) )
if not current_user.is_admin: if not current_user.is_admin:
query = query.where(ShotReplicateSegment.user_id == current_user.id) query = query.where(ShotReplicateSegment.user_id == current_user.id)
@@ -521,9 +600,18 @@ async def list_segments(
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
.limit(page_size) .limit(page_size)
) )
return ShotSegmentListOut(total=total, page=page, page_size=page_size, items=[_segment_to_out(item) for item in rows.scalars().all()]) return ShotSegmentListOut(
total=total,
page=page,
page_size=page_size,
items=[_segment_to_out(segment, project) for segment, project in rows.all()],
)
async def segment_detail(db: AsyncSession, *, current_user: User, segment_id: str) -> ShotSegmentDetailOut: async def segment_detail(db: AsyncSession, *, current_user: User, segment_id: str) -> ShotSegmentDetailOut:
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user) segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user)
return _segment_to_detail_out(segment) project: ModuleGenerationProject | None = None
if segment.module_project_id:
project_result = await db.execute(select(ModuleGenerationProject).where(ModuleGenerationProject.id == segment.module_project_id).limit(1))
project = project_result.scalar_one_or_none()
return _segment_to_detail_out(segment, project)
+1 -1
View File
@@ -173,7 +173,7 @@ async def verify_sms_code(phone: str, code: str, scene: str = "login") -> bool:
stored = await redis.get(key) stored = await redis.get(key)
if isinstance(stored, bytes): if isinstance(stored, bytes):
stored = stored.decode() stored = stored.decode()
if stored and str(stored) == str(code): if stored is not None and stored == code:
await redis.delete(key) await redis.delete(key)
return True return True
return False return False
+241
View File
@@ -0,0 +1,241 @@
import json
import os
from typing import Optional, List, Dict
# 缓存文件路径
CACHE_FILE_PATH = os.path.join(os.path.dirname(__file__), 'area_cache.json')
class AreaInfo:
def __init__(self, code: str, name: str, level: str, geoname_id: int = None):
self.code = code
self.name = name
self.level = level
self.geoname_id = geoname_id
self.sub_districts: List[AreaInfo] = []
def to_dict(self):
return {
"code": self.code,
"name": self.name,
"level": self.level,
"geoname_id": self.geoname_id,
"sub_districts": [sd.to_dict() for sd in self.sub_districts] if self.sub_districts else [],
}
@classmethod
def from_dict(cls, data: dict):
area = cls(
code=data.get("code"),
name=data.get("name"),
level=data.get("level"),
geoname_id=data.get("geoname_id"),
)
sub_districts = data.get("sub_districts", [])
for sub in sub_districts:
area.sub_districts.append(cls.from_dict(sub))
return area
def parse_district_data(district_data: dict) -> AreaInfo:
"""解析巨量接口返回的区域数据"""
area = AreaInfo(
code=district_data.get("code"),
name=district_data.get("name"),
level=district_data.get("level"),
geoname_id=district_data.get("geoname_id"),
)
sub_districts = district_data.get("sub_districts")
if sub_districts:
for sub in sub_districts:
area.sub_districts.append(parse_district_data(sub))
return area
def save_area_cache(areas: List[AreaInfo]) -> None:
"""将区域数据保存到缓存文件"""
data = [area.to_dict() for area in areas]
with open(CACHE_FILE_PATH, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def load_area_cache() -> Optional[List[AreaInfo]]:
"""从缓存文件加载区域数据"""
if not os.path.exists(CACHE_FILE_PATH):
return None
try:
with open(CACHE_FILE_PATH, 'r', encoding='utf-8') as f:
data = json.load(f)
return [AreaInfo.from_dict(item) for item in data]
except (json.JSONDecodeError, IOError):
return None
def clear_area_cache() -> None:
"""清除缓存文件"""
if os.path.exists(CACHE_FILE_PATH):
os.remove(CACHE_FILE_PATH)
def filter_by_level(areas: List[AreaInfo], target_level: str) -> List[AreaInfo]:
"""
根据级别过滤区域信息
:param areas: 区域列表
:param target_level: ONE_LEVEL / TWO_LEVEL / THREE_LEVEL
:return: 指定级别的区域列表
"""
result = []
def traverse(area: AreaInfo):
if area.level == target_level:
filtered = AreaInfo(
code=area.code,
name=area.name,
level=area.level,
geoname_id=area.geoname_id,
)
result.append(filtered)
if area.sub_districts:
for sub in area.sub_districts:
traverse(sub)
for area in areas:
traverse(area)
return result
def get_first_level_areas(areas: List[AreaInfo]) -> List[AreaInfo]:
"""获取所有一级区域(省/直辖市)"""
return filter_by_level(areas, "ONE_LEVEL")
def get_second_level_areas(areas: List[AreaInfo], parent_code: str = None) -> List[AreaInfo]:
"""
获取二级区域
:param areas: 区域列表
:param parent_code: 一级区域code不传则返回所有二级区域
:return: 二级区域列表
"""
if parent_code:
def find_parent_and_get_children(area: AreaInfo):
if area.code == parent_code:
return [AreaInfo(
code=sub.code,
name=sub.name,
level=sub.level,
geoname_id=sub.geoname_id,
) for sub in area.sub_districts] if area.sub_districts else []
if area.sub_districts:
for sub in area.sub_districts:
result = find_parent_and_get_children(sub)
if result:
return result
return []
for area in areas:
result = find_parent_and_get_children(area)
if result:
return result
return []
else:
return filter_by_level(areas, "TWO_LEVEL")
def get_third_level_areas(areas: List[AreaInfo], parent_code: str) -> List[AreaInfo]:
"""
获取三级区域/
:param areas: 区域列表
:param parent_code: 二级区域code
:return: 三级区域列表
"""
def find_parent_and_get_children(area: AreaInfo):
if area.code == parent_code:
return [AreaInfo(
code=sub.code,
name=sub.name,
level=sub.level,
geoname_id=sub.geoname_id,
) for sub in area.sub_districts] if area.sub_districts else []
if area.sub_districts:
for sub in area.sub_districts:
result = find_parent_and_get_children(sub)
if result:
return result
return []
for area in areas:
result = find_parent_and_get_children(area)
if result:
return result
return []
def get_area_by_level(areas: List[AreaInfo], level: str, parent_code: str = None) -> List[AreaInfo]:
"""
根据级别获取区域信息
:param areas: 区域列表
:param level: ONE_LEVEL / TWO_LEVEL / THREE_LEVEL
:param parent_code: 父级区域codeTWO_LEVEL和THREE_LEVEL时可选/必填
:return: 区域列表
"""
level = level.upper()
if level == "ONE_LEVEL":
return get_first_level_areas(areas)
elif level == "TWO_LEVEL":
return get_second_level_areas(areas, parent_code)
elif level == "THREE_LEVEL":
if not parent_code:
raise ValueError("获取三级区域需要提供二级区域code")
return get_third_level_areas(areas, parent_code)
else:
raise ValueError(f"不支持的级别: {level}")
async def fetch_and_cache_area_data(oauth_id: str, advertiser_id: str = 1836693172153543, code: str = "CN") -> List[AreaInfo]:
"""
从接口获取区域数据并缓存到文件
:param oauth_id: 授权ID
:param code: 行政区域编码默认中国CN
:return: 区域列表
"""
from app.utils.douyinApi import DouyinApi
params = {
"advertiser_id": advertiser_id,
"codes": json.dumps([code]),
"language": "ZH_CN",
"sub_district": "THREE_LEVEL",
"version": "V2_3_2"
}
area_response = await DouyinApi().get_area(oauth_id=oauth_id, params=params)
if area_response.get("code") != 0:
raise Exception(f"获取区域信息失败: {area_response.get('message', '未知错误')}")
districts_data = area_response.get("data", {}).get("districts", [])
if not districts_data:
raise Exception("接口返回的区域数据为空")
area_list = [parse_district_data(d) for d in districts_data]
save_area_cache(area_list)
return area_list
def get_cached_area_data() -> Optional[List[AreaInfo]]:
"""获取缓存的区域数据"""
return load_area_cache()
File diff suppressed because it is too large Load Diff
+13
View File
@@ -52,4 +52,17 @@ class DouyinApi:
url, url,
'POST', 'POST',
options options
)
#获取区域信息
async def get_area(self, oauth_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not oauth_id:
raise RuntimeError('OAuth ID is not set.')
url = "https://api.oceanengine.com/open_api/2/tools/admin/info/"
return await self.request.request_with_token_with_context(
oauth_id,
url,
'GET',
{'params': params or {}}
) )
+3 -1
View File
@@ -24,4 +24,6 @@ dist-ssr
*.sw? *.sw?
*.bak *.bak
*.zip *.zip
*.testbak *.testbak
.env.production
+1
View File
@@ -17,6 +17,7 @@
} }
} }
.hero { .hero {
position: relative; position: relative;
+6 -1
View File
@@ -20,9 +20,10 @@ import GeneratedRecord from './pages/GeneratedRecord';
import PreTest from './pages/PreTest'; import PreTest from './pages/PreTest';
import AuthorizationPage from './pages/AuthorizationPage'; import AuthorizationPage from './pages/AuthorizationPage';
import RemoveInfo from './pages/RemoveInfo'; import RemoveInfo from './pages/RemoveInfo';
import RemoveRw from './pages/RemoveRw';
// import RemoveFenbu from './pages/RemoveFenbu';
import ConsumePage from './pages/ConsumePage'; import ConsumePage from './pages/ConsumePage';
import { useAuthStore } from './store/useAuthStore'; import { useAuthStore } from './store/useAuthStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading, checkAuth } = useAuthStore(); const { user, loading, checkAuth } = useAuthStore();
useEffect(() => { useEffect(() => {
@@ -102,6 +103,10 @@ const App = () => {
<Route path="initial/:creatID/initialinfo" element={<InitialInfo />} /> <Route path="initial/:creatID/initialinfo" element={<InitialInfo />} />
<Route path="removelens" element={<RemoveLens />} /> <Route path="removelens" element={<RemoveLens />} />
<Route path="removelens/:creatID/removeinfo" element={<RemoveInfo />} /> <Route path="removelens/:creatID/removeinfo" element={<RemoveInfo />} />
<Route path="removelens/:creatID/removefenbu" element={<RemoveRw />} />
{/* <Route path="removelens/:creatID/removefenbu" element={<RemoveFenbu />} /> */}
<Route path="generated" element={<GeneratedRecord />} /> <Route path="generated" element={<GeneratedRecord />} />
<Route path="pretest" element={<PreTest />} /> <Route path="pretest" element={<PreTest />} />
<Route path="authorization" element={<AuthorizationPage />} /> <Route path="authorization" element={<AuthorizationPage />} />
+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> { export async function getone(projectId: string, stepId: string): Promise<any> {
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-image-prompt`); 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> { 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); 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 // /api/pre-test-template/default
export async function getDefaultPreTest(): Promise<any> { export async function getDefaultPreTest(): Promise<any> {
return api.get(`/pre-test-template/default`); 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 countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const currentOrderNoRef = useRef<string | null>(null); const currentOrderNoRef = useRef<string | null>(null);
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false }); const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
// LocalStorage keys // LocalStorage keys
const PENDING_ORDER_KEY = 'pending_payment_order'; const PENDING_ORDER_KEY = 'pending_payment_order';
@@ -177,12 +177,12 @@ const AppLayout: React.FC = () => {
getSiteInfo().then(info => { getSiteInfo().then(info => {
const name = info.siteName || '民众智创'; const name = info.siteName || '民众智创';
const logo = info.siteLogo || ''; const logo = info.siteLogo || '';
if (name !== siteName) { if (name !== siteName) {
setSiteName(name); setSiteName(name);
document.title = name; document.title = name;
} }
if (logo && logo !== siteLogo) { if (logo && logo !== siteLogo) {
setSiteLogo(logo); setSiteLogo(logo);
let faviconLink = document.querySelector('link[rel="icon"]') as HTMLLinkElement; let faviconLink = document.querySelector('link[rel="icon"]') as HTMLLinkElement;
@@ -194,15 +194,15 @@ const AppLayout: React.FC = () => {
faviconLink.href = logo; faviconLink.href = logo;
faviconLink.type = 'image/png'; faviconLink.type = 'image/png';
} }
localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo })); localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo }));
}).catch(() => {}); }).catch(() => { });
}, []); }, []);
const loadUnreadCount = () => { const loadUnreadCount = () => {
getUnreadCount().then(count => { getUnreadCount().then(count => {
setUnreadCount(count); setUnreadCount(count);
}).catch(() => {}); }).catch(() => { });
}; };
// 检查并恢复待处理的支付订单 // 检查并恢复待处理的支付订单
@@ -229,7 +229,7 @@ const AppLayout: React.FC = () => {
const timeoutSeconds = savedOrder.timeoutSeconds || 180; const timeoutSeconds = savedOrder.timeoutSeconds || 180;
const elapsedSeconds = Math.floor((now - createdAt) / 1000); const elapsedSeconds = Math.floor((now - createdAt) / 1000);
const remainingSeconds = Math.max(0, timeoutSeconds - elapsedSeconds); const remainingSeconds = Math.max(0, timeoutSeconds - elapsedSeconds);
if (remainingSeconds > 0) { if (remainingSeconds > 0) {
setQrCodeModalOpen(true); setQrCodeModalOpen(true);
startPolling(savedOrder.orderNo, remainingSeconds); startPolling(savedOrder.orderNo, remainingSeconds);
@@ -252,7 +252,7 @@ const AppLayout: React.FC = () => {
} }
} }
}; };
checkPendingOrder(); checkPendingOrder();
}, []); }, []);
@@ -274,16 +274,16 @@ const AppLayout: React.FC = () => {
}); });
} }
setMenuItems(items); setMenuItems(items);
}).catch(() => {}); }).catch(() => { });
getRechargePackages().then(data => { getRechargePackages().then(data => {
setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false)); setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false));
}).catch(() => {}); }).catch(() => { });
getPaymentMethods().then(data => { getPaymentMethods().then(data => {
setEnabledMethods(data); setEnabledMethods(data);
// Auto-select the first enabled method // Auto-select the first enabled method
if (data.alipay) setPaymentMethod('alipay'); if (data.alipay) setPaymentMethod('alipay');
else if (data.wechat) setPaymentMethod('wechat'); else if (data.wechat) setPaymentMethod('wechat');
}).catch(() => {}); }).catch(() => { });
loadUnreadCount(); loadUnreadCount();
}, [user]); }, [user]);
@@ -344,7 +344,7 @@ const AppLayout: React.FC = () => {
const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => { const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => {
stopPolling(); stopPolling();
setCountdown(timeoutSeconds); setCountdown(timeoutSeconds);
// 订单状态轮询(每2秒查询一次,只查询当前订单 // 订单状态轮询(每2秒查询一次,只查询当前订单
const pollingTimer = setInterval(async () => { const pollingTimer = setInterval(async () => {
try { try {
@@ -368,7 +368,7 @@ const AppLayout: React.FC = () => {
} }
}, 2000); }, 2000);
pollingTimerRef.current = pollingTimer; pollingTimerRef.current = pollingTimer;
// 倒计时 // 倒计时
const countdownTimer = setInterval(() => { const countdownTimer = setInterval(() => {
setCountdown(prev => { setCountdown(prev => {
@@ -376,7 +376,7 @@ const AppLayout: React.FC = () => {
// 超时自动取消 // 超时自动取消
stopPolling(); stopPolling();
if (currentOrderNoRef.current) { if (currentOrderNoRef.current) {
cancelPaymentOrder(currentOrderNoRef.current).catch(() => {}); cancelPaymentOrder(currentOrderNoRef.current).catch(() => { });
currentOrderNoRef.current = null; currentOrderNoRef.current = null;
} }
localStorage.removeItem(PENDING_ORDER_KEY); 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', 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', transition: 'all 0.2s ease',
}}> }}>
<span style={{ <span style={{
fontSize: depth > 0 ? 14 : 16, fontSize: depth > 0 ? 14 : 16,
flexShrink: 0, flexShrink: 0,
color: isActive ? '#6366f1' : '#64748b', color: isActive ? '#6366f1' : '#64748b',
}}>{menuIcon}</span> }}>{menuIcon}</span>
@@ -521,13 +521,13 @@ const AppLayout: React.FC = () => {
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.4)', boxShadow: '0 4px 16px rgba(99, 102, 241, 0.4)',
transition: 'all 0.3s ease', transition: 'all 0.3s ease',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)'; e.currentTarget.style.background = 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)';
e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(99, 102, 241, 0.5)'; e.currentTarget.style.boxShadow = '0 6px 20px rgba(99, 102, 241, 0.5)';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'; e.currentTarget.style.background = 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)';
e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(99, 102, 241, 0.4)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(99, 102, 241, 0.4)';
}} }}
@@ -544,7 +544,7 @@ const AppLayout: React.FC = () => {
justifyContent: 'flex-start', justifyContent: 'flex-start',
gap: 12, gap: 12,
padding: '10px 14px', padding: '10px 14px',
borderRadius: 14, cursor: 'pointer', borderRadius: 14, cursor: 'pointer',
transition: 'all 0.25s ease', transition: 'all 0.25s ease',
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)', 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)', boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)',
@@ -559,8 +559,8 @@ const AppLayout: React.FC = () => {
}} }}
> >
<Avatar size={36} icon={<UserOutlined />} <Avatar size={36} icon={<UserOutlined />}
style={{ style={{
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
flexShrink: 0, flexShrink: 0,
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)', 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 }}> <div style={{ color: '#1e293b', fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: -0.01 }}>
{user?.username} {user?.username}
</div> </div>
<div style={{ <div style={{
color: '#6366f1', color: '#6366f1',
fontSize: 12, fontSize: 12,
fontWeight: 500, fontWeight: 500,
letterSpacing: 0, letterSpacing: 0,
background: 'rgba(99, 102, 241, 0.08)', background: 'rgba(99, 102, 241, 0.08)',
@@ -586,18 +586,21 @@ const AppLayout: React.FC = () => {
{/* Main Content */} {/* Main Content */}
<div className="desktop-content" style={{ <div className="desktop-content" style={{
marginLeft: sidebarW + 48, marginLeft: sidebarW + 48,
marginRight: 32, marginRight: 32,
marginTop: 16, marginTop: 16,
marginBottom: 16, marginBottom: 16,
flex: 1, flex: 1,
minHeight: 'calc(100vh - 32px)', // height: '100%',
minHeight: 'calc(100vh - 32px)',
background: 'transparent', background: 'transparent',
padding: 0, padding: 0,
transition: 'margin-left 0.25s ease', transition: 'margin-left 0.25s ease',
}}> }}>
<div style={{ <div style={{
background: '#ffffff', boxSizing: 'border-box',
height: '100%',
background: '#ffffffff',
borderRadius: '20px', borderRadius: '20px',
boxShadow: '0 4px 32px rgba(0, 0, 0, 0.06), 0 1px 8px rgba(0, 0, 0, 0.04)', boxShadow: '0 4px 32px rgba(0, 0, 0, 0.06), 0 1px 8px rgba(0, 0, 0, 0.04)',
minHeight: '100%', minHeight: '100%',
@@ -672,33 +675,33 @@ const AppLayout: React.FC = () => {
const g = GRADIENTS[idx % GRADIENTS.length]; const g = GRADIENTS[idx % GRADIENTS.length];
const totalCredits = (opt.credits || 0) + (opt.bonus_credits || opt.bonusCredits || 0); const totalCredits = (opt.credits || 0) + (opt.bonus_credits || opt.bonusCredits || 0);
return ( return (
<div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{ <div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{
flex: '1 1 45%', minWidth: 200, borderRadius: 16, padding: '20px 16px', flex: '1 1 45%', minWidth: 200, borderRadius: 16, padding: '20px 16px',
background: selectedPlan === opt.id ? 'rgba(99,102,241,0.04)' : '#fafbff', background: selectedPlan === opt.id ? 'rgba(99,102,241,0.04)' : '#fafbff',
border: selectedPlan === opt.id ? '2px solid #6366f1' : '1px solid #f0f0f5', border: selectedPlan === opt.id ? '2px solid #6366f1' : '1px solid #f0f0f5',
cursor: 'pointer', position: 'relative', transition: 'all 0.2s', cursor: 'pointer', position: 'relative', transition: 'all 0.2s',
}}> }}>
{opt.description && ( {opt.description && (
<Tag color="purple" style={{ position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)', borderRadius: 8, fontSize: 11 }}>{opt.description}</Tag> <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={{ 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 style={{ <div style={{
fontSize: 22, fontWeight: 800, marginTop: 4, width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', background: g.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>¥{opt.price}</div> 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>
</div>
); );
})} })}
</div> </div>
@@ -763,7 +766,7 @@ const AppLayout: React.FC = () => {
setRechargeModalOpen(false); setRechargeModalOpen(false);
setQrCodeModalOpen(true); setQrCodeModalOpen(true);
currentOrderNoRef.current = order.orderNo; currentOrderNoRef.current = order.orderNo;
// 保存到 localStorage // 保存到 localStorage
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({ localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
orderNo: order.orderNo, orderNo: order.orderNo,
@@ -774,7 +777,7 @@ const AppLayout: React.FC = () => {
createdAt: order.createdAt || new Date().toISOString(), createdAt: order.createdAt || new Date().toISOString(),
timeoutSeconds: 180, timeoutSeconds: 180,
})); }));
// Start polling for payment status // Start polling for payment status
startPolling(order.orderNo); startPolling(order.orderNo);
} else { } else {
@@ -808,7 +811,7 @@ const AppLayout: React.FC = () => {
stopPolling(); stopPolling();
// Mark order as cancelled if it's still pending // Mark order as cancelled if it's still pending
if (currentOrderNoRef.current) { if (currentOrderNoRef.current) {
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {} try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { }
currentOrderNoRef.current = null; currentOrderNoRef.current = null;
} }
localStorage.removeItem(PENDING_ORDER_KEY); localStorage.removeItem(PENDING_ORDER_KEY);
@@ -934,7 +937,7 @@ const AppLayout: React.FC = () => {
onClick={async () => { onClick={async () => {
stopPolling(); stopPolling();
if (currentOrderNoRef.current) { if (currentOrderNoRef.current) {
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {} try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { }
currentOrderNoRef.current = null; currentOrderNoRef.current = null;
} }
localStorage.removeItem(PENDING_ORDER_KEY); 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 ( return (
<Layout style={{ height: '94vh', background: '#fafafa', overflow: 'hidden' }}> <Layout style={{ height: '90vh', background: '#fafafa', overflow: 'auto' }}>
{/* 左侧边栏 - 对话列表(已隐藏,保留代码) */} {/* 左侧边栏 - 对话列表(已隐藏,保留代码) */}
{false && ( {false && (
<Sider <Sider
@@ -2336,11 +2336,13 @@ const AIChatPage: React.FC = () => {
× ×
</button> </button>
} }
bodyStyle={{ styles={{
display: 'flex', body: {
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
minHeight: '400px', justifyContent: 'center',
minHeight: '400px',
}
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
@@ -2423,11 +2425,13 @@ const AIChatPage: React.FC = () => {
× ×
</button> </button>
} }
bodyStyle={{ styles={{
display: 'flex', body: {
alignItems: 'center', display: 'flex',
justifyContent: 'center', alignItems: 'center',
minHeight: '400px', justifyContent: 'center',
minHeight: '400px',
}
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}> <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 { Button, Typography, Collapse, Space, Modal, Input, Table, message } from 'antd';
import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined, PlayCircleOutlined, CheckCircleOutlined, EditOutlined, DownloadOutlined, SettingOutlined, LayoutOutlined } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom'; 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'; import './css/InitialInfo.css';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
function clonePlain<T>(value: T): T {
return value === undefined ? value : JSON.parse(JSON.stringify(value));
}
function InitialInfo() { function InitialInfo() {
const navigate = useNavigate(); const navigate = useNavigate();
const { creatID } = useParams<{ creatID: string }>(); const { creatID } = useParams<{ creatID: string }>();
@@ -17,6 +22,8 @@ function InitialInfo() {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [currentType, setCurrentType] = useState<string>('image'); const [currentType, setCurrentType] = useState<string>('image');
const [formData, setFormData] = useState<any>({}); const [formData, setFormData] = useState<any>({});
const [editingPromptStepId, setEditingPromptStepId] = useState<string>('');
const [promptSaving, setPromptSaving] = useState(false);
const [pollingTimer, setPollingTimer] = useState<any>(null); const [pollingTimer, setPollingTimer] = useState<any>(null);
// 引擎和视频参数相关状态 // 引擎和视频参数相关状态
@@ -180,23 +187,57 @@ function InitialInfo() {
} }
}, [steps]); }, [steps]);
const handleOpenModal = (prompt?: any, type?: string) => { const handleOpenModal = (prompt?: any, type?: string, stepId?: string | number) => {
setCurrentType(type || 'image'); setCurrentType(type || 'image');
setEditingPromptStepId(stepId ? String(stepId) : '');
if (type === 'video' && typeof prompt === 'object') {
setFormData(prompt); if (type === 'video' && typeof prompt === 'object' && prompt) {
setFormData(clonePlain(prompt));
setPromptText(''); setPromptText('');
} else { } else {
setPromptText(prompt || ''); setPromptText(prompt || '');
setFormData({}); setFormData({});
} }
setModalVisible(true); setModalVisible(true);
}; };
const handleConfirm = () => { const handleConfirm = async () => {
setModalVisible(false); 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 <Button
type="default" type="default"
icon={<EditOutlined />} 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 }} style={{ flex: 1, borderRadius: 8, borderColor: '#6366f1', color: '#6366f1', height: 36 }}
disabled={step.status !== 'completed'} disabled={step.status !== 'completed'}
> >
@@ -967,12 +1008,12 @@ function InitialInfo() {
</div> </div>
</div> </div>
<Modal <Modal
title="修改提示词" title={currentType === 'video' ? '修改视频提示词' : '修改提示词'}
open={modalVisible} open={modalVisible}
onCancel={() => setModalVisible(false)} onCancel={() => { if (!promptSaving) setModalVisible(false); }}
footer={[ footer={[
<Button key="cancel" onClick={() => setModalVisible(false)}></Button>, <Button key="cancel" onClick={() => setModalVisible(false)} disabled={promptSaving}></Button>,
<Button key="confirm" type="primary" onClick={handleConfirm}></Button>, <Button key="confirm" type="primary" onClick={handleConfirm} loading={promptSaving} disabled={promptSaving}></Button>,
]} ]}
width={800} width={800}
> >
@@ -986,7 +1027,7 @@ function InitialInfo() {
/> />
) : ( ) : (
<div style={{ maxHeight: 500, overflowY: 'auto', paddingRight: 10 }}> <div style={{ maxHeight: 500, overflowY: 'auto', paddingRight: 10 }}>
<FormRenderer data={formData} onChange={setFormData} /> <VideoPromptSchemaEditor value={formData} onChange={setFormData} />
</div> </div>
)} )}
</Modal> </Modal>
@@ -1055,7 +1096,10 @@ function InitialInfo() {
key: 'action', key: 'action',
render: (_, record) => ( render: (_, record) => (
<button <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' }} 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; export default InitialInfo;
+432 -336
View File
@@ -1,22 +1,68 @@
import React, { useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Button, Table, Tag, Drawer, Input, Upload, message } from 'antd'; import { Button, Drawer, Input, Table, Tag, Upload, message } from 'antd';
import { ArrowLeftOutlined, PlayCircleOutlined, XOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined, PlusOutlined, XOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd'; import { createRemoveLens, getShotReplicationDetail, Removelist, removeCreate, splitCustom, uploadImage } from '../api';
import VideoTrimPicker from '../components/VideoTrimPicker';
const { TextArea } = Input; 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() { function RemoveInfo() {
const { creatID } = useParams<{ creatID: string }>(); const { creatID } = useParams<{ creatID: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [drawerVisible, setDrawerVisible] = useState(false); 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 [productName, setProductName] = useState('');
const [productSellingPoint, setProductSellingPoint] = useState(''); const [productSellingPoint, setProductSellingPoint] = useState('');
const [productImage, setProductImage] = 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); setCurrentSegment(segmentId);
setDrawerVisible(true); setDrawerVisible(true);
}; };
@@ -27,41 +73,30 @@ function RemoveInfo() {
setProductName(''); setProductName('');
setProductSellingPoint(''); setProductSellingPoint('');
setProductImage(''); setProductImage('');
setDetailImage('');
}; };
const handleProductImageChange: any = (info: any) => { const handleProductImageChange: any = (info: any) => {
if (info.fileList.length > 0) { 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 {
setProductImage(''); setProductImage('');
} }
}; };
const handleDetailImageChange: any = (info: any) => { const beforeUploadProductImage = async (file: File) => {
if (info.fileList.length > 0) { try {
const file = info.fileList[0]; const uploadResult = await uploadImage(file);
if (file.originFileObj) { setProductImage(uploadResult.url);
const reader = new FileReader(); message.success('图片上传成功');
reader.onload = (e) => { } catch {
setDetailImage(e.target?.result as string); message.error('图片上传失败,请重试');
};
reader.readAsDataURL(file.originFileObj);
}
} else {
setDetailImage('');
} }
return false;
}; };
const handleManualGenerate = () => { const handleManualGenerate = async () => {
// 必填校验 if (!currentSegment) {
message.warning('请先选择拆镜片段');
return;
}
if (!productImage) { if (!productImage) {
message.warning('请上传产品图'); message.warning('请上传产品图');
return; return;
@@ -72,347 +107,408 @@ function RemoveInfo() {
} }
if (!productSellingPoint.trim()) { if (!productSellingPoint.trim()) {
message.warning('请输入产品卖点'); message.warning('请输入产品卖点');
return; return;
} }
// 输出内容 setLoading(true);
console.log('手动生成 - 片段', currentSegment); try {
console.log('产品图:', productImage); const params = {
console.log('细节图:', detailImage); target_project_name: productName.trim(),
console.log('产品名称:', productName); core_content_point: productSellingPoint.trim(),
console.log('产品卖点:', productSellingPoint); 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 = { const handleAutoGenerate = async () => {
productName: '返回', if (!creatID) return;
uploadTime: '2026-06-09 09:01:16', setAutoSplitLoading(true);
sellingPoints: ['一键匹配', '连麦聊天'], try {
audience: '123123', await createRemoveLens(creatID, {
audienceAnalysis: '123123', selected_indices: [],
videoUrl: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=320&h=180&fit=crop', replace_existing: false,
segments: [ });
{ message.success('AI 拆镜任务已提交');
key: '1', await refreshPageData();
id: 1, } catch (error: any) {
timeRange: '00:00 - 00:03', message.error(error?.message || '拆镜失败');
thumbnail: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=120&h=80&fit=crop', } finally {
content: '11111', setAutoSplitLoading(false);
lines: 'qqqqqqqqqqq', }
contentStrategy: '展示礼盒' };
},
{ const handleOpenTrimModal = () => {
key: '2', if (!videoUrl) {
id: 2, message.warning('原视频地址不存在');
timeRange: '00:03 - 00:06', return;
thumbnail: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=120&h=80&fit=crop', }
content: '1231231231', setTrimModalVisible(true);
lines: 'qqqqqqqqqqq', };
contentStrategy: '开箱展示'
}, const handleCustomSplit = async (range: { startSecond: number; endSecond: number; durationSecond: number }) => {
{ if (!creatID) return;
key: '3', if (range.durationSecond < MIN_TRIM_SECONDS) {
id: 3, message.warning(`拆镜片段不能低于 ${MIN_TRIM_SECONDS}`);
timeRange: '00:06 - 00:09', return;
thumbnail: 'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=120&h=80&fit=crop', }
content: '123123', if (range.durationSecond > MAX_TRIM_SECONDS) {
lines: 'qqqqqqqqqqq', message.warning(`拆镜片段不能超过 ${MAX_TRIM_SECONDS}`);
contentStrategy: '取出产品' 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 = [ const columns = [
{ {
title: '片段', title: '片段',
width: 100, width: 100,
render: (text: any, record: any) => ( align: 'center' as const,
render: (_: any, record: any) => (
<div> <div>
<div style={{ fontSize: 16, fontWeight: 600, color: '#333' }}>{record.id}</div> <div style={{ fontSize: 16, fontWeight: 600, color: '#333' }}>{record.segmentName || `片段${record.segmentIndex || ''}`}</div>
<div style={{ fontSize: 12, color: '#999' }}>{record.timeRange}</div> <div style={{ fontSize: 12, color: '#999' }}>{record.timeNode}</div>
{record.sourceMode === 'custom' && <Tag color="blue" style={{ marginTop: 6 }}></Tag>}
</div> </div>
) ),
}, },
{ {
title: '片段视频', title: '片段视频',
width: 120, width: 150,
render: (text: any, record: any) => ( align: 'center' as const,
<div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden' }}> render: (_: any, record: any) => (
<img <div style={{ position: 'relative', width: 120, height: 80, borderRadius: 6, overflow: 'hidden', background: '#f1f5f9' }}>
src={record.thumbnail} {record.segmentVideoUrl ? (
alt={`片段${record.id}`} <video
style={{ width: '100%', height: '100%', objectFit: 'cover' }} controls
/> src={buildAssetUrl(record.segmentVideoUrl)}
<div style={{ style={{ width: '100%', height: '100%', objectFit: 'cover' }}
position: 'absolute', />
top: '50%', ) : (
left: '50%', <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 12 }}>
transform: 'translate(-50%, -50%)', {record.splitStatus === 'failed' ? '切割失败' : '切割中'}
width: 28, </div>
height: 28, )}
background: 'rgba(0,0,0,0.6)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<PlayCircleOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
</div> </div>
) ),
}, },
{ {
title: '画面内容', title: '视频内容',
width: 250, 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, align: 'left' as const,
render: (text: any, record: any) => ( render: (_: any, record: any) => (
<div style={{ fontSize: 14, color: '#333', fontWeight: 500 }}> <div style={{ fontSize: 14, color: '#333', lineHeight: 1.6 }}>
{record.contentStrategy || '-'} {record.segmentContent || record.lastError || '-'}
</div> </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: '素材', title: '素材',
width: 140, width: 140,
align: 'center' as const, 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={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<div style={{ fontSize: 12, color: '#999', padding: '12px 24px', border: '1px dashed #ddd', borderRadius: 4 }}> {record.moduleProjectId ? (
<Button
</div> type="text"
<Button onClick={() => navigate(`/removelens/${record.moduleProjectId}/removefenbu`)}
type="text" style={{ color: '#656efa', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
onClick={() => handleGenerate(record.id)} >
style={{ color: '#656efa', fontSize: 12, padding: 0, display: 'flex', alignItems: 'center', gap: 4 }}
> </Button>
) : (
</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> </div>
) ),
} },
]; ];
return ( return (
<React.Fragment> <>
<div style={{ minHeight: '94vh', background: '#f5f5f5' }}> <div
<div style={{ background: '#fff', padding: '16px 24px 0 24px', boxShadow: '0 2px 8px rgba(0,0,0,0.06)' }}> style={{
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> minHeight: 'calc(100vh - 90px)',
<Button display: 'flex',
type="text" flexDirection: 'column',
icon={<ArrowLeftOutlined />} overflow: 'hidden',
onClick={() => navigate(-1)} }}
style={{ fontSize: 16, color: '#666' }} >
/> <div style={{ background: '#fff' }}>
<span style={{ fontSize: 18, fontWeight: 600 }}>{mockData.productName}</span> <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>
<div style={{ }}> {taskDetail ? (
<div style={{ background: '#fff', borderRadius: 12, padding: '10px 20px 20px 20px', marginBottom: 20 }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 30, marginBottom: 12 }}> <div style={{ flex: 0.4, background: '#fff', borderRadius: 12 }}>
<div style={{ position: 'relative', width: 280, height: 160, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 30, marginBottom: 12 }}>
<img <div style={{ position: 'relative', width: 280, height: 160, borderRadius: 8, overflow: 'hidden', flexShrink: 0 }}>
src={mockData.videoUrl} <video
alt="视频缩略图" controls
style={{ width: '100%', height: '100%', objectFit: 'cover' }} 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={{ </div>
position: 'absolute', </div>
top: '50%', ) : (
left: '50%', <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
transform: 'translate(-50%, -50%)', <span style={{ fontSize: 16, color: '#999' }}>...</span>
width: 48, </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, height: 48,
background: 'rgba(0,0,0,0.6)', borderRadius: 8,
borderRadius: '50%', borderColor: '#6366f1',
display: 'flex', color: '#6366f1',
alignItems: 'center', fontWeight: 500,
justifyContent: 'center' }}
}}> >
<PlayCircleOutlined style={{ fontSize: 28, color: '#fff' }} /> {loading ? '生成中...' : '手动生成'}
</div> </Button>
</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>
</div> </div>
</div> </div>
</Drawer>
<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>
); );
} }
+119 -57
View File
@@ -1,7 +1,8 @@
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback } from 'react';
import { Button, Modal, Input, Table, Upload, Popconfirm } from 'antd'; import { Button, Modal, Input, Table, Upload, Popconfirm, message } from 'antd';
import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons'; import { FileTextOutlined, CloudUploadOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { uploadVideo, createShotReplication, getShotReplicationList } from '../api';
export default function VideoFrameExtractor() { export default function VideoFrameExtractor() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -10,8 +11,12 @@ export default function VideoFrameExtractor() {
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [productName, setProductName] = useState<string>(''); const [productName, setProductName] = useState<string>('');
const [videoDuration, setVideoDuration] = useState<number>(0); 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 videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -26,7 +31,7 @@ export default function VideoFrameExtractor() {
setProductName(''); setProductName('');
}, [videoUrl]); }, [videoUrl]);
const handleFileChange = (file: File) => { const handleFileChange = async (file: File) => {
if (!file.type.startsWith('video/')) { if (!file.type.startsWith('video/')) {
setError('请选择视频文件'); setError('请选择视频文件');
return false; return false;
@@ -38,10 +43,20 @@ export default function VideoFrameExtractor() {
} }
cleanupResources(); 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; return false;
}; };
@@ -51,27 +66,57 @@ export default function VideoFrameExtractor() {
} }
}, []); }, []);
const tableData = [ const handleCreate = async () => {
{ if (!videoUrl || !productName.trim()) {
id: 1, message.warning('请先上传视频并输入产品名称');
image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=product%20image%20red%20gift%20box%20with%20hearts&image_size=square', return;
originalName: '进圈', }
productName: '他趣',
status: '视频成功', setLoading(true);
createTime: '2026-05-14 17:49:20', try {
}, const params = {
{ video_url: videoUrl,
id: 2, video_duration_seconds: videoDuration,
image: 'https://neeko-copilot.bytedance.net/api/text_to_image?prompt=luxury%20perfume%20bottle%20golden%20elegant&image_size=square', title: productName.trim(),
originalName: '香水', idempotency_key: `shot_${Date.now()}`,
productName: '面霜', };
status: '视频提示词成功',
createTime: '2026-05-08 08:57:46', 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 ( 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={{ maxWidth: 1200, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@@ -107,7 +152,7 @@ export default function VideoFrameExtractor() {
</div> </div>
<button <button
onClick={() => setIsModalOpen(true)} onClick={handleOpenModal}
style={{ style={{
padding: '10px 20px', padding: '10px 20px',
background: 'white', background: 'white',
@@ -254,7 +299,7 @@ export default function VideoFrameExtractor() {
}}> }}>
<video <video
ref={videoRef} ref={videoRef}
src={videoUrl} src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${videoUrl}`}
controls controls
onLoadedMetadata={handleVideoLoaded} onLoadedMetadata={handleVideoLoaded}
style={{ style={{
@@ -292,10 +337,11 @@ export default function VideoFrameExtractor() {
<div style={{ textAlign: 'center', marginTop: 16 }}> <div style={{ textAlign: 'center', marginTop: 16 }}>
<button <button
disabled={!videoUrl || !productName.trim()} onClick={handleCreate}
disabled={!videoUrl || !productName.trim() || loading}
style={{ style={{
padding: '14px 56px', padding: '14px 56px',
background: videoUrl && productName.trim() background: videoUrl && productName.trim() && !loading
? 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)' ? 'linear-gradient(135deg, #6366f1 0%, #ec4899 100%)'
: '#e2e8f0', : '#e2e8f0',
color: 'white', color: 'white',
@@ -303,14 +349,14 @@ export default function VideoFrameExtractor() {
borderRadius: 14, borderRadius: 14,
fontSize: 15, fontSize: 15,
fontWeight: 600, fontWeight: 600,
cursor: videoUrl && productName.trim() ? 'pointer' : 'not-allowed', cursor: videoUrl && productName.trim() && !loading ? 'pointer' : 'not-allowed',
boxShadow: videoUrl && productName.trim() ? '0 6px 20px rgba(99, 102, 241, 0.35)' : 'none', boxShadow: videoUrl && productName.trim() && !loading ? '0 6px 20px rgba(99, 102, 241, 0.35)' : 'none',
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',
gap: 10 gap: 10
}} }}
> >
<span></span> <span>{loading ? '创建中...' : '创建'}</span>
<span style={{ <span style={{
fontSize: 12, fontSize: 12,
opacity: 0.85, opacity: 0.85,
@@ -345,28 +391,23 @@ export default function VideoFrameExtractor() {
<Table <Table
columns={[ columns={[
{ // {
title: '产品图片', // title: '产品图片',
dataIndex: 'image', // dataIndex: 'image',
key: 'image', // key: 'image',
width: 90, // width: 90,
render: (image: string) => ( // render: (image: string) => (
<img // <img
src={image} // src={image}
alt="产品图片" // alt="产品图片"
style={{ width: 50, height: 50, objectFit: 'cover', borderRadius: 6 }} // style={{ width: 50, height: 50, objectFit: 'cover', borderRadius: 6 }}
/> // />
), // ),
}, // },
{
title: '原产品名称',
dataIndex: 'originalName',
key: 'originalName',
},
{ {
title: '产品名称', title: '产品名称',
dataIndex: 'productName', dataIndex: 'title',
key: 'productName', key: 'title',
}, },
{ {
title: '状态', title: '状态',
@@ -375,13 +416,13 @@ export default function VideoFrameExtractor() {
}, },
{ {
title: '创建时间', title: '创建时间',
dataIndex: 'createTime', dataIndex: 'createdAt',
key: 'createTime', key: 'createdAt',
}, },
{ {
title: '操作', title: '操作',
key: 'action', key: 'action',
render: (_, record) => ( render: (record) => (
<button <button
onClick={() => navigate(`/removelens/${record.id}/removeinfo`)} onClick={() => navigate(`/removelens/${record.id}/removeinfo`)}
style={{ color: '#6366f1', textDecoration: 'none', fontSize: 13, border: 'none', background: 'none', cursor: 'pointer' }} style={{ color: '#6366f1', textDecoration: 'none', fontSize: 13, border: 'none', background: 'none', cursor: 'pointer' }}
@@ -393,7 +434,28 @@ export default function VideoFrameExtractor() {
]} ]}
dataSource={tableData} dataSource={tableData}
rowKey="id" 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> </Modal>
</div> </div>
File diff suppressed because it is too large Load Diff