合并代码解决冲突
This commit is contained in:
@@ -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=
|
||||||
@@ -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
|
||||||
|
|||||||
+405
File diff suppressed because one or more lines are too long
-398
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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="请输入文图理解提示词,例如: 你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" 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="请输入文图理解提示词,例如: 你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" 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;
|
|
||||||
@@ -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;
|
||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
+248
-108
@@ -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 ###
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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_user、processing、completed、failed;为空不过滤"),
|
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)}",
|
||||||
|
)
|
||||||
@@ -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.id,step_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.id,step_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.id,step_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.id,step_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.id,step_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_id;duration、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.id,step_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),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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>"""
|
||||||
|
|||||||
@@ -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="分页信息")
|
||||||
@@ -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 建议拆镜原始 JSON,source_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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: 父级区域code(TWO_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
@@ -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 {}}
|
||||||
)
|
)
|
||||||
@@ -24,4 +24,6 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
*.bak
|
*.bak
|
||||||
*.zip
|
*.zip
|
||||||
*.testbak
|
*.testbak
|
||||||
|
|
||||||
|
.env.production
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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%' }}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user