拆镜复刻、爆款开头复刻管理后台完成

This commit is contained in:
2026-06-17 15:42:07 +08:00
parent 5d5e6485ee
commit 0d647a7171
31 changed files with 3042 additions and 2777 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DFBUW3T1.js"></script>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CDdTfqKl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
+10
View File
@@ -22,6 +22,11 @@ import AdminOperationLogs from './pages/AdminOperationLogs';
import AdminOauthAppList from './pages/AdminOauthAppList';
import AdminGenerationRecords from './pages/AdminGenerationRecords';
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';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
@@ -84,6 +89,11 @@ const App = () => {
<Route path="operation-logs" element={<AdminOperationLogs />} />
<Route path="generation-records" element={<AdminGenerationRecords />} />
<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 path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -1,78 +0,0 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider, App as AntApp, Spin } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import AdminLayout from './pages/AdminLayout';
import AdminLoginPage from './pages/AdminLoginPage';
import AdminDashboard from './pages/AdminDashboard';
import AdminUsers from './pages/AdminUsers';
import AdminModels from './pages/AdminModels';
import AdminSettings from './pages/AdminSettings';
import AdminNotificationManager from './pages/AdminNotificationManager';
import AdminCreditRecords from './pages/AdminCreditRecords';
import AdminPaymentConfig from './pages/AdminPaymentConfig';
import AdminIndustries from './pages/AdminIndustries';
import AdminVideoEngines from './pages/AdminVideoEngines';
import AdminImageEngines from './pages/AdminImageEngines';
import AdminCreditRatios from './pages/AdminCreditRatios';
import AdminMenuConfig from './pages/AdminMenuConfig';
import AdminRechargePackages from './pages/AdminRechargePackages';
import AdminOperationLogs from './pages/AdminOperationLogs';
import AdminGenerationRecords from './pages/AdminGenerationRecords';
import { useAdminStore } from './store';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading } = useAdminStore();
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
};
const App = () => {
const { checkAuth } = useAdminStore();
useEffect(() => { checkAuth(); }, []);
return (
<ConfigProvider locale={zhCN} theme={{
token: { colorPrimary: '#6366f1', borderRadius: 8 },
components: {
Button: { controlHeight: 36, controlHeightLG: 44 },
Card: { boxShadow: '0 1px 3px rgba(0,0,0,0.04)' },
Table: { headerBg: '#fafbfc' },
},
}}>
<AntApp>
<BrowserRouter>
<Routes>
<Route path="/login" element={<AdminLoginPage />} />
<Route path="/" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
<Route index element={<AdminDashboard />} />
<Route path="users" element={<AdminUsers />} />
<Route path="credit-records" element={<AdminCreditRecords />} />
<Route path="models" element={<AdminModels />} />
<Route path="credit-ratios" element={<AdminCreditRatios />} />
<Route path="video-engines" element={<AdminVideoEngines />} />
<Route path="industries" element={<AdminIndustries />} />
<Route path="menu-configs" element={<AdminMenuConfig />} />
<Route path="recharge-packages" element={<AdminRechargePackages />} />
<Route path="payment" element={<AdminPaymentConfig />} />
<Route path="settings" element={<AdminSettings />} />
<Route path="notifications" element={<AdminNotificationManager />} />
<Route path="operation-logs" element={<AdminOperationLogs />} />
<Route path="generation-records" element={<AdminGenerationRecords />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AntApp>
</ConfigProvider>
);
};
export default App;
+69
View File
@@ -7,6 +7,9 @@ import type {
User, CreditRecord, Project, GenerationRecord, GenerationParams,
Industry, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
GenerationAiEnginesResponse, GenerationAITaskListOut, GenerationAITaskQueryParams,
AdminHotOpeningTaskQueryParams, HotOpeningTaskListOut, ReplicationProjectDetailOut,
AdminShotTaskSetQueryParams, ShotTaskSetListOut, ShotTaskSetDetailOut,
AdminShotSegmentQueryParams, ShotSegmentListOut, ShotSegmentDetailOut,
} from '../types';
// ── Auth ──────────────────────────────────────────────────
@@ -422,3 +425,69 @@ export async function getAdminGenerationAiTasks(params?: GenerationAITaskQueryPa
return api.get<GenerationAITaskListOut>(`/generation-ai/tasks${qs ? `?${qs}` : ''}`);
}
// ── Replication Modules (Admin readonly pages reuse original APIs) ───────────
function appendParam(params: URLSearchParams, key: string, value?: string | number | null): void {
if (value === undefined || value === null || String(value).trim() === '') return;
params.set(key, String(value));
}
export async function getAdminHotOpeningTasks(params?: AdminHotOpeningTaskQueryParams): Promise<HotOpeningTaskListOut> {
const q = new URLSearchParams();
appendParam(q, 'status', params?.status);
appendParam(q, 'keyword', params?.keyword);
appendParam(q, 'user_id', params?.userId);
appendParam(q, 'user_name', params?.userName);
appendParam(q, 'created_start', params?.createdStart);
appendParam(q, 'created_end', params?.createdEnd);
appendParam(q, 'page', params?.page);
appendParam(q, 'page_size', params?.pageSize);
const qs = q.toString();
return api.get<HotOpeningTaskListOut>(`/hot-opening-replications/tasks${qs ? `?${qs}` : ''}`);
}
export async function getAdminHotOpeningTaskDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
return api.get<ReplicationProjectDetailOut>(`/hot-opening-replications/tasks/${projectId}`);
}
export async function getAdminShotTaskSets(params?: AdminShotTaskSetQueryParams): Promise<ShotTaskSetListOut> {
const q = new URLSearchParams();
appendParam(q, 'status', params?.status);
appendParam(q, 'analysis_status', params?.analysisStatus);
appendParam(q, 'split_status', params?.splitStatus);
appendParam(q, 'keyword', params?.keyword);
appendParam(q, 'user_id', params?.userId);
appendParam(q, 'user_name', params?.userName);
appendParam(q, 'created_start', params?.createdStart);
appendParam(q, 'created_end', params?.createdEnd);
appendParam(q, 'page', params?.page);
appendParam(q, 'page_size', params?.pageSize);
const qs = q.toString();
return api.get<ShotTaskSetListOut>(`/shot-replications/task-sets${qs ? `?${qs}` : ''}`);
}
export async function getAdminShotTaskSetDetail(taskSetId: string): Promise<ShotTaskSetDetailOut> {
return api.get<ShotTaskSetDetailOut>(`/shot-replications/task-sets/${taskSetId}`);
}
export async function getAdminShotSegments(taskSetId: string, params?: AdminShotSegmentQueryParams): Promise<ShotSegmentListOut> {
const q = new URLSearchParams();
appendParam(q, 'source_mode', params?.sourceMode);
appendParam(q, 'split_status', params?.splitStatus);
appendParam(q, 'analysis_status', params?.analysisStatus);
appendParam(q, 'replicate_status', params?.replicateStatus);
appendParam(q, 'page', params?.page);
appendParam(q, 'page_size', params?.pageSize);
const qs = q.toString();
return api.get<ShotSegmentListOut>(`/shot-replications/task-sets/${taskSetId}/segments${qs ? `?${qs}` : ''}`);
}
export async function getAdminShotSegmentDetail(segmentId: string): Promise<ShotSegmentDetailOut> {
return api.get<ShotSegmentDetailOut>(`/shot-replications/segments/${segmentId}`);
}
export async function getAdminShotProjectDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
return api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
}
@@ -0,0 +1,8 @@
import React from 'react';
import AdminReplicationProjectDetail from './AdminReplicationProjectDetail';
const AdminHotOpeningReplicationDetail: React.FC = () => (
<AdminReplicationProjectDetail moduleType="hot_opening_replicate" />
);
export default AdminHotOpeningReplicationDetail;
@@ -0,0 +1,233 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getAdminHotOpeningTasks } from '../api';
import type { HotOpeningTaskListItemOut } from '../types';
import { formatDate } from '../utils/formatDate';
const PAGE_SIZE = 20;
const STATUS_OPTIONS = [
{ value: 'pending', label: '已创建' },
{ value: 'waiting_user', label: '等待用户操作' },
{ value: 'processing', label: '处理中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
];
const STATUS_MAP: Record<string, { color: string; text: string }> = {
pending: { color: 'default', text: '已创建' },
waiting_user: { color: 'processing', text: '等待用户操作' },
processing: { color: 'warning', text: '处理中' },
completed: { color: 'success', text: '已完成' },
failed: { color: 'error', text: '失败' },
cancelled: { color: 'default', text: '已取消' },
};
const STEP_MAP: Record<string, string> = {
material_input: '素材输入',
image_prompt_optimize: '图片 AI 提词',
image_generate: '图片生成',
video_prompt_optimize: '视频 AI 提词',
video_generate: '视频生成',
};
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
if (!status) return <Tag>-</Tag>;
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
return <Tag color={meta.color}>{meta.text}</Tag>;
};
const AdminHotOpeningReplications: React.FC = () => {
const navigate = useNavigate();
const [items, setItems] = useState<HotOpeningTaskListItemOut[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>('');
const [inputKeyword, setInputKeyword] = useState('');
const [inputUserId, setInputUserId] = useState('');
const [inputUserName, setInputUserName] = useState('');
const [createdRange, setCreatedRange] = useState<any>(null);
const [queryKeyword, setQueryKeyword] = useState('');
const [queryUserId, setQueryUserId] = useState('');
const [queryUserName, setQueryUserName] = useState('');
const [queryCreatedRange, setQueryCreatedRange] = useState<any>(null);
const [reloadKey, setReloadKey] = useState(0);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getAdminHotOpeningTasks({
status: status || undefined,
keyword: queryKeyword || undefined,
userId: queryUserId || undefined,
userName: queryUserName || undefined,
createdStart: queryCreatedRange?.[0]?.toISOString?.(),
createdEnd: queryCreatedRange?.[1]?.toISOString?.(),
page,
pageSize: PAGE_SIZE,
});
setItems(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载爆款开头复刻任务失败');
} finally {
setLoading(false);
}
}, [page, queryCreatedRange, queryKeyword, queryUserId, queryUserName, status]);
useEffect(() => {
load();
}, [load, reloadKey]);
const doSearch = () => {
setQueryKeyword(inputKeyword.trim());
setQueryUserId(inputUserId.trim());
setQueryUserName(inputUserName.trim());
setQueryCreatedRange(createdRange);
setPage(1);
};
const resetSearch = () => {
setStatus('');
setInputKeyword('');
setInputUserId('');
setInputUserName('');
setCreatedRange(null);
setQueryKeyword('');
setQueryUserId('');
setQueryUserName('');
setQueryCreatedRange(null);
setPage(1);
setReloadKey(v => v + 1);
};
return (
<div style={{ padding: 24 }}>
<Card>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space>
<Space wrap>
<Select
allowClear
placeholder="任务状态"
style={{ width: 160 }}
value={status || undefined}
onChange={value => { setStatus(value || ''); setPage(1); }}
options={STATUS_OPTIONS}
/>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="关键词:标题/项目名/核心点"
style={{ width: 260 }}
value={inputKeyword}
onChange={e => setInputKeyword(e.target.value)}
onPressEnter={doSearch}
/>
<Input
allowClear
placeholder="用户ID(管理员)"
style={{ width: 190 }}
value={inputUserId}
onChange={e => setInputUserId(e.target.value)}
onPressEnter={doSearch}
/>
<Input
allowClear
placeholder="用户名(管理员)"
style={{ width: 230 }}
value={inputUserName}
onChange={e => setInputUserName(e.target.value)}
onPressEnter={doSearch}
/>
<DatePicker.RangePicker
showTime
value={createdRange}
onChange={setCreatedRange}
placeholder={['创建开始', '创建结束']}
/>
<Button type="primary" icon={<SearchOutlined />} onClick={doSearch}></Button>
<Button onClick={resetSearch}></Button>
</Space>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
showSizeChanger: false,
showTotal: value => `${value}`,
onChange: setPage,
}}
scroll={{ x: 1380 }}
columns={[
{
title: '项目ID',
dataIndex: 'id',
width: 150,
render: (value: string) => <Tooltip title={value}>{shortId(value)}</Tooltip>,
},
{
title: '用户',
width: 180,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Typography.Text>{record.userName || '-'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{shortId(record.userId)}</Typography.Text>
</Space>
),
},
{
title: '项目信息',
width: 260,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Typography.Text strong>{record.title || record.targetProjectName || '-'}</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ maxWidth: 240 }}>{record.sourceProjectName || '-'}</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ maxWidth: 240 }}>{record.coreContentPoint || '-'}</Typography.Text>
</Space>
),
},
{ title: '状态', dataIndex: 'status', width: 130, render: (v: string) => <StatusTag status={v} /> },
{ title: '当前步骤', dataIndex: 'currentStepCode', width: 140, render: (v: string) => STEP_MAP[v] || v || '-' },
{ title: '图片结果', dataIndex: 'finalImageUrl', width: 90, render: (v: string) => v ? <Tag color="success"></Tag> : <Tag></Tag> },
{ title: '视频结果', dataIndex: 'finalVideoUrl', width: 90, render: (v: string) => v ? <Tag color="success"></Tag> : <Tag></Tag> },
{ title: '错误信息', dataIndex: 'errorMessage', width: 220, ellipsis: true, render: (v: string) => v || '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: safeDate },
{
title: '操作',
fixed: 'right',
width: 110,
render: (_, record) => (
<Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/hot-opening-replications/${record.id}`)}></Button>
),
},
]}
/>
</Space>
</Card>
</div>
);
};
export default AdminHotOpeningReplications;
@@ -1,250 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getImageEngines, saveImageEngine, deleteImageEngine } from '../api';
interface ImageEngine {
id: string;
name: string;
provider: string;
apiBase: string;
apiKey: string;
modelName: string;
supportedSizes: string[];
supportedStyles: string[];
generateUrl: string;
isActive: boolean;
priority: number;
}
function parseJsonArray(val: unknown): any[] {
if (Array.isArray(val)) return val;
if (typeof val === 'string') {
try { return JSON.parse(val); } catch { return []; }
}
return [];
}
const AdminImageEngines: React.FC = () => {
const [engines, setEngines] = useState<ImageEngine[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getImageEngines();
setEngines(data.map((e: any) => ({
...e,
supportedSizes: parseJsonArray(e.supportedSizes),
supportedStyles: parseJsonArray(e.supportedStyles),
})));
} catch {
message.error('加载图片引擎失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
const payload = {
name: values.name,
provider: values.provider,
api_base: values.apiBase,
api_key: values.apiKey,
model_name: values.modelName,
supported_sizes: JSON.stringify(values.supportedSizes || []),
supported_styles: JSON.stringify(values.supportedStyles || []),
generate_url: values.generateUrl || '',
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
};
if (modal.engine) {
await saveImageEngine({ id: modal.engine.id, ...payload });
message.success('已更新');
} else {
await saveImageEngine(payload);
message.success('已添加');
}
setModal({ open: false, engine: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
}
};
const handleDelete = async (id: string) => {
try {
await deleteImageEngine(id);
message.success('已删除');
load();
} catch {
message.error('删除失败');
}
};
const openEdit = (engine?: ImageEngine) => {
setModal({ open: true, engine: engine || null });
if (engine) {
form.setFieldsValue(engine);
} else {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
supportedSizes: ['1024x1024', '1024x1792', '1792x1024'],
supportedStyles: ['realistic', 'anime', 'oil_painting'],
});
}
};
const columns = [
{
title: '引擎名称', key: 'name', width: 180,
render: (_: any, r: ImageEngine) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: r.isActive
? 'linear-gradient(135deg, #10b981, #059669)'
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 16,
}}><PictureOutlined /></div>
<div>
<Typography.Text strong>{r.name}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider}</div>
</div>
</div>
),
},
{
title: '支持尺寸', dataIndex: 'supportedSizes', width: 280,
render: (sizes: string[]) => <Space size={2} wrap>{sizes.map(s => <Tag key={s}>{s}</Tag>)}</Space>,
},
{
title: '支持风格', dataIndex: 'supportedStyles', width: 280,
render: (styles: string[]) => <Space size={2} wrap>{styles.map(s => <Tag key={s} color="blue">{s}</Tag>)}</Space>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: ImageEngine) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<PictureOutlined style={{ fontSize: 18, color: '#10b981' }} />
<Typography.Text strong style={{ fontSize: 16 }}>图片引擎配置</Typography.Text>
<Tag color="green">{engines.length} 个引擎</Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
添加引擎
</Button>
</div>
<Table
columns={columns}
dataSource={engines}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><PictureOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={620}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="name" label="引擎名称" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Input placeholder="豆包文生图" size="large" />
</Form.Item>
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'ark', label: '火山引擎 (Ark)' },
]} />
</Form.Item>
</div>
<Form.Item name="apiBase" label="API基础地址" rules={[{ required: true }]}>
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3" size="large" />
</Form.Item>
<Form.Item name="apiKey" label="API Key">
<Input.Password placeholder="sk-****" size="large" />
</Form.Item>
<Form.Item name="modelName" label="模型名称">
<Input placeholder="doubao-seedream-3-0-t2i-250415" size="large" />
</Form.Item>
<Form.Item name="generateUrl" label="生成接口地址">
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
</Form.Item>
<Form.Item name="supportedSizes" label="支持尺寸">
<Select mode="tags" size="large" placeholder="输入尺寸后回车添加" tokenSeparators={[',', '']}
options={[
{ value: '1024x1024' }, { value: '1024x1792' }, { value: '1792x1024' },
{ value: '512x512' }, { value: '768x768' },
]} />
</Form.Item>
<Form.Item name="supportedStyles" label="支持风格">
<Select mode="tags" size="large" placeholder="输入风格后回车添加" tokenSeparators={[',', '']}
options={[
{ value: 'realistic', label: '写实' },
{ value: 'anime', label: '动漫' },
{ value: 'oil_painting', label: '油画' },
{ value: 'watercolor', label: '水彩' },
{ value: 'sketch', label: '素描' },
]} />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级">
<Select size="large" options={[
{ value: 0, label: '0 (默认)' },
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
{ value: 5, label: '5' },
{ value: 10, label: '10 (最高)' },
]} />
</Form.Item>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminImageEngines;
@@ -1,339 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Checkbox, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getImageEngines, saveImageEngine, deleteImageEngine } from '../api';
interface ImageEngine {
id: string;
name: string;
provider: string;
apiBase: string;
apiKey: string;
modelName: string;
supportedModels: string[];
supportedSizes: Record<string, Record<string, string>>;
defaultSize: string;
generateUrl: string;
isActive: boolean;
priority: number;
}
function parseJsonArray(val: unknown): any[] {
if (Array.isArray(val)) return val;
if (typeof val === 'string') {
try { return JSON.parse(val); } catch { return []; }
}
return [];
}
function parseSizes(val: unknown): Record<string, Record<string, string>> {
if (val && typeof val === 'object' && !Array.isArray(val)) return val as Record<string, Record<string, string>>;
if (typeof val === 'string') {
try { const p = JSON.parse(val); return (p && typeof p === 'object' && !Array.isArray(p)) ? p : {}; } catch { return {}; }
}
return {};
}
// Default size options with pixel mappings
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
"2K": {
"1:1": "2048×2048",
"4:3": "2304×1728",
"3:4": "1728×2304",
"16:9": "2560×1440",
"9:16": "1600×2848",
"3:2": "2496×1664",
"2:3": "1664×2496",
"21:9": "3024×1296",
},
"4K": {
"1:1": "4096×4096",
"4:3": "4608×3456",
"3:4": "3520×4704",
"16:9": "5404×3040",
"9:16": "3040×5504",
"3:2": "4992×3328",
"2:3": "3328×4992",
"21:9": "6197×2656",
},
};
const ALL_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9"];
const AdminImageEngines: React.FC = () => {
const [engines, setEngines] = useState<ImageEngine[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getImageEngines();
setEngines(data.map((e: any) => ({
...e,
supportedModels: parseJsonArray(e.supportedModels),
supportedSizes: parseSizes(e.supportedSizes),
})));
} catch {
message.error('加载图片引擎失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
// Build supportedSizes from form values
const sizes: Record<string, Record<string, string>> = {};
for (const tier of ["2K", "4K"]) {
const selected: string[] = values[`size_${tier}`] || [];
if (selected.length > 0) {
sizes[tier] = {};
for (const ratio of selected) {
sizes[tier][ratio] = SIZE_OPTIONS[tier]?.[ratio] || ratio;
}
}
}
const payload = {
name: values.name,
provider: values.provider,
api_base: values.apiBase,
api_key: values.apiKey,
model_name: values.modelName,
supported_models: JSON.stringify(values.supportedModels || []),
supported_sizes: JSON.stringify(sizes),
default_size: values.defaultSize || '2K',
generate_url: values.generateUrl || '',
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
};
if (modal.engine) {
await saveImageEngine({ id: modal.engine.id, ...payload });
message.success('已更新');
} else {
await saveImageEngine(payload);
message.success('已添加');
}
setModal({ open: false, engine: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
}
};
const handleDelete = async (id: string) => {
try {
await deleteImageEngine(id);
message.success('已删除');
load();
} catch {
message.error('删除失败');
}
};
const openEdit = (engine?: ImageEngine) => {
setModal({ open: true, engine: engine || null });
if (engine) {
const sizeFields: Record<string, string[]> = {};
for (const tier of ["2K", "4K"]) {
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
}
form.setFieldsValue({
...engine,
...sizeFields,
});
} else {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0,
supportedModels: ['doubao-seedream-5-0-260128'],
defaultSize: '2K',
size_2K: ALL_RATIOS,
size_4K: ALL_RATIOS,
});
}
};
const columns = [
{
title: '引擎名称', key: 'name', width: 180,
render: (_: any, r: ImageEngine) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: r.isActive
? 'linear-gradient(135deg, #10b981, #059669)'
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 16,
}}><PictureOutlined /></div>
<div>
<Typography.Text strong>{r.name}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.modelName}</div>
</div>
</div>
),
},
{
title: '2K 支持比例', key: 'sizes_2k', width: 260,
render: (_: any, r: ImageEngine) => {
const ratios = Object.keys(r.supportedSizes?.["2K"] || {});
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
return <Space size={2} wrap>{ratios.map(ratio => (
<Tag key={ratio} color="blue">{ratio} {r.supportedSizes["2K"][ratio]}</Tag>
))}</Space>;
},
},
{
title: '4K 支持比例', key: 'sizes_4k', width: 260,
render: (_: any, r: ImageEngine) => {
const ratios = Object.keys(r.supportedSizes?.["4K"] || {});
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
return <Space size={2} wrap>{ratios.map(ratio => (
<Tag key={ratio} color="purple">{ratio} {r.supportedSizes["4K"][ratio]}</Tag>
))}</Space>;
},
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: ImageEngine) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<PictureOutlined style={{ fontSize: 18, color: '#10b981' }} />
<Typography.Text strong style={{ fontSize: 16 }}>图片引擎配置</Typography.Text>
<Tag color="green">{engines.length} 个引擎</Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
添加引擎
</Button>
</div>
<Table
columns={columns}
dataSource={engines}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 1000 }}
/>
</Card>
<Modal
title={<Space><PictureOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={680}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="name" label="引擎名称" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Input placeholder="豆包文生图" size="large" />
</Form.Item>
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'ark', label: '火山引擎 (Ark)' },
]} />
</Form.Item>
</div>
<Form.Item name="apiBase" label="API基础地址" rules={[{ required: true }]}>
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3" size="large" />
</Form.Item>
<Form.Item name="apiKey" label="API Key">
<Input.Password placeholder="sk-****" size="large" />
</Form.Item>
<Form.Item name="modelName" label="默认模型">
<Input placeholder="doubao-seedream-5-0-260128" size="large" />
</Form.Item>
<Form.Item name="supportedModels" label="支持模型列表">
<Select mode="tags" size="large" placeholder="输入模型ID后回车添加" tokenSeparators={[',', '']}
options={[{ value: 'doubao-seedream-5-0-260128' }]} />
</Form.Item>
{/* Size config */}
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 14 }}>尺寸配置</Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
勾选每个档位支持的比例,前台选择后传对应像素值给SDK
</Typography.Text>
</div>
{["2K", "4K"].map(tier => (
<div key={tier} style={{
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
marginBottom: 12, border: '1px solid #f0f0f5',
}}>
<Typography.Text strong style={{ fontSize: 13, color: tier === '2K' ? '#3b82f6' : '#8b5cf6' }}>
{tier}
</Typography.Text>
<Form.Item name={`size_${tier}`} style={{ marginTop: 8, marginBottom: 0 }}>
<Checkbox.Group style={{ width: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6px 0' }}>
{ALL_RATIOS.map(ratio => (
<Checkbox key={ratio} value={ratio} style={{ fontSize: 12 }}>
{ratio} <span style={{ color: '#94a3b8', fontSize: 11 }}>{SIZE_OPTIONS[tier]?.[ratio]}</span>
</Checkbox>
))}
</div>
</Checkbox.Group>
</Form.Item>
</div>
))}
<Form.Item name="defaultSize" label="默认尺寸档位">
<Select size="large" options={[
{ value: '2K', label: '2K' },
{ value: '4K', label: '4K' },
]} />
</Form.Item>
<Form.Item name="generateUrl" label="生成接口地址">
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级">
<Select size="large" options={[
{ value: 0, label: '0 (默认)' },
{ value: 1, label: '1' }, { value: 2, label: '2' }, { value: 3, label: '3' },
{ value: 5, label: '5' }, { value: 10, label: '10 (最高)' },
]} />
</Form.Item>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminImageEngines;
@@ -1,487 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Modal, Popconfirm, Space, Switch, Table, Tag, Typography, Dropdown, Select,
} from 'antd';
import {
AppstoreOutlined, PlusOutlined, EditOutlined, DeleteOutlined, DownOutlined, MinusCircleOutlined,
ShoppingCartOutlined, BookOutlined, HomeOutlined, FireOutlined, RocketOutlined, SkinOutlined,
CompassOutlined, HeartOutlined, CarOutlined, CameraOutlined, CloudOutlined, StarOutlined,
TrophyOutlined, ThunderboltOutlined, BulbOutlined, CoffeeOutlined, CrownOutlined, DashboardOutlined,
FlagOutlined, GlobalOutlined, GiftOutlined, LaptopOutlined, MobileOutlined, MonitorOutlined,
PayCircleOutlined, PictureOutlined, PlayCircleOutlined, SafetyOutlined,
ShoppingOutlined, SmileOutlined, SoundOutlined, TagOutlined, TeamOutlined,
ToolOutlined, TruckOutlined, VideoCameraOutlined, WalletOutlined, BankOutlined,
BuildOutlined, ExperimentOutlined, HighlightOutlined, IdcardOutlined,
MedicineBoxOutlined, ReadOutlined, RestOutlined, SketchOutlined, SolutionOutlined,
} from '@ant-design/icons';
import { getIndustryConfigs, saveIndustryConfig, deleteIndustryConfig } from '../api';
interface OptionGroup {
name: string;
options: string[];
}
interface IndustryItem {
id: string;
key: string;
label: string;
icon: string;
description: string;
skills: string;
optionGroups: OptionGroup[];
isActive: boolean;
sortOrder: number;
}
// Icon library for industries
const ICON_OPTIONS: { name: string; label: string; component: React.ReactNode }[] = [
{ name: 'ShoppingCartOutlined', label: '购物', component: <ShoppingCartOutlined /> },
{ name: 'BookOutlined', label: '书籍', component: <BookOutlined /> },
{ name: 'HomeOutlined', label: '房产', component: <HomeOutlined /> },
{ name: 'FireOutlined', label: '火', component: <FireOutlined /> },
{ name: 'RocketOutlined', label: '科技', component: <RocketOutlined /> },
{ name: 'SkinOutlined', label: '时尚', component: <SkinOutlined /> },
{ name: 'CompassOutlined', label: '指南', component: <CompassOutlined /> },
{ name: 'HeartOutlined', label: '健康', component: <HeartOutlined /> },
{ name: 'CarOutlined', label: '汽车', component: <CarOutlined /> },
{ name: 'CameraOutlined', label: '相机', component: <CameraOutlined /> },
{ name: 'CloudOutlined', label: '云', component: <CloudOutlined /> },
{ name: 'StarOutlined', label: '星', component: <StarOutlined /> },
{ name: 'TrophyOutlined', label: '奖杯', component: <TrophyOutlined /> },
{ name: 'ThunderboltOutlined', label: '闪电', component: <ThunderboltOutlined /> },
{ name: 'BulbOutlined', label: '灯泡', component: <BulbOutlined /> },
{ name: 'CoffeeOutlined', label: '咖啡', component: <CoffeeOutlined /> },
{ name: 'CrownOutlined', label: '皇冠', component: <CrownOutlined /> },
{ name: 'DashboardOutlined', label: '仪表', component: <DashboardOutlined /> },
{ name: 'FlagOutlined', label: '旗帜', component: <FlagOutlined /> },
{ name: 'GlobalOutlined', label: '全球', component: <GlobalOutlined /> },
{ name: 'GiftOutlined', label: '礼物', component: <GiftOutlined /> },
{ name: 'LaptopOutlined', label: '笔记本', component: <LaptopOutlined /> },
{ name: 'MobileOutlined', label: '手机', component: <MobileOutlined /> },
{ name: 'MonitorOutlined', label: '显示器', component: <MonitorOutlined /> },
{ name: 'PayCircleOutlined', label: '支付', component: <PayCircleOutlined /> },
{ name: 'PictureOutlined', label: '图片', component: <PictureOutlined /> },
{ name: 'PlayCircleOutlined', label: '播放', component: <PlayCircleOutlined /> },
{ name: 'SafetyOutlined', label: '安全', component: <SafetyOutlined /> },
{ name: 'ShoppingOutlined', label: '商店', component: <ShoppingOutlined /> },
{ name: 'SmileOutlined', label: '笑脸', component: <SmileOutlined /> },
{ name: 'SoundOutlined', label: '声音', component: <SoundOutlined /> },
{ name: 'TagOutlined', label: '标签', component: <TagOutlined /> },
{ name: 'TeamOutlined', label: '团队', component: <TeamOutlined /> },
{ name: 'ToolOutlined', label: '工具', component: <ToolOutlined /> },
{ name: 'TruckOutlined', label: '物流', component: <TruckOutlined /> },
{ name: 'VideoCameraOutlined', label: '视频', component: <VideoCameraOutlined /> },
{ name: 'WalletOutlined', label: '钱包', component: <WalletOutlined /> },
{ name: 'BankOutlined', label: '银行', component: <BankOutlined /> },
{ name: 'BuildOutlined', label: '建筑', component: <BuildOutlined /> },
{ name: 'ExperimentOutlined', label: '实验', component: <ExperimentOutlined /> },
{ name: 'HighlightOutlined', label: '高亮', component: <HighlightOutlined /> },
{ name: 'IdcardOutlined', label: '名片', component: <IdcardOutlined /> },
{ name: 'MedicineBoxOutlined', label: '医药', component: <MedicineBoxOutlined /> },
{ name: 'ReadOutlined', label: '阅读', component: <ReadOutlined /> },
{ name: 'RestOutlined', label: '休息', component: <RestOutlined /> },
{ name: 'SketchOutlined', label: '钻石', component: <SketchOutlined /> },
{ name: 'SolutionOutlined', label: '方案', component: <SolutionOutlined /> },
];
const iconMap: Record<string, React.ReactNode> = Object.fromEntries(
ICON_OPTIONS.map(o => [o.name, o.component])
);
function getIconComponent(name: string): React.ReactNode {
return iconMap[name] || <AppstoreOutlined />;
}
// Parse skills JSON into { skillItems, optionGroups }
function parseSkills(raw: string | any[]): { skillItems: { key: string; label: string }[]; optionGroups: OptionGroup[] } {
try {
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!Array.isArray(arr)) return { skillItems: [], optionGroups: [] };
const skillItems: { key: string; label: string }[] = [];
const optionGroups: OptionGroup[] = [];
for (const item of arr) {
if (item && item.type === 'option_group' && item.name && Array.isArray(item.options)) {
optionGroups.push({ name: item.name, options: item.options });
} else if (item && item.key && item.label) {
skillItems.push({ key: item.key, label: item.label });
}
}
return { skillItems, optionGroups };
} catch {
return { skillItems: [], optionGroups: [] };
}
}
// Icon Picker Component
const IconPicker: React.FC<{ value?: string; onChange?: (v: string) => void }> = ({ value, onChange }) => {
const selected = ICON_OPTIONS.find(o => o.name === value);
return (
<Dropdown
trigger={['click']}
dropdownRender={() => (
<div style={{
background: '#fff', borderRadius: 12, padding: 12, width: 360,
boxShadow: '0 12px 40px rgba(0,0,0,0.15)', border: '1px solid #f0f0f5',
}}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
{ICON_OPTIONS.map(opt => (
<div key={opt.name} onMouseDown={e => { e.preventDefault(); e.stopPropagation(); onChange?.(opt.name); }} title={opt.label} style={{
width: 40, height: 40, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 18, transition: 'all 0.15s',
background: value === opt.name ? '#6366f1' : 'transparent',
color: value === opt.name ? '#fff' : '#64748b',
border: value === opt.name ? 'none' : '1px solid transparent',
}}
onMouseEnter={e => { if (value !== opt.name) e.currentTarget.style.background = '#f1f5f9'; }}
onMouseLeave={e => { if (value !== opt.name) e.currentTarget.style.background = 'transparent'; }}
>
{opt.component}
</div>
))}
</div>
</div>
)}
>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px',
borderRadius: 8, border: '1px solid #d9d9d9', cursor: 'pointer', height: 40,
}}>
{value ? (
<>
<span style={{ fontSize: 18, color: '#6366f1' }}>{getIconComponent(value)}</span>
<span style={{ color: '#64748b', fontSize: 13 }}>{selected?.label || value}</span>
</>
) : (
<span style={{ color: '#bfbfbf', fontSize: 13 }}>选择图标</span>
)}
<DownOutlined style={{ fontSize: 10, color: '#bfbfbf', marginLeft: 'auto' }} />
</div>
</Dropdown>
);
};
const AdminIndustries: React.FC = () => {
const [industries, setIndustries] = useState<IndustryItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [modal, setModal] = useState<{ open: boolean; item: IndustryItem | null }>({ open: false, item: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getIndustryConfigs();
setIndustries(data.map((item: any) => {
const { skillItems, optionGroups } = parseSkills(item.skills);
return {
id: item.id,
key: item.key,
label: item.label,
icon: item.icon || '',
description: item.description || '',
skills: JSON.stringify(skillItems),
optionGroups,
isActive: item.is_active ?? item.isActive ?? true,
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
};
}));
} catch {
message.error('加载行业配置失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
// Build skills array: skill items + option groups
const skillsArr: any[] = [];
if (values.skills_wentutujie?.trim()) {
skillsArr.push({ type: 'skill', key: '文图理解', label: values.skills_wentutujie.trim() });
}
// Add option groups
const groups: OptionGroup[] = (values.optionGroups || [])
.filter((g: any) => g?.name?.trim())
.map((g: any) => ({
name: g.name.trim(),
options: (g.options || []).filter((o: string) => o?.trim()),
}))
.filter((g: OptionGroup) => g.options.length > 0);
for (const g of groups) {
skillsArr.push({ type: 'option_group', name: g.name, options: g.options });
}
const payload: any = {
key: values.key,
label: values.label,
icon: values.icon || '',
description: values.description || '',
skills: skillsArr,
is_active: values.is_active ?? true,
sort_order: values.sort_order ?? 0,
};
if (modal.item?.id) {
await saveIndustryConfig({ id: modal.item.id, ...payload });
message.success('已更新');
} else {
await saveIndustryConfig(payload);
message.success('已添加');
}
setModal({ open: false, item: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
await deleteIndustryConfig(id);
message.success('已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openEdit = (item?: IndustryItem) => {
setModal({ open: true, item: item || null });
let wentutujie = '';
let optionGroups: OptionGroup[] = [];
if (item) {
// Parse skills for 文图理解
try {
const arr = JSON.parse(item.skills);
if (Array.isArray(arr)) {
for (const s of arr) {
if (s.key === '文图理解') { wentutujie = s.label || ''; break; }
}
}
} catch { /* ignore */ }
optionGroups = item.optionGroups.length > 0 ? item.optionGroups : [{ name: '', options: [] }];
}
if (item) {
form.setFieldsValue({
key: item.key,
label: item.label,
icon: item.icon || '',
description: item.description,
skills_wentutujie: wentutujie,
optionGroups,
is_active: item.isActive,
sort_order: item.sortOrder,
});
} else {
form.resetFields();
form.setFieldsValue({ is_active: true, sort_order: 0, optionGroups: [{ name: '', options: [] }] });
}
};
const columns = [
{
title: '行业', key: 'industry', width: 180,
render: (_: any, r: IndustryItem) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 10, background: '#f1f5f9',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, color: '#6366f1', flexShrink: 0,
}}>
{getIconComponent(r.icon)}
</div>
<div>
<Typography.Text strong>{r.label}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.key}</div>
</div>
</div>
),
},
{ title: '描述', dataIndex: 'description', ellipsis: true },
{
title: '选项配置', key: 'optionGroups', width: 260,
render: (_: any, r: IndustryItem) => {
if (!r.optionGroups || r.optionGroups.length === 0) {
return <Typography.Text style={{ fontSize: 12, color: '#cbd5e1' }}>未配置</Typography.Text>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{r.optionGroups.map((g, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>{g.name}</Tag>
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
{g.options.slice(0, 3).join('、')}{g.options.length > 3 ? `...${g.options.length}项` : ''}
</Typography.Text>
</div>
))}
</div>
);
},
},
{
title: '文图理解生成视频提示词', dataIndex: 'skills', ellipsis: true,
render: (v: string) => {
try {
const arr = JSON.parse(v);
if (Array.isArray(arr)) {
const wtj = arr.find((s: any) => s.key === '文图理解生成视频提示词' || s.key === '文图理解');
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
}
} catch { /* ignore */ }
return <span style={{ color: '#bfbfbf' }}>-</span>;
},
},
{
title: '文图理解生成图片提示词', dataIndex: 'skills', ellipsis: true,
render: (v: string) => {
try {
const arr = JSON.parse(v);
if (Array.isArray(arr)) {
const wtj = arr.find((s: any) => s.key === '文图理解生成图片提示词');
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
}
} catch { /* ignore */ }
return <span style={{ color: '#bfbfbf' }}>-</span>;
},
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: IndustryItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
<Popconfirm title="确定删除该行业?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<AppstoreOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}>行业与技能配置</Typography.Text>
<Tag color="purple">{industries.length} 个行业</Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
添加行业
</Button>
</div>
<Table
columns={columns}
dataSource={industries}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><AppstoreOutlined />{modal.item ? '编辑行业' : '添加行业'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={640}
confirmLoading={saving}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="key" label="行业标识" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入标识' }]}>
<Input placeholder="例如:ecommerce" size="large" />
</Form.Item>
<Form.Item name="label" label="行业名称" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如:电商" size="large" />
</Form.Item>
</div>
<Form.Item name="icon" label="行业图标">
<IconPicker />
</Form.Item>
<Form.Item name="description" label="行业描述">
<Input.TextArea rows={2} placeholder="行业描述" size="large" />
</Form.Item>
<Form.Item name="skills_wentutujie" label="文图理解提示词" extra="用于LLM优化提示词的系统指令,根据行业特点引导AI理解文案与画面的关系">
<Input.TextArea rows={3} placeholder="请输入文图理解提示词,例如:&#10;你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" size="large" />
</Form.Item>
{/* Option Groups */}
<div style={{ marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 13 }}>行业选项配置</Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
添加选项组,每组包含名称和多个选项,前台将显示为下拉选择
</Typography.Text>
</div>
<Form.List name="optionGroups">
{(fields, { add, remove }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{
display: 'flex', gap: 8, alignItems: 'flex-start',
padding: '10px 12px', borderRadius: 10,
background: '#f8f9fc', border: '1px solid #f0f0f5',
}}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Form.Item {...restField} name={[name, 'name']} label="选项名称" style={{ marginBottom: 0 }}
rules={[{ required: true, message: '请输入选项名称' }]}>
<Input placeholder="例如:视频风格" size="middle" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item {...restField} name={[name, 'options']} label="选项内容" style={{ marginBottom: 0 }}>
<Select
mode="tags"
size="middle"
placeholder="输入选项后回车添加"
style={{ borderRadius: 8 }}
tokenSeparators={[',', '', '、']}
/>
</Form.Item>
</div>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: '#ef4444', fontSize: 16, marginTop: 34, cursor: 'pointer', flexShrink: 0 }}
/>
</div>
))}
<Button
type="dashed" onClick={() => add()} block
icon={<PlusOutlined />}
style={{ borderRadius: 8, height: 36 }}
>
添加选项组
</Button>
</div>
)}
</Form.List>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="is_active" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0} style={{ flex: 1 }}>
<Input type="number" size="large" />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminIndustries;
@@ -1,490 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Modal, Popconfirm, Space, Switch, Table, Tag, Typography, Dropdown, Select,
} from 'antd';
import {
AppstoreOutlined, PlusOutlined, EditOutlined, DeleteOutlined, DownOutlined, MinusCircleOutlined,
ShoppingCartOutlined, BookOutlined, HomeOutlined, FireOutlined, RocketOutlined, SkinOutlined,
CompassOutlined, HeartOutlined, CarOutlined, CameraOutlined, CloudOutlined, StarOutlined,
TrophyOutlined, ThunderboltOutlined, BulbOutlined, CoffeeOutlined, CrownOutlined, DashboardOutlined,
FlagOutlined, GlobalOutlined, GiftOutlined, LaptopOutlined, MobileOutlined, MonitorOutlined,
PayCircleOutlined, PictureOutlined, PlayCircleOutlined, SafetyOutlined,
ShoppingOutlined, SmileOutlined, SoundOutlined, TagOutlined, TeamOutlined,
ToolOutlined, TruckOutlined, VideoCameraOutlined, WalletOutlined, BankOutlined,
BuildOutlined, ExperimentOutlined, HighlightOutlined, IdcardOutlined,
MedicineBoxOutlined, ReadOutlined, RestOutlined, SketchOutlined, SolutionOutlined,
} from '@ant-design/icons';
import { getIndustryConfigs, saveIndustryConfig, deleteIndustryConfig } from '../api';
interface OptionGroup {
name: string;
options: string[];
}
interface IndustryItem {
id: string;
key: string;
label: string;
icon: string;
description: string;
skills: string;
optionGroups: OptionGroup[];
isActive: boolean;
sortOrder: number;
}
// Icon library for industries
const ICON_OPTIONS: { name: string; label: string; component: React.ReactNode }[] = [
{ name: 'ShoppingCartOutlined', label: '购物', component: <ShoppingCartOutlined /> },
{ name: 'BookOutlined', label: '书籍', component: <BookOutlined /> },
{ name: 'HomeOutlined', label: '房产', component: <HomeOutlined /> },
{ name: 'FireOutlined', label: '火', component: <FireOutlined /> },
{ name: 'RocketOutlined', label: '科技', component: <RocketOutlined /> },
{ name: 'SkinOutlined', label: '时尚', component: <SkinOutlined /> },
{ name: 'CompassOutlined', label: '指南', component: <CompassOutlined /> },
{ name: 'HeartOutlined', label: '健康', component: <HeartOutlined /> },
{ name: 'CarOutlined', label: '汽车', component: <CarOutlined /> },
{ name: 'CameraOutlined', label: '相机', component: <CameraOutlined /> },
{ name: 'CloudOutlined', label: '云', component: <CloudOutlined /> },
{ name: 'StarOutlined', label: '星', component: <StarOutlined /> },
{ name: 'TrophyOutlined', label: '奖杯', component: <TrophyOutlined /> },
{ name: 'ThunderboltOutlined', label: '闪电', component: <ThunderboltOutlined /> },
{ name: 'BulbOutlined', label: '灯泡', component: <BulbOutlined /> },
{ name: 'CoffeeOutlined', label: '咖啡', component: <CoffeeOutlined /> },
{ name: 'CrownOutlined', label: '皇冠', component: <CrownOutlined /> },
{ name: 'DashboardOutlined', label: '仪表', component: <DashboardOutlined /> },
{ name: 'FlagOutlined', label: '旗帜', component: <FlagOutlined /> },
{ name: 'GlobalOutlined', label: '全球', component: <GlobalOutlined /> },
{ name: 'GiftOutlined', label: '礼物', component: <GiftOutlined /> },
{ name: 'LaptopOutlined', label: '笔记本', component: <LaptopOutlined /> },
{ name: 'MobileOutlined', label: '手机', component: <MobileOutlined /> },
{ name: 'MonitorOutlined', label: '显示器', component: <MonitorOutlined /> },
{ name: 'PayCircleOutlined', label: '支付', component: <PayCircleOutlined /> },
{ name: 'PictureOutlined', label: '图片', component: <PictureOutlined /> },
{ name: 'PlayCircleOutlined', label: '播放', component: <PlayCircleOutlined /> },
{ name: 'SafetyOutlined', label: '安全', component: <SafetyOutlined /> },
{ name: 'ShoppingOutlined', label: '商店', component: <ShoppingOutlined /> },
{ name: 'SmileOutlined', label: '笑脸', component: <SmileOutlined /> },
{ name: 'SoundOutlined', label: '声音', component: <SoundOutlined /> },
{ name: 'TagOutlined', label: '标签', component: <TagOutlined /> },
{ name: 'TeamOutlined', label: '团队', component: <TeamOutlined /> },
{ name: 'ToolOutlined', label: '工具', component: <ToolOutlined /> },
{ name: 'TruckOutlined', label: '物流', component: <TruckOutlined /> },
{ name: 'VideoCameraOutlined', label: '视频', component: <VideoCameraOutlined /> },
{ name: 'WalletOutlined', label: '钱包', component: <WalletOutlined /> },
{ name: 'BankOutlined', label: '银行', component: <BankOutlined /> },
{ name: 'BuildOutlined', label: '建筑', component: <BuildOutlined /> },
{ name: 'ExperimentOutlined', label: '实验', component: <ExperimentOutlined /> },
{ name: 'HighlightOutlined', label: '高亮', component: <HighlightOutlined /> },
{ name: 'IdcardOutlined', label: '名片', component: <IdcardOutlined /> },
{ name: 'MedicineBoxOutlined', label: '医药', component: <MedicineBoxOutlined /> },
{ name: 'ReadOutlined', label: '阅读', component: <ReadOutlined /> },
{ name: 'RestOutlined', label: '休息', component: <RestOutlined /> },
{ name: 'SketchOutlined', label: '钻石', component: <SketchOutlined /> },
{ name: 'SolutionOutlined', label: '方案', component: <SolutionOutlined /> },
];
const iconMap: Record<string, React.ReactNode> = Object.fromEntries(
ICON_OPTIONS.map(o => [o.name, o.component])
);
function getIconComponent(name: string): React.ReactNode {
return iconMap[name] || <AppstoreOutlined />;
}
// Parse skills JSON into { skillItems, optionGroups }
function parseSkills(raw: string | any[]): { skillItems: { key: string; label: string }[]; optionGroups: OptionGroup[] } {
try {
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
if (!Array.isArray(arr)) return { skillItems: [], optionGroups: [] };
const skillItems: { key: string; label: string }[] = [];
const optionGroups: OptionGroup[] = [];
for (const item of arr) {
if (item && item.type === 'option_group' && item.name && Array.isArray(item.options)) {
optionGroups.push({ name: item.name, options: item.options });
} else if (item && item.key && item.label) {
skillItems.push({ key: item.key, label: item.label });
}
}
return { skillItems, optionGroups };
} catch {
return { skillItems: [], optionGroups: [] };
}
}
// Icon Picker Component
const IconPicker: React.FC<{ value?: string; onChange?: (v: string) => void }> = ({ value, onChange }) => {
const selected = ICON_OPTIONS.find(o => o.name === value);
return (
<Dropdown
trigger={['click']}
dropdownRender={() => (
<div style={{
background: '#fff', borderRadius: 12, padding: 12, width: 360,
boxShadow: '0 12px 40px rgba(0,0,0,0.15)', border: '1px solid #f0f0f5',
}}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
{ICON_OPTIONS.map(opt => (
<div key={opt.name} onMouseDown={e => { e.preventDefault(); e.stopPropagation(); onChange?.(opt.name); }} title={opt.label} style={{
width: 40, height: 40, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 18, transition: 'all 0.15s',
background: value === opt.name ? '#6366f1' : 'transparent',
color: value === opt.name ? '#fff' : '#64748b',
border: value === opt.name ? 'none' : '1px solid transparent',
}}
onMouseEnter={e => { if (value !== opt.name) e.currentTarget.style.background = '#f1f5f9'; }}
onMouseLeave={e => { if (value !== opt.name) e.currentTarget.style.background = 'transparent'; }}
>
{opt.component}
</div>
))}
</div>
</div>
)}
>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px',
borderRadius: 8, border: '1px solid #d9d9d9', cursor: 'pointer', height: 40,
}}>
{value ? (
<>
<span style={{ fontSize: 18, color: '#6366f1' }}>{getIconComponent(value)}</span>
<span style={{ color: '#64748b', fontSize: 13 }}>{selected?.label || value}</span>
</>
) : (
<span style={{ color: '#bfbfbf', fontSize: 13 }}>选择图标</span>
)}
<DownOutlined style={{ fontSize: 10, color: '#bfbfbf', marginLeft: 'auto' }} />
</div>
</Dropdown>
);
};
const AdminIndustries: React.FC = () => {
const [industries, setIndustries] = useState<IndustryItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [modal, setModal] = useState<{ open: boolean; item: IndustryItem | null }>({ open: false, item: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getIndustryConfigs();
setIndustries(data.map((item: any) => {
const { skillItems, optionGroups } = parseSkills(item.skills);
return {
id: item.id,
key: item.key,
label: item.label,
icon: item.icon || '',
description: item.description || '',
skills: JSON.stringify(skillItems),
optionGroups,
isActive: item.is_active ?? item.isActive ?? true,
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
};
}));
} catch {
message.error('加载行业配置失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
// Build skills array: skill items + option groups
const skillsArr: any[] = [];
if (values.skills_wentutujie?.trim()) {
skillsArr.push({ type: 'skill', key: '文图理解', label: values.skills_wentutujie.trim() });
}
// Add option groups
const groups: OptionGroup[] = (values.optionGroups || [])
.filter((g: any) => g?.name?.trim())
.map((g: any) => ({
name: g.name.trim(),
options: (g.options || []).filter((o: string) => o?.trim()),
}))
.filter((g: OptionGroup) => g.options.length > 0);
for (const g of groups) {
skillsArr.push({ type: 'option_group', name: g.name, options: g.options });
}
const payload: any = {
key: values.key,
label: values.label,
icon: values.icon || '',
description: values.description || '',
skills: skillsArr,
is_active: values.is_active ?? true,
sort_order: values.sort_order ?? 0,
};
if (modal.item?.id) {
await saveIndustryConfig({ id: modal.item.id, ...payload });
message.success('已更新');
} else {
await saveIndustryConfig(payload);
message.success('已添加');
}
setModal({ open: false, item: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
await deleteIndustryConfig(id);
message.success('已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openEdit = (item?: IndustryItem) => {
setModal({ open: true, item: item || null });
let wentutujie = '';
let wentutujieImage = '';
let optionGroups: OptionGroup[] = [];
if (item) {
// Parse skills for 文图理解 video and image prompts
try {
const arr = JSON.parse(item.skills);
if (Array.isArray(arr)) {
for (const s of arr) {
if (s.key === '文图理解生成视频提示词' || s.key === '文图理解') { wentutujie = s.label || ''; }
if (s.key === '文图理解生成图片提示词') { wentutujieImage = s.label || ''; }
}
}
} catch { /* ignore */ }
optionGroups = item.optionGroups.length > 0 ? item.optionGroups : [{ name: '', options: [] }];
}
if (item) {
form.setFieldsValue({
key: item.key,
label: item.label,
icon: item.icon || '',
description: item.description,
skills_wentutujie: wentutujie,
skills_wentutujie_image: wentutujieImage,
optionGroups,
is_active: item.isActive,
sort_order: item.sortOrder,
});
} else {
form.resetFields();
form.setFieldsValue({ is_active: true, sort_order: 0, optionGroups: [{ name: '', options: [] }] });
}
};
const columns = [
{
title: '行业', key: 'industry', width: 180,
render: (_: any, r: IndustryItem) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 10, background: '#f1f5f9',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, color: '#6366f1', flexShrink: 0,
}}>
{getIconComponent(r.icon)}
</div>
<div>
<Typography.Text strong>{r.label}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.key}</div>
</div>
</div>
),
},
{ title: '描述', dataIndex: 'description', ellipsis: true },
{
title: '选项配置', key: 'optionGroups', width: 260,
render: (_: any, r: IndustryItem) => {
if (!r.optionGroups || r.optionGroups.length === 0) {
return <Typography.Text style={{ fontSize: 12, color: '#cbd5e1' }}>未配置</Typography.Text>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{r.optionGroups.map((g, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>{g.name}</Tag>
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
{g.options.slice(0, 3).join('、')}{g.options.length > 3 ? `...${g.options.length}项` : ''}
</Typography.Text>
</div>
))}
</div>
);
},
},
{
title: '文图理解生成视频提示词', dataIndex: 'skills', ellipsis: true,
render: (v: string) => {
try {
const arr = JSON.parse(v);
if (Array.isArray(arr)) {
const wtj = arr.find((s: any) => s.key === '文图理解生成视频提示词' || s.key === '文图理解');
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
}
} catch { /* ignore */ }
return <span style={{ color: '#bfbfbf' }}>-</span>;
},
},
{
title: '文图理解生成图片提示词', dataIndex: 'skills', ellipsis: true,
render: (v: string) => {
try {
const arr = JSON.parse(v);
if (Array.isArray(arr)) {
const wtj = arr.find((s: any) => s.key === '文图理解生成图片提示词');
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
}
} catch { /* ignore */ }
return <span style={{ color: '#bfbfbf' }}>-</span>;
},
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: IndustryItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
<Popconfirm title="确定删除该行业?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<AppstoreOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}>行业与技能配置</Typography.Text>
<Tag color="purple">{industries.length} 个行业</Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
添加行业
</Button>
</div>
<Table
columns={columns}
dataSource={industries}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><AppstoreOutlined />{modal.item ? '编辑行业' : '添加行业'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={640}
confirmLoading={saving}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="key" label="行业标识" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入标识' }]}>
<Input placeholder="例如:ecommerce" size="large" />
</Form.Item>
<Form.Item name="label" label="行业名称" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如:电商" size="large" />
</Form.Item>
</div>
<Form.Item name="icon" label="行业图标">
<IconPicker />
</Form.Item>
<Form.Item name="description" label="行业描述">
<Input.TextArea rows={2} placeholder="行业描述" size="large" />
</Form.Item>
<Form.Item name="skills_wentutujie" label="文图理解提示词" extra="用于LLM优化提示词的系统指令,根据行业特点引导AI理解文案与画面的关系">
<Input.TextArea rows={3} placeholder="请输入文图理解提示词,例如:&#10;你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" size="large" />
</Form.Item>
{/* Option Groups */}
<div style={{ marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 13 }}>行业选项配置</Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
添加选项组,每组包含名称和多个选项,前台将显示为下拉选择
</Typography.Text>
</div>
<Form.List name="optionGroups">
{(fields, { add, remove }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{
display: 'flex', gap: 8, alignItems: 'flex-start',
padding: '10px 12px', borderRadius: 10,
background: '#f8f9fc', border: '1px solid #f0f0f5',
}}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Form.Item {...restField} name={[name, 'name']} label="选项名称" style={{ marginBottom: 0 }}
rules={[{ required: true, message: '请输入选项名称' }]}>
<Input placeholder="例如:视频风格" size="middle" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item {...restField} name={[name, 'options']} label="选项内容" style={{ marginBottom: 0 }}>
<Select
mode="tags"
size="middle"
placeholder="输入选项后回车添加"
style={{ borderRadius: 8 }}
tokenSeparators={[',', '', '、']}
/>
</Form.Item>
</div>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: '#ef4444', fontSize: 16, marginTop: 34, cursor: 'pointer', flexShrink: 0 }}
/>
</div>
))}
<Button
type="dashed" onClick={() => add()} block
icon={<PlusOutlined />}
style={{ borderRadius: 8, height: 36 }}
>
添加选项组
</Button>
</div>
)}
</Form.List>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="is_active" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0} style={{ flex: 1 }}>
<Input type="number" size="large" />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminIndustries;
@@ -1,318 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd';
import {
DashboardOutlined,
UserOutlined,
RobotOutlined,
SettingOutlined,
BellOutlined,
ThunderboltOutlined,
LogoutOutlined,
LockOutlined,
WalletOutlined,
CalculatorOutlined,
DollarOutlined,
AppstoreOutlined,
PlayCircleOutlined,
GiftOutlined,
HomeOutlined,
StarOutlined,
HeartOutlined,
CameraOutlined,
FileTextOutlined,
HistoryOutlined,
VideoCameraOutlined,
PictureOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
import { useAdminStore } from '../store';
import { getMenuConfigs, adminChangePassword } from '../api';
const { Sider, Content } = Layout;
const iconMap: Record<string, React.ReactNode> = {
DashboardOutlined: <DashboardOutlined />,
UserOutlined: <UserOutlined />,
RobotOutlined: <RobotOutlined />,
SettingOutlined: <SettingOutlined />,
BellOutlined: <BellOutlined />,
LogoutOutlined: <LogoutOutlined />,
WalletOutlined: <WalletOutlined />,
CalculatorOutlined: <CalculatorOutlined />,
DollarOutlined: <DollarOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
PlayCircleOutlined: <PlayCircleOutlined />,
GiftOutlined: <GiftOutlined />,
HomeOutlined: <HomeOutlined />,
StarOutlined: <StarOutlined />,
HeartOutlined: <HeartOutlined />,
CameraOutlined: <CameraOutlined />,
FileTextOutlined: <FileTextOutlined />,
HistoryOutlined: <HistoryOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
};
const AdminLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, loading, logout } = useAdminStore();
const [collapsed, setCollapsed] = useState(false);
const [menuItems, setMenuItems] = useState<any[]>([]);
const [pwdModal, setPwdModal] = useState(false);
const [pwdForm] = Form.useForm();
useEffect(() => {
getMenuConfigs().then(data => {
let menus = data.filter((m: any) => {
const target = m.menu_target ?? m.menuTarget ?? 'admin';
const active = m.is_active ?? m.isActive ?? true;
return active && (target === 'admin' || target === 'both');
});
// Non-admin backend users: filter by allowedMenus
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
if (user && !isAdminUser) {
const allowed = user.allowedMenus ?? (user as any)?.allowed_menus;
if (allowed && Array.isArray(allowed) && allowed.length > 0) {
const allowedSet = new Set(allowed);
menus = menus.filter((m: any) => {
const menuType = m.menu_type ?? m.menuType;
if (menuType === 'group') {
return menus.some((c: any) => {
const pid = c.parent_id ?? c.parentId;
return pid === m.id && allowedSet.has(c.path);
});
}
return allowedSet.has(m.path);
});
} else {
// No allowed menus set and not admin = show nothing
menus = [];
}
}
setMenuItems(menus);
}).catch(() => {});
}, [user]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!user) {
return <Navigate to="/admin/login" replace />;
}
// Build menu items for Ant Design Menu — single sorted list interleaving groups and pages
const childMap: Record<string, any[]> = {};
menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group' && (m.parent_id ?? m.parentId)).forEach(m => {
const pid = m.parent_id ?? m.parentId;
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(m);
});
// Sort all menu items by sortOrder, then build ant menu items
const sorted = [...menuItems].sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0));
const antMenuItems: any[] = [];
sorted.forEach(m => {
const menuType = m.menu_type ?? m.menuType;
const parentId = m.parent_id ?? m.parentId;
if (menuType === 'group') {
const children = (childMap[m.id] || [])
.sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0))
.map(c => ({
key: c.path,
icon: iconMap[c.icon] || undefined,
label: c.label,
}));
if (children.length > 0) {
antMenuItems.push({
key: `group-${m.id}`,
icon: iconMap[m.icon] || undefined,
label: m.label,
children,
});
}
} else if (!parentId) {
antMenuItems.push({
key: m.path,
icon: iconMap[m.icon] || undefined,
label: m.label,
});
}
});
// Fallback only for admin users — non-admin users with no permissions see nothing
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
if (antMenuItems.length === 0 && isAdminUser) {
antMenuItems.push(
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
);
}
const selectedKey = location.pathname;
// Find the leaf menu key that matches (for sub-menus, need to find the right key)
let activeKey = selectedKey;
const allLeafKeys: string[] = [];
antMenuItems.forEach(item => {
if (item.children) {
item.children.forEach((c: any) => allLeafKeys.push(c.key));
} else {
allLeafKeys.push(item.key);
}
});
// Exact match or prefix match
if (!allLeafKeys.includes(activeKey)) {
activeKey = allLeafKeys.find(k => activeKey.startsWith(k)) || '/';
}
// Find open keys for sub-menus
const openKeys: string[] = [];
antMenuItems.forEach(item => {
if (item.children) {
if (item.children.some((c: any) => c.key === activeKey)) {
openKeys.push(item.key);
}
}
});
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
theme="dark"
style={{
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
}}
>
{/* Logo */}
<div style={{
height: 64, display: 'flex', alignItems: 'center',
justifyContent: 'center', gap: 10,
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
{!collapsed && (
<span style={{ color: '#f1f5f9', fontSize: 15, fontWeight: 700 }}>
管理后台
</span>
)}
</div>
{/* Menu */}
<Menu
mode="inline"
selectedKeys={[activeKey]}
defaultOpenKeys={openKeys}
items={antMenuItems}
onClick={({ key }) => {
if (key.startsWith('group-')) return;
navigate(key);
}}
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
theme="dark"
/>
</Sider>
<Layout>
{/* Header */}
<div style={{
height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px',
}}>
<Typography.Text strong style={{ fontSize: 16 }}>
{antMenuItems.find(m => m.key === activeKey)?.label
|| antMenuItems.flatMap(m => m.children || []).find((c: any) => c.key === activeKey)?.label
|| '管理后台'}
</Typography.Text>
<Dropdown menu={{
items: [
{ key: 'user', icon: <UserOutlined />, label: user?.username, disabled: true },
{ type: 'divider' as const },
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
],
onClick: ({ key }) => {
if (key === 'logout') { logout(); navigate('/login'); }
if (key === 'changePwd') { setPwdModal(true); pwdForm.resetFields(); }
},
}} placement="bottomRight" arrow>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', padding: '4px 8px', borderRadius: 8, transition: 'background 0.2s' }}>
<Avatar size={28} icon={<UserOutlined />}
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>
{user?.username}
</Typography.Text>
</div>
</Dropdown>
</div>
{/* Content */}
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
{antMenuItems.length === 0 && !isAdminUser ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', color: '#94a3b8' }}>
<LockOutlined style={{ fontSize: 48, marginBottom: 16, color: '#cbd5e1' }} />
<div style={{ fontSize: 16, fontWeight: 600, color: '#64748b' }}>暂无任何权限</div>
<div style={{ fontSize: 13, marginTop: 8 }}>请联系管理员配置菜单权限</div>
</div>
) : (
<Outlet />
)}
</Content>
</Layout>
<Modal
title={<Space><LockOutlined />修改密码</Space>}
open={pwdModal}
onOk={async () => {
try {
const values = await pwdForm.validateFields();
if (values.newPassword !== values.confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
await adminChangePassword(values.oldPassword, values.newPassword);
message.success('密码修改成功');
setPwdModal(false);
pwdForm.resetFields();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '修改失败');
}
}}
onCancel={() => { setPwdModal(false); pwdForm.resetFields(); }}
okText="确认修改" cancelText="取消" width={420}
>
<Form form={pwdForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
<Input.Password placeholder="请输入原密码" size="large" />
</Form.Item>
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
<Input.Password placeholder="请输入新密码(至少6位)" size="large" />
</Form.Item>
<Form.Item name="confirmPassword" label="确认新密码" rules={[{ required: true, message: '请再次输入新密码' }]}>
<Input.Password placeholder="请再次输入新密码" size="large" />
</Form.Item>
</Form>
</Modal>
</Layout>
);
};
export default AdminLayout;
@@ -1,319 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd';
import {
DashboardOutlined,
UserOutlined,
RobotOutlined,
SettingOutlined,
BellOutlined,
ThunderboltOutlined,
LogoutOutlined,
LockOutlined,
WalletOutlined,
CalculatorOutlined,
DollarOutlined,
AppstoreOutlined,
PlayCircleOutlined,
GiftOutlined,
HomeOutlined,
StarOutlined,
HeartOutlined,
CameraOutlined,
FileTextOutlined,
HistoryOutlined,
VideoCameraOutlined,
PictureOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
import { useAdminStore } from '../store';
import { getMenuConfigs, adminChangePassword } from '../api';
const { Sider, Content } = Layout;
const iconMap: Record<string, React.ReactNode> = {
DashboardOutlined: <DashboardOutlined />,
UserOutlined: <UserOutlined />,
RobotOutlined: <RobotOutlined />,
SettingOutlined: <SettingOutlined />,
BellOutlined: <BellOutlined />,
LogoutOutlined: <LogoutOutlined />,
WalletOutlined: <WalletOutlined />,
CalculatorOutlined: <CalculatorOutlined />,
DollarOutlined: <DollarOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
PlayCircleOutlined: <PlayCircleOutlined />,
GiftOutlined: <GiftOutlined />,
HomeOutlined: <HomeOutlined />,
StarOutlined: <StarOutlined />,
HeartOutlined: <HeartOutlined />,
CameraOutlined: <CameraOutlined />,
FileTextOutlined: <FileTextOutlined />,
HistoryOutlined: <HistoryOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
PictureOutlined: <PictureOutlined />,
};
const AdminLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, loading, logout } = useAdminStore();
const [collapsed, setCollapsed] = useState(false);
const [menuItems, setMenuItems] = useState<any[]>([]);
const [pwdModal, setPwdModal] = useState(false);
const [pwdForm] = Form.useForm();
useEffect(() => {
getMenuConfigs().then(data => {
let menus = data.filter((m: any) => {
const target = m.menu_target ?? m.menuTarget ?? 'admin';
const active = m.is_active ?? m.isActive ?? true;
return active && (target === 'admin' || target === 'both');
});
// Non-admin backend users: filter by allowedMenus
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
if (user && !isAdminUser) {
const allowed = user.allowedMenus ?? (user as any)?.allowed_menus;
if (allowed && Array.isArray(allowed) && allowed.length > 0) {
const allowedSet = new Set(allowed);
menus = menus.filter((m: any) => {
const menuType = m.menu_type ?? m.menuType;
if (menuType === 'group') {
return menus.some((c: any) => {
const pid = c.parent_id ?? c.parentId;
return pid === m.id && allowedSet.has(c.path);
});
}
return allowedSet.has(m.path);
});
} else {
// No allowed menus set and not admin = show nothing
menus = [];
}
}
setMenuItems(menus);
}).catch(() => {});
}, [user]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!user) {
return <Navigate to="/admin/login" replace />;
}
// Build menu items for Ant Design Menu — single sorted list interleaving groups and pages
const childMap: Record<string, any[]> = {};
menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group' && (m.parent_id ?? m.parentId)).forEach(m => {
const pid = m.parent_id ?? m.parentId;
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(m);
});
// Sort all menu items by sortOrder, then build ant menu items
const sorted = [...menuItems].sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0));
const antMenuItems: any[] = [];
sorted.forEach(m => {
const menuType = m.menu_type ?? m.menuType;
const parentId = m.parent_id ?? m.parentId;
if (menuType === 'group') {
const children = (childMap[m.id] || [])
.sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0))
.map(c => ({
key: c.path,
icon: iconMap[c.icon] || undefined,
label: c.label,
}));
if (children.length > 0) {
antMenuItems.push({
key: `group-${m.id}`,
icon: iconMap[m.icon] || undefined,
label: m.label,
children,
});
}
} else if (!parentId) {
antMenuItems.push({
key: m.path,
icon: iconMap[m.icon] || undefined,
label: m.label,
});
}
});
// Fallback only for admin users — non-admin users with no permissions see nothing
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
if (antMenuItems.length === 0 && isAdminUser) {
antMenuItems.push(
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
);
}
const selectedKey = location.pathname;
// Find the leaf menu key that matches (for sub-menus, need to find the right key)
let activeKey = selectedKey;
const allLeafKeys: string[] = [];
antMenuItems.forEach(item => {
if (item.children) {
item.children.forEach((c: any) => allLeafKeys.push(c.key));
} else {
allLeafKeys.push(item.key);
}
});
// Exact match or prefix match
if (!allLeafKeys.includes(activeKey)) {
activeKey = allLeafKeys.find(k => activeKey.startsWith(k)) || '/';
}
// Find open keys for sub-menus
const openKeys: string[] = [];
antMenuItems.forEach(item => {
if (item.children) {
if (item.children.some((c: any) => c.key === activeKey)) {
openKeys.push(item.key);
}
}
});
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
theme="dark"
style={{
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
}}
>
{/* Logo */}
<div style={{
height: 64, display: 'flex', alignItems: 'center',
justifyContent: 'center', gap: 10,
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
{!collapsed && (
<span style={{ color: '#f1f5f9', fontSize: 15, fontWeight: 700 }}>
管理后台
</span>
)}
</div>
{/* Menu */}
<Menu
mode="inline"
selectedKeys={[activeKey]}
defaultOpenKeys={openKeys}
items={antMenuItems}
onClick={({ key }) => {
if (key.startsWith('group-')) return;
navigate(key);
}}
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
theme="dark"
/>
</Sider>
<Layout>
{/* Header */}
<div style={{
height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px',
}}>
<Typography.Text strong style={{ fontSize: 16 }}>
{antMenuItems.find(m => m.key === activeKey)?.label
|| antMenuItems.flatMap(m => m.children || []).find((c: any) => c.key === activeKey)?.label
|| '管理后台'}
</Typography.Text>
<Dropdown menu={{
items: [
{ key: 'user', icon: <UserOutlined />, label: user?.username, disabled: true },
{ type: 'divider' as const },
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
],
onClick: ({ key }) => {
if (key === 'logout') { logout(); navigate('/login'); }
if (key === 'changePwd') { setPwdModal(true); pwdForm.resetFields(); }
},
}} placement="bottomRight" arrow>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', padding: '4px 8px', borderRadius: 8, transition: 'background 0.2s' }}>
<Avatar size={28} icon={<UserOutlined />}
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>
{user?.username}
</Typography.Text>
</div>
</Dropdown>
</div>
{/* Content */}
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
{antMenuItems.length === 0 && !isAdminUser ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', color: '#94a3b8' }}>
<LockOutlined style={{ fontSize: 48, marginBottom: 16, color: '#cbd5e1' }} />
<div style={{ fontSize: 16, fontWeight: 600, color: '#64748b' }}>暂无任何权限</div>
<div style={{ fontSize: 13, marginTop: 8 }}>请联系管理员配置菜单权限</div>
</div>
) : (
<Outlet />
)}
</Content>
</Layout>
<Modal
title={<Space><LockOutlined />修改密码</Space>}
open={pwdModal}
onOk={async () => {
try {
const values = await pwdForm.validateFields();
if (values.newPassword !== values.confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
await adminChangePassword(values.oldPassword, values.newPassword);
message.success('密码修改成功');
setPwdModal(false);
pwdForm.resetFields();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '修改失败');
}
}}
onCancel={() => { setPwdModal(false); pwdForm.resetFields(); }}
okText="确认修改" cancelText="取消" width={420}
>
<Form form={pwdForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
<Input.Password placeholder="请输入原密码" size="large" />
</Form.Item>
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
<Input.Password placeholder="请输入新密码(至少6位)" size="large" />
</Form.Item>
<Form.Item name="confirmPassword" label="确认新密码" rules={[{ required: true, message: '请再次输入新密码' }]}>
<Input.Password placeholder="请再次输入新密码" size="large" />
</Form.Item>
</Form>
</Modal>
</Layout>
);
};
export default AdminLayout;
@@ -0,0 +1,384 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Card,
Collapse,
Descriptions,
Empty,
Space,
Spin,
Steps,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import {
ArrowLeftOutlined,
FileImageOutlined,
PlayCircleOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import { getAdminHotOpeningTaskDetail, getAdminShotProjectDetail } from '../api';
import type { ReplicationProjectDetailOut, ReplicationStepOut } from '../types';
import { formatDate } from '../utils/formatDate';
import JsonCollapse, { JsonBlock } from './adminReplication/components/JsonCollapse';
import MediaPreview from './adminReplication/components/MediaPreview';
import StatusTag, { getModuleLabel, getStatusLabel, getStepCodeLabel } from './adminReplication/components/StatusTag';
import VideoPromptSchemaViewer from './adminReplication/components/VideoPromptSchemaViewer';
type ReplicationModuleType = 'hot_opening_replicate' | 'shot_replicate';
interface AdminReplicationProjectDetailProps {
moduleType?: ReplicationModuleType;
}
const STEP_ORDER = [
'material_input',
'image_prompt_optimize',
'image_generate',
'video_prompt_optimize',
'video_generate',
];
const STEP_DESCRIPTIONS: Record<string, string> = {
material_input: '参考素材、项目名称和核心内容点',
image_prompt_optimize: '图片 AI 提词优化结果',
image_generate: '图片引擎、生成参数和结果图',
video_prompt_optimize: '视频 JSON schema、动作流程和最终提词',
video_generate: '视频引擎、生成参数、封面和结果视频',
};
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => {
if (!value) return '-';
return value.length > 18 ? `${value.slice(0, 10)}...${value.slice(-4)}` : value;
};
const EmptyText: React.FC<{ text?: string }> = ({ text = '-' }) => (
<Typography.Text type="secondary">{text}</Typography.Text>
);
const StepRawJson: React.FC<{ step?: ReplicationStepOut | null }> = ({ step }) => {
if (!step) return null;
return <JsonCollapse input={step.input} output={step.output} />;
};
const StepHeader: React.FC<{ index: number; stepCode: string; step?: ReplicationStepOut | null; current?: boolean }> = ({ index, stepCode, step, current }) => (
<Space wrap size={8}>
<Typography.Text strong>{`${index}步:${getStepCodeLabel(stepCode)}`}</Typography.Text>
<StatusTag status={step?.status || 'not_started'} />
{current ? <Tag color="blue"></Tag> : null}
<Typography.Text type="secondary">{STEP_DESCRIPTIONS[stepCode] || stepCode}</Typography.Text>
{step?.updatedAt ? <Typography.Text type="secondary">{safeDate(step.updatedAt)}</Typography.Text> : null}
{step?.chatTaskId ? <Tooltip title={step.chatTaskId}><Tag>Chat{shortId(step.chatTaskId)}</Tag></Tooltip> : null}
</Space>
);
const buildDefaultActiveKeys = (detail: ReplicationProjectDetailOut | null, stepsByCode: Record<string, ReplicationStepOut>): string[] => {
if (!detail) return [];
const keys = new Set<string>();
keys.add('material_input');
if (detail.currentStepCode) keys.add(detail.currentStepCode);
Object.values(stepsByCode).forEach(step => {
if (step.status === 'failed' || step.errorMessage) keys.add(step.stepCode);
});
if (detail.errorMessage) keys.add(detail.currentStepCode || 'material_input');
return Array.from(keys);
};
const renderErrorAlert = (messageText?: string | null, title = '错误信息') => {
if (!messageText) return null;
return <Alert type="error" showIcon message={title} description={messageText} style={{ marginTop: 12, marginBottom: 12 }} />;
};
const renderPromptText = (value?: string | null, empty = '暂无提词') => {
if (!value) return <EmptyText text={empty} />;
return <Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{value}</Typography.Paragraph>;
};
const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps> = ({ moduleType = 'shot_replicate' }) => {
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const [detail, setDetail] = useState<ReplicationProjectDetailOut | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!projectId) return;
setLoading(true);
try {
const res = moduleType === 'hot_opening_replicate'
? await getAdminHotOpeningTaskDetail(projectId)
: await getAdminShotProjectDetail(projectId);
setDetail(res);
} catch (e: any) {
message.error(e?.message || '加载复刻项目详情失败');
} finally {
setLoading(false);
}
}, [moduleType, projectId]);
useEffect(() => {
load();
}, [load]);
const stepsByCode = useMemo(() => {
const map: Record<string, ReplicationStepOut> = {};
(detail?.steps || []).forEach(step => { map[step.stepCode] = step; });
return map;
}, [detail]);
const moduleValue = detail?.module || moduleType;
const moduleName = getModuleLabel(moduleValue);
const stepItems = useMemo(() => STEP_ORDER.map(code => {
const step = stepsByCode[code];
let status: 'wait' | 'process' | 'finish' | 'error' = 'wait';
if (step?.status === 'completed') status = 'finish';
else if (step?.status === 'processing') status = 'process';
else if (step?.status === 'failed') status = 'error';
return {
title: getStepCodeLabel(code),
description: step ? <StatusTag status={step.status} /> : '未创建',
status,
};
}), [stepsByCode]);
const defaultActiveKeys = useMemo(() => buildDefaultActiveKeys(detail, stepsByCode), [detail, stepsByCode]);
if (loading && !detail) {
return <div style={{ padding: 64, textAlign: 'center' }}><Spin size="large" /></div>;
}
if (!detail) {
return (
<Card style={{ margin: 24 }}>
<Empty description="未找到复刻项目详情" />
</Card>
);
}
const materialStep = stepsByCode.material_input;
const imagePromptStep = stepsByCode.image_prompt_optimize;
const imageGenerateStep = stepsByCode.image_generate;
const videoPromptStep = stepsByCode.video_prompt_optimize;
const videoGenerateStep = stepsByCode.video_generate;
const imageResultUrl = detail.imageGeneration?.resultImageUrl || detail.finalImageUrl;
const videoCoverUrl = detail.videoGeneration?.resultVideoCoverUrl || detail.finalVideoCoverUrl;
const videoResultUrl = detail.videoGeneration?.resultVideoUrl || detail.finalVideoUrl;
const collapseItems = [
{
key: 'material_input',
label: <StepHeader index={1} stepCode="material_input" step={materialStep} current={detail.currentStepCode === 'material_input'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="模块类型"><StatusTag status={moduleValue} /></Descriptions.Item>
<Descriptions.Item label="素材步骤ID">{detail.material?.materialStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="参考素材项目名">{detail.material?.sourceProjectName || '-'}</Descriptions.Item>
<Descriptions.Item label="生成项目名称">{detail.material?.targetProjectName || '-'}</Descriptions.Item>
<Descriptions.Item label="核心内容点" span={2}>{detail.material?.coreContentPoint || '-'}</Descriptions.Item>
</Descriptions>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
<Card size="small" title={<Space><PlayCircleOutlined /></Space>}>
<MediaPreview type="video" url={detail.material?.materialVideoUrl} emptyDescription="暂无素材视频" />
</Card>
<Card size="small" title={<Space><FileImageOutlined /></Space>}>
<MediaPreview type="image" url={detail.material?.materialImageUrl} emptyDescription="暂无素材图片" />
</Card>
</div>
{renderErrorAlert(materialStep?.errorMessage)}
<StepRawJson step={materialStep} />
</Space>
),
},
{
key: 'image_prompt_optimize',
label: <StepHeader index={2} stepCode="image_prompt_optimize" step={imagePromptStep} current={detail.currentStepCode === 'image_prompt_optimize'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="提词步骤ID">{detail.imageGeneration?.promptStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="步骤状态"><StatusTag status={imagePromptStep?.status || detail.imageGeneration?.status} /></Descriptions.Item>
<Descriptions.Item label="图片提示词" span={2}>{renderPromptText(detail.imageGeneration?.prompt, '暂无图片提示词')}</Descriptions.Item>
</Descriptions>
{renderErrorAlert(imagePromptStep?.errorMessage || detail.imageGeneration?.errorMessage)}
<StepRawJson step={imagePromptStep} />
</Space>
),
},
{
key: 'image_generate',
label: <StepHeader index={3} stepCode="image_generate" step={imageGenerateStep} current={detail.currentStepCode === 'image_generate'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="生成步骤ID">{detail.imageGeneration?.generateStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="Chat任务ID">{detail.imageGeneration?.chatTaskId || '-'}</Descriptions.Item>
<Descriptions.Item label="状态"><StatusTag status={detail.imageGeneration?.status || imageGenerateStep?.status} /></Descriptions.Item>
<Descriptions.Item label="图片引擎ID">{detail.imageGeneration?.engineId || '-'}</Descriptions.Item>
<Descriptions.Item label="图片引擎名称">{detail.imageGeneration?.engineName || '-'}</Descriptions.Item>
<Descriptions.Item label="生成参数" span={3}><JsonBlock value={detail.imageGeneration?.params || {}} maxHeight={160} /></Descriptions.Item>
</Descriptions>
{renderErrorAlert(detail.imageGeneration?.errorMessage || imageGenerateStep?.errorMessage)}
<Card size="small" title={<Space><FileImageOutlined /></Space>}>
<MediaPreview
type="image"
url={imageResultUrl}
height={360}
emptyDescription={imageGenerateStep?.status === 'failed' || detail.imageGeneration?.errorMessage ? '图片生成失败,未返回图片地址' : '暂无图片结果'}
errorDescription="图片资源加载失败,可能图片文件不存在、签名过期或访问权限受限。"
/>
</Card>
<StepRawJson step={imageGenerateStep} />
</Space>
),
},
{
key: 'video_prompt_optimize',
label: <StepHeader index={4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="提词步骤ID">{detail.videoGeneration?.promptStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="步骤状态"><StatusTag status={videoPromptStep?.status} /></Descriptions.Item>
<Descriptions.Item label="提词参数" span={3}><JsonBlock value={detail.videoGeneration?.promptParams || {}} maxHeight={160} /></Descriptions.Item>
</Descriptions>
{renderErrorAlert(videoPromptStep?.errorMessage || detail.videoGeneration?.errorMessage)}
<VideoPromptSchemaViewer schema={detail.videoGeneration?.promptSchema} finalPrompt={detail.videoGeneration?.finalPrompt} />
<StepRawJson step={videoPromptStep} />
</Space>
),
},
{
key: 'video_generate',
label: <StepHeader index={5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
children: (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={3} bordered size="small">
<Descriptions.Item label="生成步骤ID">{detail.videoGeneration?.generateStepId || '-'}</Descriptions.Item>
<Descriptions.Item label="Chat任务ID">{detail.videoGeneration?.chatTaskId || '-'}</Descriptions.Item>
<Descriptions.Item label="状态"><StatusTag status={detail.videoGeneration?.status || videoGenerateStep?.status} /></Descriptions.Item>
<Descriptions.Item label="视频引擎ID">{detail.videoGeneration?.engineId || '-'}</Descriptions.Item>
<Descriptions.Item label="视频引擎名称">{detail.videoGeneration?.engineName || '-'}</Descriptions.Item>
<Descriptions.Item label="生成参数" span={3}><JsonBlock value={detail.videoGeneration?.params || {}} maxHeight={160} /></Descriptions.Item>
</Descriptions>
{renderErrorAlert(detail.videoGeneration?.errorMessage || videoGenerateStep?.errorMessage)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
<Card size="small" title={<Space><FileImageOutlined /></Space>}>
<MediaPreview
type="image"
url={videoCoverUrl}
emptyDescription={videoGenerateStep?.status === 'failed' || detail.videoGeneration?.errorMessage ? '视频生成失败,未返回封面' : '暂无视频封面'}
errorDescription="视频封面加载失败,可能文件不存在、签名过期或访问权限受限。"
/>
</Card>
<Card size="small" title={<Space><VideoCameraOutlined /></Space>}>
<MediaPreview
type="video"
url={videoResultUrl}
emptyDescription={videoGenerateStep?.status === 'failed' || detail.videoGeneration?.errorMessage ? '视频生成失败,未返回视频地址' : '暂无最终视频'}
errorDescription="视频资源加载失败,可能文件不存在、签名过期或访问权限受限。"
/>
</Card>
</div>
<StepRawJson step={videoGenerateStep} />
</Space>
),
},
];
return (
<div style={{ padding: 24 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space wrap style={{ justifyContent: 'space-between', width: '100%' }}>
<Space wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)}></Button>
<Typography.Title level={3} style={{ margin: 0 }}>{moduleName}</Typography.Title>
<StatusTag status={detail.status} />
{detail.module && detail.module !== moduleType ? <Tag color="gold">{getModuleLabel(detail.module)}</Tag> : null}
</Space>
<Button onClick={load} loading={loading}></Button>
</Space>
<Card>
<Descriptions title="基础信息" column={3} bordered size="small">
<Descriptions.Item label="项目ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="模块类型"><StatusTag status={moduleValue} /></Descriptions.Item>
<Descriptions.Item label="用户ID">{detail.userId || '-'}</Descriptions.Item>
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
<Descriptions.Item label="当前步骤"><Tooltip title={detail.currentStepCode || ''}>{getStepCodeLabel(detail.currentStepCode)}</Tooltip></Descriptions.Item>
<Descriptions.Item label="状态"><StatusTag status={detail.status} /></Descriptions.Item>
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
<Descriptions.Item label="完成时间">{safeDate(detail.completedAt)}</Descriptions.Item>
{detail.errorMessage ? <Descriptions.Item label="错误信息" span={3}><Alert type="error" showIcon message={detail.errorMessage} /></Descriptions.Item> : null}
</Descriptions>
</Card>
<Card title="步骤进度">
<Steps items={stepItems} />
</Card>
<Card title="最终结果预览">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
<Card size="small" title="最终图片">
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
</Card>
<Card size="small" title="最终视频封面">
<MediaPreview type="image" url={detail.finalVideoCoverUrl || detail.videoGeneration?.resultVideoCoverUrl} height={220} emptyDescription="暂无最终视频封面" />
</Card>
<Card size="small" title="最终视频">
<MediaPreview type="video" url={detail.finalVideoUrl || detail.videoGeneration?.resultVideoUrl} height={220} emptyDescription="暂无最终视频" />
</Card>
</div>
</Card>
<Collapse defaultActiveKey={defaultActiveKeys} items={collapseItems} />
<Collapse
items={[
{
key: 'steps-table',
label: '完整步骤列表',
children: (
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.steps || []}
scroll={{ x: 1100 }}
columns={[
{ title: '序号', dataIndex: 'stepIndex', width: 70 },
{ title: '步骤', dataIndex: 'stepCode', width: 160, render: (v: string) => <Tooltip title={v}>{getStepCodeLabel(v)}</Tooltip> },
{ title: '状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} /> },
{ title: '版本', dataIndex: 'version', width: 70 },
{ title: '当前有效', dataIndex: 'isCurrent', width: 90, render: (v: boolean) => v ? <Tag color="success"></Tag> : <Tag></Tag> },
{ title: 'Chat任务', dataIndex: 'chatTaskId', width: 160, render: (v: string) => <Tooltip title={v || ''}>{shortId(v)}</Tooltip> },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
{ title: '完成时间', dataIndex: 'completedAt', width: 170, render: safeDate },
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true, render: (v: string) => v || '-' },
]}
/>
),
},
{
key: 'raw-detail',
label: '完整详情原始 JSON',
children: <JsonBlock value={detail} maxHeight={520} />,
},
]}
/>
</Space>
</div>
);
};
export default AdminReplicationProjectDetail;
@@ -0,0 +1,220 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { getAdminShotTaskSets } from '../api';
import type { ShotTaskSetOut } from '../types';
import { formatDate } from '../utils/formatDate';
const PAGE_SIZE = 20;
const TASK_STATUS_OPTIONS = [
{ value: 'pending_analysis', label: '等待分析' },
{ value: 'analyzing', label: '分析中' },
{ value: 'analysis_completed', label: '分析完成' },
{ value: 'analysis_failed', label: '分析失败' },
{ value: 'splitting', label: '拆镜中' },
{ value: 'split_completed', label: '拆镜完成' },
{ value: 'partial_failed', label: '部分失败' },
{ value: 'failed', label: '失败' },
];
const ANALYSIS_STATUS_OPTIONS = [
{ value: 'pending', label: '待分析' },
{ value: 'processing', label: '分析中' },
{ value: 'completed', label: '分析完成' },
{ value: 'failed', label: '分析失败' },
];
const SPLIT_STATUS_OPTIONS = [
{ value: 'none', label: '未拆镜' },
{ value: 'pending', label: '待拆镜' },
{ value: 'processing', label: '拆镜中' },
{ value: 'completed', label: '拆镜完成' },
{ value: 'failed', label: '拆镜失败' },
{ value: 'retry_waiting', label: '等待重试' },
];
const STATUS_MAP: Record<string, { color: string; text: string }> = {
pending_analysis: { color: 'default', text: '等待分析' },
analyzing: { color: 'processing', text: '分析中' },
analysis_completed: { color: 'success', text: '分析完成' },
analysis_failed: { color: 'error', text: '分析失败' },
splitting: { color: 'warning', text: '拆镜中' },
split_completed: { color: 'success', text: '拆镜完成' },
partial_failed: { color: 'orange', text: '部分失败' },
failed: { color: 'error', text: '失败' },
none: { color: 'default', text: '未拆镜' },
pending: { color: 'default', text: '待处理' },
processing: { color: 'warning', text: '处理中' },
completed: { color: 'success', text: '完成' },
retry_waiting: { color: 'orange', text: '等待重试' },
};
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
if (!status) return <Tag>-</Tag>;
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
return <Tag color={meta.color}>{meta.text}</Tag>;
};
const AdminShotReplications: React.FC = () => {
const navigate = useNavigate();
const [items, setItems] = useState<ShotTaskSetOut[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [status, setStatus] = useState('');
const [analysisStatus, setAnalysisStatus] = useState('');
const [splitStatus, setSplitStatus] = useState('');
const [inputKeyword, setInputKeyword] = useState('');
const [inputUserId, setInputUserId] = useState('');
const [inputUserName, setInputUserName] = useState('');
const [createdRange, setCreatedRange] = useState<any>(null);
const [queryKeyword, setQueryKeyword] = useState('');
const [queryUserId, setQueryUserId] = useState('');
const [queryUserName, setQueryUserName] = useState('');
const [queryCreatedRange, setQueryCreatedRange] = useState<any>(null);
const [reloadKey, setReloadKey] = useState(0);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getAdminShotTaskSets({
status: status || undefined,
analysisStatus: analysisStatus || undefined,
splitStatus: splitStatus || undefined,
keyword: queryKeyword || undefined,
userId: queryUserId || undefined,
userName: queryUserName || undefined,
createdStart: queryCreatedRange?.[0]?.toISOString?.(),
createdEnd: queryCreatedRange?.[1]?.toISOString?.(),
page,
pageSize: PAGE_SIZE,
});
setItems(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载拆镜复刻任务失败');
} finally {
setLoading(false);
}
}, [analysisStatus, page, queryCreatedRange, queryKeyword, queryUserId, queryUserName, splitStatus, status]);
useEffect(() => {
load();
}, [load, reloadKey]);
const doSearch = () => {
setQueryKeyword(inputKeyword.trim());
setQueryUserId(inputUserId.trim());
setQueryUserName(inputUserName.trim());
setQueryCreatedRange(createdRange);
setPage(1);
};
const resetSearch = () => {
setStatus('');
setAnalysisStatus('');
setSplitStatus('');
setInputKeyword('');
setInputUserId('');
setInputUserName('');
setCreatedRange(null);
setQueryKeyword('');
setQueryUserId('');
setQueryUserName('');
setQueryCreatedRange(null);
setPage(1);
setReloadKey(v => v + 1);
};
return (
<div style={{ padding: 24 }}>
<Card>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title>
<Typography.Text type="secondary">AI </Typography.Text>
</div>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space>
<Space wrap>
<Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} />
<Select allowClear placeholder="分析状态" style={{ width: 140 }} value={analysisStatus || undefined} onChange={v => { setAnalysisStatus(v || ''); setPage(1); }} options={ANALYSIS_STATUS_OPTIONS} />
<Select allowClear placeholder="拆镜状态" style={{ width: 140 }} value={splitStatus || undefined} onChange={v => { setSplitStatus(v || ''); setPage(1); }} options={SPLIT_STATUS_OPTIONS} />
<Input allowClear prefix={<SearchOutlined />} placeholder="关键词:标题/内容/分类/受众" style={{ width: 260 }} value={inputKeyword} onChange={e => setInputKeyword(e.target.value)} onPressEnter={doSearch} />
<Input allowClear placeholder="用户ID(管理员)" style={{ width: 190 }} value={inputUserId} onChange={e => setInputUserId(e.target.value)} onPressEnter={doSearch} />
<Input allowClear placeholder="用户名(管理员)" style={{ width: 230 }} value={inputUserName} onChange={e => setInputUserName(e.target.value)} onPressEnter={doSearch} />
<DatePicker.RangePicker showTime value={createdRange} onChange={setCreatedRange} placeholder={['创建开始', '创建结束']} />
<Button type="primary" icon={<SearchOutlined />} onClick={doSearch}></Button>
<Button onClick={resetSearch}></Button>
</Space>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, showTotal: value => `${value}`, onChange: setPage }}
scroll={{ x: 1500 }}
columns={[
{ title: '任务集ID', dataIndex: 'id', width: 150, render: (v: string) => <Tooltip title={v}>{shortId(v)}</Tooltip> },
{
title: '用户',
width: 180,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Typography.Text>{record.userName || '-'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{shortId(record.userId)}</Typography.Text>
</Space>
),
},
{
title: '任务信息',
width: 300,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Typography.Text strong>{record.title || '-'}</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ maxWidth: 280 }}>{record.originalVideoCategory || '-'}</Typography.Text>
<Typography.Text type="secondary" ellipsis style={{ maxWidth: 280 }}>{record.originalVideoAudience || '-'}</Typography.Text>
</Space>
),
},
{ title: '总状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} /> },
{ title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
{
title: '切片进度',
width: 180,
render: (_, record) => {
const totalSegments = record.segmentCount || 0;
const done = record.completedSegmentCount || 0;
const percent = totalSegments ? Math.round((done / totalSegments) * 100) : 0;
return <Progress size="small" percent={percent} format={() => `${done}/${totalSegments}`} />;
},
},
{ title: '失败片段', dataIndex: 'failedSegmentCount', width: 90, render: (v: number) => v ? <Tag color="error">{v}</Tag> : <Tag>0</Tag> },
{ title: '视频时长', dataIndex: 'videoDurationSeconds', width: 100, render: (v: number) => `${Number(v || 0).toFixed(1)}s` },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: safeDate },
{
title: '操作',
fixed: 'right',
width: 120,
render: (_, record) => <Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/shot-replications/task-sets/${record.id}`)}></Button>,
},
]}
/>
</Space>
</Card>
</div>
);
};
export default AdminShotReplications;
@@ -0,0 +1,340 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
Alert,
Button,
Card,
Collapse,
Descriptions,
Drawer,
Empty,
Input,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import { ArrowLeftOutlined, EyeOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate, useParams } from 'react-router-dom';
import { getAdminShotSegmentDetail, getAdminShotSegments, getAdminShotTaskSetDetail } from '../api';
import type { ShotAiSuggestionOut, ShotSegmentDetailOut, ShotSegmentOut, ShotTaskSetDetailOut } from '../types';
import { formatDate } from '../utils/formatDate';
import { getStepCodeLabel } from './adminReplication/components/StatusTag';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
const PAGE_SIZE = 20;
const SOURCE_OPTIONS = [
{ value: 'ai_suggestion', label: 'AI 建议' },
{ value: 'custom', label: '自定义' },
];
const SPLIT_STATUS_OPTIONS = [
{ value: 'none', label: '未拆镜' },
{ value: 'pending', label: '待拆镜' },
{ value: 'processing', label: '拆镜中' },
{ value: 'completed', label: '拆镜完成' },
{ value: 'failed', label: '拆镜失败' },
{ value: 'retry_waiting', label: '等待重试' },
];
const ANALYSIS_STATUS_OPTIONS = [
{ value: 'not_required', label: '无需分析' },
{ value: 'pending', label: '待分析' },
{ value: 'processing', label: '分析中' },
{ value: 'completed', label: '分析完成' },
{ value: 'failed', label: '分析失败' },
];
const REPLICATE_STATUS_OPTIONS = [
{ value: 'not_started', label: '未复刻' },
{ value: 'project_created', label: '已创建项目' },
{ value: 'processing', label: '复刻中' },
{ value: 'completed', label: '复刻完成' },
{ value: 'failed', label: '复刻失败' },
];
const STATUS_MAP: Record<string, { color: string; text: string }> = {
pending_analysis: { color: 'default', text: '等待分析' },
analyzing: { color: 'processing', text: '分析中' },
analysis_completed: { color: 'success', text: '分析完成' },
analysis_failed: { color: 'error', text: '分析失败' },
splitting: { color: 'warning', text: '拆镜中' },
split_completed: { color: 'success', text: '拆镜完成' },
partial_failed: { color: 'orange', text: '部分失败' },
failed: { color: 'error', text: '失败' },
none: { color: 'default', text: '未拆镜' },
pending: { color: 'default', text: '待处理' },
processing: { color: 'warning', text: '处理中' },
completed: { color: 'success', text: '完成' },
retry_waiting: { color: 'orange', text: '等待重试' },
not_required: { color: 'default', text: '无需分析' },
not_started: { color: 'default', text: '未复刻' },
project_created: { color: 'processing', text: '已创建项目' },
};
const apiUrl = (url?: string | null): string => {
if (!url) return '';
const value = String(url).trim();
if (!value) return '';
if (/^(https?:)?\/\//i.test(value) || /^(blob|data):/i.test(value)) return value;
return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
};
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
if (!status) return <Tag>-</Tag>;
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
return <Tag color={meta.color}>{meta.text}</Tag>;
};
const JsonBlock: React.FC<{ value: unknown; maxHeight?: number }> = ({ value, maxHeight = 420 }) => (
<pre style={{ margin: 0, padding: 12, maxHeight, overflow: 'auto', background: '#0f172a', color: '#e2e8f0', borderRadius: 8, fontSize: 12, lineHeight: 1.6 }}>
{JSON.stringify(value ?? {}, null, 2)}
</pre>
);
const VideoPreview: React.FC<{ url?: string | null; height?: number }> = ({ url, height = 280 }) => {
if (!url) return <Empty description="暂无视频" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
return <video src={apiUrl(url)} controls preload="metadata" style={{ width: '100%', maxHeight: height, borderRadius: 12, background: '#0f172a' }} />;
};
const SuggestionTable: React.FC<{ items?: ShotAiSuggestionOut[] }> = ({ items = [] }) => (
<Table
rowKey="index"
size="small"
pagination={false}
dataSource={items}
scroll={{ x: true }}
columns={[
{ title: '序号', dataIndex: 'index', width: 70 },
{ title: '时间节点', dataIndex: 'timeNode', width: 120 },
{ title: '开始', dataIndex: 'startSecond', width: 80, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
{ title: '结束', dataIndex: 'endSecond', width: 80, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
{ title: '内容', dataIndex: 'content', ellipsis: true },
{ title: '分类', dataIndex: 'category', width: 140 },
{ title: '受众', dataIndex: 'audience', ellipsis: true },
]}
/>
);
const AdminShotTaskSetDetail: React.FC = () => {
const { taskSetId } = useParams<{ taskSetId: string }>();
const navigate = useNavigate();
const [detail, setDetail] = useState<ShotTaskSetDetailOut | null>(null);
const [segments, setSegments] = useState<ShotSegmentOut[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [segmentLoading, setSegmentLoading] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [sourceMode, setSourceMode] = useState('');
const [splitStatus, setSplitStatus] = useState('');
const [analysisStatus, setAnalysisStatus] = useState('');
const [replicateStatus, setReplicateStatus] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [segmentDetail, setSegmentDetail] = useState<ShotSegmentDetailOut | null>(null);
const loadDetail = useCallback(async () => {
if (!taskSetId) return;
setLoading(true);
try {
const res = await getAdminShotTaskSetDetail(taskSetId);
setDetail(res);
} catch (e: any) {
message.error(e?.message || '加载拆镜任务详情失败');
} finally {
setLoading(false);
}
}, [taskSetId]);
const loadSegments = useCallback(async () => {
if (!taskSetId) return;
setSegmentLoading(true);
try {
const res = await getAdminShotSegments(taskSetId, {
sourceMode: sourceMode || undefined,
splitStatus: splitStatus || undefined,
analysisStatus: analysisStatus || undefined,
replicateStatus: replicateStatus || undefined,
page,
pageSize: PAGE_SIZE,
});
setSegments(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载拆镜片段失败');
} finally {
setSegmentLoading(false);
}
}, [analysisStatus, page, replicateStatus, sourceMode, splitStatus, taskSetId]);
useEffect(() => { loadDetail(); }, [loadDetail, reloadKey]);
useEffect(() => { loadSegments(); }, [loadSegments, reloadKey]);
const openSegmentDetail = async (segmentId: string) => {
setDrawerOpen(true);
setSegmentDetail(null);
try {
const res = await getAdminShotSegmentDetail(segmentId);
setSegmentDetail(res);
} catch (e: any) {
message.error(e?.message || '加载片段详情失败');
}
};
const resetFilters = () => {
setSourceMode('');
setSplitStatus('');
setAnalysisStatus('');
setReplicateStatus('');
setPage(1);
setReloadKey(v => v + 1);
};
if (loading && !detail) {
return <div style={{ padding: 64, textAlign: 'center' }}><Spin size="large" /></div>;
}
if (!detail) {
return <Card style={{ margin: 24 }}><Empty description="未找到拆镜任务详情" /></Card>;
}
return (
<div style={{ padding: 24 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/shot-replications')}></Button>
<Typography.Title level={3} style={{ margin: 0 }}></Typography.Title>
<StatusTag status={detail.status} />
</Space>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space>
<Card>
<Descriptions title="总任务信息" column={3} bordered size="small">
<Descriptions.Item label="任务集ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户ID">{detail.userId || '-'}</Descriptions.Item>
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
<Descriptions.Item label="视频时长">{Number(detail.videoDurationSeconds || 0).toFixed(2)}s</Descriptions.Item>
<Descriptions.Item label="总状态"><StatusTag status={detail.status} /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} /></Descriptions.Item>
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} /></Descriptions.Item>
<Descriptions.Item label="片段数量">{detail.completedSegmentCount}/{detail.segmentCount} {detail.failedSegmentCount}</Descriptions.Item>
<Descriptions.Item label="原视频分类">{detail.originalVideoCategory || '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
<Descriptions.Item label="原视频内容" span={3}>{detail.originalVideoContent || '-'}</Descriptions.Item>
<Descriptions.Item label="原视频受众" span={3}>{detail.originalVideoAudience || '-'}</Descriptions.Item>
{detail.analysisErrorMessage ? <Descriptions.Item label="分析错误" span={3}><Alert type="error" message={detail.analysisErrorMessage} /></Descriptions.Item> : null}
{detail.splitErrorMessage ? <Descriptions.Item label="拆镜错误" span={3}><Alert type="error" message={detail.splitErrorMessage} /></Descriptions.Item> : null}
</Descriptions>
</Card>
<Card title={<Space><PlayCircleOutlined /></Space>}>
<VideoPreview url={detail.videoUrl} height={360} />
</Card>
<Card title="AI 建议拆镜列表">
{detail.aiSuggestions?.length ? <SuggestionTable items={detail.aiSuggestions} /> : <Empty description="暂无 AI 建议拆镜" />}
<Collapse
size="small"
ghost
style={{ marginTop: 12 }}
items={[{ key: 'raw', label: '查看原视频分析完整 JSON', children: <JsonBlock value={detail.analysisResultJson} /> }]}
/>
</Card>
<Card title="片段列表">
<Space wrap style={{ marginBottom: 16 }}>
<Select allowClear placeholder="片段来源" style={{ width: 140 }} value={sourceMode || undefined} options={SOURCE_OPTIONS} onChange={v => { setSourceMode(v || ''); setPage(1); }} />
<Select allowClear placeholder="切割状态" style={{ width: 140 }} value={splitStatus || undefined} options={SPLIT_STATUS_OPTIONS} onChange={v => { setSplitStatus(v || ''); setPage(1); }} />
<Select allowClear placeholder="分析状态" style={{ width: 140 }} value={analysisStatus || undefined} options={ANALYSIS_STATUS_OPTIONS} onChange={v => { setAnalysisStatus(v || ''); setPage(1); }} />
<Select allowClear placeholder="复刻状态" style={{ width: 150 }} value={replicateStatus || undefined} options={REPLICATE_STATUS_OPTIONS} onChange={v => { setReplicateStatus(v || ''); setPage(1); }} />
<Button icon={<SearchOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
<Button onClick={resetFilters}></Button>
</Space>
<Table
rowKey="id"
loading={segmentLoading}
dataSource={segments}
pagination={{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, showTotal: value => `${value}`, onChange: setPage }}
scroll={{ x: 1550 }}
columns={[
{ title: '片段ID', dataIndex: 'id', width: 150, render: (v: string) => <Tooltip title={v}>{shortId(v)}</Tooltip> },
{ title: '序号', dataIndex: 'segmentIndex', width: 70 },
{ title: '来源', dataIndex: 'sourceMode', width: 100, render: (v: string) => v === 'ai_suggestion' ? <Tag color="purple">AI建议</Tag> : <Tag color="cyan"></Tag> },
{ title: '时间节点', dataIndex: 'timeNode', width: 130 },
{ title: '时长', dataIndex: 'durationSeconds', width: 90, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
{ title: '切割', dataIndex: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
{ title: '片段内容', dataIndex: 'segmentContent', width: 260, ellipsis: true, render: (v: string) => v || '-' },
{ title: '分类', dataIndex: 'segmentCategory', width: 120, render: (v: string) => v || '-' },
{
title: '关联项目',
width: 220,
render: (_, record) => record.moduleProjectId ? (
<Space direction="vertical" size={0}>
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}`)}>{shortId(record.moduleProjectId)}</Button>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{record.moduleProjectTitle || getStepCodeLabel(record.moduleProjectCurrentStepCode)}</Typography.Text>
<StatusTag status={record.moduleProjectStatus} />
</Space>
) : <Tag></Tag>,
},
{ title: '错误', dataIndex: 'splitLastError', width: 220, ellipsis: true, render: (v: string, record) => v || record.analysisErrorMessage || '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
{
title: '操作',
fixed: 'right',
width: 100,
render: (_, record) => <Button type="link" icon={<EyeOutlined />} onClick={() => openSegmentDetail(record.id)}></Button>,
},
]}
/>
</Card>
</Space>
<Drawer title="片段详情" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)} destroyOnClose>
{!segmentDetail ? <Spin /> : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="片段ID" span={2}>{segmentDetail.id}</Descriptions.Item>
<Descriptions.Item label="时间节点">{segmentDetail.timeNode}</Descriptions.Item>
<Descriptions.Item label="时长">{Number(segmentDetail.durationSeconds || 0).toFixed(2)}s</Descriptions.Item>
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} /></Descriptions.Item>
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} /></Descriptions.Item>
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} /></Descriptions.Item>
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
<Descriptions.Item label="片段内容" span={2}>{segmentDetail.segmentContent || '-'}</Descriptions.Item>
<Descriptions.Item label="片段分类">{segmentDetail.segmentCategory || '-'}</Descriptions.Item>
<Descriptions.Item label="片段受众">{segmentDetail.segmentAudience || '-'}</Descriptions.Item>
</Descriptions>
<Card size="small" title="片段视频">
<VideoPreview url={segmentDetail.segmentVideoUrl} height={300} />
</Card>
{segmentDetail.splitLastError ? <Alert type="error" message="切割错误" description={segmentDetail.splitLastError} /> : null}
{segmentDetail.analysisErrorMessage ? <Alert type="error" message="分析错误" description={segmentDetail.analysisErrorMessage} /> : null}
<Collapse
size="small"
items={[
{ key: 'analysis', label: '片段分析 JSON', children: <JsonBlock value={segmentDetail.analysisJson} /> },
{ key: 'suggestion', label: 'AI 建议原始 JSON', children: <JsonBlock value={segmentDetail.aiSuggestionJson} /> },
]}
/>
</Space>
)}
</Drawer>
</div>
);
};
export default AdminShotTaskSetDetail;
@@ -0,0 +1,59 @@
import React from 'react';
import { Collapse, Empty } from 'antd';
interface JsonBlockProps {
value: unknown;
maxHeight?: number;
}
export const JsonBlock: React.FC<JsonBlockProps> = ({ value, maxHeight = 420 }) => {
if (value === undefined || value === null || value === '') {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无 JSON 数据" />;
}
return (
<pre
style={{
margin: 0,
padding: 12,
maxHeight,
overflow: 'auto',
background: '#0f172a',
color: '#e2e8f0',
borderRadius: 8,
fontSize: 12,
lineHeight: 1.6,
}}
>
{JSON.stringify(value, null, 2)}
</pre>
);
};
interface JsonCollapseProps {
input?: unknown;
output?: unknown;
raw?: unknown;
inputLabel?: string;
outputLabel?: string;
rawLabel?: string;
}
const JsonCollapse: React.FC<JsonCollapseProps> = ({
input,
output,
raw,
inputLabel = '查看 input_json',
outputLabel = '查看 output_json',
rawLabel = '查看原始 JSON',
}) => {
const items = [];
if (input !== undefined) items.push({ key: 'input', label: inputLabel, children: <JsonBlock value={input} /> });
if (output !== undefined) items.push({ key: 'output', label: outputLabel, children: <JsonBlock value={output} /> });
if (raw !== undefined) items.push({ key: 'raw', label: rawLabel, children: <JsonBlock value={raw} /> });
if (!items.length) return null;
return <Collapse size="small" style={{ marginTop: 12 }} items={items} />;
};
export default JsonCollapse;
@@ -0,0 +1,126 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd';
import { CopyOutlined, LinkOutlined } from '@ant-design/icons';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
export function resolveResourceUrl(url?: string | null): string {
if (!url) return '';
const value = String(url).trim();
if (!value) return '';
if (/^(https?:)?\/\//i.test(value) || /^(blob|data):/i.test(value)) return value;
return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
}
interface MediaPreviewProps {
url?: string | null;
type: 'image' | 'video';
height?: number;
emptyDescription?: string;
errorDescription?: string;
}
const MediaPreview: React.FC<MediaPreviewProps> = ({
url,
type,
height = 260,
emptyDescription = '暂无资源',
errorDescription = '资源加载失败,可能文件不存在、签名过期或访问权限受限。',
}) => {
const resolvedUrl = useMemo(() => resolveResourceUrl(url), [url]);
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
setLoadFailed(false);
}, [resolvedUrl]);
const copyUrl = async () => {
if (!resolvedUrl) return;
try {
await navigator.clipboard.writeText(resolvedUrl);
message.success('资源地址已复制');
} catch {
message.error('复制失败,请手动复制');
}
};
const tools = resolvedUrl ? (
<Space wrap style={{ marginTop: 8 }}>
<Button size="small" icon={<CopyOutlined />} onClick={copyUrl}></Button>
<Button size="small" icon={<LinkOutlined />} onClick={() => window.open(resolvedUrl, '_blank', 'noopener,noreferrer')}></Button>
<Typography.Text copyable={{ text: resolvedUrl }} type="secondary" style={{ maxWidth: 460 }} ellipsis>
{resolvedUrl}
</Typography.Text>
</Space>
) : null;
if (!resolvedUrl) {
return (
<div>
<div
style={{
height,
borderRadius: 12,
border: '1px dashed #cbd5e1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f8fafc',
}}
>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} />
</div>
</div>
);
}
return (
<div>
{loadFailed ? (
<Alert
type="warning"
showIcon
message="资源预览失败"
description={errorDescription}
style={{ marginBottom: 8 }}
/>
) : null}
{type === 'image' ? (
<Image
src={resolvedUrl}
onError={() => setLoadFailed(true)}
style={{
width: '100%',
maxHeight: height,
objectFit: 'contain',
borderRadius: 12,
background: '#f8fafc',
border: '1px solid #f1f5f9',
}}
fallback=""
preview={!loadFailed}
/>
) : (
<video
src={resolvedUrl}
controls
preload="metadata"
onError={() => setLoadFailed(true)}
style={{
width: '100%',
maxHeight: height,
borderRadius: 12,
background: '#0f172a',
border: '1px solid #f1f5f9',
}}
/>
)}
{tools}
</div>
);
};
export default MediaPreview;
@@ -0,0 +1,105 @@
import React from 'react';
import { Tag, Tooltip } from 'antd';
interface LabelMeta {
text: string;
color?: string;
}
const STATUS_LABELS: Record<string, LabelMeta> = {
// 通用任务状态
pending: { text: '待处理', color: 'default' },
waiting_user: { text: '等待用户操作', color: 'processing' },
processing: { text: '处理中', color: 'warning' },
completed: { text: '已完成', color: 'success' },
failed: { text: '失败', color: 'error' },
cancelled: { text: '已取消', color: 'default' },
canceled: { text: '已取消', color: 'default' },
timeout: { text: '已超时', color: 'error' },
queued: { text: '已入队', color: 'processing' },
preparing: { text: '准备中', color: 'processing' },
// 生成任务 pipeline / download stage
creating_provider_task: { text: '创建远端任务', color: 'processing' },
waiting_remote: { text: '等待远端结果', color: 'processing' },
polling: { text: '轮询远端结果', color: 'processing' },
result_ready: { text: '结果已就绪', color: 'success' },
downloading: { text: '下载中', color: 'processing' },
done: { text: '已完成', color: 'success' },
download_failed: { text: '下载失败', color: 'error' },
retry_waiting: { text: '等待重试', color: 'orange' },
// 拆镜总任务状态
pending_analysis: { text: '等待分析', color: 'default' },
analyzing: { text: '分析中', color: 'processing' },
analysis_completed: { text: '分析完成', color: 'success' },
analysis_failed: { text: '分析失败', color: 'error' },
splitting: { text: '拆镜中', color: 'warning' },
split_completed: { text: '拆镜完成', color: 'success' },
partial_failed: { text: '部分失败', color: 'orange' },
// 拆镜片段状态
none: { text: '未拆镜', color: 'default' },
not_required: { text: '无需处理', color: 'default' },
not_started: { text: '未开始', color: 'default' },
project_created: { text: '已创建项目', color: 'processing' },
// 布尔/兜底类
true: { text: '是', color: 'success' },
false: { text: '否', color: 'default' },
};
const STEP_LABELS: Record<string, string> = {
material_input: '素材输入',
image_prompt_optimize: '图片 AI 提词',
image_generate: '图片生成',
video_prompt_optimize: '视频 AI 提词',
video_generate: '视频生成',
};
const MODULE_LABELS: Record<string, string> = {
hot_opening_replicate: '爆款开头复刻',
shot_replicate: '拆镜复刻',
};
const SOURCE_MODE_LABELS: Record<string, LabelMeta> = {
ai_suggestion: { text: 'AI 建议', color: 'purple' },
custom: { text: '自定义', color: 'cyan' },
original: { text: '原视频', color: 'blue' },
};
export function getModuleLabel(value?: string | null): string {
if (!value) return '-';
return MODULE_LABELS[value] || value;
}
export function getStepCodeLabel(value?: string | null): string {
if (!value) return '-';
return STEP_LABELS[value] || value;
}
export function getStatusLabel(value?: string | null): string {
if (!value) return '-';
return STATUS_LABELS[value]?.text || STEP_LABELS[value] || MODULE_LABELS[value] || SOURCE_MODE_LABELS[value]?.text || value;
}
export function getStatusColor(value?: string | null): string {
if (!value) return 'default';
return STATUS_LABELS[value]?.color || SOURCE_MODE_LABELS[value]?.color || 'blue';
}
interface StatusTagProps {
status?: string | boolean | null;
tooltipRaw?: boolean;
}
const StatusTag: React.FC<StatusTagProps> = ({ status, tooltipRaw = true }) => {
if (status === undefined || status === null || status === '') return <Tag>-</Tag>;
const raw = String(status);
const label = getStatusLabel(raw);
const tag = <Tag color={getStatusColor(raw)}>{label}</Tag>;
if (!tooltipRaw || label === raw) return tag;
return <Tooltip title={raw}>{tag}</Tooltip>;
};
export default StatusTag;
@@ -0,0 +1,314 @@
import React from 'react';
import { Card, Collapse, Descriptions, Empty, Space, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { JsonBlock } from './JsonCollapse';
const FIELD_LABELS: Record<string, string> = {
schema_version: 'Schema 版本',
generation_type: '生成类型',
business_type: '业务类型',
usage: '用途',
product_type: '产品类型',
target_audience: '目标受众',
style: '风格',
aspect_ratio: '画面比例',
resolution: '分辨率',
duration: '时长',
fps: '帧率',
final_prompt: '最终提示词',
prompt: '提示词',
description: '说明',
content: '内容',
time: '时间',
time_range: '时间段',
start_time: '开始时间',
end_time: '结束时间',
stage: '阶段',
scene: '场景',
action: '动作',
camera: '镜头',
shot: '镜头',
lens: '镜头',
subtitle: '字幕',
voiceover: '口播',
audio: '音频',
rhythm: '节奏',
notes: '备注',
};
const SECTION_LABELS: Record<string, string> = {
basic: '基础信息',
basic_info: '基础信息',
material: '素材理解',
material_understanding: '素材理解',
business: '业务属性',
business_info: '业务属性',
visual: '画面与风格',
visual_style: '画面与风格',
time_plan: '时间规划',
timeline: '时间规划',
scene_timeline: '场景时间线',
action_flow: '动作流程',
motion_flow: '动作流程',
character_action_flow: '角色动作流程',
camera_flow: '镜头流程',
shot_flow: '镜头流程',
lens_flow: '镜头流程',
subtitle: '字幕',
subtitles: '字幕',
voiceover: '口播',
audio: '音频',
rhythm: '节奏',
compliance: '合规控制',
final_prompt: '最终提示词',
};
const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
const labelOf = (key: string): string => SECTION_LABELS[key] || FIELD_LABELS[key] || key;
const isLongText = (value: unknown): boolean => typeof value === 'string' && value.length > 80;
const EMPTY_TEXTS = new Set(['', '无', 'null', 'None', 'none', '未提及', '不适用']);
const PLACEHOLDER_FLOW_TEXTS = new Set([
'展示主体动作、核心卖点或主要视觉内容',
'展示主要动作、核心卖点或主要视觉内容',
'展示核心卖点或主要视觉内容',
'展示主体动作',
'无',
]);
const ACTION_CONTENT_KEYS = ['动作内容', '动作', '动作说明', '内容', '说明', '主体动作', '动作变化'];
const CAMERA_CONTENT_KEYS = ['镜头内容', '镜头', '镜头说明', '运镜', '运镜说明', '内容', '说明'];
const toText = (value: unknown): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const isEmptyText = (value: unknown): boolean => EMPTY_TEXTS.has(toText(value));
const isActionFlowSection = (sectionKey: string): boolean => {
const lower = sectionKey.toLowerCase();
return lower.includes('action') || lower.includes('motion') || lower.includes('动作');
};
const isCameraFlowSection = (sectionKey: string): boolean => {
const lower = sectionKey.toLowerCase();
return lower.includes('camera') || lower.includes('shot') || lower.includes('lens') || lower.includes('镜头');
};
const isTimePlanSection = (sectionKey: string): boolean => {
const lower = sectionKey.toLowerCase();
return lower.includes('time') || lower.includes('timeline') || lower.includes('时间规划') || lower.includes('动态时间规划');
};
const pickFirstContent = (item: Record<string, unknown>, keys: string[]): string => {
for (const key of keys) {
if (key in item && !isEmptyText(item[key])) return toText(item[key]);
}
return '';
};
const normalizeFlowContent = (baseContent: string, extras: string[]): string => {
const cleanBase = baseContent.trim();
const uniqueExtras = extras.filter((item, index) => item && extras.indexOf(item) === index);
if (uniqueExtras.length && (!cleanBase || PLACEHOLDER_FLOW_TEXTS.has(cleanBase))) {
return uniqueExtras.join('');
}
const parts = cleanBase && !EMPTY_TEXTS.has(cleanBase) ? [cleanBase] : [];
uniqueExtras.forEach((item) => {
if (item && !parts.includes(item)) parts.push(item);
});
return parts.join('') || '-';
};
const normalizeFlowItemForDisplay = (
item: Record<string, unknown>,
contentKey: '动作内容' | '镜头内容',
contentKeys: string[],
): Record<string, unknown> => {
const allowed = new Set(['时间段', contentKey, ...contentKeys]);
const extras: string[] = [];
Object.entries(item).forEach(([field, value]) => {
if (allowed.has(field)) return;
const fieldText = toText(field);
const valueText = toText(value);
if (fieldText && !EMPTY_TEXTS.has(fieldText)) extras.push(fieldText);
if (valueText && !EMPTY_TEXTS.has(valueText) && valueText !== fieldText) extras.push(valueText);
});
return {
时间段: toText(item['时间段']) || '-',
[contentKey]: normalizeFlowContent(pickFirstContent(item, contentKeys), extras),
};
};
const normalizeRecordForSection = (sectionKey: string, item: Record<string, unknown>): Record<string, unknown> => {
if (isActionFlowSection(sectionKey)) {
return normalizeFlowItemForDisplay(item, '动作内容', ACTION_CONTENT_KEYS);
}
if (isCameraFlowSection(sectionKey)) {
return normalizeFlowItemForDisplay(item, '镜头内容', CAMERA_CONTENT_KEYS);
}
if (isTimePlanSection(sectionKey)) {
return {
时间段: toText(item['时间段']) || '-',
阶段: toText(item['阶段']) || '-',
说明: toText(item['说明']) || '-',
};
}
return item;
};
const renderValue = (value: unknown): React.ReactNode => {
if (value === null || value === undefined || value === '') return <Typography.Text type="secondary">-</Typography.Text>;
if (typeof value === 'boolean') return value ? '是' : '否';
if (typeof value === 'number') return value;
if (typeof value === 'string') {
return <Typography.Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>{value}</Typography.Paragraph>;
}
return <JsonBlock value={value} maxHeight={220} />;
};
const getArrayMode = (key: string): 'card' | 'table' => {
const lower = key.toLowerCase();
if (
lower.includes('action') ||
lower.includes('motion') ||
lower.includes('camera') ||
lower.includes('shot') ||
lower.includes('lens') ||
lower.includes('flow') ||
lower.includes('动作') ||
lower.includes('镜头')
) {
return 'card';
}
return 'table';
};
const renderCardArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{items.map((item, index) => {
const displayItem = normalizeRecordForSection(sectionKey, item);
return (
<Card
key={`${sectionKey}-${index}`}
size="small"
title={`${labelOf(sectionKey)} ${index + 1}`}
styles={{ body: { padding: 12 } }}
>
<Descriptions size="small" column={1} bordered>
{Object.entries(displayItem).map(([field, value]) => (
<Descriptions.Item key={field} label={labelOf(field)}>
{renderValue(value)}
</Descriptions.Item>
))}
</Descriptions>
</Card>
);
})}
</Space>
);
const renderTableArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => {
const displayItems = items.map(item => normalizeRecordForSection(sectionKey, item));
const fields = Array.from(new Set(displayItems.flatMap(item => Object.keys(item))));
const columns: ColumnsType<Record<string, unknown>> = fields.map(field => ({
title: labelOf(field),
dataIndex: field,
key: field,
width: isLongText(displayItems.find(item => item[field])?.[field]) ? 280 : 160,
render: (value: unknown) => renderValue(value),
}));
return (
<Table
size="small"
rowKey={(_, index) => `${sectionKey}-${index}`}
columns={columns}
dataSource={displayItems}
pagination={false}
scroll={{ x: Math.max(900, fields.length * 180) }}
tableLayout="fixed"
/>
);
};
const renderArray = (sectionKey: string, value: unknown[]): React.ReactNode => {
if (!value.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />;
if (value.every(isRecord)) {
return getArrayMode(sectionKey) === 'card'
? renderCardArray(sectionKey, value)
: renderTableArray(sectionKey, value);
}
return <JsonBlock value={value} maxHeight={260} />;
};
const renderRecord = (value: Record<string, unknown>): React.ReactNode => (
<Descriptions size="small" column={2} bordered>
{Object.entries(value).map(([field, fieldValue]) => (
<Descriptions.Item key={field} label={labelOf(field)} span={Array.isArray(fieldValue) || isRecord(fieldValue) || isLongText(fieldValue) ? 2 : 1}>
{Array.isArray(fieldValue)
? renderArray(field, fieldValue)
: isRecord(fieldValue)
? renderRecord(fieldValue)
: renderValue(fieldValue)}
</Descriptions.Item>
))}
</Descriptions>
);
const renderSection = (key: string, value: unknown): React.ReactNode => {
if (Array.isArray(value)) return renderArray(key, value);
if (isRecord(value)) return renderRecord(value);
return renderValue(value);
};
interface VideoPromptSchemaViewerProps {
schema?: Record<string, any> | null;
finalPrompt?: string | null;
}
const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({ schema, finalPrompt }) => {
const hasSchema = !!schema && Object.keys(schema).length > 0;
if (!hasSchema && !finalPrompt) {
return <Empty description="暂无视频提词 schema" />;
}
const schemaItems = hasSchema
? Object.entries(schema || {}).map(([key, value]) => ({
key,
label: labelOf(key),
children: renderSection(key, value),
}))
: [];
const defaultKeys = finalPrompt ? ['final_prompt'] : [];
return (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{finalPrompt ? (
<Card size="small" title="最终视频提示词">
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{finalPrompt}</Typography.Paragraph>
</Card>
) : null}
{schemaItems.length ? (
<Collapse
size="small"
defaultActiveKey={defaultKeys}
items={schemaItems}
/>
) : null}
</Space>
);
};
export default VideoPromptSchemaViewer;
+239
View File
@@ -342,3 +342,242 @@ export interface GenerationAITaskQueryParams {
userName?: string;
}
// ── Admin Replication Readonly Types ──────────────────────────────────────
export type ModuleReplicationStatus =
| 'pending'
| 'waiting_user'
| 'processing'
| 'completed'
| 'failed'
| 'cancelled'
| string;
export interface ReplicationStepOut {
id: string;
projectId: string;
module: string;
stepIndex: number;
stepCode: string;
status: string;
version: number;
isCurrent: boolean;
parentStepId?: string | null;
sourceStepId?: string | null;
chatTaskId?: string | null;
input?: Record<string, any> | null;
output?: Record<string, any> | null;
errorMessage?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
completedAt?: string | null;
}
export interface ReplicationMaterialOut {
materialStepId?: string | null;
materialVideoUrl?: string | null;
materialImageUrl?: string | null;
sourceProjectName?: string | null;
targetProjectName?: string | null;
coreContentPoint?: string | null;
}
export interface ReplicationImageGenerationOut {
promptStepId?: string | null;
generateStepId?: string | null;
prompt?: string | null;
engineId?: string | null;
engineName?: string | null;
params?: Record<string, any> | null;
chatTaskId?: string | null;
status?: string | null;
resultImageUrl?: string | null;
errorMessage?: string | null;
}
export interface ReplicationVideoGenerationOut {
promptStepId?: string | null;
generateStepId?: string | null;
promptSchema?: Record<string, any> | null;
finalPrompt?: string | null;
promptParams?: Record<string, any> | null;
engineId?: string | null;
engineName?: string | null;
params?: Record<string, any> | null;
chatTaskId?: string | null;
status?: string | null;
resultVideoUrl?: string | null;
resultVideoCoverUrl?: string | null;
errorMessage?: string | null;
}
export interface ReplicationProjectDetailOut {
id: string;
projectId: string;
userId?: string | null;
userName?: string | null;
module: string;
title?: string | null;
status: ModuleReplicationStatus;
currentStepCode?: string | null;
finalImageUrl?: string | null;
finalVideoUrl?: string | null;
finalVideoCoverUrl?: string | null;
errorMessage?: string | null;
material: ReplicationMaterialOut;
imageGeneration: ReplicationImageGenerationOut;
videoGeneration: ReplicationVideoGenerationOut;
steps: ReplicationStepOut[];
createdAt?: string | null;
updatedAt?: string | null;
completedAt?: string | null;
}
export interface HotOpeningTaskListItemOut {
id: string;
projectId: string;
userId?: string | null;
userName?: string | null;
module: string;
title?: string | null;
status: ModuleReplicationStatus;
currentStepCode?: string | null;
sourceProjectName?: string | null;
targetProjectName?: string | null;
coreContentPoint?: string | null;
finalImageUrl?: string | null;
finalVideoUrl?: string | null;
finalVideoCoverUrl?: string | null;
errorMessage?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
completedAt?: string | null;
}
export interface HotOpeningTaskListOut {
total: number;
items: HotOpeningTaskListItemOut[];
}
export interface AdminHotOpeningTaskQueryParams {
status?: string;
keyword?: string;
userId?: string;
userName?: string;
createdStart?: string;
createdEnd?: string;
page?: number;
pageSize?: number;
}
export interface ShotTaskSetOut {
id: string;
userId?: string | null;
userName?: string | null;
title?: string | null;
videoUrl: string;
videoDurationSeconds: number;
status: string;
analysisStatus: string;
splitStatus: string;
originalVideoContent?: string | null;
originalVideoCategory?: string | null;
originalVideoAudience?: string | null;
segmentCount: number;
completedSegmentCount: number;
failedSegmentCount: number;
analysisErrorMessage?: string | null;
splitErrorMessage?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface ShotAiSuggestionOut {
index: number;
startSecond: number;
endSecond: number;
durationSeconds: number;
timeNode: string;
content?: string | null;
category?: string | null;
audience?: string | null;
}
export interface ShotTaskSetDetailOut extends ShotTaskSetOut {
aiSuggestions: ShotAiSuggestionOut[];
analysisResultJson?: Record<string, any> | any[] | null;
}
export interface ShotTaskSetListOut {
total: number;
page: number;
pageSize: number;
items: ShotTaskSetOut[];
}
export interface AdminShotTaskSetQueryParams {
status?: string;
analysisStatus?: string;
splitStatus?: string;
keyword?: string;
userId?: string;
userName?: string;
createdStart?: string;
createdEnd?: string;
page?: number;
pageSize?: number;
}
export interface ShotSegmentOut {
id: string;
taskSetId: string;
segmentIndex: number;
segmentName?: string | null;
sourceMode: string;
startSecond: number;
endSecond: number;
durationSeconds: number;
timeNode: string;
splitStatus: string;
analysisStatus: string;
replicateStatus: string;
segmentVideoUrl?: string | null;
originalVideoContent?: string | null;
originalVideoCategory?: string | null;
originalVideoAudience?: string | null;
segmentContent?: string | null;
segmentCategory?: string | null;
segmentAudience?: string | null;
splitRetryCount: number;
splitLastError?: string | null;
analysisErrorMessage?: string | null;
moduleProjectId?: string | null;
moduleProjectTitle?: string | null;
moduleProjectStatus?: string | null;
moduleProjectCurrentStepCode?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface ShotSegmentDetailOut extends ShotSegmentOut {
analysisJson?: Record<string, any> | any[] | null;
aiSuggestionJson?: Record<string, any> | any[] | null;
}
export interface ShotSegmentListOut {
total: number;
page: number;
pageSize: number;
items: ShotSegmentOut[];
}
export interface AdminShotSegmentQueryParams {
sourceMode?: string;
splitStatus?: string;
analysisStatus?: string;
replicateStatus?: string;
page?: number;
pageSize?: number;
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminsettings.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts"],"version":"6.0.3"}
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts"],"version":"6.0.3"}
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
@@ -241,12 +242,37 @@ async def create_task(
)
async def list_tasks(
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_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_hot_opening_projects(db, current_user=current_user, status=status.value if status else None, 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(
+19 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
@@ -293,12 +294,25 @@ async def list_shot_task_sets(
status: ShotTaskSetStatusEnum | None = Query(None, description="总任务状态筛选:pending_analysis=等待分析,analyzing=分析中,analysis_completed=分析完成,analysis_failed=分析失败,splitting=拆镜中,split_completed=拆镜完成,partial_failed=部分失败,failed=失败,deleted=已软删"),
analysis_status: ShotAnalysisStatusEnum | None = Query(None, description="原视频分析状态筛选:pending=待分析,processing=分析中,completed=分析完成,failed=分析失败"),
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="标题/原视频内容/分类/受众关键词,模糊搜索;普通用户只在自己的数据内搜索"),
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_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"),
current_user: User = Depends(get_current_user),
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(
db,
current_user=current_user,
@@ -306,6 +320,10 @@ async def list_shot_task_sets(
analysis_status=analysis_status.value if analysis_status else None,
split_status=split_status.value if split_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,
)
@@ -425,6 +425,8 @@ class HotOpeningVideoGenerationOut(BaseModel):
class HotOpeningTaskDetailOut(BaseModel):
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目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")
title: str | None = Field(None, description="项目标题,默认取生成项目名称")
status: str = Field(..., description="总任务状态:pending=已创建,waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消")
@@ -445,11 +447,15 @@ class HotOpeningTaskDetailOut(BaseModel):
class HotOpeningTaskListItemOut(BaseModel):
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目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="模块标识")
title: str | None = Field(None, description="项目标题")
status: str = Field(..., description="总任务状态:pending=已创建,waiting_user=等待用户操作,processing=处理中,completed=完成,failed=失败,cancelled=取消")
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步素材输入")
core_content_point: str | None = Field(None, description="核心内容点,来源于第1步素材输入")
final_image_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")
@@ -428,6 +428,8 @@ class ShotReplicateVideoGenerationOut(BaseModel):
class ShotReplicateTaskDetailOut(BaseModel):
id: str = Field(..., description="总任务项目ID。这个ID就是前端项目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")
title: str | None = Field(None, description="项目标题,默认取生成项目名称")
status: str = Field(..., description="总任务状态:pending/waiting_user/processing/completed/failed/cancelled")
@@ -579,6 +581,10 @@ class ShotTaskSetListQuery(BaseModel):
analysis_status: str | None = Field(None, description="分析状态筛选:pending/processing/completed/failed")
split_status: str | None = Field(None, description="拆镜状态筛选:none/pending/processing/completed/failed/retry_waiting")
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_size: int = Field(20, ge=1, le=100, description="每页数量")
@@ -587,6 +593,8 @@ class ShotTaskSetOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str = Field(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id")
user_id: str | None = Field(None, description="所属用户ID;管理员后台排查使用")
user_name: str | None = Field(None, description="所属用户名;管理员后台排查使用")
title: str | None = Field(None, description="拆镜总任务标题,可为空")
video_url: str = Field(..., description="原视频 URL,来自已有上传接口")
video_duration_seconds: float = Field(..., description="原视频时长,单位秒,允许浮点")
@@ -659,6 +667,9 @@ class ShotSegmentOut(BaseModel):
split_last_error: str | None = Field(None, description="最近一次切割失败原因")
analysis_error_message: str | None = Field(None, description="片段分析失败原因")
module_project_id: str | None = Field(None, description="由该片段创建的拆镜复刻项目ID,即 module_generation_projects.id")
module_project_title: str | None = Field(None, description="关联拆镜复刻项目标题;后台片段列表展示使用")
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="更新时间")
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy import String, cast, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -486,9 +486,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
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(
id=project.id,
project_id=project.id,
user_id=project.user_id,
user_name=user_name,
module=project.module,
title=project.title,
status=project.status,
@@ -586,40 +593,141 @@ async def create_hot_opening_project(db: AsyncSession, current_user: User, req:
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(
db: AsyncSession,
*,
current_user: User,
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_size: int,
) -> 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(
ModuleGenerationProject.module == MODULE,
ModuleGenerationProject.deleted_at.is_(None),
)
if not current_user.is_admin:
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:
query = query.where(ModuleGenerationProject.status == status)
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar_one()
result = await db.execute(query.order_by(ModuleGenerationProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
projects = list(result.scalars().all())
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
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] = []
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)
items.append(
HotOpeningTaskListItemOut(
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,
title=project.title,
status=project.status,
current_step_code=project.current_step_code,
source_project_name=material.get("source_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_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,
@@ -341,33 +341,162 @@ def fill_none_with_wu(value: Any) -> Any:
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]:
schema = copy.deepcopy(CLIENT_SCHEMA_V1)
for key, default_value in schema.items():
if key not in result:
result[key] = default_value
elif isinstance(default_value, dict) and isinstance(result.get(key), dict):
merged = copy.deepcopy(default_value)
merged.update(result[key])
result[key] = merged
return result
return normalize_top_level_schema_fields(result)
def _pick_flow_content(item: dict[str, Any], content_keys: tuple[str, ...]) -> str:
for key in content_keys:
if key in item and not _is_empty_schema_value(item.get(key)):
return _clean_schema_text(item.get(key))
return ""
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]:
plan = build_time_plan(duration)
if not isinstance(result.get("动作流程"), list) or not result["动作流程"]:
result["动作流程"] = [
{"时间段": item["时间段"], "动作内容": item["说明"]}
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
result["动作流程"] = _align_flow_time_ranges(result.get("动作流程"), plan, "动作内容")
result["镜头流程"] = _align_flow_time_ranges(result.get("镜头流程"), plan, "镜头内容")
result["动态时间规划"] = _normalize_time_plan(plan, result.get("动态时间规划"))
return result
@@ -462,16 +591,20 @@ def clean_final_prompt_specs(schema: dict[str, Any], video_config: dict[str, Any
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 []
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):
old_item = source[index] if index < len(source) and isinstance(source[index], dict) else {}
item = dict(old_item)
item["时间段"] = plan_item["时间段"]
if not any(k in item and str(item.get(k)).strip() for k in (default_content_key, "动作", "镜头", "说明", "内容")):
item[default_content_key] = plan_item["说明"]
aligned.append(item)
raw_item = source[index] if index < len(source) else {}
aligned.append(
_normalize_flow_item(
raw_item,
plan_item=plan_item,
content_key=default_content_key,
content_keys=content_keys,
)
)
return aligned
@@ -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
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(
id=project.id,
project_id=project.id,
user_id=project.user_id,
user_name=user_name,
module=project.module,
title=project.title,
status=project.status,
@@ -593,6 +600,26 @@ async def create_shot_replicate_project(db: AsyncSession, current_user: User, re
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(
db: AsyncSession,
*,
@@ -601,6 +628,11 @@ async def list_shot_replicate_projects(
page: int,
page_size: int,
) -> ShotReplicateTaskListOut:
"""
拆镜复刻项目列表查询。
这个列表目前不是后台主入口,但仍避免逐条查 material_input,保持和爆款开头列表一致的批量查询策略。
"""
query = select(ModuleGenerationProject).where(
ModuleGenerationProject.module == MODULE,
ModuleGenerationProject.deleted_at.is_(None),
@@ -610,13 +642,23 @@ async def list_shot_replicate_projects(
if status:
query = query.where(ModuleGenerationProject.status == status)
total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar_one()
result = await db.execute(query.order_by(ModuleGenerationProject.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
projects = list(result.scalars().all())
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=ShotReplicateStepCodeEnum.MATERIAL_INPUT.value,
)
items: list[ShotReplicateTaskListItemOut] = []
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)
items.append(
ShotReplicateTaskListItemOut(
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import HTTPException
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 app.enums.shot_replicate import (
@@ -19,6 +19,7 @@ from app.enums.shot_replicate import (
ShotSplitStatusEnum,
ShotTaskSetStatusEnum,
)
from app.models.module_generation_project import ModuleGenerationProject
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
from app.models.user import User
@@ -85,26 +86,37 @@ def _normalize_suggestions(value: Any) -> list[dict[str, Any]]:
return normalized
def _task_set_to_out(task_set: ShotReplicateTaskSet) -> ShotTaskSetOut:
return ShotTaskSetOut.model_validate(task_set)
def _task_set_to_out(task_set: ShotReplicateTaskSet, user_name: str | None = None) -> ShotTaskSetOut:
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)]
base = ShotTaskSetDetailOut.model_validate(task_set)
base.user_name = user_name
base.ai_suggestions = suggestions
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.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
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.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
@@ -201,6 +213,23 @@ async def create_task_set(db: AsyncSession, *, current_user: User, req: ShotTask
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(
db: AsyncSession,
*,
@@ -209,39 +238,85 @@ async def list_task_sets(
analysis_status: str | None = None,
split_status: 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_size: int = 20,
) -> 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))
if not current_user.is_admin:
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:
query = query.where(ShotReplicateTaskSet.status == status)
if analysis_status:
query = query.where(ShotReplicateTaskSet.analysis_status == analysis_status)
if split_status:
query = query.where(ShotReplicateTaskSet.split_status == split_status)
if keyword:
if keyword and keyword.strip():
like = f"%{keyword.strip()}%"
query = query.where(
(ShotReplicateTaskSet.title.ilike(like))
| (ShotReplicateTaskSet.original_video_content.ilike(like))
| (ShotReplicateTaskSet.original_video_category.ilike(like))
)
conditions = [
ShotReplicateTaskSet.id.ilike(like),
ShotReplicateTaskSet.title.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 = int(total_result.scalar() or 0)
rows = await db.execute(
result = await db.execute(
query.order_by(ShotReplicateTaskSet.created_at.desc())
.offset((page - 1) * 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:
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:
@@ -499,9 +574,13 @@ async def list_segments(
page_size: int = 20,
) -> ShotSegmentListOut:
await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user)
query = select(ShotReplicateSegment).where(
ShotReplicateSegment.task_set_id == task_set_id,
ShotReplicateSegment.deleted_at.is_(None),
query = (
select(ShotReplicateSegment, ModuleGenerationProject)
.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:
query = query.where(ShotReplicateSegment.user_id == current_user.id)
@@ -521,9 +600,18 @@ async def list_segments(
.offset((page - 1) * 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:
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)