用户端上传消耗列表

This commit is contained in:
Lrd
2026-06-25 15:16:23 +08:00
parent 5f8e2ce93a
commit 6da4fe78f3
3 changed files with 341 additions and 141 deletions
+2 -1
View File
@@ -115,12 +115,13 @@ const ConsumePage: React.FC = () => {
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}> <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space> <Space>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={handleBack} />
{/* <Button icon={<ArrowLeftOutlined />} onClick={handleBack}>返回</Button> */}
<DollarOutlined style={{ fontSize: 18, color: '#6366f1' }} /> <DollarOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text> <Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space> </Space>
<Space> <Space>
<Button icon={<SettingOutlined />} onClick={() => setShowModal(true)}></Button> <Button icon={<SettingOutlined />} onClick={() => setShowModal(true)}></Button>
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}></Button>
</Space> </Space>
</div> </div>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
+14 -1
View File
@@ -645,7 +645,20 @@ export async function getMaterialConsumpList(params: MaterialConsumpListParams):
return api.get(`/material-consumption/list?${query.toString()}`); return api.get(`/material-consumption/list?${query.toString()}`);
} }
// 查询素材消耗列表 // 拉取消耗
export async function syncMaterialConsumption(params: { date: string; advertiser_id?: string }): Promise<any> {
const query = new URLSearchParams();
if (params.advertiser_id) query.set('advertiser_id', params.advertiser_id);
if (params.date) query.set('date', params.date);
return api.get(`/material-consumption/sync?${query.toString()}`);
}
// 获取消耗字段列表
export async function getMaterialConsumptionFields(): Promise<any> {
return api.get('/material-consumption/fields');
}
// 查询上传素材列表
export interface ResourcesMaterialListParams { export interface ResourcesMaterialListParams {
advertiser_id?: string; advertiser_id?: string;
material_id?: string; material_id?: string;
+309 -123
View File
@@ -1,130 +1,85 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Table, Tag, Button, Pagination, Typography, DatePicker, App, Input } from 'antd'; import { Table, Tag, Button, Pagination, Typography, Modal, Checkbox, Input, DatePicker, App } from 'antd';
import { ArrowLeftOutlined, DollarOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined, DollarOutlined, SettingOutlined, SyncOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { getMaterialConsumpList } from '../api'; import { getMaterialConsumpList, getMaterialConsumptionFields, syncMaterialConsumption } from '../api';
import dayjs from 'dayjs';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
// 消耗类型配置 const toCamelCase = (str: string) => str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
const consumptionTypeConfig: Record<string, { label: string; color: string }> = {
video: { label: '视频生成', color: 'blue' },
audio: { label: '音频转换', color: 'purple' },
image: { label: '图片处理', color: 'green' },
};
interface ConsumptionRecord { interface ConsumptionRecord {
id: string; id: string;
authorizationId: string; advertiser_id?: string;
amount: number; user_id?: string;
type: string; consume_date?: string;
description: string; [key: string]: any;
createdAt: string;
} }
const ConsumePage: React.FC = () => { const ConsumePage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { message } = App.useApp(); const { message } = App.useApp();
const [consumptionRecords, setConsumptionRecords] = useState<ConsumptionRecord[]>([]); const [consumptionRecords, setConsumptionRecords] = useState<ConsumptionRecord[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [listLoading, setListLoading] = useState(false); const [syncLoading, setSyncLoading] = useState(false);
const [showModal, setShowModal] = useState(false);
const [columnsData, setColumnsData] = useState<{ name: string; description: string }[]>([]);
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
const [searchText, setSearchText] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0); const [advertiserId, setAdvertiserId] = useState(searchParams.get('accountId') || '');
const [searchParams, setSearchParams] = useState({ const [consumeDateRange, setConsumeDateRange] = useState<[string, string] | undefined>();
advertiser_id: '', const [syncDate, setSyncDate] = useState<string>(dayjs().subtract(1, 'day').format('YYYY-MM-DD'));
consume_date: undefined as [string, string] | undefined, const [syncAdvertiserId, setSyncAdvertiserId] = useState<string>('');
});
useEffect(() => { useEffect(() => {
loadConsumptionList(); loadColumns();
loadData();
}, []); }, []);
const loadConsumptionList = async (page = 1, pageSizeNum = 10, params = searchParams) => { const loadData = async (page = 1, pageSizeNum = 10) => {
setListLoading(true); setLoading(true);
try { try {
const response = await getMaterialConsumpList({ const res = await getMaterialConsumpList({
advertiser_id: advertiserId || undefined,
consume_date: consumeDateRange,
page, page,
page_size: pageSizeNum, page_size: pageSizeNum,
advertiser_id: params.advertiser_id || undefined,
consume_date: params.consume_date,
}); });
if (response) { setConsumptionRecords(res.data || []);
if (response.data) { setTotal(res.pagination?.total || 0);
setConsumptionRecords(response.data.data || response.data); setCurrentPage(res.pagination?.page || 1);
setTotal(response.pagination?.total || 0); setPageSize(res.pagination?.pageSize || 10);
setCurrentPage(response.pagination?.page || 1); } catch (e: any) {
setPageSize(response.pagination?.pageSize || 10); message.error('加载数据失败');
} else {
setConsumptionRecords(response.data || response || []);
setTotal(Array.isArray(response) ? response.length : 0);
}
} else {
setConsumptionRecords([]);
setTotal(0);
}
} catch (error) {
message.error('获取消耗列表失败');
console.error('获取消耗列表失败:', error);
} finally { } finally {
setListLoading(false); setLoading(false);
} }
}; };
// 表头配置 const loadColumns = async () => {
const columns = [ try {
{ const res = await getMaterialConsumptionFields();
title: '序号', const fields = res.data || [];
dataIndex: 'index', const formattedFields = fields.map((item: { field: string; description: string }) => ({
key: 'index', name: toCamelCase(item.field),
width: 80, description: item.description,
fixed: 'left' as const, }));
render: (text: number) => <span style={{ color: '#94a3b8' }}>{text}</span>, setColumnsData(formattedFields);
}, const saved = localStorage.getItem('consumeColumns');
{ if (saved) {
title: '消耗ID', setSelectedColumns(JSON.parse(saved));
dataIndex: 'id', } else {
key: 'id', setSelectedColumns(formattedFields.map((item: { name: string }) => item.name));
ellipsis: true, }
render: (text: string) => <span style={{ fontWeight: 500, color: '#1e293b' }}>{text}</span>, } catch (e: any) {
}, message.error('加载表头字段失败');
{ }
title: '授权ID', };
dataIndex: 'authorizationId',
key: 'authorizationId',
ellipsis: true,
},
{
title: '消耗类型',
dataIndex: 'type',
key: 'type',
width: 120,
render: (text: string) => {
const config = consumptionTypeConfig[text];
return <Tag color={config?.color}>{config?.label || text}</Tag>;
},
},
{
title: '消耗金额',
dataIndex: 'amount',
key: 'amount',
width: 120,
render: (text: number) => <span style={{ color: '#ef4444', fontWeight: 500 }}>{text} </span>,
},
{
title: '消耗描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '消耗时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
fixed: 'right' as const,
},
];
const tableData = consumptionRecords.map((item, index) => ({ const tableData = consumptionRecords.map((item, index) => ({
...item, ...item,
@@ -136,58 +91,130 @@ const ConsumePage: React.FC = () => {
navigate('/authorization'); navigate('/authorization');
}; };
const handleSearch = () => {
setCurrentPage(1);
loadData(1, pageSize);
};
const handleReset = () => {
setAdvertiserId('');
setConsumeDateRange(undefined);
setCurrentPage(1);
loadData(1, pageSize);
};
const handleSync = () => {
Modal.confirm({
title: '拉取消耗数据',
content: (
<div>
<div style={{ marginBottom: 12 }}>
<Typography.Text style={{ display: 'block', marginBottom: 8 }}></Typography.Text>
<DatePicker
value={dayjs(syncDate)}
onChange={(date) => setSyncDate(date ? date.format('YYYY-MM-DD') : dayjs().subtract(1, 'day').format('YYYY-MM-DD'))}
style={{ width: '100%' }}
/>
</div>
<div>
<Typography.Text style={{ display: 'block', marginBottom: 8 }}>广ID</Typography.Text>
<Input
placeholder="不指定则同步所有授权的广告主"
value={syncAdvertiserId}
onChange={(e) => setSyncAdvertiserId(e.target.value)}
/>
</div>
</div>
),
onOk: async () => {
setSyncLoading(true);
try {
await syncMaterialConsumption({
date: syncDate,
advertiser_id: syncAdvertiserId || undefined,
});
message.success('拉取消耗数据成功');
loadData(currentPage, pageSize);
} catch (e: any) {
message.error('拉取消耗数据失败');
} finally {
setSyncLoading(false);
}
},
okText: '开始拉取',
cancelText: '取消',
});
};
const dynamicColumns = selectedColumns
.map(colName => columnsData.find(col => col.name === colName))
.filter((col): col is { name: string; description: string } => !!col)
.map(col => ({
title: col.description,
dataIndex: col.name,
key: col.name,
ellipsis: true,
}));
return ( return (
<div style={{ minHeight: '94vh' }}> <div style={{ minHeight: '94vh' }}>
{/* 页面标题 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={handleBack} /> <Button type="text" icon={<ArrowLeftOutlined />} onClick={handleBack} />
<DollarOutlined style={{ color: '#6366f1', fontSize: 16 }} /> <DollarOutlined style={{ color: '#6366f1', fontSize: 16 }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text> <Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</div> </div>
{/* 搜索筛选 */} <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}> <div style={{ display: 'flex', gap: 12 }}>
<Input <Input
placeholder="授权ID" placeholder="广告主ID"
value={searchParams.advertiser_id} value={advertiserId}
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_id: e.target.value }))} onChange={(e) => setAdvertiserId(e.target.value)}
style={{ width: 200 }} style={{ width: 180 }}
onPressEnter={() => { setCurrentPage(1); loadConsumptionList(1, pageSize); }} onPressEnter={() => { setCurrentPage(1); loadData(1, pageSize); }}
/> />
<RangePicker <RangePicker
value={searchParams.consume_date ? [undefined, undefined] : undefined} value={consumeDateRange ? [dayjs(consumeDateRange[0]), dayjs(consumeDateRange[1])] : undefined}
onChange={(dates, dateStrings) => { onChange={(dates) => {
if (dates && dateStrings[0] && dateStrings[1]) { if (dates && dates[0] && dates[1]) {
setSearchParams(prev => ({ ...prev, consume_date: [dateStrings[0], dateStrings[1]] })); setConsumeDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
} else { } else {
setSearchParams(prev => ({ ...prev, consume_date: undefined })); setConsumeDateRange(undefined);
} }
}} }}
style={{ width: 280 }}
/> />
<Button <Button
type="primary" type="primary"
onClick={() => { setCurrentPage(1); loadConsumptionList(1, pageSize); }} size="medium"
onClick={handleSearch}
> >
</Button> </Button>
<Button <Button
onClick={() => { size="medium"
setSearchParams({ advertiser_id: '', consume_date: undefined }); onClick={handleReset}
setCurrentPage(1);
loadConsumptionList(1, pageSize);
}}
> >
</Button> </Button>
</div> </div>
<div style={{ display: 'flex', gap: 12 }}>
<Button
icon={<SyncOutlined />}
onClick={handleSync}
loading={syncLoading}
style={{ borderRadius: 8 }}
>
</Button>
<Button icon={<SettingOutlined />} onClick={() => setShowModal(true)} style={{ borderRadius: 8 }}></Button>
</div>
</div>
{/* 表格 */}
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}> <div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
<Table <Table
dataSource={tableData} dataSource={tableData}
columns={columns} columns={dynamicColumns}
loading={listLoading} loading={loading}
pagination={false} pagination={false}
rowKey="id" rowKey="id"
bordered={false} bordered={false}
@@ -203,12 +230,171 @@ const ConsumePage: React.FC = () => {
onChange={(page, size) => { onChange={(page, size) => {
setCurrentPage(page); setCurrentPage(page);
setPageSize(size); setPageSize(size);
loadConsumptionList(page, size); loadData(page, size);
}} }}
size="small" size="small"
/> />
</div> </div>
</div> </div>
<Modal
title="自定义表头"
open={showModal}
onOk={() => {
localStorage.setItem('consumeColumns', JSON.stringify(selectedColumns));
message.success('表头设置已保存');
setShowModal(false);
}}
onCancel={() => {
setShowModal(false);
setSearchText('');
}}
okText="确定"
cancelText="取消"
width={800}
>
<div style={{ display: 'flex', gap: 20, height: 400 }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Input
placeholder="搜索指标..."
value={searchText}
allowClear={true}
onChange={(e) => setSearchText(e.target.value)}
style={{ marginBottom: 8 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12, marginBottom: 12, display: 'block' }}>
</Typography.Text>
<div style={{ flex: 1, overflowY: 'auto', border: '1px solid #f0f0f5', borderRadius: 8, padding: 12 }}>
<div style={{ marginBottom: 16 }}>
<Typography.Text strong style={{ fontSize: 14, color: '#1e293b' }}></Typography.Text>
</div>
<Checkbox
checked={selectedColumns.length === columnsData.length && columnsData.length > 0}
indeterminate={selectedColumns.length > 0 && selectedColumns.length < columnsData.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedColumns(columnsData.map(item => item.name));
} else {
setSelectedColumns([]);
}
}}
style={{ marginBottom: 12 }}
>
</Checkbox>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{columnsData
.filter(item =>
item.description.toLowerCase().includes(searchText.toLowerCase()) ||
item.name.toLowerCase().includes(searchText.toLowerCase())
)
.map(item => (
<Checkbox
key={item.name}
checked={selectedColumns.includes(item.name)}
onChange={(e) => {
if (e.target.checked) {
setSelectedColumns(prev => [...prev, item.name]);
} else {
setSelectedColumns(prev => prev.filter(col => col !== item.name));
}
}}
>
{item.description}
</Checkbox>
))}
</div>
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 14, color: '#1e293b' }}>
({selectedColumns.length})
</Typography.Text>
<div style={{ display: 'flex', gap: 8 }}>
<Button
size="small"
onClick={() => setSelectedColumns([])}
disabled={selectedColumns.length === 0}
>
</Button>
<Button
size="small"
onClick={async () => {
try {
const res = await getMaterialConsumptionFields();
const fields = res.data || [];
const formattedFields = fields.map((item: { field: string; description: string }) => ({
name: toCamelCase(item.field),
description: item.description,
}));
setColumnsData(formattedFields);
setSelectedColumns(formattedFields.map((item: { name: string }) => item.name));
localStorage.removeItem('consumeColumns');
message.success('表头字段已更新');
} catch (e: any) {
message.error('更新表头字段失败');
}
}}
>
</Button>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', border: '1px solid #f0f0f5', borderRadius: 8, padding: 12 }}>
{selectedColumns.length === 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 14, textAlign: 'center', display: 'block', padding: '40px 0' }}>
</Typography.Text>
) : (
<div>
{selectedColumns.map((colName, index) => {
const col = columnsData.find(c => c.name === colName);
return (
<div
key={colName}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
borderBottom: '1px solid #f0f0f5',
cursor: 'move',
userSelect: 'none',
}}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('index', String(index));
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
const fromIndex = parseInt(e.dataTransfer.getData('index'));
const toIndex = index;
if (fromIndex !== toIndex) {
const newSelected = [...selectedColumns];
const [removed] = newSelected.splice(fromIndex, 1);
newSelected.splice(toIndex, 0, removed);
setSelectedColumns(newSelected);
}
}}
>
<Typography.Text>{col?.description || colName}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{index + 1}
</Typography.Text>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</Modal>
</div> </div>
); );
}; };