用户端上传消耗列表
This commit is contained in:
@@ -115,12 +115,13 @@ const ConsumePage: React.FC = () => {
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={handleBack} />
|
||||
{/* <Button icon={<ArrowLeftOutlined />} onClick={handleBack}>返回</Button> */}
|
||||
<DollarOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>消耗记录</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button icon={<SettingOutlined />} onClick={() => setShowModal(true)}>自定义表头</Button>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}>返回</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
|
||||
@@ -645,7 +645,20 @@ export async function getMaterialConsumpList(params: MaterialConsumpListParams):
|
||||
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 {
|
||||
advertiser_id?: string;
|
||||
material_id?: string;
|
||||
|
||||
@@ -1,130 +1,85 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Tag, Button, Pagination, Typography, DatePicker, App, Input } from 'antd';
|
||||
import { ArrowLeftOutlined, DollarOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getMaterialConsumpList } from '../api';
|
||||
import { Table, Tag, Button, Pagination, Typography, Modal, Checkbox, Input, DatePicker, App } from 'antd';
|
||||
import { ArrowLeftOutlined, DollarOutlined, SettingOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { getMaterialConsumpList, getMaterialConsumptionFields, syncMaterialConsumption } from '../api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// 消耗类型配置
|
||||
const consumptionTypeConfig: Record<string, { label: string; color: string }> = {
|
||||
video: { label: '视频生成', color: 'blue' },
|
||||
audio: { label: '音频转换', color: 'purple' },
|
||||
image: { label: '图片处理', color: 'green' },
|
||||
};
|
||||
const toCamelCase = (str: string) => str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
|
||||
interface ConsumptionRecord {
|
||||
id: string;
|
||||
authorizationId: string;
|
||||
amount: number;
|
||||
type: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
advertiser_id?: string;
|
||||
user_id?: string;
|
||||
consume_date?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const ConsumePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { message } = App.useApp();
|
||||
const [consumptionRecords, setConsumptionRecords] = useState<ConsumptionRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
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 [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
advertiser_id: '',
|
||||
consume_date: undefined as [string, string] | undefined,
|
||||
});
|
||||
const [advertiserId, setAdvertiserId] = useState(searchParams.get('accountId') || '');
|
||||
const [consumeDateRange, setConsumeDateRange] = useState<[string, string] | undefined>();
|
||||
const [syncDate, setSyncDate] = useState<string>(dayjs().subtract(1, 'day').format('YYYY-MM-DD'));
|
||||
const [syncAdvertiserId, setSyncAdvertiserId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
loadConsumptionList();
|
||||
loadColumns();
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadConsumptionList = async (page = 1, pageSizeNum = 10, params = searchParams) => {
|
||||
setListLoading(true);
|
||||
const loadData = async (page = 1, pageSizeNum = 10) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getMaterialConsumpList({
|
||||
const res = await getMaterialConsumpList({
|
||||
advertiser_id: advertiserId || undefined,
|
||||
consume_date: consumeDateRange,
|
||||
page,
|
||||
page_size: pageSizeNum,
|
||||
advertiser_id: params.advertiser_id || undefined,
|
||||
consume_date: params.consume_date,
|
||||
});
|
||||
if (response) {
|
||||
if (response.data) {
|
||||
setConsumptionRecords(response.data.data || response.data);
|
||||
setTotal(response.pagination?.total || 0);
|
||||
setCurrentPage(response.pagination?.page || 1);
|
||||
setPageSize(response.pagination?.pageSize || 10);
|
||||
} else {
|
||||
setConsumptionRecords(response.data || response || []);
|
||||
setTotal(Array.isArray(response) ? response.length : 0);
|
||||
}
|
||||
} else {
|
||||
setConsumptionRecords([]);
|
||||
setTotal(0);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取消耗列表失败');
|
||||
console.error('获取消耗列表失败:', error);
|
||||
setConsumptionRecords(res.data || []);
|
||||
setTotal(res.pagination?.total || 0);
|
||||
setCurrentPage(res.pagination?.page || 1);
|
||||
setPageSize(res.pagination?.pageSize || 10);
|
||||
} catch (e: any) {
|
||||
message.error('加载数据失败');
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 表头配置
|
||||
const columns = [
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
key: 'index',
|
||||
width: 80,
|
||||
fixed: 'left' as const,
|
||||
render: (text: number) => <span style={{ color: '#94a3b8' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: '消耗ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
ellipsis: true,
|
||||
render: (text: string) => <span style={{ fontWeight: 500, color: '#1e293b' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
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 loadColumns = 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);
|
||||
const saved = localStorage.getItem('consumeColumns');
|
||||
if (saved) {
|
||||
setSelectedColumns(JSON.parse(saved));
|
||||
} else {
|
||||
setSelectedColumns(formattedFields.map((item: { name: string }) => item.name));
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error('加载表头字段失败');
|
||||
}
|
||||
};
|
||||
|
||||
const tableData = consumptionRecords.map((item, index) => ({
|
||||
...item,
|
||||
@@ -136,58 +91,130 @@ const ConsumePage: React.FC = () => {
|
||||
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 (
|
||||
<div style={{ minHeight: '94vh' }}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={handleBack} />
|
||||
<DollarOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>消耗记录</Typography.Text>
|
||||
</div>
|
||||
|
||||
{/* 搜索筛选 */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="授权ID"
|
||||
value={searchParams.advertiser_id}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, advertiser_id: e.target.value }))}
|
||||
style={{ width: 200 }}
|
||||
onPressEnter={() => { setCurrentPage(1); loadConsumptionList(1, pageSize); }}
|
||||
/>
|
||||
<RangePicker
|
||||
value={searchParams.consume_date ? [undefined, undefined] : undefined}
|
||||
onChange={(dates, dateStrings) => {
|
||||
if (dates && dateStrings[0] && dateStrings[1]) {
|
||||
setSearchParams(prev => ({ ...prev, consume_date: [dateStrings[0], dateStrings[1]] }));
|
||||
} else {
|
||||
setSearchParams(prev => ({ ...prev, consume_date: undefined }));
|
||||
}
|
||||
}}
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => { setCurrentPage(1); loadConsumptionList(1, pageSize); }}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSearchParams({ advertiser_id: '', consume_date: undefined });
|
||||
setCurrentPage(1);
|
||||
loadConsumptionList(1, pageSize);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Input
|
||||
placeholder="广告主ID"
|
||||
value={advertiserId}
|
||||
onChange={(e) => setAdvertiserId(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
onPressEnter={() => { setCurrentPage(1); loadData(1, pageSize); }}
|
||||
/>
|
||||
<RangePicker
|
||||
value={consumeDateRange ? [dayjs(consumeDateRange[0]), dayjs(consumeDateRange[1])] : undefined}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setConsumeDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
|
||||
} else {
|
||||
setConsumeDateRange(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="medium"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
size="medium"
|
||||
onClick={handleReset}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</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)' }}>
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
loading={listLoading}
|
||||
columns={dynamicColumns}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
bordered={false}
|
||||
@@ -203,12 +230,171 @@ const ConsumePage: React.FC = () => {
|
||||
onChange={(page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
loadConsumptionList(page, size);
|
||||
loadData(page, size);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user