修改素材云批量上传

This commit is contained in:
Lrd
2026-06-24 11:17:33 +08:00
parent 5041660ca4
commit 8be899d5e4
10 changed files with 1149 additions and 562 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
#VITE_API_BASE=http://localhost: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
View File
@@ -3,8 +3,11 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider, App as AntApp, Spin } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import AdminLayout from './pages/AdminLayout';
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 AdminUsers from './pages/AdminUsers';
import AdminModels from './pages/AdminModels';
import AdminSettings from './pages/AdminSettings';
@@ -28,6 +31,7 @@ import AdminShotReplications from './pages/AdminShotReplications';
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
import { useAdminStore } from './store';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
@@ -96,6 +100,9 @@ const App = () => {
<Route path="shot-replications" element={<AdminShotReplications />} />
<Route path="shot-replications/task-sets/:taskSetId" element={<AdminShotTaskSetDetail />} />
<Route path="shot-replications/projects/:projectId" element={<AdminReplicationProjectDetail moduleType="shot_replicate" />} />
<Route path="authoriza" element={<AdminAuthoriz />} />
<Route path="consume" element={<AdminConsume />} />
<Route path="platform" element={<AdminPlatform />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+26
View File
@@ -540,3 +540,29 @@ export async function importVideoPromptSchemaConfig(payload: VideoPromptSchemaCo
export async function previewVideoPromptSchemaConfig(payload: VideoPromptSchemaPreviewPayload): Promise<VideoPromptSchemaPreviewOut> {
return api.post<VideoPromptSchemaPreviewOut>('/admin/video-prompt-schema-config/preview', payload);
}
// 获取授权链接
export interface RequestOAuthParams {
open_type: number;
}
export async function requestOAuth(params: RequestOAuthParams): Promise<any> {
return api.post(`/user-oauth/request_oauth`, params);
}
// 授权列表
export interface OAuthListParams {
account_userid?: string;
open_type?: number;
account_id?: string;
page?: number;
page_size?: number;
}
export async function getOAuthList(params: OAuthListParams): Promise<any> {
const query = new URLSearchParams();
if (params.account_userid) query.set('account_userid', params.account_userid);
if (params.open_type !== undefined) query.set('open_type', String(params.open_type));
if (params.account_id) query.set('account_id', params.account_id);
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(`/user-oauth/oauth_list?${query.toString()}`);
}
+362
View File
@@ -0,0 +1,362 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Card, Space, Table, Tag, Modal, Select, App, Input, Typography } from 'antd';
import { PlusOutlined, LockOutlined } from '@ant-design/icons';
import { getOAuthList, requestOAuth } from '../api';
const OPEN_TYPE_MAP: Record<number, string> = {
1: '千川',
2: '广告',
3: '本地推',
4: '星图',
5: '快手代理商',
6: '巨量星图',
7: '巨量服务单',
8: '腾讯服务单',
9: '腾讯营销K2',
10: '腾讯营销K3',
};
const PORT_TYPE_MAP: Record<number, string> = {
1: '巨量',
2: '磁力',
3: '巨量星图',
4: '服务单',
5: '腾讯',
};
// 格式化时间 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}`;
};
interface AuthorizationData {
id: string;
status: string;
description: string;
account_userid?: string;
open_type?: number;
account_id?: string;
}
const AuthorizationPage: React.FC = () => {
const { message } = App.useApp();
const [authorizations, setAuthorizations] = useState<AuthorizationData[]>([]);
const [loading, setLoading] = useState(false);
const [listLoading, setListLoading] = useState(false);
const [showModal, setShowModal] = useState(false);
const [selectedOpenType, setSelectedOpenType] = useState<number | undefined>(undefined);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [searchParams, setSearchParams] = useState({
account_userid: '',
open_type: undefined as number | undefined,
account_id: '',
});
useEffect(() => {
loadOAuthList();
}, []);
const loadOAuthList = async (page = 1, pageSize = 10, params = searchParams) => {
setListLoading(true);
try {
const response = await getOAuthList({
page,
page_size: pageSize,
account_userid: params.account_userid || undefined,
open_type: params.open_type,
account_id: params.account_id || undefined,
});
if (response) {
if (response.data) {
setAuthorizations(response.data.data || response.data);
}
if (response.pagination) {
setTotal(response.pagination.total || 0);
setCurrentPage(response.pagination.page || 1);
setPageSize(response.pagination.pageSize || 10);
}
} else {
setAuthorizations(response || []);
}
} catch (error) {
message.error('获取授权列表失败');
} finally {
setListLoading(false);
}
};
const handleAuthorize = () => {
setShowModal(true);
};
const handleConfirm = async () => {
if (!selectedOpenType) {
message.warning('请选择开户方式');
return;
}
setLoading(true);
try {
const response = await requestOAuth({ open_type: selectedOpenType });
if (response.authUrl) {
window.open(response.authUrl, '_blank');
} else {
message.error('获取授权链接失败');
}
} catch (error) {
message.error('请求授权失败');
} finally {
setLoading(false);
setShowModal(false);
setSelectedOpenType(undefined);
}
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
},
{
title: '授权账户ID',
dataIndex: 'accountId',
key: 'accountId',
width: 160,
},
{
title: '授权账户名称',
dataIndex: 'accountName',
key: 'accountName',
},
{
title: '授权账户角色',
dataIndex: 'accountRole',
key: 'accountRole',
render: (role: string) => {
const roleMap: Record<string, string> = {
ADVERTISER: '客户',
CUSTOMER_ADMIN: '普通版工作台-管理员',
CUSTOMER_OPERATOR: '普通版工作台-协作者',
AGENT: '代理商',
CHILD_AGENT: '二级代理商',
PLATFORM_ROLE_STAR: '星图账户',
PLATFORM_ROLE_SHOP_ACCOUNT: '抖音店铺账户',
PLATFORM_ROLE_QIANCHUAN_AGENT: '千川代理商',
PLATFORM_ROLE_STAR_AGENT: '星图代理商',
PLATFORM_ROLE_AWEME: '抖音号',
PLATFORM_ROLE_STAR_MCN: '星图MCN机构',
PLATFORM_ROLE_STAR_ISV: '星图服务商',
AGENT_SYSTEM_ACCOUNT: '代理商系统账户',
PLATFORM_ROLE_LOCAL_AGENT: '本地推代理商',
PLATFORM_ROLE_YUNTU_BRAND_ISV_ADMIN: '云图品牌服务商管理员',
PLATFORM_ROLE_LIFE: '抖音来客账户',
PLATFORM_ROLE_ENTERPRISE_BP_ADMIN: '升级版工作台管理员',
PLATFORM_ROLE_ENTERPRISE_BP_OPERATOR: '升级版工作台协作者',
};
return roleMap[role] || role;
},
},
{
title: '授权账户用户ID',
dataIndex: 'accountUserid',
key: 'accountUserid',
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
},
{
title: '授权账户用户名',
dataIndex: 'accountUsername',
key: 'accountUsername',
},
{
title: '授权应用ID',
dataIndex: 'appid',
key: 'appid',
},
{
title: '是否敏感物料授权',
dataIndex: 'materialAuthStatus',
key: 'materialAuthStatus',
width: 100,
render: (text: boolean) => (
<Tag color={text ? 'green' : 'default'}>
{text ? '是' : '否'}
</Tag>
),
},
{
title: '开户方式',
dataIndex: 'openType',
key: 'openType',
render: (text: number) => <span style={{ color: '#1e293b' }}>{OPEN_TYPE_MAP[text] || text}</span>,
},
{
title: '平台端口',
dataIndex: 'portType',
key: 'portType',
ellipsis: true,
render: (text: number) => <span style={{ color: '#1e293b' }}>{PORT_TYPE_MAP[text] || text}</span>,
},
{
title: '用户id',
dataIndex: 'userId',
key: 'userId',
},
{
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>,
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: unknown, record: AuthorizationData) => (
<Link to={`/consume?accountId=${record.id}`} style={{ color: '#6366f1' }}>
</Link>
),
},
];
const tableData = authorizations.map((item, index) => ({
...item,
index: index + 1,
key: item.id,
}));
return (
<div style={{ minHeight: '94vh' }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<LockOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Button
type="primary"
size="medium"
icon={<PlusOutlined />}
onClick={handleAuthorize}
loading={loading}
style={{
borderRadius: 8,
fontSize: 14,
fontWeight: 500,
}}
>
</Button>
</div>
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<Input
placeholder="账号用户ID"
value={searchParams.account_userid}
onChange={(e) => setSearchParams(prev => ({ ...prev, account_userid: e.target.value }))}
style={{ width: 180 }}
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
/>
<Select
placeholder="开户方式"
value={searchParams.open_type}
onChange={(value) => setSearchParams(prev => ({ ...prev, open_type: value }))}
style={{ width: 140 }}
options={Object.entries(OPEN_TYPE_MAP).map(([key, value]) => ({
value: Number(key),
label: value,
}))}
/>
<Input
placeholder="账号ID"
value={searchParams.account_id}
onChange={(e) => setSearchParams(prev => ({ ...prev, account_id: e.target.value }))}
style={{ width: 180 }}
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
/>
<Button
type="primary"
size="medium"
onClick={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
>
</Button>
<Button
size="medium"
onClick={() => {
setSearchParams({ account_userid: '', open_type: undefined, account_id: '' });
setCurrentPage(1);
loadOAuthList(1, pageSize);
}}
>
</Button>
</div>
<Table
dataSource={tableData}
columns={columns}
loading={listLoading}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
pagination={{
current: currentPage,
pageSize: pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
loadOAuthList(page, size);
},
size: 'small',
}}
/>
</Card>
<Modal
title="选择开户方式"
open={showModal}
onOk={handleConfirm}
onCancel={() => {
setShowModal(false);
setSelectedOpenType(undefined);
}}
okText="确认授权"
cancelText="取消"
confirmLoading={loading}
>
<Select
placeholder="请选择开户方式"
value={selectedOpenType}
onChange={(value) => setSelectedOpenType(value)}
style={{ width: '100%' }}
options={Object.entries(OPEN_TYPE_MAP).map(([key, value]) => ({
value: Number(key),
label: value,
}))}
/>
</Modal>
</div>
);
};
export default AuthorizationPage;
+267
View File
@@ -0,0 +1,267 @@
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 { useNavigate } from 'react-router-dom';
// 表头字段模拟数据
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: '产品信息' },
];
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;
}
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 [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('');
useEffect(() => {
setLoading(true);
setTimeout(() => {
setConsumptionRecords(mockConsumptionRecords);
setLoading(false);
}, 500);
setTimeout(() => {
setColumnsData(mockColumnsData);
const saved = localStorage.getItem('consumeColumns');
if (saved) {
setSelectedColumns(JSON.parse(saved));
} else {
setSelectedColumns(mockColumnsData.map(item => item.name));
}
}, 300);
}, []);
const tableData = consumptionRecords.map((item, index) => ({
...item,
index: index + 1,
key: item.id,
}));
const handleBack = () => {
navigate('/authorization');
};
const dynamicColumns = columnsData
.filter(col => selectedColumns.includes(col.name))
.map(col => ({
title: col.description,
dataIndex: col.name,
key: col.name,
ellipsis: true,
}));
return (
<div style={{ minHeight: '94vh' }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<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>
<Table
dataSource={tableData}
columns={dynamicColumns}
loading={loading}
rowKey="id"
bordered={false}
scroll={{ x: 'max-content' }}
pagination={{
pageSize: 10,
total: consumptionRecords.length,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
size: 'small',
}}
/>
</Card>
<Modal
title="自定义表头"
open={showModal}
onOk={() => {
localStorage.setItem('consumeColumns', JSON.stringify(selectedColumns));
message.success('表头设置已保存');
setShowModal(false);
}}
onCancel={() => {
setShowModal(false);
setSearchText('');
}}
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}
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>
<Space>
<Button
size="small"
onClick={() => setSelectedColumns([])}
disabled={selectedColumns.length === 0}
>
</Button>
<Button
size="small"
onClick={() => {
message.info('更新表头字段');
setTimeout(() => {
setColumnsData(mockColumnsData);
}, 300);
}}
>
</Button>
</Space>
</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;
+157
View File
@@ -0,0 +1,157 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Space, Table, Tag, Typography, message, Modal, Input, Upload,
} from 'antd';
import {
HistoryOutlined, ReloadOutlined, PlusOutlined, UploadOutlined,
} from '@ant-design/icons';
import { getOperationLogs } from '../api';
import { formatDate } from '../utils/formatDate';
interface OperationLog {
id: string;
userId: string;
username: string;
action: string;
method: string;
path: string;
detail?: string;
ip?: string;
createdAt: string;
}
const METHOD_COLORS: Record<string, string> = { POST: 'green', PUT: 'blue', DELETE: 'red' };
const AdminPlatform: React.FC = () => {
const [logs, setLogs] = useState<OperationLog[]>([]);
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 load = async (p?: number) => {
setLoading(true);
try {
const res = await getOperationLogs(p || page);
setLogs(res.items || []);
setTotal(res.total || 0);
} catch {
message.error('加载平台管理失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const columns = [
{
title: '操作人', dataIndex: 'username', width: 120,
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '操作', dataIndex: 'action', width: 160,
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: 'path', width: 220, ellipsis: true,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
},
{
title: '时间', dataIndex: 'createdAt', width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
];
return (
<div>
<Card bordered={false} 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>
</Space>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setShowModal(true)}></Button>
<Button icon={<ReloadOutlined />} onClick={() => load()}></Button>
</Space>
</div>
<Table
columns={columns}
dataSource={logs}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: 20,
total,
showTotal: (t) => `${t} 条记录`,
onChange: (p) => { setPage(p); load(p); },
}}
scroll={{ x: 800 }}
/>
</Card>
<Modal
title="新增平台"
open={showModal}
onOk={() => {
message.success('新增成功');
setShowModal(false);
setFormData({ title: '', description: '', image: '' });
load();
}}
onCancel={() => {
setShowModal(false);
setFormData({ title: '', description: '', image: '' });
}}
okText="确认"
cancelText="取消"
>
<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 }))}
/>
</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>
<Upload
action="/api/upload"
listType="picture-card"
onChange={(info) => {
if (info.file.status === 'done') {
setFormData(prev => ({ ...prev, image: info.file.response?.url || '' }));
}
}}
>
<div>
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
<div style={{ marginTop: 8 }}></div>
</div>
</Upload>
</div>
</div>
</Modal>
</div>
);
};
export default AdminPlatform;
+2 -2
View File
@@ -1,5 +1,5 @@
#VITE_API_BASE=http://localhost: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=
+32 -1
View File
@@ -577,4 +577,35 @@ export async function getArea(params?: GetAreaParams): Promise<any> {
if (params?.parent_code) query.set('parent_code', params.parent_code);
return api.get(`/pre-test-template/getArea?${query.toString()}`);
}
//
// ── Upload Material ───────────────────────────────────────
export interface AsyncBatchUploadTask {
advertiser_ids: string[];
resource_ids: string[];
oauth_id: string;
}
export interface AsyncBatchUploadParams {
tasks: AsyncBatchUploadTask[];
}
export async function asyncBatchUploadMaterial(params: AsyncBatchUploadParams): Promise<any> {
return api.post('/upload-material/async-batch-upload', params);
}
// 获取上传历史
// /api/upload-material/upload-history
export interface UploadHistoryParams {
status?: string;
page?: number;
pageSize?: number;
}
export async function getUploadHistory(params: UploadHistoryParams): Promise<any> {
const searchParams = new URLSearchParams();
if (params.status) searchParams.set('status', params.status);
if (params.page) searchParams.set('page', String(params.page));
if (params.pageSize) searchParams.set('page_size', String(params.pageSize));
const query = searchParams.toString();
return api.get(`/upload-material/upload-history${query ? `?${query}` : ''}`);
}
@@ -65,7 +65,6 @@ const AuthorizationPage: React.FC = () => {
useEffect(() => {
loadOAuthList();
handleCallback();
}, []);
const loadOAuthList = async (page = 1, pageSize = 10, params = searchParams) => {
@@ -97,22 +96,6 @@ const AuthorizationPage: React.FC = () => {
}
};
const handleCallback = async () => {
const params = new URLSearchParams(window.location.search);
const authCode = params.get('auth_code');
const state = params.get('state');
if (authCode && state) {
try {
await juliang_callback({ auth_code: authCode, state });
message.success('授权成功');
window.history.replaceState({}, document.title, window.location.pathname);
loadOAuthList();
} catch (error) {
message.error('授权回调失败');
}
}
};
const handleAuthorize = () => {
setShowModal(true);
};
+239 -485
View File
@@ -12,9 +12,9 @@ import {
XOutlined,
ClockCircleOutlined,
UploadOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { gethistory, gethistoryItems, getDefaultPreTest, getOAuthList } from '../api';
import { gethistory, gethistoryItems, getOAuthList, asyncBatchUploadMaterial, getUploadHistory } from '../api';
const { Search } = Input;
const { Text } = Typography;
@@ -35,30 +35,19 @@ const GeneratedRecord: React.FC = () => {
const videoRef = React.createRef<HTMLVideoElement>();
const [selectedDate, setSelectedDate] = useState<string>('');
// 批量上传相关状态
const [uploadModalVisible, setUploadModalVisible] = useState(false);
const [uploadFiles, setUploadFiles] = useState<any[]>([]);
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
const [uploading, setUploading] = useState(false);
// 上传配置弹窗相关状态
const [uploadConfigModalVisible, setUploadConfigModalVisible] = useState(false);
const [preTestTemplates, setPreTestTemplates] = useState<any[]>([
{ id: 'template_001', name: '前测模板A' },
{ id: 'template_002', name: '前测模板B' },
{ id: 'template_003', name: '前测模板C' },
]);
const [preTestLoading, setPreTestLoading] = useState(false);
const [accountIdList, setAccountIdList] = useState<{
accountId: string;
authStatus: 'pending' | 'authorized' | 'failed';
}[]>([]);
const [accountIdInput, setAccountIdInput] = useState('');
const [oauthList, setOauthList] = useState<any[]>([]);
const [oauthLoading, setOauthLoading] = useState(false);
const [oauthTotal, setOauthTotal] = useState(0);
const [selectedOauthItems, setSelectedOauthItems] = useState<string[]>([]);
const [selectedOauthItems, setSelectedOauthItems] = useState<{ value: string; label: string } | undefined>(undefined);
const [oauthPage, setOauthPage] = useState(1);
const [oauthPageSize, setOauthPageSize] = useState(10);
const [oauthSelectOpen, setOauthSelectOpen] = useState(false);
@@ -69,6 +58,15 @@ const GeneratedRecord: React.FC = () => {
message: string;
}[]>([]);
// 上传任务历史弹窗相关状态
const [uploadHistoryModalVisible, setUploadHistoryModalVisible] = useState(false);
const [uploadHistoryList, setUploadHistoryList] = useState<any[]>([]);
const [uploadHistoryTotal, setUploadHistoryTotal] = useState(0);
const [uploadHistoryPage, setUploadHistoryPage] = useState(1);
const [uploadHistoryPageSize, setUploadHistoryPageSize] = useState(10);
const [uploadHistoryLoading, setUploadHistoryLoading] = useState(false);
const [uploadHistoryStatus, setUploadHistoryStatus] = useState<string>('');
// 多选相关状态
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
@@ -573,58 +571,6 @@ const GeneratedRecord: React.FC = () => {
setPreviewVisible(false);
};
// 批量上传相关函数
const handleUploadChange = (info: any) => {
// 过滤文件类型
const validFiles = info.fileList.filter((file: any) => {
const type = file.type.toLowerCase();
return type.startsWith('image/') || type.startsWith('video/');
});
// 检查无效文件并提示
const invalidFiles = info.fileList.filter((file: any) => {
const type = file.type.toLowerCase();
return !type.startsWith('image/') && !type.startsWith('video/');
});
if (invalidFiles.length > 0) {
message.warning(`已过滤 ${invalidFiles.length} 个无效文件,仅支持图片和视频`);
}
setUploadFiles(validFiles);
};
const handleRemoveFile = (file: any) => {
setUploadFiles(prev => prev.filter(f => f.uid !== file.uid));
};
const handleStartUpload = async () => {
if (uploadFiles.length === 0) {
message.warning('请先选择要上传的文件');
return;
}
setUploading(true);
// 模拟批量上传过程
for (let i = 0; i < uploadFiles.length; i++) {
const file = uploadFiles[i];
setUploadProgress(prev => ({ ...prev, [file.uid]: 0 }));
// 模拟上传进度
for (let progress = 0; progress <= 100; progress += 10) {
await new Promise(resolve => setTimeout(resolve, 100));
setUploadProgress(prev => ({ ...prev, [file.uid]: progress }));
}
}
// 上传完成
await new Promise(resolve => setTimeout(resolve, 500));
message.success(`成功上传 ${uploadFiles.length} 个文件`);
setUploading(false);
setUploadFiles([]);
setUploadModalVisible(false);
// 刷新页面数据
setPagebreak(prev => ({ ...prev, page: 1 }));
};
// 多选相关函数
const handleToggleSelect = (itemId: string) => {
setSelectedItems(prev => {
@@ -665,7 +611,6 @@ const GeneratedRecord: React.FC = () => {
try {
const res = await getOAuthList({ page, page_size: pageSize });
const data = res?.data || res;
console.log(res);
setOauthList(data || []);
setOauthTotal(res.pagination.total || 0);
} catch (error) {
@@ -677,97 +622,79 @@ const GeneratedRecord: React.FC = () => {
}
};
const handleStartBatchUpload = async () => {
setUploading(true);
const uploadProgressMap: { [key: string]: { status: 'pending' | 'uploading' | 'success' | 'error'; message: string } } = {};
for (const itemId of selectedItems) {
uploadProgressMap[itemId] = { status: 'pending', message: '' };
}
setBatchUploadProgress(Object.entries(uploadProgressMap).map(([itemId, value]) => ({ itemId, ...value })));
const loadUploadHistory = async () => {
setUploadHistoryLoading(true);
try {
for (const itemId of selectedItems) {
let item: any = null;
let mediaUrl = '';
let mediaType = '';
for (const group of recordlist) {
const found = group.items.find((i: any) => i.id === itemId);
if (found) {
item = found;
break;
}
}
if (!item) continue;
if (filterMedia === 'video' && item.videoUrl) {
mediaUrl = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${item.videoUrl}`;
mediaType = 'video';
} else if (filterMedia === 'image' && item.imageUrl) {
mediaUrl = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}/static${item.imageUrl}`;
mediaType = 'image';
} else {
continue;
}
uploadProgressMap[itemId] = { status: 'uploading', message: '正在上传...' };
setBatchUploadProgress(Object.entries(uploadProgressMap).map(([itemId, value]) => ({ itemId, ...value })));
try {
const response = await fetch(mediaUrl);
if (!response.ok) throw new Error('下载失败');
const blob = await response.blob();
const file = new File([blob], item.title || `media_${itemId}`, {
type: mediaType === 'video' ? 'video/mp4' : 'image/jpeg'
const res = await getUploadHistory({
status: uploadHistoryStatus || undefined,
page: uploadHistoryPage,
pageSize: uploadHistoryPageSize,
});
for (const accountItem of accountIdList) {
const form = new FormData();
form.append('file', file);
form.append('media_type', mediaType);
form.append('original_id', itemId);
form.append('account_id', accountItem.accountId);
const token = localStorage.getItem('auth_token');
const uploadResponse = await fetch(
`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/batch-upload`,
{
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
const data = res?.data || res;
setUploadHistoryList(data || []);
setUploadHistoryTotal(res.pagination?.total || res.total || 0);
} catch (error) {
console.error('加载上传历史失败:', error);
setUploadHistoryList([]);
setUploadHistoryTotal(0);
} finally {
setUploadHistoryLoading(false);
}
);
};
const result = await uploadResponse.json();
if (!uploadResponse.ok) {
throw new Error(result.message || '上传失败');
}
}
const handleOpenUploadHistory = () => {
setUploadHistoryModalVisible(true);
setUploadHistoryPage(1);
setUploadHistoryStatus('');
loadUploadHistory();
};
uploadProgressMap[itemId] = { status: 'success', message: '上传成功' };
} catch (error: any) {
console.error(`上传失败: ${itemId}`, error);
uploadProgressMap[itemId] = { status: 'error', message: error.message || '上传失败' };
}
setBatchUploadProgress(Object.entries(uploadProgressMap).map(([itemId, value]) => ({ itemId, ...value })));
}
const handleUploadHistorySearch = () => {
setUploadHistoryPage(1);
loadUploadHistory();
};
const successCount = Object.values(uploadProgressMap).filter(p => p.status === 'success').length;
const failCount = Object.values(uploadProgressMap).filter(p => p.status === 'error').length;
const handleUploadHistoryPageChange = (page: number, pageSize: number) => {
setUploadHistoryPage(page);
setUploadHistoryPageSize(pageSize);
loadUploadHistory();
};
if (failCount === 0) {
message.success(`成功上传 ${successCount} 个文件`);
} else {
message.warning(`上传完成:成功 ${successCount} 个,失败 ${failCount}`);
const handleStartBatchUpload = async () => {
if (!selectedOauthItems) {
message.warning('请先选择授权账户');
return;
}
if (selectedItems.size === 0) {
message.warning('请先选择要上传的媒体');
return;
}
setUploading(true);
try {
const tasks: {
advertiser_ids: string[];
resource_ids: string[];
oauth_id: string;
type: string;
}[] = [];
const advertiserIds = accountIdList.map(account => account.accountId);
const type = filterType === 'project' ? 'generation_record' : 'chat_task';
for (const itemId of selectedItems) {
tasks.push({
advertiser_ids: advertiserIds,
resource_ids: [itemId],
oauth_id: selectedOauthItems.value,
type,
});
}
// console.log(tasks);
await asyncBatchUploadMaterial({ tasks });
message.success(`已提交 ${tasks.length} 个上传任务,后台异步处理中`);
setIsSelectionMode(false);
setSelectedItems(new Set());
} catch (error) {
message.error('批量上传失败');
console.error(error);
} catch (error: any) {
console.error('批量上传失败:', error);
message.error(error.message || '批量上传失败');
} finally {
setUploading(false);
}
@@ -775,13 +702,7 @@ const GeneratedRecord: React.FC = () => {
// 日期选择器变化处理函数
const handleDateChange = (dateString: string) => {
console.log(dateString);
console.log('qweqwe');
setSelectedDate(dateString);
};
useEffect(() => {
@@ -809,16 +730,11 @@ const GeneratedRecord: React.FC = () => {
total: res.total,
page: res.page,
}];
console.log('data', data);
if (recordList && recordList[0].items.length > 0) {
setRecordList(recordList);
}else{
setRecordList([]);
}
console.log('recordList', recordList);
}).catch((err) => {
}).finally(() => {
setLoading(false);
@@ -835,15 +751,12 @@ const GeneratedRecord: React.FC = () => {
data.forEach(group => {
group.page = 1;
});
console.log('qweqweqwe', data);
// 如果是第一页,替换数据;否则追加数据
if (Pagebreak.page === 1) {
setRecordList(data);
} else {
setRecordList(prev => [...prev, ...data]);
}
setTotalnumber(res?.totalDays || 0);
}).catch((err) => {
if (Pagebreak.page === 1) {
@@ -857,22 +770,6 @@ const GeneratedRecord: React.FC = () => {
}, [filterType, filterMedia, Pagebreak.page, selectedDate]);
useEffect(() => {
setPreTestLoading(true);
getDefaultPreTest().then((res: any) => {
const data = res?.data || res;
setPreTestTemplates(Array.isArray(data) ? data : []);
}).catch(() => {
setPreTestTemplates([
{ id: 'template_001', name: '前测模板A' },
{ id: 'template_002', name: '前测模板B' },
{ id: 'template_003', name: '前测模板C' },
]);
}).finally(() => {
setPreTestLoading(false);
});
}, []);
// 加载更多
const handleLoadMore = () => {
if (loading) return;
@@ -935,16 +832,6 @@ const GeneratedRecord: React.FC = () => {
return (
<div style={{ minHeight: 'calc(100vh - 90px)', background: '#ffffffff' , overflowY: 'auto'}} >
{/* Header */}
{/* <div style={{ marginBottom: 20 }}>
<Typography.Title level={3} style={{ margin: '0 0 4px', color: '#1a1a2e', fontWeight: 700 }}>
生成历史
</Typography.Title>
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>
查看所有生成的视频和图片记录
</Typography.Text>
</div> */}
{/* 操作栏:筛选 + 上传按钮 */}
<div style={{
display: 'flex',
@@ -1054,6 +941,7 @@ const GeneratedRecord: React.FC = () => {
>
</Button>
)}
</div>
</div>
@@ -1069,7 +957,9 @@ const GeneratedRecord: React.FC = () => {
background: '#fff',
border: '1px solid #f0f0f5',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}></Typography.Text>
<Space>
<Button
type={filterMedia === 'video' ? 'primary' : 'default'}
@@ -1122,6 +1012,30 @@ const GeneratedRecord: React.FC = () => {
)}
</Space>
</div>
<Button
icon={<ClockCircleOutlined />}
onClick={handleOpenUploadHistory}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
color: '#ffffff',
fontWeight: 600,
boxShadow: '0 4px 15px rgba(102, 126, 234, 0.4)',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(102, 126, 234, 0.6)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 15px rgba(102, 126, 234, 0.4)';
}}
>
</Button>
</div>
{/* Content area */}
{recordlist.length === 0 ? (
@@ -1206,147 +1120,6 @@ const GeneratedRecord: React.FC = () => {
</div>
)}
{/* 批量上传弹窗 */}
<Modal
title="批量上传媒体"
open={uploadModalVisible}
onCancel={() => {
setUploadModalVisible(false);
setUploadFiles([]);
}}
footer={null}
width={600}
>
<div style={{ padding: '16px 0' }}>
{/* 上传区域 */}
<Upload
multiple
fileList={uploadFiles}
onChange={handleUploadChange}
beforeUpload={() => false} // 手动控制上传
accept="image/*,video/*"
listType="picture-card"
onRemove={handleRemoveFile}
>
<div style={{
width: 100,
height: 100,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed #d9d9d9',
borderRadius: 8,
cursor: 'pointer',
}}>
<PlusOutlined style={{ fontSize: 24, color: '#999' }} />
<span style={{ marginTop: 8, color: '#999', fontSize: 12 }}></span>
</div>
</Upload>
{/* 上传列表和进度 */}
{uploadFiles.length > 0 && (
<div style={{ marginTop: 16 }}>
<Typography.Text strong style={{ fontSize: 14, color: '#475569' }}>
{uploadFiles.length}
</Typography.Text>
<div style={{ marginTop: 12, maxHeight: 200, overflowY: 'auto' }}>
{uploadFiles.map((file) => (
<div
key={file.uid}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 8,
border: '1px solid #e8e8e8',
borderRadius: 6,
marginBottom: 8,
}}
>
<div style={{
width: 40,
height: 40,
borderRadius: 4,
overflow: 'hidden',
backgroundColor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{file.type?.startsWith('image/') ? (
<PictureOutlined style={{ color: '#3b82f6', fontSize: 16 }} />
) : file.type?.startsWith('video/') ? (
<VideoCameraOutlined style={{ color: '#f59e0b', fontSize: 16 }} />
) : (
<FileTextOutlined style={{ color: '#999', fontSize: 16 }} />
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 13,
color: '#333',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{file.name}
</div>
{uploadProgress[file.uid] !== undefined && (
<Progress
percent={uploadProgress[file.uid]}
size="small"
showInfo={false}
style={{ marginTop: 4 }}
/>
)}
</div>
<Button
icon={<XOutlined />}
onClick={() => handleRemoveFile(file)}
style={{
background: 'transparent',
border: 'none',
color: '#999',
}}
/>
</div>
))}
</div>
</div>
)}
{/* 操作按钮 */}
<div style={{
display: 'flex',
gap: 12,
marginTop: 24,
paddingTop: 16,
borderTop: '1px solid #f0f0f0',
justifyContent: 'flex-end',
}}>
<Button
onClick={() => {
setUploadModalVisible(false);
setUploadFiles([]);
}}
style={{ borderRadius: 8 }}
>
</Button>
<Button
type="primary"
onClick={handleStartUpload}
loading={uploading}
disabled={uploading || uploadFiles.length === 0}
style={{ borderRadius: 8 }}
>
{uploading ? '上传中...' : '开始上传'}
</Button>
</div>
</div>
</Modal>
{/* 上传配置弹窗 */}
<Modal
title="批量上传配置"
@@ -1355,7 +1128,7 @@ const GeneratedRecord: React.FC = () => {
setUploadConfigModalVisible(false);
setAccountIdList([]);
setAccountIdInput('');
setSelectedOauthItems([]);
setSelectedOauthItems(undefined);
setBatchUploadProgress([]);
}}
footer={null}
@@ -1363,32 +1136,12 @@ const GeneratedRecord: React.FC = () => {
>
<div style={{ padding: '16px 0' }}>
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block' }}>
</Typography.Text>
<Select
mode="multiple"
value={selectedOauthItems}
onChange={(value) => {
setSelectedOauthItems(value as string[]);
const selectedAccounts = oauthList
.filter(item => value.includes(String(item.id)))
.map(item => ({ accountId: String(item.accountId), authStatus: 'authorized' as const }));
const textAccounts = accountIdInput.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.map(id => ({ accountId: id, authStatus: 'pending' as const }));
const mergedAccounts = [...selectedAccounts, ...textAccounts];
const seen = new Set<string>();
const finalAccounts = mergedAccounts.filter(a => {
if (seen.has(a.accountId)) return false;
seen.add(a.accountId);
return true;
});
setAccountIdList(finalAccounts);
setSelectedOauthItems(value as { value: string; label: string } | undefined);
}}
placeholder="点击选择授权账户"
style={{ width: '100%', marginBottom: 16, borderRadius: 8 }}
@@ -1398,15 +1151,28 @@ const GeneratedRecord: React.FC = () => {
dataSource={oauthList}
columns={[
{
title: '授权账户ID',
dataIndex: 'accountId',
key: 'accountId',
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 120,
},
{
title: '授权账户名称',
dataIndex: 'accountName',
key: 'accountName',
width: 120,
},
{
title: '授权应用ID',
dataIndex: 'appid',
key: 'appid',
width: 120,
},
{
title: '授权用户ID',
dataIndex: 'accountUserid',
key: 'accountUserid',
width: 120,
},
{
title: '授权账户角色',
@@ -1463,16 +1229,11 @@ const GeneratedRecord: React.FC = () => {
onRow={(record) => ({
onClick: () => {
const id = String(record.id);
setSelectedOauthItems(prev => {
if (prev.includes(id)) {
return prev.filter(item => item !== id);
}
return [...prev, id];
});
setSelectedOauthItems({ value: id, label: String(record.accountUserid) });
},
style: {
cursor: 'pointer',
backgroundColor: selectedOauthItems.includes(String(record.id)) ? '#e6f7ff' : undefined,
backgroundColor: selectedOauthItems?.value === String(record.id) ? '#e6f7ff' : undefined,
},
})}
/>
@@ -1485,150 +1246,37 @@ const GeneratedRecord: React.FC = () => {
loadOAuthList(1, oauthPageSize);
}
}}
labelInValue
fieldNames={{ label: 'accountUserid', value: 'id' }}
/>
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block', marginTop: 16 }}>
ID
ID
</Typography.Text>
<Input.TextArea
value={accountIdInput}
onChange={(e) => {
const value = e.target.value;
setAccountIdInput(value);
const ids = value.split('\n')
const ids = value.split(/[\n,]/)
.map(line => line.trim())
.filter(line => line.length > 0);
const uniqueIds = [...new Set(ids)];
const textAccounts = uniqueIds.map(id => ({ accountId: id, authStatus: 'pending' as const }));
const oauthAccounts = oauthList
.filter(item => selectedOauthItems.includes(String(item.id)))
.map(item => ({ accountId: String(item.accountId), authStatus: 'authorized' as const }));
const mergedAccounts = [...oauthAccounts, ...textAccounts];
const textAccounts = uniqueIds.map(id => ({ accountId: id }));
const seen = new Set<string>();
const finalAccounts = mergedAccounts.filter(a => {
const finalAccounts = textAccounts.filter(a => {
if (seen.has(a.accountId)) return false;
seen.add(a.accountId);
return true;
});
setAccountIdList(finalAccounts);
}}
placeholder="粘贴账户ID,每行一个,例如:
10001
10002
10003"
placeholder="粘贴账户ID,每行一个或用逗号分隔,例如:
10001,10002,10003
10004"
rows={4}
style={{ borderRadius: 8, marginBottom: 16 }}
/>
{accountIdList.length > 0 && (
<>
<Typography.Text style={{ fontSize: 13, color: '#64748b', marginBottom: 8, display: 'block' }}>
{accountIdList.length}
</Typography.Text>
<Table
dataSource={accountIdList.map((item, index) => ({ ...item, key: index }))}
columns={[
{
title: '账户ID',
dataIndex: 'accountId',
key: 'accountId',
width: '70%',
},
{
title: '授权状态',
dataIndex: 'authStatus',
key: 'authStatus',
width: '30%',
render: (status: 'pending' | 'authorized' | 'failed') => {
if (status === 'authorized') {
return <Tag color="success" style={{ borderRadius: 4 }}></Tag>;
}
if (status === 'failed') {
return <Tag color="error" style={{ borderRadius: 4 }}></Tag>;
}
return <Tag color="default" style={{ borderRadius: 4 }}></Tag>;
},
},
]}
pagination={false}
size="small"
style={{
maxHeight: 300,
overflow: 'auto',
border: '1px solid #e8e8e8',
borderRadius: 8,
}}
/>
<Button
type="text"
danger
onClick={() => {
setAccountIdList([]);
setAccountIdInput('');
setSelectedOauthItems([]);
}}
style={{ marginTop: 8 }}
>
</Button>
</>
)}
{/* 上传进度区域 */}
{batchUploadProgress.length > 0 && (
<div style={{
marginTop: 16,
padding: 12,
border: '1px solid #e8e8e8',
borderRadius: 8,
maxHeight: 200,
overflowY: 'auto',
}}>
<Typography.Text strong style={{ fontSize: 13, color: '#475569', marginBottom: 8, display: 'block' }}>
({batchUploadProgress.filter(p => p.status === 'success').length}/{batchUploadProgress.length})
</Typography.Text>
{batchUploadProgress.map((progress) => (
<div
key={progress.itemId}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: 8,
marginBottom: 6,
borderRadius: 6,
background: progress.status === 'error' ? '#fef2f2' : progress.status === 'success' ? '#f0fdf4' : '#f9fafb',
}}
>
<div style={{ width: 16, height: 16, borderRadius: 50, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{progress.status === 'success' && <span style={{ color: '#10b981', fontSize: 12 }}></span>}
{progress.status === 'error' && <span style={{ color: '#ef4444', fontSize: 12 }}></span>}
{progress.status === 'uploading' && <span style={{ color: '#6366f1', fontSize: 12 }}></span>}
{progress.status === 'pending' && <span style={{ color: '#9ca3af', fontSize: 12 }}></span>}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, color: '#374151' }}>
{progress.itemId}
</div>
{progress.message && (
<div style={{ fontSize: 11, color: progress.status === 'error' ? '#ef4444' : '#64748b', marginTop: 2 }}>
{progress.message}
</div>
)}
</div>
</div>
))}
</div>
)}
{/* 操作按钮 */}
<div style={{
display: 'flex',
@@ -1643,7 +1291,7 @@ const GeneratedRecord: React.FC = () => {
setUploadConfigModalVisible(false);
setAccountIdList([]);
setAccountIdInput('');
setSelectedOauthItems([]);
setSelectedOauthItems(undefined);
setBatchUploadProgress([]);
}}
style={{ borderRadius: 8 }}
@@ -1663,6 +1311,112 @@ const GeneratedRecord: React.FC = () => {
</div>
</Modal>
{/* 上传任务历史弹窗 */}
<Modal
title="上传任务历史"
open={uploadHistoryModalVisible}
onCancel={() => setUploadHistoryModalVisible(false)}
footer={null}
width={800}
style={{ borderRadius: 8 }}
>
<div style={{ marginBottom: 16 }}>
<Select
value={uploadHistoryStatus}
onChange={(value) => setUploadHistoryStatus(value)}
placeholder="选择状态"
style={{ width: 200, marginRight: 12 }}
options={[
{ value: '1', label: '待上传' },
{ value: '2', label: '上传中' },
{ value: '3', label: '上传成功' },
{ value: '4', label: '上传失败' },
]}
allowClear
/>
<Button
type="primary"
onClick={handleUploadHistorySearch}
style={{ borderRadius: 8 }}
>
</Button>
</div>
<Table
dataSource={uploadHistoryList}
columns={[
{
title: '任务ID',
dataIndex: 'id',
key: 'id',
width: 150,
},
{
title: '资源ID',
dataIndex: 'resource_id',
key: 'resource_id',
width: 150,
},
{
title: '账户ID',
dataIndex: 'advertiser_id',
key: 'advertiser_id',
width: 120,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => {
const statusMap: Record<string, string> = {
'1': '待上传',
'2': '上传中',
'3': '上传成功',
'4': '上传失败',
};
const statusColorMap: Record<string, string> = {
'1': '#f59e0b',
'2': '#6366f1',
'3': '#10b981',
'4': '#ef4444',
};
return (
<Tag color={statusColorMap[status] || '#64748b'} style={{ borderRadius: 4 }}>
{statusMap[status] || status}
</Tag>
);
},
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
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}
pagination={{
current: uploadHistoryPage,
pageSize: uploadHistoryPageSize,
total: uploadHistoryTotal,
showSizeChanger: true,
showTotal: (total) => `${total} 条记录`,
onChange: handleUploadHistoryPageChange,
}}
rowKey={(record, index) => record.id || record.resource_id || index}
size="small"
/>
</Modal>
{/* 预览弹窗 */}
{previewVisible && previewItem && (
<div