拆镜复刻、爆款开头复刻管理后台完成
This commit is contained in:
@@ -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;
|
||||
@@ -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="请输入文图理解提示词,例如: 你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Option Groups */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>行业选项配置</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
|
||||
添加选项组,每组包含名称和多个选项,前台将显示为下拉选择
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Form.List name="optionGroups">
|
||||
{(fields, { add, remove }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<div key={key} style={{
|
||||
display: 'flex', gap: 8, alignItems: 'flex-start',
|
||||
padding: '10px 12px', borderRadius: 10,
|
||||
background: '#f8f9fc', border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Form.Item {...restField} name={[name, 'name']} label="选项名称" style={{ marginBottom: 0 }}
|
||||
rules={[{ required: true, message: '请输入选项名称' }]}>
|
||||
<Input placeholder="例如:视频风格" size="middle" style={{ borderRadius: 8 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'options']} label="选项内容" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
mode="tags"
|
||||
size="middle"
|
||||
placeholder="输入选项后回车添加"
|
||||
style={{ borderRadius: 8 }}
|
||||
tokenSeparators={[',', ',', '、']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<MinusCircleOutlined
|
||||
onClick={() => remove(name)}
|
||||
style={{ color: '#ef4444', fontSize: 16, marginTop: 34, cursor: 'pointer', flexShrink: 0 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="dashed" onClick={() => add()} block
|
||||
icon={<PlusOutlined />}
|
||||
style={{ borderRadius: 8, height: 36 }}
|
||||
>
|
||||
添加选项组
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="is_active" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0} style={{ flex: 1 }}>
|
||||
<Input type="number" size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminIndustries;
|
||||
@@ -1,490 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Modal, Popconfirm, Space, Switch, Table, Tag, Typography, Dropdown, Select,
|
||||
} from 'antd';
|
||||
import {
|
||||
AppstoreOutlined, PlusOutlined, EditOutlined, DeleteOutlined, DownOutlined, MinusCircleOutlined,
|
||||
ShoppingCartOutlined, BookOutlined, HomeOutlined, FireOutlined, RocketOutlined, SkinOutlined,
|
||||
CompassOutlined, HeartOutlined, CarOutlined, CameraOutlined, CloudOutlined, StarOutlined,
|
||||
TrophyOutlined, ThunderboltOutlined, BulbOutlined, CoffeeOutlined, CrownOutlined, DashboardOutlined,
|
||||
FlagOutlined, GlobalOutlined, GiftOutlined, LaptopOutlined, MobileOutlined, MonitorOutlined,
|
||||
PayCircleOutlined, PictureOutlined, PlayCircleOutlined, SafetyOutlined,
|
||||
ShoppingOutlined, SmileOutlined, SoundOutlined, TagOutlined, TeamOutlined,
|
||||
ToolOutlined, TruckOutlined, VideoCameraOutlined, WalletOutlined, BankOutlined,
|
||||
BuildOutlined, ExperimentOutlined, HighlightOutlined, IdcardOutlined,
|
||||
MedicineBoxOutlined, ReadOutlined, RestOutlined, SketchOutlined, SolutionOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getIndustryConfigs, saveIndustryConfig, deleteIndustryConfig } from '../api';
|
||||
|
||||
interface OptionGroup {
|
||||
name: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
interface IndustryItem {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
skills: string;
|
||||
optionGroups: OptionGroup[];
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
// Icon library for industries
|
||||
const ICON_OPTIONS: { name: string; label: string; component: React.ReactNode }[] = [
|
||||
{ name: 'ShoppingCartOutlined', label: '购物', component: <ShoppingCartOutlined /> },
|
||||
{ name: 'BookOutlined', label: '书籍', component: <BookOutlined /> },
|
||||
{ name: 'HomeOutlined', label: '房产', component: <HomeOutlined /> },
|
||||
{ name: 'FireOutlined', label: '火', component: <FireOutlined /> },
|
||||
{ name: 'RocketOutlined', label: '科技', component: <RocketOutlined /> },
|
||||
{ name: 'SkinOutlined', label: '时尚', component: <SkinOutlined /> },
|
||||
{ name: 'CompassOutlined', label: '指南', component: <CompassOutlined /> },
|
||||
{ name: 'HeartOutlined', label: '健康', component: <HeartOutlined /> },
|
||||
{ name: 'CarOutlined', label: '汽车', component: <CarOutlined /> },
|
||||
{ name: 'CameraOutlined', label: '相机', component: <CameraOutlined /> },
|
||||
{ name: 'CloudOutlined', label: '云', component: <CloudOutlined /> },
|
||||
{ name: 'StarOutlined', label: '星', component: <StarOutlined /> },
|
||||
{ name: 'TrophyOutlined', label: '奖杯', component: <TrophyOutlined /> },
|
||||
{ name: 'ThunderboltOutlined', label: '闪电', component: <ThunderboltOutlined /> },
|
||||
{ name: 'BulbOutlined', label: '灯泡', component: <BulbOutlined /> },
|
||||
{ name: 'CoffeeOutlined', label: '咖啡', component: <CoffeeOutlined /> },
|
||||
{ name: 'CrownOutlined', label: '皇冠', component: <CrownOutlined /> },
|
||||
{ name: 'DashboardOutlined', label: '仪表', component: <DashboardOutlined /> },
|
||||
{ name: 'FlagOutlined', label: '旗帜', component: <FlagOutlined /> },
|
||||
{ name: 'GlobalOutlined', label: '全球', component: <GlobalOutlined /> },
|
||||
{ name: 'GiftOutlined', label: '礼物', component: <GiftOutlined /> },
|
||||
{ name: 'LaptopOutlined', label: '笔记本', component: <LaptopOutlined /> },
|
||||
{ name: 'MobileOutlined', label: '手机', component: <MobileOutlined /> },
|
||||
{ name: 'MonitorOutlined', label: '显示器', component: <MonitorOutlined /> },
|
||||
{ name: 'PayCircleOutlined', label: '支付', component: <PayCircleOutlined /> },
|
||||
{ name: 'PictureOutlined', label: '图片', component: <PictureOutlined /> },
|
||||
{ name: 'PlayCircleOutlined', label: '播放', component: <PlayCircleOutlined /> },
|
||||
{ name: 'SafetyOutlined', label: '安全', component: <SafetyOutlined /> },
|
||||
{ name: 'ShoppingOutlined', label: '商店', component: <ShoppingOutlined /> },
|
||||
{ name: 'SmileOutlined', label: '笑脸', component: <SmileOutlined /> },
|
||||
{ name: 'SoundOutlined', label: '声音', component: <SoundOutlined /> },
|
||||
{ name: 'TagOutlined', label: '标签', component: <TagOutlined /> },
|
||||
{ name: 'TeamOutlined', label: '团队', component: <TeamOutlined /> },
|
||||
{ name: 'ToolOutlined', label: '工具', component: <ToolOutlined /> },
|
||||
{ name: 'TruckOutlined', label: '物流', component: <TruckOutlined /> },
|
||||
{ name: 'VideoCameraOutlined', label: '视频', component: <VideoCameraOutlined /> },
|
||||
{ name: 'WalletOutlined', label: '钱包', component: <WalletOutlined /> },
|
||||
{ name: 'BankOutlined', label: '银行', component: <BankOutlined /> },
|
||||
{ name: 'BuildOutlined', label: '建筑', component: <BuildOutlined /> },
|
||||
{ name: 'ExperimentOutlined', label: '实验', component: <ExperimentOutlined /> },
|
||||
{ name: 'HighlightOutlined', label: '高亮', component: <HighlightOutlined /> },
|
||||
{ name: 'IdcardOutlined', label: '名片', component: <IdcardOutlined /> },
|
||||
{ name: 'MedicineBoxOutlined', label: '医药', component: <MedicineBoxOutlined /> },
|
||||
{ name: 'ReadOutlined', label: '阅读', component: <ReadOutlined /> },
|
||||
{ name: 'RestOutlined', label: '休息', component: <RestOutlined /> },
|
||||
{ name: 'SketchOutlined', label: '钻石', component: <SketchOutlined /> },
|
||||
{ name: 'SolutionOutlined', label: '方案', component: <SolutionOutlined /> },
|
||||
];
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = Object.fromEntries(
|
||||
ICON_OPTIONS.map(o => [o.name, o.component])
|
||||
);
|
||||
|
||||
function getIconComponent(name: string): React.ReactNode {
|
||||
return iconMap[name] || <AppstoreOutlined />;
|
||||
}
|
||||
|
||||
// Parse skills JSON into { skillItems, optionGroups }
|
||||
function parseSkills(raw: string | any[]): { skillItems: { key: string; label: string }[]; optionGroups: OptionGroup[] } {
|
||||
try {
|
||||
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (!Array.isArray(arr)) return { skillItems: [], optionGroups: [] };
|
||||
const skillItems: { key: string; label: string }[] = [];
|
||||
const optionGroups: OptionGroup[] = [];
|
||||
for (const item of arr) {
|
||||
if (item && item.type === 'option_group' && item.name && Array.isArray(item.options)) {
|
||||
optionGroups.push({ name: item.name, options: item.options });
|
||||
} else if (item && item.key && item.label) {
|
||||
skillItems.push({ key: item.key, label: item.label });
|
||||
}
|
||||
}
|
||||
return { skillItems, optionGroups };
|
||||
} catch {
|
||||
return { skillItems: [], optionGroups: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Icon Picker Component
|
||||
const IconPicker: React.FC<{ value?: string; onChange?: (v: string) => void }> = ({ value, onChange }) => {
|
||||
const selected = ICON_OPTIONS.find(o => o.name === value);
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div style={{
|
||||
background: '#fff', borderRadius: 12, padding: 12, width: 360,
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.15)', border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
|
||||
{ICON_OPTIONS.map(opt => (
|
||||
<div key={opt.name} onMouseDown={e => { e.preventDefault(); e.stopPropagation(); onChange?.(opt.name); }} title={opt.label} style={{
|
||||
width: 40, height: 40, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 18, transition: 'all 0.15s',
|
||||
background: value === opt.name ? '#6366f1' : 'transparent',
|
||||
color: value === opt.name ? '#fff' : '#64748b',
|
||||
border: value === opt.name ? 'none' : '1px solid transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (value !== opt.name) e.currentTarget.style.background = '#f1f5f9'; }}
|
||||
onMouseLeave={e => { if (value !== opt.name) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{opt.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px',
|
||||
borderRadius: 8, border: '1px solid #d9d9d9', cursor: 'pointer', height: 40,
|
||||
}}>
|
||||
{value ? (
|
||||
<>
|
||||
<span style={{ fontSize: 18, color: '#6366f1' }}>{getIconComponent(value)}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 13 }}>{selected?.label || value}</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: '#bfbfbf', fontSize: 13 }}>选择图标</span>
|
||||
)}
|
||||
<DownOutlined style={{ fontSize: 10, color: '#bfbfbf', marginLeft: 'auto' }} />
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminIndustries: React.FC = () => {
|
||||
const [industries, setIndustries] = useState<IndustryItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; item: IndustryItem | null }>({ open: false, item: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getIndustryConfigs();
|
||||
setIndustries(data.map((item: any) => {
|
||||
const { skillItems, optionGroups } = parseSkills(item.skills);
|
||||
return {
|
||||
id: item.id,
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
icon: item.icon || '',
|
||||
description: item.description || '',
|
||||
skills: JSON.stringify(skillItems),
|
||||
optionGroups,
|
||||
isActive: item.is_active ?? item.isActive ?? true,
|
||||
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
|
||||
};
|
||||
}));
|
||||
} catch {
|
||||
message.error('加载行业配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
// Build skills array: skill items + option groups
|
||||
const skillsArr: any[] = [];
|
||||
if (values.skills_wentutujie?.trim()) {
|
||||
skillsArr.push({ type: 'skill', key: '文图理解', label: values.skills_wentutujie.trim() });
|
||||
}
|
||||
// Add option groups
|
||||
const groups: OptionGroup[] = (values.optionGroups || [])
|
||||
.filter((g: any) => g?.name?.trim())
|
||||
.map((g: any) => ({
|
||||
name: g.name.trim(),
|
||||
options: (g.options || []).filter((o: string) => o?.trim()),
|
||||
}))
|
||||
.filter((g: OptionGroup) => g.options.length > 0);
|
||||
for (const g of groups) {
|
||||
skillsArr.push({ type: 'option_group', name: g.name, options: g.options });
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
key: values.key,
|
||||
label: values.label,
|
||||
icon: values.icon || '',
|
||||
description: values.description || '',
|
||||
skills: skillsArr,
|
||||
is_active: values.is_active ?? true,
|
||||
sort_order: values.sort_order ?? 0,
|
||||
};
|
||||
if (modal.item?.id) {
|
||||
await saveIndustryConfig({ id: modal.item.id, ...payload });
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await saveIndustryConfig(payload);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, item: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteIndustryConfig(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (item?: IndustryItem) => {
|
||||
setModal({ open: true, item: item || null });
|
||||
let wentutujie = '';
|
||||
let wentutujieImage = '';
|
||||
let optionGroups: OptionGroup[] = [];
|
||||
if (item) {
|
||||
// Parse skills for 文图理解 video and image prompts
|
||||
try {
|
||||
const arr = JSON.parse(item.skills);
|
||||
if (Array.isArray(arr)) {
|
||||
for (const s of arr) {
|
||||
if (s.key === '文图理解生成视频提示词' || s.key === '文图理解') { wentutujie = s.label || ''; }
|
||||
if (s.key === '文图理解生成图片提示词') { wentutujieImage = s.label || ''; }
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
optionGroups = item.optionGroups.length > 0 ? item.optionGroups : [{ name: '', options: [] }];
|
||||
}
|
||||
if (item) {
|
||||
form.setFieldsValue({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
icon: item.icon || '',
|
||||
description: item.description,
|
||||
skills_wentutujie: wentutujie,
|
||||
skills_wentutujie_image: wentutujieImage,
|
||||
optionGroups,
|
||||
is_active: item.isActive,
|
||||
sort_order: item.sortOrder,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ is_active: true, sort_order: 0, optionGroups: [{ name: '', options: [] }] });
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '行业', key: 'industry', width: 180,
|
||||
render: (_: any, r: IndustryItem) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10, background: '#f1f5f9',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, color: '#6366f1', flexShrink: 0,
|
||||
}}>
|
||||
{getIconComponent(r.icon)}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>{r.label}</Typography.Text>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.key}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: '描述', dataIndex: 'description', ellipsis: true },
|
||||
{
|
||||
title: '选项配置', key: 'optionGroups', width: 260,
|
||||
render: (_: any, r: IndustryItem) => {
|
||||
if (!r.optionGroups || r.optionGroups.length === 0) {
|
||||
return <Typography.Text style={{ fontSize: 12, color: '#cbd5e1' }}>未配置</Typography.Text>;
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{r.optionGroups.map((g, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>{g.name}</Tag>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
|
||||
{g.options.slice(0, 3).join('、')}{g.options.length > 3 ? `...${g.options.length}项` : ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '文图理解生成视频提示词', dataIndex: 'skills', ellipsis: true,
|
||||
render: (v: string) => {
|
||||
try {
|
||||
const arr = JSON.parse(v);
|
||||
if (Array.isArray(arr)) {
|
||||
const wtj = arr.find((s: any) => s.key === '文图理解生成视频提示词' || s.key === '文图理解');
|
||||
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '文图理解生成图片提示词', dataIndex: 'skills', ellipsis: true,
|
||||
render: (v: string) => {
|
||||
try {
|
||||
const arr = JSON.parse(v);
|
||||
if (Array.isArray(arr)) {
|
||||
const wtj = arr.find((s: any) => s.key === '文图理解生成图片提示词');
|
||||
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
||||
render: (_: any, r: IndustryItem) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||
<Popconfirm title="确定删除该行业?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<AppstoreOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>行业与技能配置</Typography.Text>
|
||||
<Tag color="purple">{industries.length} 个行业</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
||||
添加行业
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={industries}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={<Space><AppstoreOutlined />{modal.item ? '编辑行业' : '添加行业'}</Space>}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={640}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="key" label="行业标识" style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入标识' }]}>
|
||||
<Input placeholder="例如:ecommerce" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="label" label="行业名称" style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="例如:电商" size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="icon" label="行业图标">
|
||||
<IconPicker />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="行业描述">
|
||||
<Input.TextArea rows={2} placeholder="行业描述" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="skills_wentutujie" label="文图理解提示词" extra="用于LLM优化提示词的系统指令,根据行业特点引导AI理解文案与画面的关系">
|
||||
<Input.TextArea rows={3} placeholder="请输入文图理解提示词,例如: 你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Option Groups */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>行业选项配置</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
|
||||
添加选项组,每组包含名称和多个选项,前台将显示为下拉选择
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Form.List name="optionGroups">
|
||||
{(fields, { add, remove }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<div key={key} style={{
|
||||
display: 'flex', gap: 8, alignItems: 'flex-start',
|
||||
padding: '10px 12px', borderRadius: 10,
|
||||
background: '#f8f9fc', border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Form.Item {...restField} name={[name, 'name']} label="选项名称" style={{ marginBottom: 0 }}
|
||||
rules={[{ required: true, message: '请输入选项名称' }]}>
|
||||
<Input placeholder="例如:视频风格" size="middle" style={{ borderRadius: 8 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'options']} label="选项内容" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
mode="tags"
|
||||
size="middle"
|
||||
placeholder="输入选项后回车添加"
|
||||
style={{ borderRadius: 8 }}
|
||||
tokenSeparators={[',', ',', '、']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<MinusCircleOutlined
|
||||
onClick={() => remove(name)}
|
||||
style={{ color: '#ef4444', fontSize: 16, marginTop: 34, cursor: 'pointer', flexShrink: 0 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="dashed" onClick={() => add()} block
|
||||
icon={<PlusOutlined />}
|
||||
style={{ borderRadius: 8, height: 36 }}
|
||||
>
|
||||
添加选项组
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="is_active" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0} style={{ flex: 1 }}>
|
||||
<Input type="number" size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminIndustries;
|
||||
@@ -1,318 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
RobotOutlined,
|
||||
SettingOutlined,
|
||||
BellOutlined,
|
||||
ThunderboltOutlined,
|
||||
LogoutOutlined,
|
||||
LockOutlined,
|
||||
WalletOutlined,
|
||||
CalculatorOutlined,
|
||||
DollarOutlined,
|
||||
AppstoreOutlined,
|
||||
PlayCircleOutlined,
|
||||
GiftOutlined,
|
||||
HomeOutlined,
|
||||
StarOutlined,
|
||||
HeartOutlined,
|
||||
CameraOutlined,
|
||||
FileTextOutlined,
|
||||
HistoryOutlined,
|
||||
VideoCameraOutlined,
|
||||
PictureOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||
import { useAdminStore } from '../store';
|
||||
import { getMenuConfigs, adminChangePassword } from '../api';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
DashboardOutlined: <DashboardOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
RobotOutlined: <RobotOutlined />,
|
||||
SettingOutlined: <SettingOutlined />,
|
||||
BellOutlined: <BellOutlined />,
|
||||
LogoutOutlined: <LogoutOutlined />,
|
||||
WalletOutlined: <WalletOutlined />,
|
||||
CalculatorOutlined: <CalculatorOutlined />,
|
||||
DollarOutlined: <DollarOutlined />,
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
PlayCircleOutlined: <PlayCircleOutlined />,
|
||||
GiftOutlined: <GiftOutlined />,
|
||||
HomeOutlined: <HomeOutlined />,
|
||||
StarOutlined: <StarOutlined />,
|
||||
HeartOutlined: <HeartOutlined />,
|
||||
CameraOutlined: <CameraOutlined />,
|
||||
FileTextOutlined: <FileTextOutlined />,
|
||||
HistoryOutlined: <HistoryOutlined />,
|
||||
VideoCameraOutlined: <VideoCameraOutlined />,
|
||||
};
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, loading, logout } = useAdminStore();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [menuItems, setMenuItems] = useState<any[]>([]);
|
||||
const [pwdModal, setPwdModal] = useState(false);
|
||||
const [pwdForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
getMenuConfigs().then(data => {
|
||||
let menus = data.filter((m: any) => {
|
||||
const target = m.menu_target ?? m.menuTarget ?? 'admin';
|
||||
const active = m.is_active ?? m.isActive ?? true;
|
||||
return active && (target === 'admin' || target === 'both');
|
||||
});
|
||||
// Non-admin backend users: filter by allowedMenus
|
||||
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
|
||||
if (user && !isAdminUser) {
|
||||
const allowed = user.allowedMenus ?? (user as any)?.allowed_menus;
|
||||
if (allowed && Array.isArray(allowed) && allowed.length > 0) {
|
||||
const allowedSet = new Set(allowed);
|
||||
menus = menus.filter((m: any) => {
|
||||
const menuType = m.menu_type ?? m.menuType;
|
||||
if (menuType === 'group') {
|
||||
return menus.some((c: any) => {
|
||||
const pid = c.parent_id ?? c.parentId;
|
||||
return pid === m.id && allowedSet.has(c.path);
|
||||
});
|
||||
}
|
||||
return allowedSet.has(m.path);
|
||||
});
|
||||
} else {
|
||||
// No allowed menus set and not admin = show nothing
|
||||
menus = [];
|
||||
}
|
||||
}
|
||||
setMenuItems(menus);
|
||||
}).catch(() => {});
|
||||
}, [user]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
|
||||
// Build menu items for Ant Design Menu — single sorted list interleaving groups and pages
|
||||
const childMap: Record<string, any[]> = {};
|
||||
menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group' && (m.parent_id ?? m.parentId)).forEach(m => {
|
||||
const pid = m.parent_id ?? m.parentId;
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(m);
|
||||
});
|
||||
|
||||
// Sort all menu items by sortOrder, then build ant menu items
|
||||
const sorted = [...menuItems].sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0));
|
||||
const antMenuItems: any[] = [];
|
||||
|
||||
sorted.forEach(m => {
|
||||
const menuType = m.menu_type ?? m.menuType;
|
||||
const parentId = m.parent_id ?? m.parentId;
|
||||
if (menuType === 'group') {
|
||||
const children = (childMap[m.id] || [])
|
||||
.sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0))
|
||||
.map(c => ({
|
||||
key: c.path,
|
||||
icon: iconMap[c.icon] || undefined,
|
||||
label: c.label,
|
||||
}));
|
||||
if (children.length > 0) {
|
||||
antMenuItems.push({
|
||||
key: `group-${m.id}`,
|
||||
icon: iconMap[m.icon] || undefined,
|
||||
label: m.label,
|
||||
children,
|
||||
});
|
||||
}
|
||||
} else if (!parentId) {
|
||||
antMenuItems.push({
|
||||
key: m.path,
|
||||
icon: iconMap[m.icon] || undefined,
|
||||
label: m.label,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback only for admin users — non-admin users with no permissions see nothing
|
||||
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
|
||||
if (antMenuItems.length === 0 && isAdminUser) {
|
||||
antMenuItems.push(
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
);
|
||||
}
|
||||
|
||||
const selectedKey = location.pathname;
|
||||
|
||||
// Find the leaf menu key that matches (for sub-menus, need to find the right key)
|
||||
let activeKey = selectedKey;
|
||||
const allLeafKeys: string[] = [];
|
||||
antMenuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
item.children.forEach((c: any) => allLeafKeys.push(c.key));
|
||||
} else {
|
||||
allLeafKeys.push(item.key);
|
||||
}
|
||||
});
|
||||
// Exact match or prefix match
|
||||
if (!allLeafKeys.includes(activeKey)) {
|
||||
activeKey = allLeafKeys.find(k => activeKey.startsWith(k)) || '/';
|
||||
}
|
||||
|
||||
// Find open keys for sub-menus
|
||||
const openKeys: string[] = [];
|
||||
antMenuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
if (item.children.some((c: any) => c.key === activeKey)) {
|
||||
openKeys.push(item.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
width={220}
|
||||
theme="dark"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div style={{
|
||||
height: 64, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', gap: 10,
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span style={{ color: '#f1f5f9', fontSize: 15, fontWeight: 700 }}>
|
||||
管理后台
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[activeKey]}
|
||||
defaultOpenKeys={openKeys}
|
||||
items={antMenuItems}
|
||||
onClick={({ key }) => {
|
||||
if (key.startsWith('group-')) return;
|
||||
navigate(key);
|
||||
}}
|
||||
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
</Sider>
|
||||
|
||||
<Layout>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 24px',
|
||||
}}>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{antMenuItems.find(m => m.key === activeKey)?.label
|
||||
|| antMenuItems.flatMap(m => m.children || []).find((c: any) => c.key === activeKey)?.label
|
||||
|| '管理后台'}
|
||||
</Typography.Text>
|
||||
<Dropdown menu={{
|
||||
items: [
|
||||
{ key: 'user', icon: <UserOutlined />, label: user?.username, disabled: true },
|
||||
{ type: 'divider' as const },
|
||||
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'logout') { logout(); navigate('/login'); }
|
||||
if (key === 'changePwd') { setPwdModal(true); pwdForm.resetFields(); }
|
||||
},
|
||||
}} placement="bottomRight" arrow>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', padding: '4px 8px', borderRadius: 8, transition: 'background 0.2s' }}>
|
||||
<Avatar size={28} icon={<UserOutlined />}
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
|
||||
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{user?.username}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
|
||||
{antMenuItems.length === 0 && !isAdminUser ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', color: '#94a3b8' }}>
|
||||
<LockOutlined style={{ fontSize: 48, marginBottom: 16, color: '#cbd5e1' }} />
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#64748b' }}>暂无任何权限</div>
|
||||
<div style={{ fontSize: 13, marginTop: 8 }}>请联系管理员配置菜单权限</div>
|
||||
</div>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
<Modal
|
||||
title={<Space><LockOutlined />修改密码</Space>}
|
||||
open={pwdModal}
|
||||
onOk={async () => {
|
||||
try {
|
||||
const values = await pwdForm.validateFields();
|
||||
if (values.newPassword !== values.confirmPassword) {
|
||||
message.error('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
await adminChangePassword(values.oldPassword, values.newPassword);
|
||||
message.success('密码修改成功');
|
||||
setPwdModal(false);
|
||||
pwdForm.resetFields();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '修改失败');
|
||||
}
|
||||
}}
|
||||
onCancel={() => { setPwdModal(false); pwdForm.resetFields(); }}
|
||||
okText="确认修改" cancelText="取消" width={420}
|
||||
>
|
||||
<Form form={pwdForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
|
||||
<Input.Password placeholder="请输入原密码" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
|
||||
<Input.Password placeholder="请输入新密码(至少6位)" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="confirmPassword" label="确认新密码" rules={[{ required: true, message: '请再次输入新密码' }]}>
|
||||
<Input.Password placeholder="请再次输入新密码" size="large" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
@@ -1,319 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
RobotOutlined,
|
||||
SettingOutlined,
|
||||
BellOutlined,
|
||||
ThunderboltOutlined,
|
||||
LogoutOutlined,
|
||||
LockOutlined,
|
||||
WalletOutlined,
|
||||
CalculatorOutlined,
|
||||
DollarOutlined,
|
||||
AppstoreOutlined,
|
||||
PlayCircleOutlined,
|
||||
GiftOutlined,
|
||||
HomeOutlined,
|
||||
StarOutlined,
|
||||
HeartOutlined,
|
||||
CameraOutlined,
|
||||
FileTextOutlined,
|
||||
HistoryOutlined,
|
||||
VideoCameraOutlined,
|
||||
PictureOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||
import { useAdminStore } from '../store';
|
||||
import { getMenuConfigs, adminChangePassword } from '../api';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
DashboardOutlined: <DashboardOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
RobotOutlined: <RobotOutlined />,
|
||||
SettingOutlined: <SettingOutlined />,
|
||||
BellOutlined: <BellOutlined />,
|
||||
LogoutOutlined: <LogoutOutlined />,
|
||||
WalletOutlined: <WalletOutlined />,
|
||||
CalculatorOutlined: <CalculatorOutlined />,
|
||||
DollarOutlined: <DollarOutlined />,
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
PlayCircleOutlined: <PlayCircleOutlined />,
|
||||
GiftOutlined: <GiftOutlined />,
|
||||
HomeOutlined: <HomeOutlined />,
|
||||
StarOutlined: <StarOutlined />,
|
||||
HeartOutlined: <HeartOutlined />,
|
||||
CameraOutlined: <CameraOutlined />,
|
||||
FileTextOutlined: <FileTextOutlined />,
|
||||
HistoryOutlined: <HistoryOutlined />,
|
||||
VideoCameraOutlined: <VideoCameraOutlined />,
|
||||
PictureOutlined: <PictureOutlined />,
|
||||
};
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, loading, logout } = useAdminStore();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [menuItems, setMenuItems] = useState<any[]>([]);
|
||||
const [pwdModal, setPwdModal] = useState(false);
|
||||
const [pwdForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
getMenuConfigs().then(data => {
|
||||
let menus = data.filter((m: any) => {
|
||||
const target = m.menu_target ?? m.menuTarget ?? 'admin';
|
||||
const active = m.is_active ?? m.isActive ?? true;
|
||||
return active && (target === 'admin' || target === 'both');
|
||||
});
|
||||
// Non-admin backend users: filter by allowedMenus
|
||||
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
|
||||
if (user && !isAdminUser) {
|
||||
const allowed = user.allowedMenus ?? (user as any)?.allowed_menus;
|
||||
if (allowed && Array.isArray(allowed) && allowed.length > 0) {
|
||||
const allowedSet = new Set(allowed);
|
||||
menus = menus.filter((m: any) => {
|
||||
const menuType = m.menu_type ?? m.menuType;
|
||||
if (menuType === 'group') {
|
||||
return menus.some((c: any) => {
|
||||
const pid = c.parent_id ?? c.parentId;
|
||||
return pid === m.id && allowedSet.has(c.path);
|
||||
});
|
||||
}
|
||||
return allowedSet.has(m.path);
|
||||
});
|
||||
} else {
|
||||
// No allowed menus set and not admin = show nothing
|
||||
menus = [];
|
||||
}
|
||||
}
|
||||
setMenuItems(menus);
|
||||
}).catch(() => {});
|
||||
}, [user]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
|
||||
// Build menu items for Ant Design Menu — single sorted list interleaving groups and pages
|
||||
const childMap: Record<string, any[]> = {};
|
||||
menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group' && (m.parent_id ?? m.parentId)).forEach(m => {
|
||||
const pid = m.parent_id ?? m.parentId;
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(m);
|
||||
});
|
||||
|
||||
// Sort all menu items by sortOrder, then build ant menu items
|
||||
const sorted = [...menuItems].sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0));
|
||||
const antMenuItems: any[] = [];
|
||||
|
||||
sorted.forEach(m => {
|
||||
const menuType = m.menu_type ?? m.menuType;
|
||||
const parentId = m.parent_id ?? m.parentId;
|
||||
if (menuType === 'group') {
|
||||
const children = (childMap[m.id] || [])
|
||||
.sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0))
|
||||
.map(c => ({
|
||||
key: c.path,
|
||||
icon: iconMap[c.icon] || undefined,
|
||||
label: c.label,
|
||||
}));
|
||||
if (children.length > 0) {
|
||||
antMenuItems.push({
|
||||
key: `group-${m.id}`,
|
||||
icon: iconMap[m.icon] || undefined,
|
||||
label: m.label,
|
||||
children,
|
||||
});
|
||||
}
|
||||
} else if (!parentId) {
|
||||
antMenuItems.push({
|
||||
key: m.path,
|
||||
icon: iconMap[m.icon] || undefined,
|
||||
label: m.label,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback only for admin users — non-admin users with no permissions see nothing
|
||||
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
|
||||
if (antMenuItems.length === 0 && isAdminUser) {
|
||||
antMenuItems.push(
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
);
|
||||
}
|
||||
|
||||
const selectedKey = location.pathname;
|
||||
|
||||
// Find the leaf menu key that matches (for sub-menus, need to find the right key)
|
||||
let activeKey = selectedKey;
|
||||
const allLeafKeys: string[] = [];
|
||||
antMenuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
item.children.forEach((c: any) => allLeafKeys.push(c.key));
|
||||
} else {
|
||||
allLeafKeys.push(item.key);
|
||||
}
|
||||
});
|
||||
// Exact match or prefix match
|
||||
if (!allLeafKeys.includes(activeKey)) {
|
||||
activeKey = allLeafKeys.find(k => activeKey.startsWith(k)) || '/';
|
||||
}
|
||||
|
||||
// Find open keys for sub-menus
|
||||
const openKeys: string[] = [];
|
||||
antMenuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
if (item.children.some((c: any) => c.key === activeKey)) {
|
||||
openKeys.push(item.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
width={220}
|
||||
theme="dark"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div style={{
|
||||
height: 64, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', gap: 10,
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span style={{ color: '#f1f5f9', fontSize: 15, fontWeight: 700 }}>
|
||||
管理后台
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[activeKey]}
|
||||
defaultOpenKeys={openKeys}
|
||||
items={antMenuItems}
|
||||
onClick={({ key }) => {
|
||||
if (key.startsWith('group-')) return;
|
||||
navigate(key);
|
||||
}}
|
||||
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
</Sider>
|
||||
|
||||
<Layout>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 24px',
|
||||
}}>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>
|
||||
{antMenuItems.find(m => m.key === activeKey)?.label
|
||||
|| antMenuItems.flatMap(m => m.children || []).find((c: any) => c.key === activeKey)?.label
|
||||
|| '管理后台'}
|
||||
</Typography.Text>
|
||||
<Dropdown menu={{
|
||||
items: [
|
||||
{ key: 'user', icon: <UserOutlined />, label: user?.username, disabled: true },
|
||||
{ type: 'divider' as const },
|
||||
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'logout') { logout(); navigate('/login'); }
|
||||
if (key === 'changePwd') { setPwdModal(true); pwdForm.resetFields(); }
|
||||
},
|
||||
}} placement="bottomRight" arrow>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', padding: '4px 8px', borderRadius: 8, transition: 'background 0.2s' }}>
|
||||
<Avatar size={28} icon={<UserOutlined />}
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
|
||||
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{user?.username}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
|
||||
{antMenuItems.length === 0 && !isAdminUser ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', color: '#94a3b8' }}>
|
||||
<LockOutlined style={{ fontSize: 48, marginBottom: 16, color: '#cbd5e1' }} />
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#64748b' }}>暂无任何权限</div>
|
||||
<div style={{ fontSize: 13, marginTop: 8 }}>请联系管理员配置菜单权限</div>
|
||||
</div>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
<Modal
|
||||
title={<Space><LockOutlined />修改密码</Space>}
|
||||
open={pwdModal}
|
||||
onOk={async () => {
|
||||
try {
|
||||
const values = await pwdForm.validateFields();
|
||||
if (values.newPassword !== values.confirmPassword) {
|
||||
message.error('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
await adminChangePassword(values.oldPassword, values.newPassword);
|
||||
message.success('密码修改成功');
|
||||
setPwdModal(false);
|
||||
pwdForm.resetFields();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '修改失败');
|
||||
}
|
||||
}}
|
||||
onCancel={() => { setPwdModal(false); pwdForm.resetFields(); }}
|
||||
okText="确认修改" cancelText="取消" width={420}
|
||||
>
|
||||
<Form form={pwdForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
|
||||
<Input.Password placeholder="请输入原密码" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
|
||||
<Input.Password placeholder="请输入新密码(至少6位)" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="confirmPassword" label="确认新密码" rules={[{ required: true, message: '请再次输入新密码' }]}>
|
||||
<Input.Password placeholder="请再次输入新密码" size="large" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
@@ -0,0 +1,384 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Space,
|
||||
Spin,
|
||||
Steps,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
FileImageOutlined,
|
||||
PlayCircleOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { getAdminHotOpeningTaskDetail, getAdminShotProjectDetail } from '../api';
|
||||
import type { ReplicationProjectDetailOut, ReplicationStepOut } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import JsonCollapse, { JsonBlock } from './adminReplication/components/JsonCollapse';
|
||||
import MediaPreview from './adminReplication/components/MediaPreview';
|
||||
import StatusTag, { getModuleLabel, getStatusLabel, getStepCodeLabel } from './adminReplication/components/StatusTag';
|
||||
import VideoPromptSchemaViewer from './adminReplication/components/VideoPromptSchemaViewer';
|
||||
|
||||
type ReplicationModuleType = 'hot_opening_replicate' | 'shot_replicate';
|
||||
|
||||
interface AdminReplicationProjectDetailProps {
|
||||
moduleType?: ReplicationModuleType;
|
||||
}
|
||||
|
||||
const STEP_ORDER = [
|
||||
'material_input',
|
||||
'image_prompt_optimize',
|
||||
'image_generate',
|
||||
'video_prompt_optimize',
|
||||
'video_generate',
|
||||
];
|
||||
|
||||
const STEP_DESCRIPTIONS: Record<string, string> = {
|
||||
material_input: '参考素材、项目名称和核心内容点',
|
||||
image_prompt_optimize: '图片 AI 提词优化结果',
|
||||
image_generate: '图片引擎、生成参数和结果图',
|
||||
video_prompt_optimize: '视频 JSON schema、动作流程和最终提词',
|
||||
video_generate: '视频引擎、生成参数、封面和结果视频',
|
||||
};
|
||||
|
||||
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
|
||||
|
||||
const shortId = (value?: string | null): string => {
|
||||
if (!value) return '-';
|
||||
return value.length > 18 ? `${value.slice(0, 10)}...${value.slice(-4)}` : value;
|
||||
};
|
||||
|
||||
const EmptyText: React.FC<{ text?: string }> = ({ text = '-' }) => (
|
||||
<Typography.Text type="secondary">{text}</Typography.Text>
|
||||
);
|
||||
|
||||
const StepRawJson: React.FC<{ step?: ReplicationStepOut | null }> = ({ step }) => {
|
||||
if (!step) return null;
|
||||
return <JsonCollapse input={step.input} output={step.output} />;
|
||||
};
|
||||
|
||||
const StepHeader: React.FC<{ index: number; stepCode: string; step?: ReplicationStepOut | null; current?: boolean }> = ({ index, stepCode, step, current }) => (
|
||||
<Space wrap size={8}>
|
||||
<Typography.Text strong>{`第${index}步:${getStepCodeLabel(stepCode)}`}</Typography.Text>
|
||||
<StatusTag status={step?.status || 'not_started'} />
|
||||
{current ? <Tag color="blue">当前步骤</Tag> : null}
|
||||
<Typography.Text type="secondary">{STEP_DESCRIPTIONS[stepCode] || stepCode}</Typography.Text>
|
||||
{step?.updatedAt ? <Typography.Text type="secondary">更新时间:{safeDate(step.updatedAt)}</Typography.Text> : null}
|
||||
{step?.chatTaskId ? <Tooltip title={step.chatTaskId}><Tag>Chat:{shortId(step.chatTaskId)}</Tag></Tooltip> : null}
|
||||
</Space>
|
||||
);
|
||||
|
||||
const buildDefaultActiveKeys = (detail: ReplicationProjectDetailOut | null, stepsByCode: Record<string, ReplicationStepOut>): string[] => {
|
||||
if (!detail) return [];
|
||||
const keys = new Set<string>();
|
||||
keys.add('material_input');
|
||||
if (detail.currentStepCode) keys.add(detail.currentStepCode);
|
||||
Object.values(stepsByCode).forEach(step => {
|
||||
if (step.status === 'failed' || step.errorMessage) keys.add(step.stepCode);
|
||||
});
|
||||
if (detail.errorMessage) keys.add(detail.currentStepCode || 'material_input');
|
||||
return Array.from(keys);
|
||||
};
|
||||
|
||||
const renderErrorAlert = (messageText?: string | null, title = '错误信息') => {
|
||||
if (!messageText) return null;
|
||||
return <Alert type="error" showIcon message={title} description={messageText} style={{ marginTop: 12, marginBottom: 12 }} />;
|
||||
};
|
||||
|
||||
const renderPromptText = (value?: string | null, empty = '暂无提词') => {
|
||||
if (!value) return <EmptyText text={empty} />;
|
||||
return <Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{value}</Typography.Paragraph>;
|
||||
};
|
||||
|
||||
const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps> = ({ moduleType = 'shot_replicate' }) => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [detail, setDetail] = useState<ReplicationProjectDetailOut | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = moduleType === 'hot_opening_replicate'
|
||||
? await getAdminHotOpeningTaskDetail(projectId)
|
||||
: await getAdminShotProjectDetail(projectId);
|
||||
setDetail(res);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载复刻项目详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [moduleType, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const stepsByCode = useMemo(() => {
|
||||
const map: Record<string, ReplicationStepOut> = {};
|
||||
(detail?.steps || []).forEach(step => { map[step.stepCode] = step; });
|
||||
return map;
|
||||
}, [detail]);
|
||||
|
||||
const moduleValue = detail?.module || moduleType;
|
||||
const moduleName = getModuleLabel(moduleValue);
|
||||
|
||||
const stepItems = useMemo(() => STEP_ORDER.map(code => {
|
||||
const step = stepsByCode[code];
|
||||
let status: 'wait' | 'process' | 'finish' | 'error' = 'wait';
|
||||
if (step?.status === 'completed') status = 'finish';
|
||||
else if (step?.status === 'processing') status = 'process';
|
||||
else if (step?.status === 'failed') status = 'error';
|
||||
return {
|
||||
title: getStepCodeLabel(code),
|
||||
description: step ? <StatusTag status={step.status} /> : '未创建',
|
||||
status,
|
||||
};
|
||||
}), [stepsByCode]);
|
||||
|
||||
const defaultActiveKeys = useMemo(() => buildDefaultActiveKeys(detail, stepsByCode), [detail, stepsByCode]);
|
||||
|
||||
if (loading && !detail) {
|
||||
return <div style={{ padding: 64, textAlign: 'center' }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<Card style={{ margin: 24 }}>
|
||||
<Empty description="未找到复刻项目详情" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const materialStep = stepsByCode.material_input;
|
||||
const imagePromptStep = stepsByCode.image_prompt_optimize;
|
||||
const imageGenerateStep = stepsByCode.image_generate;
|
||||
const videoPromptStep = stepsByCode.video_prompt_optimize;
|
||||
const videoGenerateStep = stepsByCode.video_generate;
|
||||
|
||||
const imageResultUrl = detail.imageGeneration?.resultImageUrl || detail.finalImageUrl;
|
||||
const videoCoverUrl = detail.videoGeneration?.resultVideoCoverUrl || detail.finalVideoCoverUrl;
|
||||
const videoResultUrl = detail.videoGeneration?.resultVideoUrl || detail.finalVideoUrl;
|
||||
|
||||
const collapseItems = [
|
||||
{
|
||||
key: 'material_input',
|
||||
label: <StepHeader index={1} stepCode="material_input" step={materialStep} current={detail.currentStepCode === 'material_input'} />,
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="模块类型"><StatusTag status={moduleValue} /></Descriptions.Item>
|
||||
<Descriptions.Item label="素材步骤ID">{detail.material?.materialStepId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="参考素材项目名">{detail.material?.sourceProjectName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="生成项目名称">{detail.material?.targetProjectName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="核心内容点" span={2}>{detail.material?.coreContentPoint || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
|
||||
<Card size="small" title={<Space><PlayCircleOutlined />素材视频</Space>}>
|
||||
<MediaPreview type="video" url={detail.material?.materialVideoUrl} emptyDescription="暂无素材视频" />
|
||||
</Card>
|
||||
<Card size="small" title={<Space><FileImageOutlined />素材图片</Space>}>
|
||||
<MediaPreview type="image" url={detail.material?.materialImageUrl} emptyDescription="暂无素材图片" />
|
||||
</Card>
|
||||
</div>
|
||||
{renderErrorAlert(materialStep?.errorMessage)}
|
||||
<StepRawJson step={materialStep} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'image_prompt_optimize',
|
||||
label: <StepHeader index={2} stepCode="image_prompt_optimize" step={imagePromptStep} current={detail.currentStepCode === 'image_prompt_optimize'} />,
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="提词步骤ID">{detail.imageGeneration?.promptStepId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="步骤状态"><StatusTag status={imagePromptStep?.status || detail.imageGeneration?.status} /></Descriptions.Item>
|
||||
<Descriptions.Item label="图片提示词" span={2}>{renderPromptText(detail.imageGeneration?.prompt, '暂无图片提示词')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{renderErrorAlert(imagePromptStep?.errorMessage || detail.imageGeneration?.errorMessage)}
|
||||
<StepRawJson step={imagePromptStep} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'image_generate',
|
||||
label: <StepHeader index={3} stepCode="image_generate" step={imageGenerateStep} current={detail.currentStepCode === 'image_generate'} />,
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions column={3} bordered size="small">
|
||||
<Descriptions.Item label="生成步骤ID">{detail.imageGeneration?.generateStepId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Chat任务ID">{detail.imageGeneration?.chatTaskId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态"><StatusTag status={detail.imageGeneration?.status || imageGenerateStep?.status} /></Descriptions.Item>
|
||||
<Descriptions.Item label="图片引擎ID">{detail.imageGeneration?.engineId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="图片引擎名称">{detail.imageGeneration?.engineName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="生成参数" span={3}><JsonBlock value={detail.imageGeneration?.params || {}} maxHeight={160} /></Descriptions.Item>
|
||||
</Descriptions>
|
||||
{renderErrorAlert(detail.imageGeneration?.errorMessage || imageGenerateStep?.errorMessage)}
|
||||
<Card size="small" title={<Space><FileImageOutlined />图片结果</Space>}>
|
||||
<MediaPreview
|
||||
type="image"
|
||||
url={imageResultUrl}
|
||||
height={360}
|
||||
emptyDescription={imageGenerateStep?.status === 'failed' || detail.imageGeneration?.errorMessage ? '图片生成失败,未返回图片地址' : '暂无图片结果'}
|
||||
errorDescription="图片资源加载失败,可能图片文件不存在、签名过期或访问权限受限。"
|
||||
/>
|
||||
</Card>
|
||||
<StepRawJson step={imageGenerateStep} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'video_prompt_optimize',
|
||||
label: <StepHeader index={4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions column={3} bordered size="small">
|
||||
<Descriptions.Item label="提词步骤ID">{detail.videoGeneration?.promptStepId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="步骤状态"><StatusTag status={videoPromptStep?.status} /></Descriptions.Item>
|
||||
<Descriptions.Item label="提词参数" span={3}><JsonBlock value={detail.videoGeneration?.promptParams || {}} maxHeight={160} /></Descriptions.Item>
|
||||
</Descriptions>
|
||||
{renderErrorAlert(videoPromptStep?.errorMessage || detail.videoGeneration?.errorMessage)}
|
||||
<VideoPromptSchemaViewer schema={detail.videoGeneration?.promptSchema} finalPrompt={detail.videoGeneration?.finalPrompt} />
|
||||
<StepRawJson step={videoPromptStep} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'video_generate',
|
||||
label: <StepHeader index={5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions column={3} bordered size="small">
|
||||
<Descriptions.Item label="生成步骤ID">{detail.videoGeneration?.generateStepId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Chat任务ID">{detail.videoGeneration?.chatTaskId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态"><StatusTag status={detail.videoGeneration?.status || videoGenerateStep?.status} /></Descriptions.Item>
|
||||
<Descriptions.Item label="视频引擎ID">{detail.videoGeneration?.engineId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="视频引擎名称">{detail.videoGeneration?.engineName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="生成参数" span={3}><JsonBlock value={detail.videoGeneration?.params || {}} maxHeight={160} /></Descriptions.Item>
|
||||
</Descriptions>
|
||||
{renderErrorAlert(detail.videoGeneration?.errorMessage || videoGenerateStep?.errorMessage)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
|
||||
<Card size="small" title={<Space><FileImageOutlined />视频封面</Space>}>
|
||||
<MediaPreview
|
||||
type="image"
|
||||
url={videoCoverUrl}
|
||||
emptyDescription={videoGenerateStep?.status === 'failed' || detail.videoGeneration?.errorMessage ? '视频生成失败,未返回封面' : '暂无视频封面'}
|
||||
errorDescription="视频封面加载失败,可能文件不存在、签名过期或访问权限受限。"
|
||||
/>
|
||||
</Card>
|
||||
<Card size="small" title={<Space><VideoCameraOutlined />最终视频</Space>}>
|
||||
<MediaPreview
|
||||
type="video"
|
||||
url={videoResultUrl}
|
||||
emptyDescription={videoGenerateStep?.status === 'failed' || detail.videoGeneration?.errorMessage ? '视频生成失败,未返回视频地址' : '暂无最终视频'}
|
||||
errorDescription="视频资源加载失败,可能文件不存在、签名过期或访问权限受限。"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<StepRawJson step={videoGenerateStep} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space wrap style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)}>返回</Button>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>{moduleName}项目详情</Typography.Title>
|
||||
<StatusTag status={detail.status} />
|
||||
{detail.module && detail.module !== moduleType ? <Tag color="gold">接口模块:{getModuleLabel(detail.module)}</Tag> : null}
|
||||
</Space>
|
||||
<Button onClick={load} loading={loading}>刷新</Button>
|
||||
</Space>
|
||||
|
||||
<Card>
|
||||
<Descriptions title="基础信息" column={3} bordered size="small">
|
||||
<Descriptions.Item label="项目ID">{detail.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="模块类型"><StatusTag status={moduleValue} /></Descriptions.Item>
|
||||
<Descriptions.Item label="用户ID">{detail.userId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="当前步骤"><Tooltip title={detail.currentStepCode || ''}>{getStepCodeLabel(detail.currentStepCode)}</Tooltip></Descriptions.Item>
|
||||
<Descriptions.Item label="状态"><StatusTag status={detail.status} /></Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间">{safeDate(detail.completedAt)}</Descriptions.Item>
|
||||
{detail.errorMessage ? <Descriptions.Item label="错误信息" span={3}><Alert type="error" showIcon message={detail.errorMessage} /></Descriptions.Item> : null}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="步骤进度">
|
||||
<Steps items={stepItems} />
|
||||
</Card>
|
||||
|
||||
<Card title="最终结果预览">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
|
||||
<Card size="small" title="最终图片">
|
||||
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
|
||||
</Card>
|
||||
<Card size="small" title="最终视频封面">
|
||||
<MediaPreview type="image" url={detail.finalVideoCoverUrl || detail.videoGeneration?.resultVideoCoverUrl} height={220} emptyDescription="暂无最终视频封面" />
|
||||
</Card>
|
||||
<Card size="small" title="最终视频">
|
||||
<MediaPreview type="video" url={detail.finalVideoUrl || detail.videoGeneration?.resultVideoUrl} height={220} emptyDescription="暂无最终视频" />
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Collapse defaultActiveKey={defaultActiveKeys} items={collapseItems} />
|
||||
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: 'steps-table',
|
||||
label: '完整步骤列表',
|
||||
children: (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={detail.steps || []}
|
||||
scroll={{ x: 1100 }}
|
||||
columns={[
|
||||
{ title: '序号', dataIndex: 'stepIndex', width: 70 },
|
||||
{ title: '步骤', dataIndex: 'stepCode', width: 160, render: (v: string) => <Tooltip title={v}>{getStepCodeLabel(v)}</Tooltip> },
|
||||
{ title: '状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '版本', dataIndex: 'version', width: 70 },
|
||||
{ title: '当前有效', dataIndex: 'isCurrent', width: 90, render: (v: boolean) => v ? <Tag color="success">是</Tag> : <Tag>否</Tag> },
|
||||
{ title: 'Chat任务', dataIndex: 'chatTaskId', width: 160, render: (v: string) => <Tooltip title={v || ''}>{shortId(v)}</Tooltip> },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
|
||||
{ title: '完成时间', dataIndex: 'completedAt', width: 170, render: safeDate },
|
||||
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true, render: (v: string) => v || '-' },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'raw-detail',
|
||||
label: '完整详情原始 JSON',
|
||||
children: <JsonBlock value={detail} maxHeight={520} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminReplicationProjectDetail;
|
||||
@@ -0,0 +1,220 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getAdminShotTaskSets } from '../api';
|
||||
import type { ShotTaskSetOut } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const TASK_STATUS_OPTIONS = [
|
||||
{ value: 'pending_analysis', label: '等待分析' },
|
||||
{ value: 'analyzing', label: '分析中' },
|
||||
{ value: 'analysis_completed', label: '分析完成' },
|
||||
{ value: 'analysis_failed', label: '分析失败' },
|
||||
{ value: 'splitting', label: '拆镜中' },
|
||||
{ value: 'split_completed', label: '拆镜完成' },
|
||||
{ value: 'partial_failed', label: '部分失败' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
];
|
||||
|
||||
const ANALYSIS_STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: '待分析' },
|
||||
{ value: 'processing', label: '分析中' },
|
||||
{ value: 'completed', label: '分析完成' },
|
||||
{ value: 'failed', label: '分析失败' },
|
||||
];
|
||||
|
||||
const SPLIT_STATUS_OPTIONS = [
|
||||
{ value: 'none', label: '未拆镜' },
|
||||
{ value: 'pending', label: '待拆镜' },
|
||||
{ value: 'processing', label: '拆镜中' },
|
||||
{ value: 'completed', label: '拆镜完成' },
|
||||
{ value: 'failed', label: '拆镜失败' },
|
||||
{ value: 'retry_waiting', label: '等待重试' },
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<string, { color: string; text: string }> = {
|
||||
pending_analysis: { color: 'default', text: '等待分析' },
|
||||
analyzing: { color: 'processing', text: '分析中' },
|
||||
analysis_completed: { color: 'success', text: '分析完成' },
|
||||
analysis_failed: { color: 'error', text: '分析失败' },
|
||||
splitting: { color: 'warning', text: '拆镜中' },
|
||||
split_completed: { color: 'success', text: '拆镜完成' },
|
||||
partial_failed: { color: 'orange', text: '部分失败' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
none: { color: 'default', text: '未拆镜' },
|
||||
pending: { color: 'default', text: '待处理' },
|
||||
processing: { color: 'warning', text: '处理中' },
|
||||
completed: { color: 'success', text: '完成' },
|
||||
retry_waiting: { color: 'orange', text: '等待重试' },
|
||||
};
|
||||
|
||||
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
|
||||
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
|
||||
|
||||
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
|
||||
if (!status) return <Tag>-</Tag>;
|
||||
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
|
||||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||
};
|
||||
|
||||
const AdminShotReplications: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<ShotTaskSetOut[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [status, setStatus] = useState('');
|
||||
const [analysisStatus, setAnalysisStatus] = useState('');
|
||||
const [splitStatus, setSplitStatus] = useState('');
|
||||
const [inputKeyword, setInputKeyword] = useState('');
|
||||
const [inputUserId, setInputUserId] = useState('');
|
||||
const [inputUserName, setInputUserName] = useState('');
|
||||
const [createdRange, setCreatedRange] = useState<any>(null);
|
||||
|
||||
const [queryKeyword, setQueryKeyword] = useState('');
|
||||
const [queryUserId, setQueryUserId] = useState('');
|
||||
const [queryUserName, setQueryUserName] = useState('');
|
||||
const [queryCreatedRange, setQueryCreatedRange] = useState<any>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getAdminShotTaskSets({
|
||||
status: status || undefined,
|
||||
analysisStatus: analysisStatus || undefined,
|
||||
splitStatus: splitStatus || undefined,
|
||||
keyword: queryKeyword || undefined,
|
||||
userId: queryUserId || undefined,
|
||||
userName: queryUserName || undefined,
|
||||
createdStart: queryCreatedRange?.[0]?.toISOString?.(),
|
||||
createdEnd: queryCreatedRange?.[1]?.toISOString?.(),
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
setItems(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载拆镜复刻任务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [analysisStatus, page, queryCreatedRange, queryKeyword, queryUserId, queryUserName, splitStatus, status]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load, reloadKey]);
|
||||
|
||||
const doSearch = () => {
|
||||
setQueryKeyword(inputKeyword.trim());
|
||||
setQueryUserId(inputUserId.trim());
|
||||
setQueryUserName(inputUserName.trim());
|
||||
setQueryCreatedRange(createdRange);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetSearch = () => {
|
||||
setStatus('');
|
||||
setAnalysisStatus('');
|
||||
setSplitStatus('');
|
||||
setInputKeyword('');
|
||||
setInputUserId('');
|
||||
setInputUserName('');
|
||||
setCreatedRange(null);
|
||||
setQueryKeyword('');
|
||||
setQueryUserId('');
|
||||
setQueryUserName('');
|
||||
setQueryCreatedRange(null);
|
||||
setPage(1);
|
||||
setReloadKey(v => v + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>拆镜复刻</Typography.Title>
|
||||
<Typography.Text type="secondary">只读排查页面:查看拆镜总任务、AI 分析结果、切片列表和关联复刻项目。</Typography.Text>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}>刷新</Button>
|
||||
</Space>
|
||||
|
||||
<Space wrap>
|
||||
<Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} />
|
||||
<Select allowClear placeholder="分析状态" style={{ width: 140 }} value={analysisStatus || undefined} onChange={v => { setAnalysisStatus(v || ''); setPage(1); }} options={ANALYSIS_STATUS_OPTIONS} />
|
||||
<Select allowClear placeholder="拆镜状态" style={{ width: 140 }} value={splitStatus || undefined} onChange={v => { setSplitStatus(v || ''); setPage(1); }} options={SPLIT_STATUS_OPTIONS} />
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="关键词:标题/内容/分类/受众" style={{ width: 260 }} value={inputKeyword} onChange={e => setInputKeyword(e.target.value)} onPressEnter={doSearch} />
|
||||
<Input allowClear placeholder="用户ID(管理员)" style={{ width: 190 }} value={inputUserId} onChange={e => setInputUserId(e.target.value)} onPressEnter={doSearch} />
|
||||
<Input allowClear placeholder="用户名(管理员)" style={{ width: 230 }} value={inputUserName} onChange={e => setInputUserName(e.target.value)} onPressEnter={doSearch} />
|
||||
<DatePicker.RangePicker showTime value={createdRange} onChange={setCreatedRange} placeholder={['创建开始', '创建结束']} />
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={doSearch}>搜索</Button>
|
||||
<Button onClick={resetSearch}>重置</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
pagination={{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, showTotal: value => `共 ${value} 条`, onChange: setPage }}
|
||||
scroll={{ x: 1500 }}
|
||||
columns={[
|
||||
{ title: '任务集ID', dataIndex: 'id', width: 150, render: (v: string) => <Tooltip title={v}>{shortId(v)}</Tooltip> },
|
||||
{
|
||||
title: '用户',
|
||||
width: 180,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text>{record.userName || '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{shortId(record.userId)}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '任务信息',
|
||||
width: 300,
|
||||
render: (_, record) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text strong>{record.title || '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis style={{ maxWidth: 280 }}>分类:{record.originalVideoCategory || '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis style={{ maxWidth: 280 }}>受众:{record.originalVideoAudience || '-'}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '总状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
|
||||
{
|
||||
title: '切片进度',
|
||||
width: 180,
|
||||
render: (_, record) => {
|
||||
const totalSegments = record.segmentCount || 0;
|
||||
const done = record.completedSegmentCount || 0;
|
||||
const percent = totalSegments ? Math.round((done / totalSegments) * 100) : 0;
|
||||
return <Progress size="small" percent={percent} format={() => `${done}/${totalSegments}`} />;
|
||||
},
|
||||
},
|
||||
{ title: '失败片段', dataIndex: 'failedSegmentCount', width: 90, render: (v: number) => v ? <Tag color="error">{v}</Tag> : <Tag>0</Tag> },
|
||||
{ title: '视频时长', dataIndex: 'videoDurationSeconds', width: 100, render: (v: number) => `${Number(v || 0).toFixed(1)}s` },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: safeDate },
|
||||
{
|
||||
title: '操作',
|
||||
fixed: 'right',
|
||||
width: 120,
|
||||
render: (_, record) => <Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/shot-replications/task-sets/${record.id}`)}>查看切片</Button>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminShotReplications;
|
||||
@@ -0,0 +1,340 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { ArrowLeftOutlined, EyeOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { getAdminShotSegmentDetail, getAdminShotSegments, getAdminShotTaskSetDetail } from '../api';
|
||||
import type { ShotAiSuggestionOut, ShotSegmentDetailOut, ShotSegmentOut, ShotTaskSetDetailOut } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { getStepCodeLabel } from './adminReplication/components/StatusTag';
|
||||
|
||||
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'ai_suggestion', label: 'AI 建议' },
|
||||
{ value: 'custom', label: '自定义' },
|
||||
];
|
||||
const SPLIT_STATUS_OPTIONS = [
|
||||
{ value: 'none', label: '未拆镜' },
|
||||
{ value: 'pending', label: '待拆镜' },
|
||||
{ value: 'processing', label: '拆镜中' },
|
||||
{ value: 'completed', label: '拆镜完成' },
|
||||
{ value: 'failed', label: '拆镜失败' },
|
||||
{ value: 'retry_waiting', label: '等待重试' },
|
||||
];
|
||||
const ANALYSIS_STATUS_OPTIONS = [
|
||||
{ value: 'not_required', label: '无需分析' },
|
||||
{ value: 'pending', label: '待分析' },
|
||||
{ value: 'processing', label: '分析中' },
|
||||
{ value: 'completed', label: '分析完成' },
|
||||
{ value: 'failed', label: '分析失败' },
|
||||
];
|
||||
const REPLICATE_STATUS_OPTIONS = [
|
||||
{ value: 'not_started', label: '未复刻' },
|
||||
{ value: 'project_created', label: '已创建项目' },
|
||||
{ value: 'processing', label: '复刻中' },
|
||||
{ value: 'completed', label: '复刻完成' },
|
||||
{ value: 'failed', label: '复刻失败' },
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<string, { color: string; text: string }> = {
|
||||
pending_analysis: { color: 'default', text: '等待分析' },
|
||||
analyzing: { color: 'processing', text: '分析中' },
|
||||
analysis_completed: { color: 'success', text: '分析完成' },
|
||||
analysis_failed: { color: 'error', text: '分析失败' },
|
||||
splitting: { color: 'warning', text: '拆镜中' },
|
||||
split_completed: { color: 'success', text: '拆镜完成' },
|
||||
partial_failed: { color: 'orange', text: '部分失败' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
none: { color: 'default', text: '未拆镜' },
|
||||
pending: { color: 'default', text: '待处理' },
|
||||
processing: { color: 'warning', text: '处理中' },
|
||||
completed: { color: 'success', text: '完成' },
|
||||
retry_waiting: { color: 'orange', text: '等待重试' },
|
||||
not_required: { color: 'default', text: '无需分析' },
|
||||
not_started: { color: 'default', text: '未复刻' },
|
||||
project_created: { color: 'processing', text: '已创建项目' },
|
||||
};
|
||||
|
||||
const apiUrl = (url?: string | null): string => {
|
||||
if (!url) return '';
|
||||
const value = String(url).trim();
|
||||
if (!value) return '';
|
||||
if (/^(https?:)?\/\//i.test(value) || /^(blob|data):/i.test(value)) return value;
|
||||
return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
|
||||
};
|
||||
|
||||
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
|
||||
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
|
||||
|
||||
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
|
||||
if (!status) return <Tag>-</Tag>;
|
||||
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
|
||||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||
};
|
||||
|
||||
const JsonBlock: React.FC<{ value: unknown; maxHeight?: number }> = ({ value, maxHeight = 420 }) => (
|
||||
<pre style={{ margin: 0, padding: 12, maxHeight, overflow: 'auto', background: '#0f172a', color: '#e2e8f0', borderRadius: 8, fontSize: 12, lineHeight: 1.6 }}>
|
||||
{JSON.stringify(value ?? {}, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
|
||||
const VideoPreview: React.FC<{ url?: string | null; height?: number }> = ({ url, height = 280 }) => {
|
||||
if (!url) return <Empty description="暂无视频" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
|
||||
return <video src={apiUrl(url)} controls preload="metadata" style={{ width: '100%', maxHeight: height, borderRadius: 12, background: '#0f172a' }} />;
|
||||
};
|
||||
|
||||
const SuggestionTable: React.FC<{ items?: ShotAiSuggestionOut[] }> = ({ items = [] }) => (
|
||||
<Table
|
||||
rowKey="index"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={items}
|
||||
scroll={{ x: true }}
|
||||
columns={[
|
||||
{ title: '序号', dataIndex: 'index', width: 70 },
|
||||
{ title: '时间节点', dataIndex: 'timeNode', width: 120 },
|
||||
{ title: '开始', dataIndex: 'startSecond', width: 80, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
|
||||
{ title: '结束', dataIndex: 'endSecond', width: 80, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
|
||||
{ title: '内容', dataIndex: 'content', ellipsis: true },
|
||||
{ title: '分类', dataIndex: 'category', width: 140 },
|
||||
{ title: '受众', dataIndex: 'audience', ellipsis: true },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
const AdminShotTaskSetDetail: React.FC = () => {
|
||||
const { taskSetId } = useParams<{ taskSetId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [detail, setDetail] = useState<ShotTaskSetDetailOut | null>(null);
|
||||
const [segments, setSegments] = useState<ShotSegmentOut[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [segmentLoading, setSegmentLoading] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
const [sourceMode, setSourceMode] = useState('');
|
||||
const [splitStatus, setSplitStatus] = useState('');
|
||||
const [analysisStatus, setAnalysisStatus] = useState('');
|
||||
const [replicateStatus, setReplicateStatus] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [segmentDetail, setSegmentDetail] = useState<ShotSegmentDetailOut | null>(null);
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
if (!taskSetId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getAdminShotTaskSetDetail(taskSetId);
|
||||
setDetail(res);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载拆镜任务详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskSetId]);
|
||||
|
||||
const loadSegments = useCallback(async () => {
|
||||
if (!taskSetId) return;
|
||||
setSegmentLoading(true);
|
||||
try {
|
||||
const res = await getAdminShotSegments(taskSetId, {
|
||||
sourceMode: sourceMode || undefined,
|
||||
splitStatus: splitStatus || undefined,
|
||||
analysisStatus: analysisStatus || undefined,
|
||||
replicateStatus: replicateStatus || undefined,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
setSegments(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载拆镜片段失败');
|
||||
} finally {
|
||||
setSegmentLoading(false);
|
||||
}
|
||||
}, [analysisStatus, page, replicateStatus, sourceMode, splitStatus, taskSetId]);
|
||||
|
||||
useEffect(() => { loadDetail(); }, [loadDetail, reloadKey]);
|
||||
useEffect(() => { loadSegments(); }, [loadSegments, reloadKey]);
|
||||
|
||||
const openSegmentDetail = async (segmentId: string) => {
|
||||
setDrawerOpen(true);
|
||||
setSegmentDetail(null);
|
||||
try {
|
||||
const res = await getAdminShotSegmentDetail(segmentId);
|
||||
setSegmentDetail(res);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载片段详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setSourceMode('');
|
||||
setSplitStatus('');
|
||||
setAnalysisStatus('');
|
||||
setReplicateStatus('');
|
||||
setPage(1);
|
||||
setReloadKey(v => v + 1);
|
||||
};
|
||||
|
||||
if (loading && !detail) {
|
||||
return <div style={{ padding: 64, textAlign: 'center' }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return <Card style={{ margin: 24 }}><Empty description="未找到拆镜任务详情" /></Card>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<Space>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/shot-replications')}>返回列表</Button>
|
||||
<Typography.Title level={3} style={{ margin: 0 }}>拆镜任务详情</Typography.Title>
|
||||
<StatusTag status={detail.status} />
|
||||
</Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}>刷新</Button>
|
||||
</Space>
|
||||
|
||||
<Card>
|
||||
<Descriptions title="总任务信息" column={3} bordered size="small">
|
||||
<Descriptions.Item label="任务集ID">{detail.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户ID">{detail.userId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="视频时长">{Number(detail.videoDurationSeconds || 0).toFixed(2)}s</Descriptions.Item>
|
||||
<Descriptions.Item label="总状态"><StatusTag status={detail.status} /></Descriptions.Item>
|
||||
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="片段数量">{detail.completedSegmentCount}/{detail.segmentCount},失败 {detail.failedSegmentCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="原视频分类">{detail.originalVideoCategory || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="原视频内容" span={3}>{detail.originalVideoContent || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="原视频受众" span={3}>{detail.originalVideoAudience || '-'}</Descriptions.Item>
|
||||
{detail.analysisErrorMessage ? <Descriptions.Item label="分析错误" span={3}><Alert type="error" message={detail.analysisErrorMessage} /></Descriptions.Item> : null}
|
||||
{detail.splitErrorMessage ? <Descriptions.Item label="拆镜错误" span={3}><Alert type="error" message={detail.splitErrorMessage} /></Descriptions.Item> : null}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title={<Space><PlayCircleOutlined />原视频预览</Space>}>
|
||||
<VideoPreview url={detail.videoUrl} height={360} />
|
||||
</Card>
|
||||
|
||||
<Card title="AI 建议拆镜列表">
|
||||
{detail.aiSuggestions?.length ? <SuggestionTable items={detail.aiSuggestions} /> : <Empty description="暂无 AI 建议拆镜" />}
|
||||
<Collapse
|
||||
size="small"
|
||||
ghost
|
||||
style={{ marginTop: 12 }}
|
||||
items={[{ key: 'raw', label: '查看原视频分析完整 JSON', children: <JsonBlock value={detail.analysisResultJson} /> }]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="片段列表">
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select allowClear placeholder="片段来源" style={{ width: 140 }} value={sourceMode || undefined} options={SOURCE_OPTIONS} onChange={v => { setSourceMode(v || ''); setPage(1); }} />
|
||||
<Select allowClear placeholder="切割状态" style={{ width: 140 }} value={splitStatus || undefined} options={SPLIT_STATUS_OPTIONS} onChange={v => { setSplitStatus(v || ''); setPage(1); }} />
|
||||
<Select allowClear placeholder="分析状态" style={{ width: 140 }} value={analysisStatus || undefined} options={ANALYSIS_STATUS_OPTIONS} onChange={v => { setAnalysisStatus(v || ''); setPage(1); }} />
|
||||
<Select allowClear placeholder="复刻状态" style={{ width: 150 }} value={replicateStatus || undefined} options={REPLICATE_STATUS_OPTIONS} onChange={v => { setReplicateStatus(v || ''); setPage(1); }} />
|
||||
<Button icon={<SearchOutlined />} onClick={() => setReloadKey(v => v + 1)}>筛选</Button>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={segmentLoading}
|
||||
dataSource={segments}
|
||||
pagination={{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, showTotal: value => `共 ${value} 条`, onChange: setPage }}
|
||||
scroll={{ x: 1550 }}
|
||||
columns={[
|
||||
{ title: '片段ID', dataIndex: 'id', width: 150, render: (v: string) => <Tooltip title={v}>{shortId(v)}</Tooltip> },
|
||||
{ title: '序号', dataIndex: 'segmentIndex', width: 70 },
|
||||
{ title: '来源', dataIndex: 'sourceMode', width: 100, render: (v: string) => v === 'ai_suggestion' ? <Tag color="purple">AI建议</Tag> : <Tag color="cyan">自定义</Tag> },
|
||||
{ title: '时间节点', dataIndex: 'timeNode', width: 130 },
|
||||
{ title: '时长', dataIndex: 'durationSeconds', width: 90, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
|
||||
{ title: '切割', dataIndex: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '片段内容', dataIndex: 'segmentContent', width: 260, ellipsis: true, render: (v: string) => v || '-' },
|
||||
{ title: '分类', dataIndex: 'segmentCategory', width: 120, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '关联项目',
|
||||
width: 220,
|
||||
render: (_, record) => record.moduleProjectId ? (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}`)}>{shortId(record.moduleProjectId)}</Button>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{record.moduleProjectTitle || getStepCodeLabel(record.moduleProjectCurrentStepCode)}</Typography.Text>
|
||||
<StatusTag status={record.moduleProjectStatus} />
|
||||
</Space>
|
||||
) : <Tag>未创建</Tag>,
|
||||
},
|
||||
{ title: '错误', dataIndex: 'splitLastError', width: 220, ellipsis: true, render: (v: string, record) => v || record.analysisErrorMessage || '-' },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: safeDate },
|
||||
{
|
||||
title: '操作',
|
||||
fixed: 'right',
|
||||
width: 100,
|
||||
render: (_, record) => <Button type="link" icon={<EyeOutlined />} onClick={() => openSegmentDetail(record.id)}>详情</Button>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
<Drawer title="片段详情" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)} destroyOnClose>
|
||||
{!segmentDetail ? <Spin /> : (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="片段ID" span={2}>{segmentDetail.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间节点">{segmentDetail.timeNode}</Descriptions.Item>
|
||||
<Descriptions.Item label="时长">{Number(segmentDetail.durationSeconds || 0).toFixed(2)}s</Descriptions.Item>
|
||||
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="片段内容" span={2}>{segmentDetail.segmentContent || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="片段分类">{segmentDetail.segmentCategory || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="片段受众">{segmentDetail.segmentAudience || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Card size="small" title="片段视频">
|
||||
<VideoPreview url={segmentDetail.segmentVideoUrl} height={300} />
|
||||
</Card>
|
||||
{segmentDetail.splitLastError ? <Alert type="error" message="切割错误" description={segmentDetail.splitLastError} /> : null}
|
||||
{segmentDetail.analysisErrorMessage ? <Alert type="error" message="分析错误" description={segmentDetail.analysisErrorMessage} /> : null}
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{ key: 'analysis', label: '片段分析 JSON', children: <JsonBlock value={segmentDetail.analysisJson} /> },
|
||||
{ key: 'suggestion', label: 'AI 建议原始 JSON', children: <JsonBlock value={segmentDetail.aiSuggestionJson} /> },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminShotTaskSetDetail;
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Collapse, Empty } from 'antd';
|
||||
|
||||
interface JsonBlockProps {
|
||||
value: unknown;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export const JsonBlock: React.FC<JsonBlockProps> = ({ value, maxHeight = 420 }) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无 JSON 数据" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
maxHeight,
|
||||
overflow: 'auto',
|
||||
background: '#0f172a',
|
||||
color: '#e2e8f0',
|
||||
borderRadius: 8,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
interface JsonCollapseProps {
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
raw?: unknown;
|
||||
inputLabel?: string;
|
||||
outputLabel?: string;
|
||||
rawLabel?: string;
|
||||
}
|
||||
|
||||
const JsonCollapse: React.FC<JsonCollapseProps> = ({
|
||||
input,
|
||||
output,
|
||||
raw,
|
||||
inputLabel = '查看 input_json',
|
||||
outputLabel = '查看 output_json',
|
||||
rawLabel = '查看原始 JSON',
|
||||
}) => {
|
||||
const items = [];
|
||||
if (input !== undefined) items.push({ key: 'input', label: inputLabel, children: <JsonBlock value={input} /> });
|
||||
if (output !== undefined) items.push({ key: 'output', label: outputLabel, children: <JsonBlock value={output} /> });
|
||||
if (raw !== undefined) items.push({ key: 'raw', label: rawLabel, children: <JsonBlock value={raw} /> });
|
||||
|
||||
if (!items.length) return null;
|
||||
return <Collapse size="small" style={{ marginTop: 12 }} items={items} />;
|
||||
};
|
||||
|
||||
export default JsonCollapse;
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd';
|
||||
import { CopyOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
|
||||
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
||||
|
||||
export function resolveResourceUrl(url?: string | null): string {
|
||||
if (!url) return '';
|
||||
const value = String(url).trim();
|
||||
if (!value) return '';
|
||||
if (/^(https?:)?\/\//i.test(value) || /^(blob|data):/i.test(value)) return value;
|
||||
return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
|
||||
}
|
||||
|
||||
interface MediaPreviewProps {
|
||||
url?: string | null;
|
||||
type: 'image' | 'video';
|
||||
height?: number;
|
||||
emptyDescription?: string;
|
||||
errorDescription?: string;
|
||||
}
|
||||
|
||||
const MediaPreview: React.FC<MediaPreviewProps> = ({
|
||||
url,
|
||||
type,
|
||||
height = 260,
|
||||
emptyDescription = '暂无资源',
|
||||
errorDescription = '资源加载失败,可能文件不存在、签名过期或访问权限受限。',
|
||||
}) => {
|
||||
const resolvedUrl = useMemo(() => resolveResourceUrl(url), [url]);
|
||||
const [loadFailed, setLoadFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoadFailed(false);
|
||||
}, [resolvedUrl]);
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!resolvedUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(resolvedUrl);
|
||||
message.success('资源地址已复制');
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制');
|
||||
}
|
||||
};
|
||||
|
||||
const tools = resolvedUrl ? (
|
||||
<Space wrap style={{ marginTop: 8 }}>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={copyUrl}>复制地址</Button>
|
||||
<Button size="small" icon={<LinkOutlined />} onClick={() => window.open(resolvedUrl, '_blank', 'noopener,noreferrer')}>新窗口打开</Button>
|
||||
<Typography.Text copyable={{ text: resolvedUrl }} type="secondary" style={{ maxWidth: 460 }} ellipsis>
|
||||
{resolvedUrl}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
) : null;
|
||||
|
||||
if (!resolvedUrl) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
borderRadius: 12,
|
||||
border: '1px dashed #cbd5e1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loadFailed ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="资源预览失败"
|
||||
description={errorDescription}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{type === 'image' ? (
|
||||
<Image
|
||||
src={resolvedUrl}
|
||||
onError={() => setLoadFailed(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: height,
|
||||
objectFit: 'contain',
|
||||
borderRadius: 12,
|
||||
background: '#f8fafc',
|
||||
border: '1px solid #f1f5f9',
|
||||
}}
|
||||
fallback=""
|
||||
preview={!loadFailed}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
src={resolvedUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
onError={() => setLoadFailed(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: height,
|
||||
borderRadius: 12,
|
||||
background: '#0f172a',
|
||||
border: '1px solid #f1f5f9',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tools}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaPreview;
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { Tag, Tooltip } from 'antd';
|
||||
|
||||
interface LabelMeta {
|
||||
text: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, LabelMeta> = {
|
||||
// 通用任务状态
|
||||
pending: { text: '待处理', color: 'default' },
|
||||
waiting_user: { text: '等待用户操作', color: 'processing' },
|
||||
processing: { text: '处理中', color: 'warning' },
|
||||
completed: { text: '已完成', color: 'success' },
|
||||
failed: { text: '失败', color: 'error' },
|
||||
cancelled: { text: '已取消', color: 'default' },
|
||||
canceled: { text: '已取消', color: 'default' },
|
||||
timeout: { text: '已超时', color: 'error' },
|
||||
queued: { text: '已入队', color: 'processing' },
|
||||
preparing: { text: '准备中', color: 'processing' },
|
||||
|
||||
// 生成任务 pipeline / download stage
|
||||
creating_provider_task: { text: '创建远端任务', color: 'processing' },
|
||||
waiting_remote: { text: '等待远端结果', color: 'processing' },
|
||||
polling: { text: '轮询远端结果', color: 'processing' },
|
||||
result_ready: { text: '结果已就绪', color: 'success' },
|
||||
downloading: { text: '下载中', color: 'processing' },
|
||||
done: { text: '已完成', color: 'success' },
|
||||
download_failed: { text: '下载失败', color: 'error' },
|
||||
retry_waiting: { text: '等待重试', color: 'orange' },
|
||||
|
||||
// 拆镜总任务状态
|
||||
pending_analysis: { text: '等待分析', color: 'default' },
|
||||
analyzing: { text: '分析中', color: 'processing' },
|
||||
analysis_completed: { text: '分析完成', color: 'success' },
|
||||
analysis_failed: { text: '分析失败', color: 'error' },
|
||||
splitting: { text: '拆镜中', color: 'warning' },
|
||||
split_completed: { text: '拆镜完成', color: 'success' },
|
||||
partial_failed: { text: '部分失败', color: 'orange' },
|
||||
|
||||
// 拆镜片段状态
|
||||
none: { text: '未拆镜', color: 'default' },
|
||||
not_required: { text: '无需处理', color: 'default' },
|
||||
not_started: { text: '未开始', color: 'default' },
|
||||
project_created: { text: '已创建项目', color: 'processing' },
|
||||
|
||||
// 布尔/兜底类
|
||||
true: { text: '是', color: 'success' },
|
||||
false: { text: '否', color: 'default' },
|
||||
};
|
||||
|
||||
const STEP_LABELS: Record<string, string> = {
|
||||
material_input: '素材输入',
|
||||
image_prompt_optimize: '图片 AI 提词',
|
||||
image_generate: '图片生成',
|
||||
video_prompt_optimize: '视频 AI 提词',
|
||||
video_generate: '视频生成',
|
||||
};
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
hot_opening_replicate: '爆款开头复刻',
|
||||
shot_replicate: '拆镜复刻',
|
||||
};
|
||||
|
||||
const SOURCE_MODE_LABELS: Record<string, LabelMeta> = {
|
||||
ai_suggestion: { text: 'AI 建议', color: 'purple' },
|
||||
custom: { text: '自定义', color: 'cyan' },
|
||||
original: { text: '原视频', color: 'blue' },
|
||||
};
|
||||
|
||||
export function getModuleLabel(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
return MODULE_LABELS[value] || value;
|
||||
}
|
||||
|
||||
export function getStepCodeLabel(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
return STEP_LABELS[value] || value;
|
||||
}
|
||||
|
||||
export function getStatusLabel(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
return STATUS_LABELS[value]?.text || STEP_LABELS[value] || MODULE_LABELS[value] || SOURCE_MODE_LABELS[value]?.text || value;
|
||||
}
|
||||
|
||||
export function getStatusColor(value?: string | null): string {
|
||||
if (!value) return 'default';
|
||||
return STATUS_LABELS[value]?.color || SOURCE_MODE_LABELS[value]?.color || 'blue';
|
||||
}
|
||||
|
||||
interface StatusTagProps {
|
||||
status?: string | boolean | null;
|
||||
tooltipRaw?: boolean;
|
||||
}
|
||||
|
||||
const StatusTag: React.FC<StatusTagProps> = ({ status, tooltipRaw = true }) => {
|
||||
if (status === undefined || status === null || status === '') return <Tag>-</Tag>;
|
||||
const raw = String(status);
|
||||
const label = getStatusLabel(raw);
|
||||
const tag = <Tag color={getStatusColor(raw)}>{label}</Tag>;
|
||||
if (!tooltipRaw || label === raw) return tag;
|
||||
return <Tooltip title={raw}>{tag}</Tooltip>;
|
||||
};
|
||||
|
||||
export default StatusTag;
|
||||
@@ -0,0 +1,314 @@
|
||||
import React from 'react';
|
||||
import { Card, Collapse, Descriptions, Empty, Space, Table, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { JsonBlock } from './JsonCollapse';
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
schema_version: 'Schema 版本',
|
||||
generation_type: '生成类型',
|
||||
business_type: '业务类型',
|
||||
usage: '用途',
|
||||
product_type: '产品类型',
|
||||
target_audience: '目标受众',
|
||||
style: '风格',
|
||||
aspect_ratio: '画面比例',
|
||||
resolution: '分辨率',
|
||||
duration: '时长',
|
||||
fps: '帧率',
|
||||
final_prompt: '最终提示词',
|
||||
prompt: '提示词',
|
||||
description: '说明',
|
||||
content: '内容',
|
||||
time: '时间',
|
||||
time_range: '时间段',
|
||||
start_time: '开始时间',
|
||||
end_time: '结束时间',
|
||||
stage: '阶段',
|
||||
scene: '场景',
|
||||
action: '动作',
|
||||
camera: '镜头',
|
||||
shot: '镜头',
|
||||
lens: '镜头',
|
||||
subtitle: '字幕',
|
||||
voiceover: '口播',
|
||||
audio: '音频',
|
||||
rhythm: '节奏',
|
||||
notes: '备注',
|
||||
};
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
basic: '基础信息',
|
||||
basic_info: '基础信息',
|
||||
material: '素材理解',
|
||||
material_understanding: '素材理解',
|
||||
business: '业务属性',
|
||||
business_info: '业务属性',
|
||||
visual: '画面与风格',
|
||||
visual_style: '画面与风格',
|
||||
time_plan: '时间规划',
|
||||
timeline: '时间规划',
|
||||
scene_timeline: '场景时间线',
|
||||
action_flow: '动作流程',
|
||||
motion_flow: '动作流程',
|
||||
character_action_flow: '角色动作流程',
|
||||
camera_flow: '镜头流程',
|
||||
shot_flow: '镜头流程',
|
||||
lens_flow: '镜头流程',
|
||||
subtitle: '字幕',
|
||||
subtitles: '字幕',
|
||||
voiceover: '口播',
|
||||
audio: '音频',
|
||||
rhythm: '节奏',
|
||||
compliance: '合规控制',
|
||||
final_prompt: '最终提示词',
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const labelOf = (key: string): string => SECTION_LABELS[key] || FIELD_LABELS[key] || key;
|
||||
|
||||
const isLongText = (value: unknown): boolean => typeof value === 'string' && value.length > 80;
|
||||
|
||||
|
||||
const EMPTY_TEXTS = new Set(['', '无', 'null', 'None', 'none', '未提及', '不适用']);
|
||||
const PLACEHOLDER_FLOW_TEXTS = new Set([
|
||||
'展示主体动作、核心卖点或主要视觉内容',
|
||||
'展示主要动作、核心卖点或主要视觉内容',
|
||||
'展示核心卖点或主要视觉内容',
|
||||
'展示主体动作',
|
||||
'无',
|
||||
]);
|
||||
const ACTION_CONTENT_KEYS = ['动作内容', '动作', '动作说明', '内容', '说明', '主体动作', '动作变化'];
|
||||
const CAMERA_CONTENT_KEYS = ['镜头内容', '镜头', '镜头说明', '运镜', '运镜说明', '内容', '说明'];
|
||||
|
||||
const toText = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmptyText = (value: unknown): boolean => EMPTY_TEXTS.has(toText(value));
|
||||
|
||||
const isActionFlowSection = (sectionKey: string): boolean => {
|
||||
const lower = sectionKey.toLowerCase();
|
||||
return lower.includes('action') || lower.includes('motion') || lower.includes('动作');
|
||||
};
|
||||
|
||||
const isCameraFlowSection = (sectionKey: string): boolean => {
|
||||
const lower = sectionKey.toLowerCase();
|
||||
return lower.includes('camera') || lower.includes('shot') || lower.includes('lens') || lower.includes('镜头');
|
||||
};
|
||||
|
||||
const isTimePlanSection = (sectionKey: string): boolean => {
|
||||
const lower = sectionKey.toLowerCase();
|
||||
return lower.includes('time') || lower.includes('timeline') || lower.includes('时间规划') || lower.includes('动态时间规划');
|
||||
};
|
||||
|
||||
const pickFirstContent = (item: Record<string, unknown>, keys: string[]): string => {
|
||||
for (const key of keys) {
|
||||
if (key in item && !isEmptyText(item[key])) return toText(item[key]);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeFlowContent = (baseContent: string, extras: string[]): string => {
|
||||
const cleanBase = baseContent.trim();
|
||||
const uniqueExtras = extras.filter((item, index) => item && extras.indexOf(item) === index);
|
||||
if (uniqueExtras.length && (!cleanBase || PLACEHOLDER_FLOW_TEXTS.has(cleanBase))) {
|
||||
return uniqueExtras.join(';');
|
||||
}
|
||||
const parts = cleanBase && !EMPTY_TEXTS.has(cleanBase) ? [cleanBase] : [];
|
||||
uniqueExtras.forEach((item) => {
|
||||
if (item && !parts.includes(item)) parts.push(item);
|
||||
});
|
||||
return parts.join(';') || '-';
|
||||
};
|
||||
|
||||
const normalizeFlowItemForDisplay = (
|
||||
item: Record<string, unknown>,
|
||||
contentKey: '动作内容' | '镜头内容',
|
||||
contentKeys: string[],
|
||||
): Record<string, unknown> => {
|
||||
const allowed = new Set(['时间段', contentKey, ...contentKeys]);
|
||||
const extras: string[] = [];
|
||||
Object.entries(item).forEach(([field, value]) => {
|
||||
if (allowed.has(field)) return;
|
||||
const fieldText = toText(field);
|
||||
const valueText = toText(value);
|
||||
if (fieldText && !EMPTY_TEXTS.has(fieldText)) extras.push(fieldText);
|
||||
if (valueText && !EMPTY_TEXTS.has(valueText) && valueText !== fieldText) extras.push(valueText);
|
||||
});
|
||||
return {
|
||||
时间段: toText(item['时间段']) || '-',
|
||||
[contentKey]: normalizeFlowContent(pickFirstContent(item, contentKeys), extras),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeRecordForSection = (sectionKey: string, item: Record<string, unknown>): Record<string, unknown> => {
|
||||
if (isActionFlowSection(sectionKey)) {
|
||||
return normalizeFlowItemForDisplay(item, '动作内容', ACTION_CONTENT_KEYS);
|
||||
}
|
||||
if (isCameraFlowSection(sectionKey)) {
|
||||
return normalizeFlowItemForDisplay(item, '镜头内容', CAMERA_CONTENT_KEYS);
|
||||
}
|
||||
if (isTimePlanSection(sectionKey)) {
|
||||
return {
|
||||
时间段: toText(item['时间段']) || '-',
|
||||
阶段: toText(item['阶段']) || '-',
|
||||
说明: toText(item['说明']) || '-',
|
||||
};
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
const renderValue = (value: unknown): React.ReactNode => {
|
||||
if (value === null || value === undefined || value === '') return <Typography.Text type="secondary">-</Typography.Text>;
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
return <Typography.Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>{value}</Typography.Paragraph>;
|
||||
}
|
||||
return <JsonBlock value={value} maxHeight={220} />;
|
||||
};
|
||||
|
||||
const getArrayMode = (key: string): 'card' | 'table' => {
|
||||
const lower = key.toLowerCase();
|
||||
if (
|
||||
lower.includes('action') ||
|
||||
lower.includes('motion') ||
|
||||
lower.includes('camera') ||
|
||||
lower.includes('shot') ||
|
||||
lower.includes('lens') ||
|
||||
lower.includes('flow') ||
|
||||
lower.includes('动作') ||
|
||||
lower.includes('镜头')
|
||||
) {
|
||||
return 'card';
|
||||
}
|
||||
return 'table';
|
||||
};
|
||||
|
||||
const renderCardArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{items.map((item, index) => {
|
||||
const displayItem = normalizeRecordForSection(sectionKey, item);
|
||||
return (
|
||||
<Card
|
||||
key={`${sectionKey}-${index}`}
|
||||
size="small"
|
||||
title={`${labelOf(sectionKey)} ${index + 1}`}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
{Object.entries(displayItem).map(([field, value]) => (
|
||||
<Descriptions.Item key={field} label={labelOf(field)}>
|
||||
{renderValue(value)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
|
||||
const renderTableArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => {
|
||||
const displayItems = items.map(item => normalizeRecordForSection(sectionKey, item));
|
||||
const fields = Array.from(new Set(displayItems.flatMap(item => Object.keys(item))));
|
||||
const columns: ColumnsType<Record<string, unknown>> = fields.map(field => ({
|
||||
title: labelOf(field),
|
||||
dataIndex: field,
|
||||
key: field,
|
||||
width: isLongText(displayItems.find(item => item[field])?.[field]) ? 280 : 160,
|
||||
render: (value: unknown) => renderValue(value),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(_, index) => `${sectionKey}-${index}`}
|
||||
columns={columns}
|
||||
dataSource={displayItems}
|
||||
pagination={false}
|
||||
scroll={{ x: Math.max(900, fields.length * 180) }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderArray = (sectionKey: string, value: unknown[]): React.ReactNode => {
|
||||
if (!value.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />;
|
||||
if (value.every(isRecord)) {
|
||||
return getArrayMode(sectionKey) === 'card'
|
||||
? renderCardArray(sectionKey, value)
|
||||
: renderTableArray(sectionKey, value);
|
||||
}
|
||||
return <JsonBlock value={value} maxHeight={260} />;
|
||||
};
|
||||
|
||||
const renderRecord = (value: Record<string, unknown>): React.ReactNode => (
|
||||
<Descriptions size="small" column={2} bordered>
|
||||
{Object.entries(value).map(([field, fieldValue]) => (
|
||||
<Descriptions.Item key={field} label={labelOf(field)} span={Array.isArray(fieldValue) || isRecord(fieldValue) || isLongText(fieldValue) ? 2 : 1}>
|
||||
{Array.isArray(fieldValue)
|
||||
? renderArray(field, fieldValue)
|
||||
: isRecord(fieldValue)
|
||||
? renderRecord(fieldValue)
|
||||
: renderValue(fieldValue)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
);
|
||||
|
||||
const renderSection = (key: string, value: unknown): React.ReactNode => {
|
||||
if (Array.isArray(value)) return renderArray(key, value);
|
||||
if (isRecord(value)) return renderRecord(value);
|
||||
return renderValue(value);
|
||||
};
|
||||
|
||||
interface VideoPromptSchemaViewerProps {
|
||||
schema?: Record<string, any> | null;
|
||||
finalPrompt?: string | null;
|
||||
}
|
||||
|
||||
const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({ schema, finalPrompt }) => {
|
||||
const hasSchema = !!schema && Object.keys(schema).length > 0;
|
||||
if (!hasSchema && !finalPrompt) {
|
||||
return <Empty description="暂无视频提词 schema" />;
|
||||
}
|
||||
|
||||
const schemaItems = hasSchema
|
||||
? Object.entries(schema || {}).map(([key, value]) => ({
|
||||
key,
|
||||
label: labelOf(key),
|
||||
children: renderSection(key, value),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const defaultKeys = finalPrompt ? ['final_prompt'] : [];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{finalPrompt ? (
|
||||
<Card size="small" title="最终视频提示词">
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{finalPrompt}</Typography.Paragraph>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{schemaItems.length ? (
|
||||
<Collapse
|
||||
size="small"
|
||||
defaultActiveKey={defaultKeys}
|
||||
items={schemaItems}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPromptSchemaViewer;
|
||||
@@ -342,3 +342,242 @@ export interface GenerationAITaskQueryParams {
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user