Merge branch 'main' of gitee.com:wg123/video-gen into main

This commit is contained in:
18610128193
2026-06-26 13:25:45 +08:00
21 changed files with 1407 additions and 484 deletions
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DAaavCN6.js"></script>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-BLuHlLoF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
+2
View File
@@ -31,6 +31,7 @@ import AdminShotReplications from './pages/AdminShotReplications';
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
import AdminContactRequests from './pages/AdminContactRequests';
import { useAdminStore } from './store';
@@ -103,6 +104,7 @@ const App = () => {
<Route path="authoriza" element={<AdminAuthoriz />} />
<Route path="consume" element={<AdminConsume />} />
<Route path="platform" element={<AdminPlatform />} />
<Route path="contact-requests" element={<AdminContactRequests />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+4 -1
View File
@@ -431,7 +431,10 @@ export async function getOpenTypeList(params?: {
page_size?: number;
type_name?: string;
open_type?: number;
}): Promise<{ total: number; items: any[] }> {
}): Promise<{
pagination: any;
data: never[];
}> {
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));
@@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Typography, message, Modal, Card, Popconfirm, Empty } from 'antd';
import { CheckOutlined, DeleteOutlined, EyeOutlined, FilterOutlined, MessageOutlined } from '@ant-design/icons';
import { useAdminStore } from '../store';
import { api } from '../api/client';
import { formatDate } from '../utils/formatDate';
interface ContactRequest {
id: string;
userId: string;
phone: string;
companyName: string;
industry: string;
name: string;
message: string | null;
isHandled: boolean;
createdAt: string;
}
const AdminContactRequests: React.FC = () => {
const [data, setData] = useState<ContactRequest[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [isHandledFilter, setIsHandledFilter] = useState<boolean | null>(null);
const [selectedItem, setSelectedItem] = useState<ContactRequest | null>(null);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const { user } = useAdminStore();
const fetchData = async () => {
if (!user?.isAdmin) return;
setLoading(true);
try {
const query = new URLSearchParams();
query.set('page', String(page));
query.set('page_size', String(pageSize));
if (isHandledFilter !== null) {
query.set('is_handled', String(isHandledFilter));
}
const res = await api.get<{ items: ContactRequest[]; total: number }>(`/contact/requests?${query.toString()}`);
setData(res.items);
setTotal(res.total);
} catch (err: any) {
message.error(err?.message || '获取失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [page, pageSize, isHandledFilter]);
const handleMarkHandled = async (id: string) => {
try {
await api.put(`/contact/requests/${id}/handle`);
message.success('已标记为处理');
fetchData();
} catch (err: any) {
message.error(err?.message || '操作失败');
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/contact/requests/${id}`);
message.success('已删除');
fetchData();
} catch (err: any) {
message.error(err?.message || '删除失败');
}
};
const handleViewDetail = (item: ContactRequest) => {
setSelectedItem(item);
setDetailModalOpen(true);
};
const handlePageChange = (p: number, ps: number) => {
setPage(p);
setPageSize(ps);
};
const columns = [
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 100,
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 120,
},
{
title: '公司名称',
dataIndex: 'companyName',
key: 'companyName',
width: 150,
ellipsis: true,
},
{
title: '行业',
dataIndex: 'industry',
key: 'industry',
width: 120,
},
{
title: '状态',
dataIndex: 'isHandled',
key: 'isHandled',
width: 80,
render: (isHandled: boolean) => (
<Tag color={isHandled ? 'green' : 'orange'}>
{isHandled ? '已处理' : '待处理'}
</Tag>
),
},
{
title: '提交时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (date: string) => formatDate(date),
},
{
title: '操作',
key: 'actions',
width: 180,
render: (_: unknown, record: ContactRequest) => (
<Space size={4}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>
</Button>
{!record.isHandled && (
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleMarkHandled(record.id)}>
</Button>
)}
<Popconfirm title="确定删除该记录?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" danger size="small" icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<MessageOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple"> {total} </Tag>
</Space>
<div style={{ display: 'flex', gap: 8 }}>
<Button
type={isHandledFilter === null ? 'primary' : 'default'}
onClick={() => setIsHandledFilter(null)}
icon={<FilterOutlined />}
size="small"
>
</Button>
<Button
type={isHandledFilter === false ? 'primary' : 'default'}
onClick={() => setIsHandledFilter(false)}
size="small"
>
</Button>
<Button
type={isHandledFilter === true ? 'primary' : 'default'}
onClick={() => setIsHandledFilter(true)}
size="small"
>
</Button>
</div>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>...</div>
) : data.length === 0 ? (
<Empty description="暂无联系请求" style={{ padding: '40px 0' }} />
) : (
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 900 }}
/>
)}
</Card>
<Modal
title={<Space><EyeOutlined /></Space>}
open={detailModalOpen}
onCancel={() => setDetailModalOpen(false)}
footer={null}
width={500}
>
{selectedItem && (
<div style={{ padding: 8 }}>
<div style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ marginBottom: 16 }}>
{selectedItem.name}
<Tag color={selectedItem.isHandled ? 'green' : 'orange'} style={{ marginLeft: 12 }}>
{selectedItem.isHandled ? '已处理' : '待处理'}
</Tag>
</Typography.Title>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12 }}>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{selectedItem.phone}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{selectedItem.companyName}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{selectedItem.industry}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{formatDate(selectedItem.createdAt)}</Typography.Text>
{selectedItem.message && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{selectedItem.message}</Typography.Text>
</>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 12 }}>
{!selectedItem.isHandled && (
<Button
type="primary"
onClick={() => {
handleMarkHandled(selectedItem.id);
setDetailModalOpen(false);
}}
icon={<CheckOutlined />}
>
</Button>
)}
<Button onClick={() => setDetailModalOpen(false)}></Button>
</div>
</div>
)}
</Modal>
</div>
);
};
export default AdminContactRequests;
+103 -60
View File
@@ -10,8 +10,8 @@ import { formatDate } from '../utils/formatDate';
interface OpenType {
id: string;
open_type: number;
type_name: string;
openType: number;
typeName: string;
description: string;
thumb?: string;
createdAt: string;
@@ -49,6 +49,8 @@ const AdminPlatform: React.FC = () => {
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [updateModalVisible, setUpdateModalVisible] = useState(false);
const [currentOpenType, setCurrentOpenType] = useState<OpenType | null>(null);
const [createThumbUrl, setCreateThumbUrl] = useState('');
const [updateThumbUrl, setUpdateThumbUrl] = useState('');
const [createForm] = Form.useForm();
const [updateForm] = Form.useForm();
@@ -60,8 +62,8 @@ const AdminPlatform: React.FC = () => {
page: p || page,
page_size: ps || pageSize,
});
setOpenTypes(res.items || []);
setTotal(res.total || 0);
setOpenTypes(res.data || []);
setTotal(res.pagination.total || 0);
} catch {
message.error('加载开户方式列表失败');
} finally {
@@ -76,16 +78,16 @@ const AdminPlatform: React.FC = () => {
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,
thumb: createThumbUrl,
});
message.success('创建成功');
setCreateModalVisible(false);
createForm.resetFields();
setCreateThumbUrl('');
load();
} catch (e: any) {
message.error(e?.message || '创建失败');
@@ -95,7 +97,7 @@ const AdminPlatform: React.FC = () => {
const handleDetail = async (id: string) => {
try {
const openType = await getOpenType(id);
setCurrentOpenType(openType);
setCurrentOpenType(openType.data || {});
setDetailModalVisible(true);
} catch (e: any) {
message.error(e?.message || '获取详情失败');
@@ -105,19 +107,14 @@ const AdminPlatform: React.FC = () => {
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,
});
setCurrentOpenType(openType.data || {});
setUpdateThumbUrl(openType.thumb || '');
setUpdateModalVisible(true);
} catch (e: any) {
message.error(e?.message || '获取详情失败');
}
};
const handleSaveUpdate = async () => {
if (!currentOpenType) return;
try {
@@ -126,12 +123,13 @@ const AdminPlatform: React.FC = () => {
open_type: values.open_type,
type_name: values.type_name,
description: values.description,
thumb: values.thumb,
thumb: updateThumbUrl,
});
message.success('更新成功');
setUpdateModalVisible(false);
updateForm.resetFields();
setCurrentOpenType(null);
setUpdateThumbUrl('');
load();
} catch (e: any) {
message.error(e?.message || '更新失败');
@@ -166,13 +164,13 @@ const AdminPlatform: React.FC = () => {
},
{
title: '开户类型',
dataIndex: 'open_type',
dataIndex: 'openType',
width: 100,
render: (v: number) => <Typography.Text>{v}</Typography.Text>,
},
{
title: '类型名称',
dataIndex: 'type_name',
dataIndex: 'typeName',
width: 150,
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
@@ -191,9 +189,12 @@ const AdminPlatform: React.FC = () => {
title: '缩略图',
dataIndex: 'thumb',
width: 120,
render: (v: string) => (
v ? <img src={v} alt="thumb" style={{ width: 80, height: 60, objectFit: 'cover' }} /> : '-'
),
render: (v: string) => {
if (!v) return '-';
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const fullUrl = v.startsWith('http') ? v : `${baseUrl}${v}`;
return <img src={fullUrl} alt="thumb" style={{ width: 80, height: 60, objectFit: 'cover' }} />;
},
},
{
title: '创建时间',
@@ -291,7 +292,9 @@ const AdminPlatform: React.FC = () => {
onCancel={() => {
setCreateModalVisible(false);
createForm.resetFields();
setCreateThumbUrl('');
}}
mask={{ closable: false }}
okText="创建"
cancelText="取消"
width={600}
@@ -326,28 +329,28 @@ const AdminPlatform: React.FC = () => {
>
<Input.TextArea placeholder="请输入描述" rows={4} />
</Form.Item>
<Form.Item
name="thumb"
label="缩略图"
>
<Form.Item label="缩略图">
<Upload
listType="picture-card"
fileList={createThumbUrl ? [{ uid: '1', name: 'thumb', status: 'done', url: createThumbUrl }] : []}
customRequest={async ({ file, onSuccess, onError }) => {
try {
const uploadFile = assertUploadFile(file);
const res = await uploadImage(uploadFile);
console.log(res);
createForm.setFieldsValue({ thumb: res.url });
setCreateThumbUrl(res.url);
onSuccess?.({ url: res.url });
} catch (e) {
onError?.(normalizeUploadError(e));
}
}}
onRemove={() => setCreateThumbUrl('')}
>
<div>
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
<div style={{ marginTop: 8 }}></div>
</div>
{!createThumbUrl && (
<div>
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
<div style={{ marginTop: 8 }}></div>
</div>
)}
</Upload>
</Form.Item>
</Form>
@@ -356,32 +359,68 @@ const AdminPlatform: React.FC = () => {
<Modal
title="开户方式详情"
open={detailModalVisible}
onOk={() => setDetailModalVisible(false)}
onCancel={() => {
setDetailModalVisible(false);
setCurrentOpenType(null);
}}
mask={{ closable: false }}
okText="关闭"
cancelText="取消"
width={600}
width={520}
>
{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 style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 16, background: '#f8fafc', borderRadius: 8 }}>
<div style={{
width: 80, height: 60, borderRadius: 6, overflow: 'hidden',
background: currentOpenType.thumb ? 'transparent' : '#e2e8f0',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{currentOpenType.thumb ? (
<img
src={currentOpenType.thumb.startsWith('http') ? currentOpenType.thumb : `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${currentOpenType.thumb}`}
alt="thumb"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
)}
</div>
<div>
<div style={{ fontSize: 16, fontWeight: 600 }}>{currentOpenType.typeName}</div>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>: {currentOpenType.openType}</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', gap: 12 }}>
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}>ID</Typography.Text>
<Typography.Text>{currentOpenType.id}</Typography.Text>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}></Typography.Text>
<Typography.Text>{currentOpenType.openType}</Typography.Text>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}></Typography.Text>
<Typography.Text>{currentOpenType.typeName}</Typography.Text>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Typography.Text type="secondary" style={{ width: 80 }}></Typography.Text>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 6, fontSize: 13, lineHeight: 1.6 }}>
{currentOpenType.description || '-'}
</div>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}></Typography.Text>
<Typography.Text>{formatDate(currentOpenType.createdAt)}</Typography.Text>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}></Typography.Text>
<Typography.Text>{formatDate(currentOpenType.updatedAt)}</Typography.Text>
</div>
</div>
</div>
)}
</Modal>
@@ -395,6 +434,16 @@ const AdminPlatform: React.FC = () => {
updateForm.resetFields();
setCurrentOpenType(null);
}}
afterOpenChange={(open) => {
if (open && currentOpenType) {
updateForm.setFieldsValue({
open_type: currentOpenType.openType,
type_name: currentOpenType.typeName,
description: currentOpenType.description,
});
}
}}
mask={{ closable: false }}
okText="更新"
cancelText="取消"
width={600}
@@ -424,29 +473,23 @@ const AdminPlatform: React.FC = () => {
>
<Input.TextArea placeholder="请输入描述" rows={4} />
</Form.Item>
<Form.Item
name="thumb"
label="缩略图"
>
<Form.Item label="缩略图">
<Upload
listType="picture-card"
defaultFileList={
currentOpenType?.thumb
? [{ uid: '1', name: 'thumb', status: 'done', url: currentOpenType.thumb }]
: []
}
fileList={updateThumbUrl ? [{ uid: '1', name: 'thumb', status: 'done', url: updateThumbUrl }] : []}
customRequest={async ({ file, onSuccess, onError }) => {
try {
const uploadFile = assertUploadFile(file);
const res = await uploadImage(uploadFile);
updateForm.setFieldsValue({ thumb: res.url });
setUpdateThumbUrl(res.url);
onSuccess?.({ url: res.url });
} catch (e) {
onError?.(normalizeUploadError(e));
}
}}
onRemove={() => setUpdateThumbUrl('')}
>
{!updateForm.getFieldValue('thumb') && (
{!updateThumbUrl && (
<div>
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
<div style={{ marginTop: 8 }}></div>
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminplatform.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminplatform.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
+2
View File
@@ -25,6 +25,7 @@ from app.api.v1.pre_test_template import router as pre_test_template_router
from app.api.v1.material_consumption import router as material_consumption_router
from app.api.v1.open_type import router as open_type_router
from app.api.v1.resources_material import router as resources_material_router
from app.api.v1.contact import router as contact_router
from app.api.admin import router as admin_module_router
api_router = APIRouter()
@@ -53,4 +54,5 @@ api_router.include_router(pre_test_template_router)
api_router.include_router(material_consumption_router)
api_router.include_router(open_type_router)
api_router.include_router(resources_material_router)
api_router.include_router(contact_router)
api_router.include_router(admin_module_router)
+26
View File
@@ -10,6 +10,7 @@ from app.dependencies import (
get_current_user_allow_password_pending,
get_db,
)
from app.models.credit_record import CreditRecord
from app.models.system_config import SystemConfig
from app.models.user import User
from app.schemas.auth import (
@@ -86,6 +87,20 @@ async def _get_register_credits(db: AsyncSession) -> int:
return int(value) if value else 100
async def _add_register_credit_record(db: AsyncSession, user: User, credits: int) -> None:
if credits <= 0:
return
record = CreditRecord(
id=generate_id(),
user_id=user.id,
type="recharge",
amount=credits,
balance_after=user.credits,
description=f"注册赠送 {credits} 积分",
)
db.add(record)
async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
enabled_result = await db.execute(
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits_enabled").limit(1)
@@ -108,6 +123,16 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
return
user.credits += credits
record = CreditRecord(
id=generate_id(),
user_id=user.id,
type="recharge",
amount=credits,
balance_after=user.credits,
description=f"每日登录赠送 {credits} 积分",
)
db.add(record)
@router.post(
@@ -204,6 +229,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
db.add(user)
await db.flush()
await _add_register_credit_record(db, user, register_credits)
await _assign_default_frontend_menus(db, user)
user.credits = round(user.credits, 2)
+140
View File
@@ -0,0 +1,140 @@
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.contact_request import ContactRequest
from app.models.user import User
from app.schemas.contact import ContactRequestCreate, ContactRequestListOut, ContactRequestOut
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/contact", tags=["contact"])
@router.post("/request", summary="提交联系请求", status_code=status.HTTP_201_CREATED)
async def create_contact_request(
request: ContactRequestCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
count = await db.execute(
select(func.count(ContactRequest.id))
.where(ContactRequest.user_id == user.id)
.where(ContactRequest.created_at >= today_start)
.where(ContactRequest.created_at < today_end)
)
daily_count = count.scalar_one()
if daily_count >= 1:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="每个账号每天只能提交一次联系我们"
)
contact_request = ContactRequest(
id=generate_id(),
user_id=user.id,
phone=request.phone,
company_name=request.company_name,
industry=request.industry,
name=request.name,
message=request.message,
)
db.add(contact_request)
await db.commit()
await db.refresh(contact_request)
return {"message": "提交成功,我们会尽快与您联系"}
@router.get("/requests", summary="获取联系请求列表", response_model=ContactRequestListOut)
async def get_contact_requests(
page: int = 1,
page_size: int = 20,
is_handled: bool | None = None,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
query = select(ContactRequest).order_by(ContactRequest.created_at.desc())
if is_handled is not None:
query = query.where(ContactRequest.is_handled == is_handled)
offset = (page - 1) * page_size
result = await db.execute(query.offset(offset).limit(page_size))
items = result.scalars().all()
count_result = await db.execute(select(func.count(ContactRequest.id)))
total = count_result.scalar_one()
return {"items": items, "total": total}
@router.get("/requests/{request_id}", summary="获取联系请求详情", response_model=ContactRequestOut)
async def get_contact_request(
request_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
contact_request = result.scalar_one_or_none()
if not contact_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
return contact_request
@router.put("/requests/{request_id}/handle", summary="标记为已处理")
async def mark_as_handled(
request_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
contact_request = result.scalar_one_or_none()
if not contact_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
contact_request.is_handled = True
await db.commit()
await db.refresh(contact_request)
return {"message": "已标记为处理"}
@router.delete("/requests/{request_id}", summary="删除联系请求")
async def delete_contact_request(
request_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
contact_request = result.scalar_one_or_none()
if not contact_request:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
await db.delete(contact_request)
await db.commit()
return {"message": "删除成功"}
+1
View File
@@ -424,6 +424,7 @@ async def _seed_data():
("/settings", "系统设置", "SettingOutlined", 4, "系统设置"),
("/operation-logs", "操作日志", "DatabaseOutlined", 5, "系统设置"),
("/oauthapp-list", "授权应用", "MenuOutlined", 28, "系统设置"),
("/contact-requests", "联系请求", "MessageCircleOutlined", 29, "系统设置"),
]
for path, label, icon, order, parent_group in admin_pages:
db.add(
@@ -0,0 +1,17 @@
from sqlalchemy import Boolean, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class ContactRequest(Base, TimestampMixin):
__tablename__ = "contact_requests"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id"), index=True)
phone: Mapped[str] = mapped_column(String(20), index=True)
company_name: Mapped[str] = mapped_column(String(128))
industry: Mapped[str] = mapped_column(String(64))
name: Mapped[str] = mapped_column(String(64))
message: Mapped[str | None] = mapped_column(Text, nullable=True)
is_handled: Mapped[bool] = mapped_column(Boolean, default=False)
+31
View File
@@ -0,0 +1,31 @@
from datetime import datetime
from pydantic import BaseModel, Field
class ContactRequestCreate(BaseModel):
phone: str = Field(..., description="手机号")
company_name: str = Field(..., description="公司名称")
industry: str = Field(..., description="行业")
name: str = Field(..., description="姓名")
message: str | None = Field(None, description="留言")
class ContactRequestOut(BaseModel):
id: str
user_id: str
phone: str
company_name: str
industry: str
name: str
message: str | None
is_handled: bool
created_at: datetime
class Config:
from_attributes = True
class ContactRequestListOut(BaseModel):
items: list[ContactRequestOut]
total: int
+7 -1
View File
@@ -11,7 +11,7 @@ Requires-Dist: alembic>=1.14.0
Requires-Dist: pydantic>=2.10.0
Requires-Dist: pydantic-settings>=2.6.0
Requires-Dist: pyjwt>=2.10.0
Requires-Dist: passlib[bcrypt]>=1.7.4
Requires-Dist: bcrypt>=4.0.0
Requires-Dist: httpx>=0.28.0
Requires-Dist: python-multipart>=0.0.17
Requires-Dist: cryptography>=44.0.0
@@ -22,6 +22,12 @@ Requires-Dist: redis>=5.2.0; extra == "redis"
Provides-Extra: celery
Requires-Dist: celery>=5.4.0; extra == "celery"
Requires-Dist: redis>=5.2.0; extra == "celery"
Provides-Extra: alipay
Requires-Dist: alipay-sdk-python>=3.7.1160; extra == "alipay"
Provides-Extra: wxpay
Requires-Dist: wechatpayv3>=2.0.2; extra == "wxpay"
Provides-Extra: volc
Requires-Dist: volcengine-python-sdk>=1.1.0; extra == "volc"
Provides-Extra: dev
Requires-Dist: pytest>=8.3.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
@@ -4,56 +4,196 @@ app/config.py
app/dependencies.py
app/main.py
app/api/__init__.py
app/api/admin/__init__.py
app/api/admin/video_prompt_schema_config.py
app/api/v1/__init__.py
app/api/v1/admin.py
app/api/v1/auth.py
app/api/v1/captcha.py
app/api/v1/contact.py
app/api/v1/credits.py
app/api/v1/generation.py
app/api/v1/generation_ai.py
app/api/v1/hot_opening_replicate.py
app/api/v1/image_engines.py
app/api/v1/industries.py
app/api/v1/material_consumption.py
app/api/v1/menu_configs.py
app/api/v1/notifications.py
app/api/v1/open_type.py
app/api/v1/payments.py
app/api/v1/pre_test_template.py
app/api/v1/projects.py
app/api/v1/recharge_packages.py
app/api/v1/resources_material.py
app/api/v1/shot_replicate.py
app/api/v1/sms.py
app/api/v1/test.py
app/api/v1/upload_material.py
app/api/v1/user_oauth.py
app/api/v1/user_oauth_app.py
app/api/v1/video_engines.py
app/enums/__init__.py
app/enums/common.py
app/enums/credit_record.py
app/enums/hot_opening_replicate.py
app/enums/module_generation_flow.py
app/enums/shot_replicate.py
app/enums/token_usage.py
app/enums/user.py
app/enums/video_prompt_schema.py
app/middleware/__init__.py
app/middleware/anti_crawler.py
app/middleware/logging.py
app/middleware/rate_limit.py
app/middleware/request_encrypt.py
app/models/__init__.py
app/models/base.py
app/models/chat_generation_task.py
app/models/chat_generation_task_event.py
app/models/chat_provider_call_log.py
app/models/contact_request.py
app/models/credit_ratio.py
app/models/credit_record.py
app/models/generated_resource.py
app/models/generation_record.py
app/models/image_engine.py
app/models/industry_config.py
app/models/material_cost.py
app/models/menu_config.py
app/models/model_config.py
app/models/module_generation_project.py
app/models/module_generation_step.py
app/models/notification.py
app/models/notification_read.py
app/models/open_type.py
app/models/operation_log.py
app/models/payment_order.py
app/models/pre_test_template.py
app/models/project.py
app/models/recharge_package.py
app/models/resources_material.py
app/models/shot_replicate_segment.py
app/models/shot_replicate_task_set.py
app/models/system_config.py
app/models/token_usage.py
app/models/upload_task.py
app/models/user.py
app/models/user_oauth.py
app/models/user_oauth_account.py
app/models/user_oauth_app.py
app/models/user_resource_month_stat.py
app/models/user_resource_total_stat.py
app/models/video_engine.py
app/schemas/__init__.py
app/schemas/admin.py
app/schemas/auth.py
app/schemas/captcha.py
app/schemas/common.py
app/schemas/contact.py
app/schemas/credit.py
app/schemas/credit_ratio.py
app/schemas/generation.py
app/schemas/generation_ai.py
app/schemas/hot_opening_replicate.py
app/schemas/image_engine.py
app/schemas/industry.py
app/schemas/menu.py
app/schemas/notification.py
app/schemas/open_type.py
app/schemas/payment.py
app/schemas/pre_test_template.py
app/schemas/project.py
app/schemas/recharge_package.py
app/schemas/resources_material.py
app/schemas/shot_replicate.py
app/schemas/sms.py
app/schemas/user.py
app/schemas/user_oauth.py
app/schemas/user_oauth_app.py
app/schemas/video_engine.py
app/schemas/video_prompt_schema_config.py
app/services/__init__.py
app/services/admin_credit_record_service.py
app/services/auth.py
app/services/captcha.py
app/services/celery_download_recovery_service.py
app/services/credit_ratio_service.py
app/services/credit_record_meta_service.py
app/services/credits.py
app/services/error_codes.py
app/services/generation_ai_service.py
app/services/generation_billing_service.py
app/services/generation_download_service.py
app/services/generation_log_service.py
app/services/generation_module_hook_service.py
app/services/generation_prompt_service.py
app/services/generation_provider_service.py
app/services/generation_provider_types.py
app/services/generation_recovery_service.py
app/services/generation_refund_service.py
app/services/generation_task_factory_service.py
app/services/hot_opening_replicate_service.py
app/services/hot_opening_video_prompt_service.py
app/services/image_gen.py
app/services/llm.py
app/services/log_config.py
app/services/material_consumption_queue.py
app/services/material_consumption_service.py
app/services/module_async_recovery_service.py
app/services/module_generation_flow_base_service.py
app/services/module_generation_log_service.py
app/services/module_generation_step_common_service.py
app/services/module_generation_step_update_service.py
app/services/notification.py
app/services/operation_log.py
app/services/payment.py
app/services/pre_test_template_service.py
app/services/provider_limit.py
app/services/redis_registry_service.py
app/services/resource_accounting_service.py
app/services/resource_signed_url_service.py
app/services/resources_material_service.py
app/services/shot_replicate_flow_service.py
app/services/shot_replicate_recovery_service.py
app/services/shot_replicate_taskset_service.py
app/services/shot_video_analysis_service.py
app/services/shot_video_split_service.py
app/services/sms.py
app/services/upload_material_service.py
app/services/upload_queue.py
app/services/upload_video_asset_service.py
app/services/user_oauth_app_service.py
app/services/user_oauth_service.py
app/services/video_cover_service.py
app/services/video_gen.py
app/services/video_prompt_schema_config_service.py
app/services/video_queue.py
app/services/video_url.py
app/tasks/__init__.py
app/tasks/async_runner.py
app/tasks/celery_app.py
app/tasks/cleanup.py
app/tasks/generation_create_tasks.py
app/tasks/generation_download_tasks.py
app/tasks/generation_poll_tasks.py
app/tasks/generation_recovery_tasks.py
app/tasks/hot_opening_replicate_tasks.py
app/tasks/material_consumption_task.py
app/tasks/module_async_recovery_tasks.py
app/tasks/pre_test_result_task.py
app/tasks/shot_replicate_flow_tasks.py
app/tasks/shot_replicate_tasks.py
app/tasks/token_refresh_task.py
app/tasks/user_oauth_tasks.py
app/tasks/video_generation.py
app/utils/__init__.py
app/utils/area.py
app/utils/douyinApi.py
app/utils/douyinRequest.py
app/utils/exceptions.py
app/utils/id_gen.py
app/utils/logger.py
app/utils/redis.py
app/utils/security.py
videogen_api.egg-info/PKG-INFO
@@ -6,11 +6,14 @@ alembic>=1.14.0
pydantic>=2.10.0
pydantic-settings>=2.6.0
pyjwt>=2.10.0
passlib[bcrypt]>=1.7.4
bcrypt>=4.0.0
httpx>=0.28.0
python-multipart>=0.0.17
cryptography>=44.0.0
[alipay]
alipay-sdk-python>=3.7.1160
[celery]
celery>=5.4.0
redis>=5.2.0
@@ -25,3 +28,9 @@ asyncpg>=0.30.0
[redis]
redis>=5.2.0
[volc]
volcengine-python-sdk>=1.1.0
[wxpay]
wechatpayv3>=2.0.2
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-Dm7vTAAt.js"></script>
<script type="module" crossorigin src="/assets/index-59NFnZ6x.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-eLRg4pQk.css">
</head>
<body>
+13
View File
@@ -666,6 +666,19 @@ export async function getMaterialConsumptionFields(): Promise<any> {
return api.get('/material-consumption/fields');
}
// ── Contact ────────────────────────────────────────────────
export interface ContactRequestParams {
phone: string;
company_name: string;
industry: string;
name: string;
message?: string;
}
export async function createContactRequest(params: ContactRequestParams): Promise<any> {
return api.post('/contact/request', params);
}
// 查询上传素材列表
export interface ResourcesMaterialListParams {
advertiser_id?: string;
@@ -53,10 +53,13 @@ import {
ApiOutlined,
DatabaseOutlined,
CloudServerOutlined,
MessageOutlined,
DownOutlined,
InfoOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount } from '../../api';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest } from '../../api';
import NotificationPopup from '../NotificationPopup';
interface MenuConfig {
@@ -167,6 +170,10 @@ const AppLayout: React.FC = () => {
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const currentOrderNoRef = useRef<string | null>(null);
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
const [contactModalOpen, setContactModalOpen] = useState(false);
const [contactForm] = Form.useForm();
const [contactHovered, setContactHovered] = useState(false);
const [submittingContact, setSubmittingContact] = useState(false);
// LocalStorage keys
const PENDING_ORDER_KEY = 'pending_payment_order';
@@ -316,6 +323,31 @@ const AppLayout: React.FC = () => {
setRechargeModalOpen(true);
};
const handleContactSubmit = async () => {
if (!user) {
message.warning('请先登录');
return;
}
try {
const values = await contactForm.validateFields();
setSubmittingContact(true);
await createContactRequest({
phone: values.phone,
company_name: values.companyName,
industry: values.industry,
name: values.name,
message: values.message,
});
message.success('提交成功,我们会尽快与您联系');
setContactModalOpen(false);
contactForm.resetFields();
} catch (err: any) {
message.error(err?.message || '提交失败');
} finally {
setSubmittingContact(false);
}
};
const stopPolling = useCallback(() => {
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
@@ -674,6 +706,20 @@ const AppLayout: React.FC = () => {
<Typography.Text style={{ color: '#64748b', letterSpacing: 0 }}></Typography.Text>
<Typography.Text strong style={{ color: '#6366f1', fontSize: 20, fontWeight: 600 }}>{user?.credits ?? 0}</Typography.Text>
</Space>
<div style={{
padding: '12px 16px',
background: 'rgba(99, 102, 241, 0.06)',
borderRadius: 10,
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 8,
}}>
<InfoOutlined style={{ color: '#6366f1', fontSize: 14 }} />
<Typography.Text style={{ color: '#ff0000ff', fontSize: 13 }}>
/
</Typography.Text>
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{rechargeOptions.map((opt, idx) => {
const g = GRADIENTS[idx % GRADIENTS.length];
@@ -958,6 +1004,142 @@ const AppLayout: React.FC = () => {
</Modal>
<NotificationPopup />
{/* Contact Button */}
<div style={{
position: 'fixed',
right: 24,
bottom: 24,
zIndex: 1000,
}}>
<div style={{
position: 'relative',
}}>
<div
style={{
position: 'absolute',
right: 64,
bottom: 8,
padding: '8px 16px',
background: '#1e293b',
color: '#ffffff',
borderRadius: 8,
fontSize: 13,
whiteSpace: 'nowrap',
opacity: contactHovered ? 1 : 0,
transition: 'opacity 0.2s ease',
pointerEvents: 'none',
}}
>
</div>
<button
onClick={() => setContactModalOpen(true)}
style={{
width: 56,
height: 56,
borderRadius: '50%',
border: 'none',
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
color: '#ffffff',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 20px rgba(99, 102, 241, 0.4)',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.boxShadow = '0 6px 24px rgba(99, 102, 241, 0.5)';
setContactHovered(true);
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 4px 20px rgba(99, 102, 241, 0.4)';
setContactHovered(false);
}}
>
<MessageOutlined style={{ fontSize: 20 }} />
</button>
</div>
</div>
{/* Contact Modal */}
<Modal
title={<Space><MessageOutlined /></Space>}
open={contactModalOpen}
onCancel={() => { setContactModalOpen(false); contactForm.resetFields(); }}
footer={null}
width={480}
>
<div style={{ marginTop: 8 }}>
<Form form={contactForm} layout="vertical">
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: '请输入姓名' }]}
>
<Input placeholder="请输入您的姓名" size="large" />
</Form.Item>
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
]}
>
<Input placeholder="请输入您的手机号" size="large" />
</Form.Item>
<Form.Item
name="companyName"
label="公司名称"
rules={[{ required: true, message: '请输入公司名称' }]}
>
<Input placeholder="请输入公司名称" size="large" />
</Form.Item>
<Form.Item
name="industry"
label="您的行业"
rules={[{ required: true, message: '请输入您的行业' }]}
>
<Input placeholder="请输入您的行业" size="large" />
</Form.Item>
<Form.Item name="message" label="留言(选填)">
<Input.TextArea
placeholder="请输入您的需求或问题"
rows={3}
style={{ borderRadius: 10 }}
/>
</Form.Item>
</Form>
<div style={{ marginTop: 16, display: 'flex', gap: 12 }}>
<Button
size="large"
onClick={() => { setContactModalOpen(false); contactForm.resetFields(); }}
style={{ borderRadius: 10, flex: 1 }}
>
</Button>
<Button
type="primary"
size="large"
onClick={handleContactSubmit}
loading={submittingContact}
style={{
borderRadius: 10,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none',
flex: 1,
}}
>
</Button>
</div>
</div>
</Modal>
</Layout>
);
};
+248 -206
View File
@@ -39,21 +39,21 @@ const GeneratedRecord: React.FC = () => {
// 上传配置弹窗相关状态
const [uploadConfigModalVisible, setUploadConfigModalVisible] = useState(false);
const [accountIdList, setAccountIdList] = useState<{
const [accountIdLists, setAccountIdLists] = useState<{
accountId: string;
}[]>([]);
const [accountIdInput, setAccountIdInput] = useState('');
}[][]>([[]]);
const [accountIdInputs, setAccountIdInputs] = useState<string[]>(['']);
const [oauthList, setOauthList] = useState<any[]>([]);
const [oauthLoading, setOauthLoading] = useState(false);
const [oauthTotal, setOauthTotal] = useState(0);
const [selectedOauthItems, setSelectedOauthItems] = useState<{ value: string; label: string } | undefined>(undefined);
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 [oauthSelectOpens, setOauthSelectOpens] = useState<boolean[]>([false]);
// 上传任务历史弹窗相关状态
const [uploadHistoryModalVisible, setUploadHistoryModalVisible] = useState(false);
@@ -682,8 +682,21 @@ const GeneratedRecord: React.FC = () => {
message.warning('请先选择要上传的媒体');
return;
}
setAccountIdList([]);
setAccountIdInput('');
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setUploadConfigModalVisible(true);
};
const handleSinglePushToMedia = () => {
if (!previewItem) return;
const resourceId = getItemResourceId(previewItem);
setSelectedItems(new Set([resourceId]));
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setMaterialFileNames(new Map());
setUnifiedFileName('');
setUploadConfigModalVisible(true);
};
@@ -743,7 +756,8 @@ const GeneratedRecord: React.FC = () => {
// 批量上传素材
const handleStartBatchUpload = async () => {
if (!selectedOauthItems) {
const validOauthItems = selectedOauthItems.filter(item => item !== undefined);
if (validOauthItems.length === 0) {
message.warning('请先选择授权账户');
return;
}
@@ -759,42 +773,34 @@ const GeneratedRecord: React.FC = () => {
oauth_id: string;
source_model: string;
}[] = [];
const advertiserIds = accountIdList.map(account => account.accountId);
// 创建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);
});
});
const resourceIds = Array.from(selectedItems);
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';
for (let i = 0; i < validOauthItems.length; i++) {
const oauthItem = validOauthItems[i];
const advertiserIds = accountIdLists[i]?.map(account => account.accountId) || [];
if (advertiserIds.length === 0) {
message.warning(`${i + 1} 组授权账户未设置账户ID,已跳过`);
continue;
}
tasks.push({
advertiser_ids: advertiserIds,
resource_ids: [itemId],
oauth_id: selectedOauthItems.value,
source_model: sourceModel,
resource_ids: resourceIds,
oauth_id: oauthItem.value,
source_model: filterType === 'project' ? 'generation_records' : 'chat_generation_tasks',
});
}
await asyncBatchUploadMaterial({ tasks });
message.success(`已提交 ${tasks.length} 个上传任务,后台异步处理中`);
setIsSelectionMode(false);
setSelectedItems(new Set());
// 关闭弹窗并清理状态
setUploadConfigModalVisible(false);
setAccountIdList([]);
setAccountIdInput('');
setSelectedOauthItems(undefined);
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setMaterialFileNames(new Map());
setUnifiedFileName('');
} catch (error: any) {
@@ -1323,13 +1329,13 @@ const GeneratedRecord: React.FC = () => {
{/* 上传配置弹窗 */}
<Modal
title="批量上传配置"
title={selectedItems.size === 1 ? '上传配置' : '批量上传配置'}
open={uploadConfigModalVisible}
onCancel={() => {
setUploadConfigModalVisible(false);
setAccountIdList([]);
setAccountIdInput('');
setSelectedOauthItems(undefined);
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setMaterialFileNames(new Map());
setUnifiedFileName('');
}}
@@ -1457,149 +1463,196 @@ const GeneratedRecord: React.FC = () => {
})()}
</div>
</div>
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block' }}>
</Typography.Text>
<Select
value={selectedOauthItems}
onChange={(value) => {
setSelectedOauthItems(value as { value: string; label: string } | undefined);
}}
placeholder="点击选择授权账户"
style={{ width: '100%', marginBottom: 16, borderRadius: 8 }}
popupRender={() => (
<div style={{ padding: 8, width: 800, maxHeight: 500, overflow: 'auto' }}>
<Table
dataSource={oauthList}
columns={[
{
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: '授权账户角色',
dataIndex: 'accountRole',
key: 'accountRole',
width: 160,
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: '授权账户用户名',
dataIndex: 'accountUsername',
key: 'accountUsername',
width: 120,
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
},
]}
loading={oauthLoading}
pagination={{
current: oauthPage,
pageSize: oauthPageSize,
total: oauthTotal,
showSizeChanger: true,
showTotal: (total) => `${total} 条记录`,
onChange: (page, size) => {
setOauthPage(page);
setOauthPageSize(size);
loadOAuthList(page, size);
},
}}
rowKey="id"
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
const id = String(record.id);
setSelectedOauthItems({ value: id, label: String(record.accountUserid) });
setOauthSelectOpen(false);
},
style: {
cursor: 'pointer',
backgroundColor: selectedOauthItems?.value === String(record.id) ? '#e6f7ff' : undefined,
},
})}
/>
{selectedOauthItems.map((oauthItem, index) => (
<div key={index} style={{ marginBottom: 16, padding: 12, border: '1px solid #e2e8f0', borderRadius: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 14, color: '#475569' }}>
{index + 1}
</Typography.Text>
{selectedOauthItems.length > 1 && (
<Button
type="text"
danger
onClick={() => {
const newOauthItems = [...selectedOauthItems];
const newAccountIdInputs = [...accountIdInputs];
const newAccountIdLists = [...accountIdLists];
const newOauthSelectOpens = [...oauthSelectOpens];
newOauthItems.splice(index, 1);
newAccountIdInputs.splice(index, 1);
newAccountIdLists.splice(index, 1);
newOauthSelectOpens.splice(index, 1);
setSelectedOauthItems(newOauthItems);
setAccountIdInputs(newAccountIdInputs);
setAccountIdLists(newAccountIdLists);
setOauthSelectOpens(newOauthSelectOpens);
}}
>
</Button>
)}
</div>
)}
open={oauthSelectOpen}
onOpenChange={(open) => {
setOauthSelectOpen(open);
if (open) {
loadOAuthList(1, oauthPageSize);
}
}}
labelInValue
fieldNames={{ label: 'accountUserid', value: 'id' }}
/>
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block', marginTop: 16 }}>
ID
</Typography.Text>
<Input.TextArea
value={accountIdInput}
onChange={(e) => {
const value = e.target.value;
setAccountIdInput(value);
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 }));
const seen = new Set<string>();
const finalAccounts = textAccounts.filter(a => {
if (seen.has(a.accountId)) return false;
seen.add(a.accountId);
return true;
});
setAccountIdList(finalAccounts);
}}
placeholder="粘贴账户ID,每行一个或用逗号分隔,例如:
<Select
value={oauthItem}
onChange={(value) => {
const newOauthItems = [...selectedOauthItems];
newOauthItems[index] = value as { value: string; label: string } | undefined;
setSelectedOauthItems(newOauthItems);
}}
placeholder="点击选择授权账户"
style={{ width: '100%', marginBottom: 12, borderRadius: 8 }}
popupRender={() => (
<div style={{ padding: 8, width: 800, maxHeight: 500, overflow: 'auto' }}>
<Table
dataSource={oauthList}
columns={[
{
title: '授权账户ID',
dataIndex: 'accountId',
key: 'accountId',
width: 120,
},
{
title: '授权账户名称',
dataIndex: 'accountName',
key: 'accountName',
width: 120,
},
{
title: '授权账户角色',
dataIndex: 'accountRole',
key: 'accountRole',
width: 160,
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: '授权账户用户名',
dataIndex: 'accountUsername',
key: 'accountUsername',
width: 120,
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
},
{
title: '授权用户ID',
dataIndex: 'accountUserid',
key: 'accountUserid',
width: 120,
},
]}
loading={oauthLoading}
pagination={{
current: oauthPage,
pageSize: oauthPageSize,
total: oauthTotal,
showSizeChanger: true,
showTotal: (total) => `${total} 条记录`,
onChange: (page, size) => {
setOauthPage(page);
setOauthPageSize(size);
loadOAuthList(page, size);
},
}}
rowKey="id"
size="small"
scroll={{ x: 'max-content' }}
onRow={(record) => ({
onClick: () => {
const id = String(record.id);
const newOauthItems = [...selectedOauthItems];
newOauthItems[index] = { value: id, label: String(record.accountId)+'-'+(record.accountName || '-') };
setSelectedOauthItems(newOauthItems);
const newOauthSelectOpens = [...oauthSelectOpens];
newOauthSelectOpens[index] = false;
setOauthSelectOpens(newOauthSelectOpens);
},
style: {
cursor: 'pointer',
backgroundColor: oauthItem?.value === String(record.id) ? '#e6f7ff' : undefined,
},
})}
/>
</div>
)}
open={oauthSelectOpens[index]}
onOpenChange={(open) => {
const newOauthSelectOpens = [...oauthSelectOpens];
newOauthSelectOpens[index] = open;
setOauthSelectOpens(newOauthSelectOpens);
if (open) {
loadOAuthList(1, oauthPageSize);
}
}}
labelInValue
fieldNames={{ label: 'accountUserid', value: 'id' }}
/>
<Typography.Text strong style={{ fontSize: 14, color: '#475569', marginBottom: 8, display: 'block' }}>
ID
</Typography.Text>
<Input.TextArea
value={accountIdInputs[index]}
onChange={(e) => {
const value = e.target.value;
const newAccountIdInputs = [...accountIdInputs];
newAccountIdInputs[index] = value;
setAccountIdInputs(newAccountIdInputs);
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 }));
const seen = new Set<string>();
const finalAccounts = textAccounts.filter(a => {
if (seen.has(a.accountId)) return false;
seen.add(a.accountId);
return true;
});
const newAccountIdLists = [...accountIdLists];
newAccountIdLists[index] = finalAccounts;
setAccountIdLists(newAccountIdLists);
}}
placeholder="粘贴账户ID,每行一个或用逗号分隔,例如:
10001,10002,10003
10004"
rows={4}
rows={3}
style={{ borderRadius: 8 }}
/>
</div>
))}
<Button
type="dashed"
block
onClick={() => {
setSelectedOauthItems([...selectedOauthItems, undefined]);
setAccountIdInputs([...accountIdInputs, '']);
setAccountIdLists([...accountIdLists, []]);
setOauthSelectOpens([...oauthSelectOpens, false]);
}}
style={{ borderRadius: 8, marginBottom: 16 }}
/>
>
+
</Button>
{/* 操作按钮 */}
<div style={{
@@ -1610,9 +1663,9 @@ const GeneratedRecord: React.FC = () => {
<Button
onClick={() => {
setUploadConfigModalVisible(false);
setAccountIdList([]);
setAccountIdInput('');
setSelectedOauthItems(undefined);
setAccountIdLists([[]]);
setAccountIdInputs(['']);
setSelectedOauthItems([undefined]);
setMaterialFileNames(new Map());
setUnifiedFileName('');
}}
@@ -1624,7 +1677,7 @@ const GeneratedRecord: React.FC = () => {
type="primary"
onClick={handleStartBatchUpload}
loading={uploading}
disabled={uploading || accountIdList.length === 0}
disabled={uploading || accountIdLists.every(list => list.length === 0)}
style={{ borderRadius: 8 }}
>
{uploading ? '上传中...' : '开始上传'}
@@ -1680,25 +1733,6 @@ const GeneratedRecord: React.FC = () => {
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',
@@ -1728,7 +1762,7 @@ const GeneratedRecord: React.FC = () => {
title: '备注',
dataIndex: 'note',
key: 'note',
width: 250,
width: 160,
ellipsis: true,
render: (note: string) => (
<span style={{ color: '#94a3b8' }}>
@@ -1738,10 +1772,17 @@ const GeneratedRecord: React.FC = () => {
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
render: (date: string) => dayjs(date).format('YYYY-MM-DD HH:mm:ss'),
render: (text: string) => formatDateTime(text),
},
{
title: '更新时间',
dataIndex: 'updatedAt',
key: 'updatedAt',
width: 180,
render: (text: string) => formatDateTime(text),
},
]}
@@ -2072,15 +2113,16 @@ const GeneratedRecord: React.FC = () => {
</Button>
</div>
{/* <div>
<div>
<Button
style={{ width: '100%', borderRadius: 8, marginTop: 20, color: '#4c49cc' }}
type="primary"
onClick={handleSinglePushToMedia}
style={{ width: '100%', borderRadius: 8, marginTop: 20 }}
disabled={isMediaExpired(previewItem.videoUrl || previewItem.imageUrl)}
>
</Button>
</div> */}
</div>
</div>
</div>
</div>