1
This commit is contained in:
Vendored
+101
-101
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-kmFIWx83.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-OStxKvM2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -34,6 +34,9 @@ import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail
|
||||
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
|
||||
import AdminContactRequests from './pages/AdminContactRequests';
|
||||
import AdminHomeMaterials from './pages/AdminHomeMaterials';
|
||||
import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
|
||||
import AdminOAuthList from './pages/AdminOAuthList';
|
||||
import AdminMaterialList from './pages/AdminMaterialList';
|
||||
|
||||
import { useAdminStore } from './store';
|
||||
|
||||
@@ -109,6 +112,9 @@ const App = () => {
|
||||
<Route path="platform" element={<AdminPlatform />} />
|
||||
<Route path="contact-requests" element={<AdminContactRequests />} />
|
||||
<Route path="home-materials" element={<AdminHomeMaterials />} />
|
||||
<Route path="pretest-templates" element={<AdminPreTestTemplates />} />
|
||||
<Route path="oauth-list" element={<AdminOAuthList />} />
|
||||
<Route path="material-list" element={<AdminMaterialList />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -500,7 +500,7 @@ export async function createOauthApp(data: {
|
||||
app_id: string;
|
||||
secret: string;
|
||||
open_type: number;
|
||||
count?: number;
|
||||
max_count?: number;
|
||||
auth_url?: string;
|
||||
company?: string;
|
||||
}): Promise<any> {
|
||||
@@ -945,3 +945,55 @@ export async function regenerateHomeMaterialWatermark(id: string, payload: HomeM
|
||||
export async function deleteHomeMaterialAsset(id: string): Promise<void> {
|
||||
await api.delete(`/admin/home-material/assets/${id}`);
|
||||
}
|
||||
|
||||
export interface OAuthAccountParams {
|
||||
id?: string;
|
||||
phone?: string;
|
||||
account_id?: string;
|
||||
account_userid?: string;
|
||||
created_at?: [string, string];
|
||||
appid?: string;
|
||||
open_type?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
export async function getOAuthAccountList(params: OAuthAccountParams): Promise<any> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.id) query.set('id', params.id);
|
||||
if (params.phone) query.set('phone', params.phone);
|
||||
if (params.account_id) query.set('account_id', params.account_id);
|
||||
if (params.account_userid) query.set('account_userid', params.account_userid);
|
||||
if (params.created_at !== undefined) query.set('created_at', params.created_at[0] + ',' + params.created_at[1]);
|
||||
if (params.appid) query.set('appid', params.appid);
|
||||
if (params.open_type !== undefined) query.set('open_type', String(params.open_type));
|
||||
if (params.page !== undefined) query.set('page', String(params.page));
|
||||
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||
return api.get(`/material-admin/oauth-list?${query.toString()}`);
|
||||
}
|
||||
|
||||
export interface MaterialListParams {
|
||||
id?: string;
|
||||
phone?: string;
|
||||
resource_type?: string;
|
||||
advertiser_id?: string;
|
||||
material_id?: string;
|
||||
upload_id?: string;
|
||||
created_at?: [string, string];
|
||||
status?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
export async function getMaterialList(params: MaterialListParams): Promise<any> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.id) query.set('id', params.id);
|
||||
if (params.phone) query.set('phone', params.phone);
|
||||
if (params.resource_type) query.set('resource_type', params.resource_type);
|
||||
if (params.advertiser_id) query.set('advertiser_id', params.advertiser_id);
|
||||
if (params.material_id) query.set('material_id', params.material_id);
|
||||
if (params.upload_id) query.set('upload_id', params.upload_id);
|
||||
if (params.created_at !== undefined) query.set('created_at', params.created_at[0] + ',' + params.created_at[1]);
|
||||
if (params.status) query.set('status', params.status);
|
||||
if (params.page !== undefined) query.set('page', String(params.page));
|
||||
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||
return api.get(`/material-admin/material-list?${query.toString()}`);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Input, Select, Table, Pagination, Tag, Typography } from 'antd';
|
||||
import { FileTextOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { getMaterialList } from '../api';
|
||||
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}`;
|
||||
};
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const AdminMaterialList: React.FC = () => {
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchMaterialId, setSearchMaterialId] = useState('');
|
||||
const [searchUploadId, setSearchUploadId] = useState('');
|
||||
const [searchResourceType, setSearchResourceType] = useState('');
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
key: 'index',
|
||||
width: 60,
|
||||
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '广告主账户ID',
|
||||
dataIndex: 'advertiserId',
|
||||
key: 'advertiserId',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
key: 'note',
|
||||
},
|
||||
{
|
||||
title: '授权ID',
|
||||
dataIndex: 'oauthId',
|
||||
key: 'oauthId',
|
||||
},
|
||||
{
|
||||
title: '预测试结果',
|
||||
dataIndex: 'preResult',
|
||||
key: 'preResult',
|
||||
},
|
||||
{
|
||||
title: '预测试模板ID',
|
||||
dataIndex: 'preTestTemplateId',
|
||||
key: 'preTestTemplateId',
|
||||
},
|
||||
{
|
||||
title: '目标标ID',
|
||||
dataIndex: 'targetId',
|
||||
key: 'targetId',
|
||||
},
|
||||
{
|
||||
title: '目标表',
|
||||
dataIndex: 'targetTable',
|
||||
key: 'targetTable',
|
||||
},
|
||||
{
|
||||
title: '任务ID',
|
||||
dataIndex: 'taskId',
|
||||
key: 'taskId',
|
||||
},
|
||||
{
|
||||
title: '用户ID',
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
},
|
||||
{
|
||||
title: '素材ID',
|
||||
dataIndex: 'materialId',
|
||||
key: 'materialId',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '上传ID',
|
||||
dataIndex: 'uploadId',
|
||||
key: 'uploadId',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '资源类型',
|
||||
dataIndex: 'resourceType',
|
||||
key: 'resourceType',
|
||||
width: 100,
|
||||
render: (text: string) => (
|
||||
<Tag color={text === 'video' ? 'blue' : 'green'}>
|
||||
{text === 'video' ? '视频' : '图片'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (text: string) => (
|
||||
<Tag color={text === 'SUCCESS' ? 'green' : text === 'PENDING' ? 'orange' : 'red'}>
|
||||
{text === 'SUCCESS' ? '成功' : text === 'PENDING' ? '处理中' : '失败'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{ formatDateTime(text)}</span>,
|
||||
},
|
||||
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{ formatDateTime(text)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const loadMaterialList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getMaterialList({
|
||||
material_id: searchMaterialId || undefined,
|
||||
upload_id: searchUploadId || undefined,
|
||||
resource_type: searchResourceType || undefined,
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
});
|
||||
const data = res.items || [];
|
||||
const tableData = data.map((item: any, index: number) => ({
|
||||
...item,
|
||||
index: (currentPage - 1) * pageSize + index + 1,
|
||||
}));
|
||||
setTableData(tableData);
|
||||
setTotal(res.total || 0);
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadMaterialList();
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
loadMaterialList();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<FileTextOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Text strong style={{ fontSize: 16 }}>素材ID列表</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder="素材ID"
|
||||
value={searchMaterialId}
|
||||
onChange={(e) => setSearchMaterialId(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Input
|
||||
placeholder="上传ID"
|
||||
value={searchUploadId}
|
||||
onChange={(e) => setSearchUploadId(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Input
|
||||
placeholder="文件名"
|
||||
value={searchResourceType}
|
||||
onChange={(e) => setSearchResourceType(e.target.value)}
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Select
|
||||
placeholder="资源类型"
|
||||
value={searchResourceType}
|
||||
onChange={(value) => setSearchResourceType(value)}
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'video', label: '视频' },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
bordered={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
<div style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger
|
||||
showTotal={(total) => `共 ${total} 条记录`}
|
||||
onChange={(page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminMaterialList;
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Input, Select, Table, Pagination, Tag, Typography } from 'antd';
|
||||
import { LockOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { getOAuthAccountList, getOpenTypeAll } from '../api';
|
||||
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}`;
|
||||
};
|
||||
const { Text } = Typography;
|
||||
|
||||
const AdminOAuthList: React.FC = () => {
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchAccountId, setSearchAccountId] = useState('');
|
||||
const [searchUserId, setSearchUserId] = useState('');
|
||||
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
|
||||
const [openTypeOptions, setOpenTypeOptions] = useState<{ value: number; label: string }[]>([]);
|
||||
const [searchOpenType, setSearchOpenType] = useState<number | undefined>(undefined);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
key: 'index',
|
||||
width: 60,
|
||||
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '前台用户ID',
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
},
|
||||
{
|
||||
title: '前台用户手机号',
|
||||
dataIndex: 'userPhone',
|
||||
key: 'userPhone',
|
||||
},
|
||||
{
|
||||
title: '授权应用ID',
|
||||
dataIndex: 'appid',
|
||||
key: 'appid',
|
||||
},
|
||||
{
|
||||
title: '授权绑定账户ID',
|
||||
dataIndex: 'accountId',
|
||||
key: 'accountId',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
title: '授权登录账户用户名',
|
||||
dataIndex: 'accountUsername',
|
||||
key: 'accountUsername',
|
||||
},
|
||||
{
|
||||
title: '开户方式',
|
||||
dataIndex: 'openType',
|
||||
key: 'openType',
|
||||
width: 120,
|
||||
render: (text: number) => <span style={{ color: '#1e293b' }}>{openTypeMap[text] || text}</span>,
|
||||
},
|
||||
{
|
||||
title: '平台端口',
|
||||
dataIndex: 'portType',
|
||||
key: 'portType',
|
||||
render: (role: string) => {
|
||||
const roleMap: Record<string, string> = {
|
||||
1: '巨量',
|
||||
2: '磁力',
|
||||
3: '巨量星图',
|
||||
4: '服务单',
|
||||
5: '腾讯',
|
||||
};
|
||||
return roleMap[role] || role;
|
||||
},
|
||||
// 1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯
|
||||
// render: (text: number) => <span style={{ color: '#1e293b' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: '授权刷新Token',
|
||||
dataIndex: 'refreshToken',
|
||||
key: 'refreshToken',
|
||||
},
|
||||
{
|
||||
title: '刷新Token过期时间',
|
||||
dataIndex: 'refreshTokenExpired',
|
||||
key: 'refreshTokenExpired',
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: '授权Token',
|
||||
dataIndex: 'accessToken',
|
||||
key: 'accessToken',
|
||||
},
|
||||
{
|
||||
title: 'Token过期时间',
|
||||
dataIndex: 'accessTokenExpired',
|
||||
key: 'accessTokenExpired',
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
];
|
||||
const loadOpenTypeList = async () => {
|
||||
try {
|
||||
const res = await getOpenTypeAll();
|
||||
const data = res.data || [];
|
||||
const map: Record<number, string> = {};
|
||||
const options: { value: number; label: string }[] = [];
|
||||
data.forEach(item => {
|
||||
map[item.openType] = item.typeName;
|
||||
options.push({ value: item.openType, label: item.typeName });
|
||||
});
|
||||
setOpenTypeMap(map);
|
||||
setOpenTypeOptions(options);
|
||||
} catch (error) {
|
||||
console.error('加载开户方式列表失败:', error);
|
||||
}
|
||||
};
|
||||
const loadOAuthList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getOAuthAccountList({
|
||||
account_id: searchAccountId || undefined,
|
||||
account_userid: searchUserId || undefined,
|
||||
open_type: searchOpenType,
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
});
|
||||
const data = res.data || [];
|
||||
const tableData = data.map((item: any, index: number) => ({
|
||||
...item,
|
||||
index: (currentPage - 1) * pageSize + index + 1,
|
||||
}));
|
||||
setTableData(tableData);
|
||||
setTotal(res.pagination?.total || 0);
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadOpenTypeList();
|
||||
loadOAuthList();
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
loadOAuthList();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<LockOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Text strong style={{ fontSize: 16 }}>投放平台授权列表</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder="授权绑定账户ID"
|
||||
value={searchAccountId}
|
||||
onChange={(e) => setSearchAccountId(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Input
|
||||
placeholder="授权登录账户用户ID"
|
||||
value={searchUserId}
|
||||
onChange={(e) => setSearchUserId(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Select
|
||||
placeholder="开户方式"
|
||||
value={searchOpenType}
|
||||
onChange={(value) => setSearchOpenType(value)}
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
options={openTypeOptions}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
bordered={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminOAuthList;
|
||||
@@ -10,16 +10,16 @@ import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface OAuthApp {
|
||||
id: string;
|
||||
app_id: string;
|
||||
appId: string;
|
||||
secret: string;
|
||||
status: number;
|
||||
max_count: number;
|
||||
open_type: number;
|
||||
auth_url?: string;
|
||||
maxCount: number;
|
||||
openType: number;
|
||||
authUrl?: string;
|
||||
company?: string;
|
||||
create_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const AdminOauthAppList: React.FC = () => {
|
||||
@@ -54,7 +54,7 @@ const AdminOauthAppList: React.FC = () => {
|
||||
app_id: values.app_id,
|
||||
secret: values.secret,
|
||||
open_type: values.open_type,
|
||||
count: values.count,
|
||||
max_count: values.max_count,
|
||||
auth_url: values.auth_url,
|
||||
company: values.company,
|
||||
});
|
||||
@@ -82,11 +82,11 @@ const AdminOauthAppList: React.FC = () => {
|
||||
const app = await getOauthApp(id);
|
||||
setCurrentApp(app);
|
||||
updateForm.setFieldsValue({
|
||||
app_id: app.app_id,
|
||||
appId: app.appId,
|
||||
secret: app.secret,
|
||||
open_type: app.open_type,
|
||||
count: app.max_count,
|
||||
auth_url: app.auth_url,
|
||||
openType: app.openType,
|
||||
maxCount: app.maxCount,
|
||||
authUrl: app.authUrl,
|
||||
company: app.company,
|
||||
});
|
||||
setUpdateModalVisible(true);
|
||||
@@ -298,7 +298,7 @@ const AdminOauthAppList: React.FC = () => {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="count"
|
||||
name="max_count"
|
||||
label="最大授权用户数"
|
||||
initialValue={100}
|
||||
>
|
||||
@@ -334,22 +334,22 @@ const AdminOauthAppList: React.FC = () => {
|
||||
{currentApp && (
|
||||
<div style={{ lineHeight: '2' }}>
|
||||
<p><strong>ID:</strong> {currentApp.id}</p>
|
||||
<p><strong>应用ID:</strong> {currentApp.app_id}</p>
|
||||
<p><strong>应用ID:</strong> {currentApp.appId}</p>
|
||||
<p><strong>应用密钥:</strong> {currentApp.secret}</p>
|
||||
<p><strong>开户方式:</strong> {(() => {
|
||||
const typeMap: Record<number, string> = {
|
||||
1: '千川', 2: '广告', 3: '本地推', 4: '星图', 5: '快手代理商',
|
||||
6: '巨量星图', 7: '巨量服务单', 8: '腾讯服务单', 9: '腾讯营销K2', 10: '腾讯营销K3'
|
||||
};
|
||||
return typeMap[currentApp.open_type] || currentApp.open_type;
|
||||
return typeMap[currentApp.openType] || currentApp.openType;
|
||||
})()}</p>
|
||||
<p><strong>归属公司:</strong> {currentApp.company || '-'}</p>
|
||||
<p><strong>授权次数:</strong> {currentApp.max_count}</p>
|
||||
<p><strong>授权次数:</strong> {currentApp.maxCount}</p>
|
||||
<p><strong>状态:</strong> {currentApp.status === 1 ? '正常' : '禁用'}</p>
|
||||
<p><strong>授权URL:</strong> {currentApp.auth_url || '-'}</p>
|
||||
<p><strong>创建人:</strong> {currentApp.create_by}</p>
|
||||
<p><strong>创建时间:</strong> {formatDate(currentApp.created_at)}</p>
|
||||
<p><strong>更新时间:</strong> {formatDate(currentApp.updated_at)}</p>
|
||||
<p><strong>授权URL:</strong> {currentApp.authUrl || '-'}</p>
|
||||
<p><strong>创建人:</strong> {currentApp.createBy}</p>
|
||||
<p><strong>创建时间:</strong> {formatDate(currentApp.createdAt)}</p>
|
||||
<p><strong>更新时间:</strong> {formatDate(currentApp.updatedAt)}</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
@@ -369,7 +369,7 @@ const AdminOauthAppList: React.FC = () => {
|
||||
>
|
||||
<Form form={updateForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="app_id"
|
||||
name="appId"
|
||||
label="应用ID"
|
||||
rules={[{ required: true, message: '请输入应用ID' }, { max: 64, message: '应用ID不能超过64个字符' }]}
|
||||
>
|
||||
@@ -383,7 +383,7 @@ const AdminOauthAppList: React.FC = () => {
|
||||
<Input placeholder="请输入应用密钥" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="open_type"
|
||||
name="openType"
|
||||
label="开户方式"
|
||||
rules={[{ required: true, message: '请选择开户方式' }]}
|
||||
>
|
||||
@@ -401,13 +401,13 @@ const AdminOauthAppList: React.FC = () => {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="count"
|
||||
name="maxCount"
|
||||
label="最大授权用户数"
|
||||
>
|
||||
<InputNumber min={1} placeholder="应用最大可以授权多少个用户" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="auth_url"
|
||||
name="authUrl"
|
||||
label="应用授权链接"
|
||||
>
|
||||
<Input placeholder="请输入应用授权链接" />
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Input, Select, Table, Pagination, Tag, Typography } from 'antd';
|
||||
import { FileTextOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const AdminPreTestTemplates: React.FC = () => {
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchPlatform, setSearchPlatform] = useState('');
|
||||
const [searchName, setSearchName] = useState('');
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
key: 'index',
|
||||
width: 60,
|
||||
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: '模板名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '投放平台',
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
width: 120,
|
||||
render: (text: string) => (
|
||||
<Tag color={text === 'AD' ? 'blue' : text === 'QIANCHUAN' ? 'green' : 'orange'}>
|
||||
{text === 'AD' ? 'AD' : text === 'QIANCHUAN' ? '千川' : '本地推'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (text: string) => (
|
||||
<Tag color={text === 'active' ? 'green' : 'red'}>
|
||||
{text === 'active' ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{text}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const loadRecords = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(currentPage));
|
||||
params.set('page_size', String(pageSize));
|
||||
if (searchPlatform) params.set('platform', searchPlatform);
|
||||
if (searchName) params.set('name', searchName);
|
||||
const mockData = {
|
||||
items: Array.from({ length: pageSize }, (_, i) => ({
|
||||
id: `${currentPage}-${i}`,
|
||||
index: (currentPage - 1) * pageSize + i + 1,
|
||||
name: `前测模板${(currentPage - 1) * pageSize + i + 1}`,
|
||||
platform: ['AD', 'QIANCHUAN', 'LOCAL'][i % 3],
|
||||
status: i % 5 === 0 ? 'inactive' : 'active',
|
||||
createdAt: '2026-07-01 10:00:00',
|
||||
updatedAt: '2026-07-02 14:30:00',
|
||||
})),
|
||||
total: 50,
|
||||
};
|
||||
setTableData(mockData.items);
|
||||
setTotal(mockData.total);
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
const handlePageChange = (page: number, size: number) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
loadRecords();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<FileTextOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Text strong style={{ fontSize: 16 }}>素材前测模板</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder="模板名称"
|
||||
value={searchName}
|
||||
onChange={(e) => setSearchName(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Select
|
||||
placeholder="投放平台"
|
||||
value={searchPlatform}
|
||||
onChange={(value) => setSearchPlatform(value)}
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'AD', label: 'AD' },
|
||||
{ value: 'QIANCHUAN', label: '千川' },
|
||||
{ value: 'LOCAL', label: '本地推' },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.04)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
bordered={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
onRow={() => ({
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s',
|
||||
},
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = '#f8fafc';
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent) => {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent';
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<div style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger
|
||||
showTotal={(total) => `共 ${total} 条记录`}
|
||||
pageSizeOptions={['10', '20', '50', '100']}
|
||||
onChange={handlePageChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPreTestTemplates;
|
||||
@@ -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/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.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/adminplatform.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.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/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.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/resourceurl.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/adminhomematerials.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/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.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/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.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/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||
@@ -31,6 +31,7 @@ from app.api.v1.contact import router as contact_router
|
||||
from app.api.v1.team import router as team_router
|
||||
from app.api.v1.home_materials import router as home_materials_router
|
||||
from app.api.admin import router as admin_module_router
|
||||
from app.api.v1.material_admin import router as material_admin_router
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth_router)
|
||||
@@ -64,3 +65,4 @@ api_router.include_router(contact_router)
|
||||
api_router.include_router(team_router)
|
||||
api_router.include_router(home_materials_router)
|
||||
api_router.include_router(admin_module_router)
|
||||
api_router.include_router(material_admin_router)
|
||||
@@ -0,0 +1,168 @@
|
||||
from typing import Any, Optional, List
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Query, Depends, Body
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.resources_material import ResourcesMaterial
|
||||
from app.models.material_cost import MaterialCost
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.models.user_oauth_account import UserOAuthAccount
|
||||
from app.models.pre_test_template import PreTestTemplate
|
||||
from app.models.upload_task import UploadTask
|
||||
from app.dependencies import get_current_user, get_db, get_admin_user
|
||||
from app.services.material_consumption_queue import sync_all_advertisers_consumption, _fetch_and_save_consumption
|
||||
from app.services.material_consumption_service import get_consumption_list, format_consumption_response
|
||||
from app.services.material_admin_service import get_oauth_list, get_material_list, get_pre_test_template_list, get_upload_task_list
|
||||
|
||||
router = APIRouter(prefix="/material-admin", tags=["material-admin"])
|
||||
|
||||
@router.get("/oauth-list", summary="管理员查看所有授权列表")
|
||||
async def admin_get_oauth_list(
|
||||
id: Optional[str] = Query(None, description="主键id"),
|
||||
phone: Optional[str] = Query(None, description="用户登录手机号"),
|
||||
account_id: Optional[str] = Query(None, description="授权账户id"),
|
||||
account_userid: Optional[str] = Query(None, description="授权登录账号id"),
|
||||
created_at: Optional[List[datetime]] = Query(None, description="授权时间"),
|
||||
appid: Optional[str] = Query(None, description="应用id"),
|
||||
open_type: Optional[int] = Query(None, description="开户方式"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(10, ge=1, le=3000, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(get_admin_user),
|
||||
) -> Any | dict:
|
||||
try:
|
||||
oauth_list, total = await get_oauth_list(
|
||||
id=id,
|
||||
db=db,
|
||||
phone=phone,
|
||||
account_id=account_id,
|
||||
account_userid=account_userid,
|
||||
created_at=created_at,
|
||||
appid=appid,
|
||||
open_type=open_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": oauth_list,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"code": 1,
|
||||
"msg": str(e),
|
||||
"data": None,
|
||||
}
|
||||
|
||||
@router.get("/material-list", summary="管理员查看所有素材资源列表")
|
||||
async def admin_get_material_list(
|
||||
phone: Optional[str] = Query(None, description="用户登录手机号"),
|
||||
id: Optional[str] = Query(None, description="主键id"),
|
||||
resource_type: Optional[str] = Query(None, description="素材资源类型"),
|
||||
advertiser_id: Optional[str] = Query(None, description="广告主id"),
|
||||
material_id: Optional[str] = Query(None, description="素材id"),
|
||||
upload_id: Optional[str] = Query(None, description="平台id【视频id/图片id】"),
|
||||
created_at: Optional[List[datetime]] = Query(None, description="创建时间"),
|
||||
status: Optional[str] = Query(None, description="前测状态"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(10, ge=1, le=3000, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(get_admin_user),
|
||||
) -> Any | dict:
|
||||
materials, total = await get_material_list(
|
||||
db=db,
|
||||
phone=phone,
|
||||
id=id,
|
||||
resource_type=resource_type,
|
||||
advertiser_id=advertiser_id,
|
||||
material_id=material_id,
|
||||
upload_id=upload_id,
|
||||
created_at=created_at,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": materials,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pre-test-template-list", summary="管理员查看所有前测模板列表")
|
||||
async def admin_get_pre_test_template_list(
|
||||
phone: Optional[str] = Query(None, description="用户登录手机号"),
|
||||
id: Optional[str] = Query(None, description="主键id"),
|
||||
created_at: Optional[datetime] = Query(None, description="创建时间"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(10, ge=1, le=3000, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(get_admin_user),
|
||||
) -> Any | dict:
|
||||
templates, total = await get_pre_test_template_list(
|
||||
db=db,
|
||||
phone=phone,
|
||||
id=id,
|
||||
created_at=created_at,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": templates,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/upload-task-list", summary="管理员查看推送任务列表")
|
||||
async def admin_get_upload_task_list(
|
||||
phone: Optional[str] = Query(None, description="用户登录手机号"),
|
||||
id: Optional[str] = Query(None, description="主键id"),
|
||||
created_at: Optional[List[str]] = Query(None, description="创建时间范围,示例: ['2023-01-01', '2023-03-01']"),
|
||||
advertiser_id: Optional[str] = Query(None, description="广告主id"),
|
||||
status: Optional[int] = Query(None, description="前测状态"),
|
||||
resource_id: Optional[str] = Query(None, description="资源id"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(10, ge=1, le=3000, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin_user: User = Depends(get_admin_user),
|
||||
) -> Any | dict:
|
||||
tasks, total = await get_upload_task_list(
|
||||
db=db,
|
||||
phone=phone,
|
||||
id=id,
|
||||
created_at=created_at,
|
||||
advertiser_id=advertiser_id,
|
||||
status=status,
|
||||
resource_id=resource_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": tasks,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
},
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"query_params": dict(request.query_params),
|
||||
"request_body": encrypt_data(request_body) if request_body else "",
|
||||
"request_body": encrypt_data(request_body, True) if request_body else "",
|
||||
"status": response.status_code,
|
||||
"duration_ms": duration_ms,
|
||||
"ip": request.client.host if request.client else "-",
|
||||
|
||||
@@ -159,4 +159,4 @@ def extract_error_message(exc: Exception, service_type: str = "video") -> str:
|
||||
return f"{service_type}生成失败: {code}"
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
return raw[:200] if len(raw) > 200 else raw
|
||||
return raw[:5000] if len(raw) > 5000 else raw
|
||||
@@ -30,7 +30,7 @@ def _log_image_request(engine: ProviderImageEngineLike, record_id: str, request_
|
||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
||||
request_str = json.dumps(request_data, ensure_ascii=False)
|
||||
request_encrypted = encrypt_data(request_data)
|
||||
request_encrypted = encrypt_data(request_data, True)
|
||||
entry = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"type": "image_gen_request",
|
||||
@@ -54,7 +54,7 @@ def _log_image_response(record_id: str, response_data: dict, error: str | None =
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
||||
response_encrypted = encrypt_data(response_data) if response_data else ""
|
||||
response_encrypted = encrypt_data(response_data, True) if response_data else ""
|
||||
entry = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"type": "image_gen_response",
|
||||
|
||||
@@ -37,8 +37,8 @@ def _log_ai_request_response(config, request_data: dict, response_data: dict | N
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
||||
request_encrypted = encrypt_data(_sanitize_for_log(request_data))
|
||||
response_encrypted = encrypt_data(_sanitize_for_log(response_data)) if response_data else ""
|
||||
request_encrypted = encrypt_data(_sanitize_for_log(request_data), True)
|
||||
response_encrypted = encrypt_data(_sanitize_for_log(response_data), True) if response_data else ""
|
||||
entry = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"model_name": config.name,
|
||||
|
||||
@@ -18,8 +18,11 @@ ENCRYPTION_KEY = b'videogen@202605!'
|
||||
|
||||
|
||||
|
||||
def encrypt_data(data: dict) -> str:
|
||||
def encrypt_data(data: dict, is_encrypt: bool = False) -> str:
|
||||
data_str = json.dumps(data, ensure_ascii=False, sort_keys=True)
|
||||
if is_encrypt:
|
||||
return data_str
|
||||
|
||||
data_bytes = data_str.encode("utf-8")
|
||||
|
||||
padder = padding.PKCS7(128).padder()
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
from typing import Optional, List, Tuple
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.models.user_oauth_account import UserOAuthAccount
|
||||
from app.models.resources_material import ResourcesMaterial
|
||||
from app.models.pre_test_template import PreTestTemplate
|
||||
from app.models.upload_task import UploadTask
|
||||
from app.utils.datetime_util import BEIJING_TZ, datetime_to_db_tz_str, db_tz_str_to_datetime, parse_date_range
|
||||
|
||||
|
||||
async def get_oauth_list(
|
||||
id: Optional[str] = None,
|
||||
db: AsyncSession = None,
|
||||
phone: Optional[str] = None,
|
||||
account_id: Optional[str] = None,
|
||||
account_userid: Optional[str] = None,
|
||||
created_at: Optional[datetime] = None,
|
||||
appid: Optional[str] = None,
|
||||
open_type: Optional[int] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> Tuple[List[UserOAuth], int]:
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
query = select(UserOAuth).where(UserOAuth.deleted_at.is_(None))
|
||||
if id:
|
||||
query = query.where(UserOAuth.id == id)
|
||||
if phone:
|
||||
query = query.join(User, User.id == UserOAuth.user_id).where(User.phone == phone)
|
||||
if account_id:
|
||||
query = query.where(UserOAuth.account_id == account_id)
|
||||
if account_userid:
|
||||
query = query.where(UserOAuth.account_userid == account_userid)
|
||||
# 【改动3】适配入参 created_at=["2025-01-01","2026-01-01"] 字符串日期场景
|
||||
if created_at and len(created_at) == 2:
|
||||
try:
|
||||
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
|
||||
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
|
||||
query = query.where(
|
||||
UserOAuth.created_at >= start_dt,
|
||||
UserOAuth.created_at <= end_dt
|
||||
)
|
||||
except ValueError:
|
||||
# 捕获日期格式错误,非法时间直接不附加该查询条件
|
||||
pass
|
||||
if appid:
|
||||
query = query.where(UserOAuth.appid == appid)
|
||||
if open_type:
|
||||
query = query.where(UserOAuth.open_type == open_type)
|
||||
|
||||
count_query = select(func.count(UserOAuth.id)).where(UserOAuth.deleted_at.is_(None))
|
||||
|
||||
if phone:
|
||||
count_query = count_query.join(User, User.id == UserOAuth.user_id).where(User.phone == phone)
|
||||
if account_id:
|
||||
count_query = count_query.where(UserOAuth.account_id == account_id)
|
||||
if account_userid:
|
||||
count_query = count_query.where(UserOAuth.account_userid == account_userid)
|
||||
if created_at:
|
||||
try:
|
||||
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
|
||||
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
|
||||
count_query = count_query.where(
|
||||
UserOAuth.created_at >= start_dt,
|
||||
UserOAuth.created_at <= end_dt
|
||||
)
|
||||
except ValueError:
|
||||
# 捕获日期格式错误,非法时间直接不附加该查询条件
|
||||
pass
|
||||
if appid:
|
||||
count_query = count_query.where(UserOAuth.appid == appid)
|
||||
if open_type:
|
||||
count_query = count_query.where(UserOAuth.open_type == open_type)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
query = query.order_by(UserOAuth.id.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
list = [];
|
||||
for oauth in result.scalars().all():
|
||||
user_query = select(User).where(User.id == oauth.user_id)
|
||||
user_result = await db.execute(user_query)
|
||||
user = user_result.scalar_one()
|
||||
if user:
|
||||
phone = user.phone
|
||||
else:
|
||||
phone = None
|
||||
item = {
|
||||
"id": oauth.id,
|
||||
"account_id": oauth.account_id,
|
||||
"account_name": oauth.account_name,
|
||||
"account_role": oauth.account_role,
|
||||
"account_username": oauth.account_username,
|
||||
"account_userid": oauth.account_userid,
|
||||
"user_id": oauth.user_id,
|
||||
"user_phone": phone,
|
||||
"open_type": oauth.open_type,
|
||||
"port_type": oauth.port_type,
|
||||
"appid": oauth.appid,
|
||||
"access_token": oauth.access_token,
|
||||
"refresh_token": oauth.refresh_token,
|
||||
"access_token_expired": oauth.access_token_expired,
|
||||
"refresh_token_expired": oauth.refresh_token_expired,
|
||||
"created_at": oauth.created_at,
|
||||
"updated_at": oauth.updated_at,
|
||||
}
|
||||
list.append(item)
|
||||
return list, total
|
||||
|
||||
|
||||
async def get_material_list(
|
||||
db: AsyncSession,
|
||||
phone: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
advertiser_id: Optional[str] = None,
|
||||
material_id: Optional[str] = None,
|
||||
upload_id: Optional[str] = None,
|
||||
created_at: Optional[datetime] = None,
|
||||
status: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> Tuple[List[ResourcesMaterial], int]:
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
query = select(ResourcesMaterial).where(ResourcesMaterial.deleted_at.is_(None))
|
||||
|
||||
if phone:
|
||||
query = query.join(UserOAuth, UserOAuth.id == ResourcesMaterial.oauth_id).join(User, User.id == UserOAuth.user_id).where(User.phone == phone)
|
||||
if id:
|
||||
query = query.where(ResourcesMaterial.id == id)
|
||||
if resource_type:
|
||||
query = query.where(ResourcesMaterial.resource_type == resource_type)
|
||||
if advertiser_id:
|
||||
query = query.where(ResourcesMaterial.advertiser_id == advertiser_id)
|
||||
if material_id:
|
||||
query = query.where(ResourcesMaterial.material_id == material_id)
|
||||
if upload_id:
|
||||
query = query.where(ResourcesMaterial.upload_id == upload_id)
|
||||
if created_at:
|
||||
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
|
||||
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
|
||||
query = query.where(
|
||||
ResourcesMaterial.created_at >= start_dt,
|
||||
ResourcesMaterial.created_at <= end_dt
|
||||
)
|
||||
if status:
|
||||
query = query.where(ResourcesMaterial.status == status)
|
||||
|
||||
count_query = select(func.count(ResourcesMaterial.id)).where(ResourcesMaterial.deleted_at.is_(None))
|
||||
|
||||
if phone:
|
||||
count_query = count_query.join(UserOAuth, UserOAuth.id == ResourcesMaterial.oauth_id).join(User, User.id == UserOAuth.user_id).where(User.phone == phone)
|
||||
if id:
|
||||
count_query = count_query.where(ResourcesMaterial.id == id)
|
||||
if resource_type:
|
||||
count_query = count_query.where(ResourcesMaterial.resource_type == resource_type)
|
||||
if advertiser_id:
|
||||
count_query = count_query.where(ResourcesMaterial.advertiser_id == advertiser_id)
|
||||
if material_id:
|
||||
count_query = count_query.where(ResourcesMaterial.material_id == material_id)
|
||||
if upload_id:
|
||||
count_query = count_query.where(ResourcesMaterial.upload_id == upload_id)
|
||||
if created_at:
|
||||
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
|
||||
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
|
||||
count_query = count_query.where(
|
||||
ResourcesMaterial.created_at >= start_dt,
|
||||
ResourcesMaterial.created_at <= end_dt
|
||||
)
|
||||
if status:
|
||||
count_query = count_query.where(ResourcesMaterial.status == status)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
query = query.order_by(ResourcesMaterial.id.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
list = []
|
||||
for item in result.scalars().all():
|
||||
user_query = select(User).where(User.id == item.user_id)
|
||||
user_result = await db.execute(user_query)
|
||||
user = user_result.scalar_one()
|
||||
if user:
|
||||
phone = user.phone
|
||||
else:
|
||||
phone = None
|
||||
item = {
|
||||
"id": item.id,
|
||||
"oauth_id": item.oauth_id,
|
||||
"user_phone": phone,
|
||||
"advertiser_id": item.advertiser_id,
|
||||
"target_table": item.target_table,
|
||||
"target_id": item.target_id,
|
||||
"material_id": item.material_id,
|
||||
"upload_id": item.upload_id,
|
||||
"resource_type": item.resource_type,
|
||||
"user_id": item.user_id,
|
||||
"created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
"task_id": item.task_id,
|
||||
"note": item.note,
|
||||
"status": item.status,
|
||||
"pre_result": item.pre_result,
|
||||
"pre_test_template_id": item.pre_test_template_id,
|
||||
}
|
||||
list.append(item)
|
||||
|
||||
return list, total
|
||||
|
||||
|
||||
async def get_pre_test_template_list(
|
||||
db: AsyncSession,
|
||||
phone: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
created_at: Optional[datetime] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> Tuple[List[PreTestTemplate], int]:
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
query = select(PreTestTemplate).where(PreTestTemplate.deleted_at.is_(None))
|
||||
|
||||
if phone:
|
||||
query = query.join(User, User.id == PreTestTemplate.user_id).where(User.phone == phone)
|
||||
if id:
|
||||
query = query.where(PreTestTemplate.id == id)
|
||||
if created_at:
|
||||
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
|
||||
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
|
||||
query = query.where(
|
||||
PreTestTemplate.created_at >= start_dt,
|
||||
PreTestTemplate.created_at <= end_dt
|
||||
)
|
||||
|
||||
count_query = select(func.count(PreTestTemplate.id)).where(PreTestTemplate.deleted_at.is_(None))
|
||||
|
||||
if phone:
|
||||
count_query = count_query.join(User, User.id == PreTestTemplate.user_id).where(User.phone == phone)
|
||||
if id:
|
||||
count_query = count_query.where(PreTestTemplate.id == id)
|
||||
if created_at:
|
||||
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
|
||||
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
|
||||
count_query = count_query.where(
|
||||
PreTestTemplate.created_at >= start_dt,
|
||||
PreTestTemplate.created_at <= end_dt
|
||||
)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
query = query.order_by(PreTestTemplate.id.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
list = []
|
||||
for item in result.scalars().all():
|
||||
user_query = select(User).where(User.id == item.user_id)
|
||||
user_result = await db.execute(user_query)
|
||||
user = user_result.scalar_one()
|
||||
if user:
|
||||
phone = user.phone
|
||||
else:
|
||||
phone = None
|
||||
item = {
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"user_phone": phone,
|
||||
"user_id": item.user_id,
|
||||
"platform": item.platform,
|
||||
"external_action": item.external_action,
|
||||
"cpa_bid": item.cpa_bid,
|
||||
"audience_gender": item.audience_gender,
|
||||
"audience_age": item.audience_age,
|
||||
"audience_region": item.audience_region,
|
||||
"audience_network": item.audience_network,
|
||||
"cus_name": item.cus_name,
|
||||
"pricing_type": item.pricing_type,
|
||||
"cost_cap": item.cost_cap,
|
||||
"target_cost": item.target_cost,
|
||||
"nobid": item.nobid,
|
||||
"cpc_bid": item.cpc_bid,
|
||||
"budget": item.budget,
|
||||
"is_default": item.is_default,
|
||||
"created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
"note": item.note,
|
||||
}
|
||||
list.append(item)
|
||||
return list, total
|
||||
|
||||
|
||||
async def get_upload_task_list(
|
||||
db: AsyncSession,
|
||||
phone: Optional[str] = None,
|
||||
id: Optional[str] = None,
|
||||
created_at: Optional[List[str]] = None,
|
||||
advertiser_id: Optional[str] = None,
|
||||
status: Optional[int] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> Tuple[List[UploadTask], int]:
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
query = select(UploadTask).where(UploadTask.deleted_at.is_(None))
|
||||
|
||||
if phone:
|
||||
query = query.join(User, User.id == UploadTask.user_id).where(User.phone == phone)
|
||||
if id:
|
||||
query = query.where(UploadTask.id == id)
|
||||
if created_at and len(created_at) >= 2:
|
||||
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
|
||||
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
|
||||
query = query.where(
|
||||
UploadTask.created_at >= start_dt,
|
||||
UploadTask.created_at <= end_dt
|
||||
)
|
||||
if advertiser_id:
|
||||
query = query.where(UploadTask.advertiser_id == advertiser_id)
|
||||
if status:
|
||||
query = query.where(UploadTask.status == status)
|
||||
if resource_id:
|
||||
query = query.where(UploadTask.resource_id == resource_id)
|
||||
|
||||
count_query = select(func.count(UploadTask.id)).where(UploadTask.deleted_at.is_(None))
|
||||
|
||||
if phone:
|
||||
count_query = count_query.join(User, User.id == UploadTask.user_id).where(User.phone == phone)
|
||||
if id:
|
||||
count_query = count_query.where(UploadTask.id == id)
|
||||
if created_at and len(created_at) >= 2:
|
||||
# 直接用datetime范围匹配,不走func.date,保留时分秒精度,命中索引
|
||||
start_dt, end_dt = parse_date_range(created_at, is_timezone=True)
|
||||
count_query = count_query.where(
|
||||
UploadTask.created_at >= start_dt,
|
||||
UploadTask.created_at <= end_dt
|
||||
)
|
||||
if advertiser_id:
|
||||
count_query = count_query.where(UploadTask.advertiser_id == advertiser_id)
|
||||
if status:
|
||||
count_query = count_query.where(UploadTask.status == status)
|
||||
if resource_id:
|
||||
count_query = count_query.where(UploadTask.resource_id == resource_id)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
query = query.order_by(UploadTask.id.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
list = []
|
||||
for item in result.scalars().all():
|
||||
user_query = select(User).where(User.id == item.user_id)
|
||||
user_result = await db.execute(user_query)
|
||||
user = user_result.scalar_one()
|
||||
if user:
|
||||
phone = user.phone
|
||||
else:
|
||||
phone = None
|
||||
item = {
|
||||
"id": item.id,
|
||||
"user_phone": phone,
|
||||
"user_id": item.user_id,
|
||||
"advertiser_id": item.advertiser_id,
|
||||
"resource_id": item.resource_id,
|
||||
"status": item.status,
|
||||
"note": item.note,
|
||||
"oauth_id": item.oauth_id,
|
||||
"created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
"other_info": item.other_info,
|
||||
}
|
||||
list.append(item)
|
||||
return list, total
|
||||
@@ -1,20 +1,22 @@
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
# 引入项目统一北京时间时区
|
||||
from app.utils.datetime_util import BEIJING_TZ
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.models.user_oauth_app import UserOAuthApp
|
||||
from app.utils.id_gen import generate_id
|
||||
from app.tasks.token_refresh_task import _update_redis_token
|
||||
from app.tasks.token_refresh_task import _update_redis_token, _delete_redis_token
|
||||
|
||||
#随机获取一个可用的应用配置
|
||||
# 随机获取一个可用的应用配置
|
||||
async def get_available_app(open_type: int, db: AsyncSession) -> dict:
|
||||
# 从 user_oauth_app 表查询可用应用
|
||||
# 过滤条件:open_type 匹配、status=1(正常)、deleted_at is None
|
||||
if not isinstance(open_type, int):
|
||||
raise ValueError("open_type必须为整数类型")
|
||||
|
||||
result = await db.execute(
|
||||
select(UserOAuthApp).where(
|
||||
UserOAuthApp.open_type == open_type,
|
||||
@@ -30,8 +32,6 @@ async def get_available_app(open_type: int, db: AsyncSession) -> dict:
|
||||
available_apps = []
|
||||
for app in apps:
|
||||
app_id = app.app_id
|
||||
|
||||
# 查询该应用当前授权数量
|
||||
count_result = await db.execute(
|
||||
select(func.count(UserOAuth.id)).where(
|
||||
UserOAuth.appid == app_id,
|
||||
@@ -39,8 +39,6 @@ async def get_available_app(open_type: int, db: AsyncSession) -> dict:
|
||||
)
|
||||
)
|
||||
count = count_result.scalar() or 0
|
||||
|
||||
# 检查是否达到最大授权数
|
||||
max_users = app.max_count
|
||||
if count < max_users:
|
||||
available_apps.append({
|
||||
@@ -59,24 +57,21 @@ async def get_available_app(open_type: int, db: AsyncSession) -> dict:
|
||||
|
||||
async def build_oauth_url(open_type: int, user_id: str, db: AsyncSession) -> str:
|
||||
if open_type == 1:
|
||||
#千川
|
||||
return await _build_jl_oauth_url(open_type, user_id, db)
|
||||
elif open_type in [2, 3]:
|
||||
#广告,本地推
|
||||
return await _build_jl_oauth_url(2, user_id, db)
|
||||
elif open_type == 5:
|
||||
#快手代理商
|
||||
return "无配置"
|
||||
elif open_type == 9:
|
||||
#腾讯营销K2
|
||||
return "无配置"
|
||||
elif open_type == 10:
|
||||
#腾讯营销K3
|
||||
return "无配置"
|
||||
else:
|
||||
raise ValueError(f"不支持的应用类型: {app_type}")
|
||||
# 修复BUG:变量名错误 app_type -> open_type
|
||||
raise ValueError(f"不支持的应用类型: {open_type}")
|
||||
|
||||
#千川授权链接构建
|
||||
|
||||
# 千川授权链接构建
|
||||
async def _build_jl_oauth_url(open_type: int, user_id: str, db: AsyncSession) -> str:
|
||||
app = await get_available_app(open_type, db)
|
||||
app_id = app.get("app_id")
|
||||
@@ -92,221 +87,232 @@ async def _build_jl_oauth_url(open_type: int, user_id: str, db: AsyncSession) ->
|
||||
|
||||
|
||||
async def get_token(code: str, user_id: str, app_id: str, db: AsyncSession) -> dict:
|
||||
#1.根据app_id查询应用配置
|
||||
if not all([code, user_id, app_id]):
|
||||
raise ValueError("code、user_id、app_id不能为空")
|
||||
|
||||
result = await db.execute(
|
||||
select(UserOAuthApp).where(UserOAuthApp.app_id == app_id)
|
||||
select(UserOAuthApp).where(
|
||||
UserOAuthApp.app_id == app_id,
|
||||
UserOAuthApp.deleted_at.is_(None),
|
||||
UserOAuthApp.status == 1
|
||||
)
|
||||
)
|
||||
app = result.scalar_one_or_none()
|
||||
if not app:
|
||||
raise ValueError("应用配置不存在")
|
||||
raise ValueError("应用配置不存在或已禁用")
|
||||
|
||||
open_type = app.open_type
|
||||
secret = app.secret
|
||||
|
||||
if open_type == 1 or open_type == 2:
|
||||
#千川或广告
|
||||
if open_type in (1, 2):
|
||||
return await get_juliang_token(app_id, secret, code, open_type, user_id, db)
|
||||
elif open_type == 5:
|
||||
#快手代理商
|
||||
return await get_kuaishou_token(app_id, secret, code, open_type, user_id, db)
|
||||
elif open_type == 9 or open_type == 10:
|
||||
#腾讯营销K2或腾讯营销K3
|
||||
elif open_type in (9, 10):
|
||||
return await get_tencent_token(app_id, secret, code, open_type, user_id, db)
|
||||
else:
|
||||
raise ValueError(f"不支持的应用类型: {open_type}")
|
||||
|
||||
|
||||
async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int, user_id: str, db: AsyncSession) -> dict:
|
||||
timeout = httpx.Timeout(30.0)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
# 1. 获取token
|
||||
url = "https://api.oceanengine.com/open_api/oauth2/access_token/"
|
||||
resp = await client.post(
|
||||
url,
|
||||
json={
|
||||
"app_id": app_id,
|
||||
"secret": secret,
|
||||
"auth_code": code,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("code") != 0:
|
||||
msg = data.get("message", "获取token失败")
|
||||
raise ValueError(f"获取token失败:{msg}")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
#1.请求token
|
||||
url = "https://api.oceanengine.com/open_api/oauth2/access_token/"
|
||||
response = await client.post(
|
||||
url,
|
||||
json={
|
||||
"app_id": app_id,
|
||||
"secret": secret,
|
||||
"auth_code": code,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise ValueError(data.get("message", "获取token失败")+f",错误信息:{data.get('message', '')}")
|
||||
data = data.get("data", {})
|
||||
access_token = data.get("access_token", "")
|
||||
refresh_token = data.get("refresh_token", "")
|
||||
expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 0))
|
||||
refresh_token_expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
|
||||
#2.获取已授权角色账户,一个授权可能有多个角色账户
|
||||
url = "https://api.oceanengine.com/open_api/oauth2/advertiser/get/"
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"Access-Token": access_token},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise ValueError(data.get("message", "获取已授权账户失败")+f",错误信息:{data.get('message', '')}")
|
||||
data = data.get("data", {})
|
||||
account_list = data.get("list", [])
|
||||
#3.获取已授权登录信息
|
||||
url = "https://api.oceanengine.com/open_api/2/user/info/"
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"Access-Token": access_token},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("code") != 0:
|
||||
raise ValueError(data.get("message", "获取已授权登录信息失败")+f",错误信息:{data.get('message', '')}")
|
||||
data = data.get("data", {})
|
||||
account_username = data.get("email", "")
|
||||
account_userid = str(data.get("id", ""))
|
||||
material_auth_status = data.get("material_auth_status", False)
|
||||
#4.根据登录信息判断是否新增token或更新token,不同的登录信息对应不同的token,然后更新数据库
|
||||
#查询email,appid,user_id是否存在已授权记录
|
||||
oauth = await db.execute(
|
||||
select(UserOAuth)
|
||||
.where(UserOAuth.account_username == account_username, UserOAuth.account_userid == account_userid, UserOAuth.appid == app_id, UserOAuth.user_id == user_id)
|
||||
.limit(1)
|
||||
)
|
||||
oauth = oauth.scalar_one_or_none()
|
||||
if not oauth:
|
||||
new_oauth_ids = []
|
||||
for account in account_list:
|
||||
oauth_id = generate_id()
|
||||
new_oauth_ids.append(oauth_id)
|
||||
#新增授权记录
|
||||
db.add(UserOAuth(
|
||||
id=oauth_id,
|
||||
account_id = str(account.get("account_id", "")),
|
||||
account_name = account.get("account_name", ""),
|
||||
account_role = account.get("account_role", ""),
|
||||
account_username = account_username,
|
||||
account_userid = account_userid,
|
||||
user_id = user_id,
|
||||
appid = app_id,
|
||||
open_type = open_type,
|
||||
port_type = 1,
|
||||
access_token = access_token,
|
||||
access_token_expired = expires_in,
|
||||
refresh_token = refresh_token,
|
||||
refresh_token_expired = refresh_token_expires_in,
|
||||
material_auth_status = material_auth_status,
|
||||
))
|
||||
await db.commit()
|
||||
resp_data = data.get("data", {})
|
||||
access_token = resp_data.get("access_token", "")
|
||||
refresh_token = resp_data.get("refresh_token", "")
|
||||
expires_sec = resp_data.get("expires_in", 7200)
|
||||
refresh_expires_sec = resp_data.get("refresh_token_expires_in", 30 * 24 * 3600)
|
||||
|
||||
# 【修复:统一使用北京时间计算过期时间】
|
||||
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||
access_expired = now_beijing + timedelta(seconds=expires_sec)
|
||||
refresh_expired = now_beijing + timedelta(seconds=refresh_expires_sec)
|
||||
|
||||
for oauth_id in new_oauth_ids:
|
||||
await _update_redis_token(oauth_id, access_token, expires_in)
|
||||
else:
|
||||
# 查询现有授权记录(未删除的)
|
||||
existing_accounts = await db.execute(
|
||||
select(UserOAuth).where(
|
||||
# 2. 获取授权广告账户列表
|
||||
resp = await client.get(
|
||||
"https://api.oceanengine.com/open_api/oauth2/advertiser/get/",
|
||||
headers={"Access-Token": access_token},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("code") != 0:
|
||||
raise ValueError(f"获取已授权账户失败:{data.get('message')}")
|
||||
account_list = data.get("data", {}).get("list", [])
|
||||
|
||||
# 3. 获取登录用户信息
|
||||
resp = await client.get(
|
||||
"https://api.oceanengine.com/open_api/2/user/info/",
|
||||
headers={"Access-Token": access_token},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data.get("code") != 0:
|
||||
raise ValueError(f"获取登录信息失败:{data.get('message')}")
|
||||
|
||||
user_info = data.get("data", {})
|
||||
account_username = user_info.get("email", "")
|
||||
account_userid = str(user_info.get("id", ""))
|
||||
material_auth_status = user_info.get("material_auth_status", False)
|
||||
|
||||
# 4. 查询当前用户+应用+登录账号下的授权记录
|
||||
oauth_query = select(UserOAuth).where(
|
||||
UserOAuth.account_username == account_username,
|
||||
UserOAuth.account_userid == account_userid,
|
||||
UserOAuth.appid == app_id,
|
||||
UserOAuth.user_id == user_id,
|
||||
UserOAuth.deleted_at.is_(None)
|
||||
)
|
||||
oauth_exist = await db.execute(oauth_query.limit(1))
|
||||
oauth_exist = oauth_exist.scalar_one_or_none()
|
||||
|
||||
if not oauth_exist:
|
||||
# 新增授权
|
||||
new_oauth_ids = []
|
||||
for account in account_list:
|
||||
oauth_id = generate_id()
|
||||
new_oauth_ids.append(oauth_id)
|
||||
db.add(UserOAuth(
|
||||
id=oauth_id,
|
||||
account_id=str(account.get("account_id", "")),
|
||||
account_name=account.get("account_name", ""),
|
||||
account_role=account.get("account_role", ""),
|
||||
account_username=account_username,
|
||||
account_userid=account_userid,
|
||||
user_id=user_id,
|
||||
appid=app_id,
|
||||
open_type=open_type,
|
||||
port_type=1,
|
||||
access_token=access_token,
|
||||
access_token_expired=access_expired,
|
||||
refresh_token=refresh_token,
|
||||
refresh_token_expired=refresh_expired,
|
||||
material_auth_status=material_auth_status,
|
||||
))
|
||||
await db.commit()
|
||||
# 批量写入Redis
|
||||
for oauth_id in new_oauth_ids:
|
||||
await _update_redis_token(oauth_id, access_token, access_expired)
|
||||
else:
|
||||
# 查询当前所有有效授权账户
|
||||
exist_query = select(UserOAuth).where(
|
||||
UserOAuth.account_username == account_username,
|
||||
UserOAuth.account_userid == account_userid,
|
||||
UserOAuth.appid == app_id,
|
||||
UserOAuth.user_id == user_id,
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
existing_accounts = {acc.account_id: acc for acc in existing_accounts.scalars().all()}
|
||||
exist_res = await db.execute(exist_query)
|
||||
exist_map = {item.account_id: item for item in exist_res.scalars().all()}
|
||||
|
||||
# 新的账户列表
|
||||
new_account_ids = {str(account.get("account_id")) for account in account_list}
|
||||
old_account_ids = set(existing_accounts.keys())
|
||||
new_account_ids = {str(acc.get("account_id")) for acc in account_list}
|
||||
old_account_ids = set(exist_map.keys())
|
||||
update_redis_ids = []
|
||||
|
||||
# 需要更新Redis的oauth_id列表
|
||||
update_redis_ids = []
|
||||
# 下线的账户:软删除 + 清理Redis脏缓存
|
||||
for del_account_id in old_account_ids - new_account_ids:
|
||||
del_oauth = exist_map[del_account_id]
|
||||
del_oauth.deleted_at = now_beijing
|
||||
await _delete_redis_token(del_oauth.id)
|
||||
|
||||
# 1. 软删除已消失的账户
|
||||
for account_id in old_account_ids - new_account_ids:
|
||||
existing_accounts[account_id].deleted_at = datetime.now(timezone.utc)
|
||||
|
||||
# 2. 更新或新增账户
|
||||
for account in account_list:
|
||||
account_id = str(account.get("account_id", ""))
|
||||
if account_id in existing_accounts:
|
||||
# 更新现有记录
|
||||
existing_oauth = existing_accounts[account_id]
|
||||
existing_oauth.account_name = account.get("account_name", "")
|
||||
existing_oauth.account_role = account.get("account_role", "")
|
||||
existing_oauth.access_token = access_token
|
||||
existing_oauth.access_token_expired = expires_in
|
||||
existing_oauth.refresh_token = refresh_token
|
||||
existing_oauth.refresh_token_expired = refresh_token_expires_in
|
||||
existing_oauth.material_auth_status = material_auth_status
|
||||
update_redis_ids.append(existing_oauth.id)
|
||||
else:
|
||||
# 新增记录
|
||||
oauth_id = generate_id()
|
||||
update_redis_ids.append(oauth_id)
|
||||
db.add(UserOAuth(
|
||||
# 更新/新增当前授权账户
|
||||
for account in account_list:
|
||||
aid = str(account.get("account_id", ""))
|
||||
if aid in exist_map:
|
||||
item = exist_map[aid]
|
||||
item.account_name = account.get("account_name", "")
|
||||
item.account_role = account.get("account_role", "")
|
||||
item.access_token = access_token
|
||||
item.access_token_expired = access_expired
|
||||
item.refresh_token = refresh_token
|
||||
item.refresh_token_expired = refresh_expired
|
||||
item.material_auth_status = material_auth_status
|
||||
update_redis_ids.append(item.id)
|
||||
else:
|
||||
oauth_id = generate_id()
|
||||
update_redis_ids.append(oauth_id)
|
||||
db.add(UserOAuth(
|
||||
id=oauth_id,
|
||||
account_id=str(account_id),
|
||||
account_id=aid,
|
||||
account_name=account.get("account_name", ""),
|
||||
account_role=account.get("account_role", ""),
|
||||
account_username=account_username,
|
||||
account_userid = account_userid,
|
||||
account_userid=account_userid,
|
||||
user_id=user_id,
|
||||
appid=app_id,
|
||||
open_type=open_type,
|
||||
port_type=1,
|
||||
access_token = access_token,
|
||||
access_token_expired=expires_in,
|
||||
access_token=access_token,
|
||||
access_token_expired=access_expired,
|
||||
refresh_token=refresh_token,
|
||||
refresh_token_expired=refresh_token_expires_in,
|
||||
refresh_token_expired=refresh_expired,
|
||||
material_auth_status=material_auth_status,
|
||||
))
|
||||
await db.commit()
|
||||
await db.commit()
|
||||
# 更新有效账号缓存
|
||||
for oid in update_redis_ids:
|
||||
await _update_redis_token(oid, access_token, access_expired)
|
||||
|
||||
for oauth_id in update_redis_ids:
|
||||
await _update_redis_token(oauth_id, access_token, expires_in)
|
||||
|
||||
#5.本次更新成功以后,判断是否有其他同一个appid,同一个授权登录账号的授权记录,如果有,则更新token信息
|
||||
from sqlalchemy import update
|
||||
|
||||
related_oauths = await db.execute(
|
||||
select(UserOAuth).where(
|
||||
# 5. 同登录账号、同应用、其他用户下的授权批量同步最新token
|
||||
related_query = select(UserOAuth).where(
|
||||
UserOAuth.account_username == account_username,
|
||||
UserOAuth.account_userid == account_userid,
|
||||
UserOAuth.appid == app_id,
|
||||
UserOAuth.user_id != user_id,
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
related_oauths = related_oauths.scalars().all()
|
||||
|
||||
if related_oauths:
|
||||
await db.execute(
|
||||
update(UserOAuth).where(
|
||||
UserOAuth.account_username == account_username,
|
||||
UserOAuth.account_userid == account_userid,
|
||||
UserOAuth.appid == app_id,
|
||||
UserOAuth.user_id != user_id,
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
).values(
|
||||
access_token=access_token,
|
||||
access_token_expired=expires_in,
|
||||
refresh_token=refresh_token,
|
||||
refresh_token_expired=refresh_token_expires_in,
|
||||
material_auth_status=material_auth_status,
|
||||
related_res = await db.execute(related_query)
|
||||
related_list = related_res.scalars().all()
|
||||
if related_list:
|
||||
await db.execute(
|
||||
update(UserOAuth).where(
|
||||
UserOAuth.account_username == account_username,
|
||||
UserOAuth.account_userid == account_userid,
|
||||
UserOAuth.appid == app_id,
|
||||
UserOAuth.user_id != user_id,
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
).values(
|
||||
access_token=access_token,
|
||||
access_token_expired=access_expired,
|
||||
refresh_token=refresh_token,
|
||||
refresh_token_expired=refresh_expired,
|
||||
material_auth_status=material_auth_status,
|
||||
)
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await db.commit()
|
||||
for item in related_list:
|
||||
await _update_redis_token(item.id, access_token, access_expired)
|
||||
|
||||
for related_oauth in related_oauths:
|
||||
await _update_redis_token(related_oauth.id, access_token, expires_in)
|
||||
return {"message": "授权成功"}
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
raise ValueError(f"第三方接口请求异常:{str(e)}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"授权处理异常:{str(e)}")
|
||||
|
||||
return {"message": "授权成功"}
|
||||
|
||||
async def get_kuaishou_token(app_id: str, secret: str, code: str, oauth_type: int, user_id: str, db: AsyncSession) -> dict:
|
||||
return "未配置"
|
||||
return {"message": "快手渠道暂未实现授权逻辑"}
|
||||
|
||||
|
||||
async def get_tencent_token(app_id: str, secret: str, code: str, oauth_type: int, user_id: str, db: AsyncSession) -> str:
|
||||
return "未配置"
|
||||
return "腾讯营销渠道暂未实现授权逻辑"
|
||||
|
||||
|
||||
async def get_oauth_list(
|
||||
@@ -318,36 +324,35 @@ async def get_oauth_list(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
) -> dict:
|
||||
if page < 1:
|
||||
page = 1
|
||||
if page_size < 1:
|
||||
page_size = 10
|
||||
# 分页参数容错
|
||||
page = max(page, 1)
|
||||
page_size = max(min(page_size, 100), 1)
|
||||
|
||||
query = select(UserOAuth).where(
|
||||
base_where = [
|
||||
UserOAuth.user_id == user_id,
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
)
|
||||
|
||||
]
|
||||
if account_userid:
|
||||
query = query.where(UserOAuth.account_userid == account_userid)
|
||||
base_where.append(UserOAuth.account_userid == account_userid)
|
||||
if open_type:
|
||||
query = query.where(UserOAuth.open_type == open_type)
|
||||
base_where.append(UserOAuth.open_type == open_type)
|
||||
if account_id:
|
||||
query = query.where(UserOAuth.account_id == account_id)
|
||||
base_where.append(UserOAuth.account_id == account_id)
|
||||
|
||||
query = query.order_by(UserOAuth.created_at.desc())
|
||||
# 统计总条数(优化:使用count,避免全量查询)
|
||||
count_stmt = select(func.count(UserOAuth.id)).where(*base_where)
|
||||
total = await db.scalar(count_stmt) or 0
|
||||
|
||||
total_result = await db.execute(query.with_only_columns(UserOAuth.id))
|
||||
total = len(total_result.scalars().all())
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = query.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(query)
|
||||
oauth_list = result.scalars().all()
|
||||
# 分页查询数据
|
||||
data_stmt = select(UserOAuth).where(*base_where)\
|
||||
.order_by(UserOAuth.created_at.desc())\
|
||||
.offset((page - 1) * page_size)\
|
||||
.limit(page_size)
|
||||
result = await db.execute(data_stmt)
|
||||
data_list = result.scalars().all()
|
||||
|
||||
return {
|
||||
"data": oauth_list,
|
||||
"data": data_list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
|
||||
@@ -30,7 +30,7 @@ def _log_video_request(engine: ProviderVideoEngineLike, record_id: str, request_
|
||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
||||
request_str = json.dumps(request_data, ensure_ascii=False)
|
||||
request_encrypted = encrypt_data(request_data)
|
||||
request_encrypted = encrypt_data(request_data, True)
|
||||
entry = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"type": "video_gen_request",
|
||||
@@ -54,7 +54,7 @@ def _log_video_response(record_id: str, response_data: dict, error: str | None =
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
||||
response_encrypted = encrypt_data(response_data) if response_data else ""
|
||||
response_encrypted = encrypt_data(response_data, True) if response_data else ""
|
||||
|
||||
entry = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
|
||||
@@ -1,49 +1,64 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
# 引入自定义北京时间工具类
|
||||
from app.utils.datetime_util import BEIJING_TZ
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.models.user_oauth_app import UserOAuthApp
|
||||
from app.models.base import async_session
|
||||
from app.config import settings
|
||||
from app.utils.redis import get_redis
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.datetime_util import datetime_to_db_tz_str, db_tz_str_to_datetime
|
||||
|
||||
REDIS_KEY = "douyin:tokens"
|
||||
logger = get_logger("token_refresh", "token_refresh")
|
||||
|
||||
|
||||
|
||||
# 配置常量
|
||||
REFRESH_THRESHOLD_SECONDS = 800
|
||||
CHECK_INTERVAL_MINUTES = 5
|
||||
HTTP_TIMEOUT = httpx.Timeout(30.0)
|
||||
|
||||
|
||||
async def _delete_redis_token(oauth_id: str):
|
||||
"""【改动1:废弃空值覆盖,直接删除Hash脏缓存】refresh失效则移除字段"""
|
||||
redis = get_redis()
|
||||
if not redis:
|
||||
logger.warning("Redis连接未配置,跳过缓存清理")
|
||||
return
|
||||
try:
|
||||
await redis.hdel(REDIS_KEY, oauth_id)
|
||||
logger.info(f"清理失效授权Redis缓存: oauth_id={oauth_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"删除Redis缓存失败 oauth_id:{oauth_id}, err:{str(e)}")
|
||||
|
||||
|
||||
async def _update_redis_token(oauth_id: str, token: str, expired_at: datetime):
|
||||
"""更新Redis缓存中的token"""
|
||||
"""更新Redis缓存中的token(统一北京时间序列化)"""
|
||||
redis = get_redis()
|
||||
if not redis:
|
||||
logger.warning("Redis连接未配置,跳过缓存更新")
|
||||
return
|
||||
|
||||
try:
|
||||
cache = {
|
||||
"token": token,
|
||||
"expired_at": expired_at.isoformat(),
|
||||
}
|
||||
"token": token,
|
||||
"expired_at": datetime_to_db_tz_str(expired_at),
|
||||
}
|
||||
await redis.hset(REDIS_KEY, oauth_id, json.dumps(cache))
|
||||
logger.info(f"Redis缓存已更新: oauth_id={oauth_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"更新Redis缓存失败: {str(e)}")
|
||||
logger.error(f"更新Redis缓存失败 oauth_id:{oauth_id}, err:{str(e)}")
|
||||
|
||||
|
||||
async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSession):
|
||||
"""刷新巨量引擎token"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||
url = "https://api.oceanengine.com/open_api/oauth2/refresh_token/"
|
||||
response = await client.post(
|
||||
url,
|
||||
@@ -55,13 +70,11 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"刷新巨量引擎token失败: oauth_id={oauth.id}, 错误信息: {data}")
|
||||
#如果code=40103或者40107,传入refresh_token已失效,失效原因一般是由于refresh_token已被使用,或授权账号重新授权并生成了新的Token
|
||||
if data.get("code") in [40103, 40107]:
|
||||
#清空数据库中的token信息,和Redis缓存中的token
|
||||
from sqlalchemy import update
|
||||
|
||||
# refresh_token已失效或刷新失败,统一清理token
|
||||
if data.get("code") in [40103, 40107, 40000]:
|
||||
where_cond = UserOAuth.deleted_at.is_(None)
|
||||
if oauth.appid:
|
||||
where_cond = where_cond & (UserOAuth.appid == oauth.appid)
|
||||
@@ -80,23 +93,20 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
related_oauth_ids = await db.execute(
|
||||
select(UserOAuth.id).where(where_cond)
|
||||
)
|
||||
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
|
||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||
for related_id in related_oauth_ids:
|
||||
await _update_redis_token(related_id, "", None)
|
||||
|
||||
await _delete_redis_token(related_id)
|
||||
return
|
||||
|
||||
resp_data = data.get("data", {})
|
||||
new_access_token = resp_data.get("access_token", "")
|
||||
new_refresh_token = resp_data.get("refresh_token", "")
|
||||
|
||||
data = data.get("data", {})
|
||||
new_access_token = data.get("access_token", "")
|
||||
new_refresh_token = data.get("refresh_token", "")
|
||||
expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 0))
|
||||
refresh_token_expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
|
||||
|
||||
from sqlalchemy import update
|
||||
# 【改动3:全部使用北京时间计算过期时间,统一时区】
|
||||
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||
expires_in = now_beijing + timedelta(seconds=resp_data.get("expires_in", 0))
|
||||
refresh_expires = now_beijing + timedelta(seconds=resp_data.get("refresh_token_expires_in", 0))
|
||||
|
||||
where_cond = UserOAuth.deleted_at.is_(None)
|
||||
if oauth.appid:
|
||||
@@ -111,19 +121,20 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
|
||||
access_token=new_access_token,
|
||||
access_token_expired=expires_in,
|
||||
refresh_token=new_refresh_token,
|
||||
refresh_token_expired=refresh_token_expires_in,
|
||||
refresh_token_expired=refresh_expires,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
related_oauth_ids = await db.execute(
|
||||
select(UserOAuth.id).where(where_cond)
|
||||
)
|
||||
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
|
||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||
for related_id in related_oauth_ids:
|
||||
await _update_redis_token(related_id, new_access_token, expires_in)
|
||||
|
||||
logger.info(f"成功刷新巨量引擎token: oauth_id={oauth.id}, account_id={oauth.account_id}, 关联账户数={len(related_oauth_ids)}")
|
||||
logger.info(
|
||||
f"成功刷新巨量引擎token: oauth_id={oauth.id}, "
|
||||
f"account_id={oauth.account_id}, 关联账户数={len(related_oauth_ids)}"
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"HTTP请求失败: oauth_id={oauth.id}, 错误: {str(e)}")
|
||||
except Exception as e:
|
||||
@@ -133,13 +144,14 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
|
||||
async def check_and_refresh_tokens():
|
||||
"""检查并刷新即将过期的token"""
|
||||
async with async_session() as db:
|
||||
now = datetime.now(timezone.utc)
|
||||
# 【改动4:统一使用北京时间当前时间,避免时区运算异常】
|
||||
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||
|
||||
query = select(UserOAuth).where(
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
UserOAuth.refresh_token.is_not(None),
|
||||
UserOAuth.refresh_token_expired.is_not(None),
|
||||
UserOAuth.refresh_token_expired > now,
|
||||
UserOAuth.refresh_token_expired > now_beijing,
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
@@ -149,77 +161,71 @@ async def check_and_refresh_tokens():
|
||||
|
||||
for oauth in oauth_list:
|
||||
try:
|
||||
#检查是否为支持的平台(巨量引擎)
|
||||
# port_type: 平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)
|
||||
if oauth.port_type not in [1]:
|
||||
continue
|
||||
|
||||
#构建登录账号唯一标识,同一登录账号共享token
|
||||
# 唯一键优化:过滤空值避免拼接异常
|
||||
key_parts = []
|
||||
if oauth.appid:
|
||||
key_parts.append(oauth.appid)
|
||||
if oauth.account_username:
|
||||
key_parts.append(oauth.account_username)
|
||||
if oauth.account_userid:
|
||||
key_parts.append(oauth.account_userid)
|
||||
key_parts.append(str(oauth.account_userid))
|
||||
login_key = "|".join(key_parts)
|
||||
|
||||
#同一登录账号已刷新过,直接跳过(避免使用旧数据判断)
|
||||
if login_key in refreshed_keys:
|
||||
logger.debug(f"跳过重复刷新: oauth_id={oauth.id}, 同一登录账号已刷新")
|
||||
continue
|
||||
|
||||
#获取应用配置
|
||||
# 【改动5:过滤已禁用、已删除应用】
|
||||
app_result = await db.execute(
|
||||
select(UserOAuthApp).where(UserOAuthApp.app_id == oauth.appid)
|
||||
select(UserOAuthApp).where(
|
||||
UserOAuthApp.app_id == oauth.appid,
|
||||
UserOAuthApp.status == 1,
|
||||
UserOAuthApp.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
app = app_result.scalar_one_or_none()
|
||||
|
||||
if not app:
|
||||
logger.warning(f"oauth_id:{oauth.id} 对应应用不存在/已禁用,跳过刷新")
|
||||
continue
|
||||
|
||||
#检查access_token是否需要刷新
|
||||
need_refresh = False
|
||||
|
||||
# access_token为空,需要刷新
|
||||
if not oauth.access_token:
|
||||
if not oauth.access_token or not oauth.access_token_expired:
|
||||
need_refresh = True
|
||||
# access_token_expired为空,需要刷新
|
||||
elif not oauth.access_token_expired:
|
||||
need_refresh = True
|
||||
# access_token即将过期(剩余时间小于800秒),需要刷新
|
||||
else:
|
||||
remaining_seconds = (oauth.access_token_expired - now).total_seconds()
|
||||
if remaining_seconds < REFRESH_THRESHOLD_SECONDS:
|
||||
# 同时区时间运算,不会抛异常
|
||||
remain_sec = (oauth.access_token_expired - now_beijing).total_seconds()
|
||||
if remain_sec < REFRESH_THRESHOLD_SECONDS:
|
||||
need_refresh = True
|
||||
|
||||
if not need_refresh:
|
||||
continue
|
||||
|
||||
#refresh_token已在查询条件中过滤,确保有效才能刷新
|
||||
|
||||
#刷新token
|
||||
await refresh_juliang_token(oauth, app, db)
|
||||
|
||||
refreshed_keys.add(login_key)
|
||||
|
||||
# 简单限流,防止瞬间大量请求
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
except Exception as e:
|
||||
#7.增加错误日志
|
||||
logger.error(f"刷新token失败: {str(e)}")
|
||||
logger.error(f"oauth_id:{oauth.id} 刷新token异常: {str(e)}", exc_info=True)
|
||||
|
||||
|
||||
async def token_refresh_scheduler():
|
||||
"""定时任务调度器"""
|
||||
logger.info("开始执行定时Token刷新任务")
|
||||
while True:
|
||||
try:
|
||||
await check_and_refresh_tokens()
|
||||
except Exception as e:
|
||||
logger.error(f"定时任务token_refresh_scheduler执行失败: {str(e)}")
|
||||
logger.error(f"定时任务token_refresh_scheduler执行失败: {str(e)}", exc_info=True)
|
||||
|
||||
await asyncio.sleep(CHECK_INTERVAL_MINUTES * 60)
|
||||
|
||||
|
||||
def start_token_refresh_task():
|
||||
"""启动token刷新定时任务"""
|
||||
logger.info("启动token刷新定时任务")
|
||||
logger.info("启动token刷新定时后台任务")
|
||||
asyncio.create_task(token_refresh_scheduler())
|
||||
@@ -0,0 +1,270 @@
|
||||
from datetime import datetime, timedelta, timezone, date
|
||||
from typing import Optional, Union, Tuple
|
||||
from dateutil import parser
|
||||
|
||||
# ===================== 时区与格式化常量(PHP风格映射) =====================
|
||||
# 北京时间 东八区 UTC+8
|
||||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||||
UTC_TZ = timezone.utc
|
||||
|
||||
# Python strftime 标准格式
|
||||
FORMAT_DATETIME = "%Y-%m-%d %H:%M:%S"
|
||||
FORMAT_DATE = "%Y-%m-%d"
|
||||
FORMAT_TIME = "%H:%M:%S"
|
||||
|
||||
# PHP格式 -> Python格式 映射,贴近PHP使用习惯
|
||||
PHP_FMT_MAP = {
|
||||
"Y-m-d": FORMAT_DATE,
|
||||
"Y-m-d H:i:s": FORMAT_DATETIME,
|
||||
"H:i:s": FORMAT_TIME
|
||||
}
|
||||
|
||||
# ===================== 私有公共工具函数(内部复用) =====================
|
||||
def _normalize_date_str(date_str: str) -> str:
|
||||
"""
|
||||
预处理中文格式日期字符串,统一转为横杠分隔标准格式
|
||||
支持:2025年01月01日 12时30分00秒 / 2025:01:01 等格式
|
||||
"""
|
||||
if not isinstance(date_str, str):
|
||||
return ""
|
||||
s = date_str.strip()
|
||||
# 中文、全角符号替换清洗
|
||||
s = s.replace(":", ":") \
|
||||
.replace("年", "-") \
|
||||
.replace("月", "-") \
|
||||
.replace("日", " ") \
|
||||
.replace("时", ":") \
|
||||
.replace("分", ":") \
|
||||
.replace("秒", "")
|
||||
return s
|
||||
|
||||
|
||||
def _get_python_fmt(fmt: str) -> str:
|
||||
"""兼容PHP格式字符串,自动转为Python strftime格式"""
|
||||
return PHP_FMT_MAP.get(fmt, fmt)
|
||||
|
||||
|
||||
def _convert_ts_to_datetime(timestamp: Union[int, float], tz: timezone = BEIJING_TZ) -> datetime:
|
||||
"""
|
||||
统一处理 10位秒 / 13位毫秒 时间戳 -> 带时区datetime
|
||||
"""
|
||||
ts = float(timestamp)
|
||||
# 毫秒时间戳兼容
|
||||
if ts > 10 ** 12:
|
||||
ts /= 1000
|
||||
return datetime.fromtimestamp(ts, tz=tz)
|
||||
|
||||
|
||||
def _parse_datetime_with_tz(dt_str: str, fmt: str, tz: timezone = BEIJING_TZ) -> Optional[datetime]:
|
||||
"""
|
||||
通用:固定格式日期字符串解析+绑定时区,支持中文日期预处理
|
||||
仅支持指定fmt格式,不支持带时区后缀字符串
|
||||
"""
|
||||
try:
|
||||
fmt = _get_python_fmt(fmt)
|
||||
normalize_str = _normalize_date_str(dt_str)
|
||||
dt = datetime.strptime(normalize_str, fmt)
|
||||
return dt.replace(tzinfo=tz)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _smart_parse_datetime(dt_str: str, base_dt: datetime, is_timezone: bool) -> Optional[datetime]:
|
||||
"""
|
||||
私有:dateutil智能解析日期(用于带时区、相对时间、中文复杂日期)
|
||||
"""
|
||||
clean_str = _normalize_date_str(dt_str)
|
||||
try:
|
||||
parsed_dt = parser.parse(clean_str, default=base_dt, tzinfos=None)
|
||||
if is_timezone:
|
||||
# 保留原始时区,无时区则兜底北京时间
|
||||
if parsed_dt.tzinfo is None:
|
||||
parsed_dt = parsed_dt.replace(tzinfo=BEIJING_TZ)
|
||||
else:
|
||||
# 普通日期强制绑定北京时间
|
||||
parsed_dt = parsed_dt.replace(tzinfo=BEIJING_TZ)
|
||||
return parsed_dt
|
||||
except (parser.ParserError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
# ===================== 对外工具方法(对标PHP + dateutil增强) =====================
|
||||
def time() -> int:
|
||||
"""
|
||||
对标 PHP time()
|
||||
获取当前北京时间 10位 秒级时间戳
|
||||
"""
|
||||
return int(datetime.now(tz=BEIJING_TZ).timestamp())
|
||||
|
||||
|
||||
def microtime(get_as_float: bool = False) -> Union[float, str]:
|
||||
"""
|
||||
对标 PHP microtime()
|
||||
:param get_as_float: True 返回浮点时间戳,False 返回 "微秒 秒" 字符串
|
||||
"""
|
||||
now = datetime.now(tz=BEIJING_TZ)
|
||||
ts = now.timestamp()
|
||||
if get_as_float:
|
||||
return ts
|
||||
sec = int(ts)
|
||||
usec = int((ts - sec) * 1000000)
|
||||
return f"{usec:06d} {sec}"
|
||||
|
||||
|
||||
def strtotime(
|
||||
time_str: str,
|
||||
now: Optional[int] = None,
|
||||
is_timezone: bool = False
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
【增强版 对标 PHP strtotime】依托 dateutil 支持相对时间、带时区时间解析
|
||||
:param time_str: 待解析日期/相对时间字符串
|
||||
:param now: 基准时间戳,默认当前北京时间
|
||||
:param is_timezone: 是否为带时区格式的时间字符串(如 2026-07-03 10:01:29.621351+08)
|
||||
- True:优先使用字符串自带时区,无时区则兜底北京时间
|
||||
- False:默认按北京时间解析普通日期字符串
|
||||
:return: 秒级时间戳,解析失败返回 None
|
||||
"""
|
||||
if not isinstance(time_str, str) or not time_str.strip():
|
||||
return None
|
||||
|
||||
# 基准时间:默认当前北京时间
|
||||
base_dt = datetime.fromtimestamp(now, tz=BEIJING_TZ) if now else datetime.now(tz=BEIJING_TZ)
|
||||
parsed_dt = _smart_parse_datetime(time_str, base_dt, is_timezone)
|
||||
if not parsed_dt:
|
||||
return None
|
||||
return int(parsed_dt.timestamp())
|
||||
|
||||
|
||||
def date(fmt: str, timestamp: Optional[int] = None) -> str:
|
||||
"""
|
||||
对标 PHP date()
|
||||
时间戳格式化,默认北京时间
|
||||
:param fmt: 支持 Y-m-d / Y-m-d H:i:s 或 %Y-%m-%d 原生格式
|
||||
:param timestamp: 秒时间戳,None则取当前时间
|
||||
"""
|
||||
if timestamp is None:
|
||||
dt = datetime.now(tz=BEIJING_TZ)
|
||||
else:
|
||||
dt = _convert_ts_to_datetime(timestamp)
|
||||
|
||||
python_fmt = _get_python_fmt(fmt)
|
||||
return dt.strftime(python_fmt)
|
||||
|
||||
|
||||
def timestamp_to_datetime(timestamp: Union[int, float], fmt: str = "Y-m-d H:i:s") -> str:
|
||||
"""
|
||||
时间戳(秒/毫秒)→ 北京时间格式化字符串
|
||||
:param timestamp: 10位秒 /13位毫秒
|
||||
:param fmt: PHP风格格式 Y-m-d / Y-m-d H:i:s
|
||||
"""
|
||||
dt = _convert_ts_to_datetime(timestamp)
|
||||
python_fmt = _get_python_fmt(fmt)
|
||||
return dt.strftime(python_fmt)
|
||||
|
||||
|
||||
def datetime_to_timestamp(dt_str: str, fmt: str = "Y-m-d H:i:s") -> Optional[int]:
|
||||
"""
|
||||
日期字符串(含中文日期) → 北京时间秒时间戳
|
||||
:param dt_str: 日期字符串
|
||||
:param fmt: PHP风格格式化模板
|
||||
:return: 秒时间戳,解析失败返回None
|
||||
"""
|
||||
dt = _parse_datetime_with_tz(dt_str, fmt)
|
||||
if not dt:
|
||||
return None
|
||||
return int(dt.timestamp())
|
||||
|
||||
|
||||
def get_today_start_timestamp() -> int:
|
||||
"""获取今日 00:00:00 北京时间 秒时间戳"""
|
||||
today: date = datetime.now(tz=BEIJING_TZ).date()
|
||||
start_dt = datetime.combine(today, datetime.min.time(), tzinfo=BEIJING_TZ)
|
||||
return int(start_dt.timestamp())
|
||||
|
||||
|
||||
def get_today_end_timestamp() -> int:
|
||||
"""获取今日 23:59:59.999999 北京时间 秒时间戳"""
|
||||
today: date = datetime.now(tz=BEIJING_TZ).date()
|
||||
end_dt = datetime.combine(today, datetime.max.time(), tzinfo=BEIJING_TZ)
|
||||
return int(end_dt.timestamp())
|
||||
|
||||
|
||||
def parse_date_range(date_list: list, fmt: str = "Y-m-d", is_timezone: bool = False) -> Optional[Tuple[datetime, datetime]]:
|
||||
"""
|
||||
时间范围解析:["2025-01-01","2026-01-01"] → (开始0点,结束23:59:59.999999) 带北京时间
|
||||
:param date_list: 长度为2的日期字符串数组
|
||||
:param fmt: 日期格式,默认Y-m-d
|
||||
:param is_timezone: 是否为带时区格式的时间字符串(如 2026-07-03 10:01:29.621351+08)
|
||||
- True:使用dateutil智能解析,优先保留字符串自带时区,无时区兜底北京时间
|
||||
- False:按指定fmt格式精准解析,强制北京时间
|
||||
:return: (start_dt, end_dt) 格式非法返回None
|
||||
"""
|
||||
# 强参数校验
|
||||
if not isinstance(date_list, list) or len(date_list) != 2:
|
||||
return None
|
||||
start_str, end_str = date_list[0].strip(), date_list[1].strip()
|
||||
if not start_str or not end_str:
|
||||
return None
|
||||
|
||||
base_now = datetime.now(tz=BEIJING_TZ)
|
||||
|
||||
if is_timezone:
|
||||
# 带时区场景:智能解析,支持 2026-07-03 08:16:26.88303+08
|
||||
start_dt = _smart_parse_datetime(start_str, base_now, is_timezone=True)
|
||||
end_dt = _smart_parse_datetime(end_str, base_now, is_timezone=True)
|
||||
else:
|
||||
# 普通前端日期:固定格式解析
|
||||
start_dt = _parse_datetime_with_tz(start_str, fmt, BEIJING_TZ)
|
||||
end_dt = _parse_datetime_with_tz(end_str, fmt, BEIJING_TZ)
|
||||
|
||||
if not start_dt or not end_dt:
|
||||
return None
|
||||
|
||||
# 结束时间补全到当日最后一毫秒
|
||||
end_dt = end_dt.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
return start_dt, end_dt
|
||||
|
||||
|
||||
def db_tz_str_to_datetime(db_datetime_str: str) -> Optional[datetime]:
|
||||
"""
|
||||
数据库timestamptz格式字符串(2026-07-03 08:16:26.88303+08)转为带时区datetime
|
||||
"""
|
||||
try:
|
||||
dt = datetime.fromisoformat(db_datetime_str)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=BEIJING_TZ)
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def datetime_to_db_tz_str(dt: datetime) -> str:
|
||||
"""
|
||||
带时区datetime转为数据库timestamptz标准字符串,统一北京时间存储
|
||||
"""
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=BEIJING_TZ)
|
||||
else:
|
||||
dt = dt.astimezone(BEIJING_TZ)
|
||||
return dt.isoformat()
|
||||
|
||||
|
||||
# 导出列表
|
||||
__all__ = [
|
||||
"BEIJING_TZ",
|
||||
"UTC_TZ",
|
||||
"FORMAT_DATETIME",
|
||||
"FORMAT_DATE",
|
||||
"FORMAT_TIME",
|
||||
"time",
|
||||
"microtime",
|
||||
"strtotime",
|
||||
"date",
|
||||
"timestamp_to_datetime",
|
||||
"datetime_to_timestamp",
|
||||
"get_today_start_timestamp",
|
||||
"get_today_end_timestamp",
|
||||
"parse_date_range",
|
||||
"db_tz_str_to_datetime",
|
||||
"datetime_to_db_tz_str"
|
||||
]
|
||||
@@ -5,8 +5,14 @@ from typing import Any, Dict, Optional, Tuple
|
||||
import httpx
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 【改动1:引入自定义北京时间工具类】
|
||||
from app.utils.datetime_util import (
|
||||
BEIJING_TZ,
|
||||
datetime_to_db_tz_str,
|
||||
db_tz_str_to_datetime
|
||||
)
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.models.user_oauth_app import UserOAuthApp
|
||||
from app.models.base import async_session
|
||||
@@ -40,50 +46,75 @@ class DouyinRequest:
|
||||
self._client = None
|
||||
|
||||
async def _get_redis_token(self, oauth_id: str) -> Optional[str]:
|
||||
"""
|
||||
【核心改动:北京时间解析+脏缓存自动清理】
|
||||
1. 缓存格式异常/字段缺失:直接删除脏缓存
|
||||
2. Token过期则不返回,交由上层判断refresh是否过期决定是否删除缓存
|
||||
"""
|
||||
redis = get_redis()
|
||||
if not redis:
|
||||
raise ValueError("Redis连接未配置")
|
||||
|
||||
try:
|
||||
cache_str = await redis.hget(self._redis_key, oauth_id)
|
||||
if cache_str:
|
||||
cache = json.loads(cache_str)
|
||||
token = cache.get("token")
|
||||
expired_at_str = cache.get("expired_at")
|
||||
if token and expired_at_str:
|
||||
expired_at = datetime.fromisoformat(expired_at_str).replace(tzinfo=timezone.utc)
|
||||
if expired_at > datetime.now(timezone.utc):
|
||||
return token
|
||||
except Exception as e:
|
||||
raise ValueError(f"获取Redis缓存失败: {e}")
|
||||
if not cache_str:
|
||||
return None
|
||||
|
||||
return None
|
||||
cache = json.loads(cache_str)
|
||||
token = cache.get("token")
|
||||
expired_at_str = cache.get("expired_at")
|
||||
|
||||
# 脏缓存:关键字段缺失,直接删除
|
||||
if not (token and expired_at_str):
|
||||
await redis.hdel(self._redis_key, oauth_id)
|
||||
logger.warning(f"oauth_id:{oauth_id} Redis缓存字段缺失,已清理脏数据")
|
||||
return None
|
||||
|
||||
# 【改动3:使用工具类解析带时区时间,不再强制覆盖UTC】
|
||||
expired_at = db_tz_str_to_datetime(expired_at_str)
|
||||
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||
|
||||
if expired_at > now_beijing:
|
||||
return token
|
||||
|
||||
# AccessToken已过期,返回None,上层会校验refresh_token状态决定是否清理缓存
|
||||
return None
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# JSON格式损坏,清理脏缓存
|
||||
await redis.hdel(self._redis_key, oauth_id)
|
||||
logger.error(f"oauth_id:{oauth_id} Redis缓存JSON格式异常,已清理脏数据")
|
||||
return None
|
||||
except Exception as e:
|
||||
raise ValueError(f"获取Redis缓存失败: {str(e)}")
|
||||
|
||||
async def _set_redis_token(self, oauth_id: str, token: str, expired_at: datetime):
|
||||
redis = get_redis()
|
||||
if not redis:
|
||||
raise ValueError("Redis连接未配置")
|
||||
|
||||
# 【改动4:统一转为北京时间序列化存入Redis,和数据库时区对齐】
|
||||
cache = {
|
||||
"token": token,
|
||||
"expired_at": datetime_to_db_tz_str(expired_at),
|
||||
}
|
||||
try:
|
||||
cache = {
|
||||
"token": token,
|
||||
"expired_at": expired_at.isoformat(),
|
||||
}
|
||||
await redis.hset(self._redis_key, oauth_id, json.dumps(cache))
|
||||
await redis.hset(self._redis_key, oauth_id, json.dumps(cache, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
raise ValueError(f"设置Redis缓存失败: {e}")
|
||||
raise ValueError(f"设置Redis缓存失败: {str(e)}")
|
||||
|
||||
async def _delete_redis_token(self, oauth_id: str):
|
||||
"""删除单个oauth_id缓存(授权彻底失效时调用)"""
|
||||
redis = get_redis()
|
||||
if not redis:
|
||||
raise ValueError("Redis连接未配置")
|
||||
|
||||
try:
|
||||
await redis.hdel(self._redis_key, oauth_id)
|
||||
logger.info(f"oauth_id:{oauth_id} 授权失效,已清理Redis缓存脏数据")
|
||||
except Exception as e:
|
||||
raise ValueError(f"删除Redis缓存失败: {e}")
|
||||
raise ValueError(f"删除Redis缓存失败: {str(e)}")
|
||||
|
||||
async def get_access_token(self, oauth_id: str, force_refresh: bool = False) -> str:
|
||||
# 优先读取缓存
|
||||
if not force_refresh:
|
||||
token = await self._get_redis_token(oauth_id)
|
||||
if token:
|
||||
@@ -101,7 +132,17 @@ class DouyinRequest:
|
||||
if not oauth_data:
|
||||
raise ValueError("无效的oauth_id")
|
||||
|
||||
# 【改动5:统一北京时间当前时间】
|
||||
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||
|
||||
if force_refresh:
|
||||
# 强制刷新:先检查refresh_token是否为空
|
||||
if not oauth_data.refresh_token:
|
||||
# RefreshToken为空 → 授权已失效,清理Redis脏缓存
|
||||
await self._delete_redis_token(oauth_id)
|
||||
raise ValueError("授权已过期,请重新授权登录")
|
||||
|
||||
# 清空同条件下所有账号缓存与数据库token
|
||||
where_cond = UserOAuth.deleted_at.is_(None)
|
||||
if oauth_data.appid:
|
||||
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
|
||||
@@ -118,9 +159,7 @@ class DouyinRequest:
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
related_oauth_ids = await db.execute(
|
||||
select(UserOAuth.id).where(where_cond)
|
||||
)
|
||||
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
|
||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||
for related_id in related_oauth_ids:
|
||||
await self._delete_redis_token(related_id)
|
||||
@@ -128,12 +167,14 @@ class DouyinRequest:
|
||||
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
|
||||
return new_token
|
||||
|
||||
if oauth_data.access_token_expired and oauth_data.access_token_expired > datetime.now(timezone.utc):
|
||||
# 数据库AccessToken未过期,写入缓存直接返回
|
||||
if oauth_data.access_token_expired and oauth_data.access_token_expired > now_beijing:
|
||||
token = oauth_data.access_token
|
||||
expired_at = oauth_data.access_token_expired
|
||||
await self._set_redis_token(oauth_id, token, expired_at)
|
||||
return token
|
||||
|
||||
# 查找同账号下未过期有效token复用
|
||||
where_cond = UserOAuth.deleted_at.is_(None)
|
||||
if oauth_data.appid:
|
||||
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
|
||||
@@ -146,7 +187,7 @@ class DouyinRequest:
|
||||
select(UserOAuth.access_token, UserOAuth.access_token_expired).where(
|
||||
where_cond,
|
||||
UserOAuth.access_token_expired.is_not(None),
|
||||
UserOAuth.access_token_expired > datetime.now(timezone.utc),
|
||||
UserOAuth.access_token_expired > now_beijing,
|
||||
).limit(1)
|
||||
)
|
||||
related_oauth = related_oauths.first()
|
||||
@@ -155,9 +196,18 @@ class DouyinRequest:
|
||||
await self._set_redis_token(oauth_id, token, expired_at)
|
||||
return token
|
||||
|
||||
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < datetime.now(timezone.utc):
|
||||
raise ValueError("授权已过期,请重新授权")
|
||||
# =========【核心业务规则实现:判断RefreshToken是否过期】=========
|
||||
if not oauth_data.refresh_token:
|
||||
# RefreshToken为空 → 授权已失效,清理Redis脏缓存
|
||||
await self._delete_redis_token(oauth_id)
|
||||
raise ValueError("授权已过期,请重新授权登录")
|
||||
|
||||
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < now_beijing:
|
||||
# RefreshToken过期 → 授权彻底失效,清理Redis脏缓存
|
||||
await self._delete_redis_token(oauth_id)
|
||||
raise ValueError("授权已过期,请重新授权登录")
|
||||
|
||||
# RefreshToken有效,执行刷新并更新缓存
|
||||
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
|
||||
return new_token
|
||||
|
||||
@@ -203,6 +253,7 @@ class DouyinRequest:
|
||||
|
||||
code = data.get('code', 0)
|
||||
if code != 0:
|
||||
#刷新token
|
||||
raise ValueError(f"刷新access_token失败,接口返回:{data}")
|
||||
|
||||
data = data.get('data', {})
|
||||
@@ -211,7 +262,10 @@ class DouyinRequest:
|
||||
expires_in = data.get('expires_in', 0)
|
||||
refresh_token_expires_in = data.get('refresh_token_expires_in', 0)
|
||||
|
||||
new_expired_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
||||
# 【改动6:北京时间计算过期时间】
|
||||
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||
new_expired_at = now_beijing + timedelta(seconds=expires_in)
|
||||
new_refresh_expired = now_beijing + timedelta(seconds=refresh_token_expires_in)
|
||||
|
||||
where_cond = UserOAuth.deleted_at.is_(None)
|
||||
if appid:
|
||||
@@ -222,20 +276,16 @@ class DouyinRequest:
|
||||
where_cond = where_cond & (UserOAuth.account_userid == account_userid)
|
||||
|
||||
await db.execute(
|
||||
update(UserOAuth).where(
|
||||
where_cond,
|
||||
).values(
|
||||
update(UserOAuth).where(where_cond).values(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
access_token_expired=new_expired_at,
|
||||
refresh_token_expired=datetime.now(timezone.utc) + timedelta(seconds=refresh_token_expires_in),
|
||||
refresh_token_expired=new_refresh_expired,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
related_oauth_ids = await db.execute(
|
||||
select(UserOAuth.id).where(where_cond)
|
||||
)
|
||||
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
|
||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||
|
||||
for related_id in related_oauth_ids:
|
||||
@@ -243,92 +293,79 @@ class DouyinRequest:
|
||||
|
||||
return new_access_token, new_expired_at
|
||||
|
||||
# 有token请求
|
||||
async def request_with_token_with_context(
|
||||
self,
|
||||
oauth_id: str,
|
||||
url: str,
|
||||
method: str = 'GET',
|
||||
options: any = None,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
request_count: int = 1,
|
||||
) -> Any:
|
||||
options = options or {}
|
||||
token = await self.get_access_token(oauth_id)
|
||||
resp_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
for i in range(1, request_count+1):
|
||||
# 有一些错误是触发频次管理的,需要重试
|
||||
for i in range(1, request_count + 1):
|
||||
try:
|
||||
headers = options.get('headers', {}).copy()
|
||||
headers['Access-Token'] = token
|
||||
|
||||
has_files = 'files' in options
|
||||
if has_files:
|
||||
# 移除可能错误设置的 Content-Type,让库自动生成 multipart 头
|
||||
headers.pop('Content-Type', None)
|
||||
else:
|
||||
headers.setdefault('Content-Type', 'application/json')
|
||||
|
||||
options['headers'] = headers
|
||||
|
||||
response = await self.client.request(method, url, **options)
|
||||
response.raise_for_status()
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
resp_data = response.json()
|
||||
except json.JSONDecodeError:
|
||||
data = {'code': 0, 'data': response.text, 'msg':'JSON解析失败'}
|
||||
resp_data = {'code': 0, 'data': response.text, 'msg': 'JSON解析失败'}
|
||||
|
||||
if 'code' not in data:
|
||||
if 'code' not in resp_data:
|
||||
await asyncio.sleep(i * 5)
|
||||
continue
|
||||
|
||||
code = data.get('code', 0)
|
||||
|
||||
code = resp_data.get('code', 0)
|
||||
if code in [40102, 40104]:
|
||||
await asyncio.sleep(i * 5)
|
||||
token = await self.get_access_token(oauth_id, force_refresh=True)
|
||||
continue
|
||||
|
||||
if code in [40100, 40110]:
|
||||
wait_time = min(2 * (2 ** (i - 1)), 10)
|
||||
await asyncio.sleep(wait_time)
|
||||
continue
|
||||
|
||||
if code == 50000:
|
||||
await asyncio.sleep(i * 10)
|
||||
continue
|
||||
|
||||
return data
|
||||
return resp_data
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
await asyncio.sleep(i * 10)
|
||||
continue
|
||||
except httpx.RequestError as e:
|
||||
except (httpx.HTTPStatusError, httpx.RequestError):
|
||||
await asyncio.sleep(i * 10)
|
||||
continue
|
||||
|
||||
# 重试耗尽,日志记录
|
||||
options_log = {}
|
||||
if options:
|
||||
for key, value in options.items():
|
||||
if key == 'files':
|
||||
options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
|
||||
else:
|
||||
options_log[key] = value
|
||||
|
||||
res = json.dumps(data, ensure_ascii=False) if 'data' in locals() else ''
|
||||
for key, value in options.items():
|
||||
if key == 'files':
|
||||
options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
|
||||
else:
|
||||
options_log[key] = value
|
||||
|
||||
res = json.dumps(resp_data, ensure_ascii=False) if resp_data else ''
|
||||
logger.error(
|
||||
f'DouYin API request failed after {request_count} retries. '
|
||||
f'url:{url};method:{method};oauth_id:{oauth_id};options:{json.dumps(options_log, ensure_ascii=False)};response:{res}'
|
||||
)
|
||||
|
||||
if 'data' in locals() and data.get('code', 0) != 0:
|
||||
raise ValueError(f'接口返回错误[code:{data.get("code", "接口编码")}]{data.get("message", "接口返回错误")}')
|
||||
else:
|
||||
raise ValueError('网络错误,稍后重试。')
|
||||
if resp_data and resp_data.get("code", 0) != 0:
|
||||
raise ValueError(f'接口返回错误[code:{resp_data.get("code")}]{resp_data.get("message", "接口异常")}')
|
||||
raise ValueError("网络请求失败,请稍后重试")
|
||||
|
||||
|
||||
# 无token请求
|
||||
async def request_with_context(
|
||||
self,
|
||||
url: str,
|
||||
@@ -336,8 +373,9 @@ class DouyinRequest:
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
options = options or {}
|
||||
resp_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
for i in range(1, 2):
|
||||
for i in range(1, 3):
|
||||
try:
|
||||
headers = options.get('headers', {}).copy()
|
||||
headers.setdefault('Content-Type', 'application/json')
|
||||
@@ -347,30 +385,24 @@ class DouyinRequest:
|
||||
response.raise_for_status()
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
resp_data = response.json()
|
||||
except json.JSONDecodeError:
|
||||
data = {'code': 0, 'data': response.text}
|
||||
resp_data = {'code': 0, 'data': response.text}
|
||||
|
||||
if 'code' not in data:
|
||||
if 'code' not in resp_data:
|
||||
await asyncio.sleep(i * 5)
|
||||
continue
|
||||
|
||||
code = data.get('code', 0)
|
||||
if code >= 50000:
|
||||
if resp_data.get("code", 0) >= 50000:
|
||||
await asyncio.sleep(i * 10)
|
||||
continue
|
||||
return resp_data
|
||||
|
||||
return data
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
await asyncio.sleep(i * 10)
|
||||
continue
|
||||
except httpx.RequestError as e:
|
||||
except (httpx.HTTPStatusError, httpx.RequestError):
|
||||
await asyncio.sleep(i * 10)
|
||||
continue
|
||||
|
||||
res = json.dumps(data) if 'data' in locals() else ''
|
||||
res = json.dumps(resp_data, ensure_ascii=False) if resp_data else ''
|
||||
raise RuntimeError(
|
||||
f'DouYin API request failed after 5 retries. '
|
||||
f'url:{url};options:{json.dumps(options)};response:{res}'
|
||||
f'DouYin API request failed after 2 retries. '
|
||||
f'url:{url};options:{json.dumps(options, ensure_ascii=False)};response:{res}'
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.utils.kuaishouRequest import KuaishouRequest
|
||||
from app.models.user_oauth import UserOAuth
|
||||
|
||||
|
||||
class KuaishouApi:
|
||||
def __init__(self):
|
||||
self.request = KuaishouRequest()
|
||||
|
||||
# 获取广告主信息
|
||||
async def get_advertiser_info(self, oauth_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
if not oauth_id:
|
||||
raise RuntimeError('OAuth ID is not set.')
|
||||
|
||||
url = "https://ad.e.kuaishou.com/rest/openapi/v1/advertiser/info"
|
||||
return await self.request.request_with_token_with_context(
|
||||
oauth_id,
|
||||
url,
|
||||
'GET',
|
||||
{'params': params or {}}
|
||||
)
|
||||
|
||||
# 上传图片素材
|
||||
async def upload_image_material(self, oauth_id: str, data: Optional[Dict[str, Any]] = None, files: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
if not oauth_id:
|
||||
raise RuntimeError('OAuth ID is not set.')
|
||||
|
||||
url = "https://ad.e.kuaishou.com/rest/openapi/v1/file/ad/image/upload"
|
||||
options: Dict[str, Any] = {}
|
||||
if data:
|
||||
options['data'] = data
|
||||
if files:
|
||||
options['files'] = files
|
||||
return await self.request.request_with_token_with_context(
|
||||
oauth_id,
|
||||
url,
|
||||
'POST',
|
||||
options,
|
||||
request_count=3
|
||||
)
|
||||
|
||||
# 上传视频素材
|
||||
async def upload_video_material(self, oauth_id: str, data: Optional[Dict[str, Any]] = None, files: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
if not oauth_id:
|
||||
raise RuntimeError('OAuth ID is not set.')
|
||||
|
||||
url = "https://ad.e.kuaishou.com/rest/openapi/v1/file/ad/video/upload"
|
||||
options: Dict[str, Any] = {}
|
||||
if data:
|
||||
options['data'] = data
|
||||
if files:
|
||||
options['files'] = files
|
||||
return await self.request.request_with_token_with_context(
|
||||
oauth_id,
|
||||
url,
|
||||
'POST',
|
||||
options,
|
||||
request_count=3
|
||||
)
|
||||
|
||||
# 获取地域信息
|
||||
async def get_area(self, oauth_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
if not oauth_id:
|
||||
raise RuntimeError('OAuth ID is not set.')
|
||||
|
||||
url = "https://ad.e.kuaishou.com/rest/openapi/v1/tools/admin/info"
|
||||
return await self.request.request_with_token_with_context(
|
||||
oauth_id,
|
||||
url,
|
||||
'GET',
|
||||
{'params': params or {}}
|
||||
)
|
||||
|
||||
# 获取素材消耗(报表查询)
|
||||
async def get_material_cost(self, oauth_id: str, params: any, request_count: int = 3) -> Dict[str, Any]:
|
||||
if not oauth_id:
|
||||
raise RuntimeError('OAuth ID is not set.')
|
||||
|
||||
url = "https://ad.e.kuaishou.com/rest/openapi/v1/report/get"
|
||||
return await self.request.request_with_token_with_context(
|
||||
oauth_id,
|
||||
url,
|
||||
'GET',
|
||||
{'params': params or {}},
|
||||
request_count=request_count
|
||||
)
|
||||
|
||||
# 获取账户信息
|
||||
async def get_account_info(self, oauth_id: str, params: any) -> Dict[str, Any]:
|
||||
if not oauth_id:
|
||||
raise RuntimeError('OAuth ID is not set.')
|
||||
|
||||
url = "https://ad.e.kuaishou.com/rest/openapi/v1/advertiser/info"
|
||||
return await self.request.request_with_token_with_context(
|
||||
oauth_id,
|
||||
url,
|
||||
'GET',
|
||||
{'params': params or {}}
|
||||
)
|
||||
|
||||
# 获取广告计划列表
|
||||
async def get_ad_list(self, oauth_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
if not oauth_id:
|
||||
raise RuntimeError('OAuth ID is not set.')
|
||||
|
||||
url = "https://ad.e.kuaishou.com/rest/openapi/v1/ad/list"
|
||||
return await self.request.request_with_token_with_context(
|
||||
oauth_id,
|
||||
url,
|
||||
'GET',
|
||||
{'params': params or {}}
|
||||
)
|
||||
|
||||
# 获取广告组列表
|
||||
async def get_ad_unit_list(self, oauth_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
if not oauth_id:
|
||||
raise RuntimeError('OAuth ID is not set.')
|
||||
|
||||
url = "https://ad.e.kuaishou.com/rest/openapi/v2/ad_unit/list"
|
||||
return await self.request.request_with_token_with_context(
|
||||
oauth_id,
|
||||
url,
|
||||
'GET',
|
||||
{'params': params or {}}
|
||||
)
|
||||
|
||||
# 获取账户余额
|
||||
async def get_account_balance(self, oauth_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
if not oauth_id:
|
||||
raise RuntimeError('OAuth ID is not set.')
|
||||
|
||||
url = "https://ad.e.kuaishou.com/rest/openapi/v1/advertiser/balance"
|
||||
return await self.request.request_with_token_with_context(
|
||||
oauth_id,
|
||||
url,
|
||||
'GET',
|
||||
{'params': params or {}}
|
||||
)
|
||||
@@ -0,0 +1,276 @@
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from app.models.user_oauth import UserOAuth
|
||||
from app.models.user_oauth_app import UserOAuthApp
|
||||
from app.models.base import async_session
|
||||
from app.utils.redis import get_redis
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("kuaishou_request", "kuaishou_request")
|
||||
|
||||
|
||||
class KuaishouRequest:
|
||||
def __init__(self, platform: str = "kuaishou"):
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._platform = platform
|
||||
|
||||
@property
|
||||
def client(self) -> httpx.AsyncClient:
|
||||
if not self._client:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(30.0),
|
||||
follow_redirects=True,
|
||||
)
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def _redis_key(self) -> str:
|
||||
return f"{self._platform}:tokens"
|
||||
|
||||
async def close(self):
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def _get_redis_token(self, oauth_id: str) -> Optional[str]:
|
||||
redis = get_redis()
|
||||
if not redis:
|
||||
raise ValueError("Redis连接未配置")
|
||||
|
||||
try:
|
||||
cache_str = await redis.hget(self._redis_key, oauth_id)
|
||||
if cache_str:
|
||||
cache = json.loads(cache_str)
|
||||
token = cache.get("token")
|
||||
expired_at_str = cache.get("expired_at")
|
||||
if token and expired_at_str:
|
||||
expired_at = datetime.fromisoformat(expired_at_str).replace(tzinfo=timezone.utc)
|
||||
if expired_at > datetime.now(timezone.utc):
|
||||
return token
|
||||
except Exception as e:
|
||||
raise ValueError(f"获取Redis缓存失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
async def _set_redis_token(self, oauth_id: str, token: str, expired_at: datetime):
|
||||
redis = get_redis()
|
||||
if not redis:
|
||||
raise ValueError("Redis连接未配置")
|
||||
|
||||
try:
|
||||
cache = {
|
||||
"token": token,
|
||||
"expired_at": expired_at.isoformat(),
|
||||
}
|
||||
await redis.hset(self._redis_key, oauth_id, json.dumps(cache))
|
||||
except Exception as e:
|
||||
raise ValueError(f"设置Redis缓存失败: {e}")
|
||||
|
||||
async def _delete_redis_token(self, oauth_id: str):
|
||||
redis = get_redis()
|
||||
if not redis:
|
||||
raise ValueError("Redis连接未配置")
|
||||
|
||||
try:
|
||||
await redis.hdel(self._redis_key, oauth_id)
|
||||
except Exception as e:
|
||||
raise ValueError(f"删除Redis缓存失败: {e}")
|
||||
|
||||
async def get_access_token(self, oauth_id: str, force_refresh: bool = False) -> str:
|
||||
if not force_refresh:
|
||||
token = await self._get_redis_token(oauth_id)
|
||||
if token:
|
||||
return token
|
||||
|
||||
async with async_session() as db:
|
||||
oauth_data = await db.execute(
|
||||
select(UserOAuth).where(
|
||||
UserOAuth.id == oauth_id,
|
||||
UserOAuth.deleted_at.is_(None),
|
||||
).limit(1)
|
||||
)
|
||||
oauth_data = oauth_data.scalar_one_or_none()
|
||||
|
||||
if not oauth_data:
|
||||
raise ValueError("无效的oauth_id")
|
||||
|
||||
if force_refresh:
|
||||
await db.execute(
|
||||
update(UserOAuth).where(UserOAuth.id == oauth_id).values(
|
||||
access_token=None,
|
||||
access_token_expired=None,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await self._delete_redis_token(oauth_id)
|
||||
|
||||
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
|
||||
await self._set_redis_token(oauth_id, new_token, new_expired_at)
|
||||
return new_token
|
||||
|
||||
if oauth_data.access_token_expired and oauth_data.access_token_expired > datetime.now(timezone.utc):
|
||||
token = oauth_data.access_token
|
||||
expired_at = oauth_data.access_token_expired
|
||||
await self._set_redis_token(oauth_id, token, expired_at)
|
||||
return token
|
||||
|
||||
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < datetime.now(timezone.utc):
|
||||
raise ValueError("授权已过期,请重新授权")
|
||||
|
||||
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
|
||||
await self._set_redis_token(oauth_id, new_token, new_expired_at)
|
||||
return new_token
|
||||
|
||||
async def refresh_access_token(self, db: AsyncSession, oauth_id: str, appid: str, refresh_token: str) -> Tuple[str, datetime]:
|
||||
result = await db.execute(
|
||||
select(UserOAuthApp.secret).where(
|
||||
UserOAuthApp.app_id == appid,
|
||||
UserOAuthApp.status == 1,
|
||||
UserOAuthApp.deleted_at.is_(None),
|
||||
).limit(1)
|
||||
)
|
||||
app_secret = result.scalar_one_or_none()
|
||||
|
||||
if not app_secret:
|
||||
raise ValueError("应用已被删除或禁用")
|
||||
|
||||
# 快手刷新token接口
|
||||
response = await self.client.request(
|
||||
'POST',
|
||||
'https://ad.e.kuaishou.com/rest/openapi/oauth2/authorize/refresh_token',
|
||||
data={
|
||||
'app_id': appid,
|
||||
'secret': app_secret,
|
||||
'refresh_token': refresh_token,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if 'code' not in data:
|
||||
raise ValueError("刷新access_token失败,接口未返回code")
|
||||
|
||||
code = data.get('code', 0)
|
||||
if code != 0:
|
||||
raise ValueError(f"刷新access_token失败,接口返回:{data}")
|
||||
|
||||
data = data.get('data', {})
|
||||
new_access_token = data.get('access_token', '')
|
||||
new_refresh_token = data.get('refresh_token', '')
|
||||
expires_in = data.get('access_token_expires_in', 0)
|
||||
refresh_token_expires_in = data.get('refresh_token_expires_in', 0)
|
||||
|
||||
new_expired_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
||||
|
||||
await db.execute(
|
||||
update(UserOAuth).where(
|
||||
UserOAuth.id == oauth_id,
|
||||
).values(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
access_token_expired=new_expired_at,
|
||||
refresh_token_expired=datetime.now(timezone.utc) + timedelta(seconds=refresh_token_expires_in),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return new_access_token, new_expired_at
|
||||
|
||||
# 有token请求
|
||||
async def request_with_token_with_context(
|
||||
self,
|
||||
oauth_id: str,
|
||||
url: str,
|
||||
method: str = 'GET',
|
||||
options: any = None,
|
||||
request_count: int = 3,
|
||||
) -> Any:
|
||||
options = options or {}
|
||||
token = await self.get_access_token(oauth_id)
|
||||
|
||||
for i in range(1, request_count+1):
|
||||
try:
|
||||
headers = options.get('headers', {}).copy()
|
||||
headers['Access-Token'] = token
|
||||
|
||||
has_files = 'files' in options
|
||||
if has_files:
|
||||
headers.pop('Content-Type', None)
|
||||
else:
|
||||
headers.setdefault('Content-Type', 'application/json')
|
||||
|
||||
options['headers'] = headers
|
||||
|
||||
response = await self.client.request(method, url, **options)
|
||||
response.raise_for_status()
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except json.JSONDecodeError:
|
||||
data = {'code': 0, 'data': response.text, 'msg': 'JSON解析失败'}
|
||||
|
||||
# 如果没有code,直接返回数据,快手触发接口频次会返回空
|
||||
if 'code' not in data:
|
||||
await asyncio.sleep(i * 5)
|
||||
continue
|
||||
|
||||
code = data.get('code', 0)
|
||||
|
||||
# 检查是否需要刷新令牌
|
||||
if code in [402000, 400003, 402007, 402005, 402004, 401000]:
|
||||
# 如果message包含 '该账户不在您的代理商下',说明账户已经转走了,不需要刷新token,直接返回
|
||||
message = data.get('message', '')
|
||||
if '该账户不在您的代理商下' in message:
|
||||
return data
|
||||
|
||||
await asyncio.sleep(i * 5)
|
||||
token = await self.get_access_token(oauth_id, force_refresh=True)
|
||||
continue
|
||||
|
||||
# 检查是否触发接口频次
|
||||
if code in [400001, 402007, 402008, 410000, 410001]:
|
||||
await asyncio.sleep(i * 5)
|
||||
continue
|
||||
|
||||
# 服务端错误
|
||||
if code >= 500000:
|
||||
await asyncio.sleep(i * 5)
|
||||
continue
|
||||
|
||||
# 其他错误直接返回数据
|
||||
return data
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
await asyncio.sleep(i * 10)
|
||||
continue
|
||||
except httpx.RequestError as e:
|
||||
await asyncio.sleep(i * 10)
|
||||
continue
|
||||
|
||||
options_log = {}
|
||||
if options:
|
||||
for key, value in options.items():
|
||||
if key == 'files':
|
||||
options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
|
||||
else:
|
||||
options_log[key] = value
|
||||
|
||||
res = json.dumps(data, ensure_ascii=False) if 'data' in locals() else ''
|
||||
|
||||
logger.error(
|
||||
f'KuaiShou API request failed after {request_count} retries. '
|
||||
f'url:{url};method:{method};oauth_id:{oauth_id};options:{json.dumps(options_log, ensure_ascii=False)};response:{res}'
|
||||
)
|
||||
|
||||
if 'data' in locals() and data.get('code', 0) != 0:
|
||||
raise ValueError(f'接口返回错误[code:{data.get("code", "接口编码")}]{data.get("message", "接口返回错误")}')
|
||||
else:
|
||||
raise ValueError('网络错误,稍后重试。')
|
||||
|
||||
+95
-91
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CyYhkNba.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CclRZKjR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -113,6 +113,20 @@ export async function optimizePrompt(
|
||||
image_px: params.image_px || null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadAudio(file: File): Promise<{ url: string; filename: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-audio`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error('图片上传失败');
|
||||
const data = await res.json();
|
||||
return { url: data.url, filename: data.filename };
|
||||
}
|
||||
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
@@ -721,9 +735,6 @@ export async function getHomeCaseHeader(): Promise<any> {
|
||||
export async function getHomeCaseButton(id: string,limit:number=5): Promise<any> {
|
||||
return api.get(`/home-materials?category_id=${id}&limit_per_category=${limit}&include_empty_categories=false&response_mode=grouped&page=1&page_size=20`);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function deleteResourcesMaterial(params:any): Promise<any> {
|
||||
return api.delete(`/generation-ai/history/batch`, params);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
} 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, createContactRequest, getUser } from '../../api';
|
||||
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword } from '../../api';
|
||||
import NotificationPopup from '../NotificationPopup';
|
||||
import './AppLayout.css';
|
||||
|
||||
@@ -619,10 +619,18 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
const handleChangePwd = async () => {
|
||||
try {
|
||||
await pwdForm.validateFields();
|
||||
message.success('密码修改成功(演示)');
|
||||
setPwdModalOpen(false); pwdForm.resetFields();
|
||||
} catch { }
|
||||
const values = await pwdForm.validateFields();
|
||||
await changePassword(values.oldPwd, values.newPwd);
|
||||
message.success('密码修改成功');
|
||||
setPwdModalOpen(false);
|
||||
pwdForm.resetFields();
|
||||
} catch (error: any) {
|
||||
if (error?.response?.data?.detail) {
|
||||
message.error(error.response.data.detail);
|
||||
} else if (error?.message) {
|
||||
message.error(error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ import text from '../assets/testb.png';
|
||||
|
||||
|
||||
import {
|
||||
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,
|
||||
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,uploadAudio,
|
||||
uploadVideo, getCreditRatios, deleteHistory, calculateCredits
|
||||
} from '../api';
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
DownloadOutlined,
|
||||
ReloadOutlined,
|
||||
AudioOutlined,
|
||||
PauseOutlined,
|
||||
|
||||
} from '@ant-design/icons';
|
||||
|
||||
@@ -227,6 +228,38 @@ const AIChatPage: React.FC = () => {
|
||||
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewVisible && previewType === 'video') {
|
||||
const playVideo = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
if (videoRef.current) {
|
||||
if (videoRef.current.readyState >= 2) {
|
||||
playVideo();
|
||||
} else {
|
||||
videoRef.current.addEventListener('loadedmetadata', playVideo);
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setTimeout(playVideo, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.removeEventListener('loadedmetadata', playVideo);
|
||||
videoRef.current.pause();
|
||||
}
|
||||
};
|
||||
} else {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
}
|
||||
}, [previewVisible, previewType]);
|
||||
|
||||
// 提示词展开状态
|
||||
const [expandedPrompts, setExpandedPrompts] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -278,6 +311,8 @@ const AIChatPage: React.FC = () => {
|
||||
const [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video' | 'audio'>('image');
|
||||
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
|
||||
const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const [playingAudioUrl, setPlayingAudioUrl] = useState<string | null>(null);
|
||||
const [audioProgress, setAudioProgress] = useState(0);
|
||||
|
||||
// 附件详情悬浮窗状态
|
||||
const [attachmentPopupVisible, setAttachmentPopupVisible] = useState<boolean>(false);
|
||||
@@ -831,6 +866,9 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log(mediaReferences);
|
||||
|
||||
// 创建用户消息对象
|
||||
const newMessage: Message = {
|
||||
id: '',
|
||||
@@ -994,6 +1032,35 @@ const AIChatPage: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const getAudioDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const audio = document.createElement('audio');
|
||||
audio.preload = 'metadata';
|
||||
audio.onloadedmetadata = () => {
|
||||
URL.revokeObjectURL(audio.src);
|
||||
resolve(audio.duration);
|
||||
};
|
||||
audio.onerror = () => {
|
||||
URL.revokeObjectURL(audio.src);
|
||||
reject(new Error('无法获取音频时长'));
|
||||
};
|
||||
audio.src = URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAudioPlay = (url: string) => {
|
||||
const audioUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${url}`;
|
||||
if (playingAudioUrl === audioUrl) {
|
||||
const audio = document.getElementById('audio-player') as HTMLAudioElement;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
}
|
||||
setPlayingAudioUrl(null);
|
||||
} else {
|
||||
setPlayingAudioUrl(audioUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
@@ -1096,16 +1163,24 @@ const AIChatPage: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentEngineList = mediaType === 'image' ? enginesele.image : enginesele.video;
|
||||
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
|
||||
const maxImage = currentEngine?.maxImageCount ?? 4;
|
||||
const maxVideo = currentEngine?.maxVideoCount ?? 1;
|
||||
if (isAudio) {
|
||||
const audioExt = file.name.split('.').pop()?.toLowerCase();
|
||||
if (!['wav', 'mp3'].includes(audioExt || '')) {
|
||||
message.error('音频仅支持wav和mp3格式');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaType === 'image' && (isVideo || isAudio)) {
|
||||
message.error('图片模式仅支持上传图片');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAudio && mediaType !== 'video') {
|
||||
message.error('仅视频模式支持上传音频');
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
|
||||
const fileTypeText = isVideo ? '视频' : (isAudio ? '音频' : '图片');
|
||||
if (file.size / 1024 / 1024 > maxMB) {
|
||||
@@ -1140,7 +1215,14 @@ const AIChatPage: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const audioCount = currentMedia.filter((m) => m.type === 'audio').length;
|
||||
if (isAudio && audioCount >= maxAudio) {
|
||||
message.error(`该引擎最多上传${maxAudio}个音频`);
|
||||
return false;
|
||||
}
|
||||
|
||||
let videoDuration = 0;
|
||||
let audioDuration = 0;
|
||||
if (isVideo) {
|
||||
try {
|
||||
videoDuration = await getVideoDuration(file);
|
||||
@@ -1168,10 +1250,31 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (isAudio) {
|
||||
try {
|
||||
audioDuration = await getAudioDuration(file);
|
||||
if (audioDuration < 2) {
|
||||
message.error('音频素材最短不能少于 2 秒');
|
||||
return false;
|
||||
}
|
||||
const existingAudioDuration = currentMedia
|
||||
.filter((m) => m.type === 'audio')
|
||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||
if (existingAudioDuration + audioDuration > 15) {
|
||||
message.error(`所有音频素材总时长不能超过 15 秒,当前 ${(existingAudioDuration + audioDuration).toFixed(1)} 秒`);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
message.error('无法获取音频信息,请检查文件是否损坏');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const uploadFn = isImage ? uploadImage : (isAudio ? uploadImage : uploadVideo);
|
||||
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo);
|
||||
const res = await uploadFn(file);
|
||||
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
|
||||
const newList = [...currentMedia, {
|
||||
@@ -1179,7 +1282,8 @@ const AIChatPage: React.FC = () => {
|
||||
type: mediaType,
|
||||
url: res.url,
|
||||
label: '',
|
||||
...((isVideo || isAudio) && { duration: videoDuration }),
|
||||
...(isVideo && { duration: videoDuration }),
|
||||
...(isAudio && { duration: audioDuration }),
|
||||
}];
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
@@ -1287,10 +1391,11 @@ const AIChatPage: React.FC = () => {
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const getAttachmentMediaType = (ref: any): 'image' | 'video' => {
|
||||
const getAttachmentMediaType = (ref: any): 'image' | 'video' | 'audio' => {
|
||||
const rawType = String(ref?.type || '').toLowerCase();
|
||||
const rawUrl = String(ref?.url || ref?.name || '').toLowerCase();
|
||||
if (rawType.includes('video') || /\.(mp4|mov|avi|webm|m4v)(\?|$)/.test(rawUrl)) return 'video';
|
||||
if (rawType.includes('audio') || /\.(mp3|wav|ogg|aac|m4a)(\?|$)/.test(rawUrl)) return 'audio';
|
||||
return 'image';
|
||||
};
|
||||
|
||||
@@ -1320,7 +1425,8 @@ const AIChatPage: React.FC = () => {
|
||||
if (!ref?.url) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = buildAttachmentDownloadUrl(ref.url);
|
||||
link.download = ref.name || (getAttachmentMediaType(ref) === 'image' ? 'image.png' : 'video.mp4');
|
||||
const refType = getAttachmentMediaType(ref);
|
||||
link.download = ref.name || (refType === 'image' ? 'image.png' : refType === 'video' ? 'video.mp4' : 'audio.mp3');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@@ -1348,6 +1454,10 @@ const AIChatPage: React.FC = () => {
|
||||
? '首帧必传 · 尾帧可选 · 适合首尾画面连贯过渡'
|
||||
: '多素材参考 · 支持图片 / 视频 / 音频,输入 @ 可快速引用素材';
|
||||
|
||||
const maxImage = maxImageCount;
|
||||
const maxVideo = maxVideoCount;
|
||||
const maxAudio = currentEngine?.maxAudioCount ?? 1;
|
||||
|
||||
return (
|
||||
<Layout className="ai-create-page" style={{
|
||||
margin: '-24px -32px -32px',
|
||||
@@ -1357,6 +1467,14 @@ const AIChatPage: React.FC = () => {
|
||||
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* 隐藏的音频播放器 */}
|
||||
<audio
|
||||
id="audio-player"
|
||||
src={playingAudioUrl || ''}
|
||||
autoPlay
|
||||
onEnded={() => setPlayingAudioUrl(null)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{/* 左侧边栏 - 对话列表(已隐藏,保留代码) */}
|
||||
{false && (
|
||||
<Sider
|
||||
@@ -1390,6 +1508,7 @@ const AIChatPage: React.FC = () => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{!collapsed && conversations.length > 0 && (
|
||||
<div style={{ maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
|
||||
{conversations.map((conversation) => (
|
||||
@@ -1527,7 +1646,6 @@ const AIChatPage: React.FC = () => {
|
||||
paddingBottom: 18,
|
||||
// background: 'linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,255,255,0))',
|
||||
background: '#fff',
|
||||
|
||||
// borderRadius: 22,
|
||||
}}
|
||||
>
|
||||
@@ -1961,7 +2079,7 @@ const AIChatPage: React.FC = () => {
|
||||
<div className="ai-reference-tray-scroll" style={{ display: 'flex', gap: 10, overflowX: 'auto', padding: '2px 2px 4px' }}>
|
||||
{attachmentRefs.map((ref: any, idx: number) => {
|
||||
const refType = getAttachmentMediaType(ref);
|
||||
const label = ref.label || (ref.role === 'first_frame' ? '首帧' : ref.role === 'last_frame' ? '尾帧' : `${refType === 'image' ? '图片' : '视频'}${idx + 1}`);
|
||||
const label = ref.label || (ref.role === 'first_frame' ? '首帧' : ref.role === 'last_frame' ? '尾帧' : `${refType === 'image' ? '图片' : refType === 'video' ? '视频' : '音频'}${idx + 1}`);
|
||||
return (
|
||||
<div
|
||||
key={`${ref.url || ref.name || idx}-${idx}`}
|
||||
@@ -1977,10 +2095,15 @@ const AIChatPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
openAttachmentPreview(ref);
|
||||
setAttachmentPopupVisible(false);
|
||||
setAttachmentPopupMessageId(null);
|
||||
onClick={(e) => {
|
||||
if (refType === 'audio') {
|
||||
e.stopPropagation();
|
||||
handleAudioPlay(ref.url);
|
||||
} else {
|
||||
openAttachmentPreview(ref);
|
||||
setAttachmentPopupVisible(false);
|
||||
setAttachmentPopupMessageId(null);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -1999,7 +2122,7 @@ const AIChatPage: React.FC = () => {
|
||||
alt={ref.name || label}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
) : refType === 'video' ? (
|
||||
<>
|
||||
<video
|
||||
src={buildAttachmentAssetUrl(ref.url)}
|
||||
@@ -2013,6 +2136,14 @@ const AIChatPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)' }}>
|
||||
{playingAudioUrl === (ref.url.startsWith('http') ? ref.url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${ref.url}`) ? (
|
||||
<PauseOutlined style={{ fontSize: 24, color: '#fff' }} />
|
||||
) : (
|
||||
<AudioOutlined style={{ fontSize: 24, color: '#fff' }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* <span style={{ position: 'absolute', left: 6, top: 6, padding: '2px 6px', borderRadius: 999, background: 'rgba(255,255,255,0.92)', color: '#8b5cf6', fontSize: 10, fontWeight: 800, boxShadow: '0 4px 10px rgba(31, 41, 55, 0.08)' }}>
|
||||
{label}
|
||||
@@ -2047,7 +2178,7 @@ const AIChatPage: React.FC = () => {
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
{/* <button
|
||||
onClick={(e) => downloadAttachmentRef(ref, e)}
|
||||
title="下载"
|
||||
style={{
|
||||
@@ -2067,7 +2198,7 @@ const AIChatPage: React.FC = () => {
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = '#FFFFFF'; e.currentTarget.style.color = '#667085'; }}
|
||||
>
|
||||
<DownloadOutlined style={{ fontSize: 12 }} />
|
||||
</button>
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2092,8 +2223,12 @@ const AIChatPage: React.FC = () => {
|
||||
backdropFilter: 'blur(24px)',
|
||||
}}
|
||||
>
|
||||
{/* <div>12312</div> */}
|
||||
|
||||
|
||||
{/* 上方输入布局 */}
|
||||
<div style={{ display: 'flex', gap: isFirstLastFrameComposer ? 18 : 18, alignItems: 'flex-end', marginBottom: 14 }}>
|
||||
|
||||
{/* 左侧附件区域 */}
|
||||
<div style={{ width: isFirstLastFrameComposer ? 220 : 70, minWidth: isFirstLastFrameComposer ? 220 : 70, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-start', paddingTop: 2, gap: 8 }}>
|
||||
|
||||
@@ -2223,7 +2358,9 @@ const AIChatPage: React.FC = () => {
|
||||
>
|
||||
<Tooltip title={mediaType === 'image'
|
||||
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},
|
||||
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `,
|
||||
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
|
||||
}>
|
||||
<div
|
||||
style={{
|
||||
@@ -2319,15 +2456,17 @@ const AIChatPage: React.FC = () => {
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => {
|
||||
setAttachmentPreviewUrl(media.url);
|
||||
setAttachmentPreviewType('audio');
|
||||
setAttachmentPreviewName(media.name);
|
||||
setAttachmentPreviewVisible(true);
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAudioPlay(media.url);
|
||||
}}
|
||||
style={{ width: 52, height: 60, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '1px solid rgba(255,255,255,0.98)', boxShadow: '0 4px 12px rgba(31,41,55,0.15)', background: 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
|
||||
{playingAudioUrl === (media.url.startsWith('http') ? media.url : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`) ? (
|
||||
<PauseOutlined style={{ fontSize: 20, color: '#fff' }} />
|
||||
) : (
|
||||
<AudioOutlined style={{ fontSize: 20, color: '#fff' }} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 右上角删除按钮 */}
|
||||
@@ -2378,7 +2517,9 @@ const AIChatPage: React.FC = () => {
|
||||
>
|
||||
<Tooltip title={mediaType === 'image'
|
||||
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
|
||||
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},
|
||||
视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}${maxAudio > 0 ? `,
|
||||
音频${currentMedia.filter(m => m.type === 'audio').length}/${maxAudio}` : ''}`
|
||||
}>
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -771,7 +771,8 @@ const HomePage: React.FC = () => {
|
||||
|
||||
|
||||
{/* ========== 素材案例区域 ========== */}
|
||||
<div className="animate-fadeInUp" style={{
|
||||
{caseAssets.length > 0 && (
|
||||
<div className="animate-fadeInUp" style={{
|
||||
padding: '24px 28px',
|
||||
borderRadius: 16,
|
||||
background: '#fff',
|
||||
@@ -901,6 +902,7 @@ const HomePage: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 预览弹窗 ========== */}
|
||||
<Modal
|
||||
|
||||
@@ -62,6 +62,71 @@ function InitialInfo() {
|
||||
// 当前展开的步骤
|
||||
const [activeKey, setActiveKey] = useState<string[]>([]);
|
||||
|
||||
// 预览相关状态
|
||||
const [previewVisible, setPreviewVisible] = useState<boolean>(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
|
||||
const videoRef = React.useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewVisible && previewType === 'video') {
|
||||
const playVideo = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
if (videoRef.current) {
|
||||
if (videoRef.current.readyState >= 2) {
|
||||
playVideo();
|
||||
} else {
|
||||
videoRef.current.addEventListener('loadedmetadata', playVideo);
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setTimeout(playVideo, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.removeEventListener('loadedmetadata', playVideo);
|
||||
videoRef.current.pause();
|
||||
}
|
||||
};
|
||||
} else {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
}
|
||||
}, [previewVisible, previewType]);
|
||||
|
||||
const openPreview = (url: string, type: 'image' | 'video') => {
|
||||
console.log(url, type);
|
||||
setPreviewUrl(url);
|
||||
setPreviewType(type);
|
||||
setPreviewVisible(true);
|
||||
};
|
||||
|
||||
const handleClosePreview = () => {
|
||||
setPreviewVisible(false);
|
||||
setPreviewUrl('');
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!previewUrl) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}&download=1`;
|
||||
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
//
|
||||
const baseSteps = [
|
||||
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
|
||||
@@ -347,9 +412,12 @@ function InitialInfo() {
|
||||
image_size: "2K"
|
||||
}
|
||||
gettwo(taskDetail.id, stepId.toString(), params).then((res: any) => {
|
||||
message.info('正在生成图片,请稍候...');
|
||||
// 重新获取任务详情以更新数据
|
||||
refreshTaskDetail();
|
||||
}).catch((error: any) => {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
message.error(errorMsg);
|
||||
});
|
||||
}
|
||||
const newcreateimage = () => {
|
||||
@@ -362,9 +430,13 @@ function InitialInfo() {
|
||||
}
|
||||
|
||||
gettwo(taskDetail.id, steps[1].id.toString(), params).then((res: any) => {
|
||||
message.info('正在生成图片,请稍候...');
|
||||
|
||||
refreshTaskDetail();
|
||||
|
||||
}).catch((error: any) => {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
message.error(errorMsg);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -385,7 +457,10 @@ function InitialInfo() {
|
||||
getthree(taskDetail.id, stepId.toString(), videoParams).then((res: any) => {
|
||||
// 重新获取任务详情以更新数据
|
||||
refreshTaskDetail();
|
||||
message.info('正在生成视频提示词,请稍候...');
|
||||
}).catch((error: any) => {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
message.error(errorMsg);
|
||||
});
|
||||
// 这里可以添加下一步的逻辑,比如调用接口等
|
||||
};
|
||||
@@ -398,8 +473,11 @@ function InitialInfo() {
|
||||
|
||||
getfour(taskDetail.id, stepId.toString(), params).then((res: any) => {
|
||||
// 重新获取任务详情以更新数据
|
||||
message.info('正在生成视频,请稍候...');
|
||||
refreshTaskDetail();
|
||||
}).catch((error: any) => {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
message.error(errorMsg);
|
||||
});
|
||||
}
|
||||
const agincreatevideo = () => {
|
||||
@@ -410,6 +488,8 @@ function InitialInfo() {
|
||||
|
||||
getfour(taskDetail.id, steps[3].id.toString(), params).then((res: any) => {
|
||||
// 重新获取任务详情以更新数据
|
||||
message.info('正在生成视频,请稍候...');
|
||||
|
||||
refreshTaskDetail();
|
||||
}).catch((error: any) => {
|
||||
});
|
||||
@@ -503,10 +583,10 @@ function InitialInfo() {
|
||||
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||
视频
|
||||
</span>
|
||||
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
|
||||
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialVideoUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialVideoUrl && openPreview(taskDetail.material.materialVideoUrl, 'video')}>
|
||||
{taskDetail?.material?.materialVideoUrl ? (
|
||||
<video
|
||||
controls
|
||||
|
||||
src={taskDetail.material.materialVideoUrl}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
@@ -520,7 +600,7 @@ function InitialInfo() {
|
||||
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||
产品图片
|
||||
</span>
|
||||
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
|
||||
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialImageUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialImageUrl && openPreview(taskDetail.material.materialImageUrl, 'image')}>
|
||||
{taskDetail?.material?.materialImageUrl ? (
|
||||
<img src={taskDetail.material.materialImageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
@@ -537,7 +617,7 @@ function InitialInfo() {
|
||||
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||
生成图片
|
||||
</span>
|
||||
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
|
||||
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer' }} onClick={() => openPreview(taskDetail.finalImageUrl, 'image')}>
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalImageUrl}`}
|
||||
alt=""
|
||||
@@ -554,9 +634,9 @@ function InitialInfo() {
|
||||
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||
生成视频
|
||||
</span>
|
||||
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
|
||||
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer' }} onClick={() => openPreview(taskDetail.finalVideoUrl, 'video')}>
|
||||
<video
|
||||
controls
|
||||
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
@@ -700,7 +780,7 @@ function InitialInfo() {
|
||||
修改提示词
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => { message.info('正在生成图片,请稍候...'); createimage(step.id); }}
|
||||
onClick={() => { createimage(step.id); }}
|
||||
type="primary"
|
||||
style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
|
||||
disabled={step.status !== 'completed'}
|
||||
@@ -1106,7 +1186,7 @@ function InitialInfo() {
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ width: '100%', borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
|
||||
onClick={() => { message.info('正在生成视频提示词,请稍候...'); handleNextStep(step.id); }}
|
||||
onClick={() => { handleNextStep(step.id); }}
|
||||
disabled={step.status !== 'completed'}
|
||||
>
|
||||
下一步:生成视频提示词
|
||||
@@ -1132,7 +1212,7 @@ function InitialInfo() {
|
||||
查看/修改视频提词
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => { message.info('正在生成视频,请稍候...'); createvideo(step.id, step.engineId); }}
|
||||
onClick={() => { createvideo(step.id, step.engineId); }}
|
||||
type="primary"
|
||||
style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
|
||||
disabled={step.status !== 'completed'}
|
||||
@@ -1433,6 +1513,62 @@ function InitialInfo() {
|
||||
scroll={{ y: 350 }}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 图片/视频预览弹窗 */}
|
||||
<Modal
|
||||
open={previewVisible}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #8b5cf6 0%, #ddd6fe 100%)', borderRadius: 2 }} />
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: '#8b5cf6', letterSpacing: 0.4 }}>
|
||||
预览
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
onCancel={handleClosePreview}
|
||||
width={800}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, padding: '16px 24px', background: 'rgba(255,255,255,0.6)', borderTop: '1px solid rgba(139, 92, 246, 0.08)' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownload}
|
||||
style={{ borderRadius: 10, background: 'linear-gradient(135deg, #8b5cf6 0%, #ddd6fe 100%)', border: 'none', boxShadow: '0 8px 18px rgba(47, 52, 64, 0.15)' }}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
centered
|
||||
style={{ borderRadius: 16 }}
|
||||
styles={{
|
||||
body: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '400px',
|
||||
},
|
||||
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(139, 92, 246, 0.08)', padding: '16px 24px' },
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
|
||||
{previewType === 'image' ? (
|
||||
<img
|
||||
src={previewUrl.startsWith('http') ? previewUrl : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
|
||||
alt="预览"
|
||||
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={previewUrl.startsWith('http') ? previewUrl : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ interface MaterialData {
|
||||
const MaterialListPage: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const videoRef = React.createRef<HTMLVideoElement>();
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
@@ -93,6 +94,18 @@ const MaterialListPage: React.FC = () => {
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
|
||||
const handleClosePreview = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.currentTime = 0;
|
||||
}
|
||||
const videoElements = document.querySelectorAll('video');
|
||||
videoElements.forEach(video => {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
});
|
||||
setPreviewModalOpen(false);
|
||||
};
|
||||
const [pushPreTestTemplate, setPushPreTestTemplate] = useState<string>('');
|
||||
const [pushTemplates, setPushTemplates] = useState<any[]>([]);
|
||||
const [pushTemplatesLoading, setPushTemplatesLoading] = useState(false);
|
||||
@@ -528,7 +541,7 @@ const MaterialListPage: React.FC = () => {
|
||||
<Modal
|
||||
title="预览"
|
||||
open={previewModalOpen}
|
||||
onCancel={() => setPreviewModalOpen(false)}
|
||||
onCancel={handleClosePreview}
|
||||
footer={null}
|
||||
width={600}
|
||||
style={{ borderRadius: 16 }}
|
||||
@@ -536,6 +549,7 @@ const MaterialListPage: React.FC = () => {
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||
{previewType === 'video' ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={previewUrl}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: 400, borderRadius: 8 }}
|
||||
|
||||
@@ -62,6 +62,44 @@ function InitialInfo() {
|
||||
// 当前展开的步骤
|
||||
const [activeKey, setActiveKey] = useState<string[]>([]);
|
||||
|
||||
// 预览相关状态
|
||||
const [previewVisible, setPreviewVisible] = useState<boolean>(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
|
||||
const videoRef = React.useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewVisible && previewType === 'video') {
|
||||
const playVideo = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
if (videoRef.current) {
|
||||
if (videoRef.current.readyState >= 2) {
|
||||
playVideo();
|
||||
} else {
|
||||
videoRef.current.addEventListener('loadedmetadata', playVideo);
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setTimeout(playVideo, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.removeEventListener('loadedmetadata', playVideo);
|
||||
videoRef.current.pause();
|
||||
}
|
||||
};
|
||||
} else {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
}
|
||||
}, [previewVisible, previewType]);
|
||||
|
||||
//
|
||||
const baseSteps = [
|
||||
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
|
||||
@@ -335,12 +373,42 @@ function InitialInfo() {
|
||||
}).catch((error: any) => {
|
||||
});
|
||||
};
|
||||
|
||||
const openPreview = (url: string, type: 'image' | 'video') => {
|
||||
setPreviewUrl(url);
|
||||
setPreviewType(type);
|
||||
setPreviewVisible(true);
|
||||
};
|
||||
|
||||
const handleClosePreview = () => {
|
||||
setPreviewVisible(false);
|
||||
setPreviewUrl('');
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!previewUrl) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}&download=1`;
|
||||
link.download = previewType === 'image' ? 'image.png' : 'video.mp4';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const createone = (stepId: number) => {
|
||||
|
||||
removeone(taskDetail.id, stepId.toString()).then((res: any) => {
|
||||
// 重新获取任务详情以更新数据
|
||||
message.info('正在生成图片提示词,请稍候...');
|
||||
refreshTaskDetail();
|
||||
}).catch((error: any) => {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
message.error(errorMsg);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -353,8 +421,11 @@ function InitialInfo() {
|
||||
}
|
||||
removetwo(taskDetail.id, stepId.toString(), params).then((res: any) => {
|
||||
// 重新获取任务详情以更新数据
|
||||
message.info('正在生成图片,请稍候...');
|
||||
refreshTaskDetail();
|
||||
}).catch((error: any) => {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
message.error(errorMsg);
|
||||
});
|
||||
}
|
||||
const newcreateimage = () => {
|
||||
@@ -367,9 +438,13 @@ function InitialInfo() {
|
||||
}
|
||||
|
||||
removetwo(taskDetail.id, steps[1].id.toString(), params).then((res: any) => {
|
||||
message.info('正在生成图片,请稍候...');
|
||||
|
||||
refreshTaskDetail();
|
||||
|
||||
}).catch((error: any) => {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
message.error(errorMsg);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -389,8 +464,11 @@ function InitialInfo() {
|
||||
// console.log('引擎 ID:', engineId);
|
||||
removethree(taskDetail.id, stepId.toString(), videoParams).then((res: any) => {
|
||||
// 重新获取任务详情以更新数据
|
||||
message.info('正在生成视频提示词,请稍候...');
|
||||
refreshTaskDetail();
|
||||
}).catch((error: any) => {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
message.error(errorMsg);
|
||||
});
|
||||
// 这里可以添加下一步的逻辑,比如调用接口等
|
||||
};
|
||||
@@ -403,8 +481,12 @@ function InitialInfo() {
|
||||
|
||||
removefour(taskDetail.id, stepId.toString(), params).then((res: any) => {
|
||||
// 重新获取任务详情以更新数据
|
||||
message.info('正在生成视频,请稍候...');
|
||||
refreshTaskDetail();
|
||||
}).catch((error: any) => {
|
||||
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||
message.error(errorMsg);
|
||||
|
||||
});
|
||||
}
|
||||
const agincreatevideo = () => {
|
||||
@@ -504,10 +586,10 @@ function InitialInfo() {
|
||||
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||
视频
|
||||
</span>
|
||||
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
|
||||
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialVideoUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialVideoUrl && openPreview(taskDetail.material.materialVideoUrl, 'video')}>
|
||||
{taskDetail?.material?.materialVideoUrl ? (
|
||||
<video
|
||||
controls
|
||||
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.material.materialVideoUrl}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
@@ -521,7 +603,7 @@ function InitialInfo() {
|
||||
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||
产品图片
|
||||
</span>
|
||||
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
|
||||
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: taskDetail?.material?.materialImageUrl ? 'pointer' : 'default' }} onClick={() => taskDetail?.material?.materialImageUrl && openPreview(taskDetail.material.materialImageUrl, 'image')}>
|
||||
{taskDetail?.material?.materialImageUrl ? (
|
||||
<img src={taskDetail.material.materialImageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||
) : (
|
||||
@@ -537,7 +619,7 @@ function InitialInfo() {
|
||||
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||
生成图片
|
||||
</span>
|
||||
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
|
||||
<div className="medio_box" style={{ aspectRatio: '1/1', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer' }} onClick={() => openPreview(taskDetail.finalImageUrl, 'image')}>
|
||||
<img
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalImageUrl}`}
|
||||
alt=""
|
||||
@@ -554,9 +636,9 @@ function InitialInfo() {
|
||||
<span style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||
生成视频
|
||||
</span>
|
||||
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)' }}>
|
||||
<div className="medio_box" style={{ aspectRatio: '16/9', background: 'linear-gradient(135deg, rgba(248,250,252,0.8) 0%, rgba(238,242,255,0.6) 100%)', borderRadius: 14, overflow: 'hidden', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.06)', border: '1px solid rgba(99, 102, 241, 0.08)', cursor: 'pointer' }} onClick={() => openPreview(taskDetail.finalVideoUrl, 'video')}>
|
||||
<video
|
||||
controls
|
||||
|
||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
@@ -675,7 +757,7 @@ function InitialInfo() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => { message.info('正在生成图片提示词,请稍候...'); createone(step.id); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
|
||||
<Button onClick={() => { createone(step.id); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
|
||||
下一步:生成图片提示词
|
||||
</Button>
|
||||
</div>
|
||||
@@ -700,7 +782,7 @@ function InitialInfo() {
|
||||
>
|
||||
修改提示词
|
||||
</Button>
|
||||
<Button onClick={() => { message.info('正在生成图片,请稍候...'); createimage(step.id); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
|
||||
<Button onClick={() => { createimage(step.id); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
|
||||
下一步:生成图片
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -1102,7 +1184,7 @@ function InitialInfo() {
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ width: '100%', borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
|
||||
onClick={() => { message.info('正在生成视频提示词,请稍候...'); handleNextStep(step.id); }}
|
||||
onClick={() => { handleNextStep(step.id); }}
|
||||
disabled={step.status !== 'completed'}
|
||||
>
|
||||
下一步:生成视频提示词
|
||||
@@ -1127,7 +1209,7 @@ function InitialInfo() {
|
||||
>
|
||||
查看/修改视频提词
|
||||
</Button>
|
||||
<Button onClick={() => { message.info('正在生成视频,请稍候...'); createvideo(step.id, step.engineId); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
|
||||
<Button onClick={() => { createvideo(step.id, step.engineId); }} type="primary" style={{ flex: 1, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }} disabled={step.status !== 'completed'}>
|
||||
下一步:生成视频
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -1387,6 +1469,62 @@ function InitialInfo() {
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 图片/视频预览弹窗 */}
|
||||
<Modal
|
||||
open={previewVisible}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 4, height: 20, background: 'linear-gradient(180deg, #8b5cf6 0%, #ddd6fe 100%)', borderRadius: 2 }} />
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: '#8b5cf6', letterSpacing: 0.4 }}>
|
||||
预览
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
onCancel={handleClosePreview}
|
||||
width={800}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, padding: '16px 24px', background: 'rgba(255,255,255,0.6)', borderTop: '1px solid rgba(139, 92, 246, 0.08)' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownload}
|
||||
style={{ borderRadius: 10, background: 'linear-gradient(135deg, #8b5cf6 0%, #ddd6fe 100%)', border: 'none', boxShadow: '0 8px 18px rgba(47, 52, 64, 0.15)' }}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
centered
|
||||
style={{ borderRadius: 16 }}
|
||||
styles={{
|
||||
body: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '400px',
|
||||
},
|
||||
header: { background: 'rgba(255,255,255,0.6)', backdropFilter: 'blur(10px)', borderBottom: '1px solid rgba(139, 92, 246, 0.08)', padding: '16px 24px' },
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%' }}>
|
||||
{previewType === 'image' ? (
|
||||
<img
|
||||
src={previewUrl.startsWith('http') ? previewUrl : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
|
||||
alt="预览"
|
||||
style={{ width: '100%', maxHeight: '400px', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={previewUrl.startsWith('http') ? previewUrl : `${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${previewUrl}`}
|
||||
controls
|
||||
style={{ maxWidth: '100%', maxHeight: '400px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user