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>
|
||||||
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail
|
|||||||
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
|
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
|
||||||
import AdminContactRequests from './pages/AdminContactRequests';
|
import AdminContactRequests from './pages/AdminContactRequests';
|
||||||
import AdminHomeMaterials from './pages/AdminHomeMaterials';
|
import AdminHomeMaterials from './pages/AdminHomeMaterials';
|
||||||
|
import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
|
||||||
|
import AdminOAuthList from './pages/AdminOAuthList';
|
||||||
|
import AdminMaterialList from './pages/AdminMaterialList';
|
||||||
|
|
||||||
import { useAdminStore } from './store';
|
import { useAdminStore } from './store';
|
||||||
|
|
||||||
@@ -109,6 +112,9 @@ const App = () => {
|
|||||||
<Route path="platform" element={<AdminPlatform />} />
|
<Route path="platform" element={<AdminPlatform />} />
|
||||||
<Route path="contact-requests" element={<AdminContactRequests />} />
|
<Route path="contact-requests" element={<AdminContactRequests />} />
|
||||||
<Route path="home-materials" element={<AdminHomeMaterials />} />
|
<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>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -500,7 +500,7 @@ export async function createOauthApp(data: {
|
|||||||
app_id: string;
|
app_id: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
open_type: number;
|
open_type: number;
|
||||||
count?: number;
|
max_count?: number;
|
||||||
auth_url?: string;
|
auth_url?: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
}): Promise<any> {
|
}): Promise<any> {
|
||||||
@@ -945,3 +945,55 @@ export async function regenerateHomeMaterialWatermark(id: string, payload: HomeM
|
|||||||
export async function deleteHomeMaterialAsset(id: string): Promise<void> {
|
export async function deleteHomeMaterialAsset(id: string): Promise<void> {
|
||||||
await api.delete(`/admin/home-material/assets/${id}`);
|
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 {
|
interface OAuthApp {
|
||||||
id: string;
|
id: string;
|
||||||
app_id: string;
|
appId: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
status: number;
|
status: number;
|
||||||
max_count: number;
|
maxCount: number;
|
||||||
open_type: number;
|
openType: number;
|
||||||
auth_url?: string;
|
authUrl?: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
create_by: string;
|
createBy: string;
|
||||||
created_at: string;
|
createdAt: string;
|
||||||
updated_at: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdminOauthAppList: React.FC = () => {
|
const AdminOauthAppList: React.FC = () => {
|
||||||
@@ -54,7 +54,7 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
app_id: values.app_id,
|
app_id: values.app_id,
|
||||||
secret: values.secret,
|
secret: values.secret,
|
||||||
open_type: values.open_type,
|
open_type: values.open_type,
|
||||||
count: values.count,
|
max_count: values.max_count,
|
||||||
auth_url: values.auth_url,
|
auth_url: values.auth_url,
|
||||||
company: values.company,
|
company: values.company,
|
||||||
});
|
});
|
||||||
@@ -82,11 +82,11 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
const app = await getOauthApp(id);
|
const app = await getOauthApp(id);
|
||||||
setCurrentApp(app);
|
setCurrentApp(app);
|
||||||
updateForm.setFieldsValue({
|
updateForm.setFieldsValue({
|
||||||
app_id: app.app_id,
|
appId: app.appId,
|
||||||
secret: app.secret,
|
secret: app.secret,
|
||||||
open_type: app.open_type,
|
openType: app.openType,
|
||||||
count: app.max_count,
|
maxCount: app.maxCount,
|
||||||
auth_url: app.auth_url,
|
authUrl: app.authUrl,
|
||||||
company: app.company,
|
company: app.company,
|
||||||
});
|
});
|
||||||
setUpdateModalVisible(true);
|
setUpdateModalVisible(true);
|
||||||
@@ -298,7 +298,7 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="count"
|
name="max_count"
|
||||||
label="最大授权用户数"
|
label="最大授权用户数"
|
||||||
initialValue={100}
|
initialValue={100}
|
||||||
>
|
>
|
||||||
@@ -334,22 +334,22 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
{currentApp && (
|
{currentApp && (
|
||||||
<div style={{ lineHeight: '2' }}>
|
<div style={{ lineHeight: '2' }}>
|
||||||
<p><strong>ID:</strong> {currentApp.id}</p>
|
<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> {currentApp.secret}</p>
|
||||||
<p><strong>开户方式:</strong> {(() => {
|
<p><strong>开户方式:</strong> {(() => {
|
||||||
const typeMap: Record<number, string> = {
|
const typeMap: Record<number, string> = {
|
||||||
1: '千川', 2: '广告', 3: '本地推', 4: '星图', 5: '快手代理商',
|
1: '千川', 2: '广告', 3: '本地推', 4: '星图', 5: '快手代理商',
|
||||||
6: '巨量星图', 7: '巨量服务单', 8: '腾讯服务单', 9: '腾讯营销K2', 10: '腾讯营销K3'
|
6: '巨量星图', 7: '巨量服务单', 8: '腾讯服务单', 9: '腾讯营销K2', 10: '腾讯营销K3'
|
||||||
};
|
};
|
||||||
return typeMap[currentApp.open_type] || currentApp.open_type;
|
return typeMap[currentApp.openType] || currentApp.openType;
|
||||||
})()}</p>
|
})()}</p>
|
||||||
<p><strong>归属公司:</strong> {currentApp.company || '-'}</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>状态:</strong> {currentApp.status === 1 ? '正常' : '禁用'}</p>
|
||||||
<p><strong>授权URL:</strong> {currentApp.auth_url || '-'}</p>
|
<p><strong>授权URL:</strong> {currentApp.authUrl || '-'}</p>
|
||||||
<p><strong>创建人:</strong> {currentApp.create_by}</p>
|
<p><strong>创建人:</strong> {currentApp.createBy}</p>
|
||||||
<p><strong>创建时间:</strong> {formatDate(currentApp.created_at)}</p>
|
<p><strong>创建时间:</strong> {formatDate(currentApp.createdAt)}</p>
|
||||||
<p><strong>更新时间:</strong> {formatDate(currentApp.updated_at)}</p>
|
<p><strong>更新时间:</strong> {formatDate(currentApp.updatedAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -369,7 +369,7 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Form form={updateForm} layout="vertical">
|
<Form form={updateForm} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="app_id"
|
name="appId"
|
||||||
label="应用ID"
|
label="应用ID"
|
||||||
rules={[{ required: true, message: '请输入应用ID' }, { max: 64, message: '应用ID不能超过64个字符' }]}
|
rules={[{ required: true, message: '请输入应用ID' }, { max: 64, message: '应用ID不能超过64个字符' }]}
|
||||||
>
|
>
|
||||||
@@ -383,7 +383,7 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
<Input placeholder="请输入应用密钥" />
|
<Input placeholder="请输入应用密钥" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="open_type"
|
name="openType"
|
||||||
label="开户方式"
|
label="开户方式"
|
||||||
rules={[{ required: true, message: '请选择开户方式' }]}
|
rules={[{ required: true, message: '请选择开户方式' }]}
|
||||||
>
|
>
|
||||||
@@ -401,13 +401,13 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="count"
|
name="maxCount"
|
||||||
label="最大授权用户数"
|
label="最大授权用户数"
|
||||||
>
|
>
|
||||||
<InputNumber min={1} placeholder="应用最大可以授权多少个用户" />
|
<InputNumber min={1} placeholder="应用最大可以授权多少个用户" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="auth_url"
|
name="authUrl"
|
||||||
label="应用授权链接"
|
label="应用授权链接"
|
||||||
>
|
>
|
||||||
<Input placeholder="请输入应用授权链接" />
|
<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.team import router as team_router
|
||||||
from app.api.v1.home_materials import router as home_materials_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.admin import router as admin_module_router
|
||||||
|
from app.api.v1.material_admin import router as material_admin_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(auth_router)
|
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(team_router)
|
||||||
api_router.include_router(home_materials_router)
|
api_router.include_router(home_materials_router)
|
||||||
api_router.include_router(admin_module_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,
|
"method": request.method,
|
||||||
"path": request.url.path,
|
"path": request.url.path,
|
||||||
"query_params": dict(request.query_params),
|
"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,
|
"status": response.status_code,
|
||||||
"duration_ms": duration_ms,
|
"duration_ms": duration_ms,
|
||||||
"ip": request.client.host if request.client else "-",
|
"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}"
|
return f"{service_type}生成失败: {code}"
|
||||||
except (json.JSONDecodeError, TypeError, ValueError):
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
pass
|
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)
|
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
||||||
request_str = json.dumps(request_data, ensure_ascii=False)
|
request_str = json.dumps(request_data, ensure_ascii=False)
|
||||||
request_encrypted = encrypt_data(request_data)
|
request_encrypted = encrypt_data(request_data, True)
|
||||||
entry = {
|
entry = {
|
||||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"type": "image_gen_request",
|
"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)
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
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 = {
|
entry = {
|
||||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"type": "image_gen_response",
|
"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)
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
||||||
request_encrypted = encrypt_data(_sanitize_for_log(request_data))
|
request_encrypted = encrypt_data(_sanitize_for_log(request_data), True)
|
||||||
response_encrypted = encrypt_data(_sanitize_for_log(response_data)) if response_data else ""
|
response_encrypted = encrypt_data(_sanitize_for_log(response_data), True) if response_data else ""
|
||||||
entry = {
|
entry = {
|
||||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"model_name": config.name,
|
"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)
|
data_str = json.dumps(data, ensure_ascii=False, sort_keys=True)
|
||||||
|
if is_encrypt:
|
||||||
|
return data_str
|
||||||
|
|
||||||
data_bytes = data_str.encode("utf-8")
|
data_bytes = data_str.encode("utf-8")
|
||||||
|
|
||||||
padder = padding.PKCS7(128).padder()
|
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
|
import random
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 import UserOAuth
|
||||||
from app.models.user_oauth_app import UserOAuthApp
|
from app.models.user_oauth_app import UserOAuthApp
|
||||||
from app.utils.id_gen import generate_id
|
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:
|
async def get_available_app(open_type: int, db: AsyncSession) -> dict:
|
||||||
# 从 user_oauth_app 表查询可用应用
|
if not isinstance(open_type, int):
|
||||||
# 过滤条件:open_type 匹配、status=1(正常)、deleted_at is None
|
raise ValueError("open_type必须为整数类型")
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(UserOAuthApp).where(
|
select(UserOAuthApp).where(
|
||||||
UserOAuthApp.open_type == open_type,
|
UserOAuthApp.open_type == open_type,
|
||||||
@@ -30,8 +32,6 @@ async def get_available_app(open_type: int, db: AsyncSession) -> dict:
|
|||||||
available_apps = []
|
available_apps = []
|
||||||
for app in apps:
|
for app in apps:
|
||||||
app_id = app.app_id
|
app_id = app.app_id
|
||||||
|
|
||||||
# 查询该应用当前授权数量
|
|
||||||
count_result = await db.execute(
|
count_result = await db.execute(
|
||||||
select(func.count(UserOAuth.id)).where(
|
select(func.count(UserOAuth.id)).where(
|
||||||
UserOAuth.appid == app_id,
|
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
|
count = count_result.scalar() or 0
|
||||||
|
|
||||||
# 检查是否达到最大授权数
|
|
||||||
max_users = app.max_count
|
max_users = app.max_count
|
||||||
if count < max_users:
|
if count < max_users:
|
||||||
available_apps.append({
|
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:
|
async def build_oauth_url(open_type: int, user_id: str, db: AsyncSession) -> str:
|
||||||
if open_type == 1:
|
if open_type == 1:
|
||||||
#千川
|
|
||||||
return await _build_jl_oauth_url(open_type, user_id, db)
|
return await _build_jl_oauth_url(open_type, user_id, db)
|
||||||
elif open_type in [2, 3]:
|
elif open_type in [2, 3]:
|
||||||
#广告,本地推
|
|
||||||
return await _build_jl_oauth_url(2, user_id, db)
|
return await _build_jl_oauth_url(2, user_id, db)
|
||||||
elif open_type == 5:
|
elif open_type == 5:
|
||||||
#快手代理商
|
|
||||||
return "无配置"
|
return "无配置"
|
||||||
elif open_type == 9:
|
elif open_type == 9:
|
||||||
#腾讯营销K2
|
|
||||||
return "无配置"
|
return "无配置"
|
||||||
elif open_type == 10:
|
elif open_type == 10:
|
||||||
#腾讯营销K3
|
|
||||||
return "无配置"
|
return "无配置"
|
||||||
else:
|
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:
|
async def _build_jl_oauth_url(open_type: int, user_id: str, db: AsyncSession) -> str:
|
||||||
app = await get_available_app(open_type, db)
|
app = await get_available_app(open_type, db)
|
||||||
app_id = app.get("app_id")
|
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:
|
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(
|
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()
|
app = result.scalar_one_or_none()
|
||||||
if not app:
|
if not app:
|
||||||
raise ValueError("应用配置不存在")
|
raise ValueError("应用配置不存在或已禁用")
|
||||||
|
|
||||||
open_type = app.open_type
|
open_type = app.open_type
|
||||||
secret = app.secret
|
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)
|
return await get_juliang_token(app_id, secret, code, open_type, user_id, db)
|
||||||
elif open_type == 5:
|
elif open_type == 5:
|
||||||
#快手代理商
|
|
||||||
return await get_kuaishou_token(app_id, secret, code, open_type, user_id, db)
|
return await get_kuaishou_token(app_id, secret, code, open_type, user_id, db)
|
||||||
elif open_type == 9 or open_type == 10:
|
elif open_type in (9, 10):
|
||||||
#腾讯营销K2或腾讯营销K3
|
|
||||||
return await get_tencent_token(app_id, secret, code, open_type, user_id, db)
|
return await get_tencent_token(app_id, secret, code, open_type, user_id, db)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"不支持的应用类型: {open_type}")
|
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:
|
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:
|
resp_data = data.get("data", {})
|
||||||
#1.请求token
|
access_token = resp_data.get("access_token", "")
|
||||||
url = "https://api.oceanengine.com/open_api/oauth2/access_token/"
|
refresh_token = resp_data.get("refresh_token", "")
|
||||||
response = await client.post(
|
expires_sec = resp_data.get("expires_in", 7200)
|
||||||
url,
|
refresh_expires_sec = resp_data.get("refresh_token_expires_in", 30 * 24 * 3600)
|
||||||
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()
|
|
||||||
|
|
||||||
|
# 【修复:统一使用北京时间计算过期时间】
|
||||||
|
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:
|
# 2. 获取授权广告账户列表
|
||||||
await _update_redis_token(oauth_id, access_token, expires_in)
|
resp = await client.get(
|
||||||
else:
|
"https://api.oceanengine.com/open_api/oauth2/advertiser/get/",
|
||||||
# 查询现有授权记录(未删除的)
|
headers={"Access-Token": access_token},
|
||||||
existing_accounts = await db.execute(
|
)
|
||||||
select(UserOAuth).where(
|
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_username == account_username,
|
||||||
UserOAuth.account_userid == account_userid,
|
UserOAuth.account_userid == account_userid,
|
||||||
UserOAuth.appid == app_id,
|
UserOAuth.appid == app_id,
|
||||||
UserOAuth.user_id == user_id,
|
UserOAuth.user_id == user_id,
|
||||||
UserOAuth.deleted_at.is_(None),
|
UserOAuth.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
exist_res = await db.execute(exist_query)
|
||||||
existing_accounts = {acc.account_id: acc for acc in existing_accounts.scalars().all()}
|
exist_map = {item.account_id: item for item in exist_res.scalars().all()}
|
||||||
|
|
||||||
# 新的账户列表
|
new_account_ids = {str(acc.get("account_id")) for acc in account_list}
|
||||||
new_account_ids = {str(account.get("account_id")) for account in account_list}
|
old_account_ids = set(exist_map.keys())
|
||||||
old_account_ids = set(existing_accounts.keys())
|
update_redis_ids = []
|
||||||
|
|
||||||
# 需要更新Redis的oauth_id列表
|
# 下线的账户:软删除 + 清理Redis脏缓存
|
||||||
update_redis_ids = []
|
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:
|
for account in account_list:
|
||||||
existing_accounts[account_id].deleted_at = datetime.now(timezone.utc)
|
aid = str(account.get("account_id", ""))
|
||||||
|
if aid in exist_map:
|
||||||
# 2. 更新或新增账户
|
item = exist_map[aid]
|
||||||
for account in account_list:
|
item.account_name = account.get("account_name", "")
|
||||||
account_id = str(account.get("account_id", ""))
|
item.account_role = account.get("account_role", "")
|
||||||
if account_id in existing_accounts:
|
item.access_token = access_token
|
||||||
# 更新现有记录
|
item.access_token_expired = access_expired
|
||||||
existing_oauth = existing_accounts[account_id]
|
item.refresh_token = refresh_token
|
||||||
existing_oauth.account_name = account.get("account_name", "")
|
item.refresh_token_expired = refresh_expired
|
||||||
existing_oauth.account_role = account.get("account_role", "")
|
item.material_auth_status = material_auth_status
|
||||||
existing_oauth.access_token = access_token
|
update_redis_ids.append(item.id)
|
||||||
existing_oauth.access_token_expired = expires_in
|
else:
|
||||||
existing_oauth.refresh_token = refresh_token
|
oauth_id = generate_id()
|
||||||
existing_oauth.refresh_token_expired = refresh_token_expires_in
|
update_redis_ids.append(oauth_id)
|
||||||
existing_oauth.material_auth_status = material_auth_status
|
db.add(UserOAuth(
|
||||||
update_redis_ids.append(existing_oauth.id)
|
|
||||||
else:
|
|
||||||
# 新增记录
|
|
||||||
oauth_id = generate_id()
|
|
||||||
update_redis_ids.append(oauth_id)
|
|
||||||
db.add(UserOAuth(
|
|
||||||
id=oauth_id,
|
id=oauth_id,
|
||||||
account_id=str(account_id),
|
account_id=aid,
|
||||||
account_name=account.get("account_name", ""),
|
account_name=account.get("account_name", ""),
|
||||||
account_role=account.get("account_role", ""),
|
account_role=account.get("account_role", ""),
|
||||||
account_username=account_username,
|
account_username=account_username,
|
||||||
account_userid = account_userid,
|
account_userid=account_userid,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
appid=app_id,
|
appid=app_id,
|
||||||
open_type=open_type,
|
open_type=open_type,
|
||||||
port_type=1,
|
port_type=1,
|
||||||
access_token = access_token,
|
access_token=access_token,
|
||||||
access_token_expired=expires_in,
|
access_token_expired=access_expired,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
refresh_token_expired=refresh_token_expires_in,
|
refresh_token_expired=refresh_expired,
|
||||||
material_auth_status=material_auth_status,
|
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:
|
# 5. 同登录账号、同应用、其他用户下的授权批量同步最新token
|
||||||
await _update_redis_token(oauth_id, access_token, expires_in)
|
related_query = select(UserOAuth).where(
|
||||||
|
|
||||||
#5.本次更新成功以后,判断是否有其他同一个appid,同一个授权登录账号的授权记录,如果有,则更新token信息
|
|
||||||
from sqlalchemy import update
|
|
||||||
|
|
||||||
related_oauths = await db.execute(
|
|
||||||
select(UserOAuth).where(
|
|
||||||
UserOAuth.account_username == account_username,
|
UserOAuth.account_username == account_username,
|
||||||
UserOAuth.account_userid == account_userid,
|
UserOAuth.account_userid == account_userid,
|
||||||
UserOAuth.appid == app_id,
|
UserOAuth.appid == app_id,
|
||||||
UserOAuth.user_id != user_id,
|
UserOAuth.user_id != user_id,
|
||||||
UserOAuth.deleted_at.is_(None),
|
UserOAuth.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
related_res = await db.execute(related_query)
|
||||||
related_oauths = related_oauths.scalars().all()
|
related_list = related_res.scalars().all()
|
||||||
|
if related_list:
|
||||||
if related_oauths:
|
await db.execute(
|
||||||
await db.execute(
|
update(UserOAuth).where(
|
||||||
update(UserOAuth).where(
|
UserOAuth.account_username == account_username,
|
||||||
UserOAuth.account_username == account_username,
|
UserOAuth.account_userid == account_userid,
|
||||||
UserOAuth.account_userid == account_userid,
|
UserOAuth.appid == app_id,
|
||||||
UserOAuth.appid == app_id,
|
UserOAuth.user_id != user_id,
|
||||||
UserOAuth.user_id != user_id,
|
UserOAuth.deleted_at.is_(None),
|
||||||
UserOAuth.deleted_at.is_(None),
|
).values(
|
||||||
).values(
|
access_token=access_token,
|
||||||
access_token=access_token,
|
access_token_expired=access_expired,
|
||||||
access_token_expired=expires_in,
|
refresh_token=refresh_token,
|
||||||
refresh_token=refresh_token,
|
refresh_token_expired=refresh_expired,
|
||||||
refresh_token_expired=refresh_token_expires_in,
|
material_auth_status=material_auth_status,
|
||||||
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:
|
return {"message": "授权成功"}
|
||||||
await _update_redis_token(related_oauth.id, access_token, expires_in)
|
|
||||||
|
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:
|
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:
|
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(
|
async def get_oauth_list(
|
||||||
@@ -318,36 +324,35 @@ async def get_oauth_list(
|
|||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 10,
|
page_size: int = 10,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if page < 1:
|
# 分页参数容错
|
||||||
page = 1
|
page = max(page, 1)
|
||||||
if page_size < 1:
|
page_size = max(min(page_size, 100), 1)
|
||||||
page_size = 10
|
|
||||||
|
|
||||||
query = select(UserOAuth).where(
|
base_where = [
|
||||||
UserOAuth.user_id == user_id,
|
UserOAuth.user_id == user_id,
|
||||||
UserOAuth.deleted_at.is_(None),
|
UserOAuth.deleted_at.is_(None),
|
||||||
)
|
]
|
||||||
|
|
||||||
if account_userid:
|
if account_userid:
|
||||||
query = query.where(UserOAuth.account_userid == account_userid)
|
base_where.append(UserOAuth.account_userid == account_userid)
|
||||||
if open_type:
|
if open_type:
|
||||||
query = query.where(UserOAuth.open_type == open_type)
|
base_where.append(UserOAuth.open_type == open_type)
|
||||||
if account_id:
|
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())
|
data_stmt = select(UserOAuth).where(*base_where)\
|
||||||
|
.order_by(UserOAuth.created_at.desc())\
|
||||||
offset = (page - 1) * page_size
|
.offset((page - 1) * page_size)\
|
||||||
query = query.offset(offset).limit(page_size)
|
.limit(page_size)
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
result = await db.execute(query)
|
data_list = result.scalars().all()
|
||||||
oauth_list = result.scalars().all()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"data": oauth_list,
|
"data": data_list,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"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)
|
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
||||||
request_str = json.dumps(request_data, ensure_ascii=False)
|
request_str = json.dumps(request_data, ensure_ascii=False)
|
||||||
request_encrypted = encrypt_data(request_data)
|
request_encrypted = encrypt_data(request_data, True)
|
||||||
entry = {
|
entry = {
|
||||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"type": "video_gen_request",
|
"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)
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
||||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
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 = {
|
entry = {
|
||||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"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 asyncio
|
||||||
import httpx
|
import httpx
|
||||||
import json
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 import UserOAuth
|
||||||
from app.models.user_oauth_app import UserOAuthApp
|
from app.models.user_oauth_app import UserOAuthApp
|
||||||
from app.models.base import async_session
|
from app.models.base import async_session
|
||||||
from app.config import settings
|
|
||||||
from app.utils.redis import get_redis
|
from app.utils.redis import get_redis
|
||||||
from app.utils.logger import get_logger
|
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"
|
REDIS_KEY = "douyin:tokens"
|
||||||
logger = get_logger("token_refresh", "token_refresh")
|
logger = get_logger("token_refresh", "token_refresh")
|
||||||
|
|
||||||
|
# 配置常量
|
||||||
|
|
||||||
REFRESH_THRESHOLD_SECONDS = 800
|
REFRESH_THRESHOLD_SECONDS = 800
|
||||||
CHECK_INTERVAL_MINUTES = 5
|
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):
|
async def _update_redis_token(oauth_id: str, token: str, expired_at: datetime):
|
||||||
"""更新Redis缓存中的token"""
|
"""更新Redis缓存中的token(统一北京时间序列化)"""
|
||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
if not redis:
|
if not redis:
|
||||||
logger.warning("Redis连接未配置,跳过缓存更新")
|
logger.warning("Redis连接未配置,跳过缓存更新")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache = {
|
cache = {
|
||||||
"token": token,
|
"token": token,
|
||||||
"expired_at": expired_at.isoformat(),
|
"expired_at": datetime_to_db_tz_str(expired_at),
|
||||||
}
|
}
|
||||||
await redis.hset(REDIS_KEY, oauth_id, json.dumps(cache))
|
await redis.hset(REDIS_KEY, oauth_id, json.dumps(cache))
|
||||||
logger.info(f"Redis缓存已更新: oauth_id={oauth_id}")
|
logger.info(f"Redis缓存已更新: oauth_id={oauth_id}")
|
||||||
except Exception as e:
|
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):
|
async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSession):
|
||||||
"""刷新巨量引擎token"""
|
"""刷新巨量引擎token"""
|
||||||
try:
|
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/"
|
url = "https://api.oceanengine.com/open_api/oauth2/refresh_token/"
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
url,
|
url,
|
||||||
@@ -55,13 +70,11 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
if data.get("code") != 0:
|
if data.get("code") != 0:
|
||||||
logger.error(f"刷新巨量引擎token失败: oauth_id={oauth.id}, 错误信息: {data}")
|
logger.error(f"刷新巨量引擎token失败: oauth_id={oauth.id}, 错误信息: {data}")
|
||||||
#如果code=40103或者40107,传入refresh_token已失效,失效原因一般是由于refresh_token已被使用,或授权账号重新授权并生成了新的Token
|
# refresh_token已失效或刷新失败,统一清理token
|
||||||
if data.get("code") in [40103, 40107]:
|
if data.get("code") in [40103, 40107, 40000]:
|
||||||
#清空数据库中的token信息,和Redis缓存中的token
|
|
||||||
from sqlalchemy import update
|
|
||||||
|
|
||||||
where_cond = UserOAuth.deleted_at.is_(None)
|
where_cond = UserOAuth.deleted_at.is_(None)
|
||||||
if oauth.appid:
|
if oauth.appid:
|
||||||
where_cond = where_cond & (UserOAuth.appid == 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()
|
await db.commit()
|
||||||
|
|
||||||
related_oauth_ids = await db.execute(
|
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
|
||||||
select(UserOAuth.id).where(where_cond)
|
|
||||||
)
|
|
||||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||||
for related_id in related_oauth_ids:
|
for related_id in related_oauth_ids:
|
||||||
await _update_redis_token(related_id, "", None)
|
await _delete_redis_token(related_id)
|
||||||
|
|
||||||
return
|
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", {})
|
# 【改动3:全部使用北京时间计算过期时间,统一时区】
|
||||||
new_access_token = data.get("access_token", "")
|
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||||
new_refresh_token = data.get("refresh_token", "")
|
expires_in = now_beijing + timedelta(seconds=resp_data.get("expires_in", 0))
|
||||||
expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 0))
|
refresh_expires = now_beijing + timedelta(seconds=resp_data.get("refresh_token_expires_in", 0))
|
||||||
refresh_token_expires_in = datetime.now(timezone.utc) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
|
|
||||||
|
|
||||||
from sqlalchemy import update
|
|
||||||
|
|
||||||
where_cond = UserOAuth.deleted_at.is_(None)
|
where_cond = UserOAuth.deleted_at.is_(None)
|
||||||
if oauth.appid:
|
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=new_access_token,
|
||||||
access_token_expired=expires_in,
|
access_token_expired=expires_in,
|
||||||
refresh_token=new_refresh_token,
|
refresh_token=new_refresh_token,
|
||||||
refresh_token_expired=refresh_token_expires_in,
|
refresh_token_expired=refresh_expires,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
related_oauth_ids = await db.execute(
|
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
|
||||||
select(UserOAuth.id).where(where_cond)
|
|
||||||
)
|
|
||||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||||
for related_id in related_oauth_ids:
|
for related_id in related_oauth_ids:
|
||||||
await _update_redis_token(related_id, new_access_token, expires_in)
|
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:
|
except httpx.HTTPError as e:
|
||||||
logger.error(f"HTTP请求失败: oauth_id={oauth.id}, 错误: {str(e)}")
|
logger.error(f"HTTP请求失败: oauth_id={oauth.id}, 错误: {str(e)}")
|
||||||
except Exception as 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():
|
async def check_and_refresh_tokens():
|
||||||
"""检查并刷新即将过期的token"""
|
"""检查并刷新即将过期的token"""
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
now = datetime.now(timezone.utc)
|
# 【改动4:统一使用北京时间当前时间,避免时区运算异常】
|
||||||
|
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||||
|
|
||||||
query = select(UserOAuth).where(
|
query = select(UserOAuth).where(
|
||||||
UserOAuth.deleted_at.is_(None),
|
UserOAuth.deleted_at.is_(None),
|
||||||
UserOAuth.refresh_token.is_not(None),
|
UserOAuth.refresh_token.is_not(None),
|
||||||
UserOAuth.refresh_token_expired.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)
|
result = await db.execute(query)
|
||||||
@@ -149,77 +161,71 @@ async def check_and_refresh_tokens():
|
|||||||
|
|
||||||
for oauth in oauth_list:
|
for oauth in oauth_list:
|
||||||
try:
|
try:
|
||||||
#检查是否为支持的平台(巨量引擎)
|
|
||||||
# port_type: 平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)
|
|
||||||
if oauth.port_type not in [1]:
|
if oauth.port_type not in [1]:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
#构建登录账号唯一标识,同一登录账号共享token
|
# 唯一键优化:过滤空值避免拼接异常
|
||||||
key_parts = []
|
key_parts = []
|
||||||
if oauth.appid:
|
if oauth.appid:
|
||||||
key_parts.append(oauth.appid)
|
key_parts.append(oauth.appid)
|
||||||
if oauth.account_username:
|
if oauth.account_username:
|
||||||
key_parts.append(oauth.account_username)
|
key_parts.append(oauth.account_username)
|
||||||
if oauth.account_userid:
|
if oauth.account_userid:
|
||||||
key_parts.append(oauth.account_userid)
|
key_parts.append(str(oauth.account_userid))
|
||||||
login_key = "|".join(key_parts)
|
login_key = "|".join(key_parts)
|
||||||
|
|
||||||
#同一登录账号已刷新过,直接跳过(避免使用旧数据判断)
|
|
||||||
if login_key in refreshed_keys:
|
if login_key in refreshed_keys:
|
||||||
logger.debug(f"跳过重复刷新: oauth_id={oauth.id}, 同一登录账号已刷新")
|
logger.debug(f"跳过重复刷新: oauth_id={oauth.id}, 同一登录账号已刷新")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
#获取应用配置
|
# 【改动5:过滤已禁用、已删除应用】
|
||||||
app_result = await db.execute(
|
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()
|
app = app_result.scalar_one_or_none()
|
||||||
|
|
||||||
if not app:
|
if not app:
|
||||||
|
logger.warning(f"oauth_id:{oauth.id} 对应应用不存在/已禁用,跳过刷新")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
#检查access_token是否需要刷新
|
|
||||||
need_refresh = False
|
need_refresh = False
|
||||||
|
if not oauth.access_token or not oauth.access_token_expired:
|
||||||
# access_token为空,需要刷新
|
|
||||||
if not oauth.access_token:
|
|
||||||
need_refresh = True
|
need_refresh = True
|
||||||
# access_token_expired为空,需要刷新
|
|
||||||
elif not oauth.access_token_expired:
|
|
||||||
need_refresh = True
|
|
||||||
# access_token即将过期(剩余时间小于800秒),需要刷新
|
|
||||||
else:
|
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
|
need_refresh = True
|
||||||
|
|
||||||
if not need_refresh:
|
if not need_refresh:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
#refresh_token已在查询条件中过滤,确保有效才能刷新
|
|
||||||
|
|
||||||
#刷新token
|
|
||||||
await refresh_juliang_token(oauth, app, db)
|
await refresh_juliang_token(oauth, app, db)
|
||||||
|
|
||||||
refreshed_keys.add(login_key)
|
refreshed_keys.add(login_key)
|
||||||
|
|
||||||
|
# 简单限流,防止瞬间大量请求
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
#7.增加错误日志
|
logger.error(f"oauth_id:{oauth.id} 刷新token异常: {str(e)}", exc_info=True)
|
||||||
logger.error(f"刷新token失败: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
async def token_refresh_scheduler():
|
async def token_refresh_scheduler():
|
||||||
"""定时任务调度器"""
|
"""定时任务调度器"""
|
||||||
|
logger.info("开始执行定时Token刷新任务")
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await check_and_refresh_tokens()
|
await check_and_refresh_tokens()
|
||||||
except Exception as e:
|
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)
|
await asyncio.sleep(CHECK_INTERVAL_MINUTES * 60)
|
||||||
|
|
||||||
|
|
||||||
def start_token_refresh_task():
|
def start_token_refresh_task():
|
||||||
"""启动token刷新定时任务"""
|
"""启动token刷新定时任务"""
|
||||||
logger.info("启动token刷新定时任务")
|
logger.info("启动token刷新定时后台任务")
|
||||||
asyncio.create_task(token_refresh_scheduler())
|
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
|
import httpx
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 import UserOAuth
|
||||||
from app.models.user_oauth_app import UserOAuthApp
|
from app.models.user_oauth_app import UserOAuthApp
|
||||||
from app.models.base import async_session
|
from app.models.base import async_session
|
||||||
@@ -40,50 +46,75 @@ class DouyinRequest:
|
|||||||
self._client = None
|
self._client = None
|
||||||
|
|
||||||
async def _get_redis_token(self, oauth_id: str) -> Optional[str]:
|
async def _get_redis_token(self, oauth_id: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
【核心改动:北京时间解析+脏缓存自动清理】
|
||||||
|
1. 缓存格式异常/字段缺失:直接删除脏缓存
|
||||||
|
2. Token过期则不返回,交由上层判断refresh是否过期决定是否删除缓存
|
||||||
|
"""
|
||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
if not redis:
|
if not redis:
|
||||||
raise ValueError("Redis连接未配置")
|
raise ValueError("Redis连接未配置")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cache_str = await redis.hget(self._redis_key, oauth_id)
|
cache_str = await redis.hget(self._redis_key, oauth_id)
|
||||||
if cache_str:
|
if not cache_str:
|
||||||
cache = json.loads(cache_str)
|
return None
|
||||||
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
|
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):
|
async def _set_redis_token(self, oauth_id: str, token: str, expired_at: datetime):
|
||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
if not redis:
|
if not redis:
|
||||||
raise ValueError("Redis连接未配置")
|
raise ValueError("Redis连接未配置")
|
||||||
|
# 【改动4:统一转为北京时间序列化存入Redis,和数据库时区对齐】
|
||||||
|
cache = {
|
||||||
|
"token": token,
|
||||||
|
"expired_at": datetime_to_db_tz_str(expired_at),
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
cache = {
|
await redis.hset(self._redis_key, oauth_id, json.dumps(cache, ensure_ascii=False))
|
||||||
"token": token,
|
|
||||||
"expired_at": expired_at.isoformat(),
|
|
||||||
}
|
|
||||||
await redis.hset(self._redis_key, oauth_id, json.dumps(cache))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"设置Redis缓存失败: {e}")
|
raise ValueError(f"设置Redis缓存失败: {str(e)}")
|
||||||
|
|
||||||
async def _delete_redis_token(self, oauth_id: str):
|
async def _delete_redis_token(self, oauth_id: str):
|
||||||
|
"""删除单个oauth_id缓存(授权彻底失效时调用)"""
|
||||||
redis = get_redis()
|
redis = get_redis()
|
||||||
if not redis:
|
if not redis:
|
||||||
raise ValueError("Redis连接未配置")
|
raise ValueError("Redis连接未配置")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await redis.hdel(self._redis_key, oauth_id)
|
await redis.hdel(self._redis_key, oauth_id)
|
||||||
|
logger.info(f"oauth_id:{oauth_id} 授权失效,已清理Redis缓存脏数据")
|
||||||
except Exception as e:
|
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:
|
async def get_access_token(self, oauth_id: str, force_refresh: bool = False) -> str:
|
||||||
|
# 优先读取缓存
|
||||||
if not force_refresh:
|
if not force_refresh:
|
||||||
token = await self._get_redis_token(oauth_id)
|
token = await self._get_redis_token(oauth_id)
|
||||||
if token:
|
if token:
|
||||||
@@ -101,7 +132,17 @@ class DouyinRequest:
|
|||||||
if not oauth_data:
|
if not oauth_data:
|
||||||
raise ValueError("无效的oauth_id")
|
raise ValueError("无效的oauth_id")
|
||||||
|
|
||||||
|
# 【改动5:统一北京时间当前时间】
|
||||||
|
now_beijing = datetime.now(tz=BEIJING_TZ)
|
||||||
|
|
||||||
if force_refresh:
|
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)
|
where_cond = UserOAuth.deleted_at.is_(None)
|
||||||
if oauth_data.appid:
|
if oauth_data.appid:
|
||||||
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
|
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
|
||||||
@@ -118,9 +159,7 @@ class DouyinRequest:
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
related_oauth_ids = await db.execute(
|
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
|
||||||
select(UserOAuth.id).where(where_cond)
|
|
||||||
)
|
|
||||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||||
for related_id in related_oauth_ids:
|
for related_id in related_oauth_ids:
|
||||||
await self._delete_redis_token(related_id)
|
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)
|
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
|
||||||
return new_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
|
token = oauth_data.access_token
|
||||||
expired_at = oauth_data.access_token_expired
|
expired_at = oauth_data.access_token_expired
|
||||||
await self._set_redis_token(oauth_id, token, expired_at)
|
await self._set_redis_token(oauth_id, token, expired_at)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
# 查找同账号下未过期有效token复用
|
||||||
where_cond = UserOAuth.deleted_at.is_(None)
|
where_cond = UserOAuth.deleted_at.is_(None)
|
||||||
if oauth_data.appid:
|
if oauth_data.appid:
|
||||||
where_cond = where_cond & (UserOAuth.appid == 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(
|
select(UserOAuth.access_token, UserOAuth.access_token_expired).where(
|
||||||
where_cond,
|
where_cond,
|
||||||
UserOAuth.access_token_expired.is_not(None),
|
UserOAuth.access_token_expired.is_not(None),
|
||||||
UserOAuth.access_token_expired > datetime.now(timezone.utc),
|
UserOAuth.access_token_expired > now_beijing,
|
||||||
).limit(1)
|
).limit(1)
|
||||||
)
|
)
|
||||||
related_oauth = related_oauths.first()
|
related_oauth = related_oauths.first()
|
||||||
@@ -155,9 +196,18 @@ class DouyinRequest:
|
|||||||
await self._set_redis_token(oauth_id, token, expired_at)
|
await self._set_redis_token(oauth_id, token, expired_at)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < datetime.now(timezone.utc):
|
# =========【核心业务规则实现:判断RefreshToken是否过期】=========
|
||||||
raise ValueError("授权已过期,请重新授权")
|
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)
|
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
|
||||||
return new_token
|
return new_token
|
||||||
|
|
||||||
@@ -203,6 +253,7 @@ class DouyinRequest:
|
|||||||
|
|
||||||
code = data.get('code', 0)
|
code = data.get('code', 0)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
|
#刷新token
|
||||||
raise ValueError(f"刷新access_token失败,接口返回:{data}")
|
raise ValueError(f"刷新access_token失败,接口返回:{data}")
|
||||||
|
|
||||||
data = data.get('data', {})
|
data = data.get('data', {})
|
||||||
@@ -211,7 +262,10 @@ class DouyinRequest:
|
|||||||
expires_in = data.get('expires_in', 0)
|
expires_in = data.get('expires_in', 0)
|
||||||
refresh_token_expires_in = data.get('refresh_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)
|
# 【改动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)
|
where_cond = UserOAuth.deleted_at.is_(None)
|
||||||
if appid:
|
if appid:
|
||||||
@@ -222,20 +276,16 @@ class DouyinRequest:
|
|||||||
where_cond = where_cond & (UserOAuth.account_userid == account_userid)
|
where_cond = where_cond & (UserOAuth.account_userid == account_userid)
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(UserOAuth).where(
|
update(UserOAuth).where(where_cond).values(
|
||||||
where_cond,
|
|
||||||
).values(
|
|
||||||
access_token=new_access_token,
|
access_token=new_access_token,
|
||||||
refresh_token=new_refresh_token,
|
refresh_token=new_refresh_token,
|
||||||
access_token_expired=new_expired_at,
|
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()
|
await db.commit()
|
||||||
|
|
||||||
related_oauth_ids = await db.execute(
|
related_oauth_ids = await db.execute(select(UserOAuth.id).where(where_cond))
|
||||||
select(UserOAuth.id).where(where_cond)
|
|
||||||
)
|
|
||||||
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
|
||||||
|
|
||||||
for related_id in related_oauth_ids:
|
for related_id in related_oauth_ids:
|
||||||
@@ -243,92 +293,79 @@ class DouyinRequest:
|
|||||||
|
|
||||||
return new_access_token, new_expired_at
|
return new_access_token, new_expired_at
|
||||||
|
|
||||||
# 有token请求
|
|
||||||
async def request_with_token_with_context(
|
async def request_with_token_with_context(
|
||||||
self,
|
self,
|
||||||
oauth_id: str,
|
oauth_id: str,
|
||||||
url: str,
|
url: str,
|
||||||
method: str = 'GET',
|
method: str = 'GET',
|
||||||
options: any = None,
|
options: Optional[Dict[str, Any]] = None,
|
||||||
request_count: int = 1,
|
request_count: int = 1,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
options = options or {}
|
options = options or {}
|
||||||
token = await self.get_access_token(oauth_id)
|
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:
|
try:
|
||||||
headers = options.get('headers', {}).copy()
|
headers = options.get('headers', {}).copy()
|
||||||
headers['Access-Token'] = token
|
headers['Access-Token'] = token
|
||||||
|
|
||||||
has_files = 'files' in options
|
has_files = 'files' in options
|
||||||
if has_files:
|
if has_files:
|
||||||
# 移除可能错误设置的 Content-Type,让库自动生成 multipart 头
|
|
||||||
headers.pop('Content-Type', None)
|
headers.pop('Content-Type', None)
|
||||||
else:
|
else:
|
||||||
headers.setdefault('Content-Type', 'application/json')
|
headers.setdefault('Content-Type', 'application/json')
|
||||||
|
|
||||||
options['headers'] = headers
|
options['headers'] = headers
|
||||||
|
|
||||||
response = await self.client.request(method, url, **options)
|
response = await self.client.request(method, url, **options)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = response.json()
|
resp_data = response.json()
|
||||||
except json.JSONDecodeError:
|
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)
|
await asyncio.sleep(i * 5)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
code = data.get('code', 0)
|
code = resp_data.get('code', 0)
|
||||||
|
|
||||||
if code in [40102, 40104]:
|
if code in [40102, 40104]:
|
||||||
await asyncio.sleep(i * 5)
|
await asyncio.sleep(i * 5)
|
||||||
token = await self.get_access_token(oauth_id, force_refresh=True)
|
token = await self.get_access_token(oauth_id, force_refresh=True)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if code in [40100, 40110]:
|
if code in [40100, 40110]:
|
||||||
wait_time = min(2 * (2 ** (i - 1)), 10)
|
wait_time = min(2 * (2 ** (i - 1)), 10)
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if code == 50000:
|
if code == 50000:
|
||||||
await asyncio.sleep(i * 10)
|
await asyncio.sleep(i * 10)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return data
|
return resp_data
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
except (httpx.HTTPStatusError, httpx.RequestError):
|
||||||
await asyncio.sleep(i * 10)
|
|
||||||
continue
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
await asyncio.sleep(i * 10)
|
await asyncio.sleep(i * 10)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 重试耗尽,日志记录
|
||||||
options_log = {}
|
options_log = {}
|
||||||
if options:
|
for key, value in options.items():
|
||||||
for key, value in options.items():
|
if key == 'files':
|
||||||
if key == 'files':
|
options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
|
||||||
options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
|
else:
|
||||||
else:
|
options_log[key] = value
|
||||||
options_log[key] = value
|
|
||||||
|
|
||||||
res = json.dumps(data, ensure_ascii=False) if 'data' in locals() else ''
|
|
||||||
|
|
||||||
|
res = json.dumps(resp_data, ensure_ascii=False) if resp_data else ''
|
||||||
logger.error(
|
logger.error(
|
||||||
f'DouYin API request failed after {request_count} retries. '
|
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}'
|
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:
|
if resp_data and resp_data.get("code", 0) != 0:
|
||||||
raise ValueError(f'接口返回错误[code:{data.get("code", "接口编码")}]{data.get("message", "接口返回错误")}')
|
raise ValueError(f'接口返回错误[code:{resp_data.get("code")}]{resp_data.get("message", "接口异常")}')
|
||||||
else:
|
raise ValueError("网络请求失败,请稍后重试")
|
||||||
raise ValueError('网络错误,稍后重试。')
|
|
||||||
|
|
||||||
|
|
||||||
# 无token请求
|
|
||||||
async def request_with_context(
|
async def request_with_context(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
@@ -336,8 +373,9 @@ class DouyinRequest:
|
|||||||
options: Optional[Dict[str, Any]] = None,
|
options: Optional[Dict[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
options = options or {}
|
options = options or {}
|
||||||
|
resp_data: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
for i in range(1, 2):
|
for i in range(1, 3):
|
||||||
try:
|
try:
|
||||||
headers = options.get('headers', {}).copy()
|
headers = options.get('headers', {}).copy()
|
||||||
headers.setdefault('Content-Type', 'application/json')
|
headers.setdefault('Content-Type', 'application/json')
|
||||||
@@ -347,30 +385,24 @@ class DouyinRequest:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = response.json()
|
resp_data = response.json()
|
||||||
except json.JSONDecodeError:
|
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)
|
await asyncio.sleep(i * 5)
|
||||||
continue
|
continue
|
||||||
|
if resp_data.get("code", 0) >= 50000:
|
||||||
code = data.get('code', 0)
|
|
||||||
if code >= 50000:
|
|
||||||
await asyncio.sleep(i * 10)
|
await asyncio.sleep(i * 10)
|
||||||
continue
|
continue
|
||||||
|
return resp_data
|
||||||
|
|
||||||
return data
|
except (httpx.HTTPStatusError, httpx.RequestError):
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
await asyncio.sleep(i * 10)
|
|
||||||
continue
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
await asyncio.sleep(i * 10)
|
await asyncio.sleep(i * 10)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
res = json.dumps(data) if 'data' in locals() else ''
|
res = json.dumps(resp_data, ensure_ascii=False) if resp_data else ''
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f'DouYin API request failed after 5 retries. '
|
f'DouYin API request failed after 2 retries. '
|
||||||
f'url:{url};options:{json.dumps(options)};response:{res}'
|
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>
|
||||||
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-D9_3MPsN.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -113,6 +113,20 @@ export async function optimizePrompt(
|
|||||||
image_px: params.image_px || null,
|
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 }> {
|
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
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> {
|
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`);
|
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> {
|
export async function deleteResourcesMaterial(params:any): Promise<any> {
|
||||||
return api.delete(`/generation-ai/history/batch`, params);
|
return api.delete(`/generation-ai/history/batch`, params);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../store/useAuthStore';
|
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 NotificationPopup from '../NotificationPopup';
|
||||||
import './AppLayout.css';
|
import './AppLayout.css';
|
||||||
|
|
||||||
@@ -619,10 +619,18 @@ const AppLayout: React.FC = () => {
|
|||||||
|
|
||||||
const handleChangePwd = async () => {
|
const handleChangePwd = async () => {
|
||||||
try {
|
try {
|
||||||
await pwdForm.validateFields();
|
const values = await pwdForm.validateFields();
|
||||||
message.success('密码修改成功(演示)');
|
await changePassword(values.oldPwd, values.newPwd);
|
||||||
setPwdModalOpen(false); pwdForm.resetFields();
|
message.success('密码修改成功');
|
||||||
} catch { }
|
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 = () => {
|
const handleLogout = () => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import text from '../assets/testb.png';
|
|||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,
|
getParameters, createGenerationTask, getgen_list, getEngine, uploadImage,uploadAudio,
|
||||||
uploadVideo, getCreditRatios, deleteHistory, calculateCredits
|
uploadVideo, getCreditRatios, deleteHistory, calculateCredits
|
||||||
} from '../api';
|
} from '../api';
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
AudioOutlined,
|
AudioOutlined,
|
||||||
|
PauseOutlined,
|
||||||
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
@@ -227,6 +228,38 @@ const AIChatPage: React.FC = () => {
|
|||||||
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
|
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
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());
|
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 [attachmentPreviewType, setAttachmentPreviewType] = useState<'image' | 'video' | 'audio'>('image');
|
||||||
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
|
const [attachmentPreviewName, setAttachmentPreviewName] = useState<string>('');
|
||||||
const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null);
|
const attachmentPreviewVideoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [playingAudioUrl, setPlayingAudioUrl] = useState<string | null>(null);
|
||||||
|
const [audioProgress, setAudioProgress] = useState(0);
|
||||||
|
|
||||||
// 附件详情悬浮窗状态
|
// 附件详情悬浮窗状态
|
||||||
const [attachmentPopupVisible, setAttachmentPopupVisible] = useState<boolean>(false);
|
const [attachmentPopupVisible, setAttachmentPopupVisible] = useState<boolean>(false);
|
||||||
@@ -831,6 +866,9 @@ const AIChatPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
console.log(mediaReferences);
|
||||||
|
|
||||||
// 创建用户消息对象
|
// 创建用户消息对象
|
||||||
const newMessage: Message = {
|
const newMessage: Message = {
|
||||||
id: '',
|
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 }> => {
|
const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -1096,16 +1163,24 @@ const AIChatPage: React.FC = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentEngineList = mediaType === 'image' ? enginesele.image : enginesele.video;
|
if (isAudio) {
|
||||||
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
|
const audioExt = file.name.split('.').pop()?.toLowerCase();
|
||||||
const maxImage = currentEngine?.maxImageCount ?? 4;
|
if (!['wav', 'mp3'].includes(audioExt || '')) {
|
||||||
const maxVideo = currentEngine?.maxVideoCount ?? 1;
|
message.error('音频仅支持wav和mp3格式');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (mediaType === 'image' && (isVideo || isAudio)) {
|
if (mediaType === 'image' && (isVideo || isAudio)) {
|
||||||
message.error('图片模式仅支持上传图片');
|
message.error('图片模式仅支持上传图片');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAudio && mediaType !== 'video') {
|
||||||
|
message.error('仅视频模式支持上传音频');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
|
const maxMB = isVideo ? 100 : (isAudio ? 50 : 10);
|
||||||
const fileTypeText = isVideo ? '视频' : (isAudio ? '音频' : '图片');
|
const fileTypeText = isVideo ? '视频' : (isAudio ? '音频' : '图片');
|
||||||
if (file.size / 1024 / 1024 > maxMB) {
|
if (file.size / 1024 / 1024 > maxMB) {
|
||||||
@@ -1140,7 +1215,14 @@ const AIChatPage: React.FC = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const audioCount = currentMedia.filter((m) => m.type === 'audio').length;
|
||||||
|
if (isAudio && audioCount >= maxAudio) {
|
||||||
|
message.error(`该引擎最多上传${maxAudio}个音频`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
let videoDuration = 0;
|
let videoDuration = 0;
|
||||||
|
let audioDuration = 0;
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
try {
|
try {
|
||||||
videoDuration = await getVideoDuration(file);
|
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);
|
setUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uploadFn = isImage ? uploadImage : (isAudio ? uploadImage : uploadVideo);
|
const uploadFn = isImage ? uploadImage : (isAudio ? uploadAudio : uploadVideo);
|
||||||
const res = await uploadFn(file);
|
const res = await uploadFn(file);
|
||||||
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
|
const mediaType: 'image' | 'video' | 'audio' = isImage ? 'image' : (isAudio ? 'audio' : 'video');
|
||||||
const newList = [...currentMedia, {
|
const newList = [...currentMedia, {
|
||||||
@@ -1179,7 +1282,8 @@ const AIChatPage: React.FC = () => {
|
|||||||
type: mediaType,
|
type: mediaType,
|
||||||
url: res.url,
|
url: res.url,
|
||||||
label: '',
|
label: '',
|
||||||
...((isVideo || isAudio) && { duration: videoDuration }),
|
...(isVideo && { duration: videoDuration }),
|
||||||
|
...(isAudio && { duration: audioDuration }),
|
||||||
}];
|
}];
|
||||||
const labels = generateMediaLabels(newList);
|
const labels = generateMediaLabels(newList);
|
||||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||||
@@ -1287,10 +1391,11 @@ const AIChatPage: React.FC = () => {
|
|||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAttachmentMediaType = (ref: any): 'image' | 'video' => {
|
const getAttachmentMediaType = (ref: any): 'image' | 'video' | 'audio' => {
|
||||||
const rawType = String(ref?.type || '').toLowerCase();
|
const rawType = String(ref?.type || '').toLowerCase();
|
||||||
const rawUrl = String(ref?.url || ref?.name || '').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('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';
|
return 'image';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1320,7 +1425,8 @@ const AIChatPage: React.FC = () => {
|
|||||||
if (!ref?.url) return;
|
if (!ref?.url) return;
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = buildAttachmentDownloadUrl(ref.url);
|
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);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
@@ -1348,6 +1454,10 @@ const AIChatPage: React.FC = () => {
|
|||||||
? '首帧必传 · 尾帧可选 · 适合首尾画面连贯过渡'
|
? '首帧必传 · 尾帧可选 · 适合首尾画面连贯过渡'
|
||||||
: '多素材参考 · 支持图片 / 视频 / 音频,输入 @ 可快速引用素材';
|
: '多素材参考 · 支持图片 / 视频 / 音频,输入 @ 可快速引用素材';
|
||||||
|
|
||||||
|
const maxImage = maxImageCount;
|
||||||
|
const maxVideo = maxVideoCount;
|
||||||
|
const maxAudio = currentEngine?.maxAudioCount ?? 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout className="ai-create-page" style={{
|
<Layout className="ai-create-page" style={{
|
||||||
margin: '-24px -32px -32px',
|
margin: '-24px -32px -32px',
|
||||||
@@ -1357,6 +1467,14 @@ const AIChatPage: React.FC = () => {
|
|||||||
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
|
{/* 隐藏的音频播放器 */}
|
||||||
|
<audio
|
||||||
|
id="audio-player"
|
||||||
|
src={playingAudioUrl || ''}
|
||||||
|
autoPlay
|
||||||
|
onEnded={() => setPlayingAudioUrl(null)}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
{/* 左侧边栏 - 对话列表(已隐藏,保留代码) */}
|
{/* 左侧边栏 - 对话列表(已隐藏,保留代码) */}
|
||||||
{false && (
|
{false && (
|
||||||
<Sider
|
<Sider
|
||||||
@@ -1390,6 +1508,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{!collapsed && conversations.length > 0 && (
|
{!collapsed && conversations.length > 0 && (
|
||||||
<div style={{ maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
|
<div style={{ maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
|
||||||
{conversations.map((conversation) => (
|
{conversations.map((conversation) => (
|
||||||
@@ -1527,7 +1646,6 @@ const AIChatPage: React.FC = () => {
|
|||||||
paddingBottom: 18,
|
paddingBottom: 18,
|
||||||
// background: 'linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,255,255,0))',
|
// background: 'linear-gradient(180deg, rgba(255,255,255,0.72), rgba(255,255,255,0))',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
|
|
||||||
// borderRadius: 22,
|
// 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' }}>
|
<div className="ai-reference-tray-scroll" style={{ display: 'flex', gap: 10, overflowX: 'auto', padding: '2px 2px 4px' }}>
|
||||||
{attachmentRefs.map((ref: any, idx: number) => {
|
{attachmentRefs.map((ref: any, idx: number) => {
|
||||||
const refType = getAttachmentMediaType(ref);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${ref.url || ref.name || idx}-${idx}`}
|
key={`${ref.url || ref.name || idx}-${idx}`}
|
||||||
@@ -1977,10 +2095,15 @@ const AIChatPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
openAttachmentPreview(ref);
|
if (refType === 'audio') {
|
||||||
setAttachmentPopupVisible(false);
|
e.stopPropagation();
|
||||||
setAttachmentPopupMessageId(null);
|
handleAudioPlay(ref.url);
|
||||||
|
} else {
|
||||||
|
openAttachmentPreview(ref);
|
||||||
|
setAttachmentPopupVisible(false);
|
||||||
|
setAttachmentPopupMessageId(null);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -1999,7 +2122,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
alt={ref.name || label}
|
alt={ref.name || label}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : refType === 'video' ? (
|
||||||
<>
|
<>
|
||||||
<video
|
<video
|
||||||
src={buildAttachmentAssetUrl(ref.url)}
|
src={buildAttachmentAssetUrl(ref.url)}
|
||||||
@@ -2013,6 +2136,14 @@ const AIChatPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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)' }}>
|
{/* <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}
|
{label}
|
||||||
@@ -2047,7 +2178,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* <button
|
||||||
onClick={(e) => downloadAttachmentRef(ref, e)}
|
onClick={(e) => downloadAttachmentRef(ref, e)}
|
||||||
title="下载"
|
title="下载"
|
||||||
style={{
|
style={{
|
||||||
@@ -2067,7 +2198,7 @@ const AIChatPage: React.FC = () => {
|
|||||||
onMouseLeave={(e) => { e.currentTarget.style.background = '#FFFFFF'; e.currentTarget.style.color = '#667085'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.background = '#FFFFFF'; e.currentTarget.style.color = '#667085'; }}
|
||||||
>
|
>
|
||||||
<DownloadOutlined style={{ fontSize: 12 }} />
|
<DownloadOutlined style={{ fontSize: 12 }} />
|
||||||
</button>
|
</button> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2092,8 +2223,12 @@ const AIChatPage: React.FC = () => {
|
|||||||
backdropFilter: 'blur(24px)',
|
backdropFilter: 'blur(24px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* <div>12312</div> */}
|
||||||
|
|
||||||
|
|
||||||
{/* 上方输入布局 */}
|
{/* 上方输入布局 */}
|
||||||
<div style={{ display: 'flex', gap: isFirstLastFrameComposer ? 18 : 18, alignItems: 'flex-end', marginBottom: 14 }}>
|
<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 }}>
|
<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'
|
<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 === '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
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -2319,15 +2456,17 @@ const AIChatPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
setAttachmentPreviewUrl(media.url);
|
e.stopPropagation();
|
||||||
setAttachmentPreviewType('audio');
|
handleAudioPlay(media.url);
|
||||||
setAttachmentPreviewName(media.name);
|
|
||||||
setAttachmentPreviewVisible(true);
|
|
||||||
}}
|
}}
|
||||||
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' }}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 右上角删除按钮 */}
|
{/* 右上角删除按钮 */}
|
||||||
@@ -2378,7 +2517,9 @@ const AIChatPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Tooltip title={mediaType === 'image'
|
<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 === '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
|
<div
|
||||||
style={{
|
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',
|
padding: '24px 28px',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
@@ -901,6 +902,7 @@ const HomePage: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ========== 预览弹窗 ========== */}
|
{/* ========== 预览弹窗 ========== */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -62,6 +62,71 @@ function InitialInfo() {
|
|||||||
// 当前展开的步骤
|
// 当前展开的步骤
|
||||||
const [activeKey, setActiveKey] = useState<string[]>([]);
|
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 = [
|
const baseSteps = [
|
||||||
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
|
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
|
||||||
@@ -347,9 +412,12 @@ function InitialInfo() {
|
|||||||
image_size: "2K"
|
image_size: "2K"
|
||||||
}
|
}
|
||||||
gettwo(taskDetail.id, stepId.toString(), params).then((res: any) => {
|
gettwo(taskDetail.id, stepId.toString(), params).then((res: any) => {
|
||||||
|
message.info('正在生成图片,请稍候...');
|
||||||
// 重新获取任务详情以更新数据
|
// 重新获取任务详情以更新数据
|
||||||
refreshTaskDetail();
|
refreshTaskDetail();
|
||||||
}).catch((error: any) => {
|
}).catch((error: any) => {
|
||||||
|
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||||
|
message.error(errorMsg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const newcreateimage = () => {
|
const newcreateimage = () => {
|
||||||
@@ -362,9 +430,13 @@ function InitialInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gettwo(taskDetail.id, steps[1].id.toString(), params).then((res: any) => {
|
gettwo(taskDetail.id, steps[1].id.toString(), params).then((res: any) => {
|
||||||
|
message.info('正在生成图片,请稍候...');
|
||||||
|
|
||||||
refreshTaskDetail();
|
refreshTaskDetail();
|
||||||
|
|
||||||
}).catch((error: any) => {
|
}).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) => {
|
getthree(taskDetail.id, stepId.toString(), videoParams).then((res: any) => {
|
||||||
// 重新获取任务详情以更新数据
|
// 重新获取任务详情以更新数据
|
||||||
refreshTaskDetail();
|
refreshTaskDetail();
|
||||||
|
message.info('正在生成视频提示词,请稍候...');
|
||||||
}).catch((error: any) => {
|
}).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) => {
|
getfour(taskDetail.id, stepId.toString(), params).then((res: any) => {
|
||||||
// 重新获取任务详情以更新数据
|
// 重新获取任务详情以更新数据
|
||||||
|
message.info('正在生成视频,请稍候...');
|
||||||
refreshTaskDetail();
|
refreshTaskDetail();
|
||||||
}).catch((error: any) => {
|
}).catch((error: any) => {
|
||||||
|
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||||
|
message.error(errorMsg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const agincreatevideo = () => {
|
const agincreatevideo = () => {
|
||||||
@@ -410,6 +488,8 @@ function InitialInfo() {
|
|||||||
|
|
||||||
getfour(taskDetail.id, steps[3].id.toString(), params).then((res: any) => {
|
getfour(taskDetail.id, steps[3].id.toString(), params).then((res: any) => {
|
||||||
// 重新获取任务详情以更新数据
|
// 重新获取任务详情以更新数据
|
||||||
|
message.info('正在生成视频,请稍候...');
|
||||||
|
|
||||||
refreshTaskDetail();
|
refreshTaskDetail();
|
||||||
}).catch((error: any) => {
|
}).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 style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||||
视频
|
视频
|
||||||
</span>
|
</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 ? (
|
{taskDetail?.material?.materialVideoUrl ? (
|
||||||
<video
|
<video
|
||||||
controls
|
|
||||||
src={taskDetail.material.materialVideoUrl}
|
src={taskDetail.material.materialVideoUrl}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
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 style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||||
产品图片
|
产品图片
|
||||||
</span>
|
</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 ? (
|
{taskDetail?.material?.materialImageUrl ? (
|
||||||
<img src={taskDetail.material.materialImageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
<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 style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||||
生成图片
|
生成图片
|
||||||
</span>
|
</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
|
<img
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalImageUrl}`}
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalImageUrl}`}
|
||||||
alt=""
|
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 style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||||
生成视频
|
生成视频
|
||||||
</span>
|
</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
|
<video
|
||||||
controls
|
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
@@ -700,7 +780,7 @@ function InitialInfo() {
|
|||||||
修改提示词
|
修改提示词
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => { message.info('正在生成图片,请稍候...'); createimage(step.id); }}
|
onClick={() => { createimage(step.id); }}
|
||||||
type="primary"
|
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)' }}
|
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'}
|
disabled={step.status !== 'completed'}
|
||||||
@@ -1106,7 +1186,7 @@ function InitialInfo() {
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
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)' }}
|
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'}
|
disabled={step.status !== 'completed'}
|
||||||
>
|
>
|
||||||
下一步:生成视频提示词
|
下一步:生成视频提示词
|
||||||
@@ -1132,7 +1212,7 @@ function InitialInfo() {
|
|||||||
查看/修改视频提词
|
查看/修改视频提词
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => { message.info('正在生成视频,请稍候...'); createvideo(step.id, step.engineId); }}
|
onClick={() => { createvideo(step.id, step.engineId); }}
|
||||||
type="primary"
|
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)' }}
|
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'}
|
disabled={step.status !== 'completed'}
|
||||||
@@ -1433,6 +1513,62 @@ function InitialInfo() {
|
|||||||
scroll={{ y: 350 }}
|
scroll={{ y: 350 }}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</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>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ interface MaterialData {
|
|||||||
const MaterialListPage: React.FC = () => {
|
const MaterialListPage: React.FC = () => {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const videoRef = React.createRef<HTMLVideoElement>();
|
||||||
const [records, setRecords] = useState<any[]>([]);
|
const [records, setRecords] = useState<any[]>([]);
|
||||||
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
||||||
const [listLoading, setListLoading] = useState(false);
|
const [listLoading, setListLoading] = useState(false);
|
||||||
@@ -93,6 +94,18 @@ const MaterialListPage: React.FC = () => {
|
|||||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||||
const [previewUrl, setPreviewUrl] = useState('');
|
const [previewUrl, setPreviewUrl] = useState('');
|
||||||
const [previewType, setPreviewType] = useState<'image' | 'video'>('image');
|
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 [pushPreTestTemplate, setPushPreTestTemplate] = useState<string>('');
|
||||||
const [pushTemplates, setPushTemplates] = useState<any[]>([]);
|
const [pushTemplates, setPushTemplates] = useState<any[]>([]);
|
||||||
const [pushTemplatesLoading, setPushTemplatesLoading] = useState(false);
|
const [pushTemplatesLoading, setPushTemplatesLoading] = useState(false);
|
||||||
@@ -528,7 +541,7 @@ const MaterialListPage: React.FC = () => {
|
|||||||
<Modal
|
<Modal
|
||||||
title="预览"
|
title="预览"
|
||||||
open={previewModalOpen}
|
open={previewModalOpen}
|
||||||
onCancel={() => setPreviewModalOpen(false)}
|
onCancel={handleClosePreview}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={600}
|
width={600}
|
||||||
style={{ borderRadius: 16 }}
|
style={{ borderRadius: 16 }}
|
||||||
@@ -536,6 +549,7 @@ const MaterialListPage: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||||
{previewType === 'video' ? (
|
{previewType === 'video' ? (
|
||||||
<video
|
<video
|
||||||
|
ref={videoRef}
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
controls
|
controls
|
||||||
style={{ maxWidth: '100%', maxHeight: 400, borderRadius: 8 }}
|
style={{ maxWidth: '100%', maxHeight: 400, borderRadius: 8 }}
|
||||||
|
|||||||
@@ -62,6 +62,44 @@ function InitialInfo() {
|
|||||||
// 当前展开的步骤
|
// 当前展开的步骤
|
||||||
const [activeKey, setActiveKey] = useState<string[]>([]);
|
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 = [
|
const baseSteps = [
|
||||||
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
|
{ id: 1, title: '原始素材', description: '上传原始视频素材', childId: 1 },
|
||||||
@@ -335,12 +373,42 @@ function InitialInfo() {
|
|||||||
}).catch((error: any) => {
|
}).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) => {
|
const createone = (stepId: number) => {
|
||||||
|
|
||||||
removeone(taskDetail.id, stepId.toString()).then((res: any) => {
|
removeone(taskDetail.id, stepId.toString()).then((res: any) => {
|
||||||
// 重新获取任务详情以更新数据
|
// 重新获取任务详情以更新数据
|
||||||
|
message.info('正在生成图片提示词,请稍候...');
|
||||||
refreshTaskDetail();
|
refreshTaskDetail();
|
||||||
}).catch((error: any) => {
|
}).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) => {
|
removetwo(taskDetail.id, stepId.toString(), params).then((res: any) => {
|
||||||
// 重新获取任务详情以更新数据
|
// 重新获取任务详情以更新数据
|
||||||
|
message.info('正在生成图片,请稍候...');
|
||||||
refreshTaskDetail();
|
refreshTaskDetail();
|
||||||
}).catch((error: any) => {
|
}).catch((error: any) => {
|
||||||
|
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||||
|
message.error(errorMsg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const newcreateimage = () => {
|
const newcreateimage = () => {
|
||||||
@@ -367,9 +438,13 @@ function InitialInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removetwo(taskDetail.id, steps[1].id.toString(), params).then((res: any) => {
|
removetwo(taskDetail.id, steps[1].id.toString(), params).then((res: any) => {
|
||||||
|
message.info('正在生成图片,请稍候...');
|
||||||
|
|
||||||
refreshTaskDetail();
|
refreshTaskDetail();
|
||||||
|
|
||||||
}).catch((error: any) => {
|
}).catch((error: any) => {
|
||||||
|
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||||
|
message.error(errorMsg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,8 +464,11 @@ function InitialInfo() {
|
|||||||
// console.log('引擎 ID:', engineId);
|
// console.log('引擎 ID:', engineId);
|
||||||
removethree(taskDetail.id, stepId.toString(), videoParams).then((res: any) => {
|
removethree(taskDetail.id, stepId.toString(), videoParams).then((res: any) => {
|
||||||
// 重新获取任务详情以更新数据
|
// 重新获取任务详情以更新数据
|
||||||
|
message.info('正在生成视频提示词,请稍候...');
|
||||||
refreshTaskDetail();
|
refreshTaskDetail();
|
||||||
}).catch((error: any) => {
|
}).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) => {
|
removefour(taskDetail.id, stepId.toString(), params).then((res: any) => {
|
||||||
// 重新获取任务详情以更新数据
|
// 重新获取任务详情以更新数据
|
||||||
|
message.info('正在生成视频,请稍候...');
|
||||||
refreshTaskDetail();
|
refreshTaskDetail();
|
||||||
}).catch((error: any) => {
|
}).catch((error: any) => {
|
||||||
|
const errorMsg = error?.message?.split(': ')?.[1] || error?.message || '生成失败';
|
||||||
|
message.error(errorMsg);
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const agincreatevideo = () => {
|
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 style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||||
视频
|
视频
|
||||||
</span>
|
</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 ? (
|
{taskDetail?.material?.materialVideoUrl ? (
|
||||||
<video
|
<video
|
||||||
controls
|
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.material.materialVideoUrl}`}
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.material.materialVideoUrl}`}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
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 style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||||
产品图片
|
产品图片
|
||||||
</span>
|
</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 ? (
|
{taskDetail?.material?.materialImageUrl ? (
|
||||||
<img src={taskDetail.material.materialImageUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
<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 style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||||
生成图片
|
生成图片
|
||||||
</span>
|
</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
|
<img
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalImageUrl}`}
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalImageUrl}`}
|
||||||
alt=""
|
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 style={{ width: 3, height: 14, background: 'linear-gradient(180deg, #6366f1, #8b5cf6)', borderRadius: 2, display: 'inline-block' }} />
|
||||||
生成视频
|
生成视频
|
||||||
</span>
|
</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
|
<video
|
||||||
controls
|
|
||||||
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
|
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${taskDetail.finalVideoUrl}`}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
@@ -675,7 +757,7 @@ function InitialInfo() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -700,7 +782,7 @@ function InitialInfo() {
|
|||||||
>
|
>
|
||||||
修改提示词
|
修改提示词
|
||||||
</Button>
|
</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>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -1102,7 +1184,7 @@ function InitialInfo() {
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
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)' }}
|
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'}
|
disabled={step.status !== 'completed'}
|
||||||
>
|
>
|
||||||
下一步:生成视频提示词
|
下一步:生成视频提示词
|
||||||
@@ -1127,7 +1209,7 @@ function InitialInfo() {
|
|||||||
>
|
>
|
||||||
查看/修改视频提词
|
查看/修改视频提词
|
||||||
</Button>
|
</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>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -1387,6 +1469,62 @@ function InitialInfo() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user