Merge branch 'main' of https://gitee.com/wg123/video-gen
@@ -1,5 +1,5 @@
|
||||
VITE_API_BASE=http://192.168.120.17:8000
|
||||
# VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
|
||||
# VITE_API_BASE=http://192.168.120.17:8000
|
||||
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
|
||||
VITE_USE_MOCK=false
|
||||
# Encryption disabled for dev — enable in production
|
||||
VITE_ENCRYPTION_KEY=
|
||||
@@ -7,7 +7,7 @@ import AdminAuthoriz from './pages/AdminAuthoriz';
|
||||
import AdminConsume from './pages/AdminConsume';
|
||||
import AdminLoginPage from './pages/AdminLoginPage';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import AdminPlatform from './pages/AdminPlatform';
|
||||
import AdminPlatform from './pages/Adminplatform';
|
||||
import AdminUsers from './pages/AdminUsers';
|
||||
import AdminModels from './pages/AdminModels';
|
||||
import AdminSettings from './pages/AdminSettings';
|
||||
|
||||
@@ -424,6 +424,49 @@ export async function deleteOauthApp(id: string): Promise<void> {
|
||||
await api.get(`/admin/user-oauth-apps/delete/${id}`);
|
||||
}
|
||||
|
||||
// ── Open Type ───────────────────────────────────────────────
|
||||
|
||||
export async function getOpenTypeList(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
type_name?: string;
|
||||
open_type?: number;
|
||||
}): Promise<{ total: number; items: any[] }> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.page_size) q.set('page_size', String(params.page_size));
|
||||
if (params?.type_name) q.set('type_name', params.type_name);
|
||||
if (params?.open_type) q.set('open_type', String(params.open_type));
|
||||
const qs = q.toString();
|
||||
return api.get(`/open-type/list${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getOpenType(id: string): Promise<any> {
|
||||
return api.get(`/open-type/select/${id}`);
|
||||
}
|
||||
|
||||
export async function createOpenType(data: {
|
||||
open_type: number;
|
||||
description: string;
|
||||
type_name: string;
|
||||
thumb?: string;
|
||||
}): Promise<any> {
|
||||
return api.post('/open-type/create', data);
|
||||
}
|
||||
|
||||
export async function updateOpenType(id: string, data: {
|
||||
open_type?: number;
|
||||
description?: string;
|
||||
type_name?: string;
|
||||
thumb?: string;
|
||||
}): Promise<any> {
|
||||
return api.put(`/open-type/update/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteOpenType(id: string): Promise<void> {
|
||||
await api.delete(`/open-type/delete/${id}`);
|
||||
}
|
||||
|
||||
// ── Generation Records (Admin) ─────────────────────────────
|
||||
|
||||
export async function getAdminGenerationRecords(params?: {
|
||||
@@ -587,3 +630,40 @@ export async function getOAuthList(params: OAuthListParams): Promise<any> {
|
||||
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||
return api.get(`/user-oauth/oauth_list?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-image`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
const data = await res.json();
|
||||
return { url: data.url, filename: data.filename };
|
||||
}
|
||||
|
||||
// 自定义表头字段
|
||||
export async function getFields(): Promise<any> {
|
||||
return api.get('/material-consumption/fields');
|
||||
}
|
||||
|
||||
// 查询素材消耗列表
|
||||
export interface MaterialConsumpParams {
|
||||
advertiser_id?: string;
|
||||
user_id?: string;
|
||||
consume_date?: [string, string];
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
export async function getMaterialConsumpList(params: MaterialConsumpParams): Promise<any> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.advertiser_id) query.set('advertiser_id', params.advertiser_id);
|
||||
if (params.user_id) query.set('user_id', params.user_id);
|
||||
if (params.consume_date !== undefined) query.set('consume_date', params.consume_date[0] + ',' + params.consume_date[1]);
|
||||
if (params.page !== undefined) query.set('page', String(params.page));
|
||||
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||
return api.get(`/material-consumption/admin/list?${query.toString()}`);
|
||||
}
|
||||
@@ -1,67 +1,81 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Space, Table, Button, Typography, Modal, Checkbox, Input, message } from 'antd';
|
||||
import { ArrowLeftOutlined, DollarOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { Card, Space, Table, Button, Typography, Modal, Checkbox, Input, message, DatePicker } from 'antd';
|
||||
import { ArrowLeftOutlined, DollarOutlined, SettingOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getFields, getMaterialConsumpList } from '../api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// 表头字段模拟数据
|
||||
const mockColumnsData = [
|
||||
{ name: 'id', description: 'id' },
|
||||
{ name: 'star_id', description: '星图ID' },
|
||||
{ name: 'demand_id', description: '任务ID' },
|
||||
{ name: 'demand_name', description: '任务名称' },
|
||||
{ name: 'demander_name', description: '客户名称' },
|
||||
{ name: 'brand', description: '品牌' },
|
||||
{ name: 'max_publish_count', description: '最大发布数量' },
|
||||
{ name: 'product_name', description: '产品名称' },
|
||||
{ name: 'product_information', description: '产品信息' },
|
||||
];
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const toCamelCase = (str: string) => str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
|
||||
interface ConsumptionRecord {
|
||||
id: string;
|
||||
star_id?: string;
|
||||
demand_id?: string;
|
||||
demand_name?: string;
|
||||
demander_name?: string;
|
||||
brand?: string;
|
||||
max_publish_count?: number;
|
||||
product_name?: string;
|
||||
product_information?: string;
|
||||
advertiser_id?: string;
|
||||
user_id?: string;
|
||||
consume_date?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const mockConsumptionRecords: ConsumptionRecord[] = [
|
||||
{ id: 'C001', star_id: 'ST001', demand_id: 'DM001', demand_name: '产品推广任务', demander_name: 'XX品牌方', brand: 'XX品牌', max_publish_count: 10, product_name: 'XX产品', product_information: '产品信息描述' },
|
||||
{ id: 'C002', star_id: 'ST002', demand_id: 'DM002', demand_name: '新品发布任务', demander_name: 'YY品牌方', brand: 'YY品牌', max_publish_count: 5, product_name: 'YY产品', product_information: '新品信息描述' },
|
||||
{ id: 'C003', star_id: 'ST003', demand_id: 'DM003', demand_name: '品牌宣传任务', demander_name: 'ZZ品牌方', brand: 'ZZ品牌', max_publish_count: 8, product_name: 'ZZ产品', product_information: '品牌信息描述' },
|
||||
{ id: 'C004', star_id: 'ST001', demand_id: 'DM004', demand_name: '活动促销任务', demander_name: 'XX品牌方', brand: 'XX品牌', max_publish_count: 15, product_name: 'XX活动产品', product_information: '活动产品信息' },
|
||||
{ id: 'C005', star_id: 'ST004', demand_id: 'DM005', demand_name: '节日营销任务', demander_name: 'AA品牌方', brand: 'AA品牌', max_publish_count: 20, product_name: 'AA节日产品', product_information: '节日产品信息' },
|
||||
];
|
||||
|
||||
const ConsumePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [consumptionRecords, setConsumptionRecords] = useState<ConsumptionRecord[]>(mockConsumptionRecords);
|
||||
const [consumptionRecords, setConsumptionRecords] = useState<ConsumptionRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = 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 [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [advertiserId, setAdvertiserId] = useState('');
|
||||
const [userId, setUserId] = useState('');
|
||||
const [consumeDateRange, setConsumeDateRange] = useState<[string, string] | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
setConsumptionRecords(mockConsumptionRecords);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
loadColumns();
|
||||
loadData();
|
||||
}, [page, pageSize]);
|
||||
|
||||
setTimeout(() => {
|
||||
setColumnsData(mockColumnsData);
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getMaterialConsumpList({
|
||||
advertiser_id: advertiserId || undefined,
|
||||
user_id: userId || undefined,
|
||||
consume_date: consumeDateRange,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
console.log(res);
|
||||
setConsumptionRecords(res.data || []);
|
||||
setTotal(res.pagination?.total || 0);
|
||||
} catch (e: any) {
|
||||
message.error('加载数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadColumns = async () => {
|
||||
try {
|
||||
const res = await getFields();
|
||||
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(mockColumnsData.map(item => item.name));
|
||||
setSelectedColumns(formattedFields.map((item: { name: string }) => item.name));
|
||||
}
|
||||
}, 300);
|
||||
}, []);
|
||||
} catch (e: any) {
|
||||
message.error('加载表头字段失败');
|
||||
}
|
||||
};
|
||||
|
||||
const tableData = consumptionRecords.map((item, index) => ({
|
||||
...item,
|
||||
@@ -73,8 +87,22 @@ const ConsumePage: React.FC = () => {
|
||||
navigate('/authorization');
|
||||
};
|
||||
|
||||
const dynamicColumns = columnsData
|
||||
.filter(col => selectedColumns.includes(col.name))
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setAdvertiserId('');
|
||||
setUserId('');
|
||||
setConsumeDateRange(undefined);
|
||||
setPage(1);
|
||||
loadData();
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -84,15 +112,44 @@ const ConsumePage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '94vh' }}>
|
||||
<Card bordered={false} 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 }}>
|
||||
<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 }}>
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="广告主ID"
|
||||
value={advertiserId}
|
||||
onChange={(e) => setAdvertiserId(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="用户ID"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<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" icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
@@ -103,15 +160,19 @@ const ConsumePage: React.FC = () => {
|
||||
bordered={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
pageSize: 10,
|
||||
total: consumptionRecords.length,
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
size: 'small',
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="自定义表头"
|
||||
open={showModal}
|
||||
@@ -127,13 +188,13 @@ const ConsumePage: React.FC = () => {
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
width={800}
|
||||
bodyStyle={{ padding: 16 }}
|
||||
>
|
||||
<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 }}
|
||||
/>
|
||||
@@ -197,11 +258,21 @@ const ConsumePage: React.FC = () => {
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
message.info('更新表头字段');
|
||||
setTimeout(() => {
|
||||
setColumnsData(mockColumnsData);
|
||||
}, 300);
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await getFields();
|
||||
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('更新表头字段失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
更新表头字段
|
||||
|
||||
@@ -1,43 +1,49 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Space, Table, Tag, Typography, message, Modal, Input, Upload,
|
||||
Button, Card, Space, Table, Tag, Typography, message, Modal, Form, Input, InputNumber, Upload,
|
||||
} from 'antd';
|
||||
import {
|
||||
HistoryOutlined, ReloadOutlined, PlusOutlined, UploadOutlined,
|
||||
HistoryOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, EyeOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getOperationLogs } from '../api';
|
||||
import { getOpenTypeList, getOpenType, createOpenType, updateOpenType, deleteOpenType, uploadImage } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface OperationLog {
|
||||
interface OpenType {
|
||||
id: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
action: string;
|
||||
method: string;
|
||||
path: string;
|
||||
detail?: string;
|
||||
ip?: string;
|
||||
open_type: number;
|
||||
type_name: string;
|
||||
description: string;
|
||||
thumb?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = { POST: 'green', PUT: 'blue', DELETE: 'red' };
|
||||
|
||||
const AdminPlatform: React.FC = () => {
|
||||
const [logs, setLogs] = useState<OperationLog[]>([]);
|
||||
const [openTypes, setOpenTypes] = useState<OpenType[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [formData, setFormData] = useState({ title: '', description: '', image: '' });
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const load = async (p?: number) => {
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [updateModalVisible, setUpdateModalVisible] = useState(false);
|
||||
const [currentOpenType, setCurrentOpenType] = useState<OpenType | null>(null);
|
||||
|
||||
const [createForm] = Form.useForm();
|
||||
const [updateForm] = Form.useForm();
|
||||
|
||||
const load = async (p?: number, ps?: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getOperationLogs(p || page);
|
||||
setLogs(res.items || []);
|
||||
const res = await getOpenTypeList({
|
||||
page: p || page,
|
||||
page_size: ps || pageSize,
|
||||
});
|
||||
setOpenTypes(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
message.error('加载平台管理失败');
|
||||
message.error('加载开户方式列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -45,100 +51,227 @@ const AdminPlatform: React.FC = () => {
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const values = await createForm.validateFields();
|
||||
console.log(values);
|
||||
await createOpenType({
|
||||
open_type: values.open_type,
|
||||
type_name: values.type_name,
|
||||
description: values.description,
|
||||
thumb: values.thumb,
|
||||
});
|
||||
message.success('创建成功');
|
||||
setCreateModalVisible(false);
|
||||
createForm.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetail = async (id: string) => {
|
||||
try {
|
||||
const openType = await getOpenType(id);
|
||||
setCurrentOpenType(openType);
|
||||
setDetailModalVisible(true);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '获取详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string) => {
|
||||
try {
|
||||
const openType = await getOpenType(id);
|
||||
setCurrentOpenType(openType);
|
||||
updateForm.setFieldsValue({
|
||||
open_type: openType.open_type,
|
||||
type_name: openType.type_name,
|
||||
description: openType.description,
|
||||
thumb: openType.thumb,
|
||||
});
|
||||
setUpdateModalVisible(true);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '获取详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveUpdate = async () => {
|
||||
if (!currentOpenType) return;
|
||||
try {
|
||||
const values = await updateForm.validateFields();
|
||||
await updateOpenType(currentOpenType.id, {
|
||||
open_type: values.open_type,
|
||||
type_name: values.type_name,
|
||||
description: values.description,
|
||||
thumb: values.thumb,
|
||||
});
|
||||
message.success('更新成功');
|
||||
setUpdateModalVisible(false);
|
||||
updateForm.resetFields();
|
||||
setCurrentOpenType(null);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个开户方式吗?',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteOpenType(id);
|
||||
message.success('删除成功');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '操作人', dataIndex: 'username', width: 120,
|
||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '操作', dataIndex: 'action', width: 160,
|
||||
title: 'ID', dataIndex: 'id', width: 100,
|
||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '方法', dataIndex: 'method', width: 80,
|
||||
render: (v: string) => <Tag color={METHOD_COLORS[v] || 'default'}>{v}</Tag>,
|
||||
title: '开户类型', dataIndex: 'open_type', width: 100,
|
||||
render: (v: number) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '路径', dataIndex: 'path', width: 220, ellipsis: true,
|
||||
title: '类型名称', dataIndex: 'type_name', width: 150,
|
||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '描述', dataIndex: 'description', width: 250, ellipsis: true,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '时间', dataIndex: 'createdAt', width: 160,
|
||||
title: '缩略图', dataIndex: 'thumb', width: 120,
|
||||
render: (v: string) => v ? <img src={v} alt="thumb" style={{ width: 80, height: 60, objectFit: 'cover' }} /> : '-',
|
||||
},
|
||||
{
|
||||
title: '创建时间', dataIndex: 'createdAt', width: 160,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '更新时间', dataIndex: 'updatedAt', width: 160,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '操作', width: 200,
|
||||
render: (_: any, record: OpenType) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<EyeOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleDetail(record.id)}
|
||||
>详情</Button>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleUpdate(record.id)}
|
||||
>更新</Button>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => handleDelete(record.id)}
|
||||
>删除</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} 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 }}>
|
||||
<Space>
|
||||
<HistoryOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>平台管理</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>平台开户方式管理</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setShowModal(true)}>新增</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>新增</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load()}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={logs}
|
||||
dataSource={openTypes}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: 20,
|
||||
pageSize,
|
||||
total,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
onChange: (p) => { setPage(p); load(p); },
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); load(p, ps); },
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="新增平台"
|
||||
open={showModal}
|
||||
onOk={() => {
|
||||
message.success('新增成功');
|
||||
setShowModal(false);
|
||||
setFormData({ title: '', description: '', image: '' });
|
||||
load();
|
||||
}}
|
||||
title="新增开户方式"
|
||||
open={createModalVisible}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => {
|
||||
setShowModal(false);
|
||||
setFormData({ title: '', description: '', image: '' });
|
||||
setCreateModalVisible(false);
|
||||
createForm.resetFields();
|
||||
}}
|
||||
okText="确认"
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<Typography.Text strong style={{ marginBottom: 8, display: 'block' }}>标题</Typography.Text>
|
||||
<Input
|
||||
placeholder="请输入标题"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
/>
|
||||
<Form form={createForm} layout="vertical">
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="open_type"
|
||||
label="开户方式Id"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入开户方式Id' }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} min={1} placeholder="请输入开户方式Id" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="type_name"
|
||||
label="类型名称"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入类型名称' }, { max: 100, message: '类型名称不能超过100个字符' }]}
|
||||
>
|
||||
<Input style={{ width: '100%' }} placeholder="请输入类型名称" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong style={{ marginBottom: 8, display: 'block' }}>描述</Typography.Text>
|
||||
<Input.TextArea
|
||||
placeholder="请输入描述"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong style={{ marginBottom: 8, display: 'block' }}>封面图片</Typography.Text>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
rules={[{ required: true, message: '请输入描述' }]}
|
||||
>
|
||||
<Input.TextArea placeholder="请输入描述" rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="thumb"
|
||||
label="缩略图"
|
||||
>
|
||||
<Upload
|
||||
action="/api/upload"
|
||||
listType="picture-card"
|
||||
onChange={(info) => {
|
||||
if (info.file.status === 'done') {
|
||||
setFormData(prev => ({ ...prev, image: info.file.response?.url || '' }));
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const res = await uploadImage(file as File);
|
||||
console.log(res);
|
||||
createForm.setFieldsValue({ thumb: res.url });
|
||||
onSuccess(res);
|
||||
} catch (e: any) {
|
||||
onError(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -147,8 +280,95 @@ const AdminPlatform: React.FC = () => {
|
||||
<div style={{ marginTop: 8 }}>上传图片</div>
|
||||
</div>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="开户方式详情"
|
||||
open={detailModalVisible}
|
||||
onCancel={() => {
|
||||
setDetailModalVisible(false);
|
||||
setCurrentOpenType(null);
|
||||
}}
|
||||
okText="关闭"
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
>
|
||||
{currentOpenType && (
|
||||
<div style={{ lineHeight: '2' }}>
|
||||
<p><strong>ID:</strong> {currentOpenType.id}</p>
|
||||
<p><strong>开户类型:</strong> {currentOpenType.open_type}</p>
|
||||
<p><strong>类型名称:</strong> {currentOpenType.type_name}</p>
|
||||
<p><strong>描述:</strong> {currentOpenType.description}</p>
|
||||
<p><strong>缩略图:</strong> {currentOpenType.thumb ? <img src={currentOpenType.thumb} alt="thumb" style={{ width: 120, height: 80, objectFit: 'cover' }} /> : '-'}</p>
|
||||
<p><strong>创建时间:</strong> {formatDate(currentOpenType.createdAt)}</p>
|
||||
<p><strong>更新时间:</strong> {formatDate(currentOpenType.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="更新开户方式"
|
||||
open={updateModalVisible}
|
||||
onOk={handleSaveUpdate}
|
||||
onCancel={() => {
|
||||
setUpdateModalVisible(false);
|
||||
updateForm.resetFields();
|
||||
setCurrentOpenType(null);
|
||||
}}
|
||||
okText="更新"
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
>
|
||||
<Form form={updateForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="open_type"
|
||||
label="开户类型"
|
||||
rules={[{ required: true, message: '请输入开户类型' }]}
|
||||
>
|
||||
<InputNumber min={1} placeholder="请输入开户类型编号" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="type_name"
|
||||
label="类型名称"
|
||||
rules={[{ required: true, message: '请输入类型名称' }, { max: 100, message: '类型名称不能超过100个字符' }]}
|
||||
>
|
||||
<Input placeholder="请输入类型名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
rules={[{ required: true, message: '请输入描述' }]}
|
||||
>
|
||||
<Input.TextArea placeholder="请输入描述" rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="thumb"
|
||||
label="缩略图"
|
||||
>
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
defaultFileList={currentOpenType?.thumb ? [{ uid: '1', name: 'thumb', status: 'done', url: currentOpenType.thumb }] : []}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const res = await uploadImage(file);
|
||||
updateForm.setFieldsValue({ thumb: res.url });
|
||||
onSuccess(res);
|
||||
} catch (e: any) {
|
||||
onError(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!updateForm.getFieldValue('thumb') && (
|
||||
<div>
|
||||
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
|
||||
<div style={{ marginTop: 8 }}>上传图片</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""上传日志表增加前测信息字段
|
||||
|
||||
Revision ID: a65cf38abbb8
|
||||
Revises: 5e2c124e5484
|
||||
Create Date: 2026-06-25 11:03:40.134713
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a65cf38abbb8'
|
||||
down_revision: Union[str, None] = '5e2c124e5484'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('upload_task', sa.Column('other_info', sa.String(length=500), nullable=True, comment='其他信息'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('upload_task', 'other_info')
|
||||
# ### end Alembic commands ###
|
||||
@@ -110,6 +110,7 @@ async def async_batch_upload_material(
|
||||
|
||||
target_source_model = source_model_map.get(task.source_model)
|
||||
|
||||
# 检查资源id是否存在,非资源id
|
||||
if target_source_model:
|
||||
query = (
|
||||
select(GeneratedResource.id)
|
||||
@@ -133,6 +134,7 @@ async def async_batch_upload_material(
|
||||
|
||||
resource_ids_to_upload = valid_resource_ids
|
||||
else:
|
||||
#用户提交的直接是资源id
|
||||
query = (
|
||||
select(GeneratedResource.id)
|
||||
.where(GeneratedResource.id.in_(task.resource_ids))
|
||||
@@ -156,6 +158,11 @@ async def async_batch_upload_material(
|
||||
|
||||
for advertiser_id in task.advertiser_ids:
|
||||
for resource_id in resource_ids_to_upload:
|
||||
other_info = {}
|
||||
if task.is_pre_test == "1":
|
||||
other_info["is_pre_test"] = task.is_pre_test
|
||||
other_info["pre_test_template"] = task.pre_test_template
|
||||
|
||||
task_id = generate_id()
|
||||
upload_task = UploadTask(
|
||||
id=task_id,
|
||||
@@ -165,6 +172,7 @@ async def async_batch_upload_material(
|
||||
resource_id=resource_id,
|
||||
status=1,
|
||||
note=None,
|
||||
other_info=json.dumps(other_info) if other_info else None,
|
||||
)
|
||||
|
||||
db.add(upload_task)
|
||||
|
||||
@@ -86,7 +86,7 @@ async def juliang_callback(
|
||||
|
||||
await get_token(auth_code, user_id, app_id, db)
|
||||
return {
|
||||
"message": "授权成功,这里需要跳转页面路径到 /user-oauth/oauth_list",
|
||||
"message": "授权成功",
|
||||
"code": 0,
|
||||
}
|
||||
|
||||
@@ -95,8 +95,11 @@ async def juliang_callback(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except HTTPException as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"授权失败: {str(e)}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -76,6 +76,10 @@ async def lifespan(app: FastAPI):
|
||||
from app.tasks.material_consumption_task import schedule_daily_sync
|
||||
consumption_schedule_task = asyncio.create_task(schedule_daily_sync())
|
||||
|
||||
# 启动前测结果轮询任务(每分钟检查一次)
|
||||
from app.tasks.pre_test_result_task import poll_pre_test_results
|
||||
pre_test_poll_task = asyncio.create_task(poll_pre_test_results())
|
||||
|
||||
# 启动时立即同步一次未支付订单
|
||||
asyncio.create_task(asyncio.sleep(5)) # 等待5秒后再同步,让系统完全启动
|
||||
async def startup_sync():
|
||||
@@ -103,6 +107,7 @@ async def lifespan(app: FastAPI):
|
||||
material_consumption_queue.stop()
|
||||
await consumption_queue_task
|
||||
consumption_schedule_task.cancel()
|
||||
pre_test_poll_task.cancel()
|
||||
expiry_task.cancel()
|
||||
token_refresh_task.cancel()
|
||||
await close_database()
|
||||
|
||||
@@ -27,4 +27,7 @@ class UploadTask(Base, TimestampMixin, SoftDeleteMixin):
|
||||
)
|
||||
oauth_id: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, index=True, comment="授权表id"
|
||||
)
|
||||
)
|
||||
other_info: Mapped[str | None] = mapped_column(
|
||||
String(500), nullable=True, comment="其他信息"
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import aliased
|
||||
from app.models.generated_resource import GeneratedResource
|
||||
from app.models.resources_material import ResourcesMaterial
|
||||
from app.schemas.resources_material import GeneratedResourceOut, ResourcesMaterialOut
|
||||
|
||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||
|
||||
async def get_resources_material_list(
|
||||
db: AsyncSession,
|
||||
@@ -69,10 +69,8 @@ async def get_resources_material_list(
|
||||
if generated_resource:
|
||||
resource = GeneratedResourceOut(
|
||||
file_name=generated_resource.file_name,
|
||||
resource_url=generated_resource.resource_url,
|
||||
remote_url=generated_resource.remote_url,
|
||||
resource_url = build_resource_signed_url(generated_resource.resource_url) if generated_resource.resource_url else "",
|
||||
storage_type=generated_resource.storage_type,
|
||||
storage_path=generated_resource.storage_path,
|
||||
file_size_bytes=generated_resource.file_size_bytes,
|
||||
source_model=generated_resource.source_model,
|
||||
source_model_module=generated_resource.source_model_module,
|
||||
|
||||
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.base import async_session
|
||||
from app.models.upload_task import UploadTask
|
||||
@@ -11,6 +12,7 @@ from app.models.generated_resource import GeneratedResource
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.models.resources_material import ResourcesMaterial
|
||||
from app.models.user_oauth_account import UserOAuthAccount
|
||||
from app.models.pre_test_template import PreTestTemplate
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.utils.douyinApi import DouyinApi
|
||||
|
||||
@@ -51,7 +53,7 @@ if not logger.handlers:
|
||||
|
||||
douyin_api = DouyinApi()
|
||||
|
||||
|
||||
#上传素材队列,处理上传素材的任务
|
||||
class UploadQueue:
|
||||
def __init__(self):
|
||||
self.queue: asyncio.Queue[str] = asyncio.Queue()
|
||||
@@ -124,7 +126,8 @@ class UploadQueue:
|
||||
task.oauth_id,
|
||||
task.advertiser_id,
|
||||
task.resource_id,
|
||||
db=None
|
||||
db=None,
|
||||
other_info=json.loads(task.other_info) if task.other_info else None,
|
||||
)
|
||||
|
||||
async with async_session() as db:
|
||||
@@ -215,11 +218,14 @@ async def _upload_single_material(
|
||||
oauth_id: str,
|
||||
advertiser_id: str,
|
||||
resource_id: str,
|
||||
db=None
|
||||
db=None,
|
||||
other_info=None,
|
||||
) -> dict:
|
||||
own_db = False
|
||||
if db is None:
|
||||
from app.models.base import async_session
|
||||
db = async_session()
|
||||
own_db = True
|
||||
|
||||
try:
|
||||
oauth = await db.execute(
|
||||
@@ -253,6 +259,7 @@ async def _upload_single_material(
|
||||
|
||||
resource_type = resource.resource_type
|
||||
storage_path = resource.storage_path
|
||||
file_name = resource.file_name
|
||||
|
||||
if resource_type not in ["image", "video"]:
|
||||
return {
|
||||
@@ -266,11 +273,22 @@ async def _upload_single_material(
|
||||
"error": "资源本地存储路径为空",
|
||||
}
|
||||
|
||||
return await _upload_to_juliang(
|
||||
oauth_id, storage_path, resource_type, advertiser_id, resource, db, user_id
|
||||
result = await _upload_to_juliang(
|
||||
oauth_id, storage_path, resource_type, advertiser_id, resource, db, user_id, file_name
|
||||
)
|
||||
|
||||
if result.get("success") and resource_type == "video" and other_info and "is_pre_test" in other_info and other_info["is_pre_test"] == "1" and "pre_test_template" in other_info:
|
||||
await _pre_test_material(
|
||||
oauth_id,
|
||||
advertiser_id,
|
||||
[result.get("upload_id")],
|
||||
other_info["pre_test_template"],
|
||||
db,
|
||||
)
|
||||
|
||||
return result
|
||||
finally:
|
||||
if db is not None:
|
||||
if own_db and db is not None:
|
||||
await db.close()
|
||||
|
||||
|
||||
@@ -282,8 +300,13 @@ async def _upload_to_juliang(
|
||||
resource: GeneratedResource,
|
||||
db,
|
||||
current_user_id: str,
|
||||
file_name: str,
|
||||
) -> dict:
|
||||
filename = os.path.basename(storage_path)
|
||||
#如果file_name不等于空,那么就是用file_name,否则用storage_path的文件名
|
||||
if file_name:
|
||||
filename = file_name
|
||||
else:
|
||||
filename = os.path.basename(storage_path)
|
||||
|
||||
if resource_type == "image":
|
||||
if resource.file_size_bytes > 5 * 1024 * 1024:
|
||||
@@ -462,4 +485,172 @@ async def _upload_to_juliang(
|
||||
}
|
||||
|
||||
|
||||
async def _pre_test_material(
|
||||
oauth_id: str,
|
||||
advertiser_id: str,
|
||||
video_ids: list[str],
|
||||
pre_test_template_id: str,
|
||||
db: AsyncSession,
|
||||
) -> any:
|
||||
oauth = await db.execute(
|
||||
select(UserOAuth).where(
|
||||
UserOAuth.id == oauth_id,
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
oauth = oauth.scalar_one_or_none()
|
||||
if not oauth:
|
||||
note = "授权记录不存在"
|
||||
for video_id in video_ids:
|
||||
await _update_material_pre_test_status(
|
||||
db, oauth_id, advertiser_id, video_id,
|
||||
task_id=None,
|
||||
status="FAILED",
|
||||
note=note,
|
||||
pre_result=None,
|
||||
pre_test_template_id=pre_test_template_id,
|
||||
)
|
||||
await db.commit()
|
||||
return {"code": -1, "message": note, "data": {}}
|
||||
|
||||
pre_test_template = await db.execute(
|
||||
select(PreTestTemplate).where(
|
||||
PreTestTemplate.id == pre_test_template_id,
|
||||
PreTestTemplate.deleted_at.is_(None),
|
||||
PreTestTemplate.user_id == oauth.user_id,
|
||||
)
|
||||
)
|
||||
pre_test_template = pre_test_template.scalar_one_or_none()
|
||||
if not pre_test_template:
|
||||
note = f"前测模板 {pre_test_template_id} 不存在"
|
||||
for video_id in video_ids:
|
||||
await _update_material_pre_test_status(
|
||||
db, oauth_id, advertiser_id, video_id,
|
||||
task_id=None,
|
||||
status="FAILED",
|
||||
note=note,
|
||||
pre_result=None,
|
||||
pre_test_template_id=pre_test_template_id,
|
||||
)
|
||||
await db.commit()
|
||||
return {"code": -1, "message": note, "data": {}}
|
||||
|
||||
diagnose_config = {}
|
||||
if pre_test_template.platform:
|
||||
diagnose_config["platform"] = pre_test_template.platform
|
||||
if pre_test_template.external_action:
|
||||
diagnose_config["external_action"] = pre_test_template.external_action
|
||||
if pre_test_template.cpa_bid:
|
||||
diagnose_config["cpa_bid"] = pre_test_template.cpa_bid
|
||||
if pre_test_template.audience_gender:
|
||||
diagnose_config["audience_gender"] = pre_test_template.audience_gender
|
||||
if pre_test_template.audience_age:
|
||||
diagnose_config["audience_age"] = json.loads(pre_test_template.audience_age)
|
||||
if pre_test_template.audience_region:
|
||||
diagnose_config["audience_region"] = json.loads(pre_test_template.audience_region)
|
||||
if pre_test_template.audience_network:
|
||||
diagnose_config["audience_network"] = json.loads(pre_test_template.audience_network)
|
||||
if pre_test_template.cus_name:
|
||||
diagnose_config["cus_name"] = pre_test_template.cus_name
|
||||
if pre_test_template.pricing_type:
|
||||
diagnose_config["pricing_type"] = pre_test_template.pricing_type
|
||||
if pre_test_template.cost_cap:
|
||||
diagnose_config["cost_cap"] = pre_test_template.cost_cap
|
||||
if pre_test_template.target_cost:
|
||||
diagnose_config["target_cost"] = pre_test_template.target_cost
|
||||
if pre_test_template.nobid:
|
||||
diagnose_config["nobid"] = pre_test_template.nobid
|
||||
if pre_test_template.cpc_bid:
|
||||
diagnose_config["cpc_bid"] = pre_test_template.cpc_bid
|
||||
if pre_test_template.budget:
|
||||
diagnose_config["budget"] = pre_test_template.budget
|
||||
|
||||
params = {
|
||||
"advertiser_id": int(advertiser_id),
|
||||
"video_ids": video_ids,
|
||||
"diagnose_config": diagnose_config,
|
||||
}
|
||||
response = await douyin_api.pre_test_material(oauth_id, params)
|
||||
|
||||
code = response.get("code", -1)
|
||||
|
||||
if code != 0:
|
||||
note = response.get("message", "未知错误")
|
||||
for video_id in video_ids:
|
||||
await _update_material_pre_test_status(
|
||||
db, oauth_id, advertiser_id, video_id,
|
||||
task_id=None,
|
||||
status="FAILED",
|
||||
note=note,
|
||||
pre_result=None,
|
||||
pre_test_template_id=pre_test_template_id,
|
||||
)
|
||||
await db.commit()
|
||||
return response
|
||||
|
||||
data = response.get("data", {})
|
||||
task_ids = data.get("task_ids", [])
|
||||
fail_video_ids = data.get("fail_video_ids", {})
|
||||
|
||||
success_count = 0
|
||||
for i, video_id in enumerate(video_ids):
|
||||
if video_id in fail_video_ids:
|
||||
fail_info = fail_video_ids[video_id]
|
||||
err_code = fail_info.get("err_code", "")
|
||||
err_message = fail_info.get("err_message", "未知错误")
|
||||
note = f"失败[{err_code}]: {err_message}"
|
||||
|
||||
await _update_material_pre_test_status(
|
||||
db, oauth_id, advertiser_id, video_id,
|
||||
task_id=None,
|
||||
status="FAILED",
|
||||
note=note,
|
||||
pre_result=None,
|
||||
pre_test_template_id=pre_test_template_id,
|
||||
)
|
||||
else:
|
||||
task_id = str(task_ids[success_count]) if success_count < len(task_ids) else None
|
||||
|
||||
await _update_material_pre_test_status(
|
||||
db, oauth_id, advertiser_id, video_id,
|
||||
task_id=task_id,
|
||||
status="PENDING",
|
||||
note="",
|
||||
pre_result=None,
|
||||
pre_test_template_id=pre_test_template_id,
|
||||
)
|
||||
success_count += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def _update_material_pre_test_status(
|
||||
db: AsyncSession,
|
||||
oauth_id: str,
|
||||
advertiser_id: str,
|
||||
upload_id: str,
|
||||
task_id: str | None,
|
||||
status: str,
|
||||
note: str,
|
||||
pre_result: str | None,
|
||||
pre_test_template_id: str | None,
|
||||
):
|
||||
await db.execute(
|
||||
update(ResourcesMaterial).where(
|
||||
ResourcesMaterial.oauth_id == oauth_id,
|
||||
ResourcesMaterial.advertiser_id == advertiser_id,
|
||||
ResourcesMaterial.upload_id == upload_id,
|
||||
ResourcesMaterial.deleted_at.is_(None),
|
||||
).values(
|
||||
task_id=task_id,
|
||||
status=status,
|
||||
note=note,
|
||||
pre_result=pre_result,
|
||||
pre_test_template_id=pre_test_template_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
upload_queue = UploadQueue()
|
||||
@@ -0,0 +1,154 @@
|
||||
from datetime import datetime, timezone
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.base import async_session
|
||||
from app.models.resources_material import ResourcesMaterial
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.utils.douyinApi import DouyinApi
|
||||
|
||||
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
#前测结果和上传素材属于一种任务,放到一起日志里边
|
||||
logger = logging.getLogger("upload_queue")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class DailyRotatingFileHandler(logging.FileHandler):
|
||||
def __init__(self, directory, encoding=None):
|
||||
self.directory = directory
|
||||
filename = self._get_log_filename()
|
||||
super().__init__(filename, encoding=encoding)
|
||||
|
||||
def _get_log_filename(self):
|
||||
return os.path.join(self.directory, f"pre_test_result_task-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.log")
|
||||
|
||||
def emit(self, record):
|
||||
current_filename = self._get_log_filename()
|
||||
if self.baseFilename != current_filename:
|
||||
self.close()
|
||||
self.baseFilename = current_filename
|
||||
self.stream = self._open()
|
||||
super().emit(record)
|
||||
|
||||
|
||||
if not logger.handlers:
|
||||
handler = DailyRotatingFileHandler(LOG_DIR, encoding="utf-8")
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S"))
|
||||
logger.addHandler(handler)
|
||||
|
||||
douyin_api = DouyinApi()
|
||||
|
||||
#获取前测结果并更新数据库,计划任务,每2分钟执行一次
|
||||
async def poll_pre_test_results():
|
||||
"""每2分钟轮询前测结果并更新数据库"""
|
||||
logger.info("Pre-test result polling task started")
|
||||
|
||||
while True:
|
||||
try:
|
||||
await process_pending_pre_tests()
|
||||
await asyncio.sleep(120)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Pre-test result polling task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in poll_pre_test_results: {e}")
|
||||
await asyncio.sleep(120)
|
||||
|
||||
|
||||
async def process_pending_pre_tests():
|
||||
"""处理所有待查询的前测任务"""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(ResourcesMaterial).where(
|
||||
ResourcesMaterial.status == "PENDING",
|
||||
ResourcesMaterial.task_id.is_not(None),
|
||||
ResourcesMaterial.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
pending_materials = result.scalars().all()
|
||||
|
||||
if not pending_materials:
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(pending_materials)} pending pre-test tasks to process")
|
||||
|
||||
for material in pending_materials:
|
||||
try:
|
||||
await update_single_pre_test_result(db, material)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing pre-test for material {material.id}: {e}")
|
||||
|
||||
|
||||
async def update_single_pre_test_result(db: AsyncSession, material: ResourcesMaterial):
|
||||
"""更新单个素材的前测结果"""
|
||||
oauth = await db.execute(
|
||||
select(UserOAuth).where(
|
||||
UserOAuth.id == material.oauth_id,
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
oauth = oauth.scalar_one_or_none()
|
||||
|
||||
if not oauth:
|
||||
logger.error(f"OAuth record not found for material {material.id}")
|
||||
await db.execute(
|
||||
update(ResourcesMaterial).where(ResourcesMaterial.id == material.id).values(
|
||||
status="FAILED",
|
||||
note="授权记录不存在",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
params = {
|
||||
"advertiser_id": int(material.advertiser_id),
|
||||
"task_ids": json.dumps([int(material.task_id)]),
|
||||
}
|
||||
|
||||
try:
|
||||
response = await douyin_api.get_material_pre_test_result(oauth.id, params)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get pre-test result for task {material.task_id}: {e}")
|
||||
return
|
||||
|
||||
code = response.get("code", -1)
|
||||
if code != 0:
|
||||
logger.error(f"API error for task {material.task_id}: {response.get('message', 'Unknown error')}")
|
||||
return
|
||||
|
||||
data = response.get("data", {})
|
||||
task_details = data.get("task_list", [])
|
||||
|
||||
if not task_details:
|
||||
return
|
||||
|
||||
task_detail = task_details[0]
|
||||
status = task_detail.get("status")
|
||||
pre_result = {
|
||||
"video_id": task_detail.get("video_id") or None,
|
||||
"advertiser_id": task_detail.get("advertiser_id") or None,
|
||||
"material_id": task_detail.get("material_id") or None,
|
||||
"is_ad_high_quality_material": task_detail.get("is_ad_high_quality_material") or None,
|
||||
"is_ecp_high_quality_material": task_detail.get("is_ecp_high_quality_material") or None,
|
||||
"is_inefficient_material": task_detail.get("is_inefficient_material") or None,
|
||||
"is_first_publish_material": task_detail.get("is_first_publish_material") or None,
|
||||
"not_ad_high_quality_reason": task_detail.get("not_ad_high_quality_reason") or None,
|
||||
"not_ecp_high_quality_reason": task_detail.get("not_ecp_high_quality_reason") or None,
|
||||
"is_local_high_quality_material": task_detail.get("is_local_high_quality_material") or None,
|
||||
}
|
||||
|
||||
await db.execute(
|
||||
update(ResourcesMaterial).where(ResourcesMaterial.id == material.id).values(
|
||||
status=status,
|
||||
pre_result=json.dumps(pre_result, ensure_ascii=False),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
@@ -97,6 +97,18 @@ class DouyinApi:
|
||||
if not oauth_id:
|
||||
raise RuntimeError('OAuth ID is not set.')
|
||||
url = "https://api.oceanengine.com/open_api/2/agent/advertiser_info/query/"
|
||||
return await self.request.request_with_token_with_context(
|
||||
oauth_id,
|
||||
url,
|
||||
'GET',
|
||||
{'params': params or {}}
|
||||
)
|
||||
|
||||
#获取素材前测结果
|
||||
async def get_material_pre_test_result(self, oauth_id: str, params: any) -> Dict[str, Any]:
|
||||
if not oauth_id:
|
||||
raise RuntimeError('OAuth ID is not set.')
|
||||
url = "https://api.oceanengine.com/open_api/2/diagnosis_task/adv/get/"
|
||||
return await self.request.request_with_token_with_context(
|
||||
oauth_id,
|
||||
url,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
VITE_API_BASE=http://192.168.120.17:8000
|
||||
# VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
|
||||
|
||||
# VITE_API_BASE=http://192.168.120.17:8000
|
||||
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
|
||||
VITE_USE_MOCK=false
|
||||
# Encryption disabled for dev — enable in production
|
||||
VITE_ENCRYPTION_KEY=
|
||||
|
||||
|
Before Width: | Height: | Size: 4.9 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 4.6 MiB |
|
After Width: | Height: | Size: 459 KiB |
|
Before Width: | Height: | Size: 3.2 MiB |
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BwL6llN9.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DUhIsGse.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-eLRg4pQk.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@types/three": "^0.184.1",
|
||||
"antd": "^6.3.7",
|
||||
"dayjs": "^1.11.20",
|
||||
"jszip": "^3.10.1",
|
||||
"plyr-react": "^6.0.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
@@ -2501,6 +2502,12 @@
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3083,6 +3090,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -3093,6 +3106,12 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -3161,6 +3180,12 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -3241,6 +3266,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -3265,6 +3302,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -3770,6 +3816,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4040,6 +4092,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -4179,6 +4237,21 @@
|
||||
"pify": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -4303,6 +4376,12 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -4334,6 +4413,12 @@
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -4367,6 +4452,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-convert": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
|
||||
@@ -4659,7 +4753,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@types/three": "^0.184.1",
|
||||
"antd": "^6.3.7",
|
||||
"dayjs": "^1.11.20",
|
||||
"jszip": "^3.10.1",
|
||||
"plyr-react": "^6.0.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
|
||||
@@ -19,6 +19,7 @@ import RemoveLens from './pages/RemoveLens';
|
||||
import GeneratedRecord from './pages/GeneratedRecord';
|
||||
import PreTest from './pages/PreTest';
|
||||
import AuthorizationPage from './pages/AuthorizationPage';
|
||||
import MaterialListPage from './pages/MaterialListPage';
|
||||
import RemoveInfo from './pages/RemoveInfo';
|
||||
import RemoveRw from './pages/RemoveRw';
|
||||
// import RemoveFenbu from './pages/RemoveFenbu';
|
||||
@@ -108,6 +109,7 @@ const App = () => {
|
||||
<Route path="generated" element={<GeneratedRecord />} />
|
||||
<Route path="pretest" element={<PreTest />} />
|
||||
<Route path="authorization" element={<AuthorizationPage />} />
|
||||
<Route path="materials" element={<MaterialListPage />} />
|
||||
<Route path="consume" element={<ConsumePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||
|
||||
@@ -616,3 +616,66 @@ export async function getUploadHistory(params: UploadHistoryParams): Promise<any
|
||||
const query = searchParams.toString();
|
||||
return api.get(`/upload-material/upload-history${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export interface UploadFilenames {
|
||||
source_id: string;
|
||||
file_name: string;
|
||||
}
|
||||
export interface UpdateFilenameParams {
|
||||
filenames: UploadFilenames[];
|
||||
}
|
||||
// 上传文件名
|
||||
export async function updateFilename(params: UpdateFilenameParams): Promise<any> {
|
||||
return api.post('/upload-material/batch-update-filename', params);
|
||||
}
|
||||
|
||||
// 查询素材消耗列表
|
||||
export interface MaterialConsumpListParams {
|
||||
advertiser_id?: string;
|
||||
consume_date?: [string, string];
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
export async function getMaterialConsumpList(params: MaterialConsumpListParams): Promise<any> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.advertiser_id) query.set('advertiser_id', params.advertiser_id);
|
||||
if (params.consume_date !== undefined) query.set('consume_date', params.consume_date[0] + ',' + params.consume_date[1]);
|
||||
if (params.page !== undefined) query.set('page', String(params.page));
|
||||
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||
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;
|
||||
upload_id?: string;
|
||||
file_name?: string;
|
||||
resource_type?: string; // image或者video
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
export async function getResourcesMaterialList(params: ResourcesMaterialListParams): Promise<any> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.advertiser_id) query.set('advertiser_id', params.advertiser_id);
|
||||
if (params.material_id) query.set('material_id', params.material_id);
|
||||
if (params.upload_id) query.set('upload_id', params.upload_id);
|
||||
if (params.file_name) query.set('file_name', params.file_name);
|
||||
if (params.resource_type) query.set('resource_type', params.resource_type);
|
||||
if (params.page !== undefined) query.set('page', String(params.page));
|
||||
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||
return api.get(`/resources-material/list?${query.toString()}`);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.9 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 459 KiB |
@@ -243,6 +243,7 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
|
||||
const abortRef = useRef(false);
|
||||
const rangeRef = useRef<[number, number]>([0, minDuration]);
|
||||
const currentTimeRef = useRef(0);
|
||||
const frameContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
@@ -380,8 +381,13 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
|
||||
const handleRangeChange = useCallback((value: number[]) => {
|
||||
if (!integerDuration) return;
|
||||
setLocalError('');
|
||||
setRange((prev) => normalizeRange([Number(value[0]), Number(value[1])], prev, integerDuration, minDuration, maxDuration));
|
||||
}, [integerDuration, maxDuration, minDuration, setRange]);
|
||||
const nextRange = [Number(value[0]), Number(value[1])] as [number, number];
|
||||
const clamped = [
|
||||
Math.max(0, Math.min(integerDuration, nextRange[0])),
|
||||
Math.max(nextRange[0], Math.min(integerDuration, nextRange[1])),
|
||||
] as [number, number];
|
||||
setRange(clamped);
|
||||
}, [integerDuration, setRange]);
|
||||
|
||||
const handleRangeChangeComplete = useCallback(() => {
|
||||
seekPreview(rangeRef.current[0]);
|
||||
@@ -534,114 +540,139 @@ const VideoTrimPicker: React.FC<VideoTrimPickerProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', padding: '0 6px' }}>
|
||||
<div
|
||||
ref={frameContainerRef}
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
padding: '0 6px',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
div::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${Math.max(frames.length, 1)}, minmax(0, 1fr))`,
|
||||
height: 86,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
position: 'relative',
|
||||
gap: 1,
|
||||
margin: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: frames.length > 0 ? `${frames.length * 80 + Math.max(0, frames.length - 1) * 1}px` : '100%',
|
||||
}}
|
||||
>
|
||||
{frameLoading && frames.length === 0 ? (
|
||||
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="small" />
|
||||
<Text style={{ marginLeft: 8, color: '#64748b' }}>正在抽取每秒帧...</Text>
|
||||
</div>
|
||||
) : frames.length > 0 ? (
|
||||
frames.map((frame) => {
|
||||
const isSelected = frame.second >= range[0] && frame.second < range[1];
|
||||
return (
|
||||
<button
|
||||
key={frame.second}
|
||||
type="button"
|
||||
onClick={() => handleFrameClick(frame.second)}
|
||||
title={`${frame.second}s${frame.fallback ? `,实际取帧 ${frame.captureSecond?.toFixed(1)}s` : ''}`}
|
||||
style={{
|
||||
minWidth: 0,
|
||||
height: 86,
|
||||
borderTop: isSelected ? '5px solid #6366f1' : 'none',
|
||||
borderBottom: isSelected ? '5px solid #6366f1' : 'none',
|
||||
padding: 0,
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
cursor: disabledByDuration ? 'not-allowed' : 'pointer',
|
||||
position: 'relative',
|
||||
// borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{frame.status === 'success' && frame.image ? (
|
||||
<img src={frame.image} alt={`${frame.second}s`} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : frame.status === 'loading' ? (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#64748b', fontSize: 12, background: '#fff' }}>
|
||||
<span>{frame.second}s</span>
|
||||
<span style={{ fontSize: 11 }}>无有效帧</span>
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: 3,
|
||||
color: '#ffffffff',
|
||||
fontSize: 10,
|
||||
textShadow: '0 1px 3px rgba(0,0,0,.7)',
|
||||
}}
|
||||
>
|
||||
{frame.second}s
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div style={{ gridColumn: '1 / -1', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748b' }}>
|
||||
暂无帧预览,请确认视频是否加载完成
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'absolute', top: -6, left: 6, right: 0, }}>
|
||||
<Slider
|
||||
range
|
||||
min={0}
|
||||
max={integerDuration || maxDuration}
|
||||
step={1}
|
||||
value={range}
|
||||
onChange={handleRangeChange}
|
||||
onChangeComplete={handleRangeChangeComplete}
|
||||
tooltip={{ formatter: (value) => `${toIntegerSecond(Number(value || 0))}s` }}
|
||||
disabled={!integerDuration || disabledByDuration}
|
||||
styles={{
|
||||
track: {
|
||||
background: '#6969dd63',
|
||||
height: 70,
|
||||
borderRadius: 3,
|
||||
margin: '0 ',
|
||||
},
|
||||
rail: {
|
||||
// background: '#0e447e70',
|
||||
height: 70,
|
||||
borderRadius: 3,
|
||||
},
|
||||
handle: {
|
||||
width: 20,
|
||||
height: 70,
|
||||
marginTop: 0,
|
||||
// backgroundColor: '#fff',
|
||||
// border: '2px solid #6366f1',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: 86,
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
gap: 1,
|
||||
position: 'relative',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{frameLoading && frames.length === 0 ? (
|
||||
<div style={{ flex: '1 1 auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="small" />
|
||||
<Text style={{ marginLeft: 8, color: '#64748b' }}>正在抽取每秒帧...</Text>
|
||||
</div>
|
||||
) : frames.length > 0 ? (
|
||||
frames.map((frame) => {
|
||||
const isSelected = frame.second >= range[0] && frame.second < range[1];
|
||||
return (
|
||||
<button
|
||||
key={frame.second}
|
||||
type="button"
|
||||
onClick={() => handleFrameClick(frame.second)}
|
||||
title={`${frame.second}s${frame.fallback ? `,实际取帧 ${frame.captureSecond?.toFixed(1)}s` : ''}`}
|
||||
style={{
|
||||
minWidth: 80,
|
||||
flex: '0 0 80px',
|
||||
height: 86,
|
||||
borderTop: isSelected ? '5px solid #6366f1' : 'none',
|
||||
borderBottom: isSelected ? '5px solid #6366f1' : 'none',
|
||||
padding: 0,
|
||||
background: 'transparent',
|
||||
overflow: 'hidden',
|
||||
cursor: disabledByDuration ? 'not-allowed' : 'pointer',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{frame.status === 'success' && frame.image ? (
|
||||
<img src={frame.image} alt={`${frame.second}s`} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||
) : frame.status === 'loading' ? (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#64748b', fontSize: 12, background: '#fff' }}>
|
||||
<span>{frame.second}s</span>
|
||||
<span style={{ fontSize: 11 }}>无有效帧</span>
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: 3,
|
||||
color: '#ffffffff',
|
||||
fontSize: 16,
|
||||
textShadow: '0 1px 3px rgba(0,0,0,.7)',
|
||||
}}
|
||||
>
|
||||
{frame.second}s
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div style={{ flex: '1 1 auto', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748b' }}>
|
||||
暂无帧预览,请确认视频是否加载完成
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<Slider
|
||||
range
|
||||
min={0}
|
||||
max={integerDuration || maxDuration}
|
||||
step={1}
|
||||
value={range}
|
||||
onChange={handleRangeChange}
|
||||
onChangeComplete={handleRangeChangeComplete}
|
||||
tooltip={{ formatter: (value) => `${toIntegerSecond(Number(value || 0))}s` }}
|
||||
disabled={!integerDuration || disabledByDuration}
|
||||
styles={{
|
||||
track: {
|
||||
background: '#6969dd63',
|
||||
height: 86,
|
||||
borderRadius: 12,
|
||||
margin: '0 ',
|
||||
},
|
||||
rail: {
|
||||
height: 86,
|
||||
borderRadius: 12,
|
||||
},
|
||||
handle: {
|
||||
width: 20,
|
||||
height: 86,
|
||||
marginTop: 0,
|
||||
borderRadius: '50%',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Table, Tag, Modal, Select, App, Input, Pagination, Typography } from 'antd';
|
||||
import { PlusOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { getOAuthList, juliang_callback, requestOAuth } from '../api';
|
||||
import { getOAuthList, requestOAuth } from '../api';
|
||||
|
||||
const OPEN_TYPE_MAP: Record<number, string> = {
|
||||
1: '千川',
|
||||
|
||||
@@ -1,74 +1,86 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Tag, Button, Pagination, Typography } from 'antd';
|
||||
import { ArrowLeftOutlined, DollarOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 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 mockConsumptionRecords: ConsumptionRecord[] = [
|
||||
{ id: 'C001', authorizationId: '1867060028363785', amount: 100, type: 'video', description: '视频生成消耗', createdAt: '2024-01-15 10:30:00' },
|
||||
{ id: 'C002', authorizationId: '1867060028363785', amount: 50, type: 'audio', description: '音频转换消耗', createdAt: '2024-01-15 11:20:00' },
|
||||
{ id: 'C003', authorizationId: '1867059808785418', amount: 200, type: 'video', description: '视频生成消耗', createdAt: '2024-01-14 14:45:00' },
|
||||
{ id: 'C004', authorizationId: '1867060028363785', amount: 75, type: 'image', description: '图片处理消耗', createdAt: '2024-01-14 09:15:00' },
|
||||
{ id: 'C005', authorizationId: '1867059757929740', amount: 150, type: 'video', description: '视频生成消耗', createdAt: '2024-01-13 16:00:00' },
|
||||
];
|
||||
|
||||
// 消耗类型配置
|
||||
const consumptionTypeConfig = {
|
||||
video: { label: '视频生成', color: 'blue' },
|
||||
audio: { label: '音频转换', color: 'purple' },
|
||||
image: { label: '图片处理', color: 'green' },
|
||||
};
|
||||
|
||||
// 表头配置
|
||||
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 as keyof typeof consumptionTypeConfig];
|
||||
return <Tag color={config?.color}>{config?.label}</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 ConsumePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [consumptionRecords, setConsumptionRecords] = useState<ConsumptionRecord[]>(mockConsumptionRecords);
|
||||
const [searchParams] = useSearchParams();
|
||||
const { message } = App.useApp();
|
||||
const [consumptionRecords, setConsumptionRecords] = useState<ConsumptionRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = 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 [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(() => {
|
||||
setLoading(true);
|
||||
// 模拟异步获取数据
|
||||
setTimeout(() => {
|
||||
setConsumptionRecords(mockConsumptionRecords);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
loadColumns();
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async (page = 1, pageSizeNum = 10) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getMaterialConsumpList({
|
||||
advertiser_id: advertiserId || undefined,
|
||||
consume_date: consumeDateRange,
|
||||
page,
|
||||
page_size: pageSizeNum,
|
||||
});
|
||||
setConsumptionRecords(res.data || []);
|
||||
setTotal(res.pagination?.total || 0);
|
||||
setCurrentPage(res.pagination?.page || 1);
|
||||
setPageSize(res.pagination?.pageSize || 10);
|
||||
} catch (e: any) {
|
||||
message.error('加载数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
index: index + 1,
|
||||
@@ -79,19 +91,129 @@ 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', 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}
|
||||
columns={dynamicColumns}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
@@ -100,16 +222,181 @@ const ConsumePage: React.FC = () => {
|
||||
/>
|
||||
<div style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<Pagination
|
||||
pageSize={10}
|
||||
total={consumptionRecords.length}
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger
|
||||
showTotal={(total) => `共 ${total} 条记录`}
|
||||
onChange={(page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(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>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConsumePage;
|
||||
export default ConsumePage;
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
uploadVideo, getCreditRatios, deleteHistory, calculateCredits
|
||||
} from '../api';
|
||||
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
|
||||
import {
|
||||
PlusOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
@@ -112,9 +114,31 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
|
||||
const [mediaType, setMediaType] = useState<string>('image');
|
||||
|
||||
const [countType, setCountType] = useState<string>('请选择');
|
||||
// 从 store 读取生成配置状态
|
||||
const {
|
||||
mediaType,
|
||||
countType,
|
||||
selectedRatio,
|
||||
selectedResolution,
|
||||
width,
|
||||
height,
|
||||
videoDuration,
|
||||
videoAspectRatio,
|
||||
videoResolution,
|
||||
engineOptions,
|
||||
enginesele,
|
||||
setMediaType,
|
||||
setCountType,
|
||||
setSelectedRatio,
|
||||
setSelectedResolution,
|
||||
setWidth,
|
||||
setHeight,
|
||||
setVideoDuration,
|
||||
setVideoAspectRatio,
|
||||
setVideoResolution,
|
||||
setEngineOptions,
|
||||
setEnginesele,
|
||||
} = useAppStore();
|
||||
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
|
||||
@@ -183,11 +207,6 @@ const AIChatPage: React.FC = () => {
|
||||
const [attachmentPopupMessageId, setAttachmentPopupMessageId] = useState<string | null>(null);
|
||||
const [attachmentPopupPosition, setAttachmentPopupPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
// 图片设置相关状态
|
||||
const [selectedRatio, setSelectedRatio] = useState<string>('1:1');
|
||||
const [selectedResolution, setSelectedResolution] = useState<string>('2K');
|
||||
const [width, setWidth] = useState<number>(2048);
|
||||
const [height, setHeight] = useState<number>(2048);
|
||||
const [showImageSettingsModal, setShowImageSettingsModal] = useState(false);
|
||||
const [showVideoSettingsModal, setShowVideoSettingsModal] = useState(false);
|
||||
const [ratioOptions, setRatioOptions] = useState([]);
|
||||
@@ -196,22 +215,8 @@ const AIChatPage: React.FC = () => {
|
||||
const [blindex, setBlindex] = useState<any>(0);
|
||||
const [fblindex, setFBlindex] = useState<any>(0);
|
||||
|
||||
// 视频设置相关状态
|
||||
const [videoDuration, setVideoDuration] = useState(5);
|
||||
const [videoAspectRatio, setVideoAspectRatio] = useState<string>('16:9');
|
||||
const [videoResolution, setVideoResolution] = useState<string>('720p');
|
||||
const [showEngineModal, setShowEngineModal] = useState(false);
|
||||
const [engineOptions, setEngineOptions] = useState<{
|
||||
ratios: string[];
|
||||
resolutions: string[];
|
||||
durations: number[];
|
||||
}>({
|
||||
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
resolutions: ['480p', '720p', '1080p'],
|
||||
durations: [5, 8, 10, 12, 15],
|
||||
});
|
||||
|
||||
const [enginesele, setEnginesele] = useState<any>([]);
|
||||
const [creditRatios, setCreditRatios] = useState<any[]>([]);
|
||||
const [cimage, setCimage] = useState<any[]>([]);
|
||||
const [creditCalculationData, setCreditCalculationData] = useState<any[]>([]);
|
||||
@@ -359,23 +364,33 @@ const AIChatPage: React.FC = () => {
|
||||
// 如果是视频模式,初始化视频参数选项
|
||||
if (data.engine.video && data.engine.video.length > 0) {
|
||||
const defaultEngine = data.engine.video[0];
|
||||
|
||||
// 查找用户之前选择的引擎(如果存在)
|
||||
const savedEngine = data.engine.video.find((e: any) => e.id === countType);
|
||||
const targetEngine = savedEngine || defaultEngine;
|
||||
|
||||
// 更新引擎选项为当前引擎支持的参数
|
||||
setEngineOptions({
|
||||
ratios: defaultEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
resolutions: defaultEngine.supportedResolutions || ['480p', '720p', '1080p'],
|
||||
durations: defaultEngine.supportedDurations || [5, 8, 10, 12, 15],
|
||||
ratios: targetEngine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
resolutions: targetEngine.supportedResolutions || ['480p', '720p', '1080p'],
|
||||
durations: targetEngine.supportedDurations || [5, 8, 10, 12, 15],
|
||||
});
|
||||
// 设置默认选中值
|
||||
if (defaultEngine.supportedRatios?.length > 0) {
|
||||
setVideoAspectRatio(defaultEngine.supportedRatios[0]);
|
||||
|
||||
// 确保视频参数在引擎支持的范围内
|
||||
if (!targetEngine.supportedRatios?.includes(videoAspectRatio)) {
|
||||
setVideoAspectRatio(targetEngine.supportedRatios?.[0] || '16:9');
|
||||
}
|
||||
if (defaultEngine.supportedResolutions?.length > 0) {
|
||||
setVideoResolution(defaultEngine.supportedResolutions[0]);
|
||||
if (!targetEngine.supportedResolutions?.includes(videoResolution)) {
|
||||
setVideoResolution(targetEngine.supportedResolutions?.[0] || '720p');
|
||||
}
|
||||
if (defaultEngine.supportedDurations?.length > 0) {
|
||||
setVideoDuration(defaultEngine.supportedDurations[0]);
|
||||
if (!targetEngine.supportedDurations?.includes(videoDuration)) {
|
||||
setVideoDuration(targetEngine.supportedDurations?.[0] || 5);
|
||||
}
|
||||
|
||||
// 如果之前没有选择过引擎(还是默认值),才设置默认引擎
|
||||
if (countType === '请选择') {
|
||||
setCountType(targetEngine.id);
|
||||
}
|
||||
// 设置默认引擎
|
||||
setCountType(defaultEngine.id);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useState, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { Button, Empty, Input, Select, Space, Typography, Tag, message, Upload, Modal, Progress, Table, DatePicker } from 'antd';
|
||||
import { Button, Empty, Input, Select, Space, Typography, Tag, message, Modal, Table, DatePicker } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import JSZip from 'jszip';
|
||||
import {
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
VideoCameraOutlined,
|
||||
PictureOutlined,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
UploadOutlined,
|
||||
|
||||
} from '@ant-design/icons';
|
||||
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, getUploadHistory } from '../api';
|
||||
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, updateFilename, getUploadHistory } from '../api';
|
||||
|
||||
const { Search } = Input;
|
||||
const { Text } = Typography;
|
||||
@@ -48,16 +48,13 @@ const GeneratedRecord: React.FC = () => {
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
const [oauthTotal, setOauthTotal] = useState(0);
|
||||
const [selectedOauthItems, setSelectedOauthItems] = useState<{ value: string; label: string } | undefined>(undefined);
|
||||
const [materialFileNames, setMaterialFileNames] = useState<Map<string, string>>(new Map());
|
||||
const [unifiedFileName, setUnifiedFileName] = useState('');
|
||||
const updateFilenameDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [oauthPage, setOauthPage] = useState(1);
|
||||
const [oauthPageSize, setOauthPageSize] = useState(10);
|
||||
const [oauthSelectOpen, setOauthSelectOpen] = useState(false);
|
||||
|
||||
const [batchUploadProgress, setBatchUploadProgress] = useState<{
|
||||
itemId: string;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
message: string;
|
||||
}[]>([]);
|
||||
|
||||
// 上传任务历史弹窗相关状态
|
||||
const [uploadHistoryModalVisible, setUploadHistoryModalVisible] = useState(false);
|
||||
const [uploadHistoryList, setUploadHistoryList] = useState<any[]>([]);
|
||||
@@ -501,7 +498,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelect?.(item.id);
|
||||
onToggleSelect?.(item.generatedResourceId || item.id);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.1)';
|
||||
@@ -571,6 +568,16 @@ const GeneratedRecord: React.FC = () => {
|
||||
setPreviewVisible(false);
|
||||
};
|
||||
|
||||
// 获取item的资源ID(优先使用generatedResourceId,否则使用id)
|
||||
const getItemResourceId = (item: any): string => {
|
||||
return item.generatedResourceId || item.id;
|
||||
};
|
||||
|
||||
// 判断item是否有generatedResourceId
|
||||
const hasGeneratedResourceId = (item: any): boolean => {
|
||||
return Boolean(item.generatedResourceId);
|
||||
};
|
||||
|
||||
// 多选相关函数
|
||||
const handleToggleSelect = (itemId: string) => {
|
||||
setSelectedItems(prev => {
|
||||
@@ -586,7 +593,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const allItemIds = recordlist.flatMap((group: any) =>
|
||||
group.items.map((item: any) => item.id)
|
||||
group.items.map((item: any) => getItemResourceId(item))
|
||||
);
|
||||
if (selectedItems.size === allItemIds.length) {
|
||||
setSelectedItems(new Set());
|
||||
@@ -595,6 +602,81 @@ const GeneratedRecord: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadSelected = async () => {
|
||||
if (selectedItems.size === 0) {
|
||||
message.warning('请先选择要下载的媒体');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查下载数量限制
|
||||
if (selectedItems.size > 10) {
|
||||
message.warning('最多只能单次下载10个文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedContent: any[] = [];
|
||||
recordlist.forEach((group: any) => {
|
||||
group.items.forEach((item: any) => {
|
||||
if (selectedItems.has(getItemResourceId(item))) {
|
||||
selectedContent.push(item);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const zip = new JSZip();
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || "http://localhost:8000";
|
||||
const folder = zip.folder('downloads');
|
||||
let hasError = false;
|
||||
let successCount = 0;
|
||||
|
||||
message.loading({ content: '下载中,请稍候...', key: 'downloadProgress' });
|
||||
|
||||
for (const item of selectedContent) {
|
||||
const url = `${baseUrl}${item.videoUrl || item.imageUrl}`;
|
||||
const filename = ((item.videoUrl || item.imageUrl).split('/').pop() || `file_${Date.now()}`).split('?')[0];
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
const blob = await response.blob();
|
||||
folder?.file(filename, blob);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.warn(`文件下载失败(CORS限制): ${filename},将使用备用方式下载`);
|
||||
hasError = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
// Fallback: 逐个打开文件下载(不受 CORS 限制)
|
||||
message.destroy('downloadProgress');
|
||||
message.info('由于跨域限制,将逐个下载文件');
|
||||
selectedContent.forEach((item, index) => {
|
||||
setTimeout(() => {
|
||||
const url = `${baseUrl}${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);
|
||||
}, index * 500);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(zipBlob);
|
||||
link.download = `downloads_${Date.now()}.zip`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
|
||||
message.destroy('downloadProgress');
|
||||
message.success(`下载完成,共 ${successCount} 个文件`);
|
||||
};
|
||||
|
||||
|
||||
const handleBatchUploadSelected = () => {
|
||||
if (selectedItems.size === 0) {
|
||||
message.warning('请先选择要上传的媒体');
|
||||
@@ -602,7 +684,6 @@ const GeneratedRecord: React.FC = () => {
|
||||
}
|
||||
setAccountIdList([]);
|
||||
setAccountIdInput('');
|
||||
setBatchUploadProgress([]);
|
||||
setUploadConfigModalVisible(true);
|
||||
};
|
||||
|
||||
@@ -660,6 +741,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
loadUploadHistory();
|
||||
};
|
||||
|
||||
// 批量上传素材
|
||||
const handleStartBatchUpload = async () => {
|
||||
if (!selectedOauthItems) {
|
||||
message.warning('请先选择授权账户');
|
||||
@@ -675,23 +757,46 @@ const GeneratedRecord: React.FC = () => {
|
||||
advertiser_ids: string[];
|
||||
resource_ids: string[];
|
||||
oauth_id: string;
|
||||
type: string;
|
||||
source_model: string;
|
||||
}[] = [];
|
||||
const advertiserIds = accountIdList.map(account => account.accountId);
|
||||
const type = filterType === 'project' ? 'generation_record' : 'chat_task';
|
||||
// 创建itemId到item对象的映射
|
||||
const itemMap = new Map<string, any>();
|
||||
recordlist.forEach((group: any) => {
|
||||
group.items.forEach((item: any) => {
|
||||
const resourceId = getItemResourceId(item);
|
||||
itemMap.set(resourceId, item);
|
||||
});
|
||||
});
|
||||
|
||||
for (const itemId of selectedItems) {
|
||||
const item = itemMap.get(itemId);
|
||||
// 根据item是否有generatedResourceId来决定source_model
|
||||
let sourceModel: string;
|
||||
if (item && hasGeneratedResourceId(item)) {
|
||||
sourceModel = 'generated_resources';
|
||||
} else {
|
||||
sourceModel = filterType === 'project' ? 'generation_records' : 'chat_generation_tasks';
|
||||
}
|
||||
|
||||
tasks.push({
|
||||
advertiser_ids: advertiserIds,
|
||||
resource_ids: [itemId],
|
||||
oauth_id: selectedOauthItems.value,
|
||||
type,
|
||||
source_model: sourceModel,
|
||||
});
|
||||
}
|
||||
// console.log(tasks);
|
||||
await asyncBatchUploadMaterial({ tasks });
|
||||
message.success(`已提交 ${tasks.length} 个上传任务,后台异步处理中`);
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
// 关闭弹窗并清理状态
|
||||
setUploadConfigModalVisible(false);
|
||||
setAccountIdList([]);
|
||||
setAccountIdInput('');
|
||||
setSelectedOauthItems(undefined);
|
||||
setMaterialFileNames(new Map());
|
||||
setUnifiedFileName('');
|
||||
} catch (error: any) {
|
||||
console.error('批量上传失败:', error);
|
||||
message.error(error.message || '批量上传失败');
|
||||
@@ -700,6 +805,73 @@ const GeneratedRecord: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 更新文件名函数
|
||||
const handleUpdateFileName = async (sourceId: string, newFileName: string) => {
|
||||
if (!newFileName.trim()) return;
|
||||
try {
|
||||
const response = await updateFilename({
|
||||
filenames: [{ source_id: sourceId, file_name: newFileName }],
|
||||
});
|
||||
// 更新 recordlist 中的文件名,使用 API 返回的 new_file_name
|
||||
const result = response?.results?.find((r: any) => r.source_id === sourceId);
|
||||
const actualFileName = result?.new_file_name || newFileName;
|
||||
setRecordList(prevList => {
|
||||
return prevList.map(group => ({
|
||||
...group,
|
||||
items: group.items.map((item: any) => {
|
||||
const resourceId = getItemResourceId(item);
|
||||
if (resourceId === sourceId) {
|
||||
return { ...item, fileName: actualFileName };
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
}));
|
||||
});
|
||||
message.success('文件名更新成功');
|
||||
} catch (error: any) {
|
||||
console.error('文件名更新失败:', error);
|
||||
message.error(error.message || '文件名更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 批量更新文件名函数
|
||||
const handleBatchUpdateFileName = async (sourceIds: string[], newFileName: string) => {
|
||||
if (!newFileName.trim() || sourceIds.length === 0) return;
|
||||
try {
|
||||
const filenames = sourceIds.map(sourceId => ({
|
||||
source_id: sourceId,
|
||||
file_name: newFileName,
|
||||
}));
|
||||
const response = await updateFilename({ filenames });
|
||||
// 批量更新 recordlist 中的文件名,使用 API 返回的 new_file_name
|
||||
const resultsMap = new Map<string, string>();
|
||||
response?.results?.forEach((r: any) => {
|
||||
if (r.success && r.new_file_name) {
|
||||
resultsMap.set(r.source_id, r.new_file_name);
|
||||
}
|
||||
});
|
||||
setRecordList(prevList => {
|
||||
const sourceIdSet = new Set(sourceIds);
|
||||
return prevList.map(group => ({
|
||||
...group,
|
||||
items: group.items.map((item: any) => {
|
||||
const resourceId = getItemResourceId(item);
|
||||
if (sourceIdSet.has(resourceId)) {
|
||||
const actualFileName = resultsMap.get(resourceId) || newFileName;
|
||||
return { ...item, fileName: actualFileName };
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
}));
|
||||
});
|
||||
const successCount = response?.success_count || 0;
|
||||
message.success(`已更新 ${successCount} 个文件名`);
|
||||
} catch (error: any) {
|
||||
console.error('文件名更新失败:', error);
|
||||
message.error(error.message || '文件名更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 日期选择器变化处理函数
|
||||
const handleDateChange = (dateString: string) => {
|
||||
setSelectedDate(dateString);
|
||||
@@ -731,8 +903,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
page: res.page,
|
||||
}];
|
||||
if (recordList && recordList[0].items.length > 0) {
|
||||
setRecordList(recordList);
|
||||
}else{
|
||||
setRecordList(recordList);
|
||||
} else {
|
||||
setRecordList([]);
|
||||
}
|
||||
}).catch((err) => {
|
||||
@@ -831,7 +1003,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
}, [filterType, filterMedia]);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: 'calc(100vh - 90px)', background: '#ffffffff' , overflowY: 'auto'}} >
|
||||
<div style={{ minHeight: 'calc(100vh - 90px)', background: '#ffffffff', overflowY: 'auto' }} >
|
||||
{/* 操作栏:筛选 + 上传按钮 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
@@ -849,7 +1021,11 @@ const GeneratedRecord: React.FC = () => {
|
||||
<Space>
|
||||
<Button
|
||||
type={filterType === 'project' ? 'primary' : 'default'}
|
||||
onClick={() => setFilterType('project')}
|
||||
onClick={() => {
|
||||
setFilterType('project');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterType === 'project'
|
||||
@@ -865,7 +1041,11 @@ const GeneratedRecord: React.FC = () => {
|
||||
</Button>
|
||||
<Button
|
||||
type={filterType === 'creation' ? 'primary' : 'default'}
|
||||
onClick={() => setFilterType('creation')}
|
||||
onClick={() => {
|
||||
setFilterType('creation');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterType === 'creation'
|
||||
@@ -891,7 +1071,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
borderRadius: 8,
|
||||
background: '#f8f9fc',
|
||||
border: '1px solid #e2e8f0',
|
||||
color: '#64748b',
|
||||
color: '#222222ff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
@@ -912,6 +1092,17 @@ const GeneratedRecord: React.FC = () => {
|
||||
>
|
||||
取消选择
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDownloadSelected()}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
下载 ({selectedItems.size})
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleBatchUploadSelected}
|
||||
@@ -919,12 +1110,12 @@ const GeneratedRecord: React.FC = () => {
|
||||
disabled={uploading || selectedItems.size === 0}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{uploading ? '上传中...' : `上传选中 (${selectedItems.size})`}
|
||||
{uploading ? '上传中...' : `推送至账户 (${selectedItems.size})`}
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
@@ -934,14 +1125,14 @@ const GeneratedRecord: React.FC = () => {
|
||||
onClick={() => setIsSelectionMode(true)}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
批量上传
|
||||
批量操作
|
||||
</Button>
|
||||
|
||||
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -949,6 +1140,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
{/* Second row filter: 视频 / 图片 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
@@ -959,11 +1151,15 @@ const GeneratedRecord: React.FC = () => {
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>媒体类型:</Typography.Text>
|
||||
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
type={filterMedia === 'video' ? 'primary' : 'default'}
|
||||
onClick={() => setFilterMedia('video')}
|
||||
onClick={() => {
|
||||
setFilterMedia('video');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterMedia === 'video'
|
||||
@@ -979,7 +1175,11 @@ const GeneratedRecord: React.FC = () => {
|
||||
</Button>
|
||||
<Button
|
||||
type={filterMedia === 'image' ? 'primary' : 'default'}
|
||||
onClick={() => setFilterMedia('image')}
|
||||
onClick={() => {
|
||||
setFilterMedia('image');
|
||||
setIsSelectionMode(false);
|
||||
setSelectedItems(new Set());
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: filterMedia === 'image'
|
||||
@@ -1017,7 +1217,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
onClick={handleOpenUploadHistory}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
border: 'none',
|
||||
color: '#ffffff',
|
||||
fontWeight: 600,
|
||||
@@ -1066,11 +1266,11 @@ const GeneratedRecord: React.FC = () => {
|
||||
}}>
|
||||
{group.items.map((item: any) => (
|
||||
<LazyMedia
|
||||
key={item.id}
|
||||
key={getItemResourceId(item)}
|
||||
item={item}
|
||||
mediaType={filterMedia}
|
||||
onClick={() => isSelectionMode ? handleToggleSelect(item.id) : handlePreview(item)}
|
||||
isSelected={selectedItems.has(item.id)}
|
||||
onClick={() => isSelectionMode ? handleToggleSelect(getItemResourceId(item)) : handlePreview(item)}
|
||||
isSelected={selectedItems.has(getItemResourceId(item))}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
isSelectionMode={isSelectionMode}
|
||||
/>
|
||||
@@ -1129,12 +1329,133 @@ const GeneratedRecord: React.FC = () => {
|
||||
setAccountIdList([]);
|
||||
setAccountIdInput('');
|
||||
setSelectedOauthItems(undefined);
|
||||
setBatchUploadProgress([]);
|
||||
setMaterialFileNames(new Map());
|
||||
setUnifiedFileName('');
|
||||
}}
|
||||
footer={null}
|
||||
width={900}
|
||||
mask={{ closable: false }}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block' }}>
|
||||
选中素材 ({selectedItems.size}个)
|
||||
</Typography.Text>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#64748b' }}>统一修改名称:</Typography.Text>
|
||||
<Input
|
||||
value={unifiedFileName}
|
||||
onChange={(e) => setUnifiedFileName(e.target.value)}
|
||||
placeholder="输入名称后点击应用"
|
||||
style={{ flex: 1, borderRadius: 8 }}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (unifiedFileName.trim() && selectedItems.size > 0) {
|
||||
handleBatchUpdateFileName(Array.from(selectedItems), unifiedFileName);
|
||||
}
|
||||
}}
|
||||
disabled={!unifiedFileName.trim() || selectedItems.size === 0}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{
|
||||
maxHeight: 300,
|
||||
overflow: 'auto',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
}}>
|
||||
{(() => {
|
||||
const itemMap = new Map<string, any>();
|
||||
recordlist.forEach((group: any) => {
|
||||
group.items.forEach((item: any) => {
|
||||
const resourceId = getItemResourceId(item);
|
||||
itemMap.set(resourceId, item);
|
||||
});
|
||||
});
|
||||
return Array.from(selectedItems).map((itemId) => {
|
||||
const item = itemMap.get(itemId);
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid #f5f5f5',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: 60,
|
||||
height: 40,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#f5f5f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{(() => {
|
||||
const coverField = filterMedia === 'video' ? item?.videoCoverUrl : item?.imageUrl;
|
||||
if (!coverField) {
|
||||
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>预览</Typography.Text>;
|
||||
}
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || "http://localhost:8000";
|
||||
const cleanPath = coverField.startsWith('/') ? coverField.slice(1) : coverField;
|
||||
const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
const coverUrl = `${cleanBase}/static/${cleanPath}&w=300&q=50`;
|
||||
return (
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#1e293b' }}>
|
||||
{item?.fileName || `素材 ${item.id}`}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
value={materialFileNames.get(itemId) || item?.fileName || ''}
|
||||
onChange={(e) => {
|
||||
const newName = e.target.value;
|
||||
const newNames = new Map(materialFileNames);
|
||||
newNames.set(itemId, newName);
|
||||
setMaterialFileNames(newNames);
|
||||
// 防抖调用API
|
||||
if (updateFilenameDebounceRef.current) {
|
||||
clearTimeout(updateFilenameDebounceRef.current);
|
||||
}
|
||||
updateFilenameDebounceRef.current = setTimeout(() => {
|
||||
handleUpdateFileName(itemId, newName);
|
||||
}, 800);
|
||||
}}
|
||||
placeholder="输入新名称"
|
||||
style={{ width: 200, borderRadius: 4 }}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block' }}>
|
||||
选择授权账户
|
||||
</Typography.Text>
|
||||
@@ -1281,9 +1602,6 @@ const GeneratedRecord: React.FC = () => {
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
justifyContent: 'flex-end',
|
||||
}}>
|
||||
<Button
|
||||
@@ -1292,7 +1610,8 @@ const GeneratedRecord: React.FC = () => {
|
||||
setAccountIdList([]);
|
||||
setAccountIdInput('');
|
||||
setSelectedOauthItems(undefined);
|
||||
setBatchUploadProgress([]);
|
||||
setMaterialFileNames(new Map());
|
||||
setUnifiedFileName('');
|
||||
}}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
@@ -1319,6 +1638,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
footer={null}
|
||||
width={800}
|
||||
style={{ borderRadius: 8 }}
|
||||
mask={{ closable: false }}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
@@ -1346,23 +1666,36 @@ const GeneratedRecord: React.FC = () => {
|
||||
dataSource={uploadHistoryList}
|
||||
columns={[
|
||||
{
|
||||
title: '任务ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '资源ID',
|
||||
dataIndex: 'resource_id',
|
||||
key: 'resource_id',
|
||||
width: 150,
|
||||
title: '素材名称',
|
||||
dataIndex: 'fileName',
|
||||
key: 'fileName',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '账户ID',
|
||||
dataIndex: 'advertiser_id',
|
||||
key: 'advertiser_id',
|
||||
width: 120,
|
||||
dataIndex: 'advertiserId',
|
||||
key: 'advertiserId',
|
||||
width: 180,
|
||||
},
|
||||
// {
|
||||
// title: '状态',
|
||||
// dataIndex: 'status',
|
||||
// key: 'status',
|
||||
// width: 100,
|
||||
// render: (status: number, record: any) => {
|
||||
// const statusColorMap: Record<number, string> = {
|
||||
// 1: '#f59e0b',
|
||||
// 2: '#6366f1',
|
||||
// 3: '#10b981',
|
||||
// 4: '#ef4444',
|
||||
// };
|
||||
// return (
|
||||
// <Tag color={statusColorMap[status] || '#64748b'} style={{ borderRadius: 4 }}>
|
||||
// {record.status_text || status}
|
||||
// </Tag>
|
||||
// );
|
||||
// },
|
||||
// },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -1388,6 +1721,18 @@ const GeneratedRecord: React.FC = () => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
key: 'note',
|
||||
width: 250,
|
||||
ellipsis: true,
|
||||
render: (note: string) => (
|
||||
<span style={{ color: '#94a3b8' }}>
|
||||
{note || '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
@@ -1395,15 +1740,10 @@ const GeneratedRecord: React.FC = () => {
|
||||
width: 180,
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
key: 'updated_at',
|
||||
width: 180,
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
|
||||
]}
|
||||
loading={uploadHistoryLoading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: uploadHistoryPage,
|
||||
pageSize: uploadHistoryPageSize,
|
||||
@@ -1412,7 +1752,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
showTotal: (total) => `共 ${total} 条记录`,
|
||||
onChange: handleUploadHistoryPageChange,
|
||||
}}
|
||||
rowKey={(record, index) => record.id || record.resource_id || index}
|
||||
rowKey={(record, index) => record.task_id || record.resource_id || index}
|
||||
size="small"
|
||||
/>
|
||||
</Modal>
|
||||
@@ -1729,7 +2069,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{/* <div>
|
||||
<Button
|
||||
|
||||
style={{ width: '100%', borderRadius: 8, marginTop: 20, color: '#4c49cc' }}
|
||||
@@ -1737,9 +2077,7 @@ const GeneratedRecord: React.FC = () => {
|
||||
|
||||
推送媒体后台
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Table, Tag, Input, Pagination, Typography, Select, App } from 'antd';
|
||||
import { FolderOpenOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { getResourcesMaterialList } from '../api';
|
||||
|
||||
// 格式化时间 2026-06-12T03:47:28.542988Z -> 2026-06-12 03:47:28
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
// 安全拼接URL
|
||||
const buildUrl = (path: string): string => {
|
||||
if (!path) return '';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
return `${cleanBase}/${cleanPath}`;
|
||||
};
|
||||
|
||||
// 资源类型配置
|
||||
const resourceTypeConfig: Record<string, { label: string; color: string }> = {
|
||||
image: { label: '图片', color: 'green' },
|
||||
video: { label: '视频', color: 'blue' },
|
||||
};
|
||||
|
||||
// 状态配置
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
'1': { label: '待上传', color: 'orange' },
|
||||
'2': { label: '上传中', color: 'processing' },
|
||||
'3': { label: '上传成功', color: 'success' },
|
||||
'4': { label: '上传失败', color: 'error' },
|
||||
};
|
||||
|
||||
interface MaterialResource {
|
||||
fileName: string;
|
||||
resourceUrl: string;
|
||||
remoteUrl: string;
|
||||
storageType: string;
|
||||
storagePath: string;
|
||||
fileSizeBytes: number;
|
||||
sourceModel: string;
|
||||
sourceModelModule: string;
|
||||
sourceId: string;
|
||||
engineId: string;
|
||||
engineType: string;
|
||||
provider: string;
|
||||
modelName: string;
|
||||
generatedAt: string;
|
||||
resourceMonth: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface MaterialData {
|
||||
id: string;
|
||||
oauth_idId: string;
|
||||
advertiserId: string;
|
||||
targetTable: string;
|
||||
targetId: string;
|
||||
materialId: string;
|
||||
uploadId: string;
|
||||
resourceType: string;
|
||||
userId: string;
|
||||
taskId: string;
|
||||
note: string;
|
||||
status: string;
|
||||
preResult: string;
|
||||
preTestTemplateId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
resource: MaterialResource;
|
||||
}
|
||||
|
||||
const MaterialListPage: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
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 [searchParams, setSearchParams] = useState({
|
||||
advertiser_id: '',
|
||||
material_id: '',
|
||||
upload_id: '',
|
||||
file_name: '',
|
||||
resource_type: undefined as string | undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadMaterialList();
|
||||
}, []);
|
||||
|
||||
const loadMaterialList = async (page = 1, pageSizeNum = 10, params = searchParams) => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
const response = await getResourcesMaterialList({
|
||||
advertiser_id: params.advertiser_id || undefined,
|
||||
material_id: params.material_id || undefined,
|
||||
upload_id: params.upload_id || undefined,
|
||||
file_name: params.file_name || undefined,
|
||||
resource_type: params.resource_type,
|
||||
page,
|
||||
page_size: pageSizeNum,
|
||||
});
|
||||
if (response?.code === 0) {
|
||||
setMaterials(response.data || []);
|
||||
setTotal(response.total || 0);
|
||||
} else {
|
||||
setMaterials([]);
|
||||
setTotal(0);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取素材列表失败');
|
||||
console.error('获取素材列表失败:', error);
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
// {
|
||||
// title: 'ID',
|
||||
// dataIndex: 'id',
|
||||
// key: 'id',
|
||||
// width: 100,
|
||||
// },
|
||||
{
|
||||
title: '广告主ID',
|
||||
dataIndex: 'advertiserId',
|
||||
key: 'advertiserId',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '素材ID',
|
||||
dataIndex: 'materialId',
|
||||
key: 'materialId',
|
||||
width: 120,
|
||||
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '上传平台Id',
|
||||
dataIndex: 'uploadId',
|
||||
key: 'uploadId',
|
||||
width: 120,
|
||||
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '资源类型',
|
||||
dataIndex: 'resourceType',
|
||||
key: 'resourceType',
|
||||
width: 100,
|
||||
render: (text: string) => {
|
||||
const config = resourceTypeConfig[text];
|
||||
return <Tag color={config?.color}>{config?.label || text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '预览',
|
||||
key: 'preview',
|
||||
width: 100,
|
||||
render: (_: unknown, record: MaterialData) => {
|
||||
const url = record.resource?.resourceUrl || record.resource?.remoteUrl;
|
||||
if (url) {
|
||||
return (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => window.open(buildUrl(url), '_blank')}
|
||||
size="small"
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return <Tag>-</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: ['resource', 'fileName'],
|
||||
key: 'fileName',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (text: string) => <span style={{ color: '#1e293b' }}>{text || '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '文件大小',
|
||||
dataIndex: ['resource', 'fileSizeBytes'],
|
||||
key: 'fileSizeBytes',
|
||||
ellipsis: true,
|
||||
render: (text: number) =>{
|
||||
return `${(text / 1024 / 1024).toFixed(2)} MB`
|
||||
// return `${text} 字`
|
||||
// return `${text} 字节`
|
||||
}
|
||||
|
||||
},
|
||||
// {
|
||||
// title: '来源模型',
|
||||
// dataIndex: ['resource', 'sourceModel'],
|
||||
// key: 'sourceModel',
|
||||
// width: 120,
|
||||
// ellipsis: true,
|
||||
// render: (text: string) => <span style={{ color: '#64748b' }}>{text || '-'}</span>,
|
||||
// },
|
||||
// {
|
||||
// title: '供应商',
|
||||
// dataIndex: ['resource', 'provider'],
|
||||
// key: 'provider',
|
||||
// width: 100,
|
||||
// ellipsis: true,
|
||||
// render: (text: string) => <span style={{ color: '#64748b' }}>{text || '-'}</span>,
|
||||
// },
|
||||
// {
|
||||
// title: '模型名称',
|
||||
// dataIndex: ['resource', 'modelName'],
|
||||
// key: 'modelName',
|
||||
// width: 120,
|
||||
// ellipsis: true,
|
||||
// render: (text: string) => <span style={{ color: '#64748b' }}>{text || '-'}</span>,
|
||||
// },
|
||||
{
|
||||
title: '前测状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{text || '-'}</span>,
|
||||
// render: (text: string) => {
|
||||
// const config = statusConfig[text];
|
||||
// return <Tag color={config?.color}>{config?.label || text}</Tag>;
|
||||
// },
|
||||
},
|
||||
{
|
||||
title: '前测结果',
|
||||
dataIndex: 'preResult',
|
||||
key: 'preResult',
|
||||
width: 120,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{text || '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
key: 'note',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (text: string) => <span style={{ color: '#94a3b8' }}>{text || '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '用户ID',
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
width: 120,
|
||||
// ellipsis: true,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{text || '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
// {
|
||||
// title: '更新时间',
|
||||
// dataIndex: 'updatedAt',
|
||||
// key: 'updatedAt',
|
||||
// width: 160,
|
||||
// render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
// },
|
||||
];
|
||||
|
||||
const tableData = materials.map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
key: item.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '94vh' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<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>
|
||||
|
||||
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
loading={listLoading}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
bordered={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
<div style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger
|
||||
showTotal={(total) => `共 ${total} 条记录`}
|
||||
onChange={(page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
loadMaterialList(page, size);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MaterialListPage;
|
||||
@@ -36,6 +36,7 @@ function RemoveInfo() {
|
||||
const [splitLoading, setSplitLoading] = useState(false);
|
||||
const [autoSplitLoading, setAutoSplitLoading] = useState(false);
|
||||
const pollingIntervalRef = useRef<number | null>(null);
|
||||
const analysisPollingRef = useRef<number | null>(null);
|
||||
|
||||
const videoUrl = useMemo(() => buildAssetUrl(taskDetail?.videoUrl), [taskDetail?.videoUrl]);
|
||||
|
||||
@@ -143,6 +144,10 @@ function RemoveInfo() {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
if (analysisPollingRef.current) {
|
||||
clearInterval(analysisPollingRef.current);
|
||||
analysisPollingRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [refreshPageData]);
|
||||
|
||||
@@ -152,7 +157,7 @@ function RemoveInfo() {
|
||||
if (taskDetail.analysisStatus === 'processing') {
|
||||
console.log("开始轮询");
|
||||
|
||||
pollingIntervalRef.current = window.setInterval(async () => {
|
||||
analysisPollingRef.current = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await getShotReplicationDetail(creatID);
|
||||
setTaskDetail(res);
|
||||
@@ -160,16 +165,16 @@ function RemoveInfo() {
|
||||
}
|
||||
}, 3000);
|
||||
} else {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
if (analysisPollingRef.current) {
|
||||
clearInterval(analysisPollingRef.current);
|
||||
analysisPollingRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
if (analysisPollingRef.current) {
|
||||
clearInterval(analysisPollingRef.current);
|
||||
analysisPollingRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [taskDetail?.analysisStatus, creatID]);
|
||||
@@ -1011,11 +1016,11 @@ function RemoveInfo() {
|
||||
width={640}
|
||||
centered
|
||||
styles={{
|
||||
body: { padding: 24, background: '#0f172a' },
|
||||
header: { background: '#1e293b', borderBottom: 'none', padding: '20px 24px' },
|
||||
body: { padding: 24, },
|
||||
header: { borderBottom: '1px solid #e0e0e0', padding: '20px 24px' },
|
||||
}}
|
||||
>
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.3)' }}>
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', }}>
|
||||
<video
|
||||
ref={previewVideoRef}
|
||||
controls
|
||||
|
||||
@@ -15,6 +15,23 @@ interface AppState {
|
||||
records: api.GenerationRecordPageListOut;
|
||||
loading: boolean;
|
||||
|
||||
// 生成配置状态 - 页面跳转时保留,刷新时重置
|
||||
mediaType: string;
|
||||
countType: string;
|
||||
selectedRatio: string;
|
||||
selectedResolution: string;
|
||||
width: number;
|
||||
height: number;
|
||||
videoDuration: number;
|
||||
videoAspectRatio: string;
|
||||
videoResolution: string;
|
||||
engineOptions: {
|
||||
ratios: string[];
|
||||
resolutions: string[];
|
||||
durations: number[];
|
||||
};
|
||||
enginesele: any;
|
||||
|
||||
fetchProjects: () => Promise<void>;
|
||||
createProject: (name: string, industry: Industry) => Promise<Project>;
|
||||
deleteProject: (id: string) => Promise<void>;
|
||||
@@ -23,6 +40,23 @@ interface AppState {
|
||||
optimizePrompt: (projectId: string, params: OptimizeParams) => Promise<OptimizeResult>;
|
||||
generateVideo: (recordId: string, params: GenerateParams) => Promise<GenerationRecord>;
|
||||
updateRecordReferences: (recordId: string, references: MediaReference[]) => void;
|
||||
|
||||
// 生成配置状态更新方法
|
||||
setMediaType: (mediaType: string) => void;
|
||||
setCountType: (countType: string) => void;
|
||||
setImageSettings: (ratio: string, resolution: string, width: number, height: number) => void;
|
||||
setVideoSettings: (duration: number, aspectRatio: string, resolution: string) => void;
|
||||
setEngineOptions: (options: { ratios: string[]; resolutions: string[]; durations: number[] }) => void;
|
||||
// 单独的 setter 方法
|
||||
setSelectedRatio: (ratio: string) => void;
|
||||
setSelectedResolution: (resolution: string) => void;
|
||||
setWidth: (width: number) => void;
|
||||
setHeight: (height: number) => void;
|
||||
setVideoDuration: (duration: number) => void;
|
||||
setVideoAspectRatio: (aspectRatio: string) => void;
|
||||
setVideoResolution: (resolution: string) => void;
|
||||
setEnginesele: (enginesele: any) => void;
|
||||
resetGenerationConfig: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
@@ -30,6 +64,23 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
records: emptyRecordsPage(),
|
||||
loading: false,
|
||||
|
||||
// 生成配置状态初始值
|
||||
mediaType: 'image',
|
||||
countType: '请选择',
|
||||
selectedRatio: '1:1',
|
||||
selectedResolution: '2K',
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
videoDuration: 5,
|
||||
videoAspectRatio: '16:9',
|
||||
videoResolution: '720p',
|
||||
engineOptions: {
|
||||
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
resolutions: ['480p', '720p', '1080p'],
|
||||
durations: [5, 8, 10, 12, 15],
|
||||
},
|
||||
enginesele: [],
|
||||
|
||||
fetchProjects: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
@@ -99,4 +150,39 @@ export const useAppStore = create<AppState>((set, get) => ({
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// 生成配置状态更新方法
|
||||
setMediaType: (mediaType) => set({ mediaType }),
|
||||
setCountType: (countType) => set({ countType }),
|
||||
setImageSettings: (ratio, resolution, width, height) =>
|
||||
set({ selectedRatio: ratio, selectedResolution: resolution, width, height }),
|
||||
setVideoSettings: (duration, aspectRatio, resolution) =>
|
||||
set({ videoDuration: duration, videoAspectRatio: aspectRatio, videoResolution: resolution }),
|
||||
setEngineOptions: (options) => set({ engineOptions: options }),
|
||||
// 单独的 setter 方法
|
||||
setSelectedRatio: (ratio) => set({ selectedRatio: ratio }),
|
||||
setSelectedResolution: (resolution) => set({ selectedResolution: resolution }),
|
||||
setWidth: (width) => set({ width }),
|
||||
setHeight: (height) => set({ height }),
|
||||
setVideoDuration: (duration) => set({ videoDuration: duration }),
|
||||
setVideoAspectRatio: (aspectRatio) => set({ videoAspectRatio: aspectRatio }),
|
||||
setVideoResolution: (resolution) => set({ videoResolution: resolution }),
|
||||
setEnginesele: (enginesele) => set({ enginesele }),
|
||||
resetGenerationConfig: () => set({
|
||||
mediaType: 'image',
|
||||
countType: '请选择',
|
||||
selectedRatio: '1:1',
|
||||
selectedResolution: '2K',
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
videoDuration: 5,
|
||||
videoAspectRatio: '16:9',
|
||||
videoResolution: '720p',
|
||||
engineOptions: {
|
||||
ratios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
resolutions: ['480p', '720p', '1080p'],
|
||||
durations: [5, 8, 10, 12, 15],
|
||||
},
|
||||
enginesele: [],
|
||||
}),
|
||||
}));
|
||||
|
||||