This commit is contained in:
sjy
2026-06-30 19:30:33 +08:00
51 changed files with 6742 additions and 1192 deletions
+6 -14
View File
@@ -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>
+3 -11
View File
@@ -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"
+1 -6
View File
@@ -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
+60 -97
View File
@@ -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%' }}>
+9 -10
View File
@@ -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>
</>
)}
+114 -109
View File
@@ -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>
);
+179 -61
View File
@@ -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
View File
@@ -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="请选择默认模板"