Merge branch 'main' of https://gitee.com/wg123/video-gen
This commit is contained in:
@@ -449,53 +449,45 @@ export async function getOAuthList(params: OAuthListParams): Promise<any> {
|
||||
}
|
||||
|
||||
// 前测模板列表
|
||||
// /api/pre-test-template/list
|
||||
export interface PreTestListParams {
|
||||
platform?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export async function getPreTestList(params?: PreTestListParams): Promise<any> {
|
||||
if (USE_MOCK) return mock.mockGetPreTestList(params);
|
||||
const page = params?.page || 1;
|
||||
const pageSize = Math.min(params?.pageSize || 10, 100);
|
||||
return api.get(`/pre-test-template/list?page=${page}&page_size=${pageSize}`);
|
||||
const query = new URLSearchParams();
|
||||
if (params.platform) query.set('platform', params.platform);
|
||||
if (params.page !== undefined) query.set('page', String(params.page));
|
||||
if (params.pageSize !== undefined) query.set('page_size', String(params.pageSize));
|
||||
return api.get(`/pre-test-template/list?${query.toString()}`);
|
||||
}
|
||||
|
||||
// 获取前测字段列表
|
||||
// /api/pre-test-template/fields
|
||||
export async function getPreTestFields(): Promise<any> {
|
||||
if (USE_MOCK) return mock.mockGetPreTestFields();
|
||||
return api.get(`/pre-test-template/fields`);
|
||||
}
|
||||
|
||||
// 创建前测模板
|
||||
// /api/pre-test-template/create
|
||||
export async function createPreTest(params: any): Promise<any> {
|
||||
if (USE_MOCK) return mock.mockCreatePreTest(params);
|
||||
return api.post(`/pre-test-template/create`, params);
|
||||
}
|
||||
|
||||
// 前测模板详情
|
||||
// /api/pre-test-template/select/{template_id}
|
||||
export async function getPreTestDetail(templateId: string): Promise<any> {
|
||||
return api.get(`/pre-test-template/select/${templateId}`);
|
||||
}
|
||||
|
||||
// 更新前测模板
|
||||
// /api/pre-test-template/update/{template_id}
|
||||
export async function updatePreTest(templateId: string, params: any): Promise<any> {
|
||||
return api.post(`/pre-test-template/update/${templateId}`, params);
|
||||
}
|
||||
|
||||
// 删除前测模板
|
||||
// /api/pre-test-template/delete/{template_id}
|
||||
export async function deletePreTest(templateId: string): Promise<any> {
|
||||
return api.get(`/pre-test-template/delete/${templateId}`);
|
||||
}
|
||||
|
||||
// 获取默认前测模板
|
||||
// /api/pre-test-template/default
|
||||
export async function getDefaultPreTest(): Promise<any> {
|
||||
return api.get(`/pre-test-template/default`);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -2,27 +2,29 @@ import React, { useState } from 'react';
|
||||
import { Popover, Tag, List, Descriptions, Typography } from 'antd';
|
||||
|
||||
interface PreResultData {
|
||||
video_id: string; //视频id
|
||||
advertiser_id: number; //广告主id
|
||||
material_id: string; //素材id
|
||||
is_ad_high_quality_material: string; //是否优质素材
|
||||
is_ecp_high_quality_material: string; //是否千川优质素材
|
||||
is_inefficient_material: string; //是否低效素材
|
||||
is_first_publish_material: string; //是否首发素材
|
||||
not_ad_high_quality_reason: string[] | null; //AD非优质原因
|
||||
not_ecp_high_quality_reason: string[] | null; //千川非优质原因
|
||||
is_local_high_quality_material: string; //是否本地推优质素材
|
||||
video_id: string;
|
||||
advertiser_id: number;
|
||||
material_id: string;
|
||||
is_ad_high_quality_material: string;
|
||||
is_ecp_high_quality_material: string;
|
||||
is_inefficient_material: string;
|
||||
is_first_publish_material: string;
|
||||
is_local_high_quality_material: string;
|
||||
not_ad_high_quality_reason: string[] | null;
|
||||
not_ecp_high_quality_reason: string[] | null;
|
||||
}
|
||||
|
||||
interface PreResultDisplayProps {
|
||||
preResult: string;
|
||||
}
|
||||
|
||||
const qualityConfig: Record<string, { color: string; label: string }> = {
|
||||
YES: { color: 'green', label: '是' },
|
||||
NO: { color: 'red', label: '否' },
|
||||
UNKNOWN: { color: 'default', label: '未知' },
|
||||
};
|
||||
const fieldConfig = [
|
||||
{ key: 'is_ad_high_quality_material', label: 'AD优质', noLabel: 'AD非优质素材', unknownLabel: 'AD未知', yesColor: 'green', noColor: 'red', unknownColor: 'default' },
|
||||
{ key: 'is_ecp_high_quality_material', label: '千川优质', noLabel: '千川非优质素材', unknownLabel: '千川未知', yesColor: 'green', noColor: 'red', unknownColor: 'default' },
|
||||
{ key: 'is_local_high_quality_material', label: '本地推优质', noLabel: '本地推非优质', unknownLabel: '本地推未知', yesColor: 'green', noColor: 'red', unknownColor: 'default' },
|
||||
{ key: 'is_inefficient_material', label: '低效', noLabel: '非低效', unknownLabel: '是否低效未知', yesColor: 'red', noColor: 'green', unknownColor: 'default' },
|
||||
{ key: 'is_first_publish_material', label: '首发', noLabel: '非首发', unknownLabel: '是否首发未知', yesColor: 'blue', noColor: 'default', unknownColor: 'default' },
|
||||
];
|
||||
|
||||
const PreResultDisplay: React.FC<PreResultDisplayProps> = ({ preResult }) => {
|
||||
const [parsedData, setParsedData] = useState<PreResultData | null>(null);
|
||||
@@ -48,51 +50,79 @@ const PreResultDisplay: React.FC<PreResultDisplayProps> = ({ preResult }) => {
|
||||
return <span style={{ color: '#64748b' }}>-</span>;
|
||||
}
|
||||
|
||||
const allQualityFields = [
|
||||
parsedData.is_ad_high_quality_material,
|
||||
parsedData.is_ecp_high_quality_material,
|
||||
parsedData.is_local_high_quality_material,
|
||||
];
|
||||
|
||||
const hasNoQuality = allQualityFields.some((val) => val === 'NO');
|
||||
const allYes = allQualityFields.every((val) => val === 'YES');
|
||||
|
||||
let statusTag;
|
||||
if (hasNoQuality) {
|
||||
statusTag = <Tag color="red">非优质</Tag>;
|
||||
} else if (allYes) {
|
||||
statusTag = <Tag color="green">优质</Tag>;
|
||||
} else {
|
||||
statusTag = <Tag color="default">待评估</Tag>;
|
||||
}
|
||||
const getIndicatorDisplay = (key: string) => {
|
||||
const config = fieldConfig.find(c => c.key === key);
|
||||
if (!config) return null;
|
||||
const value = parsedData[key as keyof PreResultData];
|
||||
const isYes = value === 'YES';
|
||||
const isUnknown = value === 'UNKNOWN';
|
||||
let displayLabel, displayColor;
|
||||
if (isUnknown) {
|
||||
displayLabel = config.unknownLabel;
|
||||
displayColor = config.unknownColor;
|
||||
} else if (isYes) {
|
||||
displayLabel = config.label;
|
||||
displayColor = config.yesColor;
|
||||
} else {
|
||||
displayLabel = config.noLabel;
|
||||
displayColor = config.noColor;
|
||||
}
|
||||
return {
|
||||
tag: (
|
||||
<Tag
|
||||
key={key}
|
||||
color={displayColor}
|
||||
style={{ marginRight: 6, fontSize: 12, borderRadius: 4 }}
|
||||
>
|
||||
{displayLabel}
|
||||
</Tag>
|
||||
),
|
||||
detail: {
|
||||
label: config.label.replace('优质', '优质素材'),
|
||||
value: displayLabel,
|
||||
color: displayColor,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div style={{ maxWidth: 500 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12 }}>前测结果详情</Typography.Text>
|
||||
<div style={{ maxWidth: 500, padding: '8px 0' }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, color: '#1e293b' }}>前测结果详情</Typography.Text>
|
||||
|
||||
<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="视频ID">{parsedData.video_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="广告主ID">{parsedData.advertiser_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="素材ID">{parsedData.material_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="视频ID" labelStyle={{ fontWeight: 500, color: '#64748b' }} contentStyle={{ color: '#1e293b' }}>{parsedData.video_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="广告主ID" labelStyle={{ fontWeight: 500, color: '#64748b' }} contentStyle={{ color: '#1e293b' }}>{parsedData.advertiser_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="素材ID" labelStyle={{ fontWeight: 500, color: '#64748b' }} contentStyle={{ color: '#1e293b' }}>{parsedData.material_id}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Typography.Text strong style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 8 }}>质量评估</Typography.Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
|
||||
<Tag color={qualityConfig[parsedData.is_ad_high_quality_material]?.color}>
|
||||
AD优质素材: {qualityConfig[parsedData.is_ad_high_quality_material]?.label}
|
||||
</Tag>
|
||||
<Tag color={qualityConfig[parsedData.is_ecp_high_quality_material]?.color}>
|
||||
千川优质素材: {qualityConfig[parsedData.is_ecp_high_quality_material]?.label}
|
||||
</Tag>
|
||||
<Tag color={qualityConfig[parsedData.is_local_high_quality_material]?.color}>
|
||||
本地推优质素材: {qualityConfig[parsedData.is_local_high_quality_material]?.label}
|
||||
</Tag>
|
||||
<Tag color={qualityConfig[parsedData.is_inefficient_material]?.color}>
|
||||
低效素材: {qualityConfig[parsedData.is_inefficient_material]?.label}
|
||||
</Tag>
|
||||
<Tag color={qualityConfig[parsedData.is_first_publish_material]?.color}>
|
||||
首发素材: {qualityConfig[parsedData.is_first_publish_material]?.label}
|
||||
</Tag>
|
||||
{fieldConfig.map(config => {
|
||||
const value = parsedData[config.key as keyof PreResultData];
|
||||
const isYes = value === 'YES';
|
||||
const isUnknown = value === 'UNKNOWN';
|
||||
let displayLabel, displayColor;
|
||||
if (isUnknown) {
|
||||
displayLabel = config.unknownLabel;
|
||||
displayColor = config.unknownColor;
|
||||
} else if (isYes) {
|
||||
displayLabel = config.label;
|
||||
displayColor = config.yesColor;
|
||||
} else {
|
||||
displayLabel = config.noLabel;
|
||||
displayColor = config.noColor;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag
|
||||
key={config.key}
|
||||
color={displayColor}
|
||||
style={{ fontSize: 12, borderRadius: 4 }}
|
||||
>
|
||||
{displayLabel}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{parsedData.not_ad_high_quality_reason && parsedData.not_ad_high_quality_reason.length > 0 && (
|
||||
@@ -132,10 +162,21 @@ const PreResultDisplay: React.FC<PreResultDisplayProps> = ({ preResult }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={content} title={null} trigger="hover">
|
||||
{statusTag}
|
||||
<Popover
|
||||
content={content}
|
||||
title={null}
|
||||
trigger="hover"
|
||||
placement="topLeft"
|
||||
overlayStyle={{ borderRadius: 12, boxShadow: '0 8px 24px rgba(0,0,0,0.12)' }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 4}}>
|
||||
{fieldConfig.map(config => {
|
||||
const display = getIndicatorDisplay(config.key);
|
||||
return display?.tag;
|
||||
})}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreResultDisplay;
|
||||
export default PreResultDisplay;
|
||||
|
||||
@@ -278,16 +278,6 @@ const AuthAccountPage: React.FC = () => {
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
size="medium"
|
||||
onClick={() => {
|
||||
setSearchParams({ advertiser_id: '', oauth_id: '', advertiser_name: '' });
|
||||
setCurrentPage(1);
|
||||
loadOAuthList(1, pageSize);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -76,7 +76,6 @@ const AuthorizationPage: React.FC = () => {
|
||||
const list = async () => {
|
||||
try {
|
||||
const res = await getDefaultPreTest();
|
||||
console.log(res);
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
@@ -299,6 +298,7 @@ const AuthorizationPage: React.FC = () => {
|
||||
value={searchParams.account_userid}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, account_userid: e.target.value }))}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
|
||||
/>
|
||||
<Select
|
||||
@@ -306,6 +306,7 @@ const AuthorizationPage: React.FC = () => {
|
||||
value={searchParams.open_type}
|
||||
onChange={(value) => setSearchParams(prev => ({ ...prev, open_type: value }))}
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
options={openTypeOptions}
|
||||
/>
|
||||
<Input
|
||||
@@ -313,6 +314,7 @@ const AuthorizationPage: React.FC = () => {
|
||||
value={searchParams.account_id}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, account_id: e.target.value }))}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
|
||||
/>
|
||||
<Button
|
||||
@@ -322,16 +324,6 @@ const AuthorizationPage: React.FC = () => {
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
size="medium"
|
||||
onClick={() => {
|
||||
setSearchParams({ account_userid: '', open_type: undefined, account_id: '' });
|
||||
setCurrentPage(1);
|
||||
loadOAuthList(1, pageSize);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
@@ -176,6 +176,7 @@ const ConsumePage: React.FC = () => {
|
||||
value={advertiserId}
|
||||
onChange={(e) => setAdvertiserId(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
onPressEnter={() => { setCurrentPage(1); loadData(1, pageSize); }}
|
||||
/>
|
||||
<RangePicker
|
||||
@@ -195,12 +196,6 @@ const ConsumePage: React.FC = () => {
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
size="medium"
|
||||
onClick={handleReset}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
import bg1 from '../assets/bg1.png';
|
||||
import bg2 from '../assets/bg2.png';
|
||||
import bg3 from '../assets/bg3.png';
|
||||
import text from '../assets/testb.png';
|
||||
|
||||
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
SettingOutlined,
|
||||
LayoutOutlined,
|
||||
ArrowUpOutlined,
|
||||
DownloadOutlined,
|
||||
|
||||
} from '@ant-design/icons';
|
||||
|
||||
@@ -855,11 +857,12 @@ const AIChatPage: React.FC = () => {
|
||||
margin: '-24px -32px -32px',
|
||||
borderRadius: 20,
|
||||
height: 'calc(100vh - 34px)',
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #f0f4ff 50%, #faf5ff 100%)',
|
||||
backgroundImage: `url(${bg2})`,
|
||||
background: 'linear-gradient(135deg, #fafbfc 0%, #f5f8ff 50%, #fafbff 100%)',
|
||||
// backgroundImage: `url(${bg3})`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundPosition: 'center',
|
||||
backgroundBlendMode: 'lighten',
|
||||
}}>
|
||||
{/* 左侧边栏 - 对话列表(已隐藏,保留代码) */}
|
||||
{false && (
|
||||
@@ -2318,46 +2321,30 @@ const AIChatPage: React.FC = () => {
|
||||
{/* 图片/视频预览弹窗 */}
|
||||
<Modal
|
||||
open={previewVisible}
|
||||
onCancel={handleClosePreview}
|
||||
footer={[
|
||||
<Button key="download" type="primary" onClick={handleDownload} style={{ background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 10, border: 'none' }}>
|
||||
下载
|
||||
</Button>,
|
||||
]}
|
||||
width={800}
|
||||
centered
|
||||
closeIcon={
|
||||
<button
|
||||
onClick={handleClosePreview}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(99, 102, 241, 0.1)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 16,
|
||||
color: '#6366f1',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
e.currentTarget.style.color = '#ef4444';
|
||||
e.currentTarget.style.transform = 'scale(1.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.1)';
|
||||
e.currentTarget.style.color = '#6366f1';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
|
||||
<span style={{ fontSize: 16, fontWeight: 700, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
|
||||
预览
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
onCancel={handleClosePreview}
|
||||
width={800}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, padding: '16px 24px', background: 'rgba(255,255,255,0.6)', borderTop: '1px solid rgba(99, 102, 241, 0.08)' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownload}
|
||||
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
|
||||
disabled={isMediaExpired(previewUrl)}
|
||||
>
|
||||
{isMediaExpired(previewUrl) ? '资源已过期' : '下载'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
centered
|
||||
style={{ borderRadius: 16 }}
|
||||
styles={{
|
||||
body: {
|
||||
@@ -2366,21 +2353,13 @@ const AIChatPage: React.FC = () => {
|
||||
justifyContent: 'center',
|
||||
minHeight: '400px',
|
||||
},
|
||||
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(99, 102, 241, 0.08)', padding: '16px 24px' },
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
|
||||
{isMediaExpired(previewUrl) ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
{/* <div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div> */}
|
||||
<p style={{ fontSize: 16, color: '#ff4d4f', marginBottom: 16 }}>图片/视频资源已过期,请刷新重新加载~</p>
|
||||
{/* <Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
刷新页面重新加载
|
||||
</Button> */}
|
||||
</div>
|
||||
) : previewType === 'image' ? (
|
||||
<img
|
||||
@@ -2402,54 +2381,37 @@ const AIChatPage: React.FC = () => {
|
||||
{/* 附件预览弹窗(独立弹窗) */}
|
||||
<Modal
|
||||
open={attachmentPreviewVisible}
|
||||
onCancel={() => setAttachmentPreviewVisible(false)}
|
||||
footer={[
|
||||
<Button key="download" type="primary" onClick={() => {
|
||||
if (!attachmentPreviewUrl) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}&download=1`;
|
||||
link.download = attachmentPreviewName || (attachmentPreviewType === 'image' ? 'image.png' : 'video.mp4');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}} style={{ background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 10, border: 'none' }}>
|
||||
下载
|
||||
</Button>,
|
||||
]}
|
||||
width={800}
|
||||
centered
|
||||
closeIcon={
|
||||
<button
|
||||
onClick={() => setAttachmentPreviewVisible(false)}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(99, 102, 241, 0.1)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 16,
|
||||
color: '#6366f1',
|
||||
fontWeight: 'bold',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
e.currentTarget.style.color = '#ef4444';
|
||||
e.currentTarget.style.transform = 'scale(1.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.1)';
|
||||
e.currentTarget.style.color = '#6366f1';
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
|
||||
<span style={{ fontSize: 16, fontWeight: 700, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
|
||||
附件预览
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
onCancel={() => setAttachmentPreviewVisible(false)}
|
||||
width={800}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, padding: '16px 24px', background: 'rgba(255,255,255,0.6)', borderTop: '1px solid rgba(99, 102, 241, 0.08)' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
if (!attachmentPreviewUrl) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${attachmentPreviewUrl}&download=1`;
|
||||
link.download = attachmentPreviewName || (attachmentPreviewType === 'image' ? 'image.png' : 'video.mp4');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
centered
|
||||
style={{ borderRadius: 16 }}
|
||||
styles={{
|
||||
body: {
|
||||
@@ -2458,6 +2420,7 @@ const AIChatPage: React.FC = () => {
|
||||
justifyContent: 'center',
|
||||
minHeight: '400px',
|
||||
},
|
||||
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(99, 102, 241, 0.08)', padding: '16px 24px' },
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
PlusOutlined,
|
||||
PictureOutlined,
|
||||
SwapOutlined,
|
||||
XOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
@@ -1398,8 +1399,9 @@ const GeneratePage: React.FC = () => {
|
||||
margin: '-24px -32px -32px',
|
||||
borderRadius: 20,
|
||||
height: 'calc(100vh - 34px)',
|
||||
padding:'24px'
|
||||
|
||||
padding:'24px',
|
||||
background: '#fff',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div
|
||||
@@ -3311,7 +3313,7 @@ const GeneratePage: React.FC = () => {
|
||||
本项目生成记录
|
||||
</Typography.Text>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: 8 }}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 8, background: "#fff", borderRadius: 12, padding: 8, border: "1px solid #f0f0f5" }}
|
||||
className="stagger-children"
|
||||
>
|
||||
{projectRecords.map((record, i) => {
|
||||
@@ -3336,9 +3338,8 @@ const GeneratePage: React.FC = () => {
|
||||
}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: isExpanded ? "12px 12px 0 0" : 12,
|
||||
background: "#fff",
|
||||
border: "1px solid #f0f0f5",
|
||||
borderRadius: isExpanded ? "8px 8px 0 0" : 8,
|
||||
background: "#fafafa",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
display: "flex",
|
||||
@@ -3482,9 +3483,7 @@ const GeneratePage: React.FC = () => {
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #f0f0f5",
|
||||
borderTop: "none",
|
||||
borderRadius: "0 0 12px 12px",
|
||||
borderRadius: "0 0 8px 8px",
|
||||
padding: "18px 20px",
|
||||
animation: "fadeInUp 0.25s ease both",
|
||||
}}
|
||||
@@ -4587,7 +4586,7 @@ const GeneratePage: React.FC = () => {
|
||||
e.currentTarget.style.background = "rgba(0,0,0,0.6)";
|
||||
}}
|
||||
>
|
||||
<CloseCircleOutlined style={{ color: "#fff", fontSize: 16 }} />
|
||||
<XOutlined style={{ color: "#fff", fontSize: 16 }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { Button, Empty, Input, Select, Space, Typography, Tag, message, Modal, Table, DatePicker, Tabs, Transfer } from 'antd';
|
||||
import { Button, Empty, Input, Select, Space, Typography, Tag, message, Modal, Table, DatePicker, Tabs, Transfer, Tooltip } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import JSZip from 'jszip';
|
||||
import {
|
||||
@@ -216,6 +216,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [isExpired, setIsExpired] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const mediaRef = useRef<HTMLImageElement | HTMLVideoElement>(null);
|
||||
const errorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -299,12 +300,14 @@ const GeneratedRecord: React.FC = () => {
|
||||
}}
|
||||
onClick={!isSelectionMode ? onClick : undefined}
|
||||
onMouseEnter={(e) => {
|
||||
setIsHovered(true);
|
||||
if (!isSelectionMode) {
|
||||
(e.currentTarget as HTMLElement).style.transform = 'scale(1.05)';
|
||||
(e.currentTarget as HTMLElement).style.boxShadow = '0 4px 16px rgba(0,0,0,0.2)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
setIsHovered(false);
|
||||
if (!isSelectionMode) {
|
||||
(e.currentTarget as HTMLElement).style.transform = 'scale(1)';
|
||||
(e.currentTarget as HTMLElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
|
||||
@@ -484,6 +487,56 @@ const GeneratedRecord: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 下载按钮 - hover时显示 */}
|
||||
{!isSelectionMode && !isExpired && !isError && isLoaded && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
opacity: isHovered ? 1 : 0,
|
||||
transition: 'opacity 0.2s',
|
||||
pointerEvents: isHovered ? 'auto' : 'none',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${item.videoUrl || item.imageUrl}&download=1`;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = '';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
>
|
||||
<Tooltip title="下载">
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(0,0,0,0.7)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(0,0,0,0.5)';
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined style={{ color: '#fff', fontSize: 14 }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选择模式下的选择框 */}
|
||||
{isSelectionMode && (
|
||||
<div
|
||||
@@ -2030,77 +2083,67 @@ const GeneratedRecord: React.FC = () => {
|
||||
|
||||
{/* 预览弹窗 */}
|
||||
{previewVisible && previewItem && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.85)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: 16,
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
onClick={handleClosePreview}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: 16,
|
||||
padding: 0,
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '95vh',
|
||||
minHeight: '300px',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<Typography.Title level={5} style={{ margin: 0, color: '#1a1a2e', fontSize: 16 }}>
|
||||
<Modal
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #6366f1 0%, #8b5cf6 100%)', borderRadius: 2 }} />
|
||||
<span style={{ fontSize: 16, fontWeight: 700, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent', backgroundClip: 'text' }}>
|
||||
{previewItem.title || '预览'}
|
||||
</Typography.Title>
|
||||
<Button
|
||||
icon={<XOutlined />}
|
||||
onClick={handleClosePreview}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#94a3b8',
|
||||
fontSize: 16,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
height: '500px',
|
||||
gap: 20,
|
||||
padding: 20,
|
||||
overflow: 'auto',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
}
|
||||
open={previewVisible}
|
||||
onCancel={handleClosePreview}
|
||||
width={1200}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, padding: '16px 24px', background: 'rgba(255,255,255,0.6)', borderTop: '1px solid rgba(99, 102, 241, 0.08)' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.videoUrl || previewItem.imageUrl}&download=1`;
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = '';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
|
||||
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
|
||||
>
|
||||
{isMediaExpired(previewItem.videoUrl || previewItem.imageUrl) ? '资源已过期' : '下载'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSinglePushToMedia}
|
||||
style={{ borderRadius: 8 }}
|
||||
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
|
||||
>
|
||||
推送媒体后台
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
style={{ borderRadius: 20 }}
|
||||
styles={{
|
||||
body: { height: 460, display: 'flex', flexDirection: 'column', padding: 0 },
|
||||
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(99, 102, 241, 0.08)', padding: '16px 24px' },
|
||||
}}
|
||||
>
|
||||
{/* 内容区域 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
height: '460px',
|
||||
gap: 20,
|
||||
padding: 20,
|
||||
overflow: 'auto',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
{/* 媒体预览 */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
@@ -2314,47 +2357,9 @@ const GeneratedRecord: React.FC = () => {
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* 操作按钮 */}
|
||||
<div >
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 20 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
// 暂停视频
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
const url = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewItem.videoUrl || previewItem.imageUrl}&download=1`;
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
style={{ flex: 1, borderRadius: 8 }}
|
||||
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
|
||||
>
|
||||
{isMediaExpired(previewItem.videoUrl || previewItem.imageUrl) ? '资源已过期' : '下载'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClosePreview}
|
||||
style={{ flex: 1, borderRadius: 8 }}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSinglePushToMedia}
|
||||
style={{ width: '100%', borderRadius: 8, marginTop: 20 }}
|
||||
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
|
||||
>
|
||||
推送媒体后台
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Table, Tag, Input, Pagination, Typography, Select, App } from 'antd';
|
||||
import { Button, Table, Tag, Input, Pagination, Typography, Select, App, Modal } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FolderOpenOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { getResourcesMaterialList } from '../api';
|
||||
import { getResourcesMaterialList, getPreTestList } from '../api';
|
||||
import PreResultDisplay from '../components/PreResultDisplay';
|
||||
|
||||
// 格式化时间 2026-06-12T03:47:28.542988Z -> 2026-06-12 03:47:28
|
||||
@@ -82,11 +82,17 @@ interface MaterialData {
|
||||
const MaterialListPage: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [pushModalOpen, setPushModalOpen] = useState(false);
|
||||
const [pushPreTestTemplate, setPushPreTestTemplate] = useState<string>('');
|
||||
const [pushTemplates, setPushTemplates] = useState<any[]>([]);
|
||||
const [pushTemplatesLoading, setPushTemplatesLoading] = useState(false);
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
advertiser_id: '',
|
||||
material_id: '',
|
||||
@@ -113,6 +119,7 @@ const MaterialListPage: React.FC = () => {
|
||||
});
|
||||
if (response?.code === 0) {
|
||||
setMaterials(response.data || []);
|
||||
setRecords(response.data || []);
|
||||
setTotal(response.total || 0);
|
||||
} else {
|
||||
setMaterials([]);
|
||||
@@ -250,8 +257,15 @@ const MaterialListPage: React.FC = () => {
|
||||
dataIndex: 'note',
|
||||
key: 'note',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (text: string) => <span style={{ color: '#94a3b8' }}>{text || '-'}</span>,
|
||||
render: (text: string) => (
|
||||
<Input.TextArea
|
||||
value={text || ''}
|
||||
readOnly
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{ color: '#94a3b8', resize: 'none', border: 'none', background: 'transparent', padding: 0 }}
|
||||
placeholder="-"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '用户ID',
|
||||
@@ -296,6 +310,40 @@ const MaterialListPage: React.FC = () => {
|
||||
index: index + 1,
|
||||
key: item.id,
|
||||
}));
|
||||
|
||||
const handleOpenPushModal = async () => {
|
||||
if (selectedRows.size === 0) {
|
||||
message.warning('请先选择要推送的前测模板');
|
||||
return;
|
||||
}
|
||||
setPushTemplatesLoading(true);
|
||||
try {
|
||||
const res = await getPreTestList({ page: 1, pageSize: 100 });
|
||||
setPushTemplates(res.data || []);
|
||||
} catch (error) {
|
||||
message.error('获取前测模板列表失败');
|
||||
} finally {
|
||||
setPushTemplatesLoading(false);
|
||||
}
|
||||
setPushPreTestTemplate('');
|
||||
setPushModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePushSubmit = async () => {
|
||||
if (selectedRows.size === 0) {
|
||||
message.warning('请先选择要推送的素材');
|
||||
return;
|
||||
}
|
||||
if (!pushPreTestTemplate) {
|
||||
message.warning('请选择前测模板');
|
||||
return;
|
||||
}
|
||||
message.success('推送素材成功');
|
||||
setPushModalOpen(false);
|
||||
setSelectedRows(new Set());
|
||||
};
|
||||
|
||||
const selectedRecords = records.filter(record => selectedRows.has(record.id));
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '94vh' }}>
|
||||
@@ -303,64 +351,77 @@ const MaterialListPage: React.FC = () => {
|
||||
<FolderOpenOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>素材列表</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="广告主ID"
|
||||
value={searchParams.advertiser_id}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_id: e.target.value }))}
|
||||
style={{ width: 160 }}
|
||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="素材ID"
|
||||
value={searchParams.material_id}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, material_id: e.target.value }))}
|
||||
style={{ width: 160 }}
|
||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="上传ID"
|
||||
value={searchParams.upload_id}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, upload_id: e.target.value }))}
|
||||
style={{ width: 160 }}
|
||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="文件名"
|
||||
value={searchParams.file_name}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, file_name: e.target.value }))}
|
||||
style={{ width: 140 }}
|
||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="资源类型"
|
||||
value={searchParams.resource_type}
|
||||
onChange={(value) => setSearchParams(prev => ({ ...prev, resource_type: value }))}
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'video', label: '视频' },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchParams({ advertiser_id: '', material_id: '', upload_id: '', file_name: '', resource_type: undefined });
|
||||
setCurrentPage(1);
|
||||
loadMaterialList(1, pageSize);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'top', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="广告主ID"
|
||||
value={searchParams.advertiser_id}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_id: e.target.value }))}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="素材ID"
|
||||
value={searchParams.material_id}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, material_id: e.target.value }))}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="上传ID"
|
||||
value={searchParams.upload_id}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, upload_id: e.target.value }))}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="文件名"
|
||||
value={searchParams.file_name}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, file_name: e.target.value }))}
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
onPressEnter={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="资源类型"
|
||||
value={searchParams.resource_type}
|
||||
onChange={(value) => setSearchParams(prev => ({ ...prev, resource_type: value }))}
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'video', label: '视频' },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => { setCurrentPage(1); loadMaterialList(1, pageSize); }}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
{/* <div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={pushTemplatesLoading}
|
||||
onClick={handleOpenPushModal}
|
||||
disabled={selectedRows.size === 0}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 14px rgba(99,102,241,0.3)',
|
||||
}}
|
||||
>
|
||||
推送前测 ({selectedRows.size})
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
@@ -370,6 +431,13 @@ const MaterialListPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
bordered={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
// rowSelection={{
|
||||
// type: 'checkbox',
|
||||
// selectedRowKeys: Array.from(selectedRows),
|
||||
// onChange: (keys) => {
|
||||
// setSelectedRows(new Set(keys as string[]));
|
||||
// },
|
||||
// }}
|
||||
/>
|
||||
<div style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<Pagination
|
||||
@@ -387,6 +455,56 @@ const MaterialListPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="推送前测"
|
||||
open={pushModalOpen}
|
||||
onCancel={() => { setPushModalOpen(false); setSelectedRows(new Set()); }}
|
||||
onOk={handlePushSubmit}
|
||||
okText="推送"
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
style={{ borderRadius: 16 }}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Typography.Text style={{ fontSize: 14, color: '#475569' }}>前测模板:</Typography.Text>
|
||||
<Select
|
||||
value={pushPreTestTemplate}
|
||||
onChange={(value) => setPushPreTestTemplate(value)}
|
||||
style={{ width: 200 }}
|
||||
loading={pushTemplatesLoading}
|
||||
placeholder="选择前测模板"
|
||||
options={pushTemplates.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#1e293b', display: 'block', marginBottom: 12 }}>
|
||||
已选素材 ({selectedRows.size})
|
||||
</Typography.Text>
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto' }}>
|
||||
{selectedRecords.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{selectedRecords.map(record => (
|
||||
<div key={record.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 12, background: '#f8fafc', borderRadius: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#1e293b' }}>{record.resource.fileName}</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>ID: {record.id}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text style={{ color: '#94a3b8' }}>暂无选中的前测模板</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+398
-213
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Tag, Button, Typography, Space, Modal, Form, Input, Select, Cascader, App, Pagination } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { getPreTestList, createPreTest, getArea } from '../api';
|
||||
import { PlusOutlined, EditOutlined, FileTextOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { getPreTestList, createPreTest, getArea, getPreTestDetail, updatePreTest, deletePreTest } from '../api';
|
||||
|
||||
interface PreTestRecord {
|
||||
id: string;
|
||||
@@ -134,184 +134,23 @@ const formatDateTime = (dateStr: string) => {
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '前测模板编号',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 120,
|
||||
render: (text: string) => <Typography.Text strong style={{ color: '#1e293b', fontSize: 13 }}>{text}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '模板名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
ellipsis: true,
|
||||
render: (text: string) => (
|
||||
<span style={{ color: '#1e293b', fontWeight: 500, fontSize: 13 }}>{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '平台',
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
render: (text: string) => (
|
||||
<Tag color={text === 'AD' ? 'blue' : text === 'QIANCHUAN' ? 'green' : 'orange'} style={{ borderRadius: 6, fontSize: 12 }}>
|
||||
{text === 'AD' ? 'AD' : text === 'QIANCHUAN' ? '千川' : '本地推'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '转化目标',
|
||||
dataIndex: 'externalAction',
|
||||
key: 'externalAction',
|
||||
ellipsis: true,
|
||||
width: 200,
|
||||
render: (text: string) => {
|
||||
if (!text) return <span style={{ color: '#94a3b8', fontSize: 13 }}>-</span>;
|
||||
const actionArray = text.split(',').filter(item => item.trim());
|
||||
const labels = actionArray.map(action => {
|
||||
const field = PRETEST_FIELDS.find(f => f.name === action.trim());
|
||||
return field ? field.label : action;
|
||||
});
|
||||
return (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{labels.join(', ')}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '目标转化成本',
|
||||
dataIndex: 'cpaBid',
|
||||
key: 'cpaBid',
|
||||
width: 120,
|
||||
render: (text: number) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{text !== undefined ? text : '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '受众性别',
|
||||
dataIndex: 'audienceGender',
|
||||
key: 'audienceGender',
|
||||
width: 90,
|
||||
render: (text: string) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{text === 'ALL' ? '不限' : text === 'MALE' ? '男' : text === 'FEMALE' ? '女' : text || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '受众年龄',
|
||||
dataIndex: 'audienceAge',
|
||||
key: 'audienceAge',
|
||||
width: 90,
|
||||
render: (text: string[]) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{Array.isArray(text) ? text.join(', ') : text || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '受众地区',
|
||||
dataIndex: 'audience_region',
|
||||
key: 'audience_region',
|
||||
ellipsis: true,
|
||||
width: 150,
|
||||
render: (text: string[]) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{Array.isArray(text) ? text.join(', ') : text || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '网络类型',
|
||||
dataIndex: 'audienceNetwork',
|
||||
key: 'audienceNetwork',
|
||||
width: 90,
|
||||
render: (text: string[]) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{Array.isArray(text) ? text.join(', ') : text || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '客户主体名称',
|
||||
dataIndex: 'cusName',
|
||||
key: 'cusName',
|
||||
ellipsis: true,
|
||||
width: 120,
|
||||
render: (text: string) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{text || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '出价类型',
|
||||
dataIndex: 'pricingType',
|
||||
key: 'pricingType',
|
||||
width: 90,
|
||||
render: (text: string) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{text || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '目标点击成本',
|
||||
dataIndex: 'cpcBid',
|
||||
key: 'cpcBid',
|
||||
width: 120,
|
||||
render: (text: number) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{text !== undefined ? text : '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '预算金额',
|
||||
dataIndex: 'budget',
|
||||
key: 'budget',
|
||||
width: 100,
|
||||
render: (text: number) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{text !== undefined ? text : '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '默认模板',
|
||||
dataIndex: 'isDefault',
|
||||
key: 'isDefault',
|
||||
width: 90,
|
||||
render: (text: boolean) => (
|
||||
<Tag color={text ? 'green' : 'default'} style={{ borderRadius: 6, fontSize: 12 }}>
|
||||
{text ? '是' : '否'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (text: string) => <Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}>{formatDateTime(text)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'operation',
|
||||
fixed: 'right' as const,
|
||||
key: 'operation',
|
||||
render: (_: any, record: PreTestRecord) => (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
style={{ color: '#6366f1', fontSize: 12 }}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const PreTest: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const [records, setRecords] = useState<PreTestRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit' | 'detail'>('create');
|
||||
const [currentRecord, setCurrentRecord] = useState<PreTestRecord | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [platform, setPlatform] = useState<string>('AD');
|
||||
const [searchPlatform, setSearchPlatform] = useState<string | undefined>(undefined);
|
||||
const [areaOptions, setAreaOptions] = useState<any[]>([]);
|
||||
const [cityCodeMap, setCityCodeMap] = useState<Record<string, string>>({});
|
||||
const [provinceCityMap, setProvinceCityMap] = useState<Record<string, string[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
@@ -321,10 +160,14 @@ const PreTest: React.FC = () => {
|
||||
loadProvinceOptions();
|
||||
}, []);
|
||||
|
||||
const loadRecords = async () => {
|
||||
const loadRecords = async (params?: { page?: number; pageSize?: number; platform?: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getPreTestList({ page: currentPage, pageSize });
|
||||
console.log(params);
|
||||
const page = params?.page ?? currentPage;
|
||||
const size = params?.pageSize ?? pageSize;
|
||||
const platform = params?.platform ?? searchPlatform;
|
||||
const res = await getPreTestList({ page, pageSize: size, platform });
|
||||
setRecords(res.data || []);
|
||||
setTotal(res.pagination?.total || 0);
|
||||
setCurrentPage(res.pagination?.page || 1);
|
||||
@@ -338,13 +181,53 @@ const PreTest: React.FC = () => {
|
||||
|
||||
const loadProvinceOptions = async () => {
|
||||
try {
|
||||
const cachedData = localStorage.getItem('preTestAreaData');
|
||||
if (cachedData) {
|
||||
const parsed = JSON.parse(cachedData);
|
||||
setAreaOptions(parsed.areaOptions);
|
||||
setCityCodeMap(parsed.cityCodeMap);
|
||||
setProvinceCityMap(parsed.provinceCityMap);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await getArea({ level: 'ONE_LEVEL' });
|
||||
const options = (res.data || []).map((item: any) => ({
|
||||
value: item.code,
|
||||
label: item.name,
|
||||
isLeaf: false,
|
||||
}));
|
||||
setAreaOptions(options);
|
||||
const provinces = res.data || [];
|
||||
const map: Record<string, string> = {};
|
||||
const provinceCity: Record<string, string[]> = {};
|
||||
const provinceOptions = await Promise.all(
|
||||
provinces.map(async (province: any) => {
|
||||
let cities: any[] = [];
|
||||
try {
|
||||
const cityRes = await getArea({ level: 'TWO_LEVEL', parent_code: province.code });
|
||||
cities = (cityRes.data || []).map((city: any) => ({
|
||||
value: city.code,
|
||||
label: city.name,
|
||||
isLeaf: true,
|
||||
}));
|
||||
const cityCodes = cities.map((city: any) => city.value);
|
||||
provinceCity[province.code] = cityCodes;
|
||||
cities.forEach((city: any) => {
|
||||
map[city.value] = city.label;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取省份 ${province.name} 的城市失败`, error);
|
||||
provinceCity[province.code] = [];
|
||||
}
|
||||
return {
|
||||
value: province.code,
|
||||
label: province.name,
|
||||
isLeaf: false,
|
||||
children: cities,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const cacheData = { areaOptions: provinceOptions, cityCodeMap: map, provinceCityMap: provinceCity };
|
||||
localStorage.setItem('preTestAreaData', JSON.stringify(cacheData));
|
||||
|
||||
setAreaOptions(provinceOptions);
|
||||
setCityCodeMap(map);
|
||||
setProvinceCityMap(provinceCity);
|
||||
} catch (error) {
|
||||
message.error('获取省份信息失败');
|
||||
}
|
||||
@@ -353,7 +236,6 @@ const PreTest: React.FC = () => {
|
||||
const loadCityOptions = async (selectedOptions: any) => {
|
||||
const targetOption = selectedOptions[selectedOptions.length - 1];
|
||||
targetOption.loading = true;
|
||||
|
||||
try {
|
||||
const res = await getArea({ level: 'TWO_LEVEL', parent_code: targetOption.value });
|
||||
targetOption.children = (res.data || []).map((item: any) => ({
|
||||
@@ -376,12 +258,283 @@ const PreTest: React.FC = () => {
|
||||
key: item.id,
|
||||
}));
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '前测模板编号',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 120,
|
||||
render: (text: string) => <Typography.Text strong style={{ color: '#1e293b', fontSize: 13 }}>{text}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '模板名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
ellipsis: true,
|
||||
render: (text: string) => (
|
||||
<span style={{ color: '#1e293b', fontWeight: 500, fontSize: 13 }}>{text}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '平台',
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
render: (text: string) => (
|
||||
<Tag color={text === 'AD' ? 'blue' : text === 'QIANCHUAN' ? 'green' : 'orange'} style={{ borderRadius: 6, fontSize: 12 }}>
|
||||
{text === 'AD' ? 'AD' : text === 'QIANCHUAN' ? '千川' : '本地推'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '转化目标',
|
||||
dataIndex: 'externalAction',
|
||||
key: 'externalAction',
|
||||
ellipsis: true,
|
||||
width: 200,
|
||||
render: (text: string) => {
|
||||
if (!text) return <span style={{ color: '#94a3b8', fontSize: 13 }}>-</span>;
|
||||
const field = PRETEST_FIELDS.find(f => f.name === text.trim());
|
||||
return (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{field ? field.label : text}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '目标转化成本',
|
||||
dataIndex: 'cpaBid',
|
||||
key: 'cpaBid',
|
||||
width: 120,
|
||||
render: (text: number) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{text !== undefined ? text : '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '受众性别',
|
||||
dataIndex: 'audienceGender',
|
||||
key: 'audienceGender',
|
||||
width: 90,
|
||||
render: (text: string) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{text === 'ALL' ? '不限' : text === 'MALE' ? '男' : text === 'FEMALE' ? '女' : text || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '受众年龄',
|
||||
dataIndex: 'audienceAge',
|
||||
key: 'audienceAge',
|
||||
width: 90,
|
||||
render: (text: string[]) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{Array.isArray(text) ? text.join(', ') : text || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '受众地区',
|
||||
dataIndex: 'audienceRegion',
|
||||
key: 'audienceRegion',
|
||||
ellipsis: true,
|
||||
width: 300,
|
||||
render: (text: string[]) => {
|
||||
if (!Array.isArray(text) || text.length === 0) {
|
||||
return <span style={{ color: '#94a3b8', fontSize: 13 }}>-</span>;
|
||||
}
|
||||
const cityNames = text.map(code => cityCodeMap[String(code)] || String(code));
|
||||
return (
|
||||
<Input.TextArea
|
||||
value={cityNames.join(', ') || '-'}
|
||||
readOnly
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{ resize: 'none', border: 'none', background: 'transparent', padding: 0 }}
|
||||
placeholder="-"
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '网络类型',
|
||||
dataIndex: 'audienceNetwork',
|
||||
key: 'audienceNetwork',
|
||||
width: 90,
|
||||
render: (text: string[]) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{Array.isArray(text) ? text.join(', ') : text || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '客户主体名称',
|
||||
dataIndex: 'cusName',
|
||||
key: 'cusName',
|
||||
ellipsis: true,
|
||||
width: 120,
|
||||
render: (text: string) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{text || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '出价类型',
|
||||
dataIndex: 'pricingType',
|
||||
key: 'pricingType',
|
||||
width: 90,
|
||||
render: (text: string) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{text || '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '目标点击成本',
|
||||
dataIndex: 'cpcBid',
|
||||
key: 'cpcBid',
|
||||
width: 120,
|
||||
render: (text: number) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{text !== undefined ? text : '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '预算金额',
|
||||
dataIndex: 'budget',
|
||||
key: 'budget',
|
||||
width: 100,
|
||||
render: (text: number) => (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{text !== undefined ? text : '-'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '默认模板',
|
||||
dataIndex: 'isDefault',
|
||||
key: 'isDefault',
|
||||
width: 90,
|
||||
render: (text: boolean) => (
|
||||
<Tag color={text ? 'green' : 'default'} style={{ borderRadius: 6, fontSize: 12 }}>
|
||||
{text ? '是' : '否'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (text: string) => <Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}>{formatDateTime(text)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'operation',
|
||||
fixed: 'right' as const,
|
||||
key: 'operation',
|
||||
render: (_: any, record: PreTestRecord) => (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
style={{ color: '#6366f1', fontSize: 12 }}
|
||||
onClick={() => handleDetail(record)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
style={{ color: '#6366f1', fontSize: 12 }}
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ color: '#ef4444', fontSize: 12 }}
|
||||
onClick={() => handleDelete(record)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleOpenModal = () => {
|
||||
form.resetFields();
|
||||
setPlatform('AD');
|
||||
setModalMode('create');
|
||||
setCurrentRecord(null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const fillFormWithDetail = async (data: any) => {
|
||||
setCurrentRecord(data);
|
||||
setPlatform(data.platform || 'AD');
|
||||
const regionCodes = data.audienceRegion || [];
|
||||
const regionPaths: string[][] = [];
|
||||
for (const code of regionCodes) {
|
||||
const cityCode = String(code);
|
||||
if (cityCode.length >= 6) {
|
||||
const provinceCode = cityCode.slice(0, 2);
|
||||
loadCityOptions(provinceCode);
|
||||
regionPaths.push([provinceCode, cityCode]);
|
||||
}
|
||||
}
|
||||
form.setFieldsValue({
|
||||
name: data.name,
|
||||
note: data.note,
|
||||
platform: data.platform,
|
||||
external_action: data.externalAction,
|
||||
cpa_bid: data.cpaBid,
|
||||
audience_gender: data.audienceGender,
|
||||
audience_age: data.audienceAge || [],
|
||||
audience_region: regionPaths,
|
||||
audience_network: data.audienceNetwork || [],
|
||||
cus_name: data.cusName,
|
||||
pricing_type: data.pricingType,
|
||||
cost_cap: data.costCap !== undefined ? data.costCap : false,
|
||||
target_cost: data.targetCost !== undefined ? data.targetCost : false,
|
||||
nobid: data.nobid !== undefined ? data.nobid : false,
|
||||
cpc_bid: data.cpcBid,
|
||||
budget: data.budget,
|
||||
is_default: data.isDefault !== undefined ? data.isDefault : false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDetail = async (record: PreTestRecord) => {
|
||||
try {
|
||||
const res = await getPreTestDetail(record.id);
|
||||
const data = res.data;
|
||||
setModalMode('detail');
|
||||
fillFormWithDetail(data);
|
||||
setModalOpen(true);
|
||||
} catch (error) {
|
||||
message.error('获取详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (record: PreTestRecord) => {
|
||||
try {
|
||||
const res = await getPreTestDetail(record.id);
|
||||
const data = res.data;
|
||||
setModalMode('edit');
|
||||
fillFormWithDetail(data);
|
||||
setModalOpen(true);
|
||||
} catch (error) {
|
||||
message.error('获取详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (record: PreTestRecord) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除该前测模板吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deletePreTest(record.id);
|
||||
message.success('删除成功');
|
||||
loadRecords();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
@@ -392,13 +545,21 @@ const PreTest: React.FC = () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitLoading(true);
|
||||
const regionCodes = (values.audience_region || []).map((path: string[]) => path[path.length - 1]);
|
||||
const externalActionStr = (values.external_action || []).join(',');
|
||||
const result = await createPreTest({
|
||||
const regionCodes: string[] = [];
|
||||
(values.audience_region || []).forEach((path: string[]) => {
|
||||
if (path.length === 1) {
|
||||
const provinceCode = path[0];
|
||||
const cities = provinceCityMap[provinceCode] || [];
|
||||
regionCodes.push(...cities);
|
||||
} else if (path.length >= 2) {
|
||||
regionCodes.push(path[path.length - 1]);
|
||||
}
|
||||
});
|
||||
const params = {
|
||||
name: values.name,
|
||||
note: values.note,
|
||||
platform: values.platform,
|
||||
external_action: externalActionStr,
|
||||
external_action: values.external_action,
|
||||
cpa_bid: values.cpa_bid || 0,
|
||||
audience_gender: values.audience_gender,
|
||||
audience_age: values.audience_age || [],
|
||||
@@ -412,9 +573,17 @@ const PreTest: React.FC = () => {
|
||||
cpc_bid: values.cpc_bid || 0,
|
||||
budget: values.budget || 0,
|
||||
is_default: values.is_default || false,
|
||||
});
|
||||
};
|
||||
|
||||
let result;
|
||||
if (modalMode === 'edit' && currentRecord) {
|
||||
result = await updatePreTest(currentRecord.id, params);
|
||||
message.success('前测模板更新成功');
|
||||
} else {
|
||||
result = await createPreTest(params);
|
||||
message.success('前测模板创建成功');
|
||||
}
|
||||
|
||||
message.success('前测模板创建成功');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
loadRecords();
|
||||
@@ -424,7 +593,7 @@ const PreTest: React.FC = () => {
|
||||
} else if (error?.response?.data?.message) {
|
||||
message.error(error.response.data.message);
|
||||
} else {
|
||||
message.error('创建失败,请重试');
|
||||
message.error(modalMode === 'edit' ? '更新失败,请重试' : '创建失败,请重试');
|
||||
}
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
@@ -442,23 +611,45 @@ const PreTest: React.FC = () => {
|
||||
<FileTextOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>素材前测</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginBottom: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="medium"
|
||||
icon={<PlusOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleOpenModal}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 14px rgba(99,102,241,0.3)',
|
||||
}}
|
||||
>
|
||||
新增前测
|
||||
</Button>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<Select
|
||||
placeholder="投放平台"
|
||||
value={searchPlatform}
|
||||
onChange={(value) => setSearchPlatform(value)}
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'AD', label: 'AD' },
|
||||
{ value: 'QIANCHUAN', label: '千川' },
|
||||
{ value: 'LOCAL', label: '本地推' },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => { setCurrentPage(1); loadRecords(); }}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="medium"
|
||||
icon={<PlusOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleOpenModal}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 14px rgba(99,102,241,0.3)',
|
||||
}}
|
||||
>
|
||||
新增前测
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
@@ -502,21 +693,23 @@ const PreTest: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={<Space>新增前测模板</Space>}
|
||||
title={<Space>{modalMode === 'create' ? '新增前测模板' : modalMode === 'edit' ? '编辑前测模板' : '前测模板详情'}</Space>}
|
||||
open={modalOpen}
|
||||
onCancel={handleCloseModal}
|
||||
onOk={handleSubmit}
|
||||
okText="提交"
|
||||
okText={modalMode === 'detail' ? '' : '提交'}
|
||||
cancelText="取消"
|
||||
width={720}
|
||||
height={600}
|
||||
confirmLoading={submitLoading}
|
||||
style={{ borderRadius: 16 }}
|
||||
footer={modalMode === 'detail' ? null : undefined}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
disabled={modalMode === 'detail'}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item
|
||||
@@ -557,10 +750,8 @@ const PreTest: React.FC = () => {
|
||||
label="转化目标"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="请选择转化目标(可多选)"
|
||||
style={{ borderRadius: 8, minHeight: 40 }}
|
||||
maxTagCount="responsive"
|
||||
placeholder="请选择转化目标"
|
||||
style={{ borderRadius: 8, height: 40 }}
|
||||
options={PRETEST_FIELDS.filter(field => {
|
||||
if (platform === 'AD') {
|
||||
return field.name.startsWith('AD_');
|
||||
@@ -675,8 +866,6 @@ const PreTest: React.FC = () => {
|
||||
placeholder="请选择受众地区(可多选)"
|
||||
style={{ borderRadius: 8, minHeight: 40 }}
|
||||
options={areaOptions}
|
||||
loadData={loadCityOptions}
|
||||
changeOnSelect
|
||||
maxTagCount="responsive"
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -713,7 +902,6 @@ const PreTest: React.FC = () => {
|
||||
style={{ flex: 1 }}
|
||||
name="cost_cap"
|
||||
label="是否最优成本出价"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Select
|
||||
placeholder="是否最优成本出价"
|
||||
@@ -729,7 +917,6 @@ const PreTest: React.FC = () => {
|
||||
style={{ flex: 1 }}
|
||||
name="target_cost"
|
||||
label="是否稳定成本出价"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Select
|
||||
placeholder="是否稳定成本出价"
|
||||
@@ -745,7 +932,6 @@ const PreTest: React.FC = () => {
|
||||
style={{ flex: 1 }}
|
||||
name="nobid"
|
||||
label="是否最大转化出价"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Select
|
||||
placeholder="是否最大转化出价"
|
||||
@@ -799,7 +985,6 @@ const PreTest: React.FC = () => {
|
||||
name="is_default"
|
||||
label="默认模板"
|
||||
initialValue={false}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择默认模板"
|
||||
|
||||
Reference in New Issue
Block a user