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

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"}